纯安全代码触发段错误:Rust 标准库浮现 unsafe 裂纹

纯安全代码触发段错误:Rust 标准库浮现 unsafe 裂纹

Rust系统安全软件工程

数据源:Microsoft Security Blog + GitHub

纯安全代码也能触发未定义行为

在很多系统开发者的认知里,只要代码中不显式写出 unsafe 关键字,Rust 编译器就会在编译阶段消灭内存破坏隐患。2026 年 11 月 9 日,微软安全博客公布了 FORGE Lab 针对 Rust 标准库的最新审计进展。研究员 Yechan Bae 与团队借助 AI 驱动的代码扫描系统 Hyenas 以及人工验证,在 std 与 core 内部定位了多处健全性(soundness)缺陷。纯粹的安全代码能够合法穿透抽象,在运行时引发未定义行为。

工业界对语言安全边界的依赖从未如此紧密。微软已将 Rust 列为内部第一梯队系统语言,GitHub 采用 Rust 重构了 Copilot 核心代理运行时,承载全球约 20% 网络流量的 Cloudflare 也在将基础设施迁向 Rust。这类核心项目深度依赖标准库提供的基础集合与系统抽象。如果底层抽象漏出未定义行为,上层业务代码的编译期保证就会随之动摇。

工匠排查锻铁裂纹 图:工匠用金属探针排查锻铁环上的微小裂纹。来源:微软 FORGE Lab

unsafe 封装打破三类信任假设

标准库的安全封装由底层 unsafe 块搭建。FORGE Lab 团队指出,这些漏洞集中暴露出内部实现对运行环境的三种错误预设:预设代码绝不会异常中断、预设调用方实现满足隐性契约、预设操作系统始终保持状态一致。当这些假设在边角路径被打破,原本受控的裸指针操作便会越界。

第一种破损发生在异常展开路径,Rust 1.97.0 的实验性迭代器 API Iterator::map_windows 暴露了 issue #156501。其内部结构 Buffer<T, N> 承诺只有 start..start+N 区间处于初始化状态。但在 MapWindows::clone 实现中,代码先构造了一个全未初始化却武装了析构函数的新缓冲区,再去调用用户的 T::clone。一旦用户提供的克隆逻辑抛出 panic,栈展开机制就会针对 N 个未初始化插槽调用 drop_in_place,安全代码因此触发内存崩溃。

panic 展开破坏不变式 图:panic 展开如何跳过恢复步骤,导致析构函数处理未初始化内存。来源:微软 FORGE Lab

该缺陷的修补方案是将半成品缓冲区放入 ManuallyDrop。在异常展开时宁可泄露内存,也不允许执行非法析构。标准库对 panic safety 的修复历史漫长,最早能追溯到 2015 年的 CVE-2015-20001。十一年过去,异常安全依然是系统级代码中最容易遗漏的不变式。

