一个从未"到达"的请求:CVE-2026-76169,Fastify 畸形 URL 绕过封装 404 处理器认证
字数 5218
更新时间 2026-10-25 20:04:31

一个从未"到达"的请求:CVE-2026-76169,Fastify 畸形 URL 绕过封装 404 处理器认证

一句话结论:fastify 的 404 路由器在处理畸形 URL 时,会在前缀匹配发生之前,经由一个跨插件共享的处理器指针直接调用「最后注册」的那个封装 404 处理器,从而绕过该处理器声明的全部生命周期钩子(含写在 preHandler 里的认证)——这是一条 CWE-288 的替代路径认证绕过。本文完整保留漏洞成因链、补丁原理与检测缓解,不复述可直接照抄的攻击脚本行。

排版说明:正文按「现象 / 成因链 / 触发前提 / 补丁 / 缓解 / 检测 / 延伸」组织;引号统一用直角引号(仅标题按原文保留 ASCII 双引号),公式一律写成纯文本,路径与编码以无转义写法呈现。

一、现象:一个在框架日志里没有「到达」记录的请求

同一个 fastify 实例的两段日志形成对照。一次正常授权访问留下四行——请求进入、认证钩子执行、处理器执行、响应完成——这是框架与开发者之间的契约:你注册的每一个钩子都会在它该在的时刻运行。一次攻击请求却只留下一行「处理器执行」:没有 incoming request、没有 preHandler invoked、连 request completed 都没有。一个拿到 200 与受保护数据的未认证请求,在框架日志里没有留下任何到达记录。

关键在于它的定性——它不是「绕过了认证判断」,而是认证代码根本不知道有这么一个请求存在。它走的是框架自身的一条内部直调路径,一条至少从四代起就存在的路径。

二、漏洞基本信息

编号 CVE-2026-76169,安全通告 GHSA 对应编号,类型归入 CWE-288(使用替代路径或通道的认证绕过)。这是逻辑缺陷、不是内存破坏:没有溢出、没有悬垂指针,攻击者拿到的是「本不该给他的响应」,不是指令指针。受影响范围是某个大版本线的一段区间,修复版本是一段线里唯一有补丁的版本、另一条更早的版本线没有补丁、必须跨大版本升级。官方评分 CVSS 三点一为 7.5 HIGH,向量强调网络可达、复杂度低、无需权限、无需用户交互、单请求完成,危害限定在机密性(可读到受保护 404 处理器的完整响应,不能直接篡改)。

两点值得单独记。评分里的「复杂度低」说的是单次利用无难度,但漏洞带结构性前置条件:应用必须是「自定义 404 处理器 且 认证写在钩子里」的用法,且攻击方法必须在主路由器里没有对应的路由树;分数衡量的是命中之后的后果与可达性,衡量不出「可被命中的应用究竟有多少」。另外官方通告专门强调:根实例上全局注册的 onRequest 认证钩子不能作为缓解——因为这条路径不经过任何钩子运行器。

三、成因链:一台服务器、两个路由器

要理解漏洞,先要理解 fastify 特有的「封装 404」机制。一个应用进程里跑着两个路由器实例,都来自同一个底层路由库、职责却完全不同。主路由器是主要入口,业务路由注册于此,请求匹配成功后进入完整的生命周期,认证钩子、校验、处理器一样不缺;它处理畸形 URL 是 fail-closed 的——解码失败直接回 400,一直没出过问题。

404 路由器则是 fastify 的特有机制。它引入了「插件封装」:每个带前缀的注册会派生一个隔离子实例,子实例里的装饰器、钩子、处理器互不可见。于是一个设计得很精细的问题随之而来——请求若没匹配到任何业务路由,该用谁的 404 页面?答案是按前缀分发:给 404 也建一个路由器,把每个插件的 404 处理器按前缀注册成通配路由,请求落到 404 路由器时再按路径前缀精确匹配到对应插件的处理器。这个设计的关键约束是「前缀决定身份」:某前缀下的 404 处理器只应被该前缀的请求触发,它的认证钩子随处理器一起注册在该前缀的作用域内,约束由前缀匹配来保障。

