编译器目标如何影响 Unsafe Rust 的行为

本文通过一个缓冲区溢出的例子,探讨了在 Rust 中使用 unsafe 代码可能导致的未定义行为。文章展示了相同的代码在不同的编译器目标和架构下,由于内存布局和填充规则的差异,可能产生不同的结果,强调了在 unsafe Rust 中进行内存操作时,需要充分理解目标环境的特性,并进行严格的审计和测试。

这篇博文的灵感来源于 Daniel CummingRektoff 的一节课。具体来说,我们将深入研究该课程中的一个缓冲区溢出示例,并扩展我们对不同编译器目标如何影响 Rust 行为的检查。

Rust 以其编译时检查而闻名,这些检查在很大程度上消除了常见的内存错误。但是,unsafe 关键字提供了一个逃生出口,允许开发者执行不受 Rust 常规安全保证的底层操作。不安全块允许你在不安全的 Rust 中执行以下 五个操作,而这些操作在安全的 Rust 中不能执行:

  • 解引用原始指针
  • 调用不安全的函数或方法
  • 访问或修改可变静态变量
  • 实现不安全的 trait
  • 访问 union 的字段

这种能力伴随着重大的责任:当使用 unsafe 时,开发者承认编译器无法保证内部操作的内存安全,并且他们有责任确保他们的代码不会导致未定义的行为,如经典的 “缓冲区溢出” 所例证的那样。

缓冲区溢出和未定义行为

当程序尝试将数据写入固定大小内存缓冲区的已分配边界之外时,会发生缓冲区溢出。这可能导致覆盖相邻的内存位置,从而可能破坏其他数据,甚至破坏程序指令。其后果通常是不可预测的,从程序崩溃到难以追踪的细微错误,甚至可以执行恶意代码。

考虑这个简化的示例,演示了堆栈上的缓冲区溢出:

复制

图 1:示例 Rust 程序,有意溢出一个 5 字节的堆栈缓冲区,以说明未定义的行为(写入 6 字节)。下图逐步说明了写入操作可能破坏的内容。

在这段代码中,声明了一个 5 字节的 buffer 和一个 i32 变量 not_in_buffer。for 循环尝试将 6 个字节(从 0 到 5)写入 5 字节的 buffer

首先,让我们获取有关主机架构的更多信息:

复制

图 2:用于显示当前目标(机器/架构)的主机架构输出。这会影响 ABI、对齐和字节序。

我们位于运行在 moby/buildkit Docker 镜像 上的 64 位 Linux 系统上。由于此特定目标上的内存对齐和填充,not_in_buffer 变量可能不会紧邻堆栈上的 buffer。这意味着溢出可能会写入填充字节或其他看似“空”的空间,从而导致程序即使在发生溢出的情况下也似乎可以正常运行。not_in_buffer 的打印值可能保持不变。

目前,这是循环执行之前堆栈的样子:

+———————————————————————————————————+————————+————————+————————+————————+
| 1 byte | 1 byte | 1 byte | 1 byte | 1 byte |--------|--------|--------|
+———————————————————————————————————+————————+————————+————————+————————+
|              4 bytes              |--------|--------|--------|--------|
+———————————————————————————————————+————————+————————+————————+————————+

图 3:循环运行前堆栈布局的可视化表示 — 一个 5 字节的缓冲区,后跟 not_in_buffer(4 个字节),并在此 64 位目标上用填充分隔。

让我们使用 rustc 编译并运行该程序,并检查结果:

复制

图 4:程序输出显示,在此布局上,初始溢出没有明显改变 not_in_buffer(值保持为 56789)。

正如预期的那样,由于内存布局,我们没有在结果中看到任何溢出。但是,这并不意味着没有发生溢出。现在的内存布局如下所示:

+———————————————————————————————————+————————+————————+————————+————————+
| 1 byte | 1 byte | 1 byte | 1 byte | 1 byte | 1 byte |--------|--------|
+———————————————————————————————————+————————+————————+————————+————————+
|              4 bytes              |--------|--------|--------|--------|
+———————————————————————————————————+————————+————————+————————+————————+

图 5:第一次溢出写入(6 个字节)后的内存布局;第六个字节落在 64 位目标上的填充区域中。

