在 Rust 中,结构体的生命周期要求可以通过协变(covariant)字段赋值绕过。带生命周期参数的结构体(struct)本应让其字段满足相应的生命周期约束,但借助协变字段进行赋值后,即使写入值的生命周期短于结构体声明的生命周期参数,代码仍会按更长的生命周期参数通过编译。
正文
在以下代码里:
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());
}
我期望看到的结果是:程序不被接受。
但实际发生的是:程序能编译,并打印出 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX。如果使用 asan,程序会崩溃并报 AddressSanitizer: stack-use-after-scope。
元信息
此问题存在于当前的 nightly,以及至少从 1.85.0 开始的稳定版。
带 asan 的 Godbolt 链接:https://rust.godbolt.org/z/83MvfbKde 不带 asan 的 Godbolt 链接:https://rust.godbolt.org/z/GsGdqqMWe
活动
jsnell 将该问题标记为 C-bug(漏洞类别)。theemathas 将其标记为 I-unsound(可靠性漏洞),并加上了 A-borrow-checker(借用检查器)和 T-types(types 团队)标签,同时移除了 needs-triage(待分类)标签;随后又添加了 A-destructors(析构器)标签。
theemathas 评论说,问题的核心在于:
struct Q<T: 'static>(Option<T>);
impl<T: 'static> Drop for Q<T> {
fn drop(&mut self) {
// 这里的代码能访问一个非 'static 的 &String
// 但假定它是 &'static String
}
}
fn main() {
let l = String::new();
let mut q = Q(None);
q.0 = Some(&l);
}
theemathas 接着说,他这个版本的代码从 1.18.0(2017 年)起就能编译了。他惊讶地问,这到底为什么到现在才被发现??? 他还补充说,可能与 #128225 有关。
lcnr 给出了一个不依赖 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;
}
}
fn main() {
let mut me_out = "rarw";
{
let l = String::from("some cool string");
let mut q = MyType(&mut me_out, "");
q.1 = &l;
}
println!("me_out: {me_out}");
}
lcnr 随后说,从 1.4 起就是 unsound 了 🤣(Godbolt 链接:https://rust.godbolt.org/z/j1s1TevhP)。
rustbot 一度将优先级标为 P-critical,随后在 Zulip 讨论后改为 P-high(并移除了 P-critical 标记)。
maxdexh 给出了另一个从 1.4 起就会段错误或打印已释放内存的版本:
struct Q<T, F: FnMut(&T)>(Option<T>, F);
impl<T, F: FnMut(&T)> Drop for Q<T, F> {
fn drop(&mut self) {
self.1(self.0.as_ref().unwrap())
}
}
fn main() {
let mut out: Option<&'static _> = None;
{
let l = vec![vec![1]];
let mut q = Q(None, |s| {
out = Some(*s);
});
q.0 = Some(&l);
}
println!("{:?}", out);
}
lcnr 把标题从「struct's T: 'static bound bypassed via covariant field assignment」改为现在的「struct's lifetime requirements can be bypassed via covariant field assignment」。
maxdexh 添加了 A-lifetimes(生命周期/区域)和 A-variance(型变)标签。
theemathas 又给出了一个版本,说这是最容易理解的:
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
// 但假定它是 &'static String
let wrong: &'static String = self.0.unwrap();
thread::spawn(move || {
thread::sleep(Duration::from_secs(1));
println!("{}", wrong);
});
}
}
fn main() {
{
let l = String::from("abc");
let mut q = Q(None);
q.0 = Some(&l); // q.0 应该是一个 &'static String,但我们却给它赋了一个非 'static 的 &String
}
thread::sleep(Duration::from_secs(2));
}
maxdexh 认为,类型统一(unification)通常根本不检查类型的生命周期约束。他给了一个不使用字段赋值的常规统一例子:
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)>,这不是良构的(WF)
let mut unified = [
Q(
long,
(|s: &'static T| {
std::thread::spawn(move || {
println!("{s:?}");
});
}) as fn(&'static T),
),
Q(&local, (|_: &T| {}) as fn(&T)),
];
let [l, r] = &mut unified;
std::mem::swap(&mut l.0, &mut r.0);
}
std::thread::sleep_ms(1000);
}
元数据
指派对象
无人指派
标签
A-borrow-checker(借用检查器区域)、A-destructors(析构器区域)、A-lifetimes(生命周期/区域)、A-variance(型变)、C-bug(漏洞类别)、I-unsound(可靠性漏洞)、P-high(高优先级)、T-types(types 团队相关,将由该团队审查并决定 PR/issue)。
类型
无类型
项目
T-types unsound issues,状态为 open/unblocked
里程碑
无里程碑
关系
暂无
开发
无分支或拉取请求
评论
讨论气氛以惊讶、调侃和零星称赞为主:不少人被这个异常简单、像普通代码里就会遇到的 Rust 不健全例子吓到,也有人觉得它并不意外,因为 Rust 编译器里早已积累大量没人关心的健全性问题。分歧主轴在于它究竟说明「Rust 并不内存安全」,还是只是「Rust 比别的语言更好但不可能完美,且这类语言与编译器缺陷会被逐步修掉」。
认为最小例子简单得反直觉、像普通代码就会遇到
- 标题容易让人预期是冷门又复杂的边角,实际最小例子只有几行:一个带 T: 'static 约束并实现 Drop 的结构体,却能把非 'static 的引用塞进 Option,在 drop 里拿到非 'static 引用却当成 'static 用;这种形态在常规代码里并不难写出来,因此很多人第一反应是震惊,也有人问安全关键认证编译器 Ferrocene 是否同样中招。(较多人) > 问题核心是一个带 'static 约束和 Drop 实现的简单结构体,能在 drop 里拿到非 'static 的引用却当成 'static 用。(@MrBuddyCasino)
认为这并不意外、只是 Rust 大量不健全问题之一
- 较多人觉得大惊小怪没必要:Rust 编译器里早已积累大量健全性缺陷且没人关心,目前仅标记为不健全的问题就有一百多个;Rust 仍然比多数语言更好,但永远不可能完美,新 trait 求解器未来会解锁其中一部分修复(大概率不包括这次这个)。也有人翻出多年前的健全性承诺,质疑当年的安全性承诺后来是否没了下文。(较多人) > 考虑到 Rust 编译器里收集了那么多没人关心的健全性缺陷,这根本不该让人意外。(@rvz)
认为 Rust 并不内存安全、过去同类问题被刻意淡化
- 有人直接断言 Rust 不是内存安全语言,认为 cve-rs 多年前就证明了这点,但社区总把这类问题说成太刻意、或只是编译器缺陷而不算数;这次的漏洞再次说明问题真实存在。(少数人) > Rust 不是内存安全语言,cve-rs 多年前就证明了,但评论者总说它太刻意或只是编译器缺陷而不算数。(@cowlevel)
认为根源在于类型系统和型变太难、长期缺乏测试
- 有人指出类型系统本身就很困难,counterexamples.org 上能找到多个 Rust 反例样本;尤其是型变几乎没人手写,相关缺陷能十年无人测试,这类问题往往要和可变性等其他棘手特性叠加才暴露,也有人好奇这个例子能否去掉可变性重现。(少数人) > 做过型变相关 lint 才知道,几乎没人会手写型变,所以那里的漏洞能十年都没测到。(@FuzzywarmWalnut)
认为漏洞很刁钻、真实触发场景可能少见,需排查大库
- 有人花了几分钟才理解,承认这是个刁钻的漏洞,虽然最小例子简单,但猜测这种特定触发场景在真实代码里可能少见,因此接下来该去检查大型库是否受影响;这和上一条「普通代码就会遇到」的直觉形成分歧。(少数人) > 花了几分钟才搞懂,真是个刁钻的漏洞,我猜这种特定用例很少见,该去看看大库有没有受影响。(@j4k0bfr)
认为 AI 搜索与形式化验证终将让 Rust 更可靠
- 有人觉得讽刺的是,用 AI 去搜 Rust 编译器的漏洞反而会找出更多这类问题,进而把 Rust 推近完全可靠;最终 Rust 的形式化和形式化验证过的编译器(如安全关键领域的 Ferrocene)才是终点,新 trait 求解器也会解锁一部分不健全问题的修复。(少数人) > 讽刺的是,用 AI 搜索 Rust 编译器里的漏洞可能会找出更多此类问题,反而把 Rust 推近完全可靠,终点大概是形式化和经过证明的编译器。(@lambdaone)
认为编译型语言难以事后扫描漏洞、修复要重编一切
- 少数人从部署角度指出,编译型程序没法像 VM 或解释型语言那样扫描出有漏洞的版本并替换运行时或库,只能重新编译所有东西,因此这类缺陷的运维代价被放大。(少数人) > 这正是编译型程序的弱点:你不知道编译出来的代码有没有这个漏洞,至少 VM 或解释器能扫出有漏洞的版本并替换运行时,而编译型只能全部重新编译。(@fnord77)
认为 Rust 出这种问题让 C++ 老手忍不住调侃
- 一位老 C++ 程序员只回了「呵呵呵」,还感谢踩他的人用无私勇气阻止轻松幽默占上风;这条被踩的玩笑代表了部分 C++ 社区对 Rust 出问题的幸灾乐祸情绪。(少数人) > 作为一个老 C++ 人,我的看法就是:呵呵呵。(@akoboldfrying)
有价值的补充
- counterexamples.org 上能找到 11 个 Rust 相关反例样本,分布在 eventually-nothing、mutable-matching 等页面;排查时通常先看可变性是否和其他棘手特性叠加,也有人好奇这个例子能否在不使用可变性的情况下重现。(@mrkeen)
- 有人问安全关键场景用的认证编译器 Ferrocene 是否也有同样问题;新 trait 求解器未来会解锁部分不健全问题的修复,但大概率不包括这个。(@MrBuddyCasino、@weinzierl)
- 有人翻出 2020 年 Rust 的健全性承诺,质疑当年的安全性承诺后来是否没了下文。(@beanjuiceII)