Rust 埋了 11 年的健全性漏洞:协变字段赋值如何绕过结构体的生命周期约束
十月 16, 2026 [Rust] #Rust #编译原理 #安全Rust 埋了 11 年的健全性漏洞:协变字段赋值如何绕过结构体的生命周期约束
本文所有结论均在
rustc 1.96.0 (2026-05-25)上实测复现;对照实验的完整样例见文末。
结论先行
2026-10-13,rust-lang/rust#164250 报告了一个存在了十几年的 I-unsound(健全性漏洞,worst kind of bug):结构体声明的生命周期约束,可以通过一次「协变字段赋值」被绕过,全程没有 unsafe。
事情本身很快火出圈,随之而来的是大量二手介绍。其中流传最广的一种解释是:
关键在
q.0 = Some(&l)这一行。Option<T>对T是协变的,于是借用检查器在「合一 / 强制」阶段,把&'a String(短命)当成了&'static String(永生)的子类型,直接统一了进去。
这句话是错的,而且错得有点关键:它把一个「漏检」描述成了「检查判错」。本文先给可复现的 POC 并把「编译器本该在这里报错」的那一步标出来,再解释真正的机制,最后给一组对照实验说明为什么这不是「从根上坏了」。
POC:哪一步本该报错
最小核心(去掉一切干扰)
struct Q<T: 'static>(Option<T>);
impl<T: 'static> Drop for Q<T> {
fn drop(&mut self) {
// 在这里,编译器认为 self.0: Option<T>,并且 T: 'static 成立,
// 因此允许把字段当作一个「活到程序结束」的引用来使用。
let _ = self.0.take();
}
}
fn main() {
let l = String::new();
let mut q = Q(None); // 此刻 T 仍是推断变量 ?T,无从检查
// ★ 问题就在这一行 ★
// 赋值使类型推断把 ?T 解析成 &'a String('a 是 l 的借用区域)。
// 按语言规则,Q<X> 这个类型只在 X: 'static 时成立,所以 T 定下来之后,
// 编译器本应回头证明约束 `T: 'static`(也就是 `&'a String: 'static`,
// 即 `'a: 'static`),并据此报 E0597「l 活不够长」。
// 但这条证明义务被丢掉了:既没有报错,也没有生成任何 'a: 'static 约束。
q.0 = Some(&l);
// 于是程序里出现了一个按编译器自己的规则不该存在的值:Q<&'a String>。
}
这段代码在 rustc 1.96.0 上编译通过。注意两个值得对照的细节(下文有完整对照表):
- 如果把类型参数写死成
let mut q: Q<&'static String> = Q(None);,同一行q.0 = Some(&l)会正确报错E0597——因为这时「短引用要顶替 'static」的关系真的需要被判断,编译器老老实实拒绝了; - 如果构造时类型参数就已知(
Q(Some(&l))),也会正确报错。
也就是说,唯独「先 Q(None)、再由字段赋值事后推断出 T」这条路径没有检查。
原始 POC:把悬垂引用走私出去
上面那段只是「类型不该存在」,要变成肉眼可见的内存不安全,还需要一个「利用这条约束」的 Drop——这正是 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() {
// ★ 把「按声明应当是 'static」的引用装箱塞进全局容器。
// 生命周期在代码生成阶段已被完全擦除,这里只是拷贝了一个指针。
KEPT.lock().unwrap().push(Box::new(t));
}
}
}
fn main() {
{
let l = String::from("hello world, this is long enough");
let mut q = Q(None);
// ★ 同前:T 被解析成 &'a String,但约束 T: 'static 从未被证明。
q.0 = Some(&l);
} // l 在此析构;KEPT 里留下指向它栈内存的悬垂指针
let _z = String::from("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
let k = KEPT.lock().unwrap();
println!("{}", k[0].downcast_ref::<&'static String>().unwrap());
}
实测输出是 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX——不是原来那句 hello world...,而是后创建的 _z 复用了 l 曾经占用的栈槽。issue 作者补充说,开着 AddressSanitizer 跑会直接 stack-use-after-scope。
为什么 Drop 是链条里的一环:impl<T: 'static> Drop for Q<T> 的函数体是在「T: 'static 成立」这个前提下被检查的。这个前提本来由每一个构造 Q<X> 的地方负责兑现;漏掉一次兑现之后,析构函数对字段的使用就成了空头支票。没有 Drop(或没有别的依赖该约束的代码)时,漏洞仍是一个「编译器接受了自己规则不允许的程序」的健全性问题,但不会立刻产生可观测的 UB。
正确的机制:不是「统一错了」,是「该证明的没证明」
子类型的方向是反的
Rust Reference 对子类型(Subtyping and variance)的原话是:
Since
'staticoutlives the lifetime parameter'a,&'static stris a subtype of&'a str.
即长的能当短的用:&'static String 是 &'a String 的子类型。反过来 &'a String <: &'static String,恰恰是健全的编译器永远不能得出的结论——如果哪天子类型关系真被搞成了「短的 <: 长的、并能合一进去」,那么 let r: &'static String = &l; 一行就该编译,整个借用检查体系从根上就作废了。这并没有发生。
'static 在这里是「声明约束」,不是「赋值目标」
让人误以为「短命被赋给了 'static」的,是混淆了两件事:
Q<T>的类型参数要满足T: 'static——这是结构体对Q<X>是否成立的前提(well-formedness 要求),和&'a T要求T: 'a是同一类东西;- 而
q.0的类型是Option<?T>,不是Option<&'static String>。
赋值那一刻实际发生的是:类型推断把推断变量 ?T 解析成 &'a String。这一步本身完全合法,也完全没有 'static 参与比较。真正缺失的,是「解析出 T = &'a String 之后,回头证明结构体声明的 T: 'static」这一步。所以准确的说法是:
编译器不是「把短命当成了 'static 的子类型」,而是没有要求这个证明;于是它手上出现了一个按自己的规则不该存在的类型实例
Q<&'a String>。
issue 里最接近根因的公开描述来自 rust-lang 组织成员 maxdexh:
I think unification just generally doesn't check lifetime bounds of the type.
(types 团队的置顶 note 并没有给出正式根因,只说明定级与历史。这也是为什么值得警惕:根因排查仍在进行。)
顺带解释了另一个疑问:借用检查器(borrowck)不是被绕过了,而是根本没有收到那条本应生成的 'a: 'static 约束。NLL 只负责执行它拿到的区域约束;约束在上游的类型推断阶段就没被要求,borrowck 自然无感。
对照实验:漏洞只存在于一条路径上
在同一台机器(rustc 1.96.0)上把各种「把类型参数说明白」的写法逐一试过:
| 变体 | 结果 |
|---|---|
let r: &'static String = &l; | ❌ E0597 |
Q(Some(&l))(构造时 T 已知) | ❌ E0597 |
let mut q: Q<&String> = Q(None); q.0 = Some(&l); | ❌ E0597 |
let mut q: Q<&'static String> = Q(None); q.0 = Some(&l); | ❌ E0597 |
let mut q = Q(None); q.0 = Some(&l);(T 事后推断) | ✅ 编译通过(漏洞路径) |
同上、但结构体完全不带 Drop impl | ✅ 编译通过(漏洞路径) |
| 原始 POC | ✅ 编译通过并打印 XXXX… |
这张表本身就是「局部漏检」而非「全局规则反转」的证据:凡是将「短引用必须成为 'static」讲清楚的地方,编译器都正确拒绝;只有把类型参数的确定推迟到赋值之后、从而错过检查点的路径漏了过去。
社区还很快给出了两个不依赖「字段赋值」的变体,说明病灶在「推断/统一不检查约束」,而不是某一种赋值写法:
- theemathas 给出了不依赖
static的版本(struct Q<'a: 'static>(Option<&'a String>)),证明问题与'static无关,是通用的「生命周期要求被绕过」; - maxdexh 给出了数组元素统一化的版本(连字段赋值都不需要,改用
std::mem::swap把短命引用换进承诺'static的位置),随后有人在 stable 1.99.0 / nightly 1.101.0 上复现,并在 Miri 下得到use-after-free。
版本下限方面:theemathas 的简化版自 1.18.0 (2017) 起就能编译;lcnr 贴出的、只涉及生命周期约束的变体更是「unsound since 1.4」(2015)。所以「11 年」这个说法是站得住的。
那篇流传的中文介绍错在哪
逐条对照(结论:事实部分基本正确,机制解释是错的):
- 子类型方向反了。Rust 里是
&'static String <: &'a String(长的能当短的用);「把&'a String当成&'static String的子类型」正是健全编译器绝不能做的事。 - 不存在「把
&'a String与&'static String统一」。赋值时q.0: Option<?T>,与'static无关;被统一的是推断变量?T和&'a String,这本身合法。缺的是随后对T: 'static的证明。 - 阶段说错了。「合一/统一」确实发生在类型推断阶段,但问题出在「该被要求而没被要求」的约束证明上,不是「借用检查器在合一阶段做了错误代换」。借用检查器只是没收到该有的约束。
- 译名。
soundness hole是「健全性/可靠性漏洞」,不是「声音漏洞」(典型的机翻)。
其余转述——issue 日期与编号、I-unsound 标签、从 P-critical 下调为 P-high、11 年历史、「要利用需要蓄意恶意的 Drop」、以及团队在推进 next-generation trait solver / region constraints / Project Zero——都与原 issue 一致。
为什么只定 P-high,而不是 P-critical
issue 顶部由 lcnr 加了一段「降温」说明,要点:
- 这个 bug「无论有没有人在野碰到,都本应是
P-critical」; - 之所以「只是
P-high」,是因为它已经藏了 11 年——一个 11 年没被撞上的 bug,不可能随手就能触发; - 而且「即便看起来容易触发,要利用它也需要一个蓄意恶意的
Drop实现」,现实中几乎没有无意触发的可能。
背景是:P-critical 在 issue 创建后十几分钟内被(自动/人工)打上,随后下调为 P-high。
总结
- 这是货真价实的
I-unsound:编译器接受了自己规则不允许的程序,且不需要任何unsafe; - 但它的本质是某一条推断路径上缺少一次约束证明,不是「子类型/合一规则被写反了」;
- 因此它没有、也不会「从根上」破坏借用检查:
let r: &'static String = &l;照常报错;影响面限定在「恰好构造出这个非法类型 + 恰好有依赖该约束的代码(如Drop)」; - 对日常写 Rust 的启示:写
Drop与泛型生命周期约束相关的代码时,别把「声明过的约束」当成「已被证明的真理」;不过普通业务代码基本碰不到这条路径。
修复方向见 lcnr 提到的三件事:下一代 trait solver、region 约束重构,以及 Project Zero。
参考资料
- 原始 issue:rust-lang/rust#164250 — struct's lifetime requirements can be bypassed via covariant field assignment
(关键评论:lcnr 的置顶 note 与「unsound since 1.4」;theemathas 的最小核心与不依赖
static的变体;maxdexh 关于「unification 不检查生命周期边界」的判断及数组统一化变体;mueddib 在 stable 1.99.0 / nightly 1.101.0 + Miri 下的复现) - Rust Reference:Subtyping and variance
- Rust Project Goals:Project Zero、next-generation trait solver、next-generation region constraints
- 被本文纠正的网络文章:《Rust 埋了 11 年的内存安全漏洞被挖出》(丹恩爱分享,2026-10-14,网络转载/二手介绍,未提供稳定链接)——其事实转述基本正确,但关于漏洞机制的那段解释(协变 + 合一 + 子类型)有误。