深度解析塑造未来的技术文章。

Rust借用检查器漏洞:健全性究竟意味着什么

一个潜伏十一年的Rust借用检查器健全性漏洞,揭示证明、编译器与安全保证之间的差距。

一座石桥,其中一道拱上有一条发丝般的裂缝,被放大镜照亮
承重结构上只有一道发丝般的裂缝,十一年的车流都没有发现它。

Rust的类型系统做出了一个硬性承诺:只要你的安全代码能通过编译,就不会出现悬垂指针、数据竞争或释放后使用的问题。所以当一个GitHub issue贴出一段看似完全安全的Rust代码,它却能打印出已销毁栈帧里的垃圾数据,而且这个漏洞竟然在借用检查器里潜伏了十一年,很多人都感到脚下的地面在晃动。这个漏洞让结构体的生命周期约束通过协变字段赋值溜了过去。只要配上一个恶意的Drop实现,你就能把局部变量的引用藏进全局变量,等变量销毁之后再去读取它。AddressSanitizer把它标记为stack-use-after-scope,而编译器却认为一切正常。

大家的反应都在意料之中。有人宣布Rust的安全神话已经破灭,也有人耸耸肩说:bug嘛,总会有的。这两种看法都漏掉了真正有意思的地方。这个bug是一个很干净的自然实验,它告诉我们静态保证到底值多少钱,非形式化推理会在哪里失效,还有“类型系统是健全的”和“编译器确实实现了一个健全的类型系统”之间的距离,为什么恰恰是这段距离里藏着最有意思的bug。所以,我们来逐一检验这次事件引发的几个常见看法。

误区一:健全性漏洞说明Rust的安全模型失败了

第一反应往往是把它当成理论上的裂缝,但其实不是。没有人改写生命周期的规则,也没有人发现所有权和借用从根上就是坏的。按照白板上和RFC里设计的Rust类型系统,这段程序本应被拒绝,只是编译器没能拒绝它。

“规范”与“实现”之间的区别,是我们这个领域最古老的分界线之一。Tony Hoare提出了优美的通信顺序进程理论,但多年来,C编译器却把标准下完全合法的代码编译错了。理论站得住脚,而工程产物却在漏水。我们之前写过规范与实现之间的鸿沟正是bug的藏身之地,这次正是一个教科书式的案例。生命周期规则本身没有错。问题出在rustc的变体推断代码上,也就是决定何时可以把&'a T替换到需要&'static T的位置的那部分逻辑。当赋值经过一个协变字段时,这段逻辑出现了漏洞。

下面是简化后的利用方式。你构造一个包装类型,它的Drop实现会把自己的载荷复制到一个全局的'static存储中。正常情况下,编译器会强制要求载荷是'static的,因此指向局部变量的引用根本进不去。而这个bug让你可以通过协变赋值把一个短生命周期的引用“洗白”,最终使包装类型持有了&l,其中l是一个局部String:

struct Q<T: 'static + Send>(Option<T>);
impl<T: 'static + Send> Drop for Q<T> {
fn drop(&mut self) {
if let Some(t) = self.0.take() {
KEPT.lock().unwrap().push(Box::new(t)); // escapes to 'static!
}
}
}
fn main() {
{
let l = String::from("hello world, this is long enough");
let mut q = Q(None);
q.0 = Some(&l); // should be rejected; 'static required
} // <- drop runs here, stashing &l in KEPT
// l is gone; KEPT now holds a dangling &'static String
}

每一行单独看都很无害。不健全性只存在于组合之中:一个协变字段,一次放宽生命周期约束的赋值,再加上一个把载荷转移到比被引用对象活得更久的地方的Drop。这正是检查器实现类bug的典型特征:不变式是全局的,但执行却是局部的,而某条代码路径忘了做检查。

所以说,安全模型并没有失败,失败的是安全模型的执行。这是一个更小的失败,但正如我们接下来会看到的,它也绝非无关紧要。

误区二:潜伏十一年没被发现,说明这个bug无害

Rust团队自己的分级推理大致是这样的:如果有人真的在遇到这个问题,它就是P-critical;但十一年都没人碰到,说明它不容易触发,所以定为P-high。这话有一定道理。要真正造成内存不安全,你需要一个刻意写得很恶劣的Drop实现,把数据搬到一个'static的位置。几乎没人会这么写,因此意外暴露的范围非常小。

