以下文章来自微信公众号“觉学社”,作者张汉东,发表于 2025 年 12 月。

引子:一场预料之中的争论

2025 年 12 月 16 日,Linux 内核 CVE 公告列表中出现了一条特别的记录:CVE-2025-68260[1]。

这不是一个普通的内核漏洞。它来自  Linux 内核中用 Rust 编写的 Android Binder 驱动实现 rust_binder 模块。这是 Rust 代码进入 Linux 内核主线以来,第一个被正式分配 CVE 编号的安全漏洞

消息一出,社交媒体上炸开了锅。

Rust 的批评者们仿佛找到了期待已久的"实锤":

"既然内核用 unsafe 不可避免,而 unsafe 代码又存在隐患,之前就不要着急四处吹牛什么'代码编译过了就没问题',合着又要追加个条件说 unsafe 不算编译过的代码吗?"

而 Rust 的支持者则迅速回应:

"在你开始炒作 Linux 内核中第一个与 Rust 代码相关的 CVE 之前,请注意:同一天,内核的 C 代码部分发布了 159 个 CVE。"

一位 C 开发者甚至站出来为 Rust 辩护:

"作为一个 C 开发者,我居然要来帮 Rust 说话,但事实是——每天都有上百个由 C 代码导致的 CVE。五年来一个非关键性的 CVE,放在这个背景下真的不算什么。"

争论的核心,最终聚焦到了 Rust 语言中一个经常被误解的特性:Unsafe

那么,今天,我们就借这个 CVE,来彻底搞清楚:Unsafe Rust 到底是什么?它是 Rust 安全承诺的"漏洞",还是系统编程的"必要之恶"?


一:这个 CVE 究竟是怎么回事?

在开始讨论 unsafe 之前,让我们先把这个漏洞本身搞清楚。

背景:Unsafe Rust 的安全抽象

要理解这个漏洞,首先需要理解 Rust 的一个核心设计理念:安全抽象(Safe Abstraction)

Rust 把代码分为两个世界:

  • Safe Rust:编译器通过所有权、借用检查器等机制,在编译期自动证明代码的内存安全性。你不需要做任何额外的事情,编译通过就意味着没有数据竞态、悬垂指针、缓冲区溢出等问题。

  • Unsafe Rust:某些底层操作(如解引用原始指针、调用 FFI 函数)超出了编译器的静态分析能力。这时,开发者需要用 unsafe 关键字标记代码块,亲自向编译器承诺:我已经人工验证过了,这段代码是安全的。

关键在于:unsafe 块内的代码,编译器虽然也会有和 Safe Rust 一样的内存安全检查(Unsafe 是 Safe 的超集),但对于一些特定操作,比如操作裸指针这些,编译器就照顾不到了,所以需要依赖开发者必须保证它是安全的。

这就引出了一个问题:你怎么证明自己的承诺是可信的

答案是 SAFETY 注释 : 一种社区约定的文档规范。在每个 unsafe 块上方,开发者必须写清楚:

  1. 为什么这里需要 unsafe? (做了什么编译器无法验证的事)

  2. 安全性依赖哪些前提条件? (即"不变量",invariants)

  3. 这些前提条件为什么成立? (给出论证)

这不是可选的"锦上添花",而是 Rust 社区的强制性规范。没有 SAFETY 注释的 unsafe 代码,在 code review 中会被直接打回。

为什么?因为 unsafe 代码的正确性完全取决于这些不变量是否成立。如果不变量写错了、漏写了、或者随着代码演进而失效了,bug 就会出现。

现在,让我们来看这个 CVE 的问题代码。

问题代码

漏洞出现在 drivers/android/binder/node.rs 文件中。要理解这个 bug,我们需要先了解一点背景。

Binder 是什么?

Binder 是 Android 系统的核心进程间通信(IPC)机制。当一个 App 想调用另一个 App 或系统服务时,就需要通过 Binder。它是 Android 能够运转的基石之一。

death_list 是什么?

在 Binder 中,有一个重要的概念叫"死亡通知"(Death Notification)。当一个服务进程意外崩溃时,所有依赖它的客户端都需要被通知。NodeDeath 结构体就是用来表示这种死亡通知的,而 death_list 是一个链表,存储了所有注册到某个 Binder 节点上的死亡通知。