但是,如果循环的上界增加(例如,增加到 9 或更高),则影响将变得可见。然后,溢出可以到达并破坏 not_in_buffer 变量,以不可预测的方式更改其值。让我们写入 9 个字节,即传递 buffer 的 4 个字节:

复制

图 6:Diff 显示循环边界从 0..6 增加到 0..9,因此写入到达相邻内存,最终将覆盖 not_in_buffer

如果我们再次编译并运行该程序,我们将得到:

复制

图 7:增加循环边界后的程序输出;not_in_buffer 已损坏,现在打印 56584。

我们的值 not_in_buffer 从 56789 更改为 56584。这里发生了什么?

循环 i 距缓冲区起点的内存偏移量 操作 写入的值(十六进制) not_in_buffer 的内存内容
[ D5, DD, 00, 00 ]
0–4 +0 到 +4 写入 buffer 0x000x04 [ D5, DD, 00, 00 ]
5–7 +5 到 +7 写入填充 0x050x07 [ D5, DD, 00, 00 ]
8 +8 溢出到 not_in_buffer 0x08 [ 08, DD, 00, 00 ]

图 8:表格总结了每次写入的位置(buffer、填充,然后是 not_in_buffer),用于图7中的示例。

换句话说,我们的内存现在看起来像这样:

+=======================================================================================+
|                        BUFFER + PADDING (8 Bytes Total)                               |
+----------+----------+----------+----------+----------+----------+----------+----------+
|                buffer[0]...buffer[4]                 |   <-- PADDING (3 bytes) -->    |
+----------+----------+----------+----------+----------+----------+----------+----------+
|   0x00   |   0x01   |   0x02   |   0x03   |   0x04   |   0x05   |   0x06   |   0x07   |
+----------+----------+----------+----------+----------+----------+----------+----------+
                                        |                                               |
                                        V                                               |
+=======================================+===============================================+
|                             not_in_buffer (4 bytes)                                   |
+---------------------------------------+-----------------------------------------------+
|  not_in_buffer[0]  | not_in_buffer[1] |   not_in_buffer[2]    |   not_in_buffer[3]    |
+--------------------+------------------+-----------------------+-----------------------+
|     0x08  <------  |     0xDD         |          0x00         |        0x00           |
+--------------------+------------------+-----------------------+-----------------------+

逐步说明:

  1. 初始状态:not_in_buffer 以小端存储为字节序列 [D5, DD, 00, 00]。解释为 32 位小端整数,则为 0x0000DDD5 = 十进制 56789。
  2. 循环迭代 0–4 将字节 0x000x04 写入五个 buffer 插槽。这些写入保留在缓冲区内,不会触及 not_in_buffer
  3. 迭代 5–7 写入 buffernot_in_buffer 之间的填充区域。由于平台的对齐/填充,前几个溢出的字节会落在填充中,因此 not_in_buffer 仍然未被触及。
  4. 迭代 8 将单个字节 0x08 写入 not_in_buffer 的第一个字节,因为此架构将最低有效字节存储在最低地址。低位字节从 0xD5 更改为 0x08,而其他三个字节保持 [DD, 00, 00]

字节级更改(十六进制):

  • 之前:[D5, DD, 00, 00]0x0000DDD5 = 56789
  • 之后:[08, DD, 00, 00]0x0000DD08 = 56584

注意:在大端系统上,在 buffer 之后写入的第一个字节将是整数的最高有效字节,从而产生非常不同的数值结果。

为什么这被认为是未定义的?

在这一点上,有人可能会争辩说,这不是未定义的行为,而是确定性的不需要的行为。他们是对的。让我们在不同的目标上再次尝试这个例子。

为此,我们将使用 i386/debian:bullseye Docker 镜像。我们将再次执行上述步骤。

同样,我们将获得内核的架构:

复制

图 9:与之前相同的版本和提交哈希,但主机架构不同!

我们再次运行 Linux 发行版,但是这次是在 32 位架构上。暂停并思考会发生什么?现在的内存布局是什么样的?

一个好的猜测是:

+————————+————————+————————+————————+
| 1 byte | 1 byte | 1 byte | 1 byte |
+————————+————————+————————+————————+
| 1 byte |--------|--------|--------| <--- expected padding
+————————+————————+————————+————————+
|           not_in_buffer           |
+————————+————————+————————+————————+

