一个反斜杠击穿主机白名单:fast-uri 双 CVE 的成因、复现与补丁审计
一句话结论:fast-uri 把「反斜杠」当 userinfo 里的普通字符,而 Node 原生 URL(遵循 WHATWG 规范)把反斜杠当路径分隔符。同一个 URL 在校验端解析出的主机是白名单域名 allowed.com,在执行端却连到内网回环地址。CVE-2026-16221 与 CVE-2026-18446 都出在这条「解释冲突」的分歧上,白名单被绕过之后的落点是服务端请求伪造。本文完整保留成因、补丁审计与加固,不复述可直接照抄的攻击构造。
排版说明:正文一律用「反斜杠」「斜杠」等汉字描述分隔符,出现的具体字符只用码位记法(反斜杠为 U+005C、斜杠为 U+002F、全角反斜杠为 U+FF3C、小型反斜杠为 U+FE68、全角斜杠为 U+FF0F),不落任何字面反斜杠;引号统一用直角引号。
一、现象:同一个字符串,两副面孔
一个 URL,把内网主机与端口放在前面、用一个反斜杠和一个 at 符号拼出一段 userinfo、再接上白名单域名。校验它时用的 fast-uri 解析出的主机是 allowed.com(判定为白名单内、放行),而真正把请求发出去时用的 Node 原生 URL 解析出的主机却是内网回环地址。校验端与执行端对同一个反斜杠的理解完全相反——这就是两枚 CVE 的共同根子。
二、两个 CVE 与影响版本
CVE-2026-16221:字面反斜杠不被当作 authority 分隔符,被折进 userinfo。受影响版本跨三条版本线(2.3.1 至 2.4.2、3.0.0 至 3.1.3、4.0.0 至 4.1.0),修复版为 2.4.3、3.1.4、4.1.1。
CVE-2026-18446:不要求字面双斜杠才识别 authority,反斜杠对、斜杠加反斜杠、反斜杠加斜杠这类「引入符」被折进 path。受影响版本为低于 2.4.4 及 3.0.0 至 3.1.4、4.0.0 至 4.1.1,修复版为 2.4.4、3.1.5、4.1.2。
两条修复线相隔约两周,按版本线的 registry 时间戳可对齐。二者都归入 CWE-436(解释冲突),白名单被绕过后的落点是 CWE-918(服务端请求伪造)。要理解分歧,先要认清三个概念:authority 是双斜杠之后、路径之前装着主机与端口的那一段;userinfo 是 authority 里 at 符号之前、放登录凭据的位置;WHATWG URL 是浏览器与 Node 共用的解析规范,它在解析时做「错误恢复」,会把反斜杠当作斜杠——这条规则就写在规范的 special-authority-ignore-slashes 状态里。分歧由此产生:校验用 fast-uri、执行用 Node,两边对同一个反斜杠的理解相反。
三、六个解析器的横向对照
把同一个含反斜杠 userinfo 的 URL 交给六个解析器,答案分成四类,没有一个统一口径。一,Go 与 Java 直接拒绝(invalid userinfo、authority 里有非法字符),对 userinfo 里的反斜杠零容忍。二,Python 与 fast-uri 把反斜杠当 userinfo 里的普通字符,解析出的主机名是白名单域名。三,Node 原生 URL 把反斜杠当斜杠,主机名变成内网回环地址。四,curl 内部两个方向自相矛盾:一个方向把主机判成白名单域名去解析 DNS(解析失败);另一个方向把整段当作 userinfo 送进 Basic 认证头,然后真的连上了内网地址。同一个字符串里「谁被写进 Host 头、谁被真正连接」,六份答案并不一致。这解释了为什么「换个语言写校验」并不能自动对齐——真正对齐的前提是校验端与执行端用同一套解析语义、看到同一个字符串。
四、第二个 CVE:不写双斜杠也能进 authority
第二枚 CVE 与第一枚性质不同:第一枚是「分隔符判定」(反斜杠算不算 authority 的边界),第二枚是「引入符判定」(是否只有字面双斜杠才开启 authority 段)。反斜杠对、斜杠加反斜杠、反斜杠加斜杠这些引入符组合,在旧版本里被整段折进 path、编码成千分号记法,导致 fast-uri 认为请求还留在白名单域名,而 Node 认为已经去了攻击者的主机。第一枚的修复版只堵了分隔符判定,引入符检查要到下一个修复版才补上——这也提醒:同一根因往往有多个入口,补了一个不等于补了全部。
五、补丁到底改了什么:标记可疑,而非拒绝可疑
关键设计在两处新增检查的「做法」上。它们命中之后都只给解析结果赋一个 error 字段,既不 return、也不修改已解析出的 host。于是:只用解析结果的 host 做判断的调用方(守卫写法一)完全无感,行为与修复前一模一样;只有显式去读 error 字段的调用方(守卫写法二)才真正拿到拦截能力。而「要读 error」这件事既没有版本号上的强制,也没有类型系统的提示,解析函数更不会抛异常——漏读是完全静默的。
实测矩阵覆盖六个版本、三种形态、两种守卫共三十六行,命中三十三条:字面反斜杠形态下,只看 host 的守卫在全部六个版本上统统放行,连最新版也不例外;只有同时看 host 与 error 的守卫,才从各自的修复版起生效(个别版本线的例外是因为对应检查在那条线上引入得更晚)。把原始字符串直接交给执行端发请求、又不读 error 时,校验端虽然已经标了 error,请求照旧打到内网、还把内网响应里的凭证带回了业务层。结论很硬:升级到最新版并不会自动保住业务,是否读 error 才是分水岭。这两个 CVE 的修复方向是「标记可疑」而不是「拒绝可疑」,把「抬头看 error」的责任留给了每一个调用方——只要还有一行代码只比较 host,补丁就不算真正落地。
六、补丁之后的残余绕过面
最新版对若干变体的判定仍有边界。检查只用「查找字面反斜杠」这一条匹配 U+005C,源码注释明确写着千分号编码的数据属于合法编码、不处理。于是一批同形异码的变体——全角反斜杠、全角斜杠、小型反斜杠——都不在字面匹配范围内。这些变体能不能真打穿,取决于执行链路上有没有人先做归一化:Unicode 的兼容归一化 NFKC 会把全角字符折叠成半角,折叠之后全角反斜杠变成真正的反斜杠,Node 立刻按「反斜杠等于斜杠」解析落到内网,此时连 error 都是空、看 error 的守卫也拦不住。反过来,若整条链路谁都没做归一化,直接把原始全角字符串交给 Node,反斜杠会留在 userinfo 里、执行端因凭据含非法字符直接抛错、内网零命中。所以这一条的成立条件极其明确:链路上任何一环对 URL 做了归一化或百分号解码,而校验端读到的是归一化之前的字符串。
七、影响面与生态跟进不一
fast-uri 周下载量高达上亿次,fastify 与 ajv 都挂在它上面、依赖链嵌套。但消费方的跟进程度并不统一:ajv 虽依赖 fast-uri,却只用它做 schema 引用的 URI 解析;format 为 uri 的校验实现来自另一个包、其依赖里根本没有 fast-uri。所以字面反斜杠被拒,起作用的不是 fast-uri 的版本,而是那个校验包在默认「完整」模式下用的纯正则——反斜杠不在其字符集内、直接被拒;可一旦切成「快速」模式,字面反斜杠又全部放行。同一个校验库、开关一换结论就翻转。真正与 fast-uri 边界一致的漏点是百分号编码那一行:几种模式都放行,正因为校验端做的是字符串正则、执行端做的是 URL 解析,两者对编码与归一化的处理并不一致。这说明:在庞大依赖链里,「你以为生效的那层防护」和「实际生效的防护」常常不是同一层。
八、加固建议
一,升级:升到各版本线最新,最低也要跨过两个补丁对应的修复版,并把整条依赖链上的所有副本一起换掉。但要认清——升级只解决「有没有 error 字段」的问题。二,改守卫写法:白名单判断必须同时读 error,并显式处理字段不存在的老版本情况;顺序不能颠倒,必须先归一化、再比对,反过来做全角变体会直接穿过。三,校验与执行必须看到同一个字符串:把归一化与解码步骤收拢成一次、产出「最终用于请求的字符串」,再拿它做白名单校验;链路上任何一环偷偷改字符串,前面所有判断都会作废。四,不要用字符串做主机白名单:真正连接前自己解析一次目标、取最终 IP,再按 IP 段过滤——拒绝回环、私网、链路本地、云元数据地址(那个著名的元数据地址属于链路本地段)。字符串层面的比较挡不住解析器分歧,按最终 IP 过滤才挡得住。五,加回归用例:把反斜杠、反斜杠对、斜杠加反斜杠、反斜杠加斜杠、百分号编码、以及若干全角与小型变体和它们的归一化结果一起钉进测试;用例要写成「必须拦截」,不要写成「主机等于某个值」。
九、延伸思考:解释冲突是一类可迁移的漏洞模式
这两枚 CVE 的共性不是「fast-uri 写错了」,而是「同一事实存在两个解释器、且解释规则不对称」:一个解析器严格、把非法字符当错误或直接拒绝,另一个宽容、按规范做错误恢复把反斜杠当斜杠。凡是「校验端一个解析器、执行端另一个解析器」的结构,都在制造这样的差分面——WAF 规则与后端解析之于 HTTP 请求走私、路径规范化与文件系统语义之于目录穿越,都是同款根因。可迁移的检查有两条:其一,任何安全判断(白名单、黑名单、SSRF 守卫)都要问「我据以判断的那次解析,和真正发起请求的那次解析,是不是同一个解析器、同一套规则、同一个字符串」;不是,就存在解释冲突盲区。其二,当上游库的修复方式是「标记可疑而非拒绝可疑」时,安全语义的责任被下放给了调用方,此时「默认安全」不成立——必须显式地去读那个标记字段,防护才生效,而这恰恰是最容易被漏掉、且漏掉完全静默的一环。安全边界因此要分层落:版本层升到跨补丁的版本、代码层显式读 error 并先归一化再比对、架构层放弃字符串白名单改用连接前解析出的最终 IP 做段过滤——其中最后这一层,任何解析器分歧都绕不过。