这段代码在做什么?

当需要清理一个 NodeDeath 时,代码需要把它从所属的 death_list 链表中移除。问题就出在这个移除操作上:

// SAFETY: A `NodeDeath` is never inserted into the death_list
// of any node other than its owner, so it is either in this
// death list or in no death list.
unsafe { node_inner.death_list.remove(self) };

这段代码的 SAFETY 注释声称:一个 NodeDeath 节点只会被插入到它所属节点的 death_list 中,所以它要么在这个列表里,要么不在任何列表里

基于这个假设,开发者认为这个 unsafe 操作是安全的。

但问题是:这个假设在并发场景下不成立。

竞态条件的形成

让我们看看 Node::release 函数的逻辑:

线程 A (Node::release):
  1. 获取锁
  2. 把 death_list 的所有项目移动到栈上的临时列表
  3. 释放锁  ← 问题在这里
  4. 遍历临时列表,处理每个节点

线程 B (NodeDeath cleanup):
  在某个时刻调用 unsafe { death_list.remove(self) }

看出问题了吗?

当线程 A 在第 3 步释放锁之后、第 4 步处理期间,线程 B 可能同时在原始列表上调用 remove。此时:

  • 线程 A 正在通过临时列表遍历,会访问节点的 prev/next 指针

  • 线程 B 正在通过原始列表删除,也会修改节点的 prev/next 指针

  • 两个线程同时修改同一块内存,没有任何同步机制

 

图片

(图示来自 x.com/forefy[2])

这就是经典的数据竞态(Data Race),导致链表指针被损坏。最终的结果是内核崩溃:

Unable to handle kernel paging request at virtual address 000bb9841bcac70e
...
Internal error: Oops: 0000000096000044 [#1] PREEMPT SMP

修复方案

修复其实很简单:不要把项目移动到临时列表,而是在持有锁的情况下直接从原始列表中逐个弹出并处理。这样就不存在"释放锁后其他线程还能访问链表"的窗口期。

关键洞察

这个 bug 的本质是什么?

不是 unsafe 本身有问题,而是 SAFETY 注释中的不变量假设是错误的。

开发者假设"节点要么在这个列表里,要么不在任何列表里",但实际上,在 Node::release 的实现中,节点可能被移动到一个临时列表,这个临时列表没有被锁保护。这打破了原有的假设。

用 Ralf Jung (Rust 语言团队成员) 的话说:unsafe 代码里最重要的注释,是关于不变量的那一段。 而这段注释恰恰写错了。


二:Unsafe Rust 到底是什么?

巧合的是,就在这个 CVE 发布前不久,Rust 语言内存模型的权威研究者 Ralf Jung 在 Scala Days 2025 上做了一场关于 unsafe Rust 的演讲[3]。这场演讲完美地回答了"Unsafe 到底是什么"这个问题。

unsafe ≠ 不安全

首先要澄清一个常见误解:**unsafe 不是"不安全的代码",而是"编译器无法自动验证安全性的代码"**。

在 Safe Rust 中,编译器通过所有权系统、借用检查器等机制,在编译期就能证明代码不会出现:

  • 空指针解引用

  • 悬垂指针

  • 数据竞态

  • 缓冲区溢出

  • 双重释放

但有些操作,编译器无法静态证明其安全性,比如:

  • 解引用原始指针

  • 调用 FFI 函数

  • 访问可变静态变量

  • 实现某些特殊的 trait

这些操作需要用 unsafe 关键字标记。**Unsafe 不是"允许你写危险代码",而是"你向编译器承诺:我已经人工验证了这段代码的安全性"**。

Ralf Jung 的原话:

"Rust doesn't make unsafe 'free'; it makes it explicit and accountable."

"Rust 并没有让不安全变得'免费',它让它变得显式且可问责。"

unsafe 允许的五类操作

在 unsafe 块中,你可以执行以下五类操作:

操作

说明

解引用原始指针

*const T

 和 *mut T

调用 unsafe 函数或方法

包括 FFI 函数

访问或修改可变静态变量

static mut

实现 unsafe trait

如 SendSync

访问 union 的字段

因为编译器无法知道当前存储的是哪个变体

**关键点:unsafe 是"许可",不是"正确"**。 编译器允许你做这些事,但你必须自己保证不会触发未定义行为。

不变量:unsafe 的灵魂

每一段 unsafe 代码,都应该有明确的 不变量(invariants) 文档。不变量回答三个问题:

  1. 为什么需要 unsafe? 这段代码做了什么编译器无法验证的事情?

  2. 需要维持哪些假设? 代码正确运行依赖于哪些条件?

  3. 什么情况会破坏它? 哪些输入或并发访问可能违反这些假设?

回看 CVE-2025-68260 的问题代码:

// SAFETY: A `NodeDeath` is never inserted into the death_list
// of any node other than its owner, so it is either in this
// death list or in no death list.
unsafe { node_inner.death_list.remove(self) };

注释确实写了"为什么安全",但这个假设本身是不完整的——它没有考虑到 Node::release 会把节点移动到临时列表的情况。不变量写错了,unsafe 代码就不可能是安全的。


三:为什么内核必须使用 unsafe?

"既然 unsafe 有风险,为什么不完全避免?"

这个问题的答案是:在系统编程领域,unsafe 是不可避免的。

系统编程的三个刚需

Ralf Jung 列出了 unsafe 存在的三个根本原因:

1. 与底层世界交互

操作系统内核必须与硬件、C 代码、设备驱动交互。这些交互需要:

  • 原始指针操作

  • 特定的内存布局控制

  • FFI 调用

Rust 的安全抽象建立在这些底层操作之上,而这些操作本身无法被安全抽象覆盖。

2. 性能与可预测性

某些高性能数据结构(如无锁队列、内存分配器、侵入式链表)需要精细控制内存布局和访问模式。编译器的自动检查有时会阻止一些安全但高效的实现。

3. 表达跨层不变量

有些安全性保证跨越了多个抽象层次,编译器无法静态捕获。比如:

  • "这个指针指向的内存一定是有效的,因为我们在更高层的逻辑中保证了这一点"

  • "这个 trait 的实现一定满足某些语义约束"

Ralf Jung 的总结:

"Unsafe is where Rust meets the world."

"unsafe 是 Rust 与世界相接之处。"

Linux 内核的特殊挑战

Linux 内核作为一个复杂的系统软件,有大量场景必须使用 unsafe:

  • 内存管理:直接操作物理内存、页表

  • 中断处理:在特殊上下文中执行代码

  • 并发原语:实现锁、原子操作、RCU

  • 设备驱动:与硬件寄存器交互

  • 与 C 代码互操作:内核的绝大部分仍然是 C

这不是 Rust 的问题,而是系统编程的本质。C 语言的做法是默认不安全,所有代码都可能有问题,没有任何编译期检查。Rust 的做法是默认安全 + 显式不安全(unsafe block & keyword) ,把风险集中在少数受管控的边界。


四:如何正确使用 unsafe

既然 unsafe 不可避免,那么如何将风险降到最低?

Ralf Jung 给出了三条核心原则:少、清、封

原则一:少(Minimize)

仅在不可避免处使用 unsafe,把它缩到最小范围。

不要这样写:

unsafe {
    // 一大堆代码
    // 其中只有一行真正需要 unsafe
    // 但整个块都标记为 unsafe 了
}

而要这样写:

// 安全的准备工作
let ptr = get_pointer();
let len = calculate_length();

// 仅在必要的地方使用 unsafe
let value = unsafe { *ptr };

// 安全的后续处理
process(value);

原则二:清(Document)

在每个 unsafe 块上方写清楚不变量。

好的注释模板:

// SAFETY: 
// - `ptr` 是有效的,因为它来自 `Box::into_raw`,且尚未被释放
// - `ptr` 指向的内存是正确对齐的,因为 Box 保证了这一点
// - 我们有独占访问权,因为没有其他引用存在
unsafe { Box::from_raw(ptr) }

坏的注释(或者根本没有注释)会导致:

  • 后续维护者不知道这段代码依赖什么假设

  • 重构时可能无意中打破假设

  • Code review 时无法验证正确性

原则三:封(Encapsulate)

用安全的 API 封装 unsafe 实现。

这是最重要的原则。unsafe 代码应该被封装在模块内部,对外暴露完全安全的接口。使用者不需要知道内部有 unsafe,也不可能误用。

经典例子是标准库的 Vec<T>

// 内部实现使用 unsafe 进行内存管理
// 但对外接口是 100% 安全的
letmut v = Vec::new();
v.push(1);  // 安全调用,不需要 unsafe
v.push(2);
let first = v[0];  // 安全调用

Vec 的实现者承担了 unsafe 的责任,使用者完全不需要担心内存安全。

架构图:unsafe 的正确分层

┌─────────────────────────────────────────────────────────────┐
│                     应用层代码                               │
│                   (100% Safe Rust)                         │
├─────────────────────────────────────────────────────────────┤
│                     安全 API 层                              │
│              (Safe 接口,内部封装 unsafe)                   │
├─────────────────────────────────────────────────────────────┤
│                     unsafe 核心层                            │
│        (最小化的 unsafe 代码,详细的不变量文档)              │
├─────────────────────────────────────────────────────────────┤
│                   底层 / FFI / 硬件                          │
└─────────────────────────────────────────────────────────────┘

关键洞察:unsafe 的面积越小,审计成本越低,bug 越容易发现。


五:让 unsafe 可验证的工具与方法

Rust 社区并没有止步于"靠人肉保证 unsafe 正确"。一系列工具和形式化方法正在把 unsafe 的风险管理从"经验驱动"转向"证据驱动"。

Miri:unsafe 的守门人

Miri 是一个 Rust 解释器,可以在运行时检测多种未定义行为:

  • 无效的内存访问

  • 违反别名规则(Stacked Borrows / Tree Borrows)

  • 未初始化内存读取

  • 内存泄漏

在开发 unsafe 代码时,用 Miri 跑测试可以发现很多隐藏的 bug。

Loom:并发的照妖镜

Loom 是一个并发测试框架,通过穷举(或智能采样)线程调度来发现竞态条件。

CVE-2025-68260 的 bug 如果用 Loom 测试,很可能在发布前就被发现。

Sanitizers:FFI 边界的哨兵

AddressSanitizerASan)和 ThreadSanitizerTSan)可以检测内存错误和数据竞态,对于 Rust 与 C 代码的交互边界特别有用。

