【问题标题】:Can't call C standard library function on 64-bit Linux from assembly (yasm) code无法从汇编 (yasm) 代码调用 64 位 Linux 上的 C 标准库函数
【发布时间】:2024-01-01 11:14:01
【问题描述】:

我有一个函数foo 用汇编语言编写,并在 64 位 Linux (Ubuntu) 上使用 yasm 和 GCC 编译。它只是使用puts() 将消息打印到标准输出,如下所示:

bits 64

extern puts
global foo

section .data

message:
  db 'foo() called', 0

section .text

foo:
  push rbp
  mov rbp, rsp
  lea rdi, [rel message]
  call puts
  pop rbp
  ret

由 GCC 编译的 C 程序调用:

extern void foo();

int main() {
    foo();
    return 0;
}

构建命令:

yasm -f elf64 foo_64_unix.asm
gcc -c foo_main.c -o foo_main.o
gcc foo_64_unix.o foo_main.o -o foo
./foo

问题来了:

运行程序时,它会打印一条错误消息,并在调用 puts 期间立即出现段错误:

./foo: Symbol `puts' causes overflow in R_X86_64_PC32 relocation
Segmentation fault

使用 objdump 反汇编后,我看到调用是用错误的地址进行的:

0000000000000660 <foo>:
 660:   90                      nop
 661:   55                      push   %rbp
 662:   48 89 e5                mov    %rsp,%rbp
 665:   48 8d 3d a4 09 20 00    lea    0x2009a4(%rip),%rdi
 66c:   e8 00 00 00 00          callq  671 <foo+0x11>      <-- here
 671:   5d                      pop    %rbp
 672:   c3                      retq

(671是下一条指令的地址,不是puts的地址)

但是,如果我在 C 中重写相同的代码,调用的方式会有所不同:

645:   e8 c6 fe ff ff          callq  510 <puts@plt>

即它引用了 PLT 中的puts

是否可以告诉 yasm 生成类似的代码?

【问题讨论】:

    标签: linux assembly x86-64 nasm yasm


    【解决方案1】:

    0xe8 操作码后跟一个带符号的偏移量,用于计算分支目标的 PC(此时已前进到下一条指令)。因此objdump 将分支目标解释为0x671

    YASM 正在呈现零,因为它可能在该偏移量上进行了重定位,这就是它要求加载程序在加载期间为puts 填充正确偏移量的方式。加载程序在计算重定位时遇到溢出,这可能表明 puts 与您的调用的偏移量比 32 位有符号偏移量更远。因此,加载程序无法修复此指令,并且您会崩溃。

    66c: e8 00 00 00 00 显示未填充的地址。如果您查看重定位表,您应该会在 0x66d 上看到重定位。汇编器使用重定位作为全零填充地址/偏移量的情况并不少见。

    This page 表明 YASM 有一个WRT 指令,可以控制.got.plt 等的使用。

    根据the NASM documentation 上的 S9.2.5,您似乎可以使用 CALL puts WRT ..plt(假设 YASM 具有相同的语法)。

    【讨论】:

      【解决方案2】:

      TL:DR:3 个选项:

      • 构建非 PIE 可执行文件 (gcc -no-pie -fno-pie call-lib.c libcall.o),以便在您编写 call puts 时,链接器会透明地为您生成 PLT 条目。
      • call puts wrt ..plt 就像 gcc -fPIE 一样。
      • call [rel puts wrt ..got] 就像gcc -fno-plt 一样。

      后两者将在 PIE 可执行文件或共享库中工作。第三种方式,wrt ..got,效率略高。


      默认情况下,您的 gcc 正在构建 PIE 可执行文件 (32-bit absolute addresses no longer allowed in x86-64 Linux?)。

      我不知道为什么,但是这样做时链接器不会自动将call puts 解析为call puts@plt。仍然有一个puts PLT 条目生成,但call 没有去那里。

      在运行时,动态链接器尝试将puts 直接解析为该名称的libc 符号并修复call rel32。但是符号距离超过 +-2^31,所以我们会收到关于 R_X86_64_PC32 重定位溢出的警告。目标地址的低 32 位是正确的,但高位不正确。 (因此您的 call 会跳转到错误的地址)。


      如果我使用 gcc -no-pie -fno-pie call-lib.c libcall.o 构建,您的代码对我有用。 -no-pie 是关键部分:它是链接器选项。您的 YASM 命令不必更改。

      在制作传统的位置相关可执行文件时,链接器会将调用目标的 puts 符号为您转换为 puts@plt,因为我们正在链接动态可执行文件(而不是静态链接 libc 与 gcc -static -fno-pie,在这种情况下,call 可以直接转到 libc 函数。)

      无论如何,这就是为什么 gcc 在使用 -fpie(桌面上的默认值,但不是 https://godbolt.org/ 上的默认值)编译时发出 call puts@plt(GAS 语法),但在使用 @987654354 编译时只是 call puts @。


      有关 PLT 的更多信息,请参阅 What does @plt mean here?,以及几年前的 Sorry state of dynamic libraries on Linux。 (现代的gcc -fno-plt 就像那篇博文中的想法之一。)


      顺便说一句,更准确/更具体的原型可以让 gcc 在调用 foo 之前避免将 EAX 归零:

      extern void foo(); 在 C 中的意思是 extern void foo(...);
      您可以将其声明为extern void foo(void);,这就是() 在C++ 中的含义。 C++ 不允许函数声明未指定 args。


      asm 改进

      您也可以将message 放入section .rodata(只读数据,作为文本段的一部分链接)。

      您不需要堆栈帧,只需在调用前将堆栈对齐 16 即可。一个虚拟的push rax 会做到这一点。

      或者我们可以通过 跳转puts 进行尾调用,而不是调用它,堆栈位置与进入此函数时相同。无论有没有 PIE,这都适用。只需将call 替换为jmp,只要RSP 指向您自己的返回地址即可。

      如果你想制作 PIE 可执行文件(或共享库),你有两个选择

      • call puts wrt ..plt - 通过 PLT 显式调用。
      • call [rel puts wrt ..got] - 通过 GOT 条目显式地进行间接调用,例如 gcc 的 -fno-plt 代码生成样式。 (使用 RIP 相对寻址模式来访问 GOT,因此使用了 rel 关键字)。

      WRT = 尊重。 NASM 手册documents wrt ..plt,另见section 7.9.3: special symbols and WRT

      通常您会在文件顶部使用default rel,因此您实际上可以使用call [puts wrt ..got] 并且仍然获得相对于RIP 的寻址模式。您不能在 PIE 或 PIC 代码中使用 32 位绝对寻址模式。

      call [puts wrt ..got] 使用动态链接存储在 GOT 中的函数指针汇编为内存间接调用。 (早期绑定,而不是惰性动态链接。)

      NASM 文档..got 用于获取section 9.2.3 中变量的地址。 (其他)库中的函数是相同的:您从 GOT 获取指针而不是直接调用,因为偏移量不是链接时间常量,可能不适合 32 位。

      YASM 也接受 call [puts wrt ..GOTPCREL],类似于 AT&T 语法 call *puts@GOTPCREL(%rip),但 NASM 不接受。

      ; don't use BITS 64.  You *want* an error if you try to assemble this into a 32-bit .o
      
      default rel          ; RIP-relative addressing instead of 32-bit absolute by default; makes the [rel ...] optional
      
      section .rodata            ; .rodata is best for constants, not .data
      message:
        db 'foo() called', 0
      
      section .text
      
      global foo
      foo:
          sub    rsp, 8                ; align the stack by 16
      
          ; PIE with PLT
          lea    rdi, [rel message]      ; needed for PIE
          call   puts WRT ..plt          ; tailcall puts
      ;or
          ; PIE with -fno-plt style code, skips the PLT indirection
          lea   rdi, [rel message]
          call  [rel  puts wrt ..got]
      ;or
          ; non-PIE
          mov    edi, message           ; more efficient, but only works in non-PIE / non-PIC
          call   puts                   ; linker will rewrite it into call puts@plt
      
          add   rsp,8                   ; restore the stack, undoing the add
          ret
      

      在位置-依赖 Linux 可执行文件中,您可以使用mov edi, message 而不是相对于 RIP 的 LEA。它的代码量更小,可以在大多数 CPU 上的更多执行端口上运行。 (有趣的事实:MacOS 总是将“图像库”放在低 4GiB 之外,因此无法进行这种优化。)

      在非 PIE 可执行文件中,您也可以使用 call putsjmp puts 并让链接器对其进行排序,除非您想要更有效的非 plt 样式动态链接。但是,如果您确实选择静态链接 libc,我认为这是您直接 jmp 到 libc 函数的唯一方法。

      (我认为非 PIE 的静态链接的可能性是 为什么 ld 愿意为非 PIE 自动生成 PLT 存根,而不是为 PIE 或共享库。它需要你在链接 ELF 共享对象时说出你的意思。)

      如果您确实在 PIE (call rel32) 中使用了 call puts,那么只有在您将与位置无关的 puts 实现静态链接到您的 PIE 时它才能工作,所以整个事情就是一个可执行文件在运行时在随机地址加载(通过通常的动态链接器机制),但根本不依赖于libc.so.6


      当目标在静态链接时存在时,链接器“放松”调用

      GAS call *bar@GOTPCREL(%rip) 使用 R_X86_64_GOTPCRELX(可放松)
      NASM call [rel bar wrt ..got] 使用 R_X86_64_GOTPCREL(不可放松)

      这对于手写 asm 来说问题不大;当您知道符号将出现在您要链接的另一个.o(而不是.so)中时,您可以只使用call bar。但是 C 编译器不知道库函数和您使用原型声明的其他用户函数之间的区别(除非您使用诸如 gcc -fvisibility=hidden https://gcc.gnu.org/wiki/Visibility 或属性/编译指示之类的东西)。

      不过,如果您静态链接库,您可能希望编写链接器可以优化的 asm 源代码,但 AFAIK 无法使用 NASM 来做到这一点。您可以使用global bar:function hidden 将符号导出为隐藏(在静态链接时可见,但对于最终 .so 中的动态链接不可见),但这是在定义函数的源文件中,而不是在访问它的文件中。


      global bar
      bar:
          mov eax,231
          syscall
      
          call bar wrt ..plt
          call [rel bar wrt ..got]
      extern bar
      

      第二个文件,在用nasm -felf64 组装并用objdump -drwc -Mintel 反汇编后查看重定位:

      0000000000000000 <.text>:
         0:   e8 00 00 00 00          call   0x5      1: R_X86_64_PLT32       bar-0x4
         5:   ff 15 00 00 00 00       call   QWORD PTR [rip+0x0]        # 0xb 7: R_X86_64_GOTPCREL    bar-0x4
      

      ld (GNU Binutils) 2.35.1 链接后 - ld bar.o bar2.o -o bar

      0000000000401000 <_start>:
        401000:       e8 0b 00 00 00          call   401010 <bar>
        401005:       ff 15 ed 1f 00 00       call   QWORD PTR [rip+0x1fed]        # 402ff8 <.got>
        40100b:       0f 1f 44 00 00          nop    DWORD PTR [rax+rax*1+0x0]
      
      0000000000401010 <bar>:
        401010:       b8 e7 00 00 00          mov    eax,0xe7
        401015:       0f 05                   syscall 
      

      请注意,PLT 表单已放松为直接call bar,PLT 已消除。但是ff 15 调用 [rel mem] 没有放松为e8 rel32

      使用 GAS:

      _start:
              call    bar@plt
              call    *bar@GOTPCREL(%rip)
      

      gcc -c foo.s &amp;&amp; disas foo.o

      0000000000000000 <_start>:
         0:   e8 00 00 00 00          call   5 <_start+0x5>   1: R_X86_64_PLT32       bar-0x4
         5:   ff 15 00 00 00 00       call   QWORD PTR [rip+0x0]        # b <_start+0xb>      7: R_X86_64_GOTPCRELX   bar-0x4
      

      注意 R_X86_64_GOTPCRELX 末尾的 X。
      ld bar2.o foo.o -o bar &amp;&amp; disas bar:

      0000000000401000 <bar>:
        401000:       b8 e7 00 00 00          mov    eax,0xe7
        401005:       0f 05                   syscall 
      
      0000000000401007 <_start>:
        401007:       e8 f4 ff ff ff          call   401000 <bar>
        40100c:       67 e8 ee ff ff ff       addr32 call 401000 <bar>
      

      这两个电话都放松了直接e8call rel32直接到目标地址。间接调用中的额外字节用67 地址大小前缀填充(对call rel32 没有影响),将指令填充到相同的长度。 (因为重新组装和重新计算函数内的所有相关分支,对齐等已经太迟了。)

      如果您将 libc 与 gcc -static 静态链接,call *puts@GOTPCREL(%rip) 会发生这种情况。

      【讨论】: