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应用常见的敏感路径包括:
/console:Werkzeug调试器入口/static/:静态文件目录/app.py、/main.py:入口文件/.git/:版本控制泄露/backup/、/src/:备份和源码目录
扫了一圈之后,发现了一个备份文件路径。下载下来一看,是应用的部分源码。虽然不是完整的,但已经足够理解核心逻辑了。这里有个经验:CTF题目里的备份文件往往不是随便放的,它一定包含了解题所需的关键信息,比如路由定义、密钥硬编码、过滤逻辑等。
从备份源码中,我提取到了几个关键信息:
| 信息类型 | 具体内容 | 利用价值 |
|---|---|---|
| 路由结构 | 登录、注册、文件读取等接口 | 确定攻击面 |
| 密钥硬编码 | SECRET_KEY写在配置里 | 可伪造session |
| 过滤逻辑 | 黑名单过滤了部分关键字 | 需绕过 |
| 模板渲染 | 使用了render_template_string | SSTI风险 |
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-unsign:
pip install flask-unsign然后解码当前session:
flask-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时,建议先在本地搭一个相同版本的Flask+Jinja2环境测试。因为不同版本的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、过滤绕过、命令执行。每一个点单独看都不复杂,但串在一起就是一条完整的攻击链。对于防守方来说,任何一个环节做好了,整条链就断了。这也是为什么安全防护强调"纵深防御"——不要指望一层防护能挡住所有攻击。