RustBelt:形式化证明

RustBelt 是一个学术项目,为 Rust 的类型系统和 unsafe 封装提供了形式化语义基础。它从数学上证明:如果 unsafe 代码满足特定的不变量,那么它的安全封装在任何使用场景下都是安全的。

Ralf Jung 的原话:

"Tooling makes unsafe less mystical; formal methods make it trustworthy."

"工具让不安全不再神秘,形式化方法让它值得信赖。"


六:理性看待这个 CVE

现在,让我们回到最初的争论。

数据说话

CVE-2025-68260 发布的同一天,Linux 内核的 C 代码部分发布了 159 个 CVE

让我们做一个简单的对比:

指标

Rust 代码

C 代码

进入内核的时间

~3 年

~33 年

代码量占比

< 1%

> 99%

累计 CVE 数量

1

数以万计

本次发布的 CVE

1

159

这个数据不是要证明"Rust 完美无缺",而是要说明:Rust 显著降低了内存安全漏洞的发生率。

这个 bug 说明了什么?

  1. unsafe 代码需要更谨慎的审查:安全注释中的假设必须覆盖所有场景,包括并发场景。

  2. Rust 没有消灭 bug,但改变了 bug 的分布:Safe Rust 消灭了整类内存安全 bug,剩下的 bug 集中在 unsafe 边界和逻辑错误。

  3. 显式 unsafe 让问题更容易定位:这个 bug 被发现后,开发者立刻知道要检查 unsafe 块的不变量。如果是 C 代码,定位问题会困难得多。

Rust 的承诺从来不是"零 bug"

Rust 的安全承诺是:

如果你的代码是 100% safe Rust(没有 unsafe),那么它不会有内存安全问题。

对于包含 unsafe 的代码,承诺变成:

unsafe 代码的正确性取决于你维护的不变量是否正确。如果不变量正确,封装后的 safe API 就是安全的。

这个 CVE 恰恰证明了:当不变量写错时,unsafe 代码就会出问题。 这不是 Rust 的缺陷,而是 Rust 的设计工作正常——它把风险集中在了可审计、可追责的地方。

