Rust 借用检查器被爆 11 年漏洞:协变字段赋值绕过生命周期约束,类型系统的承诺还剩多少

10 月 13 日,rust-lang/rust 仓库挂出一个编号 #164250 的 issue:安全代码可以绕过结构体上的生命周期约束,把一个活不满 'static 的引用塞进声明了 T: 'static 的字段里。漏洞标签 I-unsound,优先级 P-high,从 2015 年的 Rust 1.4 起就能编译通过。类型组成员 lcnr 在置顶说明里写了一个反直觉的判断:如果这个 bug 是最近才引入的,它会是 P-critical,现在定 P-high 的唯一原因是它已经潜伏了 11 年——一个 11 年没人撞上的漏洞,说明它并不容易被踩中。

十一行的代码,绕过了类型系统的承诺

先看最小复现。贡献者 theemathas 在 issue 里给出了浓缩版:

Rust issue #164250 的最小复现代码:协变字段赋值绕过 'static 约束

逐行拆开看。struct Q<T: 'static>(Option<T>) 声明了一个约束:放进 Q 的类型 T 必须满足 'static——要么是拥有所有权的值,要么是活到程序结束的引用。这是类型系统给出的书面承诺。接下来 impl<T: 'static> Drop for Q<T> 定义了析构行为:drop 执行时,代码假定手里的 T 是合法的 'static 值。

问题出在最后两行。let l = String::from(...) 创建了一个栈上的局部字符串,它的引用生命周期远不满 'static;q.0 = Some(&l) 这个普通的字段赋值,却通过了编译。按协变(variance)规则,&'long T 可以收窄成 &'short T,编译器在统一化(unification)两个类型时只检查了收窄方向的合法性,没有回头验证字段声明处的 'static 约束是否仍然成立。类型检查在这里开了个口子。

漏洞如何变成内存不安全:Drop 是走私通道

字段约束被绕过本身还只是类型谎言,真正把它变成内存不安全的是 Drop。lcnr 在回应里点明了要害:这个漏洞看起来容易触发,但要真正造成危害,需要一段"故意恶意"的 Drop 实现。

issue 里的原始版本演示了完整的走私链路:Q 的 drop 把字段值 take() 出来,Box::new 之后塞进一个 static 的全局 Mutex<Vec<...>>。局部字符串 l 在作用域结束时销毁,而全局容器里还躺着指向这块已释放栈内存的引用。main 函数随后把它按 &'static String 取出并打印——不挂地址消毒器时程序"正常"输出,开 AddressSanitizer 直接报 stack-use-after-scope。

issue 页面:jsnell 提交,lcnr 编辑置顶官方说明,标签 I-unsound / P-high

评论区很快堆出了更多变体。lcnr 自己给了一个不依赖 static 变量的版本:MyType<'a, 'b: 'a, 'c>(&'c mut &'a str, &'b str) 的 Drop 里执行 *self.0 = self.1,把短命引用写进长命位置,1.4.0 起可编译。theemathas 补了一个线程版:drop 里把引用 move 进新线程,睡 1 秒后再读——原字符串早已释放,这是教科书式的 use-after-free。maxdexh 的数组统一化版本则展示了另一条触发路径:数组字面量 [Q(long, ...), Q(local, ...)] 统一化元素类型时,同样不检查约束,导致编译出 ill-formed 的代码。

这些变体有一个共同结构:协变字段赋值(或类型统一化)负责骗过类型检查,自定义 Drop 负责把短命引用转移到它的生命周期之外。两件事单独看都普通,组合起来就击穿了"引用不过期于所指对象"这条 Rust 最核心的安全保证。

为什么 11 年没人踩中

第一反应往往是质疑:这么短的复现代码,怎么会藏了 11 年?答案藏在触发条件里。

第一,普通代码不这么写。绝大多数 Rust 程序不给泛型结构体实现带生命周期敏感逻辑的自定义 Drop——HN 讨论里有四年系统开发经验的工程师表示从未手写过 Drop,也有不少人反驳自己写过很多,这至少说明它是相对小众的模式。第二,也是更关键的一点:从"类型谎言"到"内存不安全"之间隔着一步主观动作。字段约束被绕过后,如果 drop 老老实实用这个值而不转移它,多数情况下不会崩;只有当 Drop 的作者依赖 T: 'static 这个承诺去转移引用时,危害才成立。诚实的代码不会踩雷,撒谎的代码才会计时爆炸。第三,Miri 这类解释器能稳定检出这个变体(评论区有人用 nightly Miri 跑出了 undefined behavior 报告),但它不在默认工具链里,CI 里跑 Miri 的项目仍是少数。

这个"11 年未被发现"本身就是维护者评估风险的核心依据。lcnr 的原话:一个 11 年没人发现的漏洞,"实际上不可能那么容易撞上";从 bug 形态看,"极大概率从未因意外而造成过内存不安全"。这不是淡化问题——I-unsound 标签在 Rust 仓库里意味着类型系统保证被打破,是最高优先级类目——而是对"理论可利用性"与"实际风险"的区分。

漏洞暴露的是保障体系的层级

HN 讨论区(269 条评论)吵起来的焦点其实不在 bug 本身,而在一个更根本的问题:Rust 的"内存安全保证"到底是什么性质的承诺。

批评方指出:Rust 没有一份独立于编译器的正式语言规范,语言语义由 rustc 的行为定义。"如果编译器接受了这段代码,那它就是合法的 Rust"——按这个定义,语言永远不可能 unsound,soundness 变成了一个不可证伪的口号。这条批评在方法论上成立,也戳中了 Rust 与 Java 这类有正式规范语言的本质差异:Java 的类型系统 unsound 与否可以脱离实现单独讨论,Rust 目前做不到。

辩护方(也是更贴近事实的一方)给出三层回应。其一,"内存安全语言"描述的是设计意图和工程实践,从来没人完成过整条工具链的端到端形式证明——Java、C#、TypeScript 同样没有,多数人照样称它们内存安全。其二,Rust 社区对类型系统的形式化验证一直在推进:RustBelt 项目用 Coq 证明了 Rust 核心类型系统(含别名规则与 trait 系统的关键部分)的可靠性,但覆盖的是子集,且不等于验证编译器实现。其三,也是维护者正在做的事:这个 issue 的置顶说明里列了三条在途工作——下一代 trait solver(next-solver)、区域约束重构(next-gen region constraints)、以及更宏大的 Project Zero 路线图,目标是修掉所有已知的类型系统 unsoundness,并压低未知漏洞的存在风险。

有一个问题暂时没有答案:做安全关键认证的 Ferrocene 编译器(基于 rustc 的 ISO 26262 认证工具链)是否同样受影响。HN 上有人提出了这个疑问,issue 里没有官方回应。从原理上讲,Ferrocene 跟随上游 rustc 的版本演进,1.4 起可复现的漏洞大概率同样存在于它基于的历史版本中,但认证工具链的漏洞披露流程不同,需要等其官方说明。

对开发者的实际影响与自查清单

先说风险量级:普通业务代码几乎不需要行动。触发需要同时满足三个条件——泛型结构体带生命周期约束、自定义 Drop 且其中转移字段值、赋值路径恰好经过协变收窄或类型统一化。三者叠加在真实代码里极其罕见,这也正是 11 年零事故的原因。

但有几类代码值得对号入座。写 FFI 边界、actor 邮箱、arena、对象池这类"在 Drop 里把值转移走"的抽象时,T: 'static 约束是常见设计,这正是本次漏洞的靶区。自查方法:给这类结构体跑一遍 Miri(cargo +nightly miri test),它能检出本次所有变体;关注 rustc 的补丁发布,这个 issue 已进 T-types unsound issues 项目板,open/unblocked 状态;在高风险抽象处,可暂时用 PhantomData<&'a ()> 之类的不变型(invariant)标记收紧型变,让编译器在收窄时直接报错。

已知边界还有一条:theemathas 测试自己的写法从 1.18.0(2017)起可编译,lcnr 与 maxdexh 的变体追溯到 1.4.0(2015),issue 确认当前 nightly 与至少 1.85.0 起的 stable 全部可复现。历史版本里找不到一条"安全线"。

修复时间表尚未公布。参考仓库里其他 I-unsound 问题的处理节奏,大概率先在 nightly 落地行为修正,再随 release train 进入 stable。对绝大多数 Rust 使用者,这件事的价值在于看清一个事实:内存安全的承诺要靠类型理论、编译器实现、Miri 这类动态验证和社区持续审计共同维护,缺一层都有缝。这条防线这次被证明有缝,也正在被当场修补。

来源:rust-lang/rust issue #164250、Hacker News 讨论