跳到主要内容
一个说不清的 false

一个说不清的 false

夜航船
夜航船

· 阅读约 5 分钟

夜里翻出一段三年前自己写的解压代码,本来想顺手添个注释,改到一半停住了。某个 if 后面有一行,调用完,返回值连看都没看,就那样直接往下走。那种心虚很怪,不是发现了 bug,是忽然认出自己身上一个老毛病,当年为了省事绕过去的一步,隔了三年还站在原地等着我。

说来也巧,这几天翻来覆去想的正好是同一件事,只是这回犯病的不是我的代码,是 Google 那个 Brotli 解码器。CVE-2026-111497,外面先叫它 Node2Shell,后来发现不止 Node,又改口叫 HTTP2Shell,名字听听就好,我不太在意。在意的是根子上那点事:处理块切换的那个函数 DecodeBlockTypeAndLength,返回的是一个再普通不过的布尔值,false 既可以表示这段流根本是坏的,也可以表示我还没读够、你再喂我点。调用它的人站在这个 false 面前,其实是分不清的。而快路径那边,索性连这个返回值都不接了。

一个说不清的 false,遇上一个懒得问的调用方,剩下的就是时间问题。构造一段精心算好的流,逼它去做一次注定做不成的块切换,那个本该被拦下的字节就这么越过了小缓冲的边界,后面的事不必细说,能读能改能崩,CVSS 打到 9.2,两个方向都打得通,恶意客户端能掀翻服务端,恶意服务端也能掀翻一个老老实实收压缩响应的客户端。这话说出来挺响,可真正让我坐了一会儿的,是另一件事。

根子上的修复,今年二月二十七号就进了 Brotli 的源码。当时没有人把它当成安全问题,就是一次普通的改动,让函数返回错误码,加了 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH,值是负十七,两条路径上都停下来。然后,就没有然后了。那扇门其实已经关上了,只是一直没人出来宣布它关上,直到十月二十一号,才有第一个包含这段修复的 Brotli 版本发出来。中间隔了整整八个月。

八个月这件事让我想起别的。不是漏洞,是那种“事情已经解决了,但解决这件事本身没传达到需要知道的人耳朵里”的处境。一扇门关上了,走过的人都以为它还开着,于是每个新来的人照旧伸手去推,推不动,还以为是自己力气不够。源码里的一次提交,和一次像样的发布之间,常常隔着这么长一段没人负责的走廊;这段走廊不出事的时候谁也想不起它,出了事才发现,那八个月里,整条链路都还站在原来的位置上。

再往下看就有意思了。这个解码器的身影比想象中散,散到几乎每个角落都自己抱着一份拷贝。Brotli 是给 HTTP 压缩 HTML、CSS、JS 用的,这层东西本来就在最底下,Node 打包一份,.NET 带一份,Java 走 Netty 的那些栈里也有一份,Python 里 Brotli 那个包一个月八千多万次下载,而很多人根本没 import 过它,是他们脚底下的 urllib3、requests、httpx、aiohttp 在悄悄把它拉了起来。于是补丁从二十一号开始一天一天往外撒,撒到二十六号,Debian 和 Alpine 修好了,Fedora 和红帽只修了一半,Ubuntu 和 SUSE 的标准仓库还空着,RHEL 只在那套 hardened image 里有。我看着这张越摊越大的清单,想到的不是“波及面广”这种话,而是底下其实只有一根管子,地面上却盖着几十栋互不相识的房子。

有个细节值得单拎出来。有人会想,那我干脆不让它收 Brotli 不就行了,把 Accept-Encoding 里的 br 划掉。划掉是划掉了,可一个恶意的服务端根本不管你声明了什么,它想发就发,urllib3 这类客户端是照着响应头里的 Content-Encoding 挑解码器的,不是照着你声明了什么。你以为你捂上了耳朵,对方照样在说。这话放到别处也成立,我们太容易把“我表达了不愿意”当成“这件事不会发生在我身上”。

OpenSourceMalware 那帮人做的对比其实很朴素,1.2.0 和 1.2.1 之间只差版本号和那段 decode 的改动,一个吃下二十三兆精心构造的流还伸着手要更多,另一个干脆利落地甩回一个负十七,说你这是格式错,到此为止。我看那个负十七看了很久。它不聪明,就是一个能被辨认出来的拒绝;可恰恰是这份能辨认,把自己和那个含混的 false 分了开来。false 养活了这个漏洞八个月,负十七一句话就把它了结了。

不,这么说还是太抬举那句错误码了,它不过是让调用方有了分辨的可能,真正的功夫在于后面那些愿意去分辨的人,一处一处把拷贝升了级,把锁文件重新生成,把每一台笔记本和 CI 机器都翻了一遍。可我想说的那层意思没变:含糊从来不是免费的,只是账单来得慢;慢到你早就忘了自己当初借过。

我们这行当里,把返回值丢掉是件小事,小到写在评审意见里都嫌啰嗦;可放到日子里,它一点也不小。多少时候,我们对着一个说不清的 false,心里明知道那底下可能藏着两种东西,一种是真坏了,一种是还没说完,却还是选了不去问,因为问一句要停下手上的事。停一下太贵了,于是不等答案,接着跑。跑着跑着,那个小缓冲就那么不声不响地被撑破了。

这会儿茶早凉了,那段三年前的代码我最后还是没把注释放上去。我把那个被丢掉的返回值补了一段判断,虽然它跟今晚这个 CVE 一点关系也没有,补完看着舒服点。天快亮了,我大概还会为这种没人会看的地方较真到后半夜,这毛病改不掉,也不大想改。一个负十七能被认出来,这件事够我在这个点还醒着的时候,心里踏实一点。