C 语言的对比

如果同样的代码用 C 实现,会是什么情况?

  • 没有 unsafe 标记:所有代码都是"unsafe"的,无法快速定位高风险区域

  • 没有强制的 SAFETY 注释:不变量可能根本没有文档化

  • 更容易出现其他内存安全问题:use-after-free、buffer overflow、null pointer 等

  • 更难追踪问题来源:没有所有权系统辅助分析


七:对 Rust Haters 的回应

让我们正面回应文章开头的质疑:

"既然内核用 unsafe 不可避免,而 unsafe 代码又存在隐患,之前就不要着急四处吹牛什么'代码编译过了就没问题'……"

这个质疑基于一个错误的前提。

Rust 社区从来没有说过"代码编译过了就没问题"。准确的说法是:

  1. Safe Rust 代码:编译通过 = 没有内存安全问题(这是有形式化证明支持的)

  2. Unsafe Rust 代码:编译通过 ≠ 没问题,需要人工验证不变量

  3. 混合代码:整体安全性取决于 unsafe 部分的正确性

Rust 的价值在于:

  • 缩小了需要人工审查的范围:只有 unsafe 块需要特别关注

  • 明确了审查的重点:不变量文档告诉你需要验证什么

  • 消灭了整类 bug:在 safe Rust 中,data race、use-after-free 等根本不可能发生

把 100% 的代码都需要担心内存安全,变成只有 1% 的代码需要担心,这本身就是巨大的进步。


结语:安全不是不碰底层,而是用正确的方式碰

CVE-2025-68260 是 Rust 进入 Linux 内核以来的第一个 CVE,但它不会是最后一个。只要有 unsafe 代码,就有出错的可能。

但这恰恰说明了 Rust 的设计哲学:

承认底层操作的必要性,但用显式标记、不变量文档、工具检测和社区规范,把风险控制在最小范围。

Ralf Jung 在演讲结尾的总结,值得每一个系统程序员记住:

  1. unsafe 是必要的接口层,不是肆意逃逸

  2. 以不变量为核心进行文档化与封装,把危险关在小房间

  3. 把 UB 当作红线,用工具与测试守住它

  4. 以社区规范与形式化方法,把经验升级为可验证的工程

安全不是不碰底层,而是用正确的方式碰。

这个 CVE 不是 Rust 的失败,而是 Unsafe 机制正常工作的体现。它把问题暴露在可控的边界上,让我们能够快速定位、修复,并从中学习。

159 比 1。这个数字对比,才是今天真正值得讨论的故事。


附录:CVE-2025-68260 技术细节

漏洞类型:竞态条件(Race Condition)

受影响版本:Linux 6.18(引入)

修复版本:Linux 6.18.1、6.19-rc1

问题文件drivers/android/binder/node.rs

根本原因Node::release 在释放锁后遍历临时列表,与其他线程的 unsafe remove 操作产生数据竞态

修复方案:在持有锁的情况下直接从原始列表弹出元素,避免释放锁后的竞态窗口

参考链接

  • CVE 公告[4]

  • 修复提交 1[5]

  • 修复提交 2[6]


如果这篇文章对你有帮助,欢迎点赞、在看、转发。

关于 Rust 和系统编程,你有什么想法?欢迎在评论区讨论。

参考资料

[1] CVE-2025-68260: https://nvd.nist.gov/vuln/detail/CVE-2025-68260

[2] x.com/forefy: x.com/forefy

[3] unsafe Rust 的演讲: https://www.youtube.com/watch?v=YwABQ9eYQv4

[4] CVE 公告: https://cve.org/CVERecord/?id=CVE-2025-68260

[5] 修复提交 1: https://git.kernel.org/stable/c/3428831264096d32f830a7fcfc7885dd263e511a

[6] 修复提交 2: https://git.kernel.org/stable/c/3e0ae02ba831da2b707905f4e602e43f8507b8cc

Logo

开放原子旋武开源社区(简称“旋武社区”)是由开放原子开源基金会孵化及运营的技术社区,致力于在中国推广和发展Rust编程语言生态,推动Rust在操作系统、终端设备、安全技术、基础软件等关键领域的产业落地,构建安全、可靠、高效的软件基础设施。

更多推荐