
JWTJSON 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, encodingutf-8, errorsignore) 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注入组合出题。如果是拼SQLkid就可能成为注入点通过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 web346none算法绕过这题开始上难度了修改Payload后直接提交会得到一个无效token提示说明服务端验签了。下一步标准操作就是试algnone。我在题目环境里做了两件事先把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题也能按图索骥拿到token拆三段解码Header和Payload确认算法和业务字段。试着改Payload但不改签名直接提交确认服务端是否验签。不改签名时如果无效尝试algnone变体。none无效尝试HS256弱密钥本地枚举。弱密钥不行看题目里有没有公钥文件、jwks端点准备算法混淆。如果Header里有kid参数考虑路径穿越或可控文件作为密钥。检查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-Lengthtoken变长之后如果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变体比如algnone的各种大小写组合或者针对同一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的题基本就是按图索骥了。