图 10:在具有填充的 32 位目标上运行循环之前的预期堆栈布局。

如果我们再次编译并运行该程序,将缓冲区溢出 1 个字节,我们预计不会发生任何事情。但是,这是我们得到的结果:

复制

图 11:在 6 字节写入后 32 位目标的程序输出,显示 not_in_buffer 已损坏,现在打印 56581。

为什么我们现在溢出了?这是因为我们编译的目标要求内存尽可能紧凑,从而优化了内存大小的使用而不是性能。

+—————————————+—————————————+—————————————+—————————————+
|    1 byte   |    1 byte   |    1 byte   |    1 byte   |
+—————————————+—————————————+—————————————+—————————————+
|    1 byte   |     first 3 bytes of not_in_buffer      | <--- actually no padding
+—————————————+—————————————+—————————————+—————————————+
| last byte of not_in_buffer|-------------|-------------|
+—————————————+—————————————+—————————————+—————————————+

图 12:在 32 位架构上,5 字节的 buffernot_in_buffer 变量之间没有填充的堆栈布局的可视化。

循环写入 6 个字节 (0..5)。因为没有填充,所以第 6 次写入 (i = 5) 会立即覆盖 not_in_buffer 的第一个(最低有效)字节。

循环 i 距缓冲区起点的堆栈偏移量 操作 写入的值(十六进制) not_in_buffer 的堆栈内容
*[ D5, DD, 00, 00 ]*
0–4 +0 到 +4 写入 buffer 0x000x04 *[ D5, DD, 00, 00 ]*
5 +5 溢出到 not_in_buffer 0x05 *[ 05, DD, 00, 00 ]*

图 13:32 位、无填充示例(6 字节写入)的表格,显示立即覆盖 not_in_buffer 的 LSB 的写入。

逐步说明(32 位,无填充):

  1. 初始状态:not_in_buffer 是小端的 [D5, DD, 00, 00] (0x0000DDD5 = 十进制 56789)。
  2. 循环迭代 0–4 将 0x00..0x04 写入五个缓冲区插槽 — 仍然在 buffer 内。
  3. 第 6 次写入立即落在 not_in_buffer 的第一个字节上,因为没有填充:它将 0x05 写入最低有效字节。

字节级更改(十六进制):

  • 之前:[D5, DD, 00, 00]0x0000DDD5 = 56789
  • 之后:[05, DD, 00, 00]0x0000DD05 = 56581

这个具体的例子证明了为什么即使在没有填充的架构上,unsafe 块中的单个被覆盖的字节也会静默地破坏程序状态。

在 Rust 中,未定义的行为并不意味着在加密意义上是随机的,而是表示当违反某些规则时,对程序将做什么没有任何保证。当调用未定义的行为时,语言规范不提供对编译器行为或结果的任何要求或约束。实际结果取决于特定的编译器、操作系统和硬件架构。正如我们所看到的,在同一台机器上多次运行同一个程序可能会产生一致的结果,从而导致一种虚假的安全感,但在不同的环境中部署它可能会暴露隐藏的错误。

结论

在本文中,我们演示了 unsafe Rust 中未定义行为的一个示例,以及它并非总是立即可见,并且可能取决于目标架构和编译器设置。

虽然 Rust 强大的类型系统和所有权规则通常可以防止“安全”Rust 中出现未定义的行为,但理解 unsafe 对于底层互操作性和性能优化至关重要。

在 OpenZeppelin,理解不安全的 Rust 和未定义的行为对于审计区块链基础设施,包括客户端实现、编译器和 SDK 至关重要。大量高性能的区块链组件都使用了不安全的 Rust,因此底层漏洞是一个重要的关注领域。例如,区块链客户端中的缓冲区溢出可能会引发网络不稳定或导致共识失败。这加强了谨慎使用 unsafe 的重要性,并对其进行彻底的理解和严格的审计和测试,确保对内存布局和行为的任何假设在所有目标环境中都成立。

准备好保护你的代码了吗?

申请审计 →

  • 原文链接: openzeppelin.com/news/ho...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
OpenZeppelin
OpenZeppelin
江湖只有他的大名,没有他的介绍。