上周三,Rust 仓库多了一个 issue #164250,标题朴素得不像个重磅炸弹:struct's lifetime requirements can be bypassed via covariant field assignment。几个小时内它就被打上 P-critical,随后降级为 P-high,标签栏挂着一排刺眼的字:I-unsound、regression-from-stable-to-stable、A-borrow-checker、A-variance。

I-unsound 意味着安全代码可以造成内存不安全。regression-from-stable-to-stable 意味着这不是 nightly 上的实验性特性,它已经污染了稳定版。评论区里有人翻出时间线:这个洞能编译通过的最早版本是 stable 1.4.0,2015 年。也就是说,Rust 宣称的内存安全保证,有一道裂缝已经存在了十一年。

触发它的代码长什么样

最小的复现只需要一个 Drop 实现、一个泛型约束和一次字段赋值:

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);          // 塞进去的是一个 &'local String
    }                            // l 在这里析构
    let _z = String::from("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
    let k = KEPT.lock().unwrap();
    println!("{}", k[0].downcast_ref::<&'static String>().unwrap());
}

Q<T> 声明里写了 T: 'static,q.0 的类型名义上是 Option<&'static String>。可内层作用域里 q.0 = Some(&l) 把一个只活到作用域结束的 &l 赋了进去,借用检查器一声不吭。l 在花括号处析构,之后 main 又从静态全局里把那个引用捞出来当 &'static String 打印——读的是一块已经被 _z 的分配覆盖掉的栈内存。

作者在 Godbolt 上开了 AddressSanitizer,输出是标准的 AddressSanitizer: stack-use-after-scope。不开 ASan 时程序不崩,愉快地打印出 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX。那块死掉的内存恰好被下一个局部变量的初始化盖上了。没有崩溃的 use-after-free,比崩溃的更危险。

根因:类型统一时不检查生命周期的约束

问题的核心不在 Drop,也不在 Mutex,而在类型推断的 unify 阶段。@maxdexh 在评论里给了一个去掉 Drop、只用普通统一的版本,把问题定位得更干净:

struct Q<T: Copy, F: FnMut(T)>(T, F);

impl<T: Copy, F: FnMut(T)> Drop for Q<T, F> {
    fn drop(&mut self) {
        self.1(self.0)
    }
}

type T = Vec<Vec<i32>>;

fn main() {
    let long = Box::leak(Box::new(T::new())) as &'static T;
    {
        let local: T = vec![vec![1]];
        // 数组元素类型被统一为 Q<&'local T, fn(&'static T)>,这个类型本身 ill-formed
        let mut unified = [
            Q(long, (|s: &'static T| { /* … */ }) as fn(&'static T)),
            Q(&local, (|_: &T| {}) as fn(&T)),
        ];
        let [l, r] = &mut unified;
        std::mem::swap(&mut l.0, &mut r.0);   // 静默地把 'static 引用换成了局部引用
    }
    std::thread::sleep_ms(1000);
}

当数组的两个元素类型不同时,编译器做 LUB(最小上界)统一,得到 Q<&'local T, fn(&'static T)>——注意这个类型是自身不合法的:第一个类型参数是 &'local T(不满足 T: 'static 隐含要求的复制语义场景),第二个是要求 &'static T 的函数指针。类型系统造出了一个它自己本该拒绝的类型,然后照着它往下编译。后续 mem::swap 把值换过来,约束就被绕过了。

一句话总结:unify 只保证两个类型"形状"一致,不校验统一结果本身是否满足泛型参数的 trait bound。 生命周期出现在结构体字段位置、又恰好协变时,字段赋值就成了后门。

为什么协变是这扇后门的关键

Rust 里大部分引用结构是协变的:&'long T 可以当成 &'short T 用,因为读操作只会让生命周期变短、不会变长。协变通常是便利特性,让子类型关系自然成立。但在可变字段赋值的场景下,协变配合不完整的统一检查,就变成了把短命引用塞进长命槽位的合法通道。

@lcnr 给的版本甚至不需要 Drop 也拿掉了 'static 字面量,纯粹靠生命周期约束在字段间的流向:

struct MyType<'a, 'b: 'a, 'c>(&'c mut &'a str, &'b str);

impl<'a, 'b: 'a, 'c> Drop for MyType<'a, 'b, 'c> {
    fn drop(&mut self) {
        *self.0 = self.1;   // 'b 里的值流进了 'a 的槽位
    }
}

fn main() {
    let mut me_out = "rarw";
    {
        let l = String::from("some cool string");
        let mut q = MyType(&mut me_out, "");
        q.1 = &l;            // 'local 的值写进了标称 'b: 'a 的字段
    }
    println!("me_out: {me_out}");   // 打印悬垂引用
}

