Unsafe 到底是不是原罪?从 Rust 进入 Linux 内核后的第一个 CVE 谈起
这个 CVE 不是 Rust 的失败,而是 Unsafe 机制正常工作的体现。它把问题暴露在可控的边界上,让我们能够快速定位、修复,并从中学习。
以下文章来自微信公众号“觉学社”,作者张汉东,发表于 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 块上方,开发者必须写清楚:
-
为什么这里需要 unsafe? (做了什么编译器无法验证的事)
-
安全性依赖哪些前提条件? (即"不变量",invariants)
-
这些前提条件为什么成立? (给出论证)
这不是可选的"锦上添花",而是 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
和 |
|
调用 unsafe 函数或方法 |
包括 FFI 函数 |
|
访问或修改可变静态变量 |
static mut |
|
实现 unsafe trait |
如 |
|
访问 union 的字段 |
因为编译器无法知道当前存储的是哪个变体 |
**关键点:unsafe 是"许可",不是"正确"**。 编译器允许你做这些事,但你必须自己保证不会触发未定义行为。
不变量:unsafe 的灵魂
每一段 unsafe 代码,都应该有明确的 不变量(invariants) 文档。不变量回答三个问题:
-
为什么需要 unsafe? 这段代码做了什么编译器无法验证的事情?
-
需要维持哪些假设? 代码正确运行依赖于哪些条件?
-
什么情况会破坏它? 哪些输入或并发访问可能违反这些假设?
回看 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 边界的哨兵
AddressSanitizer(ASan)和 ThreadSanitizer(TSan)可以检测内存错误和数据竞态,对于 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 说明了什么?
-
unsafe 代码需要更谨慎的审查:安全注释中的假设必须覆盖所有场景,包括并发场景。
-
Rust 没有消灭 bug,但改变了 bug 的分布:Safe Rust 消灭了整类内存安全 bug,剩下的 bug 集中在 unsafe 边界和逻辑错误。
-
显式 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 社区从来没有说过"代码编译过了就没问题"。准确的说法是:
-
Safe Rust 代码:编译通过 = 没有内存安全问题(这是有形式化证明支持的)
-
Unsafe Rust 代码:编译通过 ≠ 没问题,需要人工验证不变量
-
混合代码:整体安全性取决于 unsafe 部分的正确性
Rust 的价值在于:
-
缩小了需要人工审查的范围:只有 unsafe 块需要特别关注
-
明确了审查的重点:不变量文档告诉你需要验证什么
-
消灭了整类 bug:在 safe Rust 中,data race、use-after-free 等根本不可能发生
把 100% 的代码都需要担心内存安全,变成只有 1% 的代码需要担心,这本身就是巨大的进步。
结语:安全不是不碰底层,而是用正确的方式碰
CVE-2025-68260 是 Rust 进入 Linux 内核以来的第一个 CVE,但它不会是最后一个。只要有 unsafe 代码,就有出错的可能。
但这恰恰说明了 Rust 的设计哲学:
“承认底层操作的必要性,但用显式标记、不变量文档、工具检测和社区规范,把风险控制在最小范围。
Ralf Jung 在演讲结尾的总结,值得每一个系统程序员记住:
-
unsafe 是必要的接口层,不是肆意逃逸
-
以不变量为核心进行文档化与封装,把危险关在小房间
-
把 UB 当作红线,用工具与测试守住它
-
以社区规范与形式化方法,把经验升级为可验证的工程
安全不是不碰底层,而是用正确的方式碰。
这个 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
开放原子旋武开源社区(简称“旋武社区”)是由开放原子开源基金会孵化及运营的技术社区,致力于在中国推广和发展Rust编程语言生态,推动Rust在操作系统、终端设备、安全技术、基础软件等关键领域的产业落地,构建安全、可靠、高效的软件基础设施。
更多推荐

所有评论(0)