但“很少因为意外而触发”这句话承担了太多含义。安全保证关心的不只是意外,更是当你周围的代码并不友好时,你还能依赖什么。健全类型系统的全部意义就在于你可以进行模块化推理:我不需要审计你的Drop实现,因为编译器已经证明了某些有问题的组合根本无法表达。一旦存在漏洞,这种模块化就不复存在了。一个恶意的依赖,或者一个出于好意却写错了的作者,现在都可能做出语言承诺过不可能发生的事情。

历史上充满了“不可利用”的bug,一旦合适的利用手段出现,它们就变得非常危险。正如一位评论者所说,实际的问题是修复它会不会破坏生态中的某个真实crate,而答案大概率是“很少或者没有”,这算是个好消息。但从这十一年的平静中应得出的正确教训,并不是“这个bug是无害的”,而是:没有证据不等于证据不存在,尤其是对于那些需要主观恶意而非偶然巧合才能触发的故障模式。

误区三:人多眼杂,所有bug都会浮出水面

这是最应该让我们汗颜的一个误区。rustc是有史以来被审视得最严格的编译器之一:它是开源的,有专门的类型团队,学术社区也很活跃(RustBelt多年前就为Rust的核心部分给出了形式化模型),还有数以百万计的用户每天在上面反复测试。然而,一个健全性漏洞却在生命周期推断中潜伏了十多年,期间周围的机制经历了彻底的重写。

Linus定律假设,眼睛是盯着正确的地方,而且脑子里装着正确的模型。类型系统的不健全性恰恰同时击穿这两个假设。它们存在于多个特性的交叉点上:变体、drop顺序、强制转换、trait约束,而每个特性单独看都是局部正确的。审查者读代码时看到的是设计者想要的不变式,因为代码本身也是由那些同样看到这个不变式的人写的。确认偏误已经内嵌在这个流程之中。要找到反例,你必须像攻击者一样思考,而站在攻击者的角度审视自己设计的抽象,确实非常困难。

有一个专门的网站counterexamples.org,收集了那些看似合理的类型规则是如何被打破的,它们的共同点是可变性与其他复杂特性的组合,恰好就是这个bug的形态。这些都不是新教训。ML的类型系统曾经因为可变引用与多态的组合出现过著名的不健全性,后来通过值限制(value restriction)才得以修复。Java的数组是协变的,因此可能在运行时抛出ArrayStoreException,因为语言设计者选择了便利性而非健全性。变化的只是语言,而子类型、可变性和泛型这三个特性的组合,总是会反复咬人。如果你对这类“考古”感兴趣,Lisp六十年的历史能告诉你这些思想已经演化了多久,而Rob Pike的那些老规则关于简单性的论述,在你意识到复杂性正是健全性漏洞的温床之后,读起来会别有一番味道。

黑板上相互交织的绳索打成结,交叉点处有一道小缝,光线从中漏了出来
每一股绳索单独看都是健全的,漏洞只出现在多股绳索交汇的地方。

眼睛确实有帮助,但远远不够。我从一位正在开发自研语言的朋友那里听到过一个很诚实的看法:没有任何审查能解决所有问题,所以你需要机械化的方法,它们不会疲劳,也不会与你共享同样的假设。

误区四:非形式化推理基本上就等于证明

Rust的生命周期规则在设计时,是由聪明的人仔细论证出来的,还配了大量示例。这就是非形式化推理,而几乎所有生产级编译器都是这样构建的。结果就是,一个系统“据我们所知”是健全的,而我们现在已经两次亲身体会到:“据我们所知”与“确实健全”不是一回事。

两者之间的鸿沟,正是RustBelt这类项目以及下一代trait求解器持续工作的意义所在。RustBelt基于Iris分离逻辑框架,在Coq(现在更名为Rocq)中对Rust的核心类型规则做了机器检验的建模,其中还包括unsafe抽象边界这类棘手的部分。这是一个里程碑。但要注意它证明了什么:证明的是Rust的一个模型,而不是rustc本身。这个证明覆盖的是λRust演算,而生产环境的编译器已经发生了漂移,不断增加新特性,积累了大量没有人重新验证过的实现决策。这次的问题,正是这种漂移的账单到期了。

