资讯动态

CTF实战:JWT伪造攻击从原理到密钥爆破完整利用链

发布时间:2026/10/22 16:00:40 来源:尧图企业网站定制
1. 题目背景与思路梳理先交代一下背景。这道题出自某场国内CTF比赛的Web方向整套题围绕JWT展开设置了多个问题递进式考察。第二问的定位很明确不是让你“读得懂JWT”而是让你“伪造得了JWT”。拿到题目环境后会看到一个普通的接口页面登录后服务端返回一段Token下一步操作需要带着这段Token去访问一个受保护的接口才能拿到Flag。第一问通常是让你识别JWT结构、找到隐藏的用户字段第二问开始上强度让你以某个高权限身份访问目标接口。我当时在比赛里卡在第二问的时间最长不是因为解法有多难而是因为对JWT的常见攻击面没有系统梳理。后来把思路理顺之后发现整个流程非常套路化解码Token、分析算法、爆破密钥、构造Payload、重新签名、替换Cookie发起请求。整个过程不涉及任何偏门技巧全是Web安全里最经典的JWT利用链。这篇文章我尽量按照实战顺序写从最基础的JWT结构讲起到具体解题的每一个命令和脚本再补充一些我踩过的坑。不管你是刚接触CTF的新手还是已经打过几场比赛但没细抠过JWT的老手都应该能从里面找到点有用的东西。2. JWT核心原理与漏洞面梳理2.1 JWT的结构三段的含义别搞混JWT全称JSON Web Token本质是一段经过编码的JSON数据。它长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwidWlkIjoxfQ.sQ2V7yVQsvPzF0Y6Vx7GxBvY3WqL0D9O7T6JxU8Zk4Y用点号分为三段分别是Header头部、Payload载荷、Signature签名。Header里记录的是这个Token用的签名算法和类型。最常见的是{alg:HS256,typ:JWT}Payload里放的是业务数据比如用户名、用户ID、过期时间、角色权限等。这部分只是Base64Url编码不加密任何人拿到Token都可以解码看到内容。很多人第一次接触时误以为JWT是加密的这完全是误解签名只是防篡改不是防读。签名部分由Header中的alg算法计算得到。以HS256为例它的计算过程是HMACSHA256(base64url(header) . base64url(payload), 密钥)也就是说签名是对前两段内容加上一个密钥做哈希/签名运算的结果。服务端收到Token后会用自己存储的密钥重新计算一遍签名比对是否一致。一致则认为Token没有被篡改。2.2 常见攻击面从算法混淆到密钥爆破JWT在CTF和真实渗透里最常见的漏洞基本集中在以下几类第一类是密钥太弱。如果Header里alg是HS256服务端会用对称密钥验证签名。密钥往往写在源码里或者是常见弱口令比如admin、secret、key、123456这种。拿到Token后直接拿字典爆破命中率极高。第二类是算法混淆。JWT标准允许同一个Token用不同算法做签名。如果服务端没有强制限定算法类型攻击者可以把Header里的alg从RS256改成HS256。RS256是非对称算法公钥用来验签HS256是对称算法同一个字符串既用来签名也用来验签。很多开发者错误地认为“我用公钥去验签私钥才不会泄露”结果把公钥当成了HS256的密钥来校验。攻击者拿到公钥后就能伪造任意签名这就是经典的RS256到HS256的算法混淆攻击。第三类是alg设为none。JWT标准里留了一个none算法表示不签名。如果服务端对alg不做白名单校验攻击者直接把alg改成none再去掉签名部分就能绕过验证。这种场景在CTF里出现得也不少但现在的题目里往往不会这么直白更多是放在后几个问里作为考点。第四类是jku/jwk头注入。Header里可以指定一个JWK的URL服务端如果会主动去拉取这个URL里的公钥来验签攻击者就能提供一个自己控制URL里面放自己生成的公钥然后用自己的私钥给Token签名实现伪造。这类攻击实战中比较经典但在比赛题目里经常作为进阶问出现。回到第二问我当时拿到的Token用Header解码后是{alg:HS256,typ:JWT}说明这道题考的就是对称密钥爆破和签名伪造先把攻击面缩小了接下来的思路就顺畅很多。2.3 为什么说JWT题目一定要看源码和提示CTF里的JWT题通常不会只有孤零零一个登录接口一般会配套给到部分源码或者接口特征。比如登录接口返回Token时响应头里可能泄露了服务端框架信息失败时会回显不同的错误信息让你区分“签名错误”和“用户不存在”。这些细节在真实比赛里是重要的信息收集点。第二问我当时没有第一时间拿到源码但通过注册、登录、访问接口这三个动作基本确认了以下信息登录成功后返回的Token是JWT格式Header里alg为HS256。受保护接口的返回值里包含当前登录用户的profile信息。尝试把Payload里的uid从1改成2后直接访问返回的是401。尝试删除签名部分同样返回401。这说明服务端严格校验签名不是简单看一眼Payload。于是问题转换为找到HS256的密钥重新签名伪造Token。3. 解题实操从Token解码到伪造完整利用3.1 先把Token拆开看我习惯拿到Token后先自己在本地解码不用在线工具因为在线工具提交Token到第三方服务器总归有点心理负担虽然比赛环境无所谓但养成习惯更重要。使用Python一行命令即可解码echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwidWlkIjoxfQ.sQ2V7yVQsvPzF0Y6Vx7GxBvY3WqL0D9O7T6JxU8Zk4Y | cut -d. -f2 | base64 -d注意JWT用的是Base64Url编码和普通Base64的区别在于字符集里没有和/而是用-和_代替同时会去掉末尾的。直接用base64 -d解码时如果遇到长度不是4的倍数需要手动补上。有些版本的base64命令可以加-i参数忽略非字母字符更方便。我实际解码出来的Payload是{username:guest,uid:1,role:guest,exp:1737000000}很典型一个普通用户Token要伪造的目标大概率就是把role改成admin或者把uid改成某个数字。这种题不会太绕关键在签名。3.2 爆破对称密钥字典选择是个学问既然算法是HS256密钥就是唯一变量。爆破方式有两种思路一种是用工具内置字典一种是自己根据题目语境生成字典。我推荐先用小字典快速试探如果没戏再上大字典。这里我常用的是在线的常见弱口令字典加上题目相关的关键词组合。比如题目名称、比赛名称、flag关键词、admin等。第二问我自己的经验是密钥藏在源码注释里是一个很常见的单词所以单纯爆破弱口令就能出来。用hashcat跑HS256的JWT有两种方式。一种是把JWT当成一个hash格式用-m 16500来破解hashcat -m 16500 jwt_token /path/to/wordlist.txt另一种更可控的方式是写Python脚本用hmac和hashlib自己实现。我现场写了一个极简爆破脚本import hmac import hashlib import base64 token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imd1ZXN0IiwidWlkIjoxLCJyb2xlIjoiZ3Vlc3QiLCJleHAiOjE3MzcwMDAwMDB9.xxxxxx header_payload token.rsplit(., 1)[0] with open(wordlist.txt) as f: for line in f: key line.strip() if len(key) 0: continue sig hmac.new(key.encode(), header_payload.encode(), hashlib.sha256).digest() b64_sig base64.urlsafe_b64encode(sig).rstrip(b).decode() if b64_sig token.rsplit(., 1)[1]: print(f[] Found key: {key}) break这个脚本的核心就两行计算HMAC-SHA256签名然后转成Base64Url格式比对。RTFM级别的逻辑但很管用。注意点在于urlsafe_b64encode会自动把和/转成-和_正好和JWT要求一致。3.3 伪造Token别在时间戳上翻车拿到密钥之后伪造Token就非常快了。目标Payload我设成了{username:admin,uid:1,role:admin,exp:9999999999}exp时间戳我习惯给一个大一点的值免得请求过程中Token过期。这里比较隐蔽的坑是某些服务端会校验exp的格式甚至只允许特定的过期时间范围也有比赛故意在源码里对exp做了限定比如必须小于某个值否则直接拒绝解析。我遇到过一次不管怎么改role接口一直报“Token expired”后来才发现是exp过大导致时间校验失败把过期时间改成当时的Unix时间戳加3600就通过了。伪造签名用同样的HMAC逻辑import hmac import hashlib import base64 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,uid:1,role:admin,exp:1737003600}) signing_input f{header}.{payload} sig hmac.new(bsecret_key, signing_input.encode(), hashlib.sha256).digest() token f{signing_input}.{b64url_encode(sig)} print(token)把生成的Token替换到Cookie里或者直接放到Authorization头Authorization: Bearer 伪造的token然后带着这个Token重新请求受保护接口就能拿到Flag了。3.4 工具和手写脚本的取舍CTF比赛里时间紧张用现成工具确实快。比如jwt_tool一条命令就能完成从解码、爆破到伪造的完整流程python jwt_tool.py jwt -C -d /path/to/wordlist.txt但我个人的建议是不要完全依赖工具至少手写一遍爆破和伪造的脚本。原因很简单比赛中的Token格式可能不完全标准工具会默认按标准JWT处理一旦遇到变体比如签名部分被URL编码过、Header里多了自定义字段工具可能直接解析失败。你自己写过脚本遇到这种非标情况才能快速改逻辑而不是手忙脚乱去翻工具文档。第二问当时我两种方法都试了。工具本身没问题但字典默认不含题目相关的自定义词条最后还是手写脚本加了几条自造词条才跑出来。所以工具适合作为辅助验证核心思路还得自己心里有数。4. 常见问题与排查技巧实录4.1 解码出来的Payload带乱码或空白这是最常见的入门问题。原因是JWT的Base64Url编码和标准Base64不兼容。如果你把-和_直接当普通字符丢给base64解码很可能得到错误结果。正确做法是先替换字符集再补echo payload部分 | tr _- / | base64 -d 2/dev/null || true更稳妥的方式是用Python的base64.urlsafe_b64decode它会自动处理字符集只是注意长度不对时手动补。我写了个辅助函数import base64 def jwt_b64_decode(s): s * (-len(s) % 4) return base64.urlsafe_b64decode(s)这个函数可以覆盖绝大多数手动解码场景。4.2 密钥爆破无误但伪造Token一直401这种情况我遇到过好多次原因通常有三个第一个是最常见的时间戳问题。exp过期时间设成9999999999某些语言的时间库会溢出或拒绝解析导致服务端返回异常。解决办法是设置成当前时间往后一小时可用date -d 1 hour %s生成。第二个是签名输入格式问题。JWT签名时输入是base64url(header) . base64url(payload)注意中间的点的位置。有些脚本会误把整个原始JSON拼进去或者多算了一个点。第三个是Header里的alg被服务端忽略或者强制覆盖。某些框架会忽略Token里的alg直接用服务端预定的算法验证。这种情况下你就算把alg改成HS256也没用题目可能考的是其他漏洞比如SQL注入或路径穿越。这时候就要回到源码分析别死磕JWT。4.3 工具爆破不出密钥先看看密钥生成逻辑很多比赛题目里的密钥不是纯弱口令而是经过拼接或哈希处理的。比如密钥是md5(admin)这种情况下直接爆明文作用不大。密钥是两张图片拼接后的内容工具字典完全跑不出来。密钥是随机的但可通过注册接口回显猜测。第二问比较良心密钥就写在源码文件的配置区里属于典型的“仔细审计源码就能发现”的送分项。当时我看源码时扫到一行配置JWT_SECRET s3cr3t_key直接手动用这个密钥去签名就通了根本不需要爆破。这个教训也说明做题时先花五分钟快速浏览源码和注释永远比直接挂字典跑半小时要高效。4.4 接口返回500而不是401怎么定位如果伪造Token访问接口时返回500说明Token通过了签名校验但到了解析阶段出了问题。常见原因是Payload里的某些字段类型不符合预期。比如服务端代码里是int(uid)你payload里写了字符串1解析时可能报错或者被强制转成0。这时可以分步调试先用原Token访问确认功能正常。用原Token和伪造Token对比只有Payload不同、签名方式相同把差异缩小到Payload字段。逐个修改字段每次只改一个观察接口返回变化。这种二分定位法在复杂题里非常好用。第二问其实只需要改role但我为了保险也改了username后来发现username必须保持原样否则接口会走另一个逻辑分支导致一直拿不到正确回显。5. 复盘JWT题目还能怎么延伸第二问解决了之后我顺手看了这道题的后续设置第三问开始考察算法混淆要求把原本的RS256公钥文件找出来然后转成HS256伪造签名。这里涉及一个非常经典的转化过程先拿到公钥文件再用Python把公钥内容作为HMAC密钥来计算签名。这类考法和第二问本质相同但思路跨度更大。如果你只学会爆破弱口令遇到RS256就完全卡住。所以我建议有志于提高的人至少要把下面这段“公钥转HMAC密钥”的脚本看懂import hmac import hashlib import base64 public_key open(public.key, rb).read() header base64.urlsafe_b64encode(b{alg:HS256,typ:JWT}).rstrip(b) payload base64.urlsafe_b64encode(b{username:admin,role:admin}).rstrip(b) signing_input header b. payload sig hmac.new(public_key, signing_input, hashlib.sha256).digest() print(signing_input.decode() . base64.urlsafe_b64encode(sig).rstrip(b).decode())之所以这样能成立是因为很多框架在拿到alg为HS256时会把配置里用于验签的公钥字符串当成对称密钥来验签而攻击者手上刚好有这份公钥。理解了这一点再看真实世界里的一些JWT安全问题很多都是同一个模型开发者信任了Token里可控的元数据而框架又没做好算法白名单的强约束。比赛中反射出的这些问题放到企业渗透测试里同样适用。6. 写在最后一些实用技巧经验和教训都写完了最后分享几个我实际用着顺手的习惯。第一准备一份自己的JWT常用字典不要每次都临时找。我的字典里除了弱口令、英文单词、键盘序列之外还会包含比赛名称、题目关键词、框架默认密钥比如flask-secret、django-insecure-key这些。不需要多大几百行足够但覆盖面比通用大字典要精准得多。第二尽量把思路整理成“观察-假设-验证”的流程。JWT题目最忌讳瞎试先看alg再想这个算法下有哪些攻击面然后根据服务端回显验证攻击面是否真的存在最后才进入工具爆破或写脚本环节。顺序对了做题速度会快很多。第三所有本地脚本别只写一份。我会把“JWT解码”“JWT签名”“JWT爆破”三个功能分开写成小函数放在同一个Python文件里随时改随时用。比赛现场时间紧张现写现调虽然也行但提前准备好工具链能节省大量试错时间。JWT这道题说难它不是特别难说简单也算不上白给。它考察的是你是否真正理解Token的构成及其信任边界。把算法、密钥、签名这三者的关系想透再做任何JWT题都会觉得思路清晰不少。希望这篇复盘能把你从“只会用解码器看内容”推向“能自己伪造一个Token”的阶段哪怕只给了你一点启发也算值得。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