1. 从一道CTF题看Flask应用的典型攻击面easyflask这个题目名字本身就带着一种微妙的暗示——出题人觉得它简单但真正上手之后你会发现它考察的是一条完整的漏洞利用链从信息泄露到权限绕过再到代码执行每一步都踩在Flask应用开发中最容易犯的错误上。我前后做了几遍每次都能从里面挖出新的东西所以决定把整个分析过程完整记录下来。这篇文章适合有一定Web安全基础、想深入理解Flask应用常见漏洞形态的读者。如果你平时写Flask应用那更应该看看——题目里出现的每一个问题在真实项目里都有人踩过。我会从信息收集开始逐步拆解漏洞原理、利用手法和防御思路尽量把每一步的为什么讲清楚。先说明一点本文讨论的所有技术细节仅用于安全学习和防御加固请勿用于未授权的测试。2. 信息收集阶段从页面特征到源码泄露2.1 初始访问与页面指纹识别拿到题目后首先看到的是一个登录页面。这种页面在CTF里太常见了但关键在于观察细节。我习惯性先看几个东西HTTP响应头、Cookie设置、页面源码里的注释和静态资源路径。响应头里有一个值得注意的地方——Server字段没有做任何伪装直接暴露了Werkzeug的版本信息。Werkzeug是Flask的底层WSGI库它的版本号往往能直接对应到Flask的版本范围。这一步的信息量比很多人想象的要大因为特定版本的Werkzeug存在已知的调试器漏洞。Cookie里设置了一个session字段这是Flask的签名会话机制。Flask默认使用客户端存储session数据经过序列化后用密钥签名但不加密。这意味着只要拿到签名密钥就能伪造任意session内容。这是后面权限绕过的关键前提。页面源码里没有明显的注释泄露但静态资源的路径结构暴露了这是一个标准的Flask应用模板目录和静态目录的命名都符合Flask默认约定。2.2 目录扫描与源码文件发现接下来做目录扫描。很多人做CTF题喜欢直接上大字典但我觉得针对Flask应用应该有侧重。Flask应用常见的敏感路径包括/consoleWerkzeug调试器入口/static/静态文件目录/app.py、/main.py入口文件/.git/版本控制泄露/backup/、/src/备份和源码目录扫了一圈之后发现了一个备份文件路径。下载下来一看是应用的部分源码。虽然不是完整的但已经足够理解核心逻辑了。这里有个经验CTF题目里的备份文件往往不是随便放的它一定包含了解题所需的关键信息比如路由定义、密钥硬编码、过滤逻辑等。从备份源码中我提取到了几个关键信息信息类型具体内容利用价值路由结构登录、注册、文件读取等接口确定攻击面密钥硬编码SECRET_KEY写在配置里可伪造session过滤逻辑黑名单过滤了部分关键字需绕过模板渲染使用了render_template_stringSSTI风险2.3 密钥泄露与session伪造的可行性分析源码里最致命的问题是SECRET_KEY直接硬编码在配置文件里。Flask的session签名用的是HMAC-SHA1默认密钥就是这个SECRET_KEY。一旦泄露攻击者可以解码现有session查看结构修改session中的字段比如用户角色、用户名重新签名生成合法session我用flask-unsign工具试了一下直接成功解码了当前session。里面存了一个username字段和role字段。把role改成admin重新签名替换Cookie刷新页面——权限直接变了。这里要强调Flask的session是客户端存储的服务端不保存状态。所以服务端完全信任客户端传来的session内容只要签名验证通过。这就是为什么密钥泄露等于权限完全失控。3. 权限绕过后的攻击链延伸3.1 从普通用户到管理员的session篡改实操具体操作步骤我详细记录一下。首先安装flask-unsignpip install flask-unsign然后解码当前sessionflask-unsign --decode --cookie eyJ1c2VybmFtZSI6InRlc3QifQ.YXXXXX.XXXXX输出会显示session的明文结构比如{username: test, role: user}接着用泄露的密钥重新签名flask-unsign --sign --cookie {username: test, role: admin} --secret 泄露的密钥拿到新Cookie后替换浏览器里的session值刷新页面管理功能就出来了。整个过程不到两分钟。这里有个细节值得注意Flask的session序列化用的是itsdangerous库不同版本的序列化格式可能不同。如果签名失败可能需要指定--legacy参数来兼容旧格式。我在测试时遇到过这个问题折腾了一会儿才反应过来是版本差异。3.2 管理功能中的SSTI入口定位拿到管理员权限后发现多了一个个人资料页面可以自定义昵称并预览。预览功能用的是render_template_string直接把用户输入拼接到模板里渲染。这就是典型的SSTI服务端模板注入。为什么render_template_string危险因为Jinja2模板引擎支持在模板中执行表达式和调用方法。如果用户输入被当作模板内容渲染就可以注入Jinja2语法来执行任意代码。比如输入{{7*7}}如果页面显示49就说明存在SSTI。我试了一下确实返回了49。接下来就是构造利用链。3.3 Jinja2沙箱逃逸的常见路径Jinja2本身有一些沙箱机制但默认配置下并不安全。常见的逃逸路径包括通过__class__、__mro__、__subclasses__遍历找到危险类通过__globals__访问全局命名空间通过__builtins__获取内置函数但题目里加了黑名单过滤过滤了class、mro、subclasses、globals、builtins等关键字。这就需要绕过。4. 过滤绕过与最终利用4.1 黑名单过滤的绕过思路过滤规则是字符串匹配那就用字符串拼接来绕过。Jinja2支持~运算符拼接字符串也支持request.args获取URL参数。所以可以这样构造{{.__class__}}被过滤了class改成{{[__cl~ass__]}}或者用attr过滤器{{|attr(__class__)}}attr过滤器是Jinja2内置的用来动态获取属性不经过字符串匹配过滤。这是最常用的绕过手法之一。4.2 构造可执行命令的Payload绕过过滤后需要找到能执行命令的类。经典路径是通过os模块的popen或system。但os模块不一定能直接访问到需要从__subclasses__里找。完整Payload构造思路{{|attr(__class__)|attr(__mro__)|attr(__getitem__)(1)|attr(__subclasses__)()}}这会返回所有子类列表。然后找到subprocess.Popen或os._wrap_close等类通过__init__.__globals__访问全局变量最终拿到os模块。由于过滤了globals同样用attr绕过|attr(__init__)|attr(__globals__)拿到os模块后执行命令|attr(__getitem__)(popen)(cat /flag).read()4.3 完整利用链的串联与验证把整个链条串起来从session伪造到SSTI到命令执行每一步都验证通过。最终读取到flag文件。这里有个实操心得在构造SSTI Payload时建议先在本地搭一个相同版本的FlaskJinja2环境测试。因为不同版本的Jinja2__subclasses__的索引位置不同直接套用网上的Payload经常失败。本地测试能快速定位问题。另外如果subprocess被禁用还可以尝试通过__builtins__里的eval或exec来执行代码。路径是|attr(__init__)|attr(__globals__)|attr(__getitem__)(__builtins__)|attr(__getitem__)(eval)(__import__(os).popen(cat /flag).read())5. 从攻击链反推防御加固方案5.1 密钥管理的正确做法SECRET_KEY绝对不能硬编码在源码里。正确做法是通过环境变量注入import os app.config[SECRET_KEY] os.environ.get(SECRET_KEY) or os.urandom(32)如果密钥泄露了要能快速轮换。建议使用密钥管理服务支持定期自动轮换。另外Flask的session默认是客户端存储如果应用对安全性要求高应该改用服务端session比如Flask-Session扩展这样即使密钥泄露攻击者也无法直接伪造有效session。5.2 模板渲染的安全边界永远不要用render_template_string渲染用户输入。如果确实需要动态模板应该使用沙箱化的模板环境如Jinja2的SandboxedEnvironment严格限制模板中可用的变量和函数对用户输入做白名单校验只允许特定字符但最安全的做法还是避免动态模板改用预定义的模板文件用户输入只作为变量传入不参与模板结构。5.3 输入过滤的局限性黑名单过滤永远可以被绕过因为攻击者的构造方式无穷无尽。正确的做法是白名单——只允许已知安全的输入通过。比如昵称字段只允许字母数字和少量符号长度限制在合理范围内。如果必须过滤危险字符应该在多个层面做输入验证、输出编码、模板沙箱、最小权限运行。单靠一层过滤是不够的。6. 这道题带给我的实际开发警示做完这道题之后我回头检查了自己写过的一些Flask项目发现有几个地方确实存在类似的风险。比如有一个内部工具为了图方便用了render_template_string做邮件模板预览虽然当时觉得内部工具无所谓但万一被利用后果是一样的。还有一次我在一个项目里把SECRET_KEY写在了config.py里虽然后来改成了环境变量但git历史里还留着。如果仓库泄露密钥就暴露了。后来我学会了用git filter-branch清理历史或者干脆轮换密钥。关于session我现在默认会用Flask-Session把session存到Redis里虽然多了一个依赖但安全性提升明显。而且服务端session可以主动失效用户登出时直接删除服务端记录比客户端session只能等过期要可控得多。最后说一个容易被忽略的点Werkzeug调试器在生产环境必须关闭。这道题里虽然没直接用到调试器但很多Flask应用的RCE都是通过调试器实现的。确保app.debug False并且不要用app.run()直接跑生产环境用Gunicorn或uWSGI部署。这道题虽然叫easyflask但它覆盖的知识点非常全面信息泄露、密钥管理、session安全、SSTI、过滤绕过、命令执行。每一个点单独看都不复杂但串在一起就是一条完整的攻击链。对于防守方来说任何一个环节做好了整条链就断了。这也是为什么安全防护强调纵深防御——不要指望一层防护能挡住所有攻击。
阅读完成 · 觉得有帮助?