☰
Flask SSTI漏洞原理解析与实战排查:从注入检测到安全修复
2026/10/19 2:59:39 网站建设 项目流程

前两天给团队做安全意识内训,我又把 Flask 的 SSTI 漏洞翻出来完整讲了一遍。之所以反复讲它,是因为在各类容器化漏洞靶场里,SSTI(Server-Side Template Injection,服务端模板注入)几乎每隔几个版本就会出现一次,而在真实业务代码里,它也是最容易被误认为“只是花括号注入”的漏洞类型之一。

很多人一开始把 SSTI 当成模板引擎自身的 bug,甚至觉得只在 Python 里才有。实际上,模板注入是个跨语言的通用问题:只要服务端把用户输入拼接进了模板源码,就有可能出现。Flask 默认使用 Jinja2,因此“Flask + SSTI”成了安全圈最常见的组合,也被各个开源漏洞靶场列为基础必修课。

这篇文章会从模板引擎的工作原理讲起,带你从零搭一个最小可复现的本地实验环境,然后完整走一遍从“页面渲染异常”到“确认模板注入点”的排查链路,最后给出我在实际项目里验证过的修复方案。无论你是刚入门的安全测试人员、写 Flask 的业务开发者,还是负责安全评审的同事,都能在十几分钟内看懂核心逻辑。

1. 模板引擎的“本职”与“越权”:SSTI是怎么发生的

1.1 正常模板渲染的流程

先把正常情况说透。模板引擎的核心职责是:把一份固定结构的“模板”和可变“数据”组合成最终文本。

以 Jinja2 为例,一份模板可能是:

<h1>欢迎 {{ username }} 回来</h1>

服务端拿到username = "张三",引擎输出<h1>欢迎 张三 回来</h1>。整个过程里,引擎只做变量替换,模板本身是开发者写死在源码里的,用户能控制的只是username这个“值”。

普通的使用方式是通过render_template或render_template_string显式传参:

return render_template_string( "<h1>欢迎 {{ username }} 回来</h1>", username=request.args.get("name") )

这时候用户即使在 URL 里输入{{ 7*7 }},引擎也只是把这一串当作文本赋值给username,最后输出时再经过转义显示为字符串,不会执行任何计算。这叫做“数据与模板分离”。

1.2 漏洞代码只是把“数据”换了个位置

问题出在开发者图省事时。常见写法是把用户输入直接拼进模板字符串:

template = "<h1>欢迎 {{ username }} 回来</h1>" + request.args.get("name") return render_template_string(template)

这里用户输入的{{ 7*7 }}已经变成了模板源码的一部分。引擎编译模板时自然会把它当作表达式执行,页面就会显示49。

把这个过程抽象一下就是一句话:开发者把模板源码和不可信数据混在了一起。模板引擎本来只该编译开发者自己写的模板,现在却把用户输入也当作模板源码的一部分编译了,这就是注入的本质。

我在实际代码里见过的三种高频变体:

  • 用字符串替换来做邮件模板,替换后再整体渲染。
  • 动态生成列名、排序字段,把用户输入 replace 进模板片段。
  • 接口直接接收模板内容,做成“用户自定义页面”。

这三个场景的修复思路完全一致:输入应当作为数据传入模板,而不是拼进模板源码。

1.3 为什么新手特别容易踩进这个坑

一个很重要的原因是:很多初学者先从“页面能显示用户输入”这个需求出发,先写出了字符串拼接,再套上模板引擎,却没有意识到两者是不同层面的事。需求层面用户只是要“显示一段文字”,实现层面却把这段文字当作了“可执行代码”。

另一个原因是模板引擎的错误提示不直观。Jinja2 在渲染出错时会把 Python 异常抛出来,尤其在 Flask 的 debug 模式里,常见的报错如jinja2.exceptions.UndefinedError或TemplateSyntaxError,新手容易只盯着“语法错误”去修,却忽略了报错里模板内容来源的问题。

这里分享一个小技巧:排查模板报错时,先别急着改语法,先把“模板内容里哪些部分是用户可控的”列出来。十有八九,问题根源在拼接逻辑,而不在模板写法本身。

2. 本地实验环境搭建:复现一个最简Flask SSTI场景

直接看代码能理解原理,但亲手复现一遍差异,印象会深得多。我推荐用本地容器化的漏洞靶场来做实验,优势很明显:环境干净、可以反复销毁重建、不会污染宿主机。

2.1 准备演示用的Flask应用

新建一个实验目录,比如flask-ssti-lab,把三个文件放进去。

应用主体:

# app.py from flask import Flask, request, render_template_string app = Flask(__name__) @app.route("/") def index(): user_input = request.args.get("q", "") template = """ <html> <body> <p>你输入的内容:</p> <div>{input}</div> </body> </html> """.replace("{input}", user_input) return render_template_string(template) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080, debug=True)

这段代码故意用了最经典的错误写法:先把用户输入replace进模板串,再整体交给render_template_string。不依赖 Docker 也可以直接在本地运行。

容器定义:

# Dockerfile FROM python:3.11-alpine WORKDIR /app RUN pip install flask COPY app.py . EXPOSE 8080 CMD ["python", "app.py"]

编排配置:

# docker-compose.yml services: flask-ssti: build: . ports: - "8080:8080"

2.2 启动环境并验证基础功能

在实验目录执行:

docker compose up -d

然后请求测试:

curl "http://localhost:8080/?q=hello"

页面会正常回显hello。如果本地没有 Docker,也可以直接python app.py,再访问 8080 端口,效果完全一致。

2.3 实验环境常见的小坑

这里记录几个我实际踩过的问题:

  1. 端口冲突:如果本机 8080 被占用,把 docker-compose.yml 里的端口映射改成8081:8080即可。
  2. Docker 权限问题:Linux 上如果遇到权限报错,检查当前用户是否在 docker 用户组,或者使用sudo执行命令。
  3. debug 模式的影响:我故意保留了debug=True,它在真实项目里绝不能开,但在本实验里非常有用。debug 模式会返回详细的报错页面,能帮助你观察模板解析失败时的输出,更快定位问题。不过要记住:生产环境开启 debug 本身就是高危行为,会泄露源码路径和配置信息。

提示:这个实验环境只适合在本地隔离环境运行,不要部署到公网可达的位置,更不要直接把这段代码带进任何生产项目。

3. 漏洞确认完整链路:从页面异样到注入点判定

现在环境已经就绪,接下来演示我对一个陌生 Flask 应用做模板注入排查时的完整流程。

3.1 找到所有用户可控的渲染点

第一步不是试 payload,而是先梳理参数。看应用里有哪些用户输入会直接影响页面输出:URL 参数、表单字段、Cookie、甚至某些自定义请求头。

在本实验里,q参数的内容会回显到页面主体,这是最明显的候选注入点。

为什么要先做这一步?因为实际项目中,注入点不只在 URL 上。我见过把User-Agent拼进模板做访问统计的例子,也见过把X-Forwarded-For写进日志模板的例子。如果只盯着 URL 参数测,很容易漏报。

3.2 用算术表达式做初筛

候选点确定后,我用最简单的算术探测来确认模板表达式是否会被执行。访问:

http://localhost:8080/?q={{ 7*7 }}

页面输出49,而不是原文{{ 7*7 }},说明 Jinja2 确实编译并执行了这个表达式。这就是整个确认链路里最坚实的证据。

有些教程会建议先试{{ 7+7 }},但我更推荐7*7。因为在不同的模板语法里,加号的语义容易和其他字符混淆,而7*7的乘法和计算结果几乎不会产生歧义。另外,{{ 7*'7' }}也是一个非常有效的辅助探针,它会输出连续 7 次字符7,这能进一步确认字符串操作被真实执行。

3.3 通过表达式差异区分引擎类型

确认表达式可执行后,下一步判断服务端用了哪类引擎。我常用的方法是用字符串乘法行为:

http://localhost:8080/?q={{ 'abc' * 3 }}