对一个生产级编译器的全部内容做形式化,代价高得惊人。CompCert是经过形式化验证的C编译器,它花费了多年专家的精力,而其语言规模远小于Rust,而且它验证的是编译各个阶段的正确性,而不是证明源语言的类型系统能捕获每一个错误程序。没有人会去为rustc的两百万行代码写一份Coq证明。现实的路径更像是纵深防御:

  • 核心规则的机器检验模型(RustBelt式),让理论本身不再有漏洞。
  • 系统化的模糊测试与案例分析工具,生成对抗性程序,覆盖人类想不到去测试的特性交互角落。
  • 对检查器做变异测试与基于属性的测试:如果翻转某个约束应该让程序编译失败,那就断言它确实失败。这方面SQLite的测试文化是很好的榜样。
  • 维护一份已知不健全问题的清单,Rust目前已有一百多个被标记的问题,应当把它们当作有工程优先级的待办事项来对待,而不是当成口耳相传的传说。

有意思的是,基于LLM的搜索最终可能成为一个净收益:一个不知疲倦、能以各种刁钻方式组合特性的工具,正是非形式化推理所缺乏的那种对手。我们很多人希望的终极目标,是一个有形式化规范的Rust,并且检查器经过了该规范的证明。但在那之前,要走的路仍然是枯燥、不起眼的约束求解工作。

误区五:如果健全性不是绝对的,静态安全就是骗局

愤世嫉俗的解读是这样的:Rust承诺了安全,但这个承诺有漏洞,所以它只是营销话术,我们不如回到C里随便操作指针。这个推论完全是反的,值得精确地说明一下原因。

类型系统的健全性是关于一个数学对象的定理。而生产级语言给你的,是一种统计意义上和经济意义上的保证:整类bug,比如释放后使用、数据竞争、迭代器失效,从“代码库里持续存在的背景辐射”变成了“罕见到一旦发现、就因为隔了十一年才被挖出而登上头版”的事件。对比一下基准率就知道了。大型C和C++代码库中的内存安全bug,年复一年地占据了严重CVE的大多数,而如今没人会对其中任何一个感到惊讶。Rust的不健全性bug则如此罕见,以至于每一个都能拥有自己的GitHub issue、讨论帖和复盘文化。可以被枚举的稀有,和无法被枚举的稀有,是两个完全不同的世界。

故障的累积方式也不同。在C里,每一行代码都可能是内存安全隐患,因此警惕的成本会随着代码量永远增长。而在Rust里,警惕集中在边缘:unsafe代码块、FFI,以及(我们现在知道的)编译器自身的检查器。正是这种集中,让审计、Miri这类工具以及形式化方法变得切实可行。这种保证不是非黑即白的,而是对风险所在位置的一次彻底重塑。

一个已知存在bug的类型系统,即使这个bug在十一年的生产流量中从未被触发,它依然比那种“bug本身就是设计”的语言强出无数倍。

你真正应该带走的东西

如果你写Rust,其实不需要改变任何东西。这个issue提交之前,你的代码并没有悄悄地变得不安全;实际的利用路径需要主观恶意才能实现。如果你维护库,诚实的做法和以前一样:把unsafe的暴露面控制得尽量小,对棘手的部分跑一遍Miri,并且不要把借用检查器当成防范他人恶意代码的神谕,而应把它当作一个极其可靠的工具,用来捕捉你自己的错误。

如果你在做语言或编译器,这个bug就是一份礼物:它是一个罕见且记录详尽的样本,展示了健全性在生产环境中究竟是如何失效的。它不在理论里,而在执行里;不在某个单一特性里,而在特性的交叉处;不是靠审查发现的,而是靠对抗性构造发现的。那些认真对待这一点的团队,比如投入资源重写trait求解器、重构区域约束、开发与验证相关的工具,他们的保证在十年后才会真正有分量。

对于所有人来说,还要重新校准什么才算“保证”。几十年来,我们在类型系统、密码学协议和分布式共识中,一直把“已证明”与“论证得很有说服力,而且迄今未被推翻”混为一谈。成熟的信任并不是假设桥梁是完美的,而是知道桥梁有检查员,他们会公开发布检查结果,修复他们发现的问题,并且保留一份公开的、尚未修复的裂缝清单。从这个角度看,十一年才找到一个漏洞,并不是对系统的控诉,反而说明包括编译器周围的人类协作体系在内的整个系统,大体上正在按应有的方式运转。