第二种破损源于轻信外部传入的安全实现。Rust 1.96.0 的 str::replace 在处理单字节模式时设计了一条快路径,默认该字节必为 ASCII(issue #156491)。然而 StringPattern 当时仅存储 &[u8],任何自定义 Pattern 都能通过安全方法 as_utf8_pattern 传入任意字节。当调用方传入代表 0x80 的单字节模式时,replace 便会拼接出一个破坏 UTF-8 编码约束的 String,后续字符串处理逻辑随即触发内存未定义行为。

维护团队随后合并 PR #156556,将 StringPattern 直接改为持有 &str 类型。依靠编译器类型检查消除安全隐患,比运行时防御更加严密。无独有偶,join 操作信任 Borrow::borrow 的漏洞曾在 2020 年被标记为 CVE-2020-36323。2026 年它又因元素在调用间意外增长引发长度下溢,再次通过 PR #155708 补充修补。

第三种隐患来自内存分配器的行为假设。std 默认在内存分配耗尽时直接中止进程,大量底层 unsafe 因而假定分配操作绝不展开。自定义分配器的引入打破了这种静态预设。FORGE 团队报告的 BTreeMap::insert 在分配器展开时存在 soundness 漏洞(issue #156490),社区紧接着提交了 #159334、#159368 与 #159370。

逐行排查全库所有分配点难以落地。Rust 1.100 在稳定化 Allocator API(PR #156882)时,选择直接在语言规范层面立下硬性条款。规范明确禁止实现者在 trait 方法与析构逻辑中执行展开,用统一契约截断了潜在的漏洞扩散。

信号栈悬空近 11 年:系统调用边界失守

语言抽象与底层操作系统对接的边界,往往藏着最隐蔽的生命周期漏洞。在 Unix 平台下,从子线程调用 std::process::exit() 会解除主线程备用信号栈的内存映射。但这一操作并未向内核注销该栈结构(issue #161018)。若进程随后捕获系统信号,内核将向已解除映射的虚拟地址压栈,直接导致段错误崩溃。

这个由 qwaz 协助提交的缺陷已在标准库稳定分支静静潜伏了近 11 年。类似的系统并发冲突还出现在 CommandExt::exec 中。该实现曾在仅持有环境锁读权限的状态下修改全局 environ 变量(issue #156951),自 2021 年起持续存在。操作系统状态的全局易变性,严重挑战着语言运行时的隔离假定。

备用信号栈与主线程栈映射冲突 图:备用信号栈与主线程栈映射冲突导致的段错误路径。来源:微软 FORGE Lab

独立安全研究员 Juho Snellman 在 2026 年 10 月 13 日报告的 issue #164243 展现了更恶劣的交织形态。开发者仅需在纯安全代码中从线程局部变量的析构函数调用 std::process::exit(),程序便会立刻触发 double free。控制台明确抛出 free(): double free detected in tcache 2 报错。该问题可回溯至 Rust 1.85.0,在标准库开发追踪中被列为 P-high 优先级。

Snellman 最初在排查其他项目时借助 Claude Code 捕捉到线索。他随后人工验证了完整攻击路径。该缺陷在 macOS 平台极难优雅修复,因为系统的线程局部析构甚至早于 atexit 回调执行。跨平台系统接口各异的生命周期约定,经常超出类型系统在编译期能够推演的极限。

健全性缺陷不等于现成漏洞

健全性(soundness)缺陷不等于现成可用的漏洞。标准库发现的每一处 soundness 漏洞,都依赖罕见的前置条件。这包括在构造中间态触发的特定 panic、刻意构造的非标准 trait 实现,或是跨线程退出进程的时序交错。真实世界中的业务逻辑如果未曾行经这些特殊分支,就不会凭空化为可利用的安全风险。

Rust 与 C/C++ 对安全 API 的工程标准存在本质分歧。在 C++ 中,向 std::sort 传入违背严格弱序的比较函数属于未定义行为,在 libstdc++ 环境下会被 AddressSanitizer 当场截获堆越界访问。但在 Rust 中,把错误的比较闭包传给 sort_by,顶多造成无序或抛出 panic。安全接口在规范上严禁产生任何内存未定义行为。

微软本次公开的技术报告未给新漏洞分配 CVE 编号,也未罗列确切的缺陷总数。即便问题已被准确定位,修复方案的打磨与合并依然需要漫长周期。截至报告发布,PR #157144 与 PR #165125 仍在等待合并。issue #158265 与 #165076 也依旧处于开放状态。

类型系统挡不住底层工程疏漏

本次排查同样展现了语言进化流程的积极一面。不少 soundness 缺陷在 nightly 阶段就被拦截,并未流入正式稳定版。维护团队重构 StringPattern 为直接存储 &str。团队还在 Allocator 规范中直接增加禁止展开约束,依靠类型与规范从源头切断风险。

部分潜伏超过十年的基础漏洞流向 stable,说明自动化静态分析在复杂状态组合面前仍有盲区。**Rust 的内存安全承诺深度维系在底层 unsafe 块的工程纪律上。**当安全封装底层的指针逻辑破坏了不变式,编译器为安全代码设立的屏障就会在运行时瞬间瓦解。

没有任何一门语言能够靠静态类型免除持续验证的工程义务。从 AI 辅助静态扫描到人工逆向复现,安全防线的稳固建立在对边界缺陷的反复审视之上。对系统开发者而言,走出对安全抽象的盲信,才是构建真正稳固软件的现实起点。

参考链接:

  • 微软安全博客 FORGE Lab 技术报告
  • Rust 官方代码仓库与 Issue 追踪