输出abcabcabc,说明字符串乘法语义是“重复”,这符合 Python(Jinja2)行为。同样的表达式在 PHP 的 Twig 里会有不同表现,在 JavaScript 系模板里甚至可能直接报错。通过这种差异信号,可以快速锁定引擎家族。

为了更直观,我整理了一份常见行为对照:

探针示例Jinja2 (Python)Twig (PHP)多数JS模板
{{ 7*7 }}4949通常报错或无输出
{{ 'abc'*3 }}abcabcabc语法错误或不支持通常报错
{{ 'a' }}aa视引擎而定

这张表不是绝对标准,但能帮你在黑盒探测时快速建立判断方向。实际结合 Flask 的响应特征(如Set-Cookie、Server头、页面报错样式)综合判断,基本可以锁定 Jinja2。

3.4 确认对象是否可访问

最后一步,确认模板表达式里能否访问底层对象。这一步的意义是判断漏洞危害等级,而不是为了构造攻击链。

访问:

http://localhost:8080/?q={{ ''.__class__ }}

在 Jinja2 里,空字符串的类型对象会以<class 'str'>的形式回显。看到这个结果,说明模板上下文对 Python 对象属性的访问没有做限制,漏洞的影响面不是“算个算术”,而是进入了“可以内省对象”的高危区间。

做完这一步,我会在测试记录里写三件事:注入点位置、引擎类型、对象访问能力。有了这三项,后续的风险定级和修复沟通就有了依据。

注意:以上验证只在自建本地环境里进行。对任何不属于你、未经授权的系统做类似测试,都是不合规的。

4. Jinja2上下文里的攻击面:为什么危害不只是“弹计算器”

SSTI 新手经常低估它的危害,觉得“我又不能弹出系统命令,怕什么”。但模板对象内省机制一旦开放,影响面会迅速扩大。下面把机制讲清楚。

4.1 Jinja2的对象属性访问模型

Jinja2 沿用了 Python 的属性访问语法,而且访问能力不只是“取到值”。在模板表达式里,{{ obj.attr }}会被解析为多种尝试:先试真实属性,再试字典键,接着会尝试调用__getattr__这类内部方法。所以模板表达式里可以引用到不少 Python 内建对象和方法。

举例来说,{{ '__class__' }}本身只是字符串,但{{ ''.__class__ }}会返回str类型。从类型出发,再通过__mro__这类属性拿到继承链,理论上就能在对象图里不断游走。这些内建属性本来是给开发者做内省和调试用的,设计上没有考虑“模板不可信输入”的威胁模型。一旦用户输入直接成为模板源码,等于把内省接口暴露给了调用者。

4.2 Flask向模板注入的全局对象

Flask 默认会在模板上下文里注入一批全局变量,比如:request、config、url_for、get_flashed_messages、g等。其中request对攻击面影响最大,因为它代表当前请求的完整上下文,包含请求头、参数、Cookie、上传文件信息等。

这就是为什么同一个 SSTI 漏洞,在 Flask 里的可观察面比在纯 Jinja2 里要大:因为模板里不仅能访问 Python 对象,还能直接访问当前请求对象,进而把“页面注入”升级为“框架内部信息泄露”。

要理解这个概念,我用一个类比:本来只允许你在仓库门口取指定的包裹,但你发现仓库大门没锁,而且墙上就挂着一串写着“各房间钥匙”的钥匙盘。模板对象访问就是这串钥匙盘,它让输入从“页面功能”层面跨到了“框架内部”层面。

4.3 风险定级和收敛思路

基于对象访问能力,我通常会把 SSTI 风险粗略分为三档:

探测结果风险描述处置建议
表达式可执行但对象不可访问低危,影响范围集中在页面内容修复拼接问题,补充自动化检测
可访问模板全局变量(如 config)中高危,可能泄露配置信息立即修复,核查日志中是否有历史 payload
可访问 Python 对象并深入内省高危,影响面可能延伸到框架上下层立即修复,评估服务所在网络环境的隔离状况

注意:这里的分档只针对“探测能力”,不代表实际攻击难度。但哪怕只是中高危,也应该进入紧急修复队列,不要等“确认能 getshell 再处理”。

