☰
JWT漏洞全家桶:CTF实战解析与BurpSuite攻击手册
2026/10/10 6:06:26 网站建设 项目流程

JWT(JSON Web Token)应该是CTF里出场率最高的鉴权组件之一,但你真正理解它的安全边界吗?CTFshow Web入门345到350这组题,我愿称之为一套“JWT漏洞全家桶”——从最简单的解码看数据,到alg设为none直接绕过签名,再到弱密钥枚举、RS256和HS256算法混淆,每一题都有不同切入点。配合BurpSuite手工改包,你会非常直观地看到服务端对每个字段的反应。这篇文章我把整个刷题过程重演了一遍,把JWT原理、攻击场景、Burp操作和翻车记录都揉在一起,写成一份可以直接对着做的笔记。不管你刚入门Web安全,还是在做授权渗透测试时需要判断一个系统的JWT实现是否安全,都可以参考。

1. JWT到底是个什么东西

1.1 三段式结构,一句话就能讲清楚

JWT全称是JSON Web Token,本质是一串用点号分隔的字符串,分成Header、Payload、Signature三部分。随便抓一个真实JWT,长这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ.签名内容

第一段Header,一般长这样:{"alg":"HS256","typ":"JWT"},标识签名算法和类型。第二段Payload是数据正文,比如用户名、角色、过期时间这些,注意它是Base64URL编码,不是加密,任何人用工具都能解开。第三段Signature是前面两段拼起来之后拿密钥做签名的结果。

编码细节这里必须提一句:JWT用的是Base64URL,不是普通Base64。普通Base64里的+和/会被换成-和_,结尾的=填充符会被去掉。很多新手拿标准Base64工具解码JWT发现乱码,就是因为这个。自己写脚本解码时记得补上填充符。

拿到一个JWT,第一步永远是拆开看内容,这一步可以用jwt.io在线工具,也可以自己在BurpSuite里搞定。你把token粘进去,Header和Payload直接明文显示,不需要任何密钥。很多人忽略这一步,其实很多漏洞信息就藏在Payload里。

1.2 签发与验证流程

JWT的典型流程是这样的:用户登录成功后,服务端用密钥对Header和Payload做签名,生成JWT返回给前端。前端后续请求在Authorization头里带上Bearer <token>。服务端收到后,按照Header里声明的alg算法,用自己保存的密钥重新计算签名,对比是否一致,再检查exp等时间字段,全部通过才算合法。

HS256的签名公式是:

HMAC_SHA256(base64url(Header) + "." + base64url(Payload), secret)

这里的secret是对称密钥,也就是说签名和验证用的是同一个串。RS256就不一样了,它是非对称签名,服务端用私钥签名,客户端或网关用公钥验证,公钥是可以公开的。

这个流程本身设计得挺聪明,无状态、跨域友好、适合分布式系统。但问题恰恰出在验证环节的实现上,很多系统直接把Header里的alg字段当成指令去执行,根本不核对这个算法是不是自己预期允许的白名单,这就给了攻击者很大的操作空间。

生活化类比:JWT像一张带防伪标签的通行证。签发方用特定印章盖上去,验证方应该检查印章的真伪。但如果验证方只看“有没有章”,不看章的种类和真假,那攻击者随便找个橡皮章也能混进去。

1.3 为什么安全圈盯上它

JWT被黑产和安全研究人员重点照顾,不是因为算法本身有问题,而是因为它在真实系统里的错误使用方式太常见了。首先是密钥管理混乱,有人把HS256的对称密钥硬编码在前端JS里,也有人把私钥直接丢到公开的Git仓库。其次是实现库的默认行为不严格,某些JWT库为了兼容旧版本,默认接受alg:none,或者没有强制校验算法。第三是开发者的认知偏差,以为Payload是加密的,把密码、手机号、身份证号直接塞进去,事实上这玩意只是Base64编码。

CTF里设计JWT题目,本质上就是把这些现实中的错误提炼成一个个考点。理解了这三点,后面刷题基本能猜出题目想让你用什么姿势绕过。

