首页 / 资讯中心 / 文章详情

Flask SSTI漏洞原理解析与实战排查:从注入检测到安全修复

Flask SSTI漏洞原理解析与实战排查:从注入检测到安全修复 ★ FEATURED ARTICLE
前两天给团队做安全意识内训我又把 Flask 的 SSTI 漏洞翻出来完整讲了一遍。之所以反复讲它是因为在各类容器化漏洞靶场里SSTIServer-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, usernamerequest.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(host0.0.0.0, port8080, debugTrue)这段代码故意用了最经典的错误写法先把用户输入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:80802.2 启动环境并验证基础功能在实验目录执行docker compose up -d然后请求测试curl http://localhost:8080/?qhello页面会正常回显hello。如果本地没有 Docker也可以直接python app.py再访问 8080 端口效果完全一致。2.3 实验环境常见的小坑这里记录几个我实际踩过的问题端口冲突如果本机 8080 被占用把 docker-compose.yml 里的端口映射改成8081:8080即可。Docker 权限问题Linux 上如果遇到权限报错检查当前用户是否在 docker 用户组或者使用sudo执行命令。debug 模式的影响我故意保留了debugTrue它在真实项目里绝不能开但在本实验里非常有用。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 确实编译并执行了这个表达式。这就是整个确认链路里最坚实的证据。有些教程会建议先试{{ 77 }}但我更推荐7*7。因为在不同的模板语法里加号的语义容易和其他字符混淆而7*7的乘法和计算结果几乎不会产生歧义。另外{{ 7*7 }}也是一个非常有效的辅助探针它会输出连续 7 次字符7这能进一步确认字符串操作被真实执行。3.3 通过表达式差异区分引擎类型确认表达式可执行后下一步判断服务端用了哪类引擎。我常用的方法是用字符串乘法行为http://localhost:8080/?q{{ abc * 3 }}输出abcabcabc说明字符串乘法语义是“重复”这符合 PythonJinja2行为。同样的表达式在 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, contentuser_input )用户输入变成模板渲染时的普通参数由引擎负责转义和输出。哪怕用户输入{{ 7*7 }}页面也只会把它当作字符串显示不会执行。如果只是个别场景需要动态模板建议封装成统一函数内部确保模板字符串固定、数据从参数传入业务侧不再允许直接拼模板。5.2 防住“过滤”方案的常见绕法强调一下不要靠黑名单。早期很多修复方案长这样template template.replace({{, ).replace(}}, )这种方案几乎立刻会被绕过。Jinja2 的模板控制结构不止{{ }}还有{% %}、{# #}。而且模板内的表达式可以通过拼接字符串、编码、过滤器组合等方式重新构造关键字。一次过滤不可能穷尽所有表达方式。正确的思路是白名单化输入结构而不是黑名单化危险字符。也就是说能接受纯文本的地方就按纯文本处理必须支持模板语法的场景就用隔离沙箱外加严格审核。5.3 开发与评审阶段的几道硬关卡我把实践中有效的防御习惯列一下。评审盯住 render_template_string凡是模板内容由字符串拼接生成的直接判定为问题代码。统一封装渲染入口项目里只留一个经过安全 review 的模板渲染函数业务代码通过它传参不直接调原始渲染函数。保留自动转义Jinja2 默认对{{ }}里的输出做 HTML 转义不要因为“我要输出富文本”就全局关闭转义。确实需要富文本时用白名单化的富文本组件处理。自动化探针CI 里加一个冒烟测试对每个接收用户输入的接口请求{{ 7*7 }}和{{ abc*3 }}一旦响应体里出现执行结果构建直接失败。跟进框架版本Jinja2 和 Flask 要定期升级。虽然版本升级解决不了“错误使用方式”但能降低“错误使用之后影响面多大”的天花板。5.4 关于“用户自定义模板”的复杂场景有一种业务需求会让上面所有简单方案失效产品希望用户能够自定义模板内容比如做电子邮件的个性化排版。这时用户输入“合法地”就应当成为模板源码就不能简单地通过传参解决。这种场景我建议按这个思路处理模板语法使用受限子集不开放全部 Jinja2 能力。模板内容保存在受管控的服务端用户只能编辑自己的模板且可回溯。渲染在独立、受限的环境中执行并设资源上限防止模板陷入死循环。模板内容上线走审核流程至少人工抽查。这条路比较重普通产品一般用不到。但一旦产品需要就不能指望靠一次过滤解决。我在实际排查里最深的一个体会是SSTI 类漏洞看着吓人但成因非常集中九成以上都是“用户输入进了模板源码”这一个点。只要代码评审盯住这一条配合本地容器靶场的定期演练团队里很容易养成条件反射。最后再分享一个小技巧排查时先把应用的所有用户输入点列成一张表再逐个验证哪些点能进入模板渲染效率远比在黑盒里漫无目的地试 payload 高得多。这套方法我用了很多年无论是带新人还是自己应急都觉得很稳。
阅读完成 · 觉得有帮助?
咨询建站