漏洞发生在前缀匹配执行之前。底层路由库匹配一条路径的流程是「先解码、后匹配」:进入树匹配之前会先把路径上的百分号编码解开;当路径含有非法编码(例如一个解码后在 UTF-8 里不存在的字节序列,如以 0xC0 开头的非法序列)时,解码抛出一个 URI 错误,路由库捕获后触发一个 onBadUrl 事件。此刻还没走到前缀匹配,请求的路径信息对 404 路由器来说已经作废。

问题出在 fastify 给 404 路由器的 onBadUrl 装的处理者:它不是「回 400」的守卫,而是一个会直接调用一个共享指针的闭包。指针指向整个应用里最后注册的那个封装 404 处理器,且调用它时不经过任何生命周期钩子。链条于是成立:解码失败导致前缀信息丢失,共享指针接管,最后注册的处理器(往往正是那个带认证的那个)被无认证直调。三个环节单看每一个都只是实现细节,叠在一起就构成对「前缀封装」这一隔离契约的整体绕过。

补充两个被分析坐实的细节。其一,被直调的处理器看到的请求对象是用根实例的 404 上下文构造的,若处理器内部访问根实例装饰器或依赖上下文配置(序列化器、错误处理、body 解析),行为可能与正常调用不同——这既是潜在的二次 bug,也是版本指纹素材。其二,「结构越简越安全」是侥幸:实测仅全局注册一个 404 处理器的最小应用,处理器照样被直调,不变量的脆弱性与架构繁简无关。

四、触发前提(三条同时满足)

第一,用法:在某个受保护前缀下或根实例上,用注册自定义 404 处理器的方式提供了兜底页,且认证逻辑写在它的 preHandler 钩子里——凡是「靠生命周期钩子做审计或鉴权」的同理。第二,注册顺序:那个受保护的 404 处理器得是最后注册的自定义 not-found 处理器;只注册过一个时这一条自动满足。第三,可达性:攻击者能触达该服务。从不注册任何自定义 404 处理器的应用,泄露路径不成立。另需注意升级后的行为变化(见第六节)。

五、调试与验证方法(可迁移的分析手艺)

这是纯 JavaScript 的逻辑漏洞,用不上二进制调试器,当「调试器」的是三样东西。日志差分:攻击请求只留一行处理器日志、完整四行缺了三行,相当于在「钩子运行器」和「处理器入口」两处下了断点。计数器差分:给处理器执行与认证执行各设一个内存计数器、经一个统计接口暴露,攻击前后取快照做差,「处理器 20 次而认证 0 次」是比读代码更干净的量化证据,还能与「编码 × 方法 × 前缀 × 注册顺序互换」的边界矩阵逐项核对。语言语义单行实验:例如用一行命令验证函数绑定的 this 优先级,确认直调时 this 恒解析到根实例的 404 上下文。

几条踩坑经验值得留档。客户端会改写请求行——有的命令行工具默认折叠点段路径、并把百分号编码的十六进制位统一成大写,排查顺序应是先挂一个原始监听器把真正发出的请求行抓出来核对、再怀疑服务端,为排除「客户端改写」这个变量、复现请求一律要求路径按原样发送。版本不要用实例暴露的版本字段验,官方某个修复包因发布疏漏硬编码了旧版本号,要直接查包清单里的版本。内存态计数器在重跑任何实验前要先重启进程清零才可比。还有一类现象(自定义 HTTP 方法被解析层直接拒绝)根本到不了应用、也不经过测试注入工具,只能在真实端口上复现。

六、补丁原理:把畸形 URL 出口接回 fail-closed 守卫

补丁的提交信息一句话概括其意图——在自定义 404 处理器之前就拒绝畸形 URL。逐行拆解后结论是:删除共享状态与那个直调闭包,把 404 路由器处理畸形 URL 的出口接到主路由器既有的 fail-closed 守卫上。这是根本修复——拆掉的是「绕过生命周期分发处理器」这个结构本身,而不是给某个指针换一个更安全的指向。同版本还配套加固了上下文快照的构造方式(浅拷贝改为原型链读取),修掉一个方向相反的潜在缺陷:本该执行的钩子因快照时机而丢失。