4.4 一说安全就必须强调的边界

讨论到这一步,有必要把边界说清楚。模板注入的实战利用链有很多现成资料,但我不建议任何人拿公网系统练手,更不提倡在非授权目标上复现。这类实验请严格限制在本地容器靶场里进行。本地容器的好处之一就是“随便折腾,折腾坏了重建即可”。

5. 修复与长期防御:真正的解法在拼接逻辑之外

修复 SSTI 的核心不是“过滤花括号”,而是“消除用户输入进入模板源码的路径”。这一点理解透了,很多讨论都会变得清晰。

5.1 正确修法:数据走参数,模板走源码

回到漏洞代码,最直接的修复是这样:

return render_template_string( "<p>你输入的内容:{{ content }}</p>", content=user_input )

用户输入变成模板渲染时的普通参数,由引擎负责转义和输出。哪怕用户输入{{ 7*7 }},页面也只会把它当作字符串显示,不会执行。

如果只是个别场景需要动态模板,建议封装成统一函数,内部确保模板字符串固定、数据从参数传入,业务侧不再允许直接拼模板。

5.2 防住“过滤”方案的常见绕法

强调一下不要靠黑名单。早期很多修复方案长这样:

template = template.replace("{{", "").replace("}}", "")

这种方案几乎立刻会被绕过。Jinja2 的模板控制结构不止{{ }},还有{% %}、{# #}。而且模板内的表达式可以通过拼接字符串、编码、过滤器组合等方式重新构造关键字。一次过滤不可能穷尽所有表达方式。

正确的思路是白名单化输入结构,而不是黑名单化危险字符。也就是说:能接受纯文本的地方,就按纯文本处理;必须支持模板语法的场景,就用隔离沙箱外加严格审核。

5.3 开发与评审阶段的几道硬关卡

我把实践中有效的防御习惯列一下。

  1. 评审盯住 render_template_string:凡是模板内容由字符串拼接生成的,直接判定为问题代码。
  2. 统一封装渲染入口:项目里只留一个经过安全 review 的模板渲染函数,业务代码通过它传参,不直接调原始渲染函数。
  3. 保留自动转义:Jinja2 默认对{{ }}里的输出做 HTML 转义,不要因为“我要输出富文本”就全局关闭转义。确实需要富文本时,用白名单化的富文本组件处理。
  4. 自动化探针:CI 里加一个冒烟测试,对每个接收用户输入的接口请求{{ 7*7 }}和{{ 'abc'*3 }},一旦响应体里出现执行结果,构建直接失败。
  5. 跟进框架版本:Jinja2 和 Flask 要定期升级。虽然版本升级解决不了“错误使用方式”,但能降低“错误使用之后影响面多大”的天花板。

5.4 关于“用户自定义模板”的复杂场景

有一种业务需求会让上面所有简单方案失效:产品希望用户能够自定义模板内容,比如做电子邮件的个性化排版。这时用户输入“合法地”就应当成为模板源码,就不能简单地通过传参解决。这种场景我建议按这个思路处理:

  • 模板语法使用受限子集,不开放全部 Jinja2 能力。
  • 模板内容保存在受管控的服务端,用户只能编辑自己的模板,且可回溯。
  • 渲染在独立、受限的环境中执行,并设资源上限,防止模板陷入死循环。
  • 模板内容上线走审核流程,至少人工抽查。

这条路比较重,普通产品一般用不到。但一旦产品需要,就不能指望靠一次过滤解决。

我在实际排查里最深的一个体会是:SSTI 类漏洞看着吓人,但成因非常集中,九成以上都是“用户输入进了模板源码”这一个点。只要代码评审盯住这一条,配合本地容器靶场的定期演练,团队里很容易养成条件反射。最后再分享一个小技巧:排查时先把应用的所有用户输入点列成一张表,再逐个验证哪些点能进入模板渲染,效率远比在黑盒里漫无目的地试 payload 高得多。这套方法我用了很多年,无论是带新人还是自己应急,都觉得很稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询