这个版本在 1.4.0 就能编译。@theemathas 在评论里连发了三个变体,最后半是感叹半是困惑地问:"这玩意儿到底怎么 11 年都没被发现的??"

一段优雅的"悬垂引用 + 线程逃逸"组合拳

最能说明危害的复现来自 @theemathas,把栈上引用通过 spawn 送到另一个线程,在 Drop 运行那一刻把生命周期强行延长:

use std::time::Duration;
use std::thread;

struct Q<'a: 'static>(Option<&'a String>);

impl<'a: 'static> Drop for Q<'a> {
    fn drop(&mut self) {
        // 这段代码手里的引用按类型说是 &'static String
        let wrong: &'static String = self.0.unwrap();
        thread::spawn(move || {
            thread::sleep(Duration::from_secs(1));
            println!("{}", wrong);   // 1 秒后才读,源数据早没了
        });
    }
}

fn main() {
    {
        let l = String::from("abc");
        let mut q = Q(None);
        q.0 = Some(&l);   // 名义上是 &'static,实际是 &'local
    }
    thread::sleep(Duration::from_secs(2));
}

'a: 'static 这个约束字面要求 'a 至少和 'static 一样长,编译器却在赋值处放行了 &l。子线程静默地读脏数据,主线程等它两秒收尸。这类"数据竞争 + 悬垂读"的组合,是内存安全语言里最不该出现的东西。

影响面有多大

三个事实决定了它的严重程度:

第一,跨稳定版。 标签 regression-from-stable-to-stable 说的是 1.85 及更早的稳定版全部中招;评论里追到 1.4.0(2015)。这不是刚引入的 bug,是长期潜伏的。任何在稳定版上跑的、字段类型涉及生命周期协变的代码都可能已经被它"放过"过。

第二,不需要 unsafe。 全部复现都是 100% 安全代码。这正是 I-unsound 标签的含义——Rust 的核心承诺(safe code 不产生 UB)被打破了。crate 里如果有类似的泛型结构 + Drop 组合,编译期看不出来任何异常。

第三,理论上可被利用。 一个能稳定构造 use-after-free 的健全性漏洞,在特定场景下可以升级成信息泄露或控制流劫持。Rust 生态里大量网络服务、解析器、加密库用泛型容器承载生命周期敏感的数据,攻击面不小。

需要说明的是,"能编译"不等于"到处都能触发"。要命中需要三个条件同时满足:结构体类型参数带生命周期 bound、字段在该生命周期上协变、代码通过字段赋值(或类型统一)把不满足 bound 的值写进去。日常写业务代码很难自然踩到,但写泛型库、自研容器、FFI 封装时就不是小概率事件了。

现在的状态和缓解

issue 当前是 open 状态,assigned P-high(最初由 rustbot 自动打 P-critical,讨论后降级)。相关讨论已经开了 Zulip 主题,@rustbot 也把它和 #128225 关联了起来——那不是孤立事件,是同一类缺陷的另一处暴露。

官方的修复方向是在类型统一完成后补一道校验:确认统一得到的具体类型确实满足泛型参数的 bound,不满足就报错而不是放行。这类改动触及 unify 这个编译器最核心的路径,改动面和回归风险都不小,短期内更可能是先加 lint 或推迟到某个 edition。

在修复落地前,开发者能做的:

# 关注安全通告,1.85+ 的 patch 版本升级优先
# 对泛型 + Drop + 生命周期字段的代码,手动审计赋值路径

如果自己的 crate 里有类似模式,可以先用 Miri 跑一遍测试套件。Miri 的栈借用模型(stacked borrows)对 use-after-scope 比 ASan 更敏感,很多这类问题在测试里就能被它揪出来:

rustup component add miri
cargo +nightly miri test

一点个人看法

这件事最值得回味的不是 bug 本身,而是它藏了十一年。Rust 的借用检查器被当作内存安全的地基,社区习惯用"编译通过就安全"作为心智模型的锚点。这个 issue 提醒我们,那个锚点建立在编译器实现之上,而编译器是一个有边界、会漏的软件。

对写泛型库的人来说,Miri 不该是 CI 里的选配,而该是标配——尤其是在你自研容器、手写 unsafe 封装、或者用 PhantomData 和生命周期参数玩花活的模块里。对普通业务开发者,这次大概率波及不到你,但它验证了一个朴素的工程直觉:任何声称"保证安全"的抽象,都值得在边界处留一道自己的检查。

把复杂的安全论证外包给编译器是合理的,前提是知道那份委托契约里写了什么、以及它什么时候可能不成立。


参考链接