【问题标题】:Is garbage allowed in high bits of parameter and return value registers in x86-64 SysV ABI?x86-64 SysV ABI 中的参数和返回值寄存器的高位是否允许垃圾?
【发布时间】:2017-03-21 10:18:28
【问题描述】:

x86-64 SysV ABI 指定函数参数如何在寄存器中传递(rdi 中的第一个参数,然后是rsi 等等),以及整数返回值如何传回(@ 987654331@ 然后rdx 用于非常大的值)。

但是,当传递小于 64 位的类型时,我找不到参数或返回值寄存器的高位应该是什么。

例如,对于以下函数:

void foo(unsigned x, unsigned y);

...x 将在 rdiy 中传递给 rsi,但它们只有 32 位。 rdirsi 的高 32 位是否需要为零?直觉上,我会假设是的,但是所有 gcc、clang 和 icc 的 code generated 在开始时都有特定的 mov 指令将高位清零,所以编译器似乎假设不是这样。

同样,如果返回值小于 64 位,编译器似乎假设返回值rax 的高位可能有垃圾位。例如下面代码中的循环:

unsigned gives32();
unsigned short gives16();

long sum32_64() {
  long total = 0;
  for (int i=1000; i--; ) {
    total += gives32();
  }
  return total;
}

long sum16_64() {
  long total = 0;
  for (int i=1000; i--; ) {
    total += gives16();
  }
  return total;
}

...compileclang 中的以下内容(其他编译器类似):

sum32_64():
...
.LBB0_1:                               
    call    gives32()
    mov     eax, eax
    add     rbx, rax
    inc     ebp
    jne     .LBB0_1


sum16_64():
...
.LBB1_1:
    call    gives16()
    movzx   eax, ax
    add     rbx, rax
    inc     ebp
    jne     .LBB1_1

注意调用返回 32 位后的 mov eax, eax 和 16 位调用后的 movzx eax, ax - 两者都分别具有将前 32 位或 48 位清零的效果。所以这种行为是有代价的——处理 64 位返回值的相同循环省略了这条指令。

我已经非常仔细地阅读了x86-64 System V ABI document,但我找不到标准中是否记录了这种行为。

这样的决定有什么好处?在我看来有明确的成本:

参数成本

在处理参数值时,被调用者的实现会产生成本。并在处理参数时的函数中。当然,这个成本通常为零,因为该函数可以有效地忽略高位,或者归零是免费的,因为可以使用 32 位操作数大小的指令来隐式地将高位归零。

但是,在函数接受 32 位参数并进行一些可以从 64 位数学中受益的数学运算的情况下,成本通常是非常实际的。以this function 为例:

uint32_t average(uint32_t a, uint32_t b) {
  return ((uint64_t)a + b) >> 2;
}

直接使用 64 位数学计算一个函数,否则必须仔细处理溢出问题(以这种方式转换许多 32 位函数的能力是 64 位架构的一个经常被忽视的好处)。这编译为:

average(unsigned int, unsigned int):
        mov     edi, edi
        mov     eax, esi
        add     rax, rdi
        shr     rax, 2
        ret  

仅需要 4 条指令中的 2 条(忽略 ret)将高位清零。这在实践中使用 mov 消除可能很便宜,但仍然需要付出很大的代价。

另一方面,如果 ABI 指定高位为零,我真的看不到调用者的类似相应成本。因为rdirsi 和其他参数传递寄存器是scratch(即可以被调用者覆盖),你只有几个场景(我们看rdi,但是替换它与您选择的参数 reg 一起使用):

  1. rdi 中传递给函数的值在调用后代码中已失效(不需要)。在这种情况下,最后分配给rdi 的任何指令只需分配给edi。这不仅是免费的,如果您避免使用 REX 前缀,它通常会小一个字节。

  2. rdi 中传递给函数的值 需要在函数之后。在这种情况下,由于rdi 是调用者保存的,调用者无论如何都需要对被调用者保存的寄存器执行mov 的值。您通常可以组织它,以便值 starts 在被调用方保存的寄存器中(比如rbx),然后像mov edi, ebx 一样移动到edi,所以它没有任何成本。

