这周 Rust 圈最热的一篇帖子,不是一个新特性,而是一个存在了 11 年的老洞。
rust#164250 在 10 月 13 日被提出:一个小小的 struct 加上一个 Drop 实现,就能让一段“编译通过”的代码,实际上读到已经被销毁的内存。issue 提出当天先被标成 P-critical,几个小时后降为 P-high。HN 那边拿到 742 分、425 条评论,满屏都是 “what the hell”。
我在本机 rustc 1.99.0 和 nightly 1.101.0 上都复现了它,又用 Miri 把 UB 的定性钉死。这篇把三件事讲清楚:洞在哪个边界、11 年是怎么算的、修复打算怎么堵。
一段“编译过了”的程序
先放最小示例,就是 issue 的原版,我原样保留,一行没改:
use std::sync::Mutex;
static KEPT: Mutex<Vec<Box<dyn std::any::Any + Send>>> = Mutex::new(Vec::new());
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));
}
}
}
fn main() {
{
let l = String::from("hello world, this is long enough");
let mut q = Q(None);
q.0 = Some(&l);
}
let _z = String::from("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
let k = KEPT.lock().unwrap();
println!("{}", k[0].downcast_ref::<&'static String>().unwrap());
}
程序的本意很直白:Q 包了一个 Option,要求里面装的东西是 'static 的;q 析构时把值“抬升”进一个全局 static,之后还能读。
接着看 main:l 是一个局部 String,作用域一结束栈帧就没了。可 q.0 = Some(&l) 把 &l 塞了进来,它的生命周期根本到不了 'static。等程序打印 k[0] 时,那个指针实际指着 l 曾经所在的栈位置,而那块内存已经被 _z 复用了。所以程序打印出来的不是 “hello world”,而是一串 X。
Rust 应该拒掉它。q.0 = Some(&l) 违反了 T: 'static 的要求,这一行就该报错。
但我在 rustc 1.99.0(2026-09-28)上编译,零警告零错误,运行打印 XXXX。issue 作者测的下限是 stable 至少一路到 1.85.0 都能复现。
11 年这个数字怎么来的
“11 年”出自 issue 讨论本身。作者 jsnell 贴了一个自己更简化的变体:
struct MyType(&'c mut &'a str, &'b str);
impl Drop for MyType {
fn drop(&mut self) {
*self.0 = self.1;
}
}
他的原话是:这一版 “compiles ever since rust 1.4.0 (2015)”。lcnr(rustc 维护者)在评论里确认:“unsound since 1.4 🤣”。Rust 1.4.0 是 2015 年 9 月发布的正式版本,2015 到 2026,正好 11 年。
这里有个边界我得钉死,别让它被标题带走:我用现代 Rust 语法,也就是显式生命周期泛型参数,把这个 MyType 变体重写后,1.99 反而拒编了,报 E0495。
也就是说,“11 年”指的是这类洞的底层语义——协变字段赋值——从 1.4 起就在。这些年编译器把一些外围路径堵上了,但作者的主例,也就是 KEPT static 那版,在 1.99 和 nightly 1.101.0(2026-10-12,e9bbadf68)上依然完整可复现,修复 PR 还没合。
洞具体打在哪个边界
这个 bug 卡在三个机制的交界上,拆开看其实不复杂。
第一层,bound 只查一次。Q<T: 'static + Send> 的 T: 'static 在类型检查和实例化时被强制,但之后不是“每来一次字段赋值都要重新验一遍”。
第二层,协变让短生命周期溜进来。字段类型 Option<T> 对 T 是协变的。q.0 = Some(&l) 走的是字段赋值路径,编译器做的是协变子类型检查:&'short String 可以装进一个“要求更长生命周期”的位置吗?可以,因为 & 本身是协变的,短引用向上抬就行。这一步把短生命周期塞了进去,但 struct 还以为自己装的是 Option<&'static str> 那类东西。
第三层,Drop 把错误焊死。drop() 把值 take 出来推进 global static,之后所有读取都经过 downcast_ref::<&'static String>()。那个栈指针从此被当成一个 'static 引用冻结住了,等外层作用域再去读它,就是 use-after-scope。
一句话串起来:bound 在构造时查了一次,值却在 drop 时换了,两个时点错位;协变在中间递了一个短生命周期。三段各自“看起来都对”,合起来就是 UB。
我本机补了两条实证,都钉进正文里:
- 1.99.0 主例复现:编译零警告,运行打印 XXXX,属于栈上悬垂读。
- nightly Miri 定性:
Undefined Behavior: constructing invalid value of type &std::string::String: encountered a dangling reference (use-after-free),报错点在 fmt 打印时解引用那个悬垂引用。
修复打算怎么堵
修复 PR 是 #164277,标题很直白:require MIR local types to be well-formed,作者 Li-Zheng-Rong,body 只有一行:“Fixes rust-lang/rust#164250, sound again :)”。
思路是把检查点后移:类型检查阶段之外,在 MIR 层再要求一遍,每个 local 的类型必须 well-formed,也就是 bound 在运行时表示上真的成立。字段赋值绕过 bound 的那条路径,会在 MIR 这道口被拦住。
截至 2026-10-15,这个 PR 还是 open,issue 也没关。lcnr 在 issue 里补了一段官方说明值得抄进你的理解里:Types Team 正在做下一代 trait solver 和 region constraint 重构,修掉所有 type system unsoundness 是明面上的大目标。这个 bug 被定 P-high 而不是 P-critical,理由就是“11 年都没被撞到,说明它没那么容易命中”,而且利用它“需要一个故意写坏的 Drop”。
“不容易误触发”不等于“安全”。Rust 卖的是“永不 UB”,不是“很难撞”。这两句话的区别,就是这篇文章的全部意义。
它在整个 Rust 里是什么位置
HN 评论里我摘了三条有分量的。
weinzierl 说:现在 rust 仓库里挂了 100 多个 unsound 标签的 issue,这个不是孤例;新一代 trait solver 会解锁其中一批修复,但不一定包括这个。
fnord77 点出了编译程序的税:发现这种 bug 之后你没法像 VM 那样“换个 runtime”,得把所有下游重编译一遍。这是“内存安全语言”附带的成本项。
lambdaone 说了句带讽刺意味的乐观话:AI 这种 bug 猎手反而可能把 Rust 往“完全可靠”推得更近——能找出 11 年洞的,未必只是人肉 code review。
还有一条是 vintcent-uden 那种反应,代表大多数人的第一感受:标题看着像“只有编译器维护者才懂的偏门 bug”,结果最小示例短到你在日常代码里随时可能写出来。
对普通使用者的实际含义,我能给的就三条:
- “编译通过 = 安全”这句口号,精确版本是“编译通过 = 在已知规则下安全”;规则本身有洞,洞在修。
- 你的代码里如果有“struct 装引用 + Drop 抬升进 static”这种形状,对照 issue 里列的变体自查一遍,成本很低。
- 关注 #164277 的合入状态。合入前这类代码的口径是:能编、能跑、是 UB。
参考:rust-lang/rust#164250(issue 全文、jsnell/lcnr 讨论、官方 P-high 说明);PR #164277(修复方案);HN 讨论(item 50069499,742 分 425 评);Godbolt asan 版 rust.godbolt.org/z/83MvfbKde。
诚实声明:本机 2 核 EPYC,rustc 1.99.0(stable)与 nightly 1.101.0(e9bbadf68,2026-10-12)实测复现主例,Miri 定性 use-after-free。“1.4.0 起可编”与“stable 至少到 1.85.0 可复现”为 issue 作者自述,我未回溯 1.85 之前各版本。MyType 变体在现代语法下 1.99 拒编(E0495),文中已标注此边界。修复 PR 状态以 10-15 时点为准,合入后本文数字需回写。
你踩过这类“形状看着无害、实际上是 UB”的代码吗?我列了三档可以聊:
- A 档:Drop 里抬引用进 static/global 的形状,也就是本文主例。评论区贴你的代码,我帮你对一遍。
- B 档:“100+ unsound issue 该先修哪 10 个”,可以出一篇按 P 级 × 命中概率排序的盘点。
- C 档:下一代 trait solver / region constraint 重构到底动了什么,等 #164277 这一串 PR 合完我做全解。
转给那个坚信“编译过就安全”的同事——它只用了 22 行代码。
话题标签:#Rust #编译器 #内存安全 #生命周期 #Drop #正确性