玩 CTF Web 的朋友应该都见过这么一种题:进去是个平平无奇的登录页,注册个账号、登录之后,访问/admin被拦下来,返回 403。随手把 Cookie 里的 session 解码,里面明文写着{"username":"guest"}。这时候题目在暗示什么?明摆着告诉你:Flask session 是可以伪造的,你得想办法把guest变成admin。
这类题目在 CTF 里出现频率极高,核心就三个词:Flask、session、伪造。它考的不是什么高深漏洞,而是你有没有真正理解 Flask 会话机制的底层实现。很多人看到SECRET_KEY就懵,拿着字典爆破半天没结果,还有人在手工构造 cookie 时因为盐、序列化、签名参数对不上而反复失败。这篇我把整套思路从头到尾捋一遍,从 session 存储原理讲到弱密钥爆破,再到手工伪造 admin cookie,附带我踩过的坑和排查方法,给正在刷题的你一份能直接照着操作的完整流程。
1. 先搞清楚 Flask 的 session 到底是什么
1.1 客户端 session 到底存哪了?
Flask 默认的 session 机制和传统服务端 session 完全不同。传统 PHP、Java 的 session 数据存在服务器内存或数据库里,客户端只拿一个 session id;而 Flask 默认把整个 session 对象序列化之后塞进浏览器 Cookie,所以叫“客户端 session”。
也就是说,当你登录后浏览器里那个很长一串的eyJ1c2VybmFtZSI6Imd1ZXN0In0.Z...,本质上就是{"username":"guest"}经过 Base64 编码再加了一段签名后的产物。把eyJ开头那一段丢进 Base64 解码工具,马上就能看到明文的 session 内容。这意味着任何拿到你 cookie 的人都能直接读出 session 里的所有字段,这就是 Flask session 的第一个特点:透明可见,不保密。
这个设计带来的问题很明显:用户能看见自己的 session 内容,就会想“我能不能改一下”。改很容易,但改完得让服务器认账,于是 Flask 在写入 cookie 前会对序列化后的数据进行签名。签名的作用是保证数据没有被篡改,而不是加密。
理解这点很关键。很多新手以为 Flask session 做了加密,安全得很,于是看到 cookie 里是密文和签名就放弃伪造了。实际上,你只需要拿到签名用的密钥,就能自己生成任何内容的合法 cookie,服务器完全分辨不出来。
1.2 签名不是加密,签名的意义和缺口
Flask 的签名机制用的是itsdangerous库,具体流程可以简化成三步:
- 把 session 字典序列化成 JSON 字符串,例如
{"username":"admin"}。 - 对这个字符串做签名,签名过程中混入了
SECRET_KEY、盐、时间戳、摘要算法等参数。 - 把“内容 + 时间戳 + 签名”一起 Base64 编码,拼成最终 cookie。
服务器收到 cookie 后,用同样的SECRET_KEY和参数重新计算签名,如果一致就信任里面的内容,再反序列化成 Python 字典作为 session。
这里有个关键点:整个验证过程只依赖 SECRET_KEY 这一个秘密。只要拿到了 SECRET_KEY,你就能伪造出任何 session 内容,包括把普通用户改成 admin、绕过任何只校验 session 的权限逻辑。
签名的缺口不止在于密钥泄露,还有另一个容易被忽略的点:签名算法只保证“内容没被改”,不保证“内容不透明”。所以即使没有密钥,攻击者也能读出 session 里的敏感信息。CTF 题目里有时会把密码、地址、密钥碎片等直接放进 session,造成信息泄露,这也算 session 机制附带的一类考点。
1.3 为什么 CTF 里特别爱考这个
因为考题非常好设计。开发者只需要在登录逻辑里把用户名存入 session,再在/admin路由里判断session.get("username") == "admin",一道题就成型了。难度高低完全取决于 SECRET_KEY 藏得好不好:
- 最简单:
SECRET_KEY = "secret",一眼可见,爆破一秒出结果。 - 中等:密钥藏在源码备份文件里,需要先找到
www.zip或者.git泄露。 - 偏难:密钥不直接在源码里,而是通过模板注入、文件读取、报错信息、环境变量等方式才能拿到。
考这个点本质上是在测试你对“客户端 session 信任边界”的理解。服务器信任了 cookie 里携带的数据,却没保护好签名密钥,于是攻击者拿到了任意伪造的权力。这个逻辑放在真实业务里同样成立,很多 Flask 应用如果密钥写死、写在代码仓库里,或者用的是弱口令,就存在同样的安全隐患。
2. 伪造 admin 之前,先判断这道题到底能不能打
2.1 信息收集三步:拿源码、解 cookie、找入口
拿到题目先别急着爆破,我做题的顺序一般是这样:
第一,看有没有源码泄露。目录扫描扫一下.git、www.zip、app.py、flag.py、docker-compose.yml这些常见文件。很多题目故意把源码放在备份文件里,或者直接允许访问某些静态文件。源码里可能会有 SECRET_KEY、路由逻辑、session 处理方式,这些都是判断可打性的关键信息。
第二,解码当前 cookie。把浏览器里的 session cookie 复制出来,用flask-unsign --decode或者随便一个 Base64 解码工具看看里面的内容。有的题目 cookie 里除了username还会带role、is_admin、user_id之类的字段,看清楚再决定伪造目标。如果 session 本身有多个字段,伪造时尽量保持原有字段结构,只改需要的字段,避免服务器因为缺少某个键而报错。
第三,找到权限校验入口。访问/admin、/flag、/console、/manage等路径,看返回什么。注意观察是纯前端隐藏还是后端判断。如果页面显示“403 Forbidden”,基本就是后端校验 session;如果只是前端不显示菜单,那改个 cookie 刷新就能看到隐藏内容。做题时不要漏掉接口文档、注释里提到的路径。
有个容易被忽略的点:题目可能根本不用admin字符串,而是用user_id=1或者role=root来控制权限。伪造之前一定先读源码或者多试几个字段名。
2.2 常用的三种密钥泄露途径
拿到 SECRET_KEY 是伪造的核心。CTF 里常见途径我分成三类:
源码硬编码。最直接。app.secret_key = "s3cr3t"写在代码里,找得到源码就等于拿到了密钥。有些题目还喜欢把密钥放在config.py、settings.py甚至 Dockerfile 的环境变量里,翻一翻就有。
文件读取 / 备份文件泄露。通过任意文件读取漏洞读/etc/passwd、/proc/self/environ,或者下载.git、.bak、*.swp等备份文件。这类路径非常考验目录扫描能力,字典里要包含app.py.bak、config.py.swp、Dockerfile、.env这些文件。
模板注入(SSTI)读配置。如果页面存在模板注入,可以直接用{{ config.SECRET_KEY }}或{{ config }}读出 Flask 配置对象里的密钥。这类题目等于把密钥喂给你,下一步就是直接伪造。
还有其他一些偏门路径,比如登录接口报错时把密钥带进日志、调试模式下/console需要 PIN 但可以从上下文拿密钥,或者 session 本身里居然存了SECRET_KEY(开发者调试时留下的)。永远不要假设密钥藏得有多深,测试环境里一切皆有可能。
2.3 弱密钥爆破的可行性判断
如果上述泄露途径都没有,也别急着放弃。很多题目为了降低难度,故意把 SECRET_KEY 设成一个弱字符串,比如"secret"、"admin"、"123456"、"password"、"ctf"、"test"、"key"这类。这时候可以上字典爆破。
判断可不可爆破,先看密钥特征。如果题目是动态 Docker 环境,密钥通常是通过secrets.token_hex(32)之类生成的强随机值,爆破没有意义;如果是从代码里简单赋值,比如app.secret_key = os.getenv("SECRET_KEY", "default123"),那大概率可以从默认值入手。实在不确定,先拿几十万词的常见字典跑一遍,成本很低,跑不出来再考虑其他路线。
还需要注意:爆破用的 session cookie 必须是你当前会话有效的原始 cookie。因为验证签名时要用到“时间戳 + 签名”的匹配关系,随便拿一个修改过内容的 cookie 去比对,就算密钥对了也验证不出来。我一般直接复制登录后的原始 session,这个 cookie 里的数据和签名是配套的,适合做签名验证。
3. 实操:从普通用户到 admin 的完整伪造流程
3.1 工具准备与安装
推荐直接使用flask-unsign,这是专门针对 Flask session 的解码、爆破、签名工具,命令行操作,Python 环境装一下就行:
pip install flask-unsign装好之后先验证能不能用:
flask-unsign --decode --cookie 'eyJ1c2VybmFtZSI6Imd1ZXN0In0.Z...'成功的话会直接输出 session 明文,比如{'username': 'guest'}。这一步能快速确认目标到底是不是 Flask,以及当前 cookie 里存了哪些字段。
如果不想装额外工具,也可以用 Python 的itsdangerous库,但需要自己处理盐、签名算法等细节,后面我会单独演示。新手建议先用flask-unsign走通流程,再回头学手工实现。
3.2 用 flask-unsign 爆破出 SECRET_KEY
爆破命令非常简单:
flask-unsign --unsign --cookie 'eyJ1c2VybmFtZSI6Imd1ZXN0In0.Z...' --wordlist /path/to/wordlist.txt参数说明:
--cookie传入原始 session cookie,注意 cookie 值可能很长,建议用单引号包裹,防止 shell 转义。--wordlist指定密码字典路径。CTF 常用的字典有rockyou.txt、flask-secret-key.txt这类针对性字典,也可以自己收集一些 CTF 出题常用弱密码。
爆破的原理不复杂。flask-unsign拿到 cookie 后,先把内容、时间戳、签名拆开,然后用字典里的每个候选密钥重新计算签名,和原签名比对。如果一致,就打印出匹配的密钥。因为 HMAC 计算非常快,几十万条字典几分钟内就能跑完。
成功后的输出类似:
[*] Server returned: 200 [*] Found secret key: 's3cr3t'如果没跑出来,输出是[*] No secret key found。这时候可以换大字典、加一些目标相关的词,或者回到源码泄露和模板注入的路子继续找。爆破不是终点,它只是众多拿到密钥的手段之一。
3.3 手工构造 admin cookie 的两种方式
拿到 SECRET_KEY 之后,伪造 admin cookie 有两种做法。
第一种,还是用flask-unsign一条命令搞定:
flask-unsign --sign --cookie "{'username': 'admin'}" --secret 's3cr3t'注意这里--cookie参数里传入的是要构造的 session 字典,Python 字典格式,键值都要用引号。--secret跟上一步爆出来的密钥。执行后工具会生成一个新的 cookie,直接替换浏览器里的旧 cookie 即可。
第二种,用 Python 脚本手工生成。如果要自己掌控盐和参数,可以参考 Flask 内部的签名实现。下面是一个兼容原生 Flask session 的脚本:
from flask.sessions import SecureCookieSessionInterface from itsdangerous import URLSafeTimedSerializer secret_key = "s3cr3t" class FakeApp: secret_key = secret_key serializer = SecureCookieSessionInterface().get_signing_serializer(FakeApp()) session_dict = {"username": "admin"} cookie = serializer.dumps(session_dict) print(cookie)关键点在于SecureCookieSessionInterface内部使用的盐是cookie-session,摘要算法是 SHA1,密钥派生方式是 HMAC。get_signing_serializer会自动配好这些参数,所以直接用 Flask 自带的类最保险,比自己拼URLSafeTimedSerializer参数要少踩很多坑。
不过要注意,不同版本的 Flask 在序列化方式上可能有细微差异。如果你用的是 Flask 2.x/3.x,上面的脚本基本通用;如果是老版本遇到问题,优先用flask-unsign,它会处理好版本兼容。
3.4 验证结果:带着新 cookie 访问 admin 页面
伪造完 cookie,下一步就是验证。打开浏览器开发者工具,找到当前站点对应的 session cookie,把生成的新 cookie 值粘进去,替换掉原来的值,然后刷新/admin页面。
有几种情况值得注意:
- 页面从 403 变成正常内容或者直接输出 flag,说明伪造成功。
- 页面跳回登录页,说明 cookie 的时间戳可能有问题,或者服务器对 session 还有额外校验,比如校验 IP、UA 等。这时候需要重新登录一次,拿到最新环境下的原始 cookie 再伪造。
- 页面报告签名无效,说明密钥不对,或者
flask-unsign生成时用的序列化参数和目标 Flask 版本不一致。排查方向就是对比原始 cookie 和伪造 cookie 的长度、结构,确认盐、摘要算法、时间戳格式一致。
还有一种常见情况:生成 cookie 时没错,但浏览器里设置不了那么长的 cookie,或者中间多复制了一个引号。遇到这种情况,把 cookie 值重新复制,去掉首尾多余字符,再试一次。别小看这些细节,实际做题时很多时间都耗在复制粘贴上。
4. 进阶场景:不是简单弱密钥怎么办?
4.1 通过模板注入读配置拿密钥
如果题目里存在 SSTI(服务端模板注入),读取 SECRET_KEY 往往比爆破更省事。利用点在 Flask 的模板上下文默认包含config,或者在可访问的对象链上能找到config。
最常见的 payload:
{{ config.SECRET_KEY }}如果过滤严格,可以试试:
{{ config }} {{ self.__init__.__globals__['app'].config['SECRET_KEY'] }} {{ get_flashed_messages.__globals__['current_app'].config['SECRET_KEY'] }}拿到密钥后,再用前面说的方法伪造 admin cookie。这种题目的本质是“先拿密钥,再伪造 session”,密钥本身只是中间目标,最终目标始终是提升权限到 admin。
实操中要留意:有些环境会自定义 Jinja2 的配置,把config从模板上下文里删掉,或者开启了 sandboxed 环境。这时可以尝试通过url_for.__globals__['current_app'].config一类的链子,因为 Flask 内置函数对象往往保存着对当前应用上下文的引用,利用空间很大。
4.2 源码泄露与备份文件排查
源码泄露是另一条拿密钥的高频路径。除了.git、www.zip,还可以看这几个:
.env:环境变量文件,经常存SECRET_KEY=xxxconfig.py或settings.py:直接定义了 Flask 配置requirements.txt:先确认目标 Flask 版本,再针对性构造伪造参数Dockerfile:可能通过ENV SECRET_KEY设置密钥docker-compose.yml:环境变量注入的地方.bak、.swp、~后缀文件:编辑器产生的备份,可能存有源码
目录扫描的字典要够全,不能只扫常见路径。CTF 出题人经常故意留下backup.zip、code.tar.gz、tmp.zip这类模糊名称,扫描工具可能因为字典里没有就漏掉。手动测试时可以把常见备份扩展名挨个试一遍,成本不高,回报率却很高。
找到源码后先打开找SECRET_KEY,再找 session 相关的处理逻辑。有时候密钥是多个字符串拼接的,比如app.secret_key = a + b + c,你得把变量值都找齐才能在本地还原出完整的密钥。
4.3 处理动态盐、自定义序列化等改版题型
标准 Flask session 的盐是固定的"cookie-session",但有些题会自定义SecureCookieSessionInterface的子类,覆盖salt、digest_method、key_derivation甚至serializer。如果直接拿flask-unsign默认参数去爆破,可能永远跑不出密钥,因为签名参数对不上。
遇到这种题,排查重点是:源码里是否重写了 session 接口。如果看到类似:
class MySessionInterface(SecureCookieSessionInterface): salt = "my_salt" digest_method = staticmethod(hashlib.sha256)那么伪造时也要用相同参数。手工脚本里就要改成:
from itsdangerous import URLSafeTimedSerializer from flask.sessions import TaggedJSONSerializer serializer = URLSafeTimedSerializer( secret_key, salt="my_salt", serializer=TaggedJSONSerializer(), signer_kwargs={ "key_derivation": "hmac", "digest_method": hashlib.sha256 } ) cookie = serializer.dumps({"username": "admin"})另一个变种是禁用时间戳。有的题目出于稳定考虑会用URLSafeSerializer而不是URLSafeTimedSerializer,这时伪造出的 cookie 长度可能比原来更短,因为少了时间戳部分。正常情况下不需要刻意处理,但如果伪造后签名无效,就要回头检查对方到底用的是不是定时签名的方式。
自定义序列化也不少见,比如把 session 改成 pickle 序列化。一旦变成 pickle,伪造的后果就从权限提升直接升级到反序列化漏洞,甚至可以 RCE。这类题需要单独分析,不能照搬 JSON session 的做法。判断方法是解码 cookie,如果看不到 JSON 的键值结构,而是出现乱码或pickle特征,立刻改变思路。
5. 常见踩坑记录与修复建议
5.1 我实际操作中遇到的 5 个典型坑
第一个坑:用修改后的 cookie 去爆破密钥。前面提过,爆破验证签名时,原始 cookie 的签名和内容是配套的。如果你先把guest改成admin再去跑字典,因为签名已经不匹配,爆破算法无法通过签名比对来验证密钥,结果自然是找不出来。正确做法永远是:先复制原始登录 cookie,用它爆破密钥,再用密钥重新签名目标内容。
第二个坑:命令行里--cookie参数引号处理出错。在 bash 里写--cookie "{'username':'admin'}"没问题,但有些人写成--cookie "{\"username\": \"admin\"}",或者漏了外层单引号,shell 会把花括号和引号吃掉,导致工具报错或者生成一个错误的 cookie。我的经验是:直接把整段 cookie 复制到引号里,不要手动去改里面的引号格式。
第三个坑:伪造成功但页面仍然 403。这时候先别怀疑签名,去看看目标路由到底校验的是什么字段。有的/admin校验的是session["role"] == "admin",有的校验的是session.get("user_id") == 1,还有的是把用户名存到数据库里,session 里只有user_id,你单纯把username改成admin根本没用。必须结合源码看清楚校验逻辑,再决定改哪个字段。
第四点:时间戳过期。Flask 默认的 session 签名带时间戳,生成 cookie 之后在一定时间内有效。如果你伪造完放了一晚上再去提交,签名验证会因为过期失败。解决办法是重新生成一个带当前时间戳的 cookie,或者干脆登录一次、立刻伪造、立刻访问。
第五个坑:浏览器缓存干扰。有时候新的 cookie 已经设置成功,但页面还是旧内容,因为浏览器缓存了 403 响应。按 Ctrl+F5 强制刷新,或者用无痕窗口重新访问。这个小问题不仔细排查真的很浪费时间。
5.2 从防御视角反推:这样修才不容易被伪造
写 CTF 题的人会故意留漏洞,但真实业务里可不能这么干。从防御端看,防止 Flask session 被伪造的关键就几个原则。
密钥必须强随机、独立管理。不要再写app.secret_key = "secret"了。生产环境应该用os.urandom(32)生成,或者从环境变量、密钥管理系统读取。密钥不能提交进 Git,不能留在 Docker 镜像的环境变量里,不能出现在报错页面。即使是强密钥,也建议定期轮换,降低泄露后的影响。
敏感操作不要只信任 session 里的用户名。admin 权限校验最好同时校验数据库中的真实角色、用户状态、登录会话标识,不要只读 session 字典。因为 session 可读可伪造,单单靠一个username字段做管理员判断,就算密钥不泄露,也可能因为信息泄露被猜到内部字段结构,留下隐患。
能不用客户端 session 就不用。对安全性要求高的应用,切换到服务端 session,比如 Flask-Session + Redis。这样 session 数据只存在服务端,客户端拿到的只是随机 ID,即使 ID 泄露,也无法篡改里面的权限字段。当然,这种方式需要引入额外组件,适合对安全性敏感的场景。
给 cookie 加上安全属性。HttpOnly防止 JavaScript 读取 cookie,Secure保证只在 HTTPS 下传输,SameSite缓解跨站请求伪造。这些属性不能阻止密钥泄露,但可以加大攻击者拿到合法 cookie 的难度,属于基础加固。
这些防御手段单独看都很简单,组合起来却能从源头上切断伪造的可能。CTF 题里我们利用的是开发者的疏忽,而真实项目中,我们需要堵住的正是这些疏忽。
最后说点个人体会。我最早刷这类题时,也以为伪造 session 就是把字典改一改再编码回去,结果折腾了半天页面始终 403。后来耐下心把 Flask session 的签名流程读透,才发现问题都出在参数细节上:盐、摘要算法、密钥派生方式、有没有时间戳,每一项错了签名都对不上。从那之后我遇到 Flask session 题,第一反应不再是盲目爆破,而是先分析源码、确认 cookie 结构、判断校验字段,再决定伪造目标。这套流程走熟了,无论题目怎么换皮,核心思路都不会乱。如果你正卡在类似题目上,别急着怀疑工具不行,回头再读一遍 session 的签名机制,九成问题都能迎刃而解。