我看不到很多归零会花费调用者很多钱的情况。一些例子是如果在分配rdi 的最后一条指令中需要 64 位数学。不过,这似乎很少见。

回报价值成本

这里的决定似乎更加中立。让被调用者清除垃圾有一个明确的代码(您有时会看到执行此操作的mov eax, eax 指令),但如果允许垃圾,则成本会转移到被调用者身上。总体而言,调用者似乎更有可能免费清除垃圾,因此允许垃圾似乎总体上不会损害性能。

我想这种行为的一个有趣用例是具有不同大小的函数可以共享相同的实现。例如,以下所有函数:

short sums(short x, short y) {
  return x + y;
}

int sumi(int x, int y) {
  return x + y;
}

long suml(long x, long y) {
  return x + y;
}

实际上可以共享相同的实现1

sum:
        lea     rax, [rdi+rsi]
        ret

1对于被占用地址的函数,这种折叠是否真的允许非常多open to debate

【问题讨论】:

  • 在 i386 和 SYSV ABI(即使是最新版本)中似乎都没有为 INTEGER 类类型指定。在 GCC 邮件列表上也对此进行了讨论 gcc.gnu.org/bugzilla/show_bug.cgi?id=46942
  • 另一件值得注意的可能是原始的 i386 ABI 这是指定的 函数将所有整数值参数作为单词传递,扩展或填充有符号或无符号字节和半字为需要。 .在该 ABI 中使用了这些定义在本规范中,术语半字指的是 16 位对象,术语字指的是 32 位对象,术语双字指的是 64 位对象。我> 。有问题的 ABI(大约 1997 年)可以在这里找到:sco.com/developers/devspecs/abi386-4.pdf
  • gcc 问题的绝佳链接。如果您愿意,请随时将其总结为答案,或者我会在某个时候。 i386 文档的另一个链接很有趣,因为 gcc 链接的当前“未写入 ABI”行为似乎遵循最多 32 位的约定(即 8 位或 16 位值 零/由 调用者 扩展的符号),但是 64 位约定中高 32 位的约定是不同的(8、16 或 32 位值是 不是调用者扩展的零/符号)。
  • stackoverflow.com/questions/36706721/… 的可能重复项。 TL:DR: 是的,正如 Michael Matz(ABI 维护者之一)今年所证实的那样,args 可能包含大量垃圾,包括持有标量浮点数的 XMM 寄存器。 (所以不要在标量函数的 args 上粗心地使用打包操作来引发虚假的 FP 异常。)对于整数,窄函数 args 被调用者符号或零扩展为 32 位。 (这是 clang 所依赖的未记录行为。)只有 args,而不是返回值。
  • 我不是当前设计的忠实拥护者,尤其是对于小功能,但它很健壮。 (当然,小函数应该内联...)有趣的事实:IIRC,mov same, same 在 Intel CPU 上总是需要一个执行单元,但是可以消除两个不同架构寄存器之间的 MOV(IvB 和更高版本)。

标签: linux x86 x86-64 calling-convention


【解决方案1】:

您似乎有两个问题:

  1. 返回值的高位是否需要在返回前清零? (在调用之前是否需要将高位参数归零?)
  2. 与此决定相关的成本/收益是什么?

第一个问题的答案是不,有可能在高位是垃圾,Peter Cordes 已经写了一个very nice answer 关于这个主题。

至于第二个问题,我怀疑不定义高位总体上会更好地提高性能。一方面,在使用 32 位操作时,预先零扩展值不会产生额外成本。但另一方面,事先将高位归零并不总是必要的。如果您允许高位出现垃圾,那么您可以将其留给接收值的代码,以便仅在实际需要时执行零扩展(或符号扩展)。

但我想强调另一个考虑因素:安全

信息泄露

