JWT 安全攻防
广州飞翔科技(learnto.cn)技术团队内部教程 | 作者:白歌(架构师)审核:大翔(CEO/CTO)
JWT 的简洁性让开发者容易低估其安全风险。本文系统梳理 JWT 的常见攻击手法与防御策略,并穿插白歌对飞翔科技系统的安全审计实录。
"alg: none" 攻击:算法剥离
攻击原理
JWT 头部中的 alg 声明告诉验证方该用什么算法验签。某些早期库直接根据这个声明选择验证算法。攻击者把 alg 改成 none,并删除签名部分:
// 原始令牌头部
{ "alg": "HS256", "typ": "JWT" }
// 攻击者篡改后
{ "alg": "none", "typ": "JWT" }
编码后令牌变成 header.payload.(注意最后没有签名)。如果库看到 alg: none 就直接跳过验证,攻击者就能随意篡改 Payload 中的权限声明,实现 权限提升 (Privilege Escalation)。
飞翔科技审计实录
白歌在审计乐途旧版管理后台时发现,一个内部工具用的 Node.js 库允许
alg: none:// 危险代码示例(已修复) const decoded = jwt.verify(token, secret); // 库自动读取 alg 字段白歌立即在飞书群里 @ 小崔:"这个库版本有 CVE,升级到新版本,并且咱们所有服务必须显式传算法参数。"
// 安全写法 const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
RS256 公钥作为 HS256 密钥:算法混淆
攻击原理
许多库的验证 API 长这样:
function jwtDecode(token, secretOrPublicKey) { ... }
攻击者拿到一个 RS256 签名的 JWT 和对应的 公钥 (公钥本来就是公开的),然后:
- 把头部
alg从RS256改成HS256 - 用公钥字符串作为 HS256 的共享密钥重新签名
- 发给服务端
服务端看到 alg: HS256,就把第二个参数当共享密钥用,结果公钥字符串恰好能验过攻击者伪造的 HMAC 签名!
防御措施
- 显式算法 :调用验证函数时强制指定
algorithms: ['RS256'] - 密钥分离 :RS256 的公钥和 HS256 的共享密钥走不同的配置通道,绝不混用同一个参数
弱 HMAC 密钥:暴力破解
为什么密码不能当密钥?
HMAC 共享密钥(Shared Secret)针对速度优化,这意味着暴力破解(Brute-force Attack)比破解 bcrypt 密码哈希快得多。规范规定:
HS256 的密钥长度 至少 256 位 (32 字节),即最少 32 个 ASCII 字符。
飞翔科技审计实录
白歌用密钥扫描工具检查乐途各服务的 JWT 配置,发现测试环境有个服务用了
secret当密钥:// 危险!只有6个字符 const SECRET = 'secret';白歌在审计报告里标红:"这个密钥用普通笔记本 2 小时就能暴力破解。生产环境必须用密码学安全的随机生成器(CSPRNG, Cryptographically Secure Pseudo-Random Number Generator)生成至少 256 位密钥。"
李眉(运维)随后把密钥管理迁移到 HashiCorp Vault,由 Vault 自动生成 512 位随机密钥。
错误的加密+签名假设
加密不等于防篡改
很多开发者误以为"数据加密了,攻击者看不懂,也就改不了"。这是致命错误。某些加密算法(如 CBC 模式)在密文被篡改后仍会输出"看似合理"的明文,攻击者可以通过精心修改密文位来翻转解密后的布尔值。
嵌套 JWT 的验证陷阱
规范支持 嵌套 JWT (Nested JWT):外层加密、内层签名。常见错误是只解密了外层,看到内层"像 JWT"就直接用,跳过内层签名验证。
飞翔科技场景 :飞翔科技向第三方合作伙伴发送嵌套 JWT,外层用合作伙伴公钥加密,内层用乐途私钥签名。白歌在对接文档里用加粗红字强调:"必须验证内层签名! 只解密不验签等于把伪造数据当圣旨。"
无效椭圆曲线攻击
输入验证缺失的后果
椭圆曲线密码学(ECC)的所有运算都必须在曲线上的有效点进行。如果库没有验证公钥是否是有效曲线点,攻击者可以构造特殊输入,让标量乘法产生异常结果,最终 泄露私钥 。
防御
- 使用经过实战检验的库(如 Node.js 的
jsonwebtoken、Java 的jjwt) - 确保库在验签前调用
isValidPoint()之类的检查 - 只使用标准曲线:P-256、P-384、P-521
替换攻击:令牌张冠李戴
不同接收者攻击
攻击者把发给 API A 的令牌拿去调用 API B。如果两个服务共享同一套密钥且只验签名不验受众,攻击者就能在 B 系统冒用 A 系统的权限。
防御 :每个令牌必须包含 aud(受众,Audience)声明,服务端严格校验精确匹配。
跨 JWT 攻击(相同接收者)
飞翔科技有两个服务:user-database 和 item-database。item-database 团队升级时偷懒,把 aud 验证写成"包含 cool-company 即可",而不是精确匹配 cool-company/item-database。结果发给 user-database 的令牌也能操作 item-database!
白歌在代码评审时抓到这个漏洞,在 GitLab 上留了评论:"
aud验证必须是全等比较(===),不能用includes()。这是跨 JWT 攻击的经典入口。"
缓解措施与最佳实践(8 条)
基于业界 JWT 最佳实践,白歌为飞翔科技制定了以下安全基线:
始终执行算法验证
绝不依赖 JWT 头部中的 alg 声明选择验证算法。代码里写死允许的算法白名单。
// 正确
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// 错误
jwt.verify(token, publicKey); // 让库自己决定
使用适当的算法
- 单体内部服务:HS256
- 联邦身份 / 第三方接入:RS256 / ES256
- 拒绝接受不安全的算法(如
none)
始终执行所有验证
嵌套 JWT 必须逐层验证。外层解密后,内层签名必须再验一次。
始终验证加密输入
椭圆曲线公钥必须是有效曲线点;RSA 模数必须满足长度和格式要求。
选择强密钥
- HMAC 密钥 ≥ 256 位,且完全随机
- RSA 密钥 ≥ 2048 位
- ECC 使用 P-256 及以上标准曲线
- 密钥由 CSPRNG 或硬件随机数生成器(HSM)生成
验证所有可能的声明
| 声明 | 含义 | 验证要求 |
|---|---|---|
exp | 过期时间(Expiration Time) | 必须存在,必须未过期 |
iat | 签发时间(Issued At) | 不能是未来时间 |
nbf | 生效时间(Not Before) | 必须已生效 |
iss | 签发者(Issuer) | 必须来自信任的签发者 |
aud | 受众(Audience) | 必须精确匹配本服务 |
sub | 主题(Subject) | 必须存在且合法 |
使用 typ 声明区分令牌类型
如果系统同时处理 Access Token、Refresh Token、ID Token,用 typ 或自定义声明区分,防止一种令牌被当另一种用。
为每种令牌使用不同的验证规则
- 不同子系统使用不同的密钥对签名
iss声明精确到子系统 URL,而非笼统的公司名- 避免"一把私钥签所有令牌"
结语
JWT 的安全问题大多出在 实现层面 ,而非规范本身。白歌在飞翔科技的安全审计中发现的漏洞,无一例外都是"图省事"或"想当然"造成的:依赖 alg 声明、密钥太短、aud 用模糊匹配、嵌套 JWT 只解密不验签……
记住两条铁律:
- 不要自己实现加密算法(Don't roll your own crypto)
- 验证一切可以验证的(Validate everything that can be validated)
JWT 是把好刀,但刀口朝向自己还是朝向敌人,取决于使用它的人是否懂行。