CVE-2026-102268:PyJWT 非对称 PEM 检测绕过——变异公钥如何作为 HMAC 密钥通过验证

一句话:PyJWT ≤ 2.13.0 用一个严格结构正则来判断"这段字节是不是非对称公钥",而底层 cryptography 库的 PEM 加载器接受比它宽松得多的格式。两者接受范围的差集,使公开公钥可以被当作 HMAC 对称密钥使用——只要验证端的算法允许列表混用了 HMAC 与非对称算法,知道公钥的任何人就能伪造通过验证的令牌。

一台运行 PyJWT 2.13.0 的演示服务在同一秒内处理了五条验证请求。第一条是服务自己签发的合法 ES256 令牌,验证通过,sub=legitimate_user role=user。中间三条都声明 alg=HS256,命中同一个端点:第二条、第四条被拒绝,InvalidSignatureError;第三条,签名它的人从来没有接触过任何私密密钥——它通过了,sub=attacker role=superadmin。第五条把刚刚被接受的伪造令牌递给只允许 ES256 的端点,InvalidAlgorithmError,请求在密钥检查之前即被拒绝。

五条请求,同一把验证密钥。决定通过还是拒绝的条件差异,就是本文要讲清楚的事。

1. 漏洞基本信息

1.1 标识与一句话概括

项目

内容

CVE 编号

CVE-2026-102268

GHSA 编号

GHSA-ffc3-869f-jxw9

一句话标题

PyJWT 非对称 PEM 检测绕过:空白/换行变异的公钥绕过 HS/非对称算法混淆防护,可在混合算法允许列表下伪造 HS256 令牌

CWE 分类

CWE-347(加密签名验证不当,Improper Verification of Cryptographic Signature)

漏洞家族

JWT 算法混淆(algorithm confusion),是对前序漏洞引入防护的不完整实现绕过

1.2 影响版本区间

  • 受影响:PyJWT ≤ 2.13.0(< 2.14.0);修复版本 2.14.0,2026-09-11 发布至 PyPI。
  • NVD CPE:cpe:2.3:a:pyjwt_project:pyjwt:*:*:*:*:*:*:*:*(versionEndExcluding=2.14.0),与 CNA(GitHub)记录一致。

版本命中 ≠ 可被利用。可利用部署须同时满足四项前置条件:

  1. 算法允许列表混用 HMAC 与非对称算法,如 algorithms=["ES256", "HS256"](RFC 8725 §2.1/§3.1 明令防范的反模式);
  2. 验证密钥以原始 PEM 文本/字节传入(非 PyJWK/PyJWKClient),且为 cryptography 加载器接受、PyJWT ≤ 2.13.0 检测正则不识别的"变异"形态——END 标记前空白/缩进、仅 CR 行尾、折叠为单行;可自然产生于带缩进的 YAML/JSON 配置块、单行环境变量/JSON 字符串,或配置工具链的 LF→CR 误转换;
  3. 已安装 cryptography(可选依赖,[crypto] extra,非对称验证与 PEM 解析都依赖它);
  4. 攻击者知晓公钥(公钥本就公开)。

前三项是部署属性,攻击者运行时无法凭空制造;大量"版本命中但配置规范"的部署因第 1、2 项实际不可达,9.1 分的真实风险面要放在这个背景下看。

官方确认不受影响的路径:

  • PyJWK/PyJWKClient:密钥构造时绑定单一算法,变异 PEM 进不了这条链路;
  • 单一算法允许列表(不含 HMAC 族,如仅 ["ES256"]):伪造的 HS256 令牌在允许列表检查处即以 InvalidAlgorithmError 被拒绝;
  • 注意 enforce_minimum_key_length(默认关闭)不构成缓解:门槛是 HMAC 摘要长度(HS256 为 32 字节),公钥 PEM 远超此值——只看长度不看内容,开不开都拦不住。

1.3 严重程度

  • CVSS 3.1:9.1 CRITICAL,向量 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N;CVSS 4.0 同为 9.1。
  • AV:N / PR:N / UI:N / S:U:网络可达,无需特权、无需用户交互,伪造令牌直接递给验证端点,越权发生在同一安全域内。
  • AC:L 的前提是四项前置条件已满足:攻击本身一次签名一次请求即成,形态未知也只需在线枚举寥寥几次——"找到可利用部署"的难度被转移到了前置条件上。
  • C:H / I:H:可伪造任意声明(任意 sub/role/过期时间),即通用伪造;A:N:不影响可用性。
  • 生态信号:CISA ADP Vulnrichment(2026-09-29)SSVC Exploitation="poc"、Automatable="yes"、Technical Impact="total";EPSS 约 0.2%(2026-09-30);无在野利用证据,未列入 CISA KEV(KEV 官方源对自动化抓取返回 403,该判断取自第三方多源旁证)。

1.4 披露时间线

协同披露,修复先于公开:

时间

事件

(报告时间未在通告正文公开)

研究者 e1024x 向维护者报告,GHSA 通告内含完整 PoC 与复现输出

不晚于 2026-09-10

维护者确认并独立复现(复现基线为 2.13.0)

2026-09-10

修复 commit 8b4e233a 合入("fix: reject loader-accepted PEM variants")

2026-09-11

PyJWT 2.14.0 发布至 PyPI;GHSA-ffc3-869f-jxw9 随发布公开

2026-09-28

CVE-2026-102268 记录公开(NVD 记录发布于 21:17 UTC)

2026-09-29

CISA ADP 完成 Vulnrichment 丰富化(SSVC 时间戳 13:59 UTC)

同批 2.14.0 还包含另外三个安全公告,变更日志以单条 Security 记录收录:"Harden HMAC key validation against public-key material supplied as JWK, JWKS, array, encoded, BOM-prefixed, DER, or PEM input"。暂无法升级时,建议同批公告一并纳入评估。

1.5 参考资料

类型

来源

属性

官方通告(CNA)

GHSA-ffc3-869f-jxw9

外部

CVE 记录

CVE.org CVE-2026-102268、NVD

外部

修复 commit

8b4e233a22206b34ec1186e912e75c0b2396ac07

外部

发行说明

PyJWT 2.14.0 Release、PyPI

外部

规范背景

RFC 7468(PEM 文本编码)、RFC 8725(JWT 最佳现行实践)

外部

生态信号

EPSS API、CISA ADP Vulnrichment(附于 NVD 记录页)

外部

第三方收录

Tenable Nessus 351353、VulDB 411185、Snyk SNYK-PYTHON-PYJWT-20249112

外部

源码走查、Docker 复现环境、PoC 与四份证据日志、正则推演与调用链分析

本文

原创

2. 环境搭建

四项前置条件已经数清,接下来动手:用最少的步骤在本地搭起受影响环境与修复对照环境,顺带交代分析时用什么工具读代码。

2.1 环境要求

  • Docker 与 Docker Compose(任何近年版本均可);
  • 一个能联网拉取镜像的环境;
  • 可选:Python 3.12 与 Git,用于本地阅读 PyJWT 2.13.0 源码。

复现项目结构如下(与本文同仓库):

reproduce/
├── docker-compose.yml   # 两套服务:vuln-app(2.13.0)/ fixed-app(2.14.0)
├── Dockerfile           # 官方 python:3.12-slim 基础镜像 + 钉版本安装 PyJWT,cryptography 不钉
├── verify.sh            # 一键复现:构建→启动→双向 PoC→留存证据
├── app/server.py        # 演示服务:满足全部四项前置条件(1–4)的部署
├── poc/poc.py           # PoC:攻击 + 三组对照,自带结论判定与退出码
├── README.md            # 复现环境说明(内容与本部分重合)
└── evidence/            # verify.sh 生成的证据日志(4 个文件)

2.2 两套并行环境的设计

PyJWT 没有官方 Docker 镜像,复现环境便基于 Docker Hub 官方 python:3.12-slim,通过 build arg 钉住 PyJWT 版本,让同一份 Dockerfile 构建出受影响与修复两个镜像:

服务

PyJWT

端口(仅绑 127.0.0.1)

角色

vuln-app

2.13.0 + cryptography 50.0.2

http://127.0.0.1:18080

复现漏洞

fixed-app

2.14.0 + cryptography 50.0.2

http://127.0.0.1:18081

修复对照

Dockerfile 的关键只有一行:pip install --no-cache-dir "PyJWT==${PYJWT_VERSION}" cryptography。PyJWT 由 build arg 钉死;cryptography 不钉版本,装的是构建时的最新版,上表与复现记录的实测值为 50.0.2。密钥对每次启动时在容器内随机生成,无持久化敏感文件;端口只绑回环地址,复现不会把一个"故意可被攻击的服务"暴露到局域网。

启动:

cd reproduce
./verify.sh        # 一键:构建、启动、轮询 /version 等就绪、两环境各跑一次 PoC,证据写入 evidence/

也可以一步步手动来:

docker compose build && docker compose up -d
curl -s http://127.0.0.1:18080/version     # 确认 {"pyjwt": "2.13.0", ...}
docker compose run --rm -T vuln-app  python /poc/poc.py http://vuln-app:8080
docker compose run --rm -T fixed-app python /poc/poc.py http://fixed-app:8080
docker compose logs vuln-app               # 服务端审计日志
docker compose down                        # 用完停止

2.3 演示服务模拟了什么

app/server.py 刻意构造了一个满足全部四项前置条件(1–4)的部署,五个端点分工如下(根路径 / 另返回一份服务自述 JSON,不算业务端点):

端点

行为

/version

报告运行时库版本

/pubkey

公开 ES256 公钥(规范 PEM 形态)

/token

签发合法 ES256 令牌(role=user)

/protected

jwt.decode(..., algorithms=["ES256", "HS256"])——混合允许列表,漏洞入口

/protected-strict

jwt.decode(..., algorithms=["ES256"])——单一允许列表,缓解对照

其中最关键的一行在 server.py:45:

VERIFY_KEY = PUBLIC_PEM.replace(b"\n", b"\r")

它模拟的是配置管道把公钥的 LF 换行替换成裸 CR——poc.py 三种变异家族中的 bare-CR-line-endings,用来满足前置条件 2 对"变异形态"的要求。服务启动时,这段字节的 %r 形式、连同 is_pem_format() 对它的判定,会直接打进日志;复现时你会在 evidence/server-vulnerable.log 第 9 行看到这样一行:jwt.utils.is_pem_format(VERIFY_KEY)=False -> HMAC guard BYPASSED, HMACAlgorithm.prepare_key returns these public-key bytes as a symmetric secret。

2.4 代码阅读环境

