写 Rust 时,我们经常依赖一个判断:只要使用安全接口,编译器就会阻止悬垂引用。但如果编译器自己漏了一道检查呢?
10 月 13 日,Rust 仓库收到了一份特别的 bug 报告:一段完全没有 unsafe 的代码,竟然可以把局部引用保存到作用域之外,并在原数据销毁后继续读取。
这个问题被标记为 I-unsound,也就是类型系统的安全性漏洞。讨论中的复现变体,甚至可以追溯到 Rust 1.4。
一句 'static,为什么没拦住它?
先看一个简化版:
struct Q<T: 'static>(Option<T>);
impl<T: 'static> Drop for Q<T> {
fn drop(&mut self) {
// 这里可以依赖 T: 'static
}
}
fn main() {
let text = String::from("hello");
let mut q = Q(None);
q.0 = Some(&text); // 本应被拒绝
}
Q 明确要求:T 必须满足 'static。
这里容易产生一个误解:T: 'static 并不是要求这个值永远存在,而是要求它不包含有效期不足的借用。
例如,局部创建的 String 拥有自己的数据,所以可以满足 T: 'static;但指向局部变量的 &String,通常不能。因此,把 &text 填进这个结构体,本应收到编译错误。
实际情况却是:受影响的编译器接受了这段代码。结构体声明的要求,就这样被绕过去了。
协变:安全的规则,遇到了漏掉的检查
issue 标题中也提到了一个词:协变。如果不是特别了解它的定义,那就先从一个简单的直觉开始:长期有效的引用,可以按较短的期限使用。
比如,一段永久有效的字符串,当然也可以传给一个只需要临时读取它的函数:
fn show<'a>(text: &'a str) {
println!("{text}");
}
let text: &'static str = "hello";
show(text);
这个过程没有延长任何引用的生命周期,因此是安全的。共享引用的协变,让 Rust 能灵活处理这种情况。
但类型关系能够调整,不代表类型自己的约束可以消失。对于 Q<T: 'static>,无论编译器如何推导、匹配类型,都必须确保最终的 T 仍然满足 'static。
这个漏洞暴露的,正是这两件事之间的检查缺口:字段接收了短生命周期引用,析构函数却仍然相信结构体声明的生命周期承诺。
真正让引用“逃出去”的,是 Drop
仅仅把引用放进去,还不足以看清后果。关键在于:Drop 可以做什么?
在原始复现中,析构函数会取出字段内容,将它装箱,保存到一个全局容器。于是发生了这样的过程:
- 创建局部字符串。
- 把字符串的引用写入
q。
- 离开作用域,
q 的析构函数将引用保存到全局。
- 字符串随后被销毁。
- 程序从全局取回引用,继续读取。
保存下来的是引用,不是字符串的副本。
当它指向的对象已经不存在,这个引用就成了悬垂引用。AddressSanitizer 在复现中报告了 stack-use-after-scope,即使用了已经离开作用域的栈对象。
这也说明,生命周期标注不会让数据多活一秒。它是一项由编译器验证的承诺。验证出了问题,运行时不会自动替我们续上对象的寿命。
严重,但不等于日常代码随时会踩中
这个漏洞严重,是因为它突破了 safe Rust 应有的安全边界。
不过,漏洞存在多久,与现实中有多容易触发,是两个问题。
维护者判断,要通过它造成内存不安全,需要刻意构造的 Drop 实现,意外触发的可能性较低。目前 issue 的优先级是 P-high。
截至 2026 年 10 月 15 日查阅时,修复 PR #164277 仍在评审。
修复方向是在 MIR 借用检查阶段,补充局部变量类型的合法性检查:不只检查“类型能不能匹配”,还要检查“这个类型是否满足自身的约束”。
我们可以从中学到什么?
对学习 Rust 的人来说,这个 bug 把几个抽象概念串了起来:
生命周期描述借用关系,协变允许安全地缩短使用期限,Drop 则可能在对象销毁时继续操作其中的数据。这些机制必须相互配合,才能守住安全边界。
Rust 的内存安全承诺,需要编译器准确落实。即使应用代码没有 unsafe,编译器本身也仍然可能出错。
而这个漏洞值得关注的地方,就是它把一道平时看不见的边界展示了出来:
一旦编译器接受了某个类型,使用这个类型的代码,就有权相信它声明的约束。
这份信任,是 Rust 安全抽象能够成立的基础。
参考链接