Zig 的内存安全性如何?

发布于 2021-03-19 更新于 2022-09-21

我经常看到一些讨论,将 Zig 的内存安全级别等同于 C(或者偶尔等同于 Rust!)。这两种说法都不太准确。本文试图对此进行更详细的分析。

本文仅限于内存安全。有关更全面的比较,请参见 Assorted thoughts on zig and rust

我主要关注的是安全性。在实践中,似乎任何级别的测试都不足以防止大型程序中因内存安全而产生的漏洞。因此,我不会介绍诸如 AddressSanitizer 之类的用于测试的工具,并且不建议 在生产环境中使用。相反,我将侧重于可以系统地排除错误的工具(例如,编译器插入的边界检查完全防止了堆的越界读取/写入)。

我还专注于通常发布的应用软件,忽略诸如 tcc 这样的边界检查编译器,或者诸如 hardened_malloc 这样的隔离分配器,因为性能开销,它们很少被使用。

最后,请注意标题下方的“更新”日期。特别是 Zig 仍在快速开发中,并且变化速度可能比本文更新速度更快。(有关安全机制,请参见 tracking issue)。

我认为主要有两大类安全机制:

临时运行时检查。这些检查出现在所有 Zig 和 Rust 代码库中,但在惯用的 C 代码中非常罕见。许多此类检查在现代 C++ 代码库中也很常见,但受到向后兼容接口的阻碍。这些检查易于实现,并且可能具有足够少的争议性,任何新的系统语言都将具有类似的功能。示例包括:

可组合的编译时证明。 这些是 Rust 独有的,并且是新颖的、难以实现的,并且会给语言增加大量的复杂性。“可组合”部分是关键。“Unsafe”代码允许向系统添加新的公理,并且编译器验证这些公理是否以有效方式组合。所有 Rust 代码都由少量此类公理的组合构建而成。这就是为什么可以用极少的 unsafe 代码和高水平的内存安全性用 Rust 编写复杂的系统成为可能的原因,而后验全局静态分析仅限于更加严格的编码风格。这也是为什么我不期望看到 Zig 的事后静态分析工具达到与 Rust 相同的安全性和灵活性级别的原因 - 必须在设计库 API 时考虑到证明系统。

Zig 还有一些优于 C 的改进,这些改进不属于上述任何一类:

总而言之:Zig 消除了 C 中一些最严重的潜在危险,具有更好的默认设置,使一些好的实践更符合人体工程学,并且受益于标准库中的全新开始(例如,到处使用切片)。但是,它几乎没有达到 Rust 所能实现的系统性预防内存不安全的水平。在 Zig 中违反内存安全仍然很简单。以下是我经常遇到的一些类别:

// use after free
var hello = try allocator.dupe(u8, "hello world");
allocator.free(hello);
std.debug.print("{s}\n", .{hello});
// use after realloc / iterator invalidation
const init_queue = [5]usize{ 0, 1, 2, 3, 4 };
var queue = try std.ArrayList(usize).initCapacity(allocator, init_queue.len);
try queue.appendSlice(&init_queue);
for (queue.items) |*item| {
  item.* += 1;
try queue.append(item.*);
}
std.debug.print("{any}\n", .{queue.items});
// invalidating an interior pointer
const Value = union(enum) {
  string: []const u8,
  number: usize,
};
var value = Value{ .number = 42 };
const number = &value.number;
value = Value{ .string = "hello world" };
number.* -= 42;
std.debug.print("{s}\n", .{value.string});

我看到一些声称(不是来自 Zig 团队)Zig 具有“完整的空间内存安全性”。我怀疑这是基于误读了本文的早期版本,在早期版本中,我使用“空间安全性”和“时间安全性”作为上述两组的名称。我不知道“完整的空间内存安全性”的任何正式定义,但是任何合理的定义肯定会被上面的内部指针示例违反。

这些缓解措施的差异实际上如何转化为错误数量?

materialize 中,我们在最初的 14 个月内编写了约 14 万行 Rust 代码,同时将团队从约 3 人扩展到约 20 人。这是一个复杂的系统,对吞吐量和延迟都有很高的要求。我们达到这一点时,只有(如果我没记错的话)9 个 unsafe 代码块,所有这些代码块都位于单个模块中,并且存在是为了解决等效安全 API 中的性能错误。尽管进行了大量的生成测试和模糊测试,但我们只发现了一个内存安全错误(自然是在 unsafe 模块中),该错误易于调试和修复。

相比之下,在几个小得多、简单得多的 Zig 代码库中,我作为唯一的开发人员,每周都会遇到多个内存安全错误。这不是一个完美的比较,因为我在 Zig 中的一次性研究项目编写得不仔细(=> 添加了更多错误),但也没有经过彻底的测试(=> 检测到的错误更少)。但这确实让我怀疑我是否有能力在没有大量额外缓解措施的情况下发布安全的 Zig 程序。

在至少一个代码库中,内存安全错误的数量是边界检查 panic 的 1/20。因此,我认为如果我用惯用的 C 编写相同的项目(即没有边界检查),那么我每周会遇到至少 20 倍的内存安全错误。

在本文的旧版本中,我试图使用 CVE 报告来估计如果使用 Zig 而不是 C 或 C++ 可以防止多少 CVE。这涉及太多的猜测而没有提供任何信息,因此我已将其删除。

我主要从事查询语言、数据库引擎、流处理系统 等方面的工作。延迟、内存使用和内存访问模式至关重要。直到最近,几乎所有这些系统都是用 C、C++ 或 Java 编写的。

