上个月帮一家客户做集群安全巡检时,我在某个业务命名空间里发现了一个粒度极不合理的 ClusterRoleBinding:一个仅用于读取 Pod 日志的服务账号,居然被绑定了system:node这个高危角色,而绑定它的运维同事只是为了让监控面板能拉到节点指标。这种“图省事”的授权方式在真实环境里远比想象中常见,而nodes/proxy权限的滥用,正是这类过度授权链条里最容易滑向 RCE 的突破口。
nodes/proxy是 Kubernetes 内置 RBAC 体系中一个极其特殊的子资源权限。拿到了它,就等于拿到了一把绕过 API Server 鉴权语义直通 kubelet 的钥匙,可以通过kubectl proxy或直接请求 API Server 的特定路径,访问节点上 kubelet 的 HTTPS 端口,进而调用 exec、attach、logs 这类高危接口。换句话说,只要攻击者有机会执行kubectl命令或者拿到一个有该权限的 ServiceAccount Token,就能在集群内的任意节点容器里执行命令,后果直接对标远程代码执行。
这篇文章我会从权限本质、攻击链路、落地防护、复现过程到踩坑记录,把nodes/proxy滥用这件事彻底讲透。适合正在做集群安全加固的运维同学、平台团队的安全负责人,也适合对 K8s 攻击面感兴趣的研究者,按文章里的步骤在自己的测试环境里完整验证一遍,比听我说一百遍都管用。
1. 先说结论:nodes/proxy 权限为什么值得重新审视
很多团队的 K8s 权限治理,核心目标就是把cluster-admin这类角色锁起来,认为只要没人拿到集群管理员权限,RBAC 就算安全了。但nodes/proxy的危险之处在于,它不属于集群管理员角色的范畴,却拥有接近集群管理员的上限破坏力。
从权限模型的视角来看,Kubernetes 的 RBAC 针对的是 API Server 暴露的各种资源和动词,而nodes/proxy这个权限的逻辑是:允许调用者对节点资源下的proxy子资源发起代理请求。API Server 收到这类请求后,会把 HTTP 请求转发到目标节点的 kubelet 端口上,相当于在 API 网关上开了一条去往节点管理端口的代理通道。
这条通道的可怕之处在于,它让“管控节点”这件事脱离了节点级别的网络访问控制。正常情况下,如果某个角色需要管理节点上的 kubelet,网络层面应该允许它直接访问节点的 10250 端口,这种暴露很容易被防火墙或者集群网络策略拦截。但通过nodes/proxy,攻击者只需要跟 API Server 通信,不需要跟节点通信。API Server 是每个走集群服务的人必然要访问的组件,这就让“网络可达性”这个天然屏障彻底失效了。
另外值得注意的一点是,nodes/proxy不是普通的 RBAC 权限,它出现在几个系统内置的 ClusterRole 里。system:node角色的规则里就包含nodes/proxy的 get 权限,而system:nodes这个角色则授权了所有节点相关操作。如果运维人员为了图省事把某个服务账号绑定了这几个角色,那权限的风控基本就形同虚设了。
我在巡检中发现,凡是绑定了system:node或包含nodes/proxy的自定义 ClusterRole 的服务账号,几乎都是因为某个监控组件、某个排障流程或者某次临时操作而创建的。这类账号长期存留在集群里,Token 可能散落在配置仓库、CI 脚本、甚至日志平台里,攻击路径远远比想象的宽泛。所以这篇文章反复强调的核心观点是:不要为了临时便利授予长期的高危权限,nodes/proxy尤其要当成炸弹来处理。
2. 把风险链路拆开看:nodes/proxy 如何一步步变成 RCE
2.1 nodes/proxy 权限的本质:一条通往 kubelet 的代理通道
要理解这个风险,必须先理解 Kubernetes 的 node 资源和 proxy 子资源到底做了什么。nodes是 Kubernetes 的核心资源对象,代表集群里的物理节点或虚拟机节点。正常情况下,管理节点信息需要get、list、watch这类资源权限,而nodes/proxy则是一个更特殊的操作入口。
Kubernetes 的 API 聚合层提供了一个代理机制,资源路径为api/v1/nodes/{nodeName}/proxy/{path},API Server 会把{path}部分原封不动地代理给节点的 kubelet 端口。举例来说,如果某个用户有nodes/proxy权限,请求https://api.example.com/api/v1/nodes/node-1/proxy/metrics,实际上等于请求了https://node-1:10250/metrics,而 kubelet 会认为这个请求来自 API Server 的代理,从而放行对端口的访问。
从设计意图上讲,这个机制本身是为了解决节点级 API 访问不便的问题。K8s 官方在调试节点、采集 kubelet 指标、查看节点状态时都推荐使用这个方式,比如kubectl get --raw访问节点指标就是通过这个代理通道实现的。但设计意图归设计意图,一旦这个权限被授予的对象不是高度可信的集群管理服务,而是普通业务服务或者给开发人员的账号,风险立刻就凸显出来了。
关键点在于:API Server 在做nodes/proxy转发时,不会对代理目标端口做任何限制。它不会检查你访问的是 kubelet 的/metrics还是/exec,不会判断你是在读数据还是在执行命令。只要你持有这个权限,就可以通过代理通道发起任意请求到 kubelet 的全部端口。
这个特性让我经常用一个并不完全恰当但足够直白的类比:nodes/proxy就像消防通道的钥匙,它本来就是拿来做紧急维修用的,但如果你把这个钥匙扔进公共抽屉,那任何人都可以打开消防通道进到机房的核心区域,而且监控摄像头很可能还拍不到,因为所有人走的都是正常门禁路线。
2.2 kubelet 的脆弱端口与可利用面
既然nodes/proxy的终点是 kubelet,那 kubelet 本身到底留了哪些可利用的接口,就得掰开来说清楚。kubelet 作为一个常驻在节点上的管理进程,对外暴露了多个 HTTP 端口,不同类型端口的安全级别差异非常大。
首先是 10250 端口,这是 kubelet 的主端口,负责接收来自 API Server 的指令,包括创建容器、执行命令、读取日志等功能。这个端口如果开启了匿名访问或者仅依赖节点上的本地认证文件,配合nodes/proxy代理通道,攻击者可以直接调用/run/{namespace}/{pod}/{container}接口,在容器内执行任意命令,这是一条最直接的 RCE 路径。
其次是 10255 端口,这是 kubelet 的只读端口,通常绑定 0.0.0.0,提供/pods、/metrics等只读信息接口。虽然它不像 10250 那样能执行命令,但通过这个端口可以拿到节点上所有 Pod 的详细清单、镜像信息、容器状态,对攻击者而言这是绝佳的信息收集入口,能快速定位高价值目标和内部网络结构。
再次是 10248 和 10249 端口,分别是健康检查和本地流量统计端口,CRI 接口的实际大端口(通常 10010)也可能暴露。通过这些端口能进一步探测容器运行时信息和网络流量情况。
在真实攻击链路中,Kubernetes 默认是启用了通过 Webhook 模式把nodes/proxy请求转发给 kubelet 进行授权认证的,这意味着如果没有正确配置 kubelet 的--authorization-mode或--authentication-token-webhook=true,那么所有经过nodes/proxy到达 10250 的请求,都会被 kubelet 当作可信请求来处理,直接免认证执行。默认开启的--anonymous-auth参数更是雪上加霜。
2.3 从权限绑定到实际控制:完整的攻击路径
假设攻击者已经拿到了一个绑定了nodes/proxy权限的服务账号 Token,完整攻击路径一般会走这样几步。
第一步,攻击者通过 Token 访问 API Server,调用GET /api/v1/nodes列出集群中所有节点名称和内部 IP。这里其实只需要有nodes资源的 list 权限,或者通过其他低权限接口间接收集,比如有些环境里nodes列表对认证用户默认可见。
第二步,攻击者构造带 target 的代理请求。Kubernetes 支持通过nodes/{nodeName}/proxy直接指定节点名,也支持通过 URL 参数方式指定 IP。实际攻击中更常见的手段是利用kubectl get --raw或直接 curl 请求 API Server 的代理路径。
第三步,攻击者命中 kubelet 的主端口。请求https://api.internal.cluster.local/api/v1/nodes/{nodeName}/proxy/run/{namespace}/{pod}/{container},这个接口如果 kubelet 的鉴权模块没有对代理请求做二次校验,就会直接在目标容器的命令行环境里创建执行进程。
第四步,攻击者在目标容器里执行命令。这一步的变种很多:有些攻击者执行/bin/sh反弹 Shell,有些直接把宿主机的敏感文件读取出来,还有些会利用容器挂载的云厂商凭据进一步横向移动。如果容器是以特权模式运行,甚至可以直接逃逸到宿主机。
这个链路里最关键的一环,还是从“有权限”到“请求被 kubelet 接受”这一步。正因为nodes/proxy把 API Server 变成了一座“鉴权代理桥”,攻击者不需要接触节点网络,也绕过了大部分网络策略的检测逻辑。所以我可以明确地说:在 K8s 集群里,nodes/proxy权限的无约束授权,和把 SSH 私钥发给全员,本质上没有任何区别。
3. 落地防护:从权限治理到运行时检测
3.1 先从权限治理下手:把不该有的授权揪出来
防护nodes/proxy滥用,第一步永远是权限治理。但这里说的治理不是笼统的“最小权限原则”,而是要有具体可执行的动作和命令。
第一步,梳理集群里所有涉及nodes/proxy或者system:node、system:nodes相关角色的绑定关系。用kubectl get clusterrolebinding -o jsonpath遍历所有 ClusterRoleBinding,把引用到高危 ClusterRole 的 Subject 全部列出来。同时也要检查 RoleBinding,因为授权可能发生在命名空间维度。
第二步,针对每个命中对象,判断它是否真的需要这个权限。常见的误用场景包括:监控组件用system:node拉取指标、运维脚本用system:node管理节点、给开发人员授予nodes/proxy用于调试 Pod。开发调试需求可以用kubectl port-forward或者临时创建带特权的 Pod 解决,完全不需要把节点代理权限交给普通账号。
第三步,清理的过程中,不要直接删除绑定,优先采用替换方案。先创建自定义 ClusterRole,只授予必需的资源和动作,再新建绑定,确认业务无影响后删除旧绑定。我习惯用rbac-lookup这个开源工具辅助排查,它的输出格式比 kubectl 原生命令直观得多,能快速看到某个角色被哪些用户、组、服务账号占用。
除了静态排查,还要把权限治理纳入到上线审批流程里。建议在 CI/CD 的部署管道中加入一个策略检查步骤,凡是新创建的 ClusterRoleBinding 或 RoleBinding 如果引用了system:node、system:nodes、含nodes/proxy规则的角色,立即阻断并告警。这样能从源头拦截,而不只是事后清理。
3.2 技术层面收紧 kubelet 的暴露面
权限治理解决的是“谁能用”的问题,而 kubelet 自身的加固解决的是“用上了能不能生效”的问题。两个层面互相配合,才能把nodes/proxy的杀伤力降下来。
kubelet 的关键启动参数需要逐一确认:
--authentication-token-webhook=true:这个参数定义了 kubelet 在收到请求时,会把请求的 Bearer Token 发送给 API Server 做 Webhook 验签。没有开启时,kubelet 面对匿名请求直接信以为真。开启后,即使请求到达 kubelet,也必须通过 API Server 的认证校验。--authorization-mode=Webhook:仅开启认证还不够,kubelet 还需要用 Webhook 模式做鉴权,也就是把请求的属性和用户信息发给 API Server 的SubjectAccessReview接口做授权判断。如果不开启这个模式,kubelet 默认的 AlwaysAllow 或 Node 模式很容易放大nodes/proxy的效果。--anonymous-auth=false:默认情况下 kubelet 允许匿名访问 10250 端口的某些只读接口。关闭匿名访问是必须的,否则攻击者甚至不需要 Token 就能探测节点状态。--read-only-port=0:直接禁用 10255 只读端口。这个端口除了信息泄露,没有任何实际业务上的必要性。--protect-kernel-defaults=true:虽然不是直接跟 RCE 相关,但开启后可以防止 kubelet 随意修改节点内核参数,降低容器逃逸之后的破坏面。
网络层面需要配合 NetworkPolicy 或者云厂商的安全组,限制对节点 10250 端口的访问来源。常规的集群架构里,API Server 到 kubelet 的访问是必需的,但除此之外的业务 Pod 不应该能直接访问节点主端口的网络地址。有些团队觉得反正都有 TLS 和 RBAC 保护,网络开放无所谓,但我在实际排查中见过多次因为某个节点组件配置异常,把 10250 暴露到了公网的情况,一旦网络层没有隔离,后续的纵深防御会变得非常被动。
3.3 审计与异常检测:提前发现而不是事后补救
防护体系里最容易被忽略的就是可观测性。很多集群出了事故,排查的时候才发现审计日志没开,或者开了但根本没监控关键事件。
Kubernetes API Server 的审计日志是发现nodes/proxy滥用的第一手信息。配置审计策略时,至少要覆盖nodes/proxy这个子资源的请求记录。审计策略可以按集群规模分档:生产环境建议 Level=RequestResponse,把请求体和响应体都记录下来,方便攻击回溯;但全量审计会带来不小的存储压力,实际上可以只对nodes/proxy相关请求开启 RequestResponse 级别,其他请求保持 Metadata 级别,这样兼顾了性能和追溯能力。
在检测层面要重点关注请求的特征。正常的nodes/proxy使用场景比较集中,主要是监控系统定时拉取 kubelet metrics,特征是请求路径集中在/metrics,频率相对固定。异常请求的特征则非常明显:请求路径中出现run、exec、attach等动词的,无论什么来源,都需要立刻告警。另外还要关注频繁切换目标节点、短时间内大量请求不同节点 kubelet 端口的模式,这类行为大概率是横向探测。
为了落实这些检测规则,可以参考这些开源方案:Falco 的 Kubernetes 审计插件可以实时解析审计日志并触发告警规则;KubeEye 内置了不少 RBAC 检查规则;Cloud Custodian 也能做周期性的权限扫描。没有预算引入复杂平台的小团队,先在日志系统里配置几个关键字告警也比什么都不做强得多,审计日志里的nodes/node*/proxy/run或者proxy/exec路径配一个正则告警,成本很低但效果立竿见影。
4. 复现与排查:亲手验证风险比听我说更重要
4.1 用 kind 搭建一个最小化验证环境
纸面讲再多,不如实操跑一遍。我强烈建议你现在就在本地用 kind 搭建一个单节点测试集群,然后按下面步骤复现整条攻击链路。你踩过的每一个坑,都比看十篇文章印象深刻。
# 创建单节点 kind 集群,kind 默认会安装本地 kubeadmin 配置 kind create cluster --name rce-demo # 确认集群就绪 kubectl cluster-info kubectl get nodeskind 创建出来的集群,kubelet 是运行在宿主机上的 Docker 容器里,端口映射和真实集群有细微差异,但 RBAC 层面的授权逻辑是完全一致的,足够验证nodes/proxy的权限边界。
接着要调整 kubelet 的参数。kind 默认的 kubelet 配置相对安全,为了复现真实问题,我们要模拟一个“没有开启 Webhook 鉴权”的典型场景。
# 查看当前 kind 节点的 kubelet 版本 kubectl get node kind-control-plane -o jsonpath='{.status.nodeInfo.kubeletVersion}' # 通过 docker exec 进入 kind 控制平面容器,查看 kubelet 启动参数 docker exec -it rce-demo-control-plane cat /var/lib/kubelet/config.yamlkind 节点上的 kubelet 配置通常位于/var/lib/kubelet/config.yaml。这里面的authentication和authorization字段就是防守的核心。为了演示风险,你可以把authentication.anonymous.enabled设置为 true,authorization.mode设置为 AlwaysAllow,然后重启 kubelet 容器。
注意:以上修改仅限本地测试集群。生产环境千万不要这样调整 kubelet,你这是在亲手拆掉安全防线。
4.2 创建高危绑定并模拟攻击
现在开始创建风险和攻击验证用的服务账号。
# 创建命名空间和测试服务账号 kubectl create ns attack-sim kubectl create serviceaccount hacker -n attack-sim # 故意绑定 system:node 角色,让服务账号拥有 nodes/proxy 权限 kubectl create clusterrolebinding attack-sim-node \ --clusterrole=system:node \ --serviceaccount=attack-sim:hacker接着获取这个 ServiceAccount 的 Token 并配置 kubectl 上下文。
# 获取 token,新版本 kubectl 可能需要在 secrets 里读取或者 use KUBECONFIG 方式 kubectl -n attack-sim create token hacker --duration=1h # 生成新的 kubeconfig 上下文 kubectl config set-credentials attack-hacker --token=<输出的token> kubectl config set-context attack-ctx --cluster=kind-rce-demo --user=attack-hacker kubectl config use-context attack-ctx当前上下文验证通过后,先用低权限视角看看能做什么。
# 尝试列出 pods,这里如果 system:node 角色包含 list pods 的权限,就能看到 kubectl get pods -A接下来是核心攻击步骤,直接通过 API Server 的nodes/proxy路径访问 kubelet 的 headers 和版本信息。
# 访问 kubelet 的配置接口(注意节点名换成你的 kind 节点名) kubectl get --raw /api/v1/nodes/kind-control-plane/proxy/configz | head -50 # 尝试调用 kubelet 的 run 接口,在 default 命名空间的某 Pod 容器内执行命令 # 先列一下 default 命名空间的 Pod 和容器名 kubectl get pods -n default实际上kubectl get --raw可以直接请求 kubelet 的 exec 或 attach 接口,这些接口在 kubelet 的 HTTP handler 里都有对应路径。Kubelet 的 exec 路径格式通常为/run/{namespace}/{pod}/{container},你可以用 curl 模拟以下请求:
# 用一个轻量的 shell 命令 curl 走 API Server 代理到 kubelet curl -k -X POST \ -H "Authorization: Bearer <token>" \ "https://127.0.0.1:6443/api/v1/nodes/kind-control-plane/proxy/run/default/nginx/nginx?cmd=id&cmd="如果你的 kubelet 配置允许,上面这条命令会直接返回容器内id命令的结果,比如uid=0(root) gid=0(root)。如果返回 403 或者 401,说明 kubelet 的 Webhook 鉴权是生效的,此时nodes/proxy的实际可用性就被压缩了很多。
4.3 加固后再验证:配置差异带来的显著变化
现在把 kubelet 的安全参数恢复成默认并加严,再跑一次同样的请求,你会发现链路被拦得死死的。
具体步骤是在/var/lib/kubelet/config.yaml中确认以下设置:
authentication: anonymous: enabled: false webhook: enabled: true authorization: mode: Webhook重启 kind 控制平面容器里的 kubelet 服务(kind 节点本身是 systemd 管理的,可能需要用docker exec执行systemctl restart kubelet,有些镜像里直接重启容器更快)。
再次发起同样的代理 exec 请求,此时 kubelet 会收到请求,但因为没有匿名访问能力,请求中携带的 Bearer Token 又来自并非集群节点的服务账号,kubelet 会将其发送到 API Server 执行 SubjectAccessReview,结果是没有权限访问 kubelet 资源,返回 403。这个实验能直观地说明:即使nodes/proxy权限落在恶意账号手里,只要 kubelet 自身做了严格鉴权,RCE 也难以执行成功。
通过这个对比实验,你会发现一个重要的防护认知:云原生的安全永远不是单点防御,而是层层设防。nodes/proxy权限治理是第一层,kubelet 加固是第二层,网络隔离是第三层,审计检测是第四层。每一层都有它独立的价值,缺一层就少一分纵深。
5. 常见问题与踩坑记录
5.1 实战中频繁踩到的五个坑
我在做集群安全加固和应急响应时,反复踩过一些坑,这些细节常规文档里很少会写,这里整理出来给你排雷。
第一个坑是想当然地认为nodes/proxy只存在于system:node角色里。实际上很多自定义 ClusterRole 也包含了nodes/proxy规则,而且团队文化越松散的集群,这种自定义角色越泛滥。排查时不能只查内置角色绑定,要全文检索所有 ClusterRole 的 rules。
第二个坑是审计日志只记录了请求但没记录响应体,导致攻击发生后无法判断攻击者到底执行了哪些命令。在审计策略里对nodes/proxy请求开启 RequestResponse 级别非常重要,能帮助应急响应时回答“攻击者做了什么”这个核心问题。
第三个坑是修改 kubelet 参数后没有确认 kubelet 成功加载了新配置。很多发行版的 kubelet 以 systemd 管理,改完 config.yaml 后需要重启服务,有些甚至需要删除缓存的 kubelet.conf 证书才能彻底生效。如果你没有验证kubectl get --raw /api/v1/nodes/节点名/proxy/configz里显示的实际运行参数,很可能你以为加固了,实际上没有。
第四个坑是只知道查 ClusterRoleBinding,忽略了 RoleBinding。RoleBinding 虽然只在单一命名空间内生效,但nodes是集群级资源,命名空间内绑定了nodes/proxy权限一样能访问所有节点。有些运维把所有权限都建模为 RoleBinding 注入到命名空间,排查时如果遗漏了这一层,监控告警永远无法覆盖所有高风险授权。
第五个坑是网络策略只挡了南北向,没挡东西向。我在测试环境模拟攻击时发现,即使 kubelet 配置了 Webhook 鉴权,如果攻击者已经进入集群内部网络,仍然可以绕过 API Server 直接访问节点的 10250 端口。所以网络策略要同时覆盖繁忙的网络平面,最好做到业务命名空间默认禁止访问节点网段。
5.2 一份可以直接抄的防护清单
结合上面的所有内容,这里给出一份我在生产环境落地时使用的检查清单,按顺序执行,基本能帮你把nodes/proxy相关风险收敛到位。
| 检查项 | 操作命令 | 目标状态 |
|---|---|---|
| 高危角色绑定排查 | kubectl get clusterrolebinding -A -o jsonpath='{range .items[?(@.roleRef.name=="system:node")]}{.metadata.name}{" -> "}{.subjects}{"\n"}{end}' | 无业务相关 Subject |
| 自定义角色规则扫描 | kubectl get clusterrole -A -o jsonpath='{range .items[?(@.rules[].resources[*]=="nodes")]}{.metadata.name}{"\n"}{end}' | 仅限可信系统角色 |
| kubelet 匿名访问状态 | kubectl get --raw /api/v1/nodes/{node}/proxy/configz | anonymous 为 false |
| kubelet Webhook 鉴权 | 同上接口检查 authorization 部分 | mode 为 Webhook |
| 审计策略覆盖范围 | 检查 kube-apiserver 启动参数中的审计策略文件 | nodes/proxy请求记录含 Response |
| NetworkPolicy 规则 | kubectl get netpol -A | 禁止业务网段访问节点 10250 |
关于审计保留周期,建议至少保留 180 天,并定期把日志归档到对象存储中。有一次我在处理一个长达半年的渗透测试攻击痕迹时,就是因为日志保留期过短导致关键攻击路径无法完整重建,这个教训非常深刻。
另外补充一点,针对nodes/proxy误授权的监控告警配置,不要只局限在审计日志的路径关键字上。要同时对 API Server 的 metrics 中添加apiserver_request_total的维度分析,观察resource=nodes、subresource=proxy的请求量变化趋势,这是从全局视角发现异常访问的有效补充手段。
6. 再说说我在实战中体会最深的事
整套方案落地过一遍之后,我个人的体会是:nodes/proxy权限只是 Kubernetes 权限体系里一个极其典型的例子,但它暴露出来的问题其实是集群管理过程中普遍存在的授权惰性。很多人在配置权限时,脑子里想的永远是“现在能不能跑通”,而不是“未来这个权限如果被恶意使用会怎样”。
我之前在一家企业的集群里处理过一个实际案例,某团队为了实现一个自研的节点维护工具,拷贝了网上的一段 RBAC 配置,直接给服务账号绑定了system:node。后来这个服务账号的 Token 意外泄露到了公开代码仓库里,虽然及时清理了,但每当想起那段时间集群曾经暴露在那么大的风险之下,后背还是会发凉。好在那次没有发生实际攻击,但如果真的被利用,攻击者可以通过nodes/proxy在任意节点上执行命令,整个集群的性质就变成了一座不设防的矿池。
所以如果你现在正在规划或执行集群安全加固,我建议把nodes/proxy的风险排查列为第一优先级的动作,先跑一遍高危角色绑定扫描,确认没有遗漏,再逐步收紧 kubelet 的姿态。安全不是一锤子买卖,权限治理需要持续进行。每隔一个季度做一次 RBAC 审计,确认新增的角色绑定都经过了审批,才能让你的集群始终处于可控的状态。希望这篇文章能帮你少走一些弯路,把该堵的口子及时堵上。