
Microsoft 的 FORGE Lab 在 Rust 标准库(std 和 core)中发现了若干内存安全问题,其中包括完全用安全 Rust 编写的代码也可能触发未定义行为的情况。这项研究使用了 Hyenas,这是 FORGE 对代号 MDASH 的前沿研究版本,一个由 AI 驱动的漏洞扫描系统。这些发现揭示了 unsafe 代码中三种反复出现的错误信任模式:相信代码不会 panic、相信用户提供的实现是正确的,以及相信底层系统。
Rust 的安全保证正是这些发现之所以有趣的原因。其丰富的类型系统和所有权模型排除了整类内存安全问题,但标准库的很大一部分在其安全 API 之下依赖 unsafe 代码。当这些代码违反其安全契约时,即便是安全 Rust 也可能遇到这门语言本意要防止的行为。
随着这门语言成为数十亿人每天依赖的软件的一部分,Rust 的内存安全保证正变得越来越重要。据估计,每天有数十亿人与基于 Rust 的软件或操作系统交互,而且常常并不知情。Microsoft 将 Rust 列为与 C++、C# 和 TypeScript 并列的 Tier-1 内部语言,GitHub 用 Rust 重写了 Copilot agent 运行时,Bun、Android、Linux 内核和 Cloudflare 也在将代码迁移到 Rust。这些软件中的许多都依赖 Rust 的基础库 core 和 std,以及它们提供的 API 的安全保证。这些库中的 bug 所造成的影响可能远远超出其起源代码。
Unsafe Rust 一直是我们长期关注的方向。我们的团队包括 Rudra 背后的人员,Rudra 是一个静态分析器,曾在 2021 年寻找整个 Rust 生态中的内存安全 bug。我们自己也用 Rust 编写了许多工具,因此它的安全性对我们来说不止是一个研究课题。今年,我们更仔细地检查了标准库,看看如今它的 unsafe 代码中仍会出现哪些类型的错误。
在本文中,我们分享一些我们报告到 Rust issue tracker 的 bug。我们借它们来讨论 Rust 保证什么、不保证什么,为什么编写正确的 unsafe Rust 如此困难,以及为什么即便 Rust 的内存安全并不完美,它的安全模型仍然重要。
我们如何发现并验证这些 bug
在 Microsoft FORGE Lab,我们研究复杂软件中的漏洞,包括 Rust 标准库。我们的团队将多年的漏洞研究经验与 AI 辅助工具结合起来,这些工具帮助我们调查代码,并发现仅靠传统分析难以发现的 bug。
本文中的一些 bug 是在 Hyenas 的帮助下发现的,Hyenas 是我们对代号 MDASH 的研究版本。我们的研究人员调查了底层代码,复现了这些 bug,评估了它们潜在的安全影响,并与 Rust 维护者合作报告和修复它们。
本文聚焦于标准库 unsafe 代码中的健全性 bug。编译器本身的 bug,例如错误编译和类型系统中的漏洞,是另一个话题,我们在此不涉及。
Rust 安全 bug 的模式
在最底层,Rust 使用 unsafe 块来处理原始指针,并与操作系统直接交互。标准库的很大一部分,包括 Vec 和 String,都是由小的 unsafe 片段构建并封装在安全 API 中的。在 unsafe 块内部,程序员要对编译器无法检查的保证负责,而这种责任延伸至围绕它构建的安全 API。该 API 必须是健全的:无论安全代码如何使用它,都必须不可能触发未定义行为。
守住这个承诺比看起来更难。考虑一个函数:它从任何可被视为字节切片的类型中返回 idx 处的字节,并在自行完成边界检查后跳过冗余的边界检查:
pub fn get<S: AsRef<[u8]>>(s: &S, idx: usize) -> Option<u8> {
if idx < s.as_ref().len() {
// SAFETY: we just checked that `idx` is in bounds.
Some(unsafe { *s.as_ref().get_unchecked(idx) })
} else {
None
}
}
unsafe Rust 的难点在于知道什么可以信任。上面的函数看起来正确,但它假设两次调用 as_ref 会返回同一个切片。由于 S 由调用者选择,一个安全的 AsRef 实现可以第一次返回长切片、第二次返回空切片,从而使 get 发生越界读取。unsafe 代码必须谨慎对待它所做的每一次调用,而我们发现的 bug 可归为三类错误信任:相信代码不会 panic、相信用户提供的实现是正确的,以及相信底层系统。
模式 1:相信代码不会 panic
unsafe 代码常常会暂时破坏某个不变量,然后再恢复它;例如,它可能分配一个未初始化的缓冲区并直接填充,从而避免先写零清零内存这种冗余写入。只要函数运行到结尾,这就没问题。但 Rust 中的许多操作都可能 panic,从越界索引到调用用户提供的代码,而默认情况下 panic 会 unwind:它会在仍运行析构函数的情况下提前离开函数。在这个窗口内发生 unwind 会变成一次不可见的退出,把已破坏的不变量暴露给信任它的代码。这类 bug 被称为 panic 安全性 bug。
MapWindows::clone (#156501)
我们在 MapWindows::clone 中发现了一个 panic 安全性问题。Iterator::map_windows 是一个 nightly API,会产生由 N 个项组成的重叠窗口。它把当前窗口保存在一个 Buffer 中,该 Buffer 的析构函数会 drop 从 start 开始的 N 个项。在 Rust 1.97.0 中,library/core/src/iter/adapters/map_windows.rs 将其定义为:
37struct Buffer<T, const N: usize> {
38 // Invariant: `self.buffer[self.start..self.start + N]` is initialized,
39 // with all other elements being uninitialized. This also
40 // implies that `self.start <= N`.
41 buffer: [[MaybeUninit<T>; N]; 2],
42 start: usize,
43}
同一文件中的 Buffer::clone 先构建新缓冲区,然后再填充它:
195impl<T: Clone, const N: usize> Clone for Buffer<T, N> {
196 fn clone(&self) -> Self {
197 let mut buffer = Buffer {
198 buffer: [[const { MaybeUninit::uninit() }; N], [const { MaybeUninit::uninit() }; N]],
199 start: self.start,
200 };
201 buffer.as_uninit_array_mut().write(self.as_array_ref().clone());
202 buffer
203 }
204}
在第 197 行之后,buffer 破坏了自身的不变量:它的槽位尚未初始化,但它的析构函数已经处于待命状态。第 201 行的 clone() 调用会在槽位写入之前运行用户的 T::clone。如果该调用 panic,unwind 会 drop buffer,并对 N 个未初始化的值运行 drop_in_place。我们的概念验证只使用安全代码:一个 clone 总是 panic 的元素类型,这会在析构函数访问未初始化内存时导致崩溃。修复方法是将新缓冲区保存在 ManuallyDrop 中,直到它被完全写入,这样 unwind 会泄漏这个半构建的缓冲区,而不是将其 drop。
一般的教训是,把每一次对你无法控制的代码的调用——例如 clone、drop、比较、闭包和迭代器方法——都视为可能的提前退出。要么在破坏不变量之前完成可能 panic 的工作,要么让已破坏的状态可以安全地 unwind 通过:把它保存在 ManuallyDrop 中,或者使用一个析构函数会恢复不变量的 guard,比如 Vec 内部的 SetLenOnDrop。The Rustonomicon 在 exception safety 一节中讨论了这一点。
分配器不得 unwind
最近稳定化的 Allocator API 表明,要正确处理 panic 安全有多么困难。在 std 中,分配失败默认会中止进程,而不是展开,因此标准库中大量 unsafe 代码假定分配永远不会展开。自定义分配器可能打破这一假定。在该 API 稳定化过程中,我们报告了 BTreeMap::insert 中一处会在分配器展开时变得不健全的地方,其他人也报告了更多问题(#159334、#159368、#159370)。逐个修复它们意味着要审计所有会进行分配的代码,因此将于 11 月 12 日在 Rust 1.100 中发布的稳定化,改为要求实现者永远不要从该 trait 的方法或 drop 中展开。该提案指出:“从许多与分配相关的方法中展开,已被发现是不健全性的一个普遍来源。”
模式 2:信任用户提供的实现是正确的
Unsafe 代码经常调用其调用者提供的代码:比较器、trait 实现、闭包。这些代码是安全的,但安全并不意味着正确,它可能返回不一致或明显错误的答案。问题在于,当它这样做时,责任在谁。在 C++ 中,责任在调用者。std::sort 要求其比较器满足严格弱序,而破坏该规则是未定义行为。使用 libstdc++ 时,用 <= 对相等元素排序会读取到 vector 末尾之后:
std::vector<int> v(100, 42);
// Bug: `<=` is not a strict weak ordering, since cmp(a, a) is true.
std::sort(v.begin(), v.end(), [](int a, int b) { return a <= b; });
// AddressSanitizer: heap-buffer-overflow in std::__introsort_loop
把同一个比较器传给 Rust 的 sort_by 只会是逻辑错误。排序可能 panic,或让切片处于未指定顺序,但绝不会导致未定义行为。责任在于 unsafe 代码。比较器是调用者提供的安全代码,因此无论它返回什么,排序内部的 unsafe 代码都必须保持健全。用户可实现的每个安全 trait 都是如此:比较可能不一致,ExactSizeIterator 可能报告的长度与其产出的项不匹配,Borrow 可能在每次调用时返回不同的值。
第 272 行的 CharPattern 分支会检查该字符是否为 ASCII,但第 271 行的 StringPattern 分支则假设单字节的字符串模式必定是 ASCII。这对 &str 模式成立,但 StringPattern 存储的是 &[u8],而 as_utf8_pattern 是任何 Pattern 都能实现的安全方法。在我们的概念验证中,一个自定义 pattern 报告单个字节 0x80,而该字节只会出现在多字节 UTF-8 序列内部:
impl Pattern for NonAsciiBytePattern {
// ...
fn as_utf8_pattern(&self) -> Option<Utf8Pattern<'_>> {
static NEEDLE: [u8; 1] = [0x80];
Some(Utf8Pattern::StringPattern(&NEEDLE))
}
}
// "\u{d000}" is [ed, 80, 80]; the result is [ed, 3f, 3f], which is not UTF-8.
let corrupted = "\u{d000}".replace(NonAsciiBytePattern, "?");
str::replace 改写了后续字节,返回一个并非合法 UTF-8 的 String,之后依赖 UTF-8 的字符串操作便会触达未定义行为。修复方案把 StringPattern 改为持有 &str 而非 &[u8],这样类型本身就能保证 UTF-8。
当 unsafe 代码依赖用户提供的实现所具有的某个性质时,不要盲目相信它。调用一次该实现并保留结果、校验其返回值,或者把这个性质编码进安全代码无法伪造的类型里,正如本次修复所做的那样。如果这些办法都行不通,就把该 trait 标记为 unsafe,让实现者显式承诺遵守它。
更多示例
join 展示了这类 bug 有多容易被只修好一半。2020 年,我们报告过它信任 Borrow::borrow 在每次调用时返回同一个字符串(CVE-2020-36323),当时的修复处理了两次调用之间字符串缩短的情况。2026 年 4 月,同一个函数又需要一次修复:join 会两次借用第一个元素,如果第二次调用时它变长了,剩余长度的计算就会下溢,变成堆溢出。早先的修复从未覆盖这条路径。
今年也有其他人报告了 Pattern API 中的类似问题。一个安全的 Pattern 可以返回一个为另一个字符串构建的合法 searcher,而 strip_prefix_of 这类使用方会不加校验地把它的范围应用到原字符串上,造成越界读取(#158265)。在有人报告 Searcher 契约没有写明 str::split 究竟依赖什么(#164904)之后,同样的问题又出现在 split_once 中(#165076)。这两个 bug 目前仍未修复。
模式 3:信任底层系统
前两个模式都停留在 Rust 代码内部。第三个模式位于与操作系统的边界上,在这里 unsafe 代码信任的是进程级状态,例如信号栈、内存映射和环境变量,而其他线程、C 库或内核随时都可能改动这些状态。这类 bug 一直很难被自动发现,因为静态分析器需要为所有这些周边行为建模。AI 智能体改变了这一局面:它们对代码所处运行环境的理解足够充分,使这一领域变得可以实际搜索。我们开始探索这一领域,并发现了一些有趣的 bug。
process::exit 与主线程的信号栈(#161018)
当某个线程的栈溢出时,Rust 会打印一条清晰的提示并中止,而不是以裸的段错误崩溃:
thread 'main' (3307186) has overflowed its stack
fatal runtime error: stack overflow, aborting
这依赖若干进程级状态。每个线程的栈末尾都有一个保护页,即紧邻栈边界之外的一小块不可访问区域:pthread 会为每个新建线程保留一个,而在 Linux 上,内核自身的栈保护区对主线程扮演这一角色。触碰它会触发 SIGSEGV,Rust 为此信号注册了处理函数。该处理函数无法在刚刚溢出的栈上运行,因此 Rust 还会给每个线程一个备用信号栈,即一块带有自己保护页的小型独立映射,内核在投递信号时会切换到它。处理函数会检查出错地址是否位于保护页内,打印提示,然后中止。
Rust 标准库中的内存安全缺陷
我们发现,从子线程调用 process::exit 会在主线程仍在运行时拆除主线程的备用栈。process::exit 在调用 libc 的 exit 之前会运行运行时的清理,而在 Rust 1.98.0 中,该清理结束于 library/std/src/sys/pal/unix/stack_overflow.rs:
196pub unsafe fn cleanup() {
197 if cfg!(panic = "immediate-abort") {
198 return;
199 }
200 // FIXME: I probably cause more bugs than I'm worth!
201 // see https://github.com/rust-lang/rust/issues/111272
202 unsafe { drop_handler(MAIN_ALTSTACK.load(Ordering::Relaxed)) };
203}
第 202 行把主线程的备用栈 MAIN_ALTSTACK 传给 drop_handler:
297// SAFETY: we warned the caller this disables the alternate signal stack!
298unsafe { sigaltstack(&disabling_stack, ptr::null_mut()) };
299// SAFETY: We know from `get_stackp` that the alternate stack we installed is part of
300// a mapping that started one page earlier, so walk back a page and unmap from there.
301unsafe { munmap(data.sub(page_size), sigstack_size + page_size) };
第 298 行禁用调用线程的备用栈,但第 301 行取消映射的是主线程的备用栈。代码假定它运行在主线程上,而当子线程调用 process::exit 时并非如此。主线程会继续运行,直到 libc 的 exit 结束,而其信号栈注册信息仍指向已取消映射的内存。如果它在该窗口内发生栈溢出,内核就无法递送信号,我们的概念验证会以普通段错误终止,而不是给出 Rust 的消息。该缺陷可追溯到 2015 年,修复方案则完全不再清理主线程的备用栈。
我们还检查了这是否可能成为安全问题。理论上,这可能导致代码执行:如果一个可写映射落在旧备用栈的地址上,内核会把主线程的下一个信号递送到该映射上,而另一个线程可以在处理程序运行时覆盖其栈帧,例如返回地址。在实践中,条件非常苛刻。一切都必须发生在 libc 的 exit 结束前的短暂窗口内,新映射必须具有恰好合适的尺寸才能复用该地址,而覆盖栈帧还需要在短暂的处理程序内部赢得第二次竞态。对于 issue #161018,我们的测试在所检查的条件下没有发现可靠的利用路径,并且我们评估实际利用不太可能。该评估不会自动适用于所讨论的其他问题。该 issue 在其安全影响分析中有更多细节。
CommandExt::exec 与全局环境(#156951)
我们的另一份报告涉及进程环境。在 Unix 上,CommandExt::exec 用新程序替换当前进程。当命令拥有自己的环境变量时,exec 会把进程全局的 environ 指向一个新数组,调用 exec
文章标题:Rust 标准库中的内存安全漏洞
正文: 第 242 行只获取了环境锁的读端,而第 410 行却写入了进程全局的 environ;第 409 行保存了旧值,以便在第 413 行的 execvp 失败时可以恢复。在持有读锁的情况下写入 environ 会让其他线程进入。一个调用 env::var 的线程会获取同一把读锁,从而可能看到本应仅供新程序使用的变量;而两个失败的 exec 调用相互竞争时,可能让 environ 指向一个已被释放的数组,随后被 env::vars_os() 读取。两个概念验证都只使用了安全 Rust。2018 年的一次修复在此处持有独占锁,但当环境锁在 2021 年变为读写锁时,exec 保留了读端。一个通过直接调用 execve 来避免触碰 environ 的修复正在审核中。
环境此前就引发过麻烦。经过一场始于 2015 年的讨论,2024 版将 env::set_var 标记为 unsafe,因为大多数 Unix 平台上的 C 函数在读取环境时不会经过 Rust 的锁。
process::exit 与线程局部析构函数(#164243)
正确处理这个边界很难,还有更多问题尚未解决。在探索这一领域时,我们独立遇到了一个由 process::exit 通过线程局部析构函数在安全代码中导致的双重释放问题。该问题由他人报告(#164243),但值得在此提及。libc 的 exit 会首先运行调用线程的线程局部析构函数,这是 C++ 的要求,而 Rust 以相同方式注册其析构函数。如果某个析构函数在线程局部变量被替换时调用 process::exit,libc 会第二次丢弃该线程局部变量。一个提议的修复将 process::exit 切换为 POSIX 的 _exit,后者会在不运行这些处理程序的情况下结束进程。
结论
每个 unsafe 块都建立在对周围代码的假设之上。本文中的漏洞来自三种错位的信任:
- 认为代码不会在错误时刻 panic,
- 认为调用者提供的实现行为正确,以及
- 认为 Rust 与操作系统和 C 库共享的进程级状态保持代码所期望的样子。
熟悉的模式依然困难(模式 1 和 2)
前两种模式已被充分研究。Rudra 在 2021 年同时查找了这两种模式,而标准库早在 2015 年就已在修复 panic 安全漏洞(CVE-2015-20001)。它们仍然难以避免,因为 unsafe 代码中的任何调用都可能展开或运行调用者提供的代码。
我们今年看到的情况好坏参半。积极的一面是,nightly 流程正在发挥作用。许多报告在受影响的 API 仍不稳定时就被捕获,若干问题在稳定化之前通过更改设计得到修复。StringPattern 现在持有 &str 而非 &[u8],Allocator 在稳定化时附带了一条规则:实现者不得展开。与此同时,这些模式中的漏洞仍会进入稳定版本,且其修复并不总是完整的。join 在 2020 年得到修复,而在 2026 年又需要针对第一次修复遗漏的一条路径进行再次修复。
系统边界漏洞如今已触手可及(模式 3)
第三种模式有所不同。其漏洞存在于 Rust 与其下层交界之处:内核,通过 sigaltstack 和 munmap 等系统调用;以及 C 库,通过 environ 和 libc 的 exit 所执行的清理等进程级状态。正确处理它们需要 Rust 类型系统之外的知识,这使得这些漏洞难以通过传统静态分析发现。这也可能是它们存续如此之久的原因。process::exit 信号栈漏洞在标准库中存在了近 11 年,而 exec 自 2021 年起就在读锁下写入 environ。与我们前两种模式中的报告不同,这两者都影响稳定版 Rust。
语言模型已经对这些接口的行为方式了解甚多,因此 agentic 扫描器可以对照 unsafe 代码所运行的系统进行检查,而不仅仅是语言本身。我们认为这个边界是 agentic 安全研究最有前景的方向之一,在 Rust 内外皆如此。
Rust 的高标准难以达到,但值得坚持
Rust 对其安全 API 的要求比 C 和 C++ 更严格。无论 API 如何使用,安全代码都必须永远无法导致未定义行为。在 C++ 中,破坏 std::sort 排序要求的比较器会导致未定义行为,而 Rust 的 sort 必须在任何 Ord 实现下都保持内存安全。POSIX 并不保证在另一个线程读取环境时调用 setenv 是安全的。这使得 Rust 中的内存安全 bug 难以与其他语言中的 bug 一一对应。
出于同样的原因,标准库中的健全性 bug 并不自动成为使用它的程序中的漏洞。本文中的每个 bug 都需要不寻常的设置才能触发,例如在恰好错误的时刻发生 panic、故意行为异常的实现,或从子线程调用 process::exit。只有当真实程序以这种方式到达该 API 时,它才会成为严重漏洞。
Rust 的安全标准严格且难以满足,但它也很有用。它为什么算作健全性 bug 给出了明确答案,因此问题可以在库中一次性修复和预防,而不是在使用它的每个程序中分别处理。Rust 的安全模型在系统编程的控制力、安全性和性能之间取得了恰当的平衡,这也是如此多项目选择它的原因。
超越实验室
在 Microsoft FORGE Lab,我们正在探索诸如代号 MDASH 的技术如何帮助研究人员更大规模地发现本文中的这类 bug。Rust 的标准库是我们应用这种方法的几个基础开源项目之一。
这项研究也延伸到了实验室之外。它有助于为代号 MDASH 的开发提供信息,该技术将智能体驱动的漏洞发现引入 Microsoft Defender。这些发现也可以作为 Microsoft Perception 的输入,这是我们新的多智能体安全系统,其中专门的智能体在更广泛的安全工作流中协同工作。例如,Web App Pen Test Agent 可以使用代号 MDASH 的发现来帮助确定测试优先级、针对实时应用验证可利用性,并产生可操作的漏洞,供下游智能体用来确定优先级并支持修复。这将 AI 驱动的代码分析与现实世界的安全验证和响应连接起来,帮助安全团队从识别潜在漏洞转向理解和应对实际风险。
我们的目标不是简单地发现更多 bug,而是识别有意义的问题,将这些发现转化为可操作的研究,并提高开发者和组织所依赖的基础软件的安全性。