在 Java 中,典型的策略是让数据平面在手动打包的堆外缓冲区(例如 arrow)上运行,以便 GC 只需要遍历控制平面中较小的堆。这确实有效,但很痛苦,并且性能上限通常低于 C++(例如,参见 redpanda vs kafkascylladb vs cassandra、[java vs c++ implementations of aeron](https://www.scattered-thoughts.net/writing/how-safe-is-zig/https:/www.youtube.com/watch?v=Pz-4co8IaI8))。

另一方面,似乎不可能保护 C 或 C++ 的安全。即使是像 heavily tested 这样的代码库,例如 SQLite,也容易受到 code execution from untrusted sql 的攻击。对于隐藏在受信任的后端服务器后面的传统数据库部署而言,这并不是一个致命的问题(只要后端阻止了 SQL 注入攻击)。但是,对于后端即服务公司、多租户云数据库,甚至像 Android/iOS 这样的操作系统来说,这是一个大问题,在这些操作系统中,应用程序不受信任,但仍然需要访问共享数据库。

在这种情况下,Rust 非常有吸引力。性能上限与 C++ 相似,安全所需的工作量与 Java 相似,所有这些都包含在一个全新的设计中,该设计有机会避免两者的最大错误。仍然存在一些主要的痛点:allocator api 仍然不稳定,并且很少有库使用它,这意味着例如 arena/slab 分配需要重写库,自引用对象仍然非常有限,pinning 容易出错,没有等效于 placement new 的功能 等等。但是,它不像在 Java 中手动打包字节并试图推理 JIT 和 GC 的性能,或者试图将 C++ 程序暴露于不受信任的输入那么痛苦。

尽管如此,我不认为数据系统的未来是 Rust,而仅仅是 Rust。

首先,托管语言不断提高性能上限:

垃圾收集器在不断改进,例如 azul zing consistently trounces hotspot in latency benchmarks

从历史上看,垃圾回收语言一直是面向指针的,并且对内存布局的控制很少,因为 a) 为具有简单布局的语言编写垃圾收集器更容易,并且 b) 几十年前,当大多数这些语言被设计出来时,缓存未命中的相对成本要低得多。但是这种情况正在改变。C# 具有 value types,并且 Java 正在 working on them。Julia 的值类型和参数化结构类型的组合提供了更多的控制 - 允许编写例如内联存储数据的 B 树。较新的语言也更容易为手动字节打包提供零成本抽象(例如 blobs.jl)。

还有一些关于非垃圾回收托管语言的有希望的实验。例如,valroc 都具有确定性的内存管理和 C/Rust 级别的内存布局控制,同时仍然保留了垃圾回收语言的简单体验。我认为我们很可能会在未来十年内看到至少一种可用于生产的此类语言。

其次,在某些情况下,Rust 的内存安全性优势并不那么大:

在内存安全错误更难利用的环境中,对语言级别的内存安全保证的压力较小。例如,TigerBeetle 是一个单租户数据库,仅使用易于解析的二进制协议通过专用网络与受信任的客户端进行通信,并且不会动态分配内存。内存安全错误仍然是错误,因此需要防止它们,但是它们不太可能发生,并且很难看出如何利用它们。用 Rust 而不是 Zig 编写 TigerBeetle 可能会使某些错误更容易捕获,但也会使其他领域更容易出错,例如,编译时配置必须替换为临时代码生成。

Zig 也可能在已经沙盒化的不受信任的插件中占有一席之地。例如,在无服务器 HTTP 处理程序中,每个请求都是一个新的 wasm 沙箱,在这种情况下,启用了运行时检查并且主要依赖于 arena 分配(甚至静态预分配)的 Zig 程序可能相当安全。Zig 能够生成非常小的 wasm 二进制文件,快速启动并保持低内存使用率,这似乎很有吸引力。我对使用 comptime 配置来积极地专门化例如 HTML 模板和数据库查询也感兴趣。(我在 Julia 中的 experiments in julia 很有希望,但是 Julia 运行时不太适合 wasm。我在 Rust 中的 experiments in rust 是一次 Turing-tarpit 的难题会议。但是 Zig 实际上是为此量身定制的。)

更大的 Zig 程序可以通过由多个 wasm 沙箱构建程序来保护(例如,参见 rlbox、[wasmboxc](https://www.scattered-thoughts.net/writing/how-safe-is-zig/https:/kripken.github.io/blog/wasm/2020/07/27/wasmboxc.html)。这尚未经过测试 - 我们不知道我们将获得多少安全性与多少性能开销 - 但我希望看到无论如何都会对此进行探索,以加强遗留代码并防止供应链攻击,然后也许我们可以将结果外推到 Zig。硬件辅助的缓解措施也是如此,例如 cheri

从长远来看,如果 Rust(和其他内存安全语言)可以在任何地方使用,而 Zig 只能在某些情况下使用,那么即使在这些情况下,网络效应也会使 Rust 具有巨大的优势。因此,我怀疑 Zig 的未来,至少在我的领域中,取决于廉价运行时缓解措施的成功开发。

我希望这不会导致 PL 研究人员和语言设计师忽略 Zig。comptime 机制大大简化了语言并实现了新型的抽象,我才刚刚开始探索各种库进行积极编译时专业化的可能性。即使缺乏内存安全性使工业采用更加困难,我也希望看到其他语言探索这种机制并将其进一步推进。我们可以将它与类似于 Julia 的动态类型系统结合起来吗(在 comptime 执行的 Zig 版本是动态类型化的并且是垃圾回收的)?我们可以删除两阶段限制并具有用于运行时专业化的内置函数吗?我们可以在一种语言中混合内存安全和不安全的子集吗(如 terra 但使用单一语言)?甚至可以将不安全的子集限制为在 wasm 沙箱内运行吗?这里有太多的潜力。

jamie@scattered-thoughts.net 通过 atom 或获得更新 我现在可以提供 consulting 服务。 在 GitHub Sponsors 上Support 我的工作。