JWT安全漏洞与防御实战:从算法混淆到密钥管理 📅 发布时间:2026/9/16 22:05:14 👁 浏览次数: 这两年做安全测试JWTJSON Web Token在项目里出镜率实在太高了。从大厂的中台服务到创业公司的小后台几乎人人都拿它做登录态管理、接口鉴权、单点登录但说实话我用过的、看过的这些项目里真正把JWT用明白的不多。我见过把Token直接塞进URL里倒腾的见过移除签名还能通过校验的也见过密钥就是secret的——字典里躺着的rockyou一梭子打穿。JWT本身是个非常轻量的认证方案它的安全边界全靠使用者自己把握而这个把握恰恰是最容易翻车的地方。这篇文章就把我在实际渗透测试、代码审计和服务端方案评审里碰到的JWT安全漏洞和常见攻击方式捋一遍再给出一套能直接落到团队里的防御清单。和JWT打交道比较多的人无论是后端开发、安全测试还是运维同学这篇都值得花几分钟过一遍。原理部分我尽量讲得直白案例都是真实场景抽象出来的不涉及具体公司但手法和防御思路完全可以复用。1. 先把JWT拆开了看Token和JWT到底什么关系很多人在最开始就栽在一个小概念上Token和JWT是不是一回事答案是有交集但不是同一个东西。Token是个泛指只要是服务端签发给客户端、用来证明身份的凭证都可以叫TokenJWT是Token的一种具体实现标准。你可以把JWT理解为一种自带信息的通行证服务端把用户身份、过期时间、权限范围直接编码进这张证里后面每次校验只需要验签不需要回头查数据库。1.1 JWT三大段Header、Payload、SignatureJWT长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6ImFkbWluIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MDAwMDAwMDB9.9XZ3uFmYh1VY6j3gHtLv0nM3k7XxPqG1RkYvN7sWv6A这串东西用点号分成三段分别是Header、Payload、Signature每一段都是Base64Url编码后的JSON内容再加一起用指定算法签名。Header部分记录了两件事签名算法alg字段和Token类型typ字段典型值是{alg:HS256,typ:JWT}。服务端看到这个Header才知道该拿什么算法去验证签名而这个客户端可控的Header恰恰埋下了后面一大堆漏洞的引子。Payload是业务数据区可以自定义放user_id、role、exp过期时间、iat签发时间这类字段。注意它只是Base64Url编码不是加密任何人拿到Token都能用解码工具直接看到里面的明文内容。很多人把手机号、邮箱甚至密码哈希往里塞等于把敏感信息直接暴露在攻击者面前。Signature是用Header和Payload拼起来之后拿密钥做HMAC或RSA签名得到的一段指纹。服务端校验Token时重新算一遍签名对得上才认为Token可信。1.2 为什么JWT的安全问题如此普遍我觉得问题出在三个地方。第一JWT是无状态的服务端拿到Token只验签名、不查数据库。这带来性能优势但也意味着Token一旦签发在没有过期或撤销机制的情况下服务端对它的控制力几乎为零。你没法像Session那样手动把它拉黑。第二JWT的Header和Payload都是客户端可见的攻击者思路天然比攻击Session要开阔——他不用蒙直接能看到算法、能看到业务字段攻击路径一目了然。第三也是最核心的一点很多开发者对JWT的理解停留在会用库的层面。调用一个verify()函数以为就安全了完全没意识到这个函数验证的算法是攻击者可控的输入也没意识到哈希签名和公钥验签这俩场景根本是两套安全模型。我后面讲算法混淆攻击就是踩在这种理解偏差上的。2. 最常见的几种JWT漏洞与攻击手法拆解这一节是全文的核心我按攻击手法的类型逐个拆。每一种都先说清楚原理再讲攻击套路最后说防御方向。2.1 算法混淆攻击从alg:none到RS256降级HS256算法混淆是JWT最有代表性的漏洞类型攻击核心是操纵Header里的alg字段让服务端用攻击者希望的方式去验签。先说最简单的alg:none。这是一种古老的攻击方式把Header改成{alg:none}签名那段直接留空很多较老版本的JWT库在收到alg为none的Token时会跳过签名验证直接把Payload当作可信数据。我几年前测一个内部管理系统时就碰到过把payload里的is_admin改成true去掉签名一个普通用户就变成了管理员。现在主流库基本都堵住了这个洞但如果你用的是自研校验逻辑或者代码里依赖的是非常老的版本仍然有中招的可能。而且老系统迭代慢这种洞常常在角落里躺个好几年。再说更常见、也更难防的RS256降级HS256攻击。场景是这样的服务端签发Token时用的是RS256RSA非对称签名私钥签名、公钥验签但验证函数同时支持HS256HMAC对称签名用同一个密钥签名和验签或者没有显式指定允许的算法列表。攻击者把Header里的算法改成HS256把Payload改成伪造的数据然后用什么密钥给这段数据算HMAC签名呢用的是服务端的RSA公钥。这个攻击的精妙之处在于RSA公钥在客户端是可以拿到的很多服务端还会把公钥放在/.well-known/jwks.json这类公开端点供第三方使用。当服务端用HS256去验证时它直接用密钥去计算HMAC和Token里的签名进行比对而这个密钥在攻击者手里就是公钥字符串。结果就是攻击者用公钥当HMAC密钥签出来的Token服务端验签通过。我今年在某个项目做安全评审时又见到一次这个场景虽然是个老问题但实实在在还没死透。防御的核心很简单校验alg时做白名单控制只允许RSA或ECDSA的算法并且在验签时指定用公钥不要动态从Header读算法。攻击类型受影响算法攻击前提核心防御alg:none绕过各类JWT库老版本服务端未禁用none显式禁止none升级库版本RS256转HS256降级支持多算法且未做白名单可获取RSA公钥算法白名单固定验签算法多次签名注入部分自研验签逻辑验签只取最后一段严格解析三段结构2.2 弱密钥爆破与服务端配置疏漏如果说算法混淆攻击是打逻辑层的洞那弱密钥爆破就是打密钥管理上的脸。JWT做HS256签名时用的HMAC密钥本质就是一个口令。口令弱不弱直接决定了Token能不能被伪造。如果密钥是123456、secret、test、password这类弱口令攻击者截获一个正常Token后完全可以在本地做离线字典爆破把字典里的每个词当作密钥对Header和Payload重新算HMAC签名跟Token里带的签名比对对上了就说明找到了密钥。这个爆破过程有多容易我拿hashcat测过跑一个8位纯数字的密钥RTX 4090上每秒能跑几十亿次秒级出结果。哪怕密钥稍微复杂点只要在rockyou字典里爆破也只是时间问题。更可笑的是2015年那个知名漏洞就是用了secret当密钥。爆破成功以后攻击者拥有和服务器一样的签名能力可以自己签发任意身份和权限的Token。这比任何绕过都彻底。除弱密钥外还有一类常见疏漏是密钥硬编码在代码或配置文件里然后被提交到公共仓库。我做过一次Github敏感信息扫描的演练用关键词jwt_secret、HS256_KEY之类一搜全公开仓库里能命中一大堆。密钥一旦泄露就算当初设置得再复杂也无济于事。所以密钥管理这一块建议至少做到三件事一是密钥强度要够至少32字节随机值用openssl rand生成二是密钥要走配置中心或环境变量管理绝不入库三是定期轮换尤其在怀疑泄露时能立即作废旧密钥。2.3 敏感信息泄露与Payload明文问题JWT的Payload是Base64Url编码这个编码没有任何保密能力解码工具到处都是。我在测试中常常直接拿Burp的Decoder插件把登录接口返回的JWT解出明文然后翻Payload里有什么可以利用的信息。常见的糟糕案例包括把用户的手机号、身份证号、邮箱、密码哈希、内部员工ID全塞进payload把内部网络路径、服务名等架构信息塞进去更离谱的是把双因子验证的种子值放进去。这些数据在正常情况下是数据库里的敏感字段结果一张Token老底全交出去了。有人可能觉得payload里的数据反正只有能看到Token的人才能解攻击者能看到Token不就已经说明会话不安全了吗这个想法不对。Token在传输链路上会被HTTPS保护但在客户端本地存储、日志打印、浏览器缓存这些环节泄露路径多得很。一旦泄露信息就直接裸奔了。而且JWT的payload是静态的你没法对单个字段单独加密放进去就等于公开。所以实践原则就一句话JWT里只放必要的最小字段比如用户ID、角色、过期时间这种必须随Token校验的数据。一切可查、可算、可延迟获取的信息全部留在服务端数据库里需要的时候再查。2.4 重放攻击与刷新Token的设计缺陷JWT无状态带来一个天然问题它在有效期内可以无限次重放。攻击者只要有一次机会截获Token在Token过期之前都能随意冒充用户身份。很多系统的Token有效期设置得过长我见过有效期设置30天的也见过设置成永不过期的。有效期越长重放攻击的窗口越大风险成倍增长。但有效期设短了用户又需要频繁重新登录体验会很糟糕。这就是为什么要有刷新机制。业界比较成熟的方案是双Token机制一个短生命周期的Access Token用于访问业务接口通常15分钟到2小时另一个长生命周期的Refresh Token用于换取新的Access Token通常7天到30天。Refresh Token只存在服务端可校验的安全端点最好用随机字符串而非JWT格式一旦检测到异常可以实时撤销。我在热词里看到有jwt实现token续签这条说明大家在做项目时确实会遇到这个需求。双Token机制之外还有一个实用的滑动续期思路用户在Token快过期时仍活跃就自动签发一个过期时间更长的新Token不打断用户操作。滑动续期要防一种情况——攻击者拿到一个Token后反复在过期前续期导致会话永不过期。可以在新Token里带一个原Token的iat或jti引用限制最长续期总时长。这个限制实现起来不复杂但能堵上一整类风险。2.5 链路中的其他漏洞点kid注入、jku参数滥用、算法不匹配JWT Header里还有一些可被利用的参数完全取决于服务端实现。kidKey ID是Header里可选的一个键用来告诉服务端应该用哪一把密钥来做验证。如果开发者偷懒直接用kid去拼接密钥文件名比如用fs.readFileSync(keys_dir / kid)攻击者就可以把kid设置成../../etc/passwd或一个可控路径来读文件如果再配合SQL注入去数据库里查特定字符串能把任意可控文件内容当作密钥。更常见的利用是天真的开发者允许从头部位加载密钥攻击者可以把自己的公钥通过jku参数指向一个攻击者控制的地址服务端去拉取这个合法公钥来验签。这类攻击叫JKU注入。这些漏洞的门槛都不高但它们的存在暴露了同一类问题JWT的Header和Payload都是用户可控输入任何从这里面取的参数都应当经过严格校验和过滤绝不能拿来做文件路径拼接、URL加载、SQL查询这类危险操作。3. 攻击链实战复盘从拿Token到成为管理员前面把每种漏洞拆开讲了可能有同学觉得信息有点散。这一节我串一条攻击链路出来模拟一次比较典型的JWT漏洞利用从截获Token到最终提权把每个环节的攻防决策都标注出来。真实性很高这套手法我测过不止一家。3.1 一次完整的攻击链路模拟场景设定某内部管理平台的用户登录后服务端返回一个JWT作为身份凭证前端的每个API请求都带上这个Token。第一步攻击者用Burp抓包在登录接口的响应里看到了返回的Token。把Token丢进JWT解码器里Header显示用的是HS256Payload里有role:user、uid:1002、exp: 1737500000。到这里攻击者已经掌握了Token的算法、字段结构、过期时间以及一个可疑的role字段。这里MitM抓包的前提是目标环境存在某个能让流量经过攻击者的条件但在内网渗透、公共WiFi、恶意路由这些场景下都可能成真。第二步攻击者想知道服务端支持的算法范围于是手动构造一个Header为alg:none的Token把role改成admin直接放行。如果服务端的库还有这个洞攻击就结束了。但大部分经过验证的方案不会中这招于是进入下一步。第三步攻击者把Token带回家用hashcat跑本地爆破。当前Payload里的签名是固定的字典也现成跑了几分钟就匹配上了密钥密钥是mysecret123。这一刻起攻击者已经可以自己签发任意角色的Token。第四步攻击者伪造了一个role:admin、有效期48小时的Token放进Burp的请求里发给管理后台的接口管理员权限直接拿下后面的敏感数据导出、越权操作就不细讲了。回头看这条链密钥弱是第一推动力。3.2 防御方的视角哪些环节本可以拦住复盘这条链路其实每一环都能设防。第一步抓包拿到Token是必然的但如果整个站点强制HTTPS并且正确配置公共WiFi上的被动抓包就拿不到明文Token。同时Token如果不用URL参数传递而是放在HttpOnly的Cookie里被恶意脚本窃取的风险会低很多。第二步服务端如果明确拒绝alg:none并且在库中做了MSIgnoreCase之类的类型判断这个洞就堵住了。更稳妥的是不信任用户提供的算法在白名单里限定必须使用RS256。第三步这是最容易拦住也是最少有人能意识到的一步密钥强度。只要密钥是32字节随机值离线爆破基本是不可能完成的任务。用密码学安全的伪随机数生成器出密钥这是零成本的防御却能直接斩断伪造链。第四步即便攻击者伪造了Token如果服务端在关键操作上做了二次校验比如请求敏感接口时校验role字段对应的权限范围是否在用户数据库中被允许伪造一个role:admin并不可怕因为数据库里根本没这个人。这意味着JWT里的角色字段只能作为缓存使用的便利场景最后一道闸应该留给数据库权限校验。从攻防视角看JWT安全不是一个单独的点它是一整条链路的安全水位。密钥管理、算法白名单、传输加密、服务端二次校验每一条都做了才能把水提起来。4. 防御方案与落地实践整理一套可行性最高的配置反复讲要注意安全没有味道这一节直接给可落地的配置和代码思路照着做就能把前面提到的大部分风险挡在门外。4.1 密钥管理与算法白名单密钥管理是JWT安全的地基这一步做不好其他都是白搭。在Node.js(bibliothek jsonwebtoken库)里建议这样处理const jwt require(jsonwebtoken); // 推荐从环境变量读取密钥不要硬编码在代码中 const secret process.env.JWT_PRIVATE_KEY; function signToken(payload) { return jwt.sign(payload, secret, { algorithm: RS256, expiresIn: 15m, issuer: your-service-name, audience: your-app-client }); } function verifyToken(token) { return jwt.verify(token, publicKey, { algorithms: [RS256], issuer: your-service-name, audience: your-app-client }); }这段代码能同时挡住两个洞algorithms参数指定了只允许RS256就粉碎了RS256转HS256的降级攻击验签用的publicKey和签名用的secret分开非对称算法的隔离性就体现出来了。在Python的PyJWT里同等配置是这样import jwt public_key open(public_key.pem).read() options {verify_exp: True, require: [exp, sub]} try: payload jwt.decode( token, public_key, algorithms[RS256], audienceyour-app-client, optionsoptions ) except jwt.InvalidTokenError as e: # 记录日志返回401 ...关于密钥轮换很多人会被换密钥之后旧Token全部失效这个问题劝退。稳妥做法是引入kid字段和密钥版本表。签发新Token时用最新版本密钥并在Header里带上kid验证时通过kid找到对应的密钥做验签。切换期新旧密钥共存旧Token到期自然退场整个过程对用户无感。实施下来成本不高但多数团队没做。4.2 过期时间与Token续签机制设计过期时间设多长需要平衡安全与体验。内部管理平台我建议Access Token设15分钟C端应用放宽到2小时。同时必须引入Refresh Token机制来做无感续签。前面提过双Token机制这里给一个标准的接口设计参考POST /api/auth/refresh 请求体: { refresh_token: xxxxxx } 校验Refresh Token有效且未过期 生成新的Access Token重新签名重置exp 返回: { access_token: xxxxxx, expires_in: 900 }Refresh Token的存储和校验方式这里有个关键点不要用JWT格式的Refresh Token。最稳的方案是服务端生成一个随机不可预测的字符串token和用户ID、过期时间一并入库每次刷新时查找并校验。检测到Refresh Token被重复使用或者用户主动登出时服务端可以联动物理删除这个记录实现真正的吊销。这是JWT本身做不到的基于纯状态能力。滑动续期这个方案也可以用但我在生产环境里被它坑过一次。原来想着只要用户在操作就续期结果测试时发现一些无头脚本会自动刷新Token导致永远不过期等于会话变成永久的。后来加了一条限制每次续期时读取旧Token的iat新Token的exp不超过旧Token的iat加最长会话时长比如12小时。这样既保住了用户连续操作的体验又给整个会话画了一条绝对的生命线。4.3 日志、监控与接口层的防线JWT攻击有一个特点密码学上的突破很难被察觉但业务行为异常往往非常明显。一个普通用户角色突然请求管理员接口、一个Token在几秒内从不同地区登录、某种接口的失败率从1%飙到50%这些都是很好的告警信号。我建议至少做三件事。第一在网关层统一解析JWT并注入到请求上下文业务代码不要各自解析、各自验证减少漏验和验法不一致的情况。第二记录每个Token的jti在Redis里做短时缓存虽然不完全阻止重放但能快速识别出同一个Token在极短时间内并发使用这种异常。第三对特权接口做双层校验JWT只是第一道门进去之后检查用户数据库实际权限是第二道门。关于日志这里有一个非常容易踩的坑把JWT打印到日志系统的明文字段里。我们曾经排查一个问题时把整个请求体打进了日志Authorization头也被顺带打了进去。日志平台若权限配置不当或者被拖库大批量有效Token直接泄露。后来统一改为只记录Token的jti、最后四字符和过期时间够排障用泄露面大幅缩小。4.4 传输层与存储层的正确姿势Token在传输过程中必须是HTTPS加密的这是底线。但同一个Token如果在多个地方传递、存储其他环节的暴露面也要收敛。存储位置的选择上最常见的选择是localStorage从写法上最方便但它是XSS攻击的重灾区。任何一处脚本注入攻击者都能用localStorage.getItem(token)毫秒级别拿走上线会话。相对更稳妥的做法是把Token放在HttpOnly的Cookie里这样恶意脚本无法通过document.cookie读取。代价是增加了CSRF攻击的暴露面需要配合SameSite属性以及CSRF Token来缓解。一个折中方案是用常规内存变量存储Access TokenRefresh Token放HttpOnly Cookie。页面刷新后Access Token丢失重新用Cookie中的Refresh Token换取新Token。这个方案的麻烦在于需要额外处理并发刷新但安全性确实要高一个量级。5. 常见问题排查与避坑记录最后这一节专治生产环境中最常见的JWT问题。我在各种群里回答过大量的同类提问把典型问题整理成速查方便大家根据自己的情况定位。5.1 生产环境高频事故复盘多个服务密钥不一致导致签名验证失败一个平台拆了多个微服务服务A签的Token在服务B验签总是失败。查了半天发现服务A用的是JWT_SECRET_APP环境变量服务B读的是JWT_SECRET。两边密钥不同签名自然对不上。这个问题排查周期通常非常长因为日志里只报signature mismatch不告诉你两边用了什么参数。建议的排查姿势是做一个内部调试接口把这个环境变量名的值和密钥的Hash脱敏输出两相对比就能定位。自研验签逻辑只校验了Payload有人觉得我只要看看这个Token是不是我签发的就行于是自己写了一段校验只解码Payload检查用户ID存在不看签名。结果就是攻击者随便改几个字段都能生效。这不是JWT的问题是完全绕过了签名。自研JWT解析一定要用官方或社区维护的库不要自己Base64解完就信了。Token放入请求头时带上了Bearer以外的字符串前端把Token用Authorization: token xxxx给服务端服务端却按照Bearer xxxx的格式去切分结果验签的对象包含了多余前缀签名总是不对。这类低级但高频的问题往往是联调期间最常见的返工点。建议在前端或网关统一构造标准请求头服务端预留兼容分支。但注意兼容分支是临时方案排齐之后要移除不然又是一个潜在攻击面。JWT过期时间设置成了操作系统时间戳我见过一个同学的exp字段直接取Date.now()他是用毫秒级时间戳去对比秒级时间戳Token永远提前3个数量级过期用户每几秒就掉线一次。统一使用标准exp单位秒并且在库或SDK层面开启动态校验基本不会踩这个坑。5.2 我在实际项目里重点盯的几个检查项整理一下我通常在安全评审时逐条过的问题清单直接可用JWT库版本是否是最新是否有已披露漏洞签名算法是否显式限定白名单是否关闭了alg:none密钥强度是否达到32字节随机值是否通过配置中心管理Payload里有没有不必要的敏感字段Access Token过期时间是否过长是否有Refresh Token机制验签时是否校验了exp、iat签发时间、iss签发者、aud受众Token在客户端是存储在localStorage还是HttpOnly Cookie是否全程使用HTTPS日志里是否打印了完整JWT特权接口是否有数据库级的权限二次校验kid/jku等Header参数是否做了过滤和控制如果一个系统里这十一个问题大部分答案都是正向的那JWT这一块的攻击面基本已经收得很紧了。如果有一半以上答案是模糊甚至反向的那这套系统在JWT这边就是赤手空拳得赶紧补。5.3 WebGoat靶场里的JWT练习上手实践的建议理论讲再多都不如自己动手测一遍印象深。OWASP的WebGoat项目里专门有一个JWT漏洞专题覆盖了算法混淆、密钥爆破、签名绕过、Kid注入等最常见的攻击场景。整个练习就像闯关游戏每一关卡会提示你当前服务器的JWT实现存在什么问题你需要手工构造对应的恶意Token去通过校验。做这个练习时我建议全程用Burp Suite配合自带的Decoder做Base64编码转换再动手写点Python脚本辅助爆破和签名构造。做完整个专题你会对JWT各类漏洞的手感有非常直观的认知比看十篇文档都有用。我带的团队里新来的安全工程师我都建议他先花两天去过一遍WebGoat的JWT模块后面做真实项目时上手快得多。最后说点个人体会和JWT打了这么多年交道踩过别人的坑也踩过自己的坑我的真实感受是JWT不是一个用了就安全的方案也不是一个迟早有洞的方案它是一个需要你持续投入正确姿势去守护的方案。它的症结不在标准本身而在使用者有没有把关键的细节都放在心上。如果只让我留一条给人印象最深的建议那就是永远不要把客户端传入的任何数值当成可信数据包括Header里的算法、Payload里的角色、request参数里的ID。这句话在JWT场景里尤其成立——因为Token的可信度全部来自Server端的签名与校验策略一旦策略出现偏差剩下的就是裸奔。保持对细节的敬畏比追求花哨的架构更管用。