TT Lab
开始
学习 学习路径 课程

会话与令牌 — 从浏览器到服务网格

JWT 签名验证与 alg 混淆、kid 注入防御

在 TT Lab 中继续学习

目标

用 cryptography 生成的 RS256 密钥对和 HS256 密钥,在 127.0.0.1:8305 上搭建自己的 JWT 验证器,亲手构造 alg:none、alg 混淆、kid 注入的伪造令牌并发过去,确认防御能否用状态码拦住它们。手写验证和 PyJWT 验证都要检查。

为什么重要

JWT 的令牌自己带着证据行走,不必向服务器查询,只验证签名即可。这种便利的代价是,只要验证写得稍微松一点,验证器就会对令牌头部的 alg 和 kid 全盘相信,让伪造品通过。alg:none 是允许无签名的令牌;alg 混淆是把 RS256 公钥误用为 HS256 密钥,仅凭公开的密钥就伪造签名;kid 注入则是由攻击者指定用来验证的密钥本身。RFC 8725(JWT BCP)的防御可以概括为三点——设置允许算法的白名单,把算法固定在密钥类型而不是头部上,不把 kid 解释为文件路径或 URL。再加上对 exp/nbf/iss/aud 的验证。本实验让你亲自证明这些规则在真实的伪造请求中是否得到遵守。

步骤

  1. 在 127.0.0.1:8305 上启动 /root/st/jwt/app.py,并在 /root/st/jwt/self.out 中留下记录:自己签发的 RS256 令牌返回 200,并且用 PyJWT 也能通过。
  2. 构造 alg:none 令牌并发过去,确认返回 401,写入 /root/st/jwt/none.out。
  3. 把 rs-1 公钥误用为 HS256 密钥的混淆令牌返回 401,合法的 hs-1 HS256 令牌返回 200,把结果写入 /root/st/jwt/confusion.out。
  4. rs-1 令牌返回 200,只替换头部的 kid 则返回 401,不存在的 kid 也返回 401,把结果写入 /root/st/jwt/kid.out。
  5. 确认 JWKS 中同时有 rs-1 和 rs-2,且两个 kid 的令牌都返回 200,把结果写入 /root/st/jwt/rotation.out。
  6. 确认把 kid 设为文件路径的注入令牌返回 401,把结果写入 /root/st/jwt/inject.out。
  7. 确认 exp/nbf/iss/aud 各自不符的令牌返回 401,只有正常令牌返回 200,把结果写入 /root/st/jwt/claims.out。
  8. 用 /root/st/jwt/e2e.sh 一次性发送 valid、none、confusion、badkid,在 /root/st/jwt/e2e.out 中留下四行。

参考

启动验证器,验证自己签发的 RS256 令牌

在 127.0.0.1:8305 上启动 /root/st/jwt/app.py。把通过 POST /sign 拿到的自己签发的 RS256 令牌发给 POST /verify,返回 200,并在 /root/st/jwt/self.out 中以 verify_code=200、pyjwt=valid ... 的形式留下 /root/st/jwt/verify_both.py 用 PyJWT 也能通过的结果。

密钥在服务器启动时用 cryptography 生成并保存为文件。把服务器放到后台运行,并轮询到它就绪为止。PyJWT 必须向 jwt.decode 传入 algorithms=["RS256"]。

拒绝 alg:none 令牌

构造 alg 为 none 且签名为空的令牌,发给 POST /verify,确认它被 401 拒绝,按 none_code=401 写入 /root/st/jwt/none.out。

这就是 RFC 7519 §6 的 Unsecured JWS。防御只需要一份允许 alg 的白名单——none 不在列表里,在看签名之前就被拦住了。

拒绝 alg 混淆(把 RS 公钥当作 HS 密钥)

以 rs-1 公钥(用 JWKS 的 n、e 还原出的 SPKI PEM)作为 HS256 密钥签名的令牌被 401 拒绝,合法的 hs-1 HS256 令牌通过并返回 200,按 confusion_code=401、legit_hs256_code=200 写入 /root/st/jwt/confusion.out。

防止混淆的不是禁止 HS256,而是把 alg 固定在密钥类型上。rs-1 是 RSA 密钥,只能用于 RS256,所以头部要求 HS256 时,在看签名之前就被拒绝。

用 kid 选择密钥,拒绝不存在的 kid

用 rs-1 签名的令牌返回 200,把该令牌头部的 kid 只替换成 rs-2(签名不变)则返回 401,不存在的 kid(rs-9)也返回 401,按 good_kid_code=200、wrong_kid_code=401、unknown_kid_code=401 写入 /root/st/jwt/kid.out。

kid 是挑选用哪把密钥验证的旋钮。只改头部的 kid,实际的签名仍是 rs-1 密钥的,所以用 rs-2 验证会失败。

JWKS 密钥轮换

JWKS 中同时有 rs-1 和 rs-2(jwks_kids=rs-1,rs-2),用新 kid(rs-2)签名的令牌和旧 kid(rs-1)的令牌都返回 200 并通过,按 jwks_kids=rs-1,rs-2、new_kid_code=200、old_kid_code=200 写入 /root/st/jwt/rotation.out。

轮换期间,用旧密钥签名的令牌仍在流通。在 JWKS 里同时放两把密钥,用 kid 挑选验证——如果太早删除旧密钥,有效令牌就会被一下子全部拒绝。

拒绝 kid 路径与 URL 注入

把 kid 设为文件路径(/root/st/jwt/evil_pub.pem),发送用种在该路径上的密钥签名的令牌,确认它被 401 拒绝,按 badkid_code=401 写入 /root/st/jwt/inject.out。

kid 可以是任何字符串(RFC 7515 §4.1.4)。如果验证器用 kid 打开文件或拉取 URL,攻击者就能指定用来验证的密钥。kid 只能作为我所知道的密钥列表的查找键。

验证 exp/nbf/iss/aud

签名全都正常,只有声明不符的令牌各自返回 401,只有正常令牌返回 200,按 valid_code=200、expired_code=401、notyet_code=401、badiss_code=401、badaud_code=401 写入 /root/st/jwt/claims.out。

签名正确也没有结束。exp 已过、nbf 在未来、iss 不是我所知道的签发方,或 aud 不是我的名字,都要拒绝。用于时钟偏差的 leeway 控制在几分钟以内。

综合:valid、none、confusion、badkid

用 /root/st/jwt/e2e.sh 一次发送四种令牌,在 /root/st/jwt/e2e.out 中留下 valid=200、none=401、confusion=401、badkid=401。

把前面各步用一个脚本串起来。只有正常令牌通过,三种伪造令牌都必须返回 401。