同一个漏洞,修了三次
不需要密码,照样能拿到管理员权限。这就是一个伪造的令牌,针对 fast-jwt 的 RS256 验证器所能换来的结果——一个连续补了三次才真正堵住的漏洞。2026 年 10 月 8 日,GitHub Advisory Database 发布了 CVE-2026-107722,这正是那份让修复终于站得住脚的报告。[1] 请慢慢读这个标题:“Incomplete patch of CVE-2026-34950”,意思是“CVE-2026-34950 的补丁并不完整”。现在再看 CVE-2026-34950 自己的标题,来自今年 4 月:“Incomplete fix for CVE-2023-48223”,意思是“CVE-2023-48223 的修复并不完整”。[3]
三份安全公告,一个库,一类漏洞。每一次修复都恰好堵住了上一位研究者演示过的那个输入,而每一次,又会有人带着一个略有不同的输入直接绕了过去。
这条链条才是真正的故事所在。这个漏洞本身是算法混淆的教科书式案例,如果您只记住这一点,那就升级到 6.3.0 然后继续前进就好。真正值得花时间琢磨的,是为什么两次由能力扎实的人做出的修复都失败了,而第三次没有——因为这个差异,周一就能用到您自己的补丁上。
我没有在实验环境中复现过这个漏洞。以下所有内容都来自该安全公告、它之前的那份公告,以及修复两者的代码差异。[1][2][3]
CVE-2023-48223 the original
v
CVE-2026-34950 "incomplete fix" 9.1
v (added trim)
CVE-2026-107722 "incomplete patch" 9.8服务器信任的是密钥,而攻击者控制的是字符串
JWT 算法混淆,用一句话概括:令牌本身告诉服务器该如何验证它。如果一个应用用 RS256 签名,而验证器能够被诱导改用 HS256,那么 RSA 公钥就不再是一把公钥,而变成了一个共享密钥。它是公开的,攻击者早就拿到了。攻击者用它给令牌签名,服务器计算出同样的 HMAC 值,签名就对上了。
fast-jwt 有意思的地方,在于这个判断究竟是在哪一步做出的。这个库通过把密钥当作一个字符串来观察,以此判断它是否为非对称密钥:如果看起来像 PEM 格式的区块,就按 RSA 处理;如果不像,就当作一段原始的 HMAC 密钥。[1]
这是一个合理的做法,几乎所有 JWT 库都会做类似的事情。但这也意味着,分类结果取决于文本匹配,而文本匹配存在密码学本身并不存在的边界情况。
为什么 trim() 看起来像是正确的修复方式
今年 4 月,CVE-2026-34950 报告称,一个开头的空格就能绕过 PEM 检查。[3] 当时的匹配器是一个锚定在字符串开头的正则表达式,所以一个以空白字符开头的密钥无法匹配,于是被归类为原始密钥,HMAC 路径由此打开。
6.2.0 版本中的修复,是在检查之前加了一次 trim() 调用。[1][2] 这个改动很小,对于已报告的那个输入来说显然是正确的,而且随附了回归测试。我猜大多数审查者会在不到一分钟内批准它,我自己大概也会是其中之一。
但那个被报告的输入,从来都不是问题的根源。问题出在“锚定”本身。一个以插入符号开头的正则表达式,隐含了一个假设:PEM 头部一定位于字符串的第 0 个位置。trim() 并没有消除这个假设,它只是让这个假设对某一类字符成立而已——因为 String.prototype.trim 只会移除 ECMAScript 规范所定义的“空白字符”。[1]
除此之外的一切依然照常运作。随真正的修复一同发布的回归测试,列举了实践中“除此之外”究竟意味着什么:一个 NUL 字节、一个转义字符、DEL、零宽字符、一行注释、一层 HTTP 包装。[2] 只要这些字符中任何一个出现在 PEM 头部之前,这把密钥就又会被当成一段原始密钥。
这份公告说得比我更到位:那次修复在文本层面过于狭窄,只处理了空白字符,而没有解决整个问题类别。[1]
// 6.1.0 and earlier
/^-----BEGIN(?: (RSA))? PUBLIC KEY-----/
// 6.2.0: trim() first, same anchor
trimmedKey.match(publicKeyPemMatcher)
// a leading NUL still walks past it第三次修复改变的是规则本身,而不是某个具体输入
来看看 6.3.0 到底做了哪些真正不同的事情,因为它并不是又加长了一份要剔除的字符列表。[2]
它彻底放弃了“锚定”这个思路。新的匹配器会在字符串的任意位置查找 PEM 头部,并从那个位置开始截取密钥。只有当一个密钥完全不包含 PEM 头部时,才会被当作原始的 HMAC 密钥;而一旦出现了头部,这个密钥就永远不可能再退回到 HMAC 路径。X.509 检查也从 includes 改成了 startsWith。一个带有 PEM 标记、但又无法被清晰分类的密钥,现在会直接抛出错误,而不是悄无声息地落入 HMAC 分支。
这次提交说明用一句话概括了新的规则,这正是值得“借用”到您自己代码库中的那种表述:一旦出现头部,密钥就必须被归类为非对称密钥材料,并且永远不能退回到 HMAC。[2]
前两次修复把某个具体输入拉进了黑名单。第三次则把某种形状纳入了白名单。差别就在这里,也正因为如此,这一次才真正堵住了整个问题类别,而不是又多堵了一个实例。
// 6.3.0 - find it anywhere
/-----BEGIN [A-Z0-9 ]+?-----/
const { pem, isRawSecret } =
locatePem(trimmedKey)
if (isRawSecret) return hsAlgorithms
// unless algorithms explicitly pins HS256 - that still skips this check固定算法有用,但固定错了算法没用
我敢打赌,很多人会在这里栽跟头。这份公告列出了存在漏洞的配置:没有设置 algorithms 白名单、使用了异步密钥回调函数,或者把 algorithms 设置成了 HS256。而被列为安全配置的,是把 algorithms 设置成 RS256。[1]
固定算法是应对算法混淆的标准建议,而且是正确的建议。但如果一个团队只是粗略浏览公告,在攻击描述里看到 HS256 字样,就把 algorithms 固定成了 HS256,那就等于把验证器固定在了攻击者的选择上。白名单里应当写明的,是您实际用来签名的算法,而不是您在分析文章里读到的那个。
当我审查身份认证代码时,这正是我首先会留意的模式:一项防护措施是照着事件报告配置出来的,而不是根据系统自身的设计来配置的。它能通过审查,是因为它看起来明显就是一项防护控制——只不过方向恰好反了。
您的服务是否真的受到影响?
影响范围比 9.8 这个评分暗示的要窄,而且需要三个条件同时成立。[1]
第一,针对这个具体 CVE,实际解析到的 fast-jwt 版本需要落在 6.2.0 到 6.2.4 之间;6.1.0 及更早版本同样不安全,只不过暴露在这条链路上的第二个漏洞 CVE-2026-34950 之下。请检查 npm 实际安装的版本,而不是 package.json 中声明的版本,也要检查传递依赖:fast-jwt 是 @fastify/jwt 的默认 JWT 后端,很多团队其实是在未曾主动选择的情况下用上了它。[1]
第二,验证器的配置方式属于上面提到的某一种存在漏洞的配置。第三——这也是唯一一条没有人能替您核实的——您实际配置的密钥,在其 PEM 头部之前带有一个非空白字符。如果密钥是干净的,就完全不受影响。
最后这一条,取决于密钥是如何到达进程的,而不是取决于您的代码本身:从文件中读取、粘贴进环境变量、从密钥管理服务中取出,或者被部署脚本拼接上了一个标签。这类情况在实际环境中出现的频率,我没有数据,公告本身也没有给出,所以请把它当作一个需要排查的方向,而不是一个概率数字。
今天应当检查的事项
三件事,按照我自己会执行的顺序排列。
先升级到 6.3.0,之后确认实际解析到的版本号。然后明确设置 algorithms 白名单,写明您的服务真正使用的那个非对称算法。接下来——这一条会在这次 CVE 之后依然有用——在启动阶段就校验验证密钥的形状,而不是无条件信任文件或环境变量递给您的任何内容。
这第三项检查只需要一行代码,却足以让这三个 CVE 中最近的两个,在您的服务上完全无法被利用,包括下一个同类问题。(第一个 CVE-2023-48223 的形态有所不同——它是旧匹配器没能识别出来的一个合法密钥,而不是密钥前面多出来的垃圾前缀——所以这项具体检查并不会拦下它。)
这才是真正的教训。一个同一类问题已经补了三次的库,很可能还会再出问题;而一个只接受“完全就是一个 PEM 区块”的密钥加载器,根本不在乎这些。
# the version that actually loads
npm ls fast-jwt
# pin the algorithm you really use
{ algorithms: ['RS256'] }
# assert the key's shape at boot
key.startsWith('-----BEGIN')要修复规则,而不是只回应报告
这几次修复,每一次都是由称职的人针对真实报告、带着测试完成的。问题并不出在不够用心,而是出在范围没有划对:报告描述的是一个具体输入,于是修复也只针对这个输入,而真正让这个输入变得危险的那条规则,原封不动地留了下来。
我在审查补丁时故意会问一个让人不太舒服的问题:这份报告所举的例子,属于哪一整类输入,而这次修复是否堵住了这整类输入?如果答案是“没有”,那您并没有防住下一个 CVE,只是替它定好了日期。
这个问题,差不多就是安全代码审计工作的核心所在。把一份报告一路追溯到使它成为可能的那个决策点,再判断这次修复究竟改变了那个决策,还是只是消除了表面症状——这正是我在客户代码上经常做的工作,而且恰恰是任何扫描器都永远不会标记出来的那类问题,因为打过补丁的版本号看起来完全正确。如果您希望在别人发现补丁中的漏洞之前,先有一双攻击者的眼睛审视它,这正是值得开启的那场对话。
参考来源
需要在您自己的产品中检查这个问题吗?
安全代码审计想学会自己发现这类漏洞吗?
安全代码审计课程(一对一)