Hacker News 中文摘要

RSS订阅

11 year old unsoundness bugs found in Rust borrow checker

在 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)