升级有两处要留心。一处是版本:那条较早的版本线没有补丁、必须跨到大版本里唯一的修复版本,而该版本同时修了另外几个安全通告、收益不止本漏洞一处。另一处是行为变化:畸形 URL 的处理结果从「进入自定义 404 处理器(通常是 404)」变成「返回 400 的一个坏 URL 错误码」。依赖自定义 404 页覆盖畸形路径的应用,升级前要确认这一变化可接受;确实需要对坏 URL 做自定义处理的,改用框架提供的错误钩子或路由选项接管。

七、无法立即升级时的缓解(按有效性排序)

一,上游语法级拒绝(厂商建议):在反向代理或网关层拒绝路径中含非法百分号编码的请求——判定要做在语法层(百分号后必须跟两个合法十六进制字符、解码必须产出合法 UTF-8),不要拿某个具体坏字节去拉特征黑名单,因为编码变体空间很大。二,方法白名单:代理层只放行业务使用的方法,它把「主路由器没有该方法的路由树」这一触发条件整段关闭、且是许多网关的默认配置;但要认识到这只是缓解,白名单一旦为业务放宽(如为跨域放开某方法)而应用又没为该法注册主路由,条件会重新打开。三,架构自查、零成本立即可做:全局搜索注册自定义 404 处理器之处,确认没有处理器返回受保护数据(厂商临时缓解的另一半正是不依赖 not-found 处理器提供受保护数据);若受保护前缀下或根级确实有兜底处理器,把认证从钩子挪到处理器函数体的第一行——直调也绕不过函数体内的检查。四,拆除两个危险依赖:不再指望全局 onRequest 钩子(已被证明对这条路径无效),也不再抱「处理器被调用即代表已认证」这一假设(这正是本漏洞摧毁的假设)。

八、检测:如何判断是否已被利用

翻日志的人有三个特征可查,特异度依次递减。其一,请求编号日志残缺——同一个请求编号名下出现了处理器日志、却没有 incoming request 记录,且 request completed 同样不出现;这是本漏洞最特异的指纹,正常请求是完整四行结构,而受保护处理器只要有自身日志输出就能观测、框架的请求编号关联无需改造。其二,畸形 URL 得到非 400 响应——百分号后跟非法十六进制或非法 UTF-8 序列的路径,响应码不是 400;升级后的基线应是一律 400。其三,方法维度异常——大量少用方法打在不存在的路径上却拿到 200(不存在的路径本不该返回 200),若网关配了方法白名单、本应被拒的请求拿到了应用响应,也是同一信号;此条特异度低、要结合前两条。这三条还能反着用:升级后拿一个畸形 URL 请求期望得到 400,可以当作一行命令的部署冒烟测试、确认补丁生效。

九、延伸思考:可模板化的审计问题

产出这个漏洞认知的核心是一个审计问题——「框架里所有不经过正常分发处理器而构造请求/响应对象的代码位置,它们的上下文从哪来」。拿它穷举这个版本,答案归并为三处:一处是本漏洞所在的注册事件回调;一处是 404 兜底、只回固定 404、安全;第三处是把错误对象交给用户注册错误钩子的几个错误出口、那是文档化的用户契约。三处里漏了一处,而只有它绕过了钩子。这个问题可以模板化为「列举组件中所有旁路主分发路径的出口,逐个检查随行状态与钩子语义」,搬到任何有中间件/钩子流水线的框架上都成立。

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

安全边界因此要分层理解。把认证放在钩子里,安全性就落在「所有到达处理器的路径都经过钩子」这个不变量上;框架的旁路路径越多,这个不变量越脆。更稳的是三层:代理层做语法合法性、拒绝畸形;框架层做路由与生命周期;处理器函数体做自己数据的最后一道检查——最后这一道,任何路径都绕不过。本漏洞里三层缺了两层,补丁补上了框架层,而第一层和第三层放在任何架构下都应当默认存在。回到那个「收到畸形路径字符串却从头到尾没使用它」的分发参数,它恰恰浓缩了整个漏洞:分发函数拿到了本可判定请求归属的信息、却选择忽略它、直接调用共享指针;修复后删掉这个函数,守卫反而把那个字符串回显成拒绝理由——信息没被丢弃、只是换了用法。安全设计里大量决策都能归约到这一步之差:拿到无法验证的输入时,是用它来分发,还是用它来拒绝。

相似文章
相似文章
小程序二维码
 全屏