2. JWT的典型攻击场景

2.1 算法改成none:签名直接躺平

none算法是JWT最经典的漏洞入口。设计初衷是为了方便调试——某些内部环境下不需要签名,只传数据。结果很多库把这功能带到了生产环境,或者通过降级策略帮开发人员“兼容”旧token,导致攻击者只要把Header里的alg字段改成none,就能去掉签名验证。

利用姿势很简单。原始Header是:

{"alg":"HS256","typ":"JWT"}

改成:

{"alg":"none","typ":"JWT"}

Payload随便改,比如把"username":"test"改成"username":"admin"。然后把签名段删掉,保留末尾的点也行,不保留也行。两种形式都要试:

改完的eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.改完的eyJwYXlsb2FkIn0. 改完的eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.改完的eyJwYXlsb2FkIn0

注意大小写变体,有些实现只认全小写,有些只认首字母大写,None、NONE、nOnE这种奇葩组合我在实战里都见过。测试的时候可以一次性把多种变体放进字典里批量试。

有一个细节容易翻车:很多服务端拿到token后,会先按点号做split,如果签名段缺失,取到的段数不对,直接报解析错误,返回500或者401。这种情况下就要保留那个空签名段,即token末尾仍有一个点。我的建议是每次测试都同时构造两个版本。

2.2 弱密钥枚举:HS256的宿命

HS256是对称签名,签名和验证用同一个字符串。这个字符串一旦太弱,等于把门锁密码写在便利贴上。CTF题目和真实渗透里最常见的弱密钥就是secret、admin、123456、test、key这些。

枚举HS256密钥有个巨大优势:不需要向服务端发送大量请求。你只需要拿到一个有效的JWT,本地用字典里的密钥挨个试算签名,比对结果是否一致。一次爆破几十万条字典也很快,完全不担心被限速或被WAF拦。

这是我常用的本地爆破脚本,基于Python标准库,不需要装第三方依赖:

import base64 import hashlib import hmac import sys def b64url_decode(data: str) -> bytes: padding = "=" * (-len(data) % 4) return base64.urlsafe_b64decode(data + padding) def b64url_encode(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode() # 用法:python jwt_crack.py eyJhbGciOiJIUzI1NiIs... token = sys.argv[1] header_payload, signature = token.rsplit(".", 1) with open("jwt_dict.txt", encoding="utf-8", errors="ignore") as fp: for line in fp: key = line.strip() if not key: continue calc_sig = b64url_encode( hmac.new(key.encode(), header_payload.encode(), hashlib.sha256).digest() ) if calc_sig == signature: print(f"[+] Found key: {key}") break else: print("[-] Not found in dictionary")

字典的选择很有讲究。CTF里如果题目提示密钥是6位纯数字,就该生成0到999999的字典;如果提示是常见弱口令,就优先用rockyou里的小型子集。真实测试中我会先跑几十条最常见的弱口令,再跑用户名字典,最后才上大字典。

拿到密钥之后,自己就能随便构造合法token。用Python或者jwt.io都行,改完Payload后用密钥重算签名,提交即可。

2.3 算法混淆:RS256被当成HS256

算法混淆攻击的核心思路是:服务端同时支持RS256和HS256,但验证逻辑没有做算法白名单。攻击者把Header里的alg改成HS256,然后偷偷用公开的公钥内容作为HMAC密钥去签名。

听上去有点绕,拆开说。正常RS256流程是私钥签、公钥验。公钥是公开的,任何人都能拿到。如果服务端收到一个alg为HS256的token,就会用HS256的验证逻辑,即拿一个密钥做HMAC对比。问题来了:这个密钥从哪来?如果代码里写的是“用当前RSA公钥文件内容作为HMAC密钥”,那攻击者只要拿到公钥文件,就能用它来签一个伪token。

具体操作步骤:

第一步,找到公钥。CTF题目中常见的位置是:/demo/public.pem、/jwt/public.key、/jwks.json,有时候公钥会直接在源码注释或JS文件里。用curl下载下来:

curl http://target/demo/public.pem -o public.pem

第二步,把公钥内容作为密钥,对伪造的Header和Payload做HMAC-SHA256签名。注意公钥内容要原样使用,包括-----BEGIN PUBLIC KEY-----这些行,除非题目明确提示只用中间部分。

import base64, hashlib, hmac public_key = open("public.pem").read() def b64url_encode(data): return base64.urlsafe_b64encode(data).rstrip(b"=").decode() header = b64url_encode(b'{"alg":"HS256","typ":"JWT"}') payload = b64url_encode(b'{"username":"admin"}') signing_input = header + "." + payload signature = b64url_encode( hmac.new(public_key.encode(), signing_input.encode(), hashlib.sha256).digest() ) fake_token = signing_input + "." + signature print(fake_token)

第三步,把生成的token替换掉原来的Authorization头内容,提交请求。如果服务端傻傻地用公钥当HS256密钥来验签,伪造身份就成功了。

这个坑的隐蔽之处在于,很多开发者在写代码时根本没意识到公钥文件内容也是一种“密钥”。安全验证时只看到用了openssl库,脑子里默认是安全的,结果被切换算法偷了家。

2.4 kid参数注入与头部伪造

Header里除了alg和typ,还可能有一个kid字段,全称Key ID,用来告诉服务端“我用的是哪一把密钥”。服务端拿到kid后,可能会拼路径去读文件:

$key = file_get_contents("keys/" . $kid);

如果这里没做任何过滤,攻击者就可以把kid改成一个恶意路径。最简单的玩法是路径穿越:../../../../etc/passwd。虽然大多数情况下你读不到文件内容,但错误信息、响应时间、调试日志都可能泄露路径是否命中,这本身就构成了一个信息泄露点。

比路径穿越更骚的操作是密钥混淆。攻击者先找一个自己内容可控的文件,比如在网站上传一个头像图片,图片内容自己完全掌控。然后把kid指向这个文件,服务端就会把图片内容当成密钥来验签。这时候攻击者因为知道图片内容,就能计算合法签名。

带kid的Header长这样:

{"alg":"HS256","typ":"JWT","kid":"../../../../tmp/evil.jpg"}

在CTF里,kid注入常常和文件上传、SQL注入组合出题。如果是拼SQL,kid就可能成为注入点,通过union select把构造的密钥字符串返回给验签逻辑。不管哪种形式,思路都是同一个:让服务端去读一个“内容你已知且可控”的东西作为密钥。

这个攻击场景再次说明,JWT的安全不只是算法和密钥强度的问题,Header里的每一个字段都可能成为可操作面。

2.5 Payload的另一个大坑:明文可读

用一句话概括:JWT的Payload是Base64URL编码,不是加密。你可以用任何文本编辑器把第二段解出来,内容完全透明。大量的真实系统习惯把邮箱、用户名、userID、角色、甚至手机号直接塞进Payload,一旦业务逻辑中有地方信任了这些字段,就会出问题。

CTF题目里最常见的坑就是role或is_admin字段:

{"username":"admin","role":"admin","is_admin":true}

如果服务端在验签之后直接读取role判断权限,而不去数据库二次查询,那只要你能通过某种方式绕过签名,伪造一个role为admin的token,就可以直接提权。

另外还有时间字段。exp表示过期时间,Unix时间戳格式;nbf表示生效时间;iat表示签发时间。一些题目里即使你伪造了合法签名,但exp是过去的时间,服务端也会返回token过期。所以自己构造token时,exp一定要设置为当前时间之后,最稳妥的做法是复制原token的exp字段,或者直接设成一个很大的数比如9999999999。

3. CTFshow Web入门345-350题解拆解

3.1 web345:第一次接触JWT,先学会拆

我记得刚打开这题时,页面是一个普通的登录功能,登录后拿到一个token,并且页面响应里直接展示了JWT的Header和Payload字段。这种设计很明显在提示:先看JWT内容。

解题流程很简单:把token的第二段Payload用Base64URL解码,里面通常有username之类的信息。这题不需要破解密钥,因为服务端逻辑可能根本没有验签,或者验签用的密钥直接写死在可见的代码里。把Payload中的用户名改成admin,重新Base64URL编码,替换token,再次访问管理功能,flag就出来了。

这题对新手最大的价值是习惯“拆token”这个动作。看到一个JWT,第一反应不是急着爆破,而是先解码看内容。信息就在眼前,很多人却直接跳到爆破环节,反而绕远路。

3.2 web346:none算法绕过

这题开始上难度了,修改Payload后直接提交,会得到一个无效token提示,说明服务端验签了。下一步标准操作就是试alg=none。

我在题目环境里做了两件事:先把Header里的alg改成none,此时有两种URL编码变体,一种是正常的{"alg":"none","typ":"JWT"},另一种是把值大小写乱换;然后把Payload改成admin相关字段。注意保留最后的点,构造空签名。

提交后如果返回内容里出现了管理功能或flag,说明服务端验证逻辑里允许none算法。实战中如果遇到这题不成功,大概率是签名段没有留点,或者服务端拒绝了大写的none。把这几种变体都丢进Burp Intruder里批量打,几分钟就能得出结论。

这题的教训很经典:很多JWT库在测试模式允许none,但生产环境忘记关闭。CTF把这种场景抽出来,就是为了让人意识到“算法降级”的危害。

3.3 web347与web348:弱密钥枚举的两种姿势

这两题是兄弟题,考点核心都是HS256弱密钥。347题给的token,我用本地脚本跑了rockyou的前面一小部分,很快找到了密钥。密钥是常见的admin之类。拿到密钥之后,重新对伪造的payload签名,生成合法token,提交即得flag。

348题则换了个花样,普通词典跑不出来。这个时候要结合题目信息,页面上或者题目描述里往往藏着提示。我遇到的这题是提示密钥为6位数字,那就什么字典都不用带了,直接生成000000到999999的字典,本地脚本跑一圈,几秒钟就出来。

爆破密钥有一个不能忽略的前置条件:你得确认token的算法确实是HS256。如果Header里写的是RS256但你非拿RS256逻辑去验HMAC,那肯定出不来。另外,本地脚本跑出来密钥后,强烈建议先在jwt.io上手工验证一次,把密钥填进去看签名是否匹配,确认无误再去构造payload。因为有些人会看错签名段,脚本找到了“相同”的签名,其实是自己写的字典循环逻辑有bug。

3.4 web349与web350:算法混淆与高阶利用

到这两题,弱密钥枚举已经走不通了,密钥强度明显提高。题目会给出一个public.pem或者类似的公钥文件,这就把考点指向了算法混淆。

我当时的操作是:先把公钥文件下载下来,然后确认Header原始算法是RS256。接下来把Header的alg改成HS256,用公钥文件内容作为HMAC密钥,对伪造的payload重新签名。关键点在于公钥内容的使用方式,题目环境里如果直接用整个文件内容能成功,那就不用做二次加工;如果失败,试着只取中间base64部分或者去掉换行空白。

web350比web349又杂了一点,可能是把none、弱密钥、算法混淆混合在一起,需要先判断服务端到底会走哪条验证分支。我的做法是先用简单payload逐个测试:改alg为none,看响应;再跑弱密钥字典,看响应;最后再试公钥签名。每步都保留原始token,不然后面想回退都麻烦。

这组题做下来的爬坡感很强,345和346基本就是送分,让你熟悉工具和思路;347和348让你动手写爆破脚本;349和350考验对非对称签名的理解。整套流程走完,JWT的主流攻击面基本上都覆盖了。

3.5 这类题通用的解题流程

把CTFshow这组题抽象成一套流程,之后遇到其他平台的JWT题也能按图索骥:

  1. 拿到token,拆三段,解码Header和Payload,确认算法和业务字段。
  2. 试着改Payload但不改签名,直接提交,确认服务端是否验签。
  3. 不改签名时如果无效,尝试alg=none变体。
  4. none无效,尝试HS256弱密钥本地枚举。
  5. 弱密钥不行,看题目里有没有公钥文件、jwks端点,准备算法混淆。
  6. 如果Header里有kid参数,考虑路径穿越或可控文件作为密钥。
  7. 检查Payload里的role、exp等字段,判断是否有逻辑信任问题。

这套流程不是线性的,实际做题时要根据题目环境和报错信息灵活跳转。页面和JS源码里藏着大量线索,千万别只看一个接口。

4. BurpSuite靶场实操全流程

4.1 环境准备:社区版Burp + 浏览器代理

BurpSuite做JWT手工操作非常合适,社区版就够用,注册后才有的那些功能在CTF场景里不是必需的。安装Burp前先确认本机有Java运行环境,现在Burp官方要求JDK版本至少是17,装好后直接启动。

代理配置是基础中的基础。Burp默认监听127.0.0.1:8080,浏览器需要把HTTP和HTTPS代理都指向这里。Firefox我个人觉得最顺手,在设置里搜索“代理”,手动填上localhost和8080就行。如果用Chrome,配合SwitchyOmega插件可以快捷切换。

抓HTTPS流量要装CA证书。浏览器访问http://burp下载CA证书,导入到系统信任列表或者浏览器证书管理里。CTF靶场很多都是HTTP,不用管证书问题,但如果某个题目环境是HTTPS,这步直接决定你能否抓到包。装证书这天我提醒一句:只在你自己授权的靶场环境里操作,别把Burp代理挂在陌生网络上。

4.2 抓包改JWT的正确姿势

拦截登录请求后,响应里的token、后续请求里的Authorization头,都是下手的目标。我一般右键请求,选择Send to Repeater,在Repeater里慢慢改,避免实时拦截影响操作节奏,也方便对比不同payload的响应。

修改JWT时,先把原始token复制出来,到Burp自带的解码器或jwt.io里拆开。改Payload字段,重新Base64URL编码,替换回Authorization头。这里最容易翻车的是Content-Length,token变长之后如果HTTP请求头里的Content-Length还是旧值,服务端可能解析不全,返回400或空白。Burp有时候会自动修正,有时候不会,所以每次改完请求体务必扫一眼Content-Length。

还有一个细节:很多题目的鉴权逻辑不止看Authorization头,还会在请求体或其他自定义头里透传用户信息。刷题时如果改了token还是没变化,把请求里所有看起来像身份标识的参数都翻一遍,比如uid、username、X-User-Id这些。

实际操作里,我会给自己定一个习惯:每改一次token,就在Burp里高亮这个请求,颜色用红色标出“已修改”。请求多了之后不会眼花,也方便返回去对比历史记录。

4.3 JWT Editor插件的用法

Burp的BApp Store里有专门的JWT插件,叫JWT Editor,强烈建议安装。它能把JWT操作从“手工复制编码”升级成“可视化编辑”。

安装路径:Burp的Extensions或BApp Store页面,搜索JWT Editor,点击Install。装好后在Repeater里选中token,右键菜单里会出现Send to JWT Editor的选项。

插件界面提供三块区域:Header、Payload、Signature。你可以直接编辑JSON字段,修改alg、增加kid,点的操作都由它处理。更重要的是它能帮你重算签名:先把Header里的alg设置好,填入密钥,点一下Sign,新token就生成了。HS256弱密钥场景下,这个功能非常实用,拿到密钥后不需要写脚本就能完成伪造。

不过插件也不是万金油。算法混淆场景中,需要把公钥内容粘贴进密钥框,这与直接用脚本构造相比容易出错,因为公钥包含多行文本和一些特殊字符。我个人的习惯是小改动用插件,复杂逻辑用Python脚本,两边配合效率最高。

4.4 用Intruder做批量变体测试

有些题目需要你以极快的速度尝试多个token变体,比如alg=none的各种大小写组合,或者针对同一Payload的不同签名结果。这时候可以把请求发送到Intruder,在Authorization头的位置打上payload标记,加载字典列表,直接开跑。

但要注意一个核心限制:Intruder本身不会帮你计算HMAC签名,它只能把准备好的字符串填充到请求里。如果你要做弱密钥枚举,正确的打开方式是先用本地脚本算出每种密钥对应的签名,把签名列表存成字典,再让Intruder逐个替换token。这样流程下来,服务端对每个token的响应差异就能集中看到。

用Intruder还有一个技巧:设置响应标记,比如把“Welcome”和“Falied”设成正负标记,跑完后按标记分组排序,一眼就能看出哪些payload生效了。刷题时不用傻等全部跑完,看到命中就直接停掉。

5. 常见坑与排查速查

5.1 高频报错对应排查表

我把刷JWT题过程中遇到的典型问题整理成了一张表,按“现象-原因-解法”对应着看:

现象可能原因解决思路
解码JWT时出现乱码或报错用的是普通Base64而非Base64URL把-换回+,_换回/,补齐=填充
改了alg为none还是返回401服务端校验算法白名单,或空签名段的点号格式不对尝试None、NONE等大小写变体,同时保留末尾点号
本地爆破脚本跑不出密钥字典太小,或者密钥是自定义格式观察题目提示,生成针对性字典比如纯数字、日期
修改token后请求失败Content-Length没更新,token变长被截断手动修正请求头Content-Length
签名重算后服务端仍拒绝算法混淆场景下公钥内容格式不对尝试带Begin/End行的完整公钥与只取中间base64内容两种形式
提示token已过期Payload里的exp是过去的时间戳将exp改为较大的未来Unix时间戳
Burp抓不到HTTPS包浏览器没有安装Burp的CA证书访问http://burp下载证书并导入信任列表

上面这些坑我基本都踩过,最隐蔽的是Content-Length问题。很多新手改了token后一直盯着签名和Payload,却没发现请求体的字节数变了,服务端压根没读到完整的token。

5.2 刷题之前先准备好这些

与其现场折磨,不如提前把装备弄齐。我给准备刷JWT专题的朋友一个精简清单:

  • 浏览器代理切换插件,Firefox自带或Chrome的SwitchyOmega。
  • BurpSuite Community版,提前装好JWT Editor扩展。
  • 一份覆盖常见弱口令的小字典,几十条几十条级别的,用于快速试探。
  • 一份纯数字6位数到8位数的生成逻辑脚本,应对“密钥是数字”的提示。
  • Python的JWT处理脚本模板,不需要第三方库的那种,复制粘贴就能用。
  • 把Unix时间戳转换工具加入浏览器书签,检查exp字段时要用。

还有一个心态上的提醒:刷这类题不要上来就怀疑题目有问题。99%的情况是你构造的token格式和题目服务端预期不一致。先回到原始token做对照,逐段比较编码后的字符串差异,往往一眼就能看出问题。

个人经验收尾

刷完CTFshow 345到350,我最大的体会是:JWT漏洞并不神秘,关键看你能否在拿到一串token后快速判断它属于哪种错误实现。很多真实项目出问题,往往不是因为算法多深奥,而是默认配置没改、密钥太弱、或者写代码时没做算法白名单校验。

最后再分享一个小技巧:刷这类题时,我会在Burp里给Authorization头设置一个自定义高亮颜色,蓝色是原始token,黄色是修改过Payload但未改签名,红色是修改过签名的版本。请求一多,整个界面像信号灯一样清爽,反复横跳对比时不会搞混自己改到哪一步。

另外一定要养成保留原始token副本的习惯。每次改动前复制一份到临时文本文件里,标注改动内容。一旦把token改到面目全非又需要回到起点时,这个副本能帮你省下大量排查时间。这套流程你完整走一遍,之后再遇到任何JWT的题,基本就是按图索骥了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询