别让令牌告诉你怎么验证它
一句话总结
JWT 是令牌自己带着证据行走。不需要向服务器查询,只要验证签名即可,代价是只要验证稍微写得松一点,验证器就会按令牌自己吩咐的“该如何验证我”去做,从而让伪造品通过。这种松懈就是 alg 混淆 和 kid 注入。
为什么需要它
一个 JWT 是由两个点分成的三段——头部、载荷(声明)、签名。头部里有 alg(签名算法),通常还有 kid(密钥标识符)。验证器读取头部的 alg,心想“哦,是 RS256”,就用那种方式验证签名。问题在于,这个 alg 和这个 kid 都是尚未经过验证、由攻击者随意写的值。
由此产生了两种经典攻击。第一,RFC 7519 §6 在规范上允许 alg 值为 "none" 的无签名令牌(Unsecured JWS)。如果验证器对头部的 alg 照单全收,攻击者就会把想要的声明放进 alg:none,把签名段留空扔过去——验证器想“none 就没有要验证的签名”,于是放行。第二种是 alg 混淆。借用 RFC 8725 的原话——“'RS256' parameters can be altered to 'HS256', leading libraries to validate using the RSA public key as an HMAC secret.” RS256 是非对称的,用公钥验证,而公钥谁都知道(通过 JWKS 公开)。攻击者把头部的 alg 改成 HS256,并把公钥字符串当作 HMAC 密钥自己签名。如果验证器相信头部的 alg,想“HS256 就用那把密钥做 HMAC 验证”,而那把密钥正是公钥,验证就通过了。仅凭公开的密钥就伪造出了有效签名。
工作原理
防御的核心是 RFC 8725 §3.1 的一句话——“Libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms.” 以及“each key MUST be used with exactly one algorithm, and this MUST be checked.”
把它落实到代码里,规则有三条。
허용 alg 화이트리스트 검증기가 받아들일 alg 를 코드에 고정한다({RS256, HS256}).
none 은 목록에 없으니 자동으로 거절된다.
alg 를 키에 고정 헤더의 alg 를 믿지 않는다. kid 로 키를 찾고, 그 키의
타입(RSA→RS256, oct→HS256)이 정한 alg 로만 검증한다.
rs-1 은 RSA 키라 RS256 으로만 쓴다 — HS256 을 요구하면
서명을 보기도 전에 거절한다. 이것이 혼동을 막는다.
kid 는 조회 키일 뿐 kid 를 파일 경로나 URL 로 해석하지 않는다. 고정된 키
목록의 딕셔너리 키로만 쓰고, 없는 kid 는 거절한다.
为什么要对 kid 这么小心?RFC 7515 §4.1.4 说“The structure of the 'kid' value is unspecified”——意思是任何字符串都可以出现。如果验证器拿到 kid 后用 open(kid) 读取密钥文件,或用 fetch(kid) 拉取 URL,攻击者就会在 kid 里填上自己的密钥文件路径或自己服务器的 URL,自己指定用来验证的密钥本身。RFC 8725 §3.10 对这种路径注入(以及通过 jku、x5u 发起的 SSRF)提出了警告。kid 只能是在我所知道的密钥中挑选其一的旋钮。
验证完签名并没有结束。还要看 RFC 7519 中的声明——exp(此时间之后拒绝)、nbf(此时间之前拒绝)、iss(签发方是不是我知道的地方),尤其是 aud。RFC 明确写道“If the principal processing the claim does not identify itself with a value in the 'aud' claim when this claim is present, then the JWT MUST be rejected”。因为我的 API 不能接受为其他 API 签发的令牌。为了应对时钟偏差,只留出几分钟以内的余量(leeway)。
密钥有多把的原因,以及 kid 的正当用途,见 RFC 7517 §4.5——“to choose among a set of keys within a JWK Set during key rollover.” 更换签名密钥时,用旧密钥签名的令牌仍在流通,所以在 JWKS 中同时放旧密钥和新密钥,并用 kid 来挑选验证。如果太早删除旧密钥,仍然有效的令牌就会被一下子全部拒绝。
在现场相遇的样子
alg 混淆出自库中的一行。旧的 API 如果像 jwt.decode(token, key) 这样不指定 algorithms 就调用,库会直接接受头部的 alg,从而被混淆攻击攻破。所以如今的库把 algorithms=["RS256"] 改成了必填参数。在本实验中,我们通过亲手编写验证器,来亲身体会这一行为什么是必需的。
kid 注入即使查看日志,也像是正常验证。日志里打出“验证通过,用户 admin”,实际上却是用攻击者种下的密钥造出的令牌。验证器拿 kid 做了什么,只有打开代码才会暴露。
下一项实验要做什么
用 cryptography 生成 RS256 密钥对和 HS256 密钥,在 8305 上搭建自己的 JWT 验证器。查看自己签发的令牌能否同时通过手写验证和 PyJWT 验证,然后亲手构造 alg:none、混淆、错误 kid 的令牌并发过去,确认它们都以 401 被拒绝。一并证明用 JWKS 放多把密钥并以 kid 挑选、密钥轮换,以及 exp/nbf/iss/aud 验证。