这是纯 Python 逻辑漏洞,不需要调试器与符号配置。分析用到的工具链很短:PyJWT 2.13.0 官方 tag 源码一份(git clone --branch 2.13.0 --depth 1 https://github.com/jpadilla/pyjwt.git),修复对照看随 2.14.0 发布的修复 commit 8b4e233a 的 diff;编辑器随意,配上 grep 就能完成走查,本文引用 PyJWT 源码的行号均对准 2.13.0 tag。

3. 漏洞原理分析

接下来沿调用栈逐帧走一遍一次伪造验证请求的完整路径;然后拆开路上唯一的内容关卡,分析它为什么拦不住变异公钥;再追失效的根源——检测面与加载面的偏差从何而来;最后回答:这道关卡在 PyJWT 历史上是不是第一次被绕过,官方修复为什么这样修,修完了吗。所有结论都锚定在 2.13.0 源码、官方 commit diff 与复现日志上。

3.1 开场:同一秒里的三种结果

把 evidence/server-vulnerable.log 里同一秒的四行日志单独拎出来(行首时间戳略去),就是整个漏洞的缩影:

[INFO] [verify] path=/protected alg=ES256 -> 200 sub=legitimate_user role=user
[INFO] [verify] path=/protected alg=HS256 -> 401 InvalidSignatureError: Signature verification failed
[INFO] [verify] path=/protected alg=HS256 -> 200 sub=attacker role=superadmin
[INFO] [verify] path=/protected-strict alg=HS256 -> 401 InvalidAlgorithmError: The specified alg value is not allowed

第一个请求是环境自检:服务自己签的 ES256 令牌,走非对称验证,正常通过——验证密钥本身功能完好,服务运行正常。

第二个和第三个请求是问题的关键。两个都是 HS256,都命中 /protected,一个 401 一个 200。(同一秒里其实还有第三条 HS256 请求——折叠变异的失败尝试,同样 401,日志第 22 行,后面会回到它。)区别只有一个:签名时用的密钥字节形态不同。第二个用的是"END 标记前插 tab"的变异公钥,第三个用的是"LF 全部换成裸 \r"的变异公钥——而服务端配置的验证密钥恰好是后一种形态。HMAC 是字节精确的:你签名的字节和服务端验证的字节必须一位不差。第三个令牌的签名者从未持有任何私密信息,它用来签名的"密钥"就是从 /pubkey 下载的公钥文本,只是照着配置管道的损坏方式把行尾重新替换了一遍。

第四行是缓解对照:同一个令牌打到只允许 ES256 的端点,InvalidAlgorithmError,请求在密钥检查之前即被拒绝。

动手读代码之前,日志已经给出结论的结构:**存在一个针对"公钥当 HMAC 密钥"的防护,这个防护对规范的 PEM 有效、对变异的 PEM 失效;验证是否通过最终取决于攻击者能否复现部署密钥的确切字节形态。**剩下的两个问题——这个防护在哪里,它的识别器为什么有盲区——都要到源码里去找。

3.2 全局结构:三个组件和一次分派

逐帧走查之前,先把 PyJWT 的分层结构交代清楚,否则每一帧的上下文都不完整。2.13.0 的 jwt 包里,与验证路径相关的是三个组件,加上调用它们的应用代码,自上而下共四层(图 1,箭头标出"委托"方向):

四层各有清晰的职责边界:JWT 层只管 claims 的编解码与业务语义校验,它把签名验证整体委托给 JWS 层;JWS 层只管紧凑序列化的拆装与按 alg 头分派;真正接触密钥字节、决定密钥如何被解释的,是最底层的 Algorithm 对象。

模块级的 jwt.decode 不是散函数,而是绑定在一个全局单例上。api_jwt.py 文件末尾:

_jwt_global_obj = PyJWT()
_jwt_global_obj._jws = _jws_global_obj
...
decode = _jwt_global_obj.decode

jwt.decode(...) 实际调用的是这个 PyJWT 实例的方法,而它内部的 self._jws 又是 api_jws.py 末尾那个全局 PyJWS 实例。两层单例先后初始化,应用代码一条 import 就获得了完整的验证栈。

分派的核心在 PyJWS.__init__(api_jws.py:36-53)和 get_default_algorithms(algorithms.py:140-171):

def get_default_algorithms() -> dict[str, Algorithm]:
    """
    Returns the algorithms that are implemented by the library.
    """
    default_algorithms: dict[str, Algorithm] = {
        "none": NoneAlgorithm(),
        "HS256": HMACAlgorithm(HMACAlgorithm.SHA256),
        "HS384": HMACAlgorithm(HMACAlgorithm.SHA384),
        "HS512": HMACAlgorithm(HMACAlgorithm.SHA512),
    }

    if has_crypto:
        default_algorithms.update(
            {
                "RS256": RSAAlgorithm(RSAAlgorithm.SHA256),
                ...
                "ES256": ECAlgorithm(ECAlgorithm.SHA256, SECP256R1),
                ...
            }
        )

    return default_algorithms

启动时构建一张"算法名 → 算法对象"的注册表,每个名字对应一个实现了 prepare_key/sign/verify 三方法契约的对象。留意 if has_crypto 这个开关——cryptography 是 PyJWT 的可选依赖([crypto] extra),没装它,注册表里只有 none 和 HS 系四个条目;装了,RSA/EC/EdDSA 才会注册进来。这个开关与漏洞的前置条件 3 直接相关,后面会再遇到它。

而决定分派哪个算法对象的,是令牌头里的 alg 字段——攻击者完全可控的字段。算法混淆类漏洞的结构性前提也在这里:

**算法对象由令牌头选择,验证密钥由部署配置提供。**当允许列表同时容纳"接受任意字节的 HMAC"和"接受密钥对象的非对称算法"两族时,同一个密钥输入就可能被攻击者引导到错误的密码学语义上。

PyJWT 对此有明确记录——api_jwt.py 中 decode 的 docstring 明确写着(RFC 8725 §2.1 的转述):

"Make sure not to mix symmetric and asymmetric algorithms that interpret the `key` in different ways (e.g. HS\* and RS\*)."

文档给出了警告,代码也实现了防护。防护设在 HMACAlgorithm.prepare_key 里,这是接下来两节的主线。

3.3 验证侧全链路逐帧走查

下面从 HTTP 请求抵达的那一刻走起,沿调用栈一帧一帧走到 hmac.new,再走回 HTTP 响应。行号全部对准 2.13.0 tag。验证侧主路径的调用链全景见图 2,自上而下正对请求的推进方向;三个红色标出的节点——_verify_signature(核心调度)、is_pem_format(根因点)、hmac.new(sink)——是接下来所有小节的分析重点,prepare_key 框下的三步对应帧 10–12 的逐分支走读,末尾回穿 JWT 层的两步对应帧 15–16。

帧 1–2:应用层入口,Handler.do_GET 与 Handler._token

server.py:69 的 do_GET 是标准库 http.server 的路由钩子,把 /protected 和 /protected-strict 都分派给 _verify,用 strict 布尔值选择允许列表。这一帧的关键是 _token(server.py:65-67):

def _token(self):
    auth = self.headers.get("Authorization", "")
    return auth[7:] if auth.startswith("Bearer ") else None

三行代码,是**source-1(攻击者令牌)**的进入点:整个令牌字符串——header、payload、signature 三段,包括决定算法分派的 alg 头——从这里原样进入验证流程。它不做任何预检:不检查 alg 是什么、不限制长度、不剥空白。这是合理的,校验本来就是下游 JWT 库的职责;但这也意味着,下游的每一道检查都必须自成闭环。

帧 3:Handler._verify,source-2 在此注入

server.py:95-112 是应用侧的验证组装点,也是**source-2(验证密钥)**汇入的地方:

allowed = ALLOWED_STRICT if strict else ALLOWED_MIXED
try:
    alg = jwt.get_unverified_header(token).get("alg", "?")
    claims = jwt.decode(token, VERIFY_KEY, algorithms=allowed)

两个数据源在这里汇合:token 来自请求头(source-1,攻击者可控),VERIFY_KEY 来自模块级常量(source-2,server.py:45 生成的裸 \r 变异公钥)。algorithms=allowed 是混合列表 ["ES256", "HS256"],即前置条件 1。get_unverified_header 的返回值只用于审计日志,不参与任何验证决策,locals().get("alg", "?") 让失败路径也能把 alg 打出来——复现日志里那些 [verify] path=... alg=... -> ... 格式的行就来自这里。

这两行之后,应用代码的责任就结束了:它把"令牌、密钥、允许列表"三样东西递给库,默认库会正确处理三者的组合——漏洞就出在这个组合上。

帧 4–5:PyJWT.decode → decode_complete,JWT 层的两个动作

api_jwt.py:303 的 decode 是个薄包装:处理完 kwargs 弃用告警后,直接委托 decode_complete 并返回 decoded["payload"](api_jwt.py:368-380)。真正的动作在 decode_complete(api_jwt.py:174-285)里,动作不多。

进来先合并选项(api_jwt.py:240-264)。verify_signature 默认 True;enforce_minimum_key_length 从合并后的选项里取,默认 False。两个开关,一个决定"验不验签",一个决定"密钥太短时是报错还是警告"。

合并完就向下一层委托(api_jwt.py:265-271):

decoded = self._jws.decode_complete(
    jwt,
    key=key,
    algorithms=algorithms,
    options=sig_options,
    detached_payload=detached_payload,
)

key 原样下传,PyJWT 层没有任何针对 key 的检查——对声明层的校验器来说,签名验证是个黑盒。

回来之后还有两件事收尾:_decode_payload(api_jwt.py:287-301)把 payload 段 json.loads 成 claims 字典,并要求顶层必须是 JSON 对象;_validate_claims(api_jwt.py:382 起)做 exp/iat/nbf/iss/aud/sub/jti 校验。这两步到帧 15–16 再回来细看——它们的顺序是先验签、后验声明:签名一旦被伪造,声明校验形同虚设。

帧 6–7:PyJWS.decode_complete 的门禁与拆包

JWS 层的入口(api_jws.py:219-283)先立了一道门禁(api_jws.py:244-247):

if verify_signature and not algorithms and not isinstance(key, PyJWK):
    raise DecodeError(
        'It is required that you pass in a value for the "algorithms" argument when calling decode().'
    )

要验签,就必须给出允许列表(除非 key 是绑定了算法的 PyJWK)。这条规则源于历史:早期 decode 的算法默认跟随令牌头,直接造成 alg=none 与 HS/RS 混淆两类漏洞,2.x 将显式给出列表定为硬性要求。我们的场景里 algorithms=["ES256","HS256"],这项前置检查通过。但它只检查"列表存在",不检查"列表是否混用了两族算法"——库把混用的判断留给 docstring 的警告,把强制留给了下一层那个内容关卡。

然后是 _load(api_jws.py:318-366),负责把紧凑序列化的三段拆开:

signing_input, crypto_segment = jwt.rsplit(b".", 1)
header_segment, payload_segment = signing_input.split(b".", 1)

从右往左先切出签名段,再切出头段与载荷段。段数不足抛 DecodeError("Not enough segments")。rsplit 从右切是个小而有讲究的选择:载荷段里出现 . 不会影响切分,因为签名段永远是最后一段。

三段各自 base64url_decode(utils.py:25-33,自动补 = 填充到 4 的倍数),头段再 json.loads,要求结果是 dict(api_jws.py:341-342)。走到这里,header 已经是一个 Python 字典,内容是攻击者构造的 {"typ":"JWT","alg":"HS256"}。

_load 里还有一段 b64=false 的分支(api_jws.py:344-353,RFC 7797 分离载荷):头声明 b64:false 时载荷段必须为空、真实载荷由调用方经 detached_payload 传入。2.13.0 的变更日志专门记录了这段的加固(拒绝未声明 crit 的 b64=false、跳过无用的 base64 解码防 DoS 放大)——与本漏洞无关,但它提供了一个对照:这里的防御是结构性的(段必须为空),不依赖对内容的启发式识别。这个对比,3.8.3 还会用到。

帧 8:_verify_signature,核心调度逐行读

整个调度的核心在 api_jws.py:368-417,一行行读:

if algorithms is None and isinstance(key, PyJWK):
    algorithms = [key.algorithm_name]

PyJWK 路径的默认:密钥自带算法,允许列表退化为单一元素。

try:
    alg = header["alg"]
except KeyError:
    raise InvalidAlgorithmError("Algorithm not specified") from None

alg 头缺失即拒绝。注意这个 alg 来自 _load 解析的、攻击者写的 header。

if not alg or (algorithms is not None and alg not in algorithms):
    raise InvalidAlgorithmError("The specified alg value is not allowed")

允许列表检查——/protected-strict 上那行 401 InvalidAlgorithmError 日志就是这行抛的。这是第一道关卡,纯字符串集合成员测试,不涉及密钥内容。混合列表 ["ES256","HS256"] 允许 HS256,检查通过。到这一步,算法混淆的必要条件已经满足,但后果尚未发生。

if isinstance(key, PyJWK):
    ...
    if alg != key.algorithm_name:
        raise InvalidAlgorithmError(...)
    alg_obj = key.Algorithm
    prepared_key = key.key
else:
    try:
        alg_obj = self.get_algorithm_by_name(alg)
    except NotImplementedError as e:
        raise InvalidAlgorithmError("Algorithm not supported") from e
    prepared_key = alg_obj.prepare_key(key)

这一段是两条路线的分岔口。PyJWK 路径(上半)把算法与密钥在构造时绑定,验证时强制 alg == key.algorithm_name——代码注释直白地解释了为什么:"otherwise the caller's allow-list check above degenerates into a string compare with no behavioural effect on which algorithm actually verifies the signature"。普通 str/bytes 密钥走下半:get_algorithm_by_name(alg) 按令牌头查注册表拿算法对象,再把部署配置的密钥交给这个对象去 prepare_key。算法对象的选择权在令牌,密钥的解释权跟着算法对象走——两条本应绑定的决策,在这里被拆给了两个不同的输入源。

图 3 画出分岔口上三方的控制权:顶部两个来源框——攻击者控制的三段令牌与部署配置提供的密钥和列表——汇入 _verify_signature,框内三行写明每种输入各自的用途;底部分岔出 prepare_key 的两条路径,红色一侧对应本漏洞。

alg 只要"在列表里"就行,列表怎么配是部署的事;key 的字节内容,列表检查一个都不看。真正把 alg 与 key 绑定起来的,只有"密钥能否通过被选中算法的 prepare_key"这一件事。非对称算法的 prepare_key 依赖 loader,不合法的输入会被拒绝;HMAC 的 prepare_key 接受任意非空字节——除非那道内容关卡拦得住。

帧 9:get_algorithm_by_name,注册表查找

api_jws.py:99-118,self._algorithms[alg_name],一次字典查找。查不到时,若 has_crypto 为假且该算法在 requires_cryptography 集合里,报错信息会提示检查是否安装了 cryptography。HS256 永远在注册表里(它不依赖 cryptography),攻击者令牌的 alg=HS256 一步命中 HMACAlgorithm(SHA256) 实例。

帧 10–12:HMACAlgorithm.prepare_key,唯一的内容关卡

algorithms.py:325-357,漏洞的直接出处。完整走一遍每个分支:

key_bytes = force_bytes(key)

utils.py:16-22 的 force_bytes:str 按 UTF-8 编码,bytes 恒等通过,其他类型抛 TypeError("Expected a string value")。规范化后,密钥以字节形态接受后续所有检查。

if len(key_bytes) == 0:
    raise InvalidKeyError("HMAC key must not be empty.")

空密钥直接拒绝——2.13.0 变更日志里"Reject empty HMAC keys outright"对应的加固。

if is_pem_format(key_bytes) or is_ssh_key(key_bytes):
    raise InvalidKeyError(
        "The specified key is an asymmetric key or x509 certificate and"
        " should not be used as an HMAC secret."
    )

这就是 前序漏洞留下的关卡:内容黑名单式的检查——"这串字节是否形如非对称密钥?是则拒绝"。is_pem_format 是主判据,is_ssh_key(utils.py:141-142,key.startswith(_SSH_KEY_FORMATS),六种 OpenSSH 前缀)负责 OpenSSH 单行格式。demo 里那段 178 字节的裸 \r 变异公钥,在这里被两个函数先后判为"不是 PEM""不是 SSH 密钥"——检查没有覆盖到它。为什么,接下来专门拆解。

stripped = key_bytes.lstrip()
if stripped.startswith(b"{"):
    try:
        jwk_obj = json.loads(key_bytes)
    except ValueError:
        jwk_obj = None
    if isinstance(jwk_obj, dict) and "kty" in jwk_obj:
        raise InvalidKeyError(
            "The specified key looks like a JWK and should not be "
            "used directly as an HMAC secret. Load it via "
            "PyJWK / HMACAlgorithm.from_jwk first."
        )

2.13.0 新增的 JWK-JSON 检查:密钥剥掉前导空白后若以 { 开头、能解析成含 kty 字段的 JSON,就当 JWK 文档拒绝。它上方的注释把动机写得再直白不过,全文抄在下面:

"Defense against algorithm-confusion attacks: an attacker with control over the token header can force this code path by setting alg=HS*, and HMACAlgorithm is the only algorithm that accepts arbitrary bytes as a valid secret. Other algorithms reject non-key-shaped input naturally. Even a symmetric (kty=oct) JWK should be loaded via PyJWK / from_jwk rather than fed as raw JSON bytes (whose contents are not the secret material)."

这段注释写明了 PyJWT 的防护思路:只有 HMAC 接受任意字节,所以只需要在 HMAC 的入口设防;非对称算法依赖 loader,不合法的输入会被 loader 拒绝。思路成立的前提,是识别器能认全所有该拦的东西——PEM 检查与这个 JWK 检查出于同一思路,后文会看到它们相似的覆盖缺口。

return key_bytes

所有检查通过,字节原样返回。对合法的对称密钥这是预期行为;变异公钥走到这里,得到的处理与任何普通字符串密钥相同。

帧 13:check_key_length,只验证长度的关卡

回到 _verify_signature(api_jws.py:409-414):

key_length_msg = alg_obj.check_key_length(prepared_key)
if key_length_msg:
    if effective_options.get("enforce_minimum_key_length", False):
        raise InvalidKeyError(key_length_msg)
    else:
        warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4)

HMACAlgorithm.check_key_length(algorithms.py:396-405)只比较字节数与哈希摘要长度(HS256 为 32 字节)。裸 \r 变异公钥 178 字节、RSA 公钥 PEM 约 450 字节,都远超下限——返回 None,连警告都没有。就算部署把 enforce_minimum_key_length 打开,这道检查也只比较长度,不检查内容,无法阻止公钥字节被当作密钥。长度检查替代不了内容检查。

帧 14:verify 与 sign,字节在密码学原语里汇合

if not alg_obj.verify(signing_input, prepared_key, signature):
    raise InvalidSignatureError("Signature verification failed")

HMACAlgorithm.verify(algorithms.py:410-411):

return hmac.compare_digest(sig, self.sign(msg, key))

sign(algorithms.py:407-408):

return hmac.new(key, msg, self.hash_alg).digest()

验证的方式是重新计算一遍:hmac.new(变异公钥字节, signing_input, SHA256) 的摘要与令牌第三段做常数时间比较。攻击者在签名侧用了完全相同的字节与算法,重算结果自然逐位一致,compare_digest 返回 True。sink 就在这里——公钥文本作为对称密钥参与了 HMAC 运算,而且"验证通过"。

被签的 msg 也要看清楚:signing_input = base64url(header) + b"." + base64url(payload)(api_jws.py:194),纯 ASCII 的两段编码。HMAC 不检查密钥字节的来源类型——给它什么字节,它就拿什么字节做密钥;区分密钥类型的责任,全部落在调用侧的检查上。

帧 15–16:声明校验,为什么救不了

签名通过后,_decode_payload(api_jwt.py:287-301)把 payload 段 json.loads 成 claims 字典,顶层非 JSON 对象即抛 DecodeError。随后 _validate_claims(api_jwt.py:382 起)接管,这是 JWT 层的核心校验逻辑。它先处理 leeway(允许把 timedelta 折算成秒),然后按固定顺序过一遍校验器。拿攻击者的 payload 逐个对一遍。

_validate_required_claims(api_jwt.py:424-431)是唯一一个**由部署方点名"查哪些声明"**的检查项:按 options["require"] 列表逐项检查 payload.get(claim) is None,None 即 MissingRequiredClaimError。部署要求什么声明,令牌就必须带什么声明。demo 服务没有配置 require,列表为空,检查空转。

_validate_sub(api_jwt.py:433-452)与 _validate_jti(api_jwt.py:454-466)是两道类型关卡:sub 在场就必须是字符串,部署方若在 decode(..., subject=...) 传了期望值,还要精确相等;jti 在场同样必须是字符串。demo 没传 subject,sub 便只剩"是字符串"这一道检查——"attacker" 是一个完全合法的字符串。攻击者的 payload 压根没带 jti,入口的 if "jti" not in payload 直接 return。

三个时间声明各有一关。_validate_iat(api_jwt.py:468-481)做 int() 强转,iat > now + leeway 才拒绝(ImmatureSignatureError,"令牌尚未生效");_validate_nbf(api_jwt.py:483-495)与 iat 同构,nbf > now + leeway 即拒;_validate_exp(api_jwt.py:497-511)查的是 exp <= now - leeway,命中抛 ExpiredSignatureError。对照攻击者的写法:forge_superadmin 写的是 iat=now(poc.py:79-80),恰好卡在边界内的"刚刚签发";exp=now+600,十分钟窗口,直接通过。payload 没带 nbf,而 _validate_claims 的调用条件是 "nbf" in payload and options["verify_nbf"],这一关直接跳过。

_validate_aud(api_jwt.py:513-561)是八个里逻辑最复杂的一个,区分宽松/严格两种模式。核心行为:部署没传期望 audience 而令牌带了 aud,抛 InvalidAudienceError;部署传了而令牌没带,抛 MissingRequiredClaimError;两边都传,做(可选严格模式的)匹配。demo 两边都没传,令牌也没带 aud,空转。

_validate_iss(api_jwt.py:563-586)要部署传了 issuer 才激活,激活后是字符串精确比较或容器成员测试——2.10.1 曾修过 iss 被部分匹配放行的问题(变更日志在案),现在的精确比较是那次的遗产。demo 未传,空转。

把攻击者的 payload 放进这台流水线:{"sub":"attacker","role":"superadmin","iat":now,"exp":now+600}——sub 是字符串✓,iat 不在未来✓,exp 未过期✓,其余声明缺席,对应校验器直接跳过。八个校验器,零次拦截。这不是哪个校验器写错了,是校验顺序的固有含义:声明校验验证"内容是否自洽且符合策略",其有效性以"签名已证明内容出自密钥持有者"为前提。签名前提一旦失效,后面每一道检查都只是在验证攻击者伪造的令牌格式规范、时间合理、类型正确。"role 提升需要额外凭证"这类检查属于业务层,不在 JWT 库的职责内——库的契约是"签名对,claims 可信"。算法混淆使这一约定失效时,直接消费 role 的业务层随之失去保护;demo 的 _verify 正是这么写的(server.py:109-112 直接回显 claims)。

还有一个容易被忽略的细节:server.py:102 在验证前调用了 jwt.get_unverified_header(token),返回值仅用于日志。函数名里的 "unverified" 是文档级的警告(RFC 8725 对"未认证头"的一贯立场,api_jws.py:310-312 的 docstring 也自我声明"在签名验证完成前不应完全信任")。demo 的用法(仅审计)是教科书的正确用法;反例是把 get_unverified_header 的 alg 拿去决定 decode 的 algorithms 参数——那等于把允许列表的决定权交给攻击者,库的 docstring 明确禁止这种写法。

帧 17:回到应用,HTTP 200

server.py:109-112,claims 被信任,响应体里原样回显 sub=attacker、role=superadmin,服务端日志记下 sub=attacker role=superadmin。至此,一次完整的认证绕过走完了从字节到业务语义的全链路,第二个 sink——业务层对 claims 的无条件消费——达成。

签名侧走查:路径 B,伪造是如何被"签发"出来的

验证链走完了,还差它的镜像——攻击者侧 jwt.encode(claims, mutated, algorithm="HS256") 的完整路径。这条链不长,但必须补上:它解释了 2.14.0 修复后 PoC 输出里那三行 jwt.encode HS256 遭 InvalidKeyError 拒绝的输出是从哪抛出来的。

帧 B-1:PyJWT.encode(api_jwt.py:90-154)。入口先做三件事:payload 必须是 dict(否则 TypeError:"Expecting a dict object, as JWT only supports JSON objects as payloads");payload 先浅拷贝再动,不污染调用方的字典;exp/iat/nbf 若是 datetime 对象,用 timegm 转成 intDate(UTC 秒)——这是库对时间声明唯一的自动转换。iss 若存在必须是字符串(2.x 某次修复加的类型收窄)。然后 _encode_payload 用紧凑分隔符 (",", ":") 序列化成 JSON 字节,委托下层。

帧 B-2:PyJWS.encode 的算法决议(api_jws.py:130-154)。这段 if 链决定了"不指定算法时库默认签什么":

if algorithm is _ALGORITHM_UNSET:
    if isinstance(key, PyJWK):
        algorithm_ = key.algorithm_name
    else:
        algorithm_ = "HS256"

调用方不传 algorithm 且密钥不是 PyJWK 时,默认 HS256。这个默认值出于便利——对称签名不需要密钥管理,是教程与快速集成的最短路径,也因此成为算法混淆类漏洞反复出现的入口。若显式传 algorithm=None,走 key.algorithm_name 或 "none"(无签名,受 NoneAlgorithm.prepare_key 的"key 必须为 None"约束)。headers 里若带了 alg,还会反过来覆盖参数——生产者侧的 alg 决议同样有三条来源,部署方稍有含混,签出来的令牌算法就可能和预期不一致。

帧 B-3:header 组装(api_jws.py:156-183)。{"typ":"JWT","alg":algorithm_} 起底,合并调用方的 headers(_validate_headers(encoding=True) 先过 kid 检查——encoding=True 时跳过 crit 校验),typ 为假值则删掉。b64=false 的分离载荷形态下,生产者必须把 b64 列进 crit(RFC 7797 §3 的义务,api_jws.py:166-176 自动补齐)。收尾的 json.dumps 以 sort_keys=sort_headers(sort_headers 默认 True)序列化——header 键序稳定,同一输入产出同一字节串,签名才可复现。

帧 B-4:三段拼装与签名(api_jws.py:185-217)。

segments.append(base64url_encode(json_header))
...
msg_payload = base64url_encode(payload)
segments.append(msg_payload)
signing_input = b".".join(segments)

alg_obj = self.get_algorithm_by_name(algorithm_)
if isinstance(key, PyJWK):
    key = key.key
key = alg_obj.prepare_key(key)          # ← :199,与验证侧同一个关卡

key_length_msg = alg_obj.check_key_length(key)
if key_length_msg:
    ...                                  # ← 同帧 13:enforce 抛错,否则告警

signature = alg_obj.sign(signing_input, key)   # ← :208
segments.append(base64url_encode(signature))
...
encoded_string = b".".join(segments)

signing_input 的构造与验证侧 _load 的切分严格互逆:两段 base64url 以 . 相连,签名在其后。密钥在 :199 走进与验证侧完全相同的 HMACAlgorithm.prepare_key——同一个关卡、同一个正则、同一个盲区。2.13.0 上变异公钥在此放行,hmac.new 产出签名;2.14.0 上同一行抛出 InvalidKeyError,伪造令牌在签出之前即被拒绝(poc-fixed.log step 4 三次拒绝的抛出点就是这里)。

编码与解码共用同一套密钥检查,这个事实有一个重要推论:这不是"验证逻辑"的 bug,而是"密钥分类"的 bug。分类器识别不出公钥,于是签发与验证两端一起失效;攻击者伪造令牌时不需要绕过签名端的任何检查——签名由库自身完成。也正因如此,本 CVE 的修复只动 is_pem_format 一处,两端同时恢复防御,不需要分别打补丁。

帧间注脚:并发与状态

并发边界也交代一句:demo 用 ThreadingHTTPServer(server.py:129),每请求一线程,但 VERIFY_KEY 是 import 时算好的模块级常量,启动后不可变;prepare_key 是无状态方法(不写实例属性),_algorithms 注册表初始化后只读。整条验证链上没有可变的共享状态,没有 TOCTOU 窗口,并发请求数不影响可达性——攻击是每请求独立、无状态的,一次成功即永久成功(在密钥轮换之前)。

3.4 根因之一:检测正则的字节级分析

关卡本身看过了,下面拆开它的实现。utils.py:116-127:

_PEM_RE = re.compile(
    b"----[- ]BEGIN ("
    + b"|".join(_PEMS)
    + b""")[- ]----\r?
.+?\r?
----[- ]END \\1[- ]----\r?\n?""",
    re.DOTALL,
)

def is_pem_format(key: bytes) -> bool:
    return bool(_PEM_RE.search(key))

有个容易看漏的地方:三引号字节串里,源码自身的两次换行就是模式的组成部分——等价于在 \r? 之后写一个 \n。编译出的模式因此与直觉一致,BEGIN 行以可选 CR 加必需 LF 结尾,主体以同样方式收尾。配套的标签集在 utils.py:97-114,十六个受支持的标签,注释标明抄自 hynek/pem 库(utils.py:96):

_PEMS = {
    b"CERTIFICATE",
    b"TRUSTED CERTIFICATE",
    b"PRIVATE KEY",
    b"PUBLIC KEY",
    b"ENCRYPTED PRIVATE KEY",
    b"OPENSSH PRIVATE KEY",
    b"DSA PRIVATE KEY",
    b"RSA PRIVATE KEY",
    b"RSA PUBLIC KEY",
    b"EC PRIVATE KEY",
    b"DH PARAMETERS",
    b"NEW CERTIFICATE REQUEST",
    b"CERTIFICATE REQUEST",
    b"SSH2 PUBLIC KEY",
    b"SSH2 ENCRYPTED PRIVATE KEY",
    b"X509 CRL",
}

3.4.1 把模式拆成四个部件

这个正则匹配的是一整块 PEM 的整体结构。把编译后的模式按部件摊开:

部件①  前标记行   ----[- ]BEGIN (LABEL)[- ]----\r?\n
部件②  主体       .+?            (DOTALL,非贪婪)
        行尾       \r?\n
部件③  后标记行   ----[- ]END \1[- ]----
部件④  尾         \r?\n?(可选)

规范的 PEM 前标记行是 -----BEGIN PUBLIC KEY-----,五个连字符。正则只把其中四个写成字面量,第五个交给 [- ] 字符类——合起来要求"至少四个连字符 + 恰好一个(连字符或空格)+ BEGIN"。又因为 search 可以从任意偏移起配,实际容忍度比字面更宽:-----BEGIN、---- BEGIN(四连杠加空格)都能匹配,连 ----- BEGIN(五连杠后跟一个空格)也行——引擎把起配点右移一位,后四个连字符充当字面量,那个空格落进 [- ]。真正容不下的是更多空白:两个空格,或空格之后再夹进别的字符,就无法构成"四连杠 + 单字符 + B"的序列了。这行还附带两处小约束:BEGIN 与标签之间恰好一个空格,是字面量;标签与结尾五连杠之间,又是一个 [- ] 字符类。

部件①的行尾(\r?\n)单独看:可选的 CR,加上必需的 LF。整个漏洞最要紧的一处约束就在这几个字节里——标记行之后必须紧跟换行 LF(0x0A),前面最多一个 CR(0x0D)。裸 CR、没有换行、其他任何空白,一律不行。

部件② .+?\r?\n,两条隐含约束。DOTALL 模式下 . 连换行在内什么都吃,非贪婪意味着引擎优先尝试最短的主体:其一,主体至少一个字符且必须以 LF 结尾——空主体的 PEM 匹配不了;其二,非贪婪与部件③的配合方式,决定了引擎的回溯行为,tab 变体的判定马上会用到这一点。

到部件③ ----[- ]END \1[- ]----,先看 \1:反向引用,要求 END 标签与 BEGIN 标签逐字节相同——-----BEGIN PUBLIC KEY----- 配 -----END PRIVATE KEY----- 不会命中。END 标记的行首约束是隐式的,部件②以 \r?\n 收尾,部件③紧跟其后,所以 END 的五个连字符必须紧贴在换行符之后,中间不容一个字节的空白。

部件④最简单:\r?\n?,结尾换行完全可选。END 行之后是文件末尾、还是接别的什么内容,都无所谓。

3.4.2 正则的四条结构约束

部件里读出来的约束,汇总成四条。后面所有变体分析,都对照这四条判断:

  1. 约束 R1:BEGIN 标记行必须以 LF 结束(\r? 可选、\n 必需);END 前的主体行同理;
  2. 约束 R2:END 标记的五个连字符必须紧贴在 LF 之后,中间不允许任何字节;
  3. 约束 R3:标签拼写精确(大小写敏感、单空格分隔、[- ] 只各容忍一个位置);
  4. 约束 R4:主体非空且结构上必须能切出"标记行—主体行—标记行"的三段结构。

一个字节串要被 is_pem_format 认作 PEM,四条必须同时满足。而它要被攻击者利用,只需在违反任一条的同时,仍然被 cryptography 的加载器接受。

3.4.3 三种变异的逐项推演

约束已经明确,把复现里三种变异逐一放到正则上推演。底本用演示服务日志里的真实公钥(server-vulnerable.log):

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMHVB1i6fHhAi+KUaUqu3SJkT8Knk
N9uM3jiEK80l1AkSqI//rY8Eb62SOTQLPbIQdSZ459s7sK0YqhUuHOnGbA==
-----END PUBLIC KEY-----

变异一:tab 插在 END 标记前(poc.py:44,k.replace(b"-----END", b"\t-----END", 1)),变成:

-----BEGIN PUBLIC KEY-----
MFkwEwYH...
N9uM3jiEK8...==
\t-----END PUBLIC KEY-----

推演从部件①起步:BEGIN 行连同 LF 一起匹配,✓。部件②非贪婪,先试"第一行主体 + LF"。轮到部件③,它要求在 LF 之后立即匹配 ----[- ]END,实际出现的是 \t(0x09)。字符类 [- ] 只认 -(0x2D)和空格(0x20),tab 不在其中——约束 R2 失败。

匹配失败后引擎回溯,.+? 扩张主体,把 \t-----END PUBLIC KEY----- 整个纳入主体,让部件②的 \r?\n 对齐到缓冲区末尾的最后一个 LF。此时部件③要在字符串结尾处匹配 ----[- ]END...,身后已无字节——再次失败。引擎穷尽了所有切分方式,search 返回 None。回溯过程中引擎甚至尝试过把 END 标记当作主体的一部分,但找不到第二个 END 标记来构成完整结构。

变异二:LF 全部替换为裸 CR(poc.py:45,也是演示服务配置管道模拟的形态,server.py:45):

-----BEGIN PUBLIC KEY-----\rMFkwEwYH...\rN9uM...\r-----END PUBLIC KEY-----\r

这一串字节里一个 LF(0x0A)都不存在。部件①结尾的 \r?\n 中 \n 是必需的,search 扫遍每个起点都无法匹配——约束 R1 失败,且不存在回溯余地。启动日志里那行 is_pem_format(VERIFY_KEY)=False -> HMAC guard BYPASSED,判的就是这段字节。

变异三:折叠成单行(poc.py:46,b" ".join(k.split()),所有空白序列压成单个空格):

-----BEGIN PUBLIC KEY----- MFkwEwYH... N9uM...== -----END PUBLIC KEY-----

同样没有 LF,部件①的 \r?\n 无处对齐——约束 R1 失败,而且只失败在 R1 上。折叠后的串里 BEGIN 标记完整、标记间空白都被压成了单个空格,R3 并未违反。由此还有一个反直觉的推论:若有人把正则的 \r?\n 放宽成 \s(单个空白字符),折叠形态反而会被认出——折叠变异的逃逸完全依赖"整串无换行"这一点。

三种变异,三种失败方式,汇成一句话:它们都是"内容不变、结构形态改变"的公钥。正则识别的是结构形态。

3.4.4 更远的边界:正则还认识哪些形态

下面这批形态,有的真试过,更多只在纸面上推演,判定的依据仍是那四条约束,而且只判正则一侧——哪些形态能被加载器接受,留给后文的可达性矩阵;已有运行时证据的说证据,没有的明确标注未验证。

  • CRLF 行尾(Windows 风格 \r\n):部件① \r?\n 完整命中,主体行、END 行同理——被识别。CRLF 往返的配置管道不构成绕过,这一点防御方不妨记住。
  • BEGIN 前有前导文本/空白(如邮箱式页眉 Subject: Public Key、YAML 缩进):search 不要求从头匹配,前缀垃圾无关紧要——被识别。
  • BEGIN 行前有缩进、END 行前也有缩进(缩进的配置块整体读入):BEGIN 侧无妨(search);END 侧,LF 与 ---- 之间多了缩进字节——不被识别。这就是通告所说"带缩进的 YAML/JSON 配置块"能自然产生变异形态的机理:缩进落在 END 行前,违反 R2。
  • END 行前插入空格(而非 tab):空格落在 \n 之后、---- 之前,R2 要求 END 紧贴换行,一个空格也是多余——不被识别。而若空格插在结尾五连杠之后(行尾空白),部件④不作约束——被识别。
  • END 标记后紧贴其他内容(无换行直接接下一段):部件④可选——被识别。
  • BEGIN/END 标签不一致:反向引用 \1 拒绝——不被识别,但这类串加载器也未必接受,不属于攻击面。
  • 标签大小写变化(-----begin public key-----):_PEMS 全大写字面量——不被识别;加载器侧是否接受未验证。
  • 五连杠变四连/六连:六连杠的表现分侧而论——BEGIN 侧(乃至任意更多连杠)仍会被识别,search 把起配点右移,多出的 - 恰好落在起配点之前,----[- ] 照常成立;END 侧则不被识别——END 模式被主体行尾的 \r?\n 锚定在 LF 之后的第一位,起配点无从右移,第 6 个 - 正落在要求出现 E 的位置上,匹配失败。真正不被识别的是恰好四连杠且无空白的 ----BEGIN([- ] 只认连字符或空格,接不上 B)。四连杠加空格的 ---- BEGIN 反而能匹配。加载器侧是否接受这些变体未验证。

这批形态暴露出一个模式:正则的每一条精确约束(R1–R4)都对应一个潜在的逃逸方向,加载器的每一条宽松规则都对应一片可被利用的接受范围。两类集合的交集,就是这类漏洞的攻击面定义。

3.4.5 关卡的另一半:is_ssh_key 的前缀检查

algorithms.py:331 的 or 右边还有半个关卡,utils.py:130-142:

# Based on https://github.com/pyca/cryptography/blob/bcb70852d577b3f490f015378c75cba74986297b/src/cryptography/hazmat/primitives/serialization/ssh.py#L40-L46
_SSH_KEY_FORMATS = (
    b"ssh-ed25519",
    b"ssh-rsa",
    b"ssh-dss",
    b"ecdsa-sha2-nistp256",
    b"ecdsa-sha2-nistp384",
    b"ecdsa-sha2-nistp521",
)

def is_ssh_key(key: bytes) -> bool:
    return key.startswith(_SSH_KEY_FORMATS)

注释标明格式列表抄自 cryptography 的 ssh 模块——与 PEM 正则一样是对加载器行为的手工拷贝,且比 PEM 正则更窄:startswith 是零偏移的字节前缀匹配,只认偏移 0。authorized_keys 风格的 OpenSSH 公钥(ssh-ed25519 AAAA... comment)确实以算法名开头,能被拦;可前面只要多出任何一个字节——一个缩进空格、一段 BOM、一行说明文字——前缀匹配就失效。PEM 正则的 search 可以从任意偏移开始匹配,这一半检查则固定从偏移 0 开始。

对本漏洞而言这半边没有影响:攻击目标是 PEM 公钥,不走 OpenSSH 格式。两半摆在一起:同一个关卡的两个判据,一个只认结构(LF、三段结构),一个只认位置(偏移 0),都离"加载器实际接受什么"很远。修复版只重写了 PEM 这半边(is_ssh_key 未改动),因为只有它被证实可被用于绕过;前缀匹配那半边的理论缝隙,留给了未来的报告者。

3.4.6 旧正则的性能面

.+? 配 DOTALL 还带来一个性能问题:对一段以 BEGIN 开头却不存在对应 END 的输入,引擎从每个可能起点做线性扩张、反复回溯,最坏情形是平方级扫描。密钥是服务方配置、长度有限,这里不构成 DoS 面;但对比修复版的单遍扫描会看到,官方替换方案在复杂度上也严格更优。

3.5 根因之二:加载面的宽松,以及规范为何允许它

正则的接受面很窄;另一边,cryptography 的 PEM 加载器接受面很宽。两个面的偏差不是偶然,得从代码与规范两头看。

3.5.1 非对称算法不调用 is_pem_format

加载面在 PyJWT 里的位置,从 ECAlgorithm.prepare_key 看起(algorithms.py:641-672),全文走读:

def prepare_key(self, key: AllowedECKeys | str | bytes) -> AllowedECKeys:
    if isinstance(key, self._crypto_key_types):
        # See note in RSAAlgorithm.prepare_key.
        ec_key = cast(AllowedECKeys, key)  # type: ignore[redundant-cast,unused-ignore]
        self._validate_curve(ec_key)
        return ec_key

第一分支是快速通道:调用方直接传 cryptography 的密钥对象(已经过类型检查的 EllipticCurvePrivateKey/PublicKey 等),校验曲线匹配(2.11.0 引入的 RFC 7518 §3.4 曲线校验,_validate_curve 在 algorithms.py:630-639)后原样返回。走这条路的应用——比如从 JWKS 构造密钥的——不经过字节形态的处理。

    if not isinstance(key, (bytes, str)):
        raise TypeError("Expecting a PEM-formatted key.")

    key_bytes = force_bytes(key)

    # Attempt to load key. We don't know if it's
    # a Signing Key or a Verifying Key, so we try
    # the Verifying Key first.
    try:
        if key_bytes.startswith(b"ecdsa-sha2-"):
            public_key: PublicKeyTypes = load_ssh_public_key(key_bytes)
        else:
            public_key = load_pem_public_key(key_bytes)

        # Explicit check the key to prevent confusing errors from cryptography
        self.check_crypto_key_type(public_key)
        ec_public_key = cast(EllipticCurvePublicKey, public_key)
        self._validate_curve(ec_public_key)
        return ec_public_key
    except ValueError:
        private_key = load_pem_private_key(key_bytes, password=None)
        self.check_crypto_key_type(private_key)
        ec_private_key = cast(EllipticCurvePrivateKey, private_key)
        self._validate_curve(ec_private_key)
        return ec_private_key

字节进来,先看是不是 OpenSSH 公钥前缀,否则直接交给 cryptography.hazmat.primitives.serialization.load_pem_public_key——这就是加载面。加载成功得到密钥对象,后续的类型检查与曲线校验,查的都是"密钥对象的密码学属性",与字节形态无关。

整个流程里没有 is_pem_format 的调用。PyJWT 不拿自己的正则去预检非对称密钥,而是完全依赖加载器。于是同一串变异公钥字节,经 ECAlgorithm.prepare_key 处理后变成一个合法的 EllipticCurvePublicKey 对象(合法 ES256 验证照常工作),到 HMACAlgorithm.prepare_key 这里却被正则判为"不是 PEM"、放行进入 HMAC。复现日志的自检步(GET /protected (ES256) -> HTTP 200 sub=legitimate_user role=user)证明的是前半句:变异密钥没有影响正常业务。这也正是这类部署能长期存活的原因——没有任何报错指向配置的形态异常。

加载面接受变异形态的直接证据在 poc-vulnerable.log 里,三种变异各有一行:

--- mutation: bare-CR-line-endings ---
is_pem_format(mutated)    -> False
cryptography loader       -> accepted

False 与 accepted 并排出现,检测面与加载面的分歧直接可见。

3.5.2 RFC 7468:宽松是规范对解析器的要求

加载器为什么宽松?这不是实现上的随意,而是 PEM 格式的历史与规范决定的。PEM 文本编码(Privacy-Enhanced Mail)定型于 1993 年的 RFC 1421 邮件加密体系,三十年间被无数工具生成、拷贝、粘贴、转码。RFC 7468("Textual Encodings of PKIX, PKCS, and CMS Structures",2015)把现实做法写入规范,而它给出的规范是不对称的:

生成端被要求严格:

"New implementations SHOULD emit the strict format (Figure 3) specified above."(§3)
生成器"MUST wrap the base64-encoded lines so that each line consists of exactly 64 characters except for the final line""MUST NOT emit extraneous whitespace"。(§2)

解析端则被明确允许宽松:

"parsers SHOULD ignore whitespace and other non-base64 characters and MUST handle different newline conventions."(§2)
"The most lax parser implementations are not line-oriented at all and will accept any mixture of whitespace outside of the encapsulation boundaries."(§2,并指引见图 2;ABNF 在 §3 里干脆定义了名为 laxtextualmsg 的宽松产生式)
"Data before the encapsulation boundaries are permitted, and parsers MUST NOT malfunction when processing such data."(§2)
"Parsers MUST handle non-conforming data gracefully."(§2)

"MUST handle different newline conventions"——裸 CR、CRLF、LF 被规范同等对待;"SHOULD ignore whitespace"——标记前后的空白不是拒绝理由。这不是 cryptography 的随意实现,而是 RFC 7468 对解析器的要求——生成严格、解析宽松是互操作性优先格式的共同约定。

而 PyJWT 的 _PEM_RE 是一个按生成端严格标准写的识别器,被部署在需要覆盖解析端全部接受面的安全关口上——落差由此产生,PyJWT 2.4.0 把关卡建在了窄的那一边。

3.5.3 偏差矩阵:漏洞位于第三象限

把两个面放进同一张矩阵:


cryptography 加载器接受

加载器拒绝

is_pem_format 判定是 PEM

正常公钥:关卡拦截,InvalidKeyError(第 I 象限,demo step 3 对照组)

关卡拦截,无所谓对错(第 II 象限,冗余拦截面)

is_pem_format 判定不是 PEM

变异公钥:关卡放行 → 本漏洞(第 III 象限)

普通字符串密钥:正常放行,业务所需(第 IV 象限)

第 IV 象限是 HMAC 的合法业务区——字符串密钥本来就不是 PEM,关卡必须放它们过。第 I 象限是前序漏洞修复的成果。漏洞位于第 III 象限:所有"加载器收、正则不认"的形态,大小等于两个面接受集合的差集。三种已证实的变异,只是这个差集里被点名的三个元素;差集本身还有多大,取决于加载器的版本与实现细节——这也是修复版选择"对齐标记识别而非整体结构匹配"的动机。

3.5.4 为什么会有两个实现:解耦的代价

还剩一个"何至于此"的问题:PyJWT 为什么不干脆用加载器来判 PEM?看 utils.py 的开头:

try:
    from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurve
    ...
except ModuleNotFoundError:
    pass

cryptography 在 PyJWT 里是可选依赖([crypto] extra):不装它,has_crypto 为假,注册表里只剩 none 与 HS 系,但库依然完整可用。is_pem_format 是纯标准库实现(re + 字节串),它的存在让"不装 cryptography 的部署"也能拥有 HMAC 关卡。这个解耦在工程上有正当理由,代价则是:从此系统里存在两个独立的"什么是 PEM"的定义——一个按 RFC 7468 的严格生成面写,一个按宽松解析面实现。两个定义各自演化,缝隙逐渐扩大。检测面与加载面的漂移,本质上是同一事实的两个副本失去了同步机制;这类"双实现漂移"不止出现在密码学库里,文末的延伸思考会回到这个模式。

3.6 数据全生命周期:一份公钥的两种处理路径

调用链是"控制流"的视角;这一节换成"数据流",跟踪这份公钥,看它的字节从生成到进入 hmac.new 的每一步形态变化。逻辑类漏洞的"内存布局",就是关键数据在各阶段的字节形态与类型形态——图 4 是它的完整快照:主链自上而下是五个阶段,阶段 2 与阶段 3 各有服务端、攻击者两条并行支路(两支输出的字节逐位一致,在同一个 prepare_key 处汇合),浅红标出的阶段 2 是全链唯一没有密码学意义的环节——它不碰密钥的数学内容,却决定了两个识别器的判定结果。

逐阶段看:

阶段 0,生成。密钥此时以对象形态存在,EllipticCurvePrivateKey 里是曲线参数与标量,没有"字节"可言。序列化(public_bytes(encoding=PEM, format=SubjectPublicKeyInfo))是从对象形态到字节形态的第一次转换,输出按 RFC 7468 严格生成面执行——64 字符一行、LF 行尾、标准标记。

分岔发生在阶段 1。PUBLIC_PEM 是这份公钥唯一一次以规范字节形态存在的时刻:服务日志把它全文打出来(server-vulnerable.log 启动段),/pubkey 端点发布的也是它。从这里开始,这 178 字节的内容走上两条路。

阶段 2 是变异。两条路各自经受一次形态变换:服务端是配置管道的 replace(b"\n", b"\r")——内容零损失,只动行尾;攻击者是 PoC 里的同名变异函数。两条变换的输出逐位一致,这是整个攻击成立的数学前提。日志第 8 行把服务端形态的 %r 渲染留了档(下面是该行的完整 %r 字面量,行首的时间戳、日志级别与消息前缀略去):

b'-----BEGIN PUBLIC KEY-----\rMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMHVB1i6fHhAi+KUaUqu3SJkT8Knk\rN9uM3jiEK80l1AkSqI//rY8Eb62SOTQLPbIQdSZ459s7sK0YqhUuHOnGbA==\r-----END PUBLIC KEY-----\r'

对照规范形态,变化只有一处:四个 \n 全成了 \r。四个字节的行尾翻转,足以让正则(要求 \n)与加载器(接受任意换行约定)给出相反的判定。

阶段 3 叫汇入:两条路在 prepare_key 关卡前再次同型——都是 bytes。这里有个容易被忽略的细节:如果这把密钥走的是 ES256 的 prepare_key,它会在 load_pem_public_key 处不再是字节,变成 EllipticCurvePublicKey 对象;而走 HS256 的 prepare_key,它到 hmac.new 为止始终是字节。算法混淆的本质,就是让同一份密钥材料在"对象语义"与"字节语义"之间被攻击者选择解释权——非对称算法需要"被解析成结构",对称算法接受"拿来即用",关卡本应阻止字节材料跨语义滥用。

到阶段 4 汇合。hmac.new 收到的 msg 是 signing_input(两段 base64url 编码的 ASCII,以 . 相连),key 是变异公钥字节。运算本身不区分密钥的来源,摘要函数对 178 字节的文本与对 32 字节的随机串处理方式相同。签名侧与验证侧的输入完全对称,compare_digest 返回 True 是数学必然,不是巧合。

回看这张生命周期图,防御方的一个朴素结论是:形态变换(阶段 2)是整个链条上唯一"无密码学意义"的环节。它不改变密钥的数学内容,却改变了两个识别器对它的判定——所有检测、所有绕过,都发生在这个"内容不变、形态漂移"的环节上。也因此,缓解措施里"保证密钥以规范形态传入"看起来朴素,针对的恰恰是这个环节。

3.7 触发条件边界枚举:可达性矩阵

原理说清了,还剩一个很实际的问题:一个部署,到底在什么条件下会被真的利用。这一节逐项收口,从变异形态一路查到环境依赖。

3.7.1 变异形态:第 III 象限的已知成员

运行时证实的第 III 象限成员有三族(2.13.0 + cryptography 50.0.2,poc-vulnerable.log):

变异族

构造

正则侧失败点

加载器

END 标记前空白

-----END 前插 tab(或缩进块自然产生)

R2:END 不贴 LF

接受

裸 CR 行尾

所有 \n → \r

R1:无 LF

接受

单行折叠

全部空白压成单空格

R1:无 LF

接受

官方通告的 PoC(报告者提供,cryptography 49.0.0)同样覆盖 EC 与 RSA 两种公钥、三种变异。RSA 公钥的 PEM 无非行数多一些——2048 位的 SubjectPublicKeyInfo 约 6–7 行——结构约束上的失败点一模一样,这印证了前面"漏洞在结构识别器而非具体密钥类型"的判断。

前面推演过的其余形态(CRLF、六连杠、大小写变化等)各有去向:有的正则侧就识别,不构成逃逸;有的正则侧不识别,加载器侧却未经验证。对后者,本文不下"可利用/不可利用"的断言,只标记为开放边界。第 III 象限的精确边界由加载器实现决定,每个 cryptography 版本都可能有细微移动——修复版干脆放弃"整体结构匹配",深层原因就在这里。

3.7.2 字节精确性:三族变异为何只有一族成功

poc-vulnerable.log 的 step 4 里,三种变异都走完了"关卡放行 → 本地签名 → 本地验证通过",打到服务端 /protected 时结果出现分歧(下面只摘各变异的首行与最后一行,中间的 is_pem_format/loader/本地 encode/decode 四行略去):

--- mutation: tab-before-END-marker ---
GET /protected (forged)   -> HTTP 401 error=InvalidSignatureError detail=Signature verification failed
--- mutation: bare-CR-line-endings ---
GET /protected (forged)   -> HTTP 200 sub=attacker role=superadmin
--- mutation: folded-single-line ---
GET /protected (forged)   -> HTTP 401 error=InvalidSignatureError detail=Signature verification failed

岔开的原因是 HMAC 的字节精确性:攻击者签名的密钥字节与服务端验证的密钥字节,必须一位不差。demo 服务端配置的是裸 \r 形态,于是只有裸 \r 变异成功;tab 与折叠两族签出的令牌,到服务端 hmac.new(裸\r公钥, ...) 里重算一遍对不上,InvalidSignatureError。

这个"失败"本身信息量不小。攻击者的真实门槛被划了出来:复现部署密钥的确切字节形态,不是随便什么变异都行——这与前置条件 2、4 的表述一致。更要紧的是,响应是可区分的。401 与 200 一眼可辨;若服务回显错误类型,InvalidSignatureError(关卡已放行、只是签名对不上)与 InvalidKeyError(关卡拦截)在 2.13.0 上的区别对攻击者只有一个意义——前者意味着"方向对了,换形态再试"。变异空间并不大,常见的配置管道损坏方式数量有限;即便服务不回显错误类型,在线枚举到 200 为止,几次也能命中。"必须知道确切形态"这道门槛,在可试错、无速率限制的现实环境里,比听起来的要低。

顺带把现实中自然产生这些变异的管道也认一认:

配置链路场景

产生的字节变化

落入的变异族

违反的约束

带缩进的 YAML/JSON 块,读入时缩进未剥离

每行前带空白(含 END 行)

END 前空白族

R2

单行环境变量 / JSON 字符串里的密钥

换行丢失或被压成空白

单行折叠族

R1

经 CR/LF 往返的工具链(老式行尾转换、某些模板引擎)

LF 全变裸 CR

裸 CR 族

R1

Windows 风格编辑后统一 CRLF

LF 前多出 CR

(CRLF,正则认识)

无——不构成变异

表里最后一行单独说一下:CRLF 是行尾转换里唯一安全的结果,_PEM_RE 的 \r?\n 与加载器都接受它。同样是"行尾被动过",LF→CRLF 不影响判定,LF→CR 直接落入第 III 象限——损坏方式差一个字节,安全后果天差地别,而部署方对此毫无感知。这就是部署侧毫不知情的静默损坏:服务不报错,ES256 照常工作(3.5.1 分析过,加载面全认),唯一的变化是 HMAC 关卡的识别率随之下降。没有监控检查 is_pem_format 的返回值,这种下降就是不可见的。

3.7.3 算法允许列表:五种配置的结果

允许列表是攻击要过的第一个开关,典型配置全数列出:

配置

alg=HS256 伪造令牌的结果

结论

["ES256", "HS256"](混合)

允许列表检查通过 → 关卡 → 放行 → 验签通过

可利用

["ES256"](单一非对称)

api_jws.py:386 拒绝,InvalidAlgorithmError

免疫(对照组 /protected-strict,日志在案)

["HS256"] 为主 + ES 密钥

列表检查照样放行(HS256 就在列表里),伪造令牌此后与混合列表路径相同;但此配置把公钥当对称秘密用,合法 ES256 令牌在 api_jws.py:386 被拒、业务本就不成立,"攻击者知晓公钥"也不再天然成立

前置条件 1 不满足,不构成此漏洞场景

未传 algorithms 且 key 非 PyJWK

decode_complete 入口 DecodeError

免疫(2.x 强制门禁)

key 为 PyJWK

alg != key.algorithm_name → InvalidAlgorithmError(api_jws.py:395)

免疫(构造时绑定)

单一非对称算法列表与 PyJWK,是两道不依赖内容识别的结构性免疫机制。"混用允许列表"位列四项前置条件之首,道理就在这里——漏洞只影响同时允许两族算法的部署。

3.7.4 可选开关:enforce_minimum_key_length 不能缓解此漏洞

check_key_length 只量长度:HS256 的参考下限是 32 字节(hash_alg().digest_size,algorithms.py:397),而公钥 PEM 动辄上百字节——最小的 Ed25519 公钥 PEM 也有 113 字节,本 demo 的 P-256 公钥 178 字节。默认配置下连警告都不触发(返回 None);部署就算打开强制,拦到的也是"过短的对称密钥",拦不住"以公钥字节冒充的密钥"。长度衡量的是密钥强度,不区分密钥类型——这道检查的设计目标本来就是提醒弱密钥,不是反算法混淆。

3.7.5 对照组复盘:四次实验各自隔离了什么变量

PoC 的输出日志(poc-vulnerable.log、poc-fixed.log)不止证明"复现成功",它们本身就是一组控制变量实验。四个对照,每个孤立出一个变量,也各自回答一个原理侧的问题。

C1 拿同一把变异密钥去验证合法 ES256 令牌。poc-vulnerable.log step 1 的结果是 GET /protected (ES256) -> HTTP 200 sub=legitimate_user role=user——变异形态在加载面被全盘接受,前面走读过的 ECAlgorithm.prepare_key → load_pem_public_key 路径畅通无阻。这个对照的意义是诊断性的:配置损坏对业务完全静默。没有报错,没有警告,合法令牌照常验证,唯一的变化是 HMAC 关卡的识别率下降——而没有任何监控覆盖这个指标。

C2 反过来看:规范 PEM 当 HMAC 密钥,2.13.0 做何反应(poc-vulnerable.log step 3):

jwt.encode                -> rejected: InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

关卡对规范形态工作正常。前序防护"存在且有效",只是有盲区。这个对照把漏洞的性质从"防护缺失"改写为"防护不完备"——一字之差,根因的方向完全不同:前者要补充一道检查,后者要修识别器的覆盖面。

C3 把伪造令牌打向单一算法列表。poc-vulnerable.log step 5 记录的是 mutation 'bare-CR-line-endings' vs /protected-strict -> HTTP 401 error=InvalidAlgorithmError detail=The specified alg value is not allowed——混用允许列表由此被证明是必要前置:HS256 一从列表里拿掉,伪造令牌就在 api_jws.py:386 的字符串集合检查处被拒绝,不会到达 prepare_key。防御方的启示也在这里,一道纯配置的改动(不混用算法族)就是完整的免疫,不必等升级。

C4 对上 2.14.0,跑同一份 PoC(poc-fixed.log step 4)。三种变异全部被判 is_pem_format(mutated) -> True,全部在 jwt.encode HS256 处遭 InvalidKeyError 拒绝,伪造根本签不出来;服务端启动日志(server-fixed.log 第 9 行)同步给出 jwt.utils.is_pem_format(VERIFY_KEY)=True -> HMAC guard ACTIVE。第 III 象限的三个已知成员被归入第 I 象限——而且是在同一密钥形态下完成的对照,变量只有 PyJWT 版本,结论干净。

四个对照,加上主实验(变异公钥 + 混合列表 + 2.13.0 → 200),一张 2×2×2 的实验矩阵就齐了:密钥形态(规范/变异)× 算法列表(混合/单一)× 版本(2.13.0/2.14.0)。每一格的行为都能从前面的原理分析推出来。混合列表一列的四格各有日志佐证;单一列表一列的四格都在同一道 api_jws.py:386 集合检查处被拒绝——它先于密钥检查生效,密钥形态与版本在到达它之前就已无关紧要,C3 的 401 就是这条路径的直接证据(2.14.0 侧因伪造令牌已无法签出,strict 无从重测)。评估自己是否受影响,把部署摆进这张矩阵里对号入座即可。

3.7.6 环境依赖:cryptography 是否安装

has_crypto 为假的部署,注册表里没有 ES 条目——混合列表无从谈起,"用非对称公钥做验证密钥"的业务形态也不存在,漏洞没有可利用的场景。真实的暴露面因此收窄为:装了 cryptography、且按"原始 PEM 字节 + 混合列表"方式使用 PyJWT 的部署。cryptography 是 PyJWT 的可选依赖([crypto] extra;前置条件 3,非对称验证与 PEM 解析都靠它),在 Web 服务场景几乎默认安装,这道"可选"的条件过滤掉的部署有限。

方向上还有一点要确认:攻击者无法注入或替换验证密钥,key 始终来自服务端配置,可控的只有令牌三段(header 的 alg、payload 的 claims、signature)。漏洞是"可控输入选择了解释密钥的算法",不是"可控输入成为了密钥"。评估同类漏洞时,这个区分经常用得上。

3.8 横向对比:这道检查的历史与相关防护

机理之外,这道检查的修复历史中,同一个模式反复出现。

3.8.1 2.4.0:关卡的引入

2022 年 5 月,PyJWT 2.4.0 发布,变更日志安全一节写着:

"Prevent key confusion through non-blocklisted public key formats."

行末带着指向对应安全通告的 URL。措辞本身反映了当时的思路:算法混淆不是新问题,某些公钥格式此前已经进了一张黑名单,那次修复补的是"没进名单的那些格式"。修复的实现形式,就是 2.13.0 源码里走读过的那道关卡——HMACAlgorithm.prepare_key 里的 is_pem_format(key_bytes) or is_ssh_key(key_bytes) 检查。本 CVE 的通告对这层关系的定性很直接:这是对前序防护的不完整实现绕过——防护的判断依据(结构正则)没有覆盖防护想要覆盖的全部对象(loader 接受的全部形态)。

测试比措辞更能说明问题。2.13.0 的 tests/test_algorithms.py:75-90 是这道关卡的回归测试族:

@pytest.mark.parametrize(
    "key",
    [
        "testkey2_rsa.pub.pem",
        "testkey2_rsa.pub.pem",
        "testkey_pkcs1.pub.pem",
        "testkey_rsa.cer",
        "testkey_rsa.pub",
    ],
)
def test_hmac_should_throw_exception(self, key: str) -> None:
    algo = HMACAlgorithm(HMACAlgorithm.SHA256)

    with pytest.raises(InvalidKeyError):
        with open(key_path(key)) as keyfile:
            algo.prepare_key(keyfile.read())

五条参数化用例,四个密钥文件,testkey2_rsa.pub.pem 重复出现了一次——恰好覆盖关卡的两个判据:三个是规范 PEM(testkey_rsa.cer 的内容也是 -----BEGIN CERTIFICATE----- 块);testkey_rsa.pub 是 OpenSSH 单行公钥,由关卡的另一半 is_ssh_key 负责。测试确认"关卡认识规范公钥",这没有错。问题在于,测试集和识别器共享了同一个形态假设——样本都是识别器最容易识别的规范形态:PEM 样本按 RFC 7468 的严格样式生成,SSH 样本以算法名零偏移开头。变异形态不在测试集里,也不在识别器里,两边都没有覆盖。修复 commit 正是针对这一点,在 tests/test_algorithms.py 增加 +43 行,覆盖 loader 接受的变异、不完整的重复标记、后续有效 PEM 块、重叠标记等场景。

3.8.2 2.13.0:同一道关卡的连续加固

2.13.0(2026 年)的变更日志安全一节列了五条,每一条都涉及这道关卡或相关机制:

  • 拒绝把 JWK JSON 文档当原始 HMAC 密钥——就是前面走读过的 startswith(b"{") + kty 检查;
  • 验证时把头里的 alg 绑定到 PyJWK.algorithm_name——_verify_signature 里那个分岔口的上半分支;
  • PyJWKClient 拒绝非 http(s) URI scheme;
  • JWKS 拉取失败时保留缓存;
  • b64=false 时跳过无条件的 payload base64 解码并要求段为空。

JWK-JSON 检查要单独拎出来说。它和 PEM 检查是同一思路的两次实现:前序防护拦"形如 PEM 的密钥",有人改用 JSON 形态绕过;2.13.0 补拦"形如 JWK 的密钥"。两次修复回答的是同一个问题——"这串字节是不是非对称密钥材料"——回答方式也相同:列举式的内容识别。PEM 检查的识别器是结构正则;JWK 检查的识别器是"剥空白、看 { 开头、尝试 JSON、查 kty"。后者同样存在理论上被变异形态绕过的空间——字节串里塞进让 json.loads 失败、但语义上仍是密钥文档的噪声时,except ValueError: jwk_obj = None 会放行。本文没有对它做绕过验证,不下断言;它与 PEM 检查同属列举式内容识别,识别面与真实接受面之间同样存在未经度量的差集。

同一份变更日志里,2.13.0 还修了空 HMAC 密钥直接拒绝、enforce_minimum_key_length 选项转发等。

3.8.3 两个对照组:结构性防御的形态

同一个代码库里就有这两类防御的现成对照。

第一个,PyJWK 的算法绑定(2.13.0 补强)。密钥对象在构造时就携带 algorithm_name,验证时强制令牌头的 alg 与之一致(api_jws.py:395)。攻击者改 alg 头没有用——算法不是被头"选择"的,是被密钥"携带"的。代码注释直接说明了设计意图:否则调用方的允许列表检查就退化成一次没有任何行为效果的字符串比较(原句为英文,前面已引)。

第二个,b64=false 的段检查(api_jws.py:352-354)。RFC 7797 分离载荷形态下,紧凑序列化的载荷段必须为空,真实载荷由调用方参数传入。防御条件是结构性的——"这一段必须没有字节"——不依赖对内容的任何识别,因而不存在"换个形态绕过"的攻击面:想绕过它,只能让段不为空,而那直接违反结构。

两类防御的共同点在于,它们约束的是"谁决定什么",而不是"内容长什么样";内容启发式(PEM 正则、JWK 探测)约束的恰恰是后者。前者一次做对就稳定,后者要随识别对抗持续进化。四年间,同一道关卡两次被绕过,说明的就是这个差别。

3.8.4 为什么不直接移除关卡

既然 PyJWK 路径免疫,PyJWT 为什么不干脆强制所有密钥走绑定路径?答案落在兼容承诺上。jwt.decode(token, pem_bytes, algorithms=[...]) 这个"原始密钥字节"接口是公开 API 的主干,大量存量代码、教程、框架集成建在它上面;强制 PyJWK 等于一次破坏性迁移。而 utils.py 的独立性(不依赖 cryptography 也能判 PEM)又决定了识别器不能简单交给加载器实现。这几项约束叠加下来,可选的修法只剩一条主线:让识别器变得足够可靠。

3.9 补丁 diff 逐行分析:扩大识别范围的修复

修复 commit 8b4e233a(2026-09-10,"fix: reject loader-accepted PEM variants")只动了 jwt/utils.py 的 +12/-8,加上测试的 +43/-1。防护的调用点——algorithms.py:331 的 if is_pem_format(...) or is_ssh_key(...)——一字未动。修的是被依赖的检测函数本身,不是重写防护结构。(防一个误读:2.14.0 成品的 algorithms.py 确有改动——那是同批另外三个公告的加固,重写了 JWK 检查、给关卡补上 DER 判据,前文已交代——但不在本 commit 的 diff 里,与本 CVE 的修法无关。)diff 如下,已对照 commit patch 逐字核实:

-_PEM_RE = re.compile(
-    b"----[- ]BEGIN ("
-    + b"|".join(_PEMS)
-    + b""")[- ]----\r?
-.+?\r?
-----[- ]END \\1[- ]----\r?\n?""",
-    re.DOTALL,
+# Accept PEM formatting variants that the cryptography loader accepts, while
+# retaining the explicit asymmetric-key marker check.
+_PEM_MARKER_RE = re.compile(
+    b"(?=(----[- ](BEGIN|END) (" + b"|".join(_PEMS) + b")[- ]----))"
 )
 
 
 def is_pem_format(key: bytes) -> bool:
-    return bool(_PEM_RE.search(key))
+    begin_markers: dict[bytes, int] = {}
+    for marker in _PEM_MARKER_RE.finditer(key):
+        marker_type, label = marker.group(2), marker.group(3)
+        if marker_type == b"BEGIN":
+            begin_markers[label] = marker.start(1) + len(marker.group(1))
+        elif label in begin_markers and begin_markers[label] < marker.start(1):
+            return True
+    return False

3.9.1 新正则逐符号:从"整体结构"到"标记 Token"

把新模式 (?=(----[- ](BEGIN|END) (LABEL…)[- ]----)) 拆开,变化有四处,一处一处看。

前瞻 (?=...):整个模式是零宽断言,不消费任何字节。finditer 在每个存在完整标记的位置各报告一次匹配——包括那些首尾相接、互相重叠的标记。消费式模式在一个标记结束时才继续找下一个,相邻字节处的第二个标记会被跳过;零宽前瞻没有这个问题。重叠标记的处理在通告的修复描述与回归覆盖里都被专门提到;通告同时提到,修复经过了独立安全复审。

(BEGIN|END) 捕获组:BEGIN 与 END 共用同一条 Token 规则,方向信息交给调用方处理。旧正则用反向引用 \1 把两个方向绑定在一次匹配里,新设计把它们拆开——方向配对从"模式内部约束"上升为"状态机逻辑",这是后面能放开形态约束的前提。

去掉的东西比加上的更重要:没有 \r?\n(不再要求换行),没有 .+?(不再要求主体),没有 DOTALL,没有反向引用,没有量词回溯。新 Token 只认一件事:这里有一个拼写正确的标记。三段结构(BEGIN 行—主体—END 行)不再由正则负责,由状态机按位置关系负责。

保留的 ----[- ] 与 [- ]----:标记两侧五连杠的宽容度(第五位可以是连字符或空格)原样保留。配合标签集 _PEMS 不变,修复刻意维持了"受支持标签的封闭集合"这一语义——注释写明"retaining the explicit asymmetric-key marker check",修的是格式变体的覆盖面,不是识别对象的范围。

3.9.2 状态机解读:BEGIN 标记的记录与配对

新的 is_pem_format 是一台两状态的小扫描器,维护一个以标签为键的字典:

begin_markers: dict[bytes, int] = {}

遇到 BEGIN 标记,记下"这个标签的 BEGIN 在哪里结束":

begin_markers[label] = marker.start(1) + len(marker.group(1))

marker.start(1) 是整个标记的起点,加上 len(marker.group(1))(标记长度)得到标记结束的偏移。存"结束偏移"而不是"起始偏移"或布尔标志,是为了让后面的判断只用一次整数比较。

遇到 END 标记,查询字典:

elif label in begin_markers and begin_markers[label] < marker.start(1):
    return True

同标签、已记录、且 END 的起点严格大于该标签最后一次 BEGIN 的结束偏移——中间有字节,姑且算作主体——判定为 PEM。

设计里有三个边界情形,挨个推演。

重复 BEGIN 怎么处理(-----BEGIN PUBLIC KEY----- 出现两次才来一个 END)?按扫描顺序处理:END 遇到时,字典里是它之前最后一条 BEGIN 的位置,END 在其后即返回 True,两者之间确有主体;END 夹在两条 BEGIN 中间时,它遇到时字典里记的是第一条 BEGIN 的位置,END 起点大于其结束偏移,同样返回 True。无论哪种顺序,只要存在任何一对"BEGIN 在前、END 在后"的同标签标记,就判 PEM——这正是"这里有一块 PEM"的语义,重复标记不产生误判。2.14.0 的回归测试专门覆盖了"不完整的重复标记"场景。

乱序与孤立 END 是另一类情形。END 到来时字典里没有该标签,label in begin_markers 为假,跳过;后续若有合法 BEGIN 但再无 END,最终返回 False——正确,一块只有 BEGIN 标记没有 END 标记的"PEM",不该被认作 PEM。

严格小于还留了一个边界。END 紧贴 BEGIN(零字节间隔,begin_markers[label] == marker.start(1))不判 PEM,这会不会漏?零间隔意味着标记之间没有主体字节——没有 base64 体的"公钥"不是任何加载器会接受的密钥,漏掉它没有实际后果。

3.9.3 复杂度与工程性质

旧正则是"非贪婪主体 + 回溯"的平方级最坏情形;新方案是单遍 finditer(模式是有限个定长字面量的交替,每个扫描位置的尝试代价有界)+ O(标签数) 的字典操作,整体线性的时间、有界的空间(至多 16 个标签的登记项)。对一段几百字节的密钥,性能影响可以忽略,但把一个安全检查从"回溯正则"换成"线性扫描",工程上仍是净收益。

3.9.4 修复完整性:已知缺口已修复,结构性问题仍在

修复解决的是已识别的检测面缺口:三种变异族(以及同类"标记完整、形态变异"的形态)全部落入识别范围。行为级证据摆在 poc-fixed.log step 4——三行 is_pem_format(mutated) 全部判 True,三个 jwt.encode HS256 全部以 InvalidKeyError 被拒绝,加上 server-fixed.log 启动行(第 9 行)的 HMAC guard ACTIVE。

残留问题要讲全,还有三点记录在案。

标签集仍是封闭的十六个。cryptography 未来支持新的 PEM 标签(新密钥类型、新封装格式),检测面不会自动跟上——两个实现依旧各自演化,这次的同步靠人工。标记拼写的宽容度也没有扩大(大小写敏感、[- ] 两侧各一),加载器若接受更松的拼写,缝隙会重新出现,前面列的开放边界依然开放。最结构性的残留是:"什么是 PEM"仍然存在两个副本——is_pem_format 一份,加载器一份。修复缩小了两个副本之间的偏差,但没有建立任何同步机制。修复注释自述的目标是"接受 cryptography loader 所接受的格式变体"(commit 注释直译),一般现在时隐含的"当前"是对覆盖范围的限定。

3.9.5 替代修法对比:为什么不是别的方案

官方之外,修法至少还有三条路可想。

方案 A,用加载器本身做检测——关卡内 try: load_pem_public_key(key); except: pass 直接判。语义上最保真,检测面与加载面天然同步。可关卡必须在不装 cryptography 时也工作(前面说过的解耦约束),条件化之后就成了"装了才拦、不装不拦",防护强度取决于环境;每次 prepare_key 都要跑一遍完整解析,签名验签的热路径上凭空多一次对象构造;DER 等非 PEM 编码的覆盖也得重新评估——PEM 加载器认不出它们,这类密钥材料会从检测面上漏掉。不是做不成,是代价与影响范围都更大。

方案 B,先把密钥归一成规范 PEM,再过正则。这条路遇到一个根本性的语义障碍:HMAC 密钥的每个字节都是密钥。prepare_key 返回什么字节,hmac.new 就用什么字节;检测前做了规范化,要么把规范化后的字节返回——等于隐式改掉调用方的密钥,合法的"带空白的对称密钥"业务直接被破坏——要么检测归检测、返回原始字节,那规范化只解决检测问题,不解决"变异形态本身就是部署事故"的问题。HMAC 的字节精确性排除了这条路。

方案 C,强制 PyJWK 绑定,拆掉原始密钥接口。前面讨论过:破坏公开 API 兼容,不在 2.x 的选项集里。

回头再看官方的"换识别器":影响范围最小(调用点零改动、行为对规范输入零变化),覆盖已证实的全部逃逸面,复杂度还顺带优化;局限前面已经数过,属于这类权衡的固有代价。

3.9.6 拿三种变异在新状态机上跑一遍

把三种变异逐个输入新版 is_pem_format,手工执行一遍扫描循环,看字典怎么记录、判断怎么得出。以演示公钥为底本,标记偏移按前文那条真实 %r 日志逐字节计算(BEGIN 标记 -----BEGIN PUBLIC KEY----- 共 26 字节,记录值即"标记结束偏移"26;裸 CR 形态 178 字节,末尾字节是 \r)。

裸 CR 行尾先来——178 字节,无任何 LF:

扫描:  偏移 0    发现 BEGIN PUBLIC KEY   → 字典{PUBLIC KEY: 26}
        偏移 153  发现 END PUBLIC KEY     → 查字典:存在,26 < 153 ✓
返回:  True

标记本身一个字节没少。旧正则要的是"标记行后必须跟 LF",新 Token 只看"标记拼写完整"——行尾是什么、有没有行尾,与识别无关。

然后是 tab 插在 END 标记前:

扫描:  偏移 0    发现 BEGIN PUBLIC KEY   → 字典{PUBLIC KEY: 26}
        偏移 154  发现 END PUBLIC KEY     → 26 < 154 ✓
返回:  True

tab 是标记之外的普通字节,扫描器跳过它,在 \t 之后照样认出拼写完整的 END 标记。旧正则的 R2 约束(END 必须贴 LF)在新设计里根本不存在——"标记之间存在哪些字节"不再被规定,只被计数。前面"END 侧锚定"的推演也一并放开:旧正则要求 END 紧跟在 LF 后的第一位、多一个连字符都不认,新扫描器从任意偏移起认标记,END 侧六连杠里多出的 - 只是标记左侧的普通字节,照样识别(加载器是否接受它,仍是前面标注的开放边界)。

单行折叠收尾:

扫描:  偏移 0    发现 BEGIN PUBLIC KEY   → 字典{PUBLIC KEY: 26}
        偏移 153  发现 END PUBLIC KEY     → 26 < 153 ✓
返回:  True

(折叠串把四个换行压成三个空格、少了行尾字节,全长 177;END 起点与裸 CR 形态一样落在 153——偏移相同,判定结果相同。)

三个变异,三种"旧约束的违反方式"(换行全变 CR、END 前多出字节、换行整个消失),在新状态机里走出同一条执行路径:找到 BEGIN、记下结束偏移、找到更靠后的同标签 END、返回 True。"放弃结构匹配、改为标记配对"的直接效果就在这里——所有"结构上的变形",只要不破坏标记本身的拼写,就都在识别范围内。

防误报的方向也验两个,其中一个有回归测试直接佐证。只有 BEGIN 没有 END(截断的 PEM 片段):字典记下 PUBLIC KEY: 26,扫描到结尾没有同标签 END,循环耗尽,return False——不完整的块不误报;那条重复 BEGIN 一万次的回归测试,断言的正是这种输入被关卡放行。END 在前、BEGIN 在后(乱序拼接)是另一个:END 先到,查字典为空,跳过;BEGIN 后到,记录;再无后续 END,return False。位置关系(先 BEGIN 后 END)是必要条件,倒置不成立。

拿 2.13.0 的旧正则对照,这两个"不该认"的输入它也不认(结构不匹配),2.14.0 保住了这个正确性——修复扩大的是真阳性面,没有扩大假阳性面。这就是"对齐加载器接受面"与"放宽判定标准"的区别。

3.10 原理小结

把前面的原理分析收拢成一段话:

PyJWT 为防止"公钥当 HMAC 密钥"的算法混淆,在 HMACAlgorithm.prepare_key 设了一道内容关卡,判据是自带的 PEM 结构正则与 SSH 前缀检查;而系统里对"什么是 PEM"还存在第二个定义——cryptography 的加载器,它按 RFC 7468 授予解析器的宽松义务工作,接受裸 CR、标记旁空白、单行折叠等形态。两个定义的差集构成第 III 象限,变异公钥位于其中:加载器认它,正常 ES256 业务不受影响,部署毫无察觉;正则不认它,关卡因此放行。于是公钥字节原样进入 hmac.new,与攻击者用同样字节签出的令牌严丝合缝。混合算法允许列表让攻击者可以选择验证算法,alg=HS256 一个头字段,就把"密钥的解释权"从非对称算法切换到对称算法。修复把结构正则换成标记扫描 + 位置配对的状态机,覆盖了已知逃逸形态;"同一事实的两个副本"这一结构性问题,则留待生态逐步解决。

4. 漏洞利用与触发分析

原理侧的"为什么"已经论证完。现在换到攻击者视角:攻击者拿到这个漏洞,每一步为什么那样做、需要知道什么、能走多远。这里拆的是 PoC 每个构造背后的设计意图;完整代码在 reproduce/poc/poc.py,约 200 行,含自检与退出码,不整段倾倒。

4.1 利用链全景:五步走

PoC 的主线是一条五步链,图 5 自上而下正对 poc.py 的执行顺序。真正动手的只有 step 4;step 3 与 step 5 是两道浅绿色的对照,架在攻击前后,各问一句——关卡对规范形态是否仍然有效?配置级缓解是否真的有效?

step 4 并非单个动作,而是对三种变异各跑一遍的循环:变异、签名、本地 jwt.decode 预验、递交 /protected,细节在下面展开。每个节点的"目的/依据/预期"栏也不是注释,是判据——step 3 与 step 5 的两个预期异常若没出现,说明环境或对照本身不成立,step 4 的那个 200 便失去了证明力。五步在日志里各有对应位置,后文逐一说明。

4.2 每个构造为什么是这样

变异函数一共三个,全在 poc.py:43-47:

MUTATIONS = {
    "tab-before-END-marker": lambda k: k.replace(b"-----END", b"\t-----END", 1),
    "bare-CR-line-endings": lambda k: k.replace(b"\n", b"\r"),
    "folded-single-line": lambda k: b" ".join(k.split()),
}

这三个 lambda 不是随手挑的。各自以一种不同的字节操作独立违反那四条正则约束,其中两个落在 R1、一个落在 R2:tab 变体踩 R2,END 五连杠不再紧贴 LF;裸 CR 与折叠踩的都是 R1,整串再无 LF 供 \r?\n 对齐。折叠要多说一句——它把空白归一成单个空格,动作看起来最大,但 BEGIN/END 标记拼写原样保留,并不触及 R3,"只踩 R1"的逐字节推演前面已经做过。三种变异覆盖"行尾替换""标记相邻插入""整体重排"三类字节操作,对应现实里三类配置管道损坏(前面的映射表)。攻击者视角下,这三个变异是对部署密钥形态的三个假设——哪个假设对了,哪个令牌就能通过。

令牌里装什么 claims,poc.py:78-82 只写了四个字段:

claims = {"sub": "attacker", "role": "superadmin", "iat": now,
          "exp": now + 600}

iat 取当前时刻、exp 外推十分钟,为的是干净地穿过声明层的时间校验:_validate_claims 的时间类校验器有三道(iat/nbf/exp),claims 只带上用得着的两道——iat=now 不在未来,exp=now+600 未过期,nbf 缺席则对应校验器直接跳过(帧 15 已逐项走查过)。sub 与 role 刻意取"提升权限"语义的值。role=superadmin 不参与任何库级校验,它只服务于一件事:让越权的后果在响应中直接可见。伪造一个 sub=boring 的令牌证明不了影响,伪造 superadmin 可以。

签出的令牌不直接打远端。poc.py:163 先在本地 jwt.decode(forged, mutated, algorithms=["ES256", "HS256"]) 走一遍,把"库的漏洞"与"应用的暴露"分开:本地验证通过,证明关卡放行、签名自洽,纯库层面的问题成立;之后打远端 /protected,检验的才是应用的暴露面。日志里因此能看到三个变异都是 jwt.decode (local, mixed) -> accepted,而 GET /protected 只有一个 HTTP 200——两层结论互不掩盖。

对照组的角色交给 step 3 与 step 5,分别隔离"密钥形态"与"算法列表"两个变量,同时证明漏洞的存在、防护的部分有效性与缓解的充分性。至于把 PoC 的全部输出当作控制变量实验来复盘(step 1 的诊断、step 3/5 的两道对照、外加 2.14.0 侧的版本对照),前面已经做过一遍,此处不重复。

4.3 前置条件与攻击者知识要求

前面把可利用部署归纳成四项前置条件:前三项是部署属性,第 4 项因公钥按定义公开而天然成立。换到攻击者一侧,四项里有两项要落实成"要获取什么、要试什么"。

公钥内容(前置条件 4 的攻击者侧)向来不是问题:/pubkey、JWKS 端点、代码仓库、证书透明日志……公开渠道很多,这一项几乎总是满足。真正未知的,是部署密钥的字节形态(前置条件 2 的攻击者侧视角)。前置条件 2 说的是部署"以变异形态持有密钥",到了攻击者手里,它变成一个未知数:配置管道把公钥弄成了三种变异里的哪一种?严格说,这是整条利用链上唯一真正的"门槛"。三种变异的在线枚举在无速率限制的环境里把门槛降到几次请求的代价;有 WAF 或限流时,就得改从目标的基础设施特征推断配置链路——什么编排工具、什么模板引擎——把假设空间压到一两次尝试以内。

剩下两项(混合允许列表、cryptography 已安装)不消耗攻击者的任何动作,纯由部署决定。整场攻击无认证、无交互、无特权,CVSS 向量里的 PR:N/UI:N 用在这里是准确的。

4.4 绕过了什么,没绕过什么

这个漏洞不涉及内存破坏,ASLR、NX、沙箱这些词与它无关。绕过的"防护"全在逻辑层,三道。第一道是核心——算法混淆关卡:变异形态绕过 is_pem_format,核心绕过点就在这里。密钥长度检查是第二道:公钥长度远超 HMAC 下限,长度检查在结构上就管不到它,即前面点名的那个无效缓解开关 enforce_minimum_key_length。第三道是声明校验,帧 15-16 走查过的"先签名后声明"顺序:签名一伪,八个校验器全部不生效。

它没有绕过的,同样要数清:官方确认不受影响的两条路径——单一非对称算法列表(强制以 InvalidAlgorithmError 拒绝)与 PyJWK 绑定路径——外加规范形态下的 PEM 关卡本身,step 3 的对照证明它对规范输入仍然工作。攻击面于是被精确地限定在"混合列表 + 原始字节密钥 + 变异形态 + cryptography 已安装"的交集上,缓解措施的着力点也正在这里。

4.5 动态证据

复现环境在三个位置留下了关键状态的字节级证据。

服务启动时是第一个位置(server-vulnerable.log 第 8-9 行)。VERIFY_KEY 的 %r 全文输出,紧接着 is_pem_format 的判定结果——这是攻击发生前,关卡对部署密钥的判定记录:False -> HMAC guard BYPASSED。

第二个位置在验证日志,即 server-vulnerable.log 的 [verify] 行:path/alg/状态码 加上 sub/role(或错误类型),把同一秒内的分岔完整记了下来——那一秒共有五条 [verify] 行(第 15/18/20/22/24 行)。第 15 行是 step 1 的环境自检(ES256 200);第 18 行与第 22 行是 tab、折叠两个不匹配变异的 HS256 401;第 20 行是命中配置形态的裸 CR 变异,HS256 200(sub=attacker role=superadmin);第 24 行是 step 5 打到 /protected-strict 的 InvalidAlgorithmError 401。攻击者视角的读法只有一句:那两条 401 不是失败而是探测——方向对、字节形态错,换一种变异再试就是(demo 的响应体回显了错误类型,"方向对"才读得出;不回显的服务上就枚举到 200 为止,3.7.2)。前面已把这一秒当作整场漏洞的缩影逐行走读,机理不在此重复。

第三个位置在 PoC 侧的输出(poc-vulnerable.log)。step 2 与 step 3 先给出规范形态的基准读数:is_pem_format(canonical) -> True(第 23 行)、jwt.encode -> rejected: InvalidKeyError(第 28 行)——关卡对规范 PEM 的有效性由此留证。到了 step 4(第 31 行起),每个变异块里 is_pem_format(mutated) -> False 与 cryptography loader -> accepted 并排打印,裸 CR 块的第 42-43 行就是一例。检测面与加载面在同一份字节上作出相反判定——这两行是本漏洞最直接的运行时证据。

修复对照侧再补一组:server-fixed.log 第 9 行的 jwt.utils.is_pem_format(VERIFY_KEY)=True -> HMAC guard ACTIVE,与 poc-fixed.log 第 37/42/47 行的三行 jwt.encode HS256 -> rejected: InvalidKeyError,构成同一组观察点在 2.14.0 上的对照读数(检测面同步翻转:同文件第 35/40/45 行的 is_pem_format(mutated) -> True)。这些日志如何产生、如何一键拿到,下文有完整步骤。

5. 复现过程

前面逐行读过的那些日志,来历只有两行命令。下面按一次真实运行的顺序把过程摊开:脚本做什么、每一步预期看到什么、手动照做会在哪里卡住,让每个行号都能在你的终端里重新出现。分工沿用前面的划法:证据形态怎么读、对照组各自隔离了什么变量,前面都已交代,这里只给执行记录与预期输出,环境准备也不再重复。

5.1 一键复现

cd reproduce
./verify.sh

脚本先构建两个镜像,再启动 vuln-app(:18080)与 fixed-app(:18081)。就绪与否靠轮询 /version 探活:curl 每秒一次,至多 60 秒——compose 没有配置 Docker healthcheck,就绪判定完全依赖这个探活。两套环境各跑一次 PoC,tee 实时落屏并写入 evidence/poc-*.log;服务端日志随后导出到 evidence/server-*.log。

下面四张截图取自一次完整实测的终端记录(在隔离容器的 /reproduce 内执行,按时间顺序连续截取),可与本节及后文的描述逐张对拍:

判成败以两侧 PoC 输出的 verdict 段为准;退出码的语义要读准。每个 PoC 进程在自己的版本上表现出预期行为时退出 0——2.13.0 复现成功、2.14.0 拒绝伪造,都算"符合预期"。脚本最后一行是 exit "$rc_vuln",退出码 0 只保证受影响侧符合预期;对照侧的结果只体现在收尾提示上——两侧都为 0 时打印 [+] reproduction driver finished with expected outcomes (see verdicts above),否则打印 [!] unexpected PoC exit codes: vulnerable=… fixed=…。边界在一种组合上:漏洞侧为 0、对照侧非 0,此时脚本提示 unexpected,退出码仍是 0。要把 verify.sh 接进自动化流水线,请解析两段 verdict 或检查这行提示,不要只看退出码。

脚本跑完,两个服务保持运行,终端提示 stop with: docker compose down,方便接着手动探查;清理命令见后文。

5.2 预期输出(实测记录:Docker 官方 python:3.12-slim,PyJWT 2.13.0/2.14.0,cryptography 50.0.2,Python 3.12.15)

先读受影响侧。evidence/poc-vulnerable.log(PyJWT 2.13.0)按运行顺序往下走,下面的摘录逐字取自存档、行号括注在侧,可与你的输出逐行对拍。

step 1-3 给出三道基准读数:第 14 行的环境自检 GET /protected (ES256) -> HTTP 200 sub=legitimate_user role=user,第 23 行的规范形态识别 is_pem_format(canonical) -> True (guard recognizes canonical PEM),第 28 行的规范形态对照——这一行在两个版本的日志里逐字相同:jwt.encode -> rejected: InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

step 4 是攻击本身,三个变异块各六行。决定性的裸 CR 块逐字摘录(第 41-46 行):

--- mutation: bare-CR-line-endings ---
is_pem_format(mutated)    -> False
cryptography loader       -> accepted
jwt.encode HS256          -> forged token {'sub': 'attacker', 'role': 'superadmin'}
jwt.decode (local, mixed) -> accepted
GET /protected (forged)   -> HTTP 200 sub=attacker role=superadmin

另两个变异,tab-before-END-marker 与 folded-single-line(第 34-39、48-53 行),六行结构相同,只有末行换成 GET /protected (forged) -> HTTP 401 error=InvalidSignatureError detail=Signature verification failed(第 39、53 行)。看到这两行 401 不要以为是复现失败——字节形态与服务端配置不一致,HMAC 语义下必然如此;反过来看,这两行正是攻击必须"命中确切形态"这一性质的直接展示。

step 5 轮到严格列表对照,第 58 行:mutation 'bare-CR-line-endings' vs /protected-strict -> HTTP 401 error=InvalidAlgorithmError detail=The specified alg value is not allowed。verdict 段(第 63-66 行)给出最终判定:

[+] VULNERABILITY REPRODUCED on PyJWT 2.13.0
[+] /protected accepted a forged HS256 token signed with the
[+] MUTATED PUBLIC KEY as HMAC secret (variant(s): bare-CR-line-endings)
[+] impact: arbitrary claim forgery, e.g. role=superadmin

换到修复侧。evidence/poc-fixed.log(PyJWT 2.14.0)的 step 1-3 三行基准与受影响侧逐字相同(第 14/23/28 行)——同一 PoC、不同版本,基准读数不变;变的从 step 4 开始。三个变异块(第 34-37、39-42、44-47 行)的检测面读数全部翻转为 True,签发一律被拒。裸 CR 块逐字摘录(第 39-42 行):

--- mutation: bare-CR-line-endings ---
is_pem_format(mutated)    -> True
cryptography loader       -> accepted
jwt.encode HS256          -> rejected: InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

没有令牌能签出,受影响侧末尾的本地预验与递交两行便无从发生,变异块因此每块只有四行;step 5 随之跳过,第 52 行留下一句 (no forged token was accepted; skipping)。verdict 段在第 57-58 行:

[-] not exploitable on PyJWT 2.14.0: mutated PEM rejected as an
[-] HMAC key (InvalidKeyError) -- the PATCHED (expected) behaviour

服务端也有对照,同一次运行里两个容器的启动判定,各取第 9 行(引文行首的时间戳略去)。evidence/server-vulnerable.log:

[INFO] jwt.utils.is_pem_format(VERIFY_KEY)=False -> HMAC guard BYPASSED, HMACAlgorithm.prepare_key returns these public-key bytes as a symmetric secret

evidence/server-fixed.log,=True 后面跟着两个空格,与 =False 一侧的箭头对齐:

[INFO] jwt.utils.is_pem_format(VERIFY_KEY)=True  -> HMAC guard ACTIVE, key would be rejected as an HMAC secret

5.3 结果对照表

两套环境、一次运行,全部观察点的两版本读数收在同一张表里(逐格证据行号见后面的证据清单):

观察点

2.13.0(vuln-app)

2.14.0(fixed-app)

启动时 is_pem_format(裸\r公钥)

False(未识别)

True(识别为 PEM)

规范 PEM 当 HS256 密钥

InvalidKeyError

InvalidKeyError

三种变异 is_pem_format

全部 False

全部 True

三种变异被 loader 接受

全部接受

全部接受

变异公钥签发 HS256 令牌

成功

InvalidKeyError,无法签出

形态匹配的伪造令牌 vs /protected

HTTP 200 superadmin

(无从签出)

伪造令牌 vs /protected-strict

InvalidAlgorithmError

(未测:无伪造令牌可签出,step 5 跳过)

第 4 行在两个版本上一致——加载面从来都接受这些变异,这与"修复只改检测面"的分析互为印证。第 7 行修复侧是"未测"而不是"通过":修复侧根本签不出伪造令牌,严格端点对照自然缺位(poc-fixed.log 第 52 行)——这不是复现的遗漏,是修复生效的连带结果。

5.4 手动步骤与注意事项

手动复现的完整命令序列,前文已列全:构建、启动、探活、两环境各跑一次 PoC、导出服务端日志、收尾。这里只单独拎出最容易写错的一步:

docker compose run --rm -T vuln-app  python /poc/poc.py http://vuln-app:8080
docker compose run --rm -T fixed-app python /poc/poc.py http://fixed-app:8080

这两行最容易写错的是目标地址。PoC 要打容器网络名(http://vuln-app:8080),不是宿主机视角的 127.0.0.1:18080——docker compose run 起的容器挂在同一 compose 网络里,用服务名互访;18080/18081 两个端口映射是给宿主机浏览器和 curl 用的。

手动路径在同一次实测里的完整终端记录如下(依次为构建启动 + 探活、受影响侧 PoC、修复侧 PoC;构建全部命中缓存,因为镜像刚由 verify.sh 构建过——这次手动执行正是后文所说的"PoC 运行了两次"中的第二次):

401 也要数准。一次完整运行里预期出现三处:tab 与 folded 两个变异打 /protected 的两行 InvalidSignatureError(上文已引,第 39/53 行),加上 step 5 打 /protected-strict 的 InvalidAlgorithmError——那是缓解对照的预期拒绝。401 本身不是失败信号,判成败看两侧的 verdict 段;手动跑时,每个 docker compose run 进程自己的退出码就是该侧 PoC 的判定,一键跑时的退出码语义前文已说明(只反映漏洞侧)。

服务进程一启动就现场生成 ES256 密钥对,密钥每次启动随机——重启容器后 /pubkey 的内容会变,旧的伪造令牌自然失效;想留存某个现场,用 verify.sh,它把四份证据都写进 evidence/。

复现是本地隔离的:端口仅绑回环,不触达宿主网络之外;用完 docker compose down 清理,要连镜像一起删就 docker compose down --rmi all --remove-orphans(目录文件保留,可随时重建)。

5.5 证据清单

文件

内容

关键行

evidence/poc-vulnerable.log

2.13.0 上的完整 PoC 输出(攻击+对照)

规范基准:第 23 行(is_pem_format(canonical) -> True)与第 28 行(step 3 遭 InvalidKeyError 拒绝);检测/加载分岔:第 42-43 行(裸 CR 块,tab、折叠两块见第 35-36、49-50 行);伪造令牌通过 /protected:第 46 行;strict 对照:第 58 行;verdict:第 63-66 行

evidence/poc-fixed.log

2.14.0 上的同一 PoC

检测面翻转读数:第 35/40/45 行;三行 rejected: InvalidKeyError:第 37/42/47 行;step 5 跳过:第 52 行;verdict:第 57-58 行

evidence/server-vulnerable.log

受影响服务端日志

第 8 行字节快照、第 9 行 BYPASSED 判定;同一秒内的五条 [verify] 行:第 15/18/20/22/24 行(依次为 ES256 200、tab 401、裸 CR 200、folded 401、strict 401)

evidence/server-fixed.log

修复版服务端日志

第 9 行 guard ACTIVE(=True 后双空格对齐)

存档的两份 server 日志里各有两轮同构请求:证据采集期间 PoC 运行了两次,docker compose logs 是追加式留存,两轮都进了文件;poc-*.log 由 tee 每次覆写,只留一轮。两轮读数一致,判读任取一轮均可,本文引用的都是第一轮。

6. 修复、检测与总结

复现的证据都归了档,该收尾了。三件事:官方的修复覆盖到了哪一层,暂时不能升级的部署能做什么,防御方怎么判断自己是否已被利用。三件说完,退后一步看这个漏洞本身——发现思路能不能推广,同样的代码模式还出现在哪里。

6.1 补丁分析:修复了什么,残留了什么

修复(commit 8b4e233a)的完整技术评估前面已经做完,这里只做定级。

已修复的部分摆在明处。第 III 象限的三个已知变异族——END 前空白、裸 CR、单行折叠——全部落入识别范围;识别器从"结构正则"换成"标记配对状态机","结构变形"这一整类逃逸方向随之消失,任何不破坏标记拼写的变形都不再能绕过;复杂度顺带从最坏平方级降到线性;调用点零改动,一处修复,签发与验证两端同时生效。运行时的证据,就是前面摘录过的那段 verdict——poc-fixed.log 第 57-58 行,最后一行以 "the PATCHED (expected) behaviour" 收尾。

再看未修复的那一层。"什么是 PEM"仍然是两个独立的实现,is_pem_format 一份,cryptography 的加载器一份。这次修复靠人工对齐了当前已知的接受面,没有建立任何自动同步机制;标签集仍是封闭的十六个,标记拼写的宽容度没有扩大,加载器将来若接受新的标签或更松的拼写,缝隙理论上会重新出现。修复注释自述的目标是"接受 cryptography loader 所接受的格式变体"(commit 注释直译),未承诺覆盖加载器未来接受的新形态。

定级一句话:对"已识别的检测面缺口",是完整修复;对"双实现漂移"这个结构性问题,只是部分缓解。在不动公开 API 兼容承诺的约束下,这已经是约束内的最优解;约束外的解——强制 PyJWK 绑定,即前面讨论过的方案 C——留给未来的破坏性版本去回答。

6.2 缓解措施:不能立即升级时

缓解按"消除前置条件"的思路排序,前两条恰好就是官方确认的那两条"不受影响路径":

  1. 算法允许列表单一化,消除的是前置条件 1。从每个 jwt.decode 的 algorithms 里去掉与主算法不同族的成员,非对称验证的端点只留 ["ES256"](或对应算法)——一行配置级的改动。缓解效果不必推测,受影响版本上的对照组直接证明过(对照 C3,poc-vulnerable.log 第 58 行):伪造令牌在允许列表检查处即遭 InvalidAlgorithmError,根本走不到密钥检查。
  2. 改走 PyJWK/PyJWKClient 路径,消除的是前置条件 2 的"原始字节"这一半。密钥在构造时绑定算法,验证时强制 alg == key.algorithm_name,官方确认该路径不受影响。
  3. 密钥形态规范化,消除的是前置条件 2 的"变异"这一半。审计密钥从文件、配置、环境变量进入应用的整条管道,确保以规范 PEM(标准换行,标记前后无多余空白)传入。前面的映射表可以直接当检查清单用,重点排查缩进的配置块、单行字符串载体、老式行尾转换。
  4. 监控辅助。四条里唯一不消除前置条件的一条,只压缩"从被试到被发现"的时间:给验证入口配置 alg 分布与异常状态码的告警,具体的日志指纹下面展开。前面说过,"静默损坏"自身不产生任何日志,攻击试探是它在流量侧唯一的暴露窗口——没有这层监控,连这个窗口都不会开。

还有一点:WAF 无法起作用。这是纯逻辑缺陷,伪造令牌的签名是一次合法的 HMAC 计算,载荷是寻常的声明集,流量层面没有可拦的特征串。唯一暴露在流量里的信号是 alg 头,而它适合做告警的依据(上一条与后面的检测方法),不适合做拦截的依据——混用列表的部署里,HS256 本就可能是合法业务,网关统一拦截会先影响合法业务。缓解的重点在代码与配置,不在流量层。

6.3 检测方法:判断是否已被利用

范围由版本划:pip show PyJWT 或依赖清单,≤ 2.13.0 且满足前置条件的,才需要往下查;Tenable 已发布 Nessus 插件 351353 做版本检测。

暴露面由代码定。全库搜 jwt.decode 与 decode_complete 的调用点,一看 algorithms 实参是否混用 HS* 与 RS*/ES*/PS*/EdDSA,二看 key 实参的来源是否为原始 PEM 字节(而非 PyJWK)。两条都命中的调用点,再核对密钥字节的实际形态规不规范——三层筛下来,剩下的就是暴露面。

"是否被试过"要问日志。没有公开 IoC,但利用尝试有清晰的指纹:

  • 一个"业务上只有非对称令牌"的端点,验证记录里出现 alg=HS256:成功即 200;失败表现为 InvalidSignatureError,注意不是 InvalidKeyError——前者说明关卡已放行、只是签名对不上,按前面的读法,攻击者已进入"换形态再试"的枚举阶段。这一条本身就是高价值告警。
  • 短时间内同一种失败密集反复,InvalidSignatureError 一条接一条,最终转 200——与 3.7.2 分析的在线枚举行为吻合。
  • 事后排查,可在测试环境重放同一套探针:取部署的实际验证密钥字节,打印 is_pem_format 判定,再交给加载器解析一次,把两个读数并排放。判据就是这对不一致的读数——poc-vulnerable.log 第 42-43 行,False 与 accepted 并排。判定为 False 且加载器接受、允许列表混用的部署,即构成"曾经可被利用"的技术确认(2.13.0 上)。同一探针在 2.14.0 上检测面读数全部翻转为 True(poc-fixed.log 第 35/40/45 行),升级后复跑一次,兼作修复验证。

6.4 延伸思考

先说这个漏洞是怎么被发现的。从通告的 PoC 形态倒推,发现路径大概率是"差分测试"式的:拿同一批密钥形态,分别输入检测面(is_pem_format)与加载面(load_pem_public_key),找出两者判定不一致的样本。这个模式可以一般化:

任何"上游自写识别器 + 下游库真实接受面"的组合,都是一个潜在的差分测试目标。识别器判定"不是"而下游判定"是"的每一个样本,都是上游防护的第 III 象限。

顺着这条思路在 PyJWT 2.13.0 里还能圈出至少三个同模式的坐标:is_pem_format 对 load_pem_public_key,本 CVE 是其一;is_ssh_key 的零偏移前缀对 load_ssh_public_key,即前文的"前缀匹配只认偏移 0";JWK-JSON 探测的 json.loads 对"密钥文档"的真实边界,即前文的 except ValueError 放行解析失败者。本文只证实了第一个,后两个仍是未经验证的开放问题。

把"双实现漂移"当成一类漏洞模式看。这个 CVE 的根因不在某行代码写错,而在同一事实——什么是 PEM——存在两个副本,且没有同步机制。识别器按 RFC 7468 的严格生成面写,加载器按它的宽松解析面实现,规范本身就包含这对矛盾。类似的"副本对"在软件里到处都是:协议解析器与协议校验器,WAF 规则与后端解析(HTTP 请求走私一族的同款根因),权限检查与资源寻址。副本各自演化,缝隙通常要到被利用时才暴露。防御上可操作的检查是:凡是发现自己在"重新实现下游已经实现的判断",先确认两个实现以哪个为准、能否让判断只有单一出处。PyJWT 修复注释里那句 "Accept PEM formatting variants that the cryptography loader accepts",说明维护者接下了"人工同步"的义务——同步机制本身并未建立。

最后回到 JWT 生态的一个长期取舍:易用性与安全契约。alg 头由令牌携带,算法对象由头选择,密钥由部署提供——这套 API 设计是易用性的产物,一次调用完成验证;而 RFC 8725 第 2.1 节描述为典型威胁、第 3.1 节以"显式算法验证"明令防范的"不要信任令牌头里的算法选择",本质上是要求部署者替库承担结构性的约束。PyJWT 在 2.x 时代的修复史——强制 algorithms 参数、PEM 关卡、JWK 拒绝、PyJWK 绑定、这次的标记扫描——是一条清晰的路线:库一步一步把"结构性安全"收回自身,同时不破坏公开 API 的契约。CVE-2026-102268 暴露的不是库没有设防,而是库设的防依赖了一个会漂移的判据。对写代码的人,这条教训可以概括为——

关卡的可靠性不取决于检查多严,而取决于识别面与接受面是否对齐。对齐维度错误的严格检查,比维度正确的宽松检查更危险:它制造虚假的安全感,同时放行真正的威胁。

6.5 致谢与声明

漏洞的发现与报告属于报告者 e1024x,修复属于 PyJWT 维护者 José Padilla(见 GHSA-ffc3-869f-jxw9);本文的调用链走查、正则推演、Docker 复现环境与全部证据日志为独立完成,引用外部事实处均已标注来源。复现环境仅用于本地研究,请勿对未授权目标使用文中技术。