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 上编译通过。注意两个值得对照的细节(下文有完整对照表):

也就是说,唯独「先 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 'static outlives the lifetime parameter 'a, &'static str is a subtype of &'a str.

即长的能当短的用:&'static String 是 &'a String 的子类型。反过来 &'a String <: &'static String,恰恰是健全的编译器永远不能得出的结论——如果哪天子类型关系真被搞成了「短的 <: 长的、并能合一进去」,那么 let r: &'static String = &l; 一行就该编译,整个借用检查体系从根上就作废了。这并没有发生。

'static 在这里是「声明约束」,不是「赋值目标」

让人误以为「短命被赋给了 'static」的,是混淆了两件事:

  1. Q<T> 的类型参数要满足 T: 'static——这是结构体对 Q<X> 是否成立的前提(well-formedness 要求),和 &'a T 要求 T: 'a 是同一类东西;
  2. 而 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 的简化版自 1.18.0 (2017) 起就能编译;lcnr 贴出的、只涉及生命周期约束的变体更是「unsound since 1.4」(2015)。所以「11 年」这个说法是站得住的。

那篇流传的中文介绍错在哪

逐条对照(结论:事实部分基本正确,机制解释是错的):

  1. 子类型方向反了。Rust 里是 &'static String <: &'a String(长的能当短的用);「把 &'a String 当成 &'static String 的子类型」正是健全编译器绝不能做的事。
  2. 不存在「把 &'a String 与 &'static String 统一」。赋值时 q.0: Option<?T>,与 'static 无关;被统一的是推断变量 ?T 和 &'a String,这本身合法。缺的是随后对 T: 'static 的证明。
  3. 阶段说错了。「合一/统一」确实发生在类型推断阶段,但问题出在「该被要求而没被要求」的约束证明上,不是「借用检查器在合一阶段做了错误代换」。借用检查器只是没收到该有的约束。
  4. 译名。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 加了一段「降温」说明,要点:

背景是:P-critical 在 issue 创建后十几分钟内被(自动/人工)打上,随后下调为 P-high。

总结

修复方向见 lcnr 提到的三件事:下一代 trait solver、region 约束重构,以及 Project Zero。

参考资料

  1. 原始 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 下的复现)
  2. Rust Reference:Subtyping and variance
  3. Rust Project Goals:Project Zero、next-generation trait solver、next-generation region constraints
  4. 被本文纠正的网络文章:《Rust 埋了 11 年的内存安全漏洞被挖出》(丹恩爱分享,2026-10-14,网络转载/二手介绍,未提供稳定链接)——其事实转述基本正确,但关于漏洞机制的那段解释(协变 + 合一 + 子类型)有误。