改进 C# 内存安全
摘要
微软宣布对 C# 16 中的 unsafe 关键字进行重新设计,以强制执行内存安全契约,使 unsafe 操作变得可见并由编译器强制执行,预览版将在 .NET 11 中发布,正式版在 .NET 12 中发布。
暂无内容
查看缓存全文
缓存时间: 2026/05/23 12:31
# 提升 C# 内存安全性 - .NET 博客 来源:https://devblogs.microsoft.com/dotnet/improving-csharp-memory-safety/ 我们正在显著提升 C# 的内存安全性(https://github.com/dotnet/runtime/issues/125800)`unsafe` 关键字正在被重新设计,以告知调用者他们必须履行的义务以维持安全性,并通过一种新的安全注释风格进行文档化。该关键字将从标记指针扩展到任何与内存交互的代码,且编译器无法验证其安全性。编译器将强制使用 `unsafe` 关键字来封装不安全操作。结果是安全契约和假设变得可见且可审查,而不是由约定隐含。我们计划将新模型和语法(暂定为 C# 16 特性)作为 .NET 11 的预览版发布,并在 .NET 12 中作为正式版本发布。它最初将采用选择加入方式,并可能在后续版本中成为默认选项。我们将像处理可为空引用类型一样更新模板以启用新模型。早期编译器实现已经落地到主分支(https://github.com/dotnet/roslyn/pull/82547),并正在成形。 C# 1.0 引入了 `unsafe` 关键字,作为在类型、方法和内部方法块上建立不安全上下文的方式,让开发者选择最方便的适用范围。不安全上下文允许访问指针特性。标记为 `unsafe` 的方法可以在其签名和实现中使用这些特性,而未标记的方法则不能。我们还公开了一组不安全类型,如 `System.Runtime.CompilerServices.Unsafe`(https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.unsafe)和 `System.Runtime.InteropServices.Marshal`(https://learn.microsoft.com/dotnet/api/system.runtime.interopservices.marshal),它们需要按照约定谨慎使用。此后,`unsafe` 关键字在 Rust 和 Swift 中被重用和修改,这些语言团队为其赋予了更严格、面向传播的语义。C# 16 遵循同样的路径,在 .NET 运行时库中统一应用 `unsafe`(包括在 `Unsafe` 和 `Marshal` 成员上),并且最接近 Rust 的实现。结果是:`unsafe` 不再标记一种语法,而是开始标记一种契约;一种编译器无法验证、需要熟练开发者阅读和维护的契约。 C# 默认已经阻止不安全代码。大多数开发者在启用新模型时不会注意到任何变化,因为他们不启用或不使用不安全 API。当 C# 16 安全模型启用时,默认阻止将覆盖更大的表面积。新模型建立了强力的防护栏,这些防护栏可见、可审查,并由编译器强制执行。它也是强制执行工程和供应链标准的重要工具。内存安全性多年来一直是行业和政府日益优先考虑的事项(https://www.cisa.gov/resources-tools/resources/memory-safe-languages-reducing-vulnerabilities-modern-software-development),而 AI 辅助代码生成则增加了新的维度,因为软件生产的规模超过了人工审查的速度。 ## 安全性 较早的一篇文章讨论了 .NET 中的结构性安全机制: > 安全性是通过语言和运行时的组合来强制执行的……变量要么引用活对象,要么为 null,要么超出作用域。内存默认自动初始化,因此新对象不会使用未初始化的内存。边界检查确保使用无效索引访问元素不会读取未定义的内存——这通常由差一错误引起——而是会抛出 `IndexOutOfRangeException`。 来源:What is .NET, and why should you choose it?(https://devblogs.microsoft.com/dotnet/why-dotnet/#safety) C# 对常规安全代码具有强大的安全执行机制。新模型使开发者和代理能够准确标记不安全代码中的安全边界。编写不安全代码有两个原因:与本机代码互操作,以及在某些情况下的性能优化。Go、Rust 和 Swift 也为此包含了一个不安全方言。语言通常无法帮助你编写不安全代码;它的作用是明确不安全代码的使用位置以及如何过渡回安全代码。 如果我们考虑另一个领域,编程安全性可能更容易理解。道路设计师通过涂上禁止越过对向车流的实黄线或白线来提高安全性。驾驶员理解并遵守这一约定。高速公路上使用护栏,通过结构性分离提供安全性,即使在没有清醒遵守的情况下也能持续发挥作用。高速公路的例子告诉我们,速度越快,风险越高。编程也有自己的事故,涉及内存。每个应用程序都有访问千兆字节虚拟内存的潜力。写入或读取任意内存会导致任意行为(**未定义行为**,简称 **UB**,是行业术语),并且是大多数安全漏洞的原因(https://security.googleblog.com/2024/10/safer-with-google-advancing-memory.html)。在安全代码中无法访问任意内存,但在不安全代码中这是始终存在的可能性。 ## 模型概述 .NET 程序应维护一个核心不变条件:每个内存访问目标都是**活内存**:即已分配、初始化并在访问时可用的内存。安全代码通过构造来维护这一点:编译器规则和运行时检查相结合,使意外访问成为不可能。**不安全代码**是任何可能违反该不变条件的操作,通常通过读取或写入非活内存,或将内存留在后续访问将失败的状态。不安全代码可以读取或写入通过互操作、`NativeMemory` 或开发人员手动管理的任意内存。该不变条件必须同样成立。编译器无法检测其中的 UB,因此验证的负担转移到了开发者身上。 解决此风险的方法是一组分层机制,有意且透明地将不安全通过调用图传播,每一层启用下一层: 1. **内部 `unsafe { }` 块**:每个不安全操作(调用 `unsafe` 成员、解引用指针以及其他 `unsafe` 动作)必须出现在内部 `unsafe { }` 块内。这是基本机制。不安全操作在语法上被标记、限定作用域并可审查。 2. **传播**:将 `unsafe` 添加到外层方法的签名中,将内部块的责任重新发布给其自身调用者,除非已解除。这将调用图划分为安全方法、`unsafe` 方法以及它们之间的边界方法。开发者可以在任意数量的中间层中链式传播,直到有人决定停止。 3. **安全文档**:每个 `unsafe` 成员应携带一个 `/// ` 块:被调用者与调用者之间的正式契约。编写它是一个强烈推荐的最佳实践,分析器可以标记其缺失。 4. **边界的抑制**:包含内部 `unsafe` 块但**未**将自身签名标记为 `unsafe` 的方法是不安全代码与安全代码之间的边界。它通过输入上的运行时防护、静态推理或来自上游 API 的已文档化不变条件(例如,`malloc` 保证返回的指针至少对 `size` 字节有效)来解除被调用者文档化的义务。正确的解除是使安全调用者真正安全的保证。 你必须逐步通过每一层才能获得价值。只做一半工作,你得到的远少于一半价值。正确通过每一层,你就拥有了一条贯穿调用图的连贯推理线,其他人可以审查并可能改进。编写不安全代码是一项特殊技能,需要深刻理解此不变条件和许多陷阱。新模型使不安全代码更容易推理和审查,而不是更容易编写——它强制采用一种正式、可见的结构。关键字和编译器强制并非安全性本身;它们是脚手架,让开发者阐明并尊重安全性。 C# 1.0 将一类“指针特性”归入 `unsafe`:声明和解引用指针类型、获取变量地址、`stackalloc` 到指针、任意类型上的 `sizeof`,以及多年来添加的其他功能,包括抑制某些编译器错误。新模型更具选择性。相对于 C# 1.0 规则的更改包括: - `unsafe` 类型修饰符会产生错误。不安全作用域下移至单个方法、属性和字段,在这些地方其契约可见且更最小化指定。委托也不能是不安全的,因为它们具有类型形状。 - 静态构造函数或终结器上不允许 `unsafe`。它们的调用没有可以包裹在 `unsafe { }` 块中的调用点模式,因此签名标记没有任何可传播的内容。 - `new()` 泛型约束仅匹配安全的无参数构造函数;其无参数构造函数为 `unsafe` 的类型无法满足 `new()`。 - 新的 `safe` 关键字允许开发者在编译器要求显式选择时证明声明是可靠的。目前唯一这样的地方是 `extern` 声明,它们必须标记为 `safe` 或 `unsafe`,包括 `LibraryImport` 分部方法声明。 - 成员上的 `unsafe` 不再建立不安全上下文。现在在不安全调用点处需要内部 `unsafe` 块。 - 签名中的指针类型不再传播不安全性。只有指针**解引用**是不安全的,因此 `byte*` 参数本身不会将其不安全性传播给调用者。对于新代码,避免将 `IntPtr` 用于指针;优先使用类型化指针如 `byte*`,或对于真正不透明的指针使用 `void*`。对于现有的基于 `IntPtr` 的 API,考虑添加指针类型重载,并隐藏或软弃用 `IntPtr` 版本。对于不透明句柄,优先使用 `SafeHandle`。`nint` 和 `IntPtr` 在元数据中无法区分,因此当参数实际上是本机大小的整数时,显式记录这一点。 采用是通过新的选择加入项目级属性。详情请参见 § 项目级选择加入(https://devblogs.microsoft.com/dotnet/improving-csharp-memory-safety/#project-level-opt-in)。 ## 模型实践 不安全代码显著提高了风险,并且总是在某些维度上无界。最好的不安全 API 被设计为尽可能缩小无界性:将能放入签名的部分推入签名,在主体中能解除的部分解除,并留给调用者一个小而定义明确的残留部分自行处理。 `Encoding.GetString(byte*, int)`(https://github.com/dotnet/runtime/blob/e42458bf1f276f49d6e3364458021a5f2d749531/src/libraries/System.Private.CoreLib/src/System/Text/Encoding.cs#L904)是一个很好的例子。 ````csharp public unsafe string GetString(byte* bytes, int byteCount) { ArgumentNullException.ThrowIfNull(bytes); ArgumentOutOfRangeException.ThrowIfNegative(byteCount); return string.CreateStringFromEncoding(bytes, byteCount, this); } ```` 该方法清楚地传达了 API 的期望:`byte*` 参数表明一个原始的非托管缓冲区,而配对的 `byteCount` 则准确说明了 API 将读取多少字节。主体解除了它能够解除的部分:空指针或负长度会引发异常。这些防护排除了部分 `string.CreateStringFromEncoding` 会静默读取任意内存的情况。`GetString` 返回一个新的 `string`,消除了与缓冲区的任何别名或生命周期问题。调用者持有一个单一、狭窄的义务:从 `bytes` 开始的 `byteCount` 字节必须是可读内存。传递大于缓冲区的大小是未定义行为:解码器可能遇到不可读内存并崩溃,或者可能读取恰好位于末尾之后的任意内容并返回一个由任意外来字节构建的字符串。 在现有模型中,签名中的 `byte*` 阻止了此 API 从安全代码中被调用。在新模型下,签名中的指针本身不再意味着不安全性;`GetString` 将被显式注释为 `unsafe`,因此它仍然无法从安全代码调用。“更好的不安全”并非由更危险或更安全来定义,而是由对不安全性的描述程度来定义;锋利的刀能做出最精细的切割,而钝刀只会撕裂。 `Marshal.ReadByte`(https://github.com/dotnet/runtime/blob/e42458bf1f276f49d6e3364458021a5f2d749531/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/Marshal.cs#L287)是一个更需谨慎的例子。 ````csharp public static unsafe byte ReadByte(IntPtr ptr, int ofs) { try { byte* addr = (byte*)ptr + ofs; return *addr; } catch (NullReferenceException) { throw new AccessViolationException(); } } ```` `Marshal.ReadByte` 的调用者传递一个 `IntPtr` 和偏移量,它们共同指向程序允许读取的一个字节。与 `GetString` 相比,谨慎的差异在于 `ReadByte` 不执行任何输入验证,并且在今天的代码中可以从安全代码调用。`try`/`catch` 子句不提供任何安全性,而是用于更改异常类型,仅针对一种错误行为场景。这被认为是可以接受的原因是 `Marshal` 和 `Unsafe` 在惯例上被理解为不安全调用的。我们可以进一步剖析该方法。 今天 `ReadByte` 上的 `unsafe` 签名为实现建立了不安全上下文,但没有创建调用者契约或记录调用者警告。现有模型通过签名中的指针类型传播不安全性,但 `IntPtr` 绕过了这一规则;该 API 实际上是指针走私。新模型弥补了这一差距。它扩大了不安全性以涵盖任何可能违反活内存不变条件的操作(不仅仅是涉及指针类型的操作),并使 `unsafe` 签名标记成为成员契约,使用内部 `unsafe` 块封装不安全操作。它还对齐了 `IntPtr` 和像 `byte*` 这样的指针的安全性特性:两者都可以在 `unsafe` 块之外被持有、赋值和暴露在签名中;指针解引用才是不安全的。 `ReadByte` 在新模型下发生变化,按照以下模拟: ````csharp /// 从非托管内存读取单个字节。 /// /// 和的总和必须指向调用者被允许 /// 读取的一个字节。 /// public static unsafe byte ReadByte(IntPtr ptr, int ofs) { try { byte* addr = (byte*)ptr; unsafe { // 安全性:依赖于调用者的义务。 return addr[ofs]; } } catch (NullReferenceException) { throw new AccessViolationException(); } } ```` 让我们深入实现。转换 `(byte*)ptr` 是指针操作,而不是解引用;`IntPtr` 和 `byte*` 形状相同,表示不同;两者都只是一个数字。不安全性在于一行:`return addr[ofs]`。这是开发者需要证明 `addr + ofs` 指向可读内存的地方,因为索引解引用了该地址。`byte*` → `byte` 需要将内存从指针地址复制到一个值中。这才是危险的操作。新模型之所以有效,是因为指针解引用 `addr[ofs]` 被包裹在 `unsafe` 块中,照亮了不安全性。`unsafe` 签名成为调用者契约,强制调用者也将其调用包裹在 `unsafe` 块中,并提醒调用者查看被调用者的安全文档。严格的“最小 unsafe 块”解读会将 `+ ofs` 算术放在块外,因为算术本身不是解引用。我们更倾向于将 `addr[ofs]` 保持在一起:索引*就是*间接寻址(按规范,`addr[0]` 等同于 `*addr`),并且分组使得在访问点可以看到确切的地址。我们期望这类选择会随着时间的推移在不安全编码指南中被规范化。 违规是编译错误,而非警告。该模型并非“荣誉系统”。以 `Marshal.ReadByte` 为例:它被标记为 `unsafe`,因为其实现在内部解引用了一个由调用者提供的不透明指针。在新模型中,它将继续作为 `unsafe` 成员,但
相似文章
内存安全是生死攸关的问题
作者认为,内存不安全的开源软件极易受到即将到来的人工智能漏洞查找代理的攻击,这使内存安全成为道德义务,并且Rust必须作为领先且零开销的内存安全语言取得成功。
Rust与C/C++在内存安全CVE上的差异
分析Rust与C/C++在内存安全CVE报告方式上的不同,论证即使存在错误,Rust的设计也能降低某些类型漏洞的发生。
面向 Morello 的 Rust:始终在线内存安全,即使在非安全代码中
本文介绍了一种针对 Morello 能力硬件架构修改的 Rust 编译器,通过利用硬件能力,实现即使在非安全代码中也始终在线内存安全。
安全变得简单 第1部分:单一所有权(并非)可选
本文介绍了一种基于线性类型和抽象解释的内存安全新方法,旨在比Rust更符合人机工程学原理地消除诸如释放后使用和内存泄漏等常见错误。
使用unsafe消除Go中的边界检查
本文解释了如何在Go中使用unsafe指针算术来消除编译器无法移除的边界检查,从而提升热点路径的性能。它讨论了边界检查的开销和传统的BCE方法,然后介绍了unsafe方法。