当结果的高位未清除时,它们可能会保留其他信息的片段,例如堆栈/堆中的函数指针或地址。如果曾经存在一种机制来执行更高权限的函数并在之后检索rax(或eax)的完整值,那么这可能会导致信息泄漏。例如,系统调用可能会从内核泄漏指向用户空间的指针,从而导致内核ASLR 失败。或者IPC 机制可能会泄漏有关另一个进程的地址空间的信息,这可能有助于开发sandbox 突破。

当然,有人可能会争辩说,防止信息泄露不是 ABI 的责任;由程序员正确实现他们的代码。虽然我同意,但强制编译器将高位归零仍然可以消除这种特殊形式的信息泄漏。

你不应该相信你的意见

另一方面,更重要的是,编译器不应盲目相信任何接收到的值的高位都被清零,否则函数可能无法按预期运行,这也可能导致可利用的条件。例如,考虑以下情况:

unsigned char buf[256];
...
__fastcall void write_index(unsigned char index, unsigned char value) {
    buf[index] = value;
}

如果允许我们假设index 的高位被清零,那么我们可以将上面的代码编译为:

write_index:  ;; sil = index, dil = value
      ; movzx esi, sil       ; skipped based on assumptions
    mov [buf + rsi], dil
    ret

但如果我们可以从我们自己的代码中调用此函数,我们可以提供一个超出[0,255] 范围的值rsi 并写入超出缓冲区边界的内存。

当然,编译器实际上不会生成这样的代码,因为如上所述,被调用者有责任对其参数进行零或符号扩展,而不是调用者。我认为,这是一个非常实际的理由,让接收值的代码始终假定高位存在垃圾并显式删除它。

(对于英特尔 IvyBridge 和更高版本(mov-elimination),编译器希望零扩展到 不同 寄存器,以至少避免延迟,如果不是前端吞吐量成本, movzx 指令。)

【讨论】:

  • 信息泄漏只能跨某种特权边界调用,不能用于进程内的函数调用。调用一个库函数可以让它完全控制你的进程,所以如果它不值得信赖,你就完蛋了。不过,您关于通过让函数做出防御性假设来减少攻击面的论点是好的。 (gcc 的行为是这样,但 clang 不会。例如,它会发出 mov [buf+rsi], dil / ret。(如果要将地址单独放入 reg 中,它将使用 RIP 相对 LEA。静态非-PIC 地址在默认代码模型中处于低 2GB 中)。
  • 我假设 Linux(内核)小心地进行零扩展以在系统调用返回时填充 RAX,大概即使在奇怪的情况下,例如使用 64- 的 32 位 int 0x80 ABI位过程。在进程启动时,它会将除 RSP 之外的所有 regs 归零,即使 ABI 说它们此时持有垃圾。 (动态链接的进程在_start 的开头确实会在regs 中获得垃圾,因为动态链接器首先在进程的上下文中运行并且没有信息泄漏,所以它只是遵循ABI 并且不会打扰归零regs .)
  • 实际上clang只假设零/符号扩展为32位,而不是64位,就像我在其他答案中所说的那样。它可能会使用 mov eax, esi 进行零扩展,而不是使用地址大小前缀(这对于已知位于低 31 位的静态 buf 来说是安全的:mov [buf+esi], dil
  • @PeterCordes - 对,这当然是我的观点 - ABI 和合同通常是双向的:调用者应该遵循规则,而被调用者应该遵循规则。此外,被调用者 经常尽可能地验证调用者是否遵循了规则(例如检查缓冲区大小)。在这种奇怪的情况下,即使您愿意也无法检查这些值。在clang 示例中,您可以拥有一个值> 255 的char,根据语言规范这是不可能的。当然,调用者没有遵循(有争议的)合同,但我希望能够检查无效值。
  • ... 所以我得出的结论是,至少在 clang 上,对于安全敏感的函数,最好使用 32 位或更大的参数,否则你可能会暴露自己的微妙漏洞垃圾位高。
猜你喜欢
  • 1970-01-01
  • 2011-11-04
  • 2019-02-12
  • 2023-04-07
  • 1970-01-01
  • 1970-01-01
  • 2017-07-13
  • 1970-01-01
  • 2011-02-23
相关资源
最近更新 更多