【问题标题】:What happens if you use the 32-bit int 0x80 Linux ABI in 64-bit code?如果在 64 位代码中使用 32 位 int 0x80 Linux ABI 会发生什么?
【发布时间】:2021-12-26 20:45:09
【问题描述】:

Linux 上的int 0x80 始终调用 32 位 ABI,无论它是从什么模式调用的:ebxecx 中的参数和来自/usr/include/asm/unistd_32.h 的系统调用号。 (或者在没有CONFIG_IA32_EMULATION 的情况下编译的 64 位内核崩溃)。

64 位代码应使用syscall,电话号码为/usr/include/asm/unistd_64.h,参数为rdirsi 等。见What are the calling conventions for UNIX & Linux system calls on i386 and x86-64。如果您的问题被标记为与此重复,请参阅该链接以了解有关您应该如何在 32 位或 64 位代码中进行系统调用的详细信息。如果您想了解什么确实发生了,请继续阅读。

(有关 32 位与 64 位 sys_write 的示例,请参阅 Using interrupt 0x80 on 64-bit Linux


syscall 系统调用比int 0x80 系统调用快,因此请使用本机 64 位 syscall,除非您编写的多语言机器代码在以 32 位或 64 位执行时运行相同。 (sysenter 总是以 32 位模式返回,因此它在 64 位用户空间中没有用处,尽管它是有效的 x86-64 指令。)

相关:The Definitive Guide to Linux System Calls (on x86) 了解如何进行 int 0x80sysenter 32 位系统调用,或 syscall 64 位系统调用,或调用 vDSO 以进行“虚拟”系统调用,如 gettimeofday。加上系统调用的背景知识。


使用int 0x80 可以编写在32 位或64 位模式下组装的东西,因此在微基准测试或其他东西的末尾使用exit_group() 很方便。

将函数和系统调用调用约定标准化的官方 i386 和 x86-64 System V psABI 文档的当前 PDF 链接自 https://github.com/hjl-tools/x86-psABI/wiki/X86-psABI

有关初学者指南、x86 手册、官方文档和性能优化指南/资源,请参阅 标签wiki


但是,由于人们不断发布使用 int 0x80 in 64-bit code 的代码的问题,或者不小心从为 32 位编写的源代码中发布了 building 64-bit binaries,我想知道 究竟在当前的 Linux 上会发生什么?

int 0x80 是否保存/恢复所有 64 位寄存器?它会将任何寄存器截断为 32 位吗?如果传递上半部分非零的指针参数会发生什么?

如果你给它传递 32 位指针,它会工作吗?

【问题讨论】:

  • @IwillnotexistIdonotexist:破坏 r8-r11 是最不重要的原因! #1 是:仅支持 32 位参数/返回值。 #2 是:strace 解码错误,所以很难调试。 #3 是:低性能。 #4 是:寄存器 args 与 x86-64 函数调用不匹配,它使用保留调用的 ebx。 (排名因用例/初学者而异,但我认为这些都比清除 r8-r11 更重要。)无论如何,我会考虑一个更好的介绍。

标签: x86 linux assembly x86-64 system-calls abi


【解决方案1】:

TL:DRint 0x80 在正确使用时工作,只要任何指针适合 32 位(堆栈指针不适合)。但请注意,strace 会错误解码,除非您使用的是最新的 strace + 内核。

int 0x80 将 r8-r11 for reasons 归零,并保留其他所有内容。就像在 32 位代码中使用 32 位索书号一样使用它。 (或者更好,不要使用它!)

甚至并非所有系统都支持int 0x80:适用于 Linux 版本 1 (WSL1) 的 Windows 子系统严格只支持 64 位:int 0x80 doesn't work at all。也可以构建 Linux 内核without IA-32 emulation。 (不支持 32 位可执行文件,不支持 32 位系统调用)。请参阅this re:确保您的 WSL 实际上是 WSL2(在 VM 中使用实际的 Linux 内核。)


细节:保存/恢复了什么,内核使用了哪些部分

int 0x80 使用eax(不是完整的rax)作为系统调用号,分派到32 位用户空间int 0x80 使用的同一个函数指针表。 (这些指针指向内核中本机 64 位实现的 sys_whatever 实现或包装器。系统调用实际上是跨用户/内核边界的函数调用。)

仅传递 arg 寄存器的低 32 位。 rbx-rbp 的上半部分被保留,但被 int 0x80 系统调用忽略。 请注意,将错误指针传递给系统调用不会导致 SIGSEGV;相反,系统调用返回-EFAULT。如果您不检查错误返回值(使用调试器或跟踪工具),它会出现静默失败。

所有寄存器(当然除了 eax)都被保存/恢复(包括 RFLAGS 和整数寄存器的高 32 位),除了 r8-r11 被清零r12-r15 在 x86-64 SysV ABI 的函数调用约定中保留调用,因此在 64 位中被 int 0x80 清零的寄存器是 AMD64 添加的“新”寄存器的调用破坏子集。

在内核内部实现寄存器保存方式的一些内部更改中保留了此行为,并且内核中的 cmets 提到它可以从 64 位使用,因此此 ABI 可能是稳定的。 (即,您可以指望 r8-r11 被归零,而其他所有内容都被保留。)

返回值进行符号扩展以填充 64 位 rax(Linux declares 32-bit sys_ functions as returning signed long.) 这意味着指针返回值(如来自 void *mmap())需要在用于 64 位寻址模式之前进行零扩展

sysenter不同,它保留了cs的原始值,因此它以调用它的相同模式返回用户空间。(使用sysenter导致内核将cs设置为@ 987654376@,为 32 位代码段选择描述符。)


旧版 strace 对 64 位进程的 int 0x80 解码不正确。它解码时好像进程使用了​​syscall 而不是int 0x80This 可以是 very confusing。例如straceeax=1 / int $0x80 打印write(0, NULL, 12 <unfinished ... exit status 1>,实际上是_exit(ebx),而不是write(rdi, rsi, rdx)

我不知道添加 PTRACE_GET_SYSCALL_INFO 功能的确切版本,但 Linux 内核 5.5 / strace 5.5 可以处理它。它误导性地说该过程“以 32 位模式运行”,但确实解码正确。 (Example)。


int 0x80 只要所有参数(包括指针)都适合寄存器的低 32 位就可以工作。默认代码模型(“小”)in the x86-64 SysV ABI 中的静态代码和数据就是这种情况。 (第 3.5.1 节 : 已知所有符号都位于0x000000000x7effffff 范围内的虚拟地址中,因此您可以执行mov edi, hello (AT&T mov $hello, %edi) 之类的操作来获取指向一个带有 5 字节指令的寄存器)。

但是不是position-independent executables 的情况,许多 Linux 发行版现在默认配置 gcc(他们enable ASLR 用于可执行文件) .例如,我在 Arch Linux 上编译了一个hello.c,并在 main 的开头设置了一个断点。传递给puts 的字符串常量位于0x555555554724,因此32 位ABI write 系统调用将不起作用。 (GDB 默认禁用 ASLR,因此如果您在 GDB 中运行,每次运行时您总是会看到相同的地址。)

Linux 将堆栈放在the "gap" between the upper and lower ranges of canonical addresses 附近,即堆栈顶部位于 2^48-1。 (或者随机的某个地方,启用了 ASLR)。所以rsp 在典型的静态链接可执行文件中进入_start 类似于0x7fffffffe550,具体取决于环境变量和参数的大小。将此指针截断为esp 并不指向任何有效内存,因此如果您尝试传递截断的堆栈指针,带有指针输入的系统调用通常会返回-EFAULT。 (如果您将rsp 截断为esp,然后对堆栈执行任何操作,例如,如果您将 32 位 asm 源代码构建为 64 位可执行文件,您的程序将会崩溃。)


它在内核中是如何工作的:

在 Linux 源代码中,arch/x86/entry/entry_64_compat.S 定义 ENTRY(entry_INT80_compat)。 32 位和 64 位进程在执行 int 0x80 时使用相同的入口点。

entry_64.S 定义了 64 位内核的本机入口点,其中包括中断/故障处理程序和来自 long mode (aka 64-bit mode) 进程的 syscall 本机系统调用。

entry_64_compat.S 将系统调用入口点从兼容模式定义到 64 位内核中,加上 int 0x80 在 64 位进程中的特殊情况。 (64 位进程中的sysenter 也可能会进入该入口点,但它会推送$__USER32_CS,因此它将始终以 32 位模式返回。)syscall 指令有一个 32 位版本,在 AMD CPU 上受支持,Linux 也支持从 32 位进程进行快速 32 位系统调用。

我猜int 0x80 在 64 位模式下可能的用例是,如果您想使用与 modify_ldt 一起安装的 a custom code-segment descriptorint 0x80 推送段寄存器本身以与iret 一起使用,Linux 总是通过iretint 0x80 系统调用返回。 64 位 syscall 入口点将 pt_regs->cs->ss 设置为常量,__USER_CS__USER_DS。 (SS和DS使用相同的段描述符是正常的。权限差异是通过分页而不是分段来完成的。)

entry_32.S 定义了进入 32 位内核的入口点,完全不涉及。

Linux 4.12's entry_64_compat.S 中的 int 0x80 入口点:

/*
 * 32-bit legacy system call entry.
 *
 * 32-bit x86 Linux system calls traditionally used the INT $0x80
 * instruction.  INT $0x80 lands here.
 *
 * This entry point can be used by 32-bit and 64-bit programs to perform
 * 32-bit system calls.  Instances of INT $0x80 can be found inline in
 * various programs and libraries.  It is also used by the vDSO's
 * __kernel_vsyscall fallback for hardware that doesn't support a faster
 * entry method.  Restarted 32-bit system calls also fall back to INT
 * $0x80 regardless of what instruction was originally used to do the
 * system call.
 *
 * This is considered a slow path.  It is not used by most libc
 * implementations on modern hardware except during process startup.
 ...
 */
 ENTRY(entry_INT80_compat)
 ...  (see the github URL for the full source)

代码将 eax 零扩展为 rax,然后将所有寄存器压入内核堆栈以形成 struct pt_regs。当系统调用返回时,它将从这里恢复。它是保存的用户空间寄存器(对于任何入口点)的标准布局,因此来自其他进程(如 gdb 或 strace)的 ptrace 如果在此进程中使用 ptrace 将读取和/或写入该内存在系统调用中。 (ptrace 寄存器的修改是使其他入口点的返回路径变得复杂的一件事。请参阅 cmets。)

但它推送$0 而不是 r8/r9/r10/r11。 (sysenter 和 AMD syscall32 入口点为 r8-r15 存储零。)

我认为 r8-r11 的这种归零是为了匹配历史行为。在Set up full pt_regs for all compat syscalls 提交之前,入口点只保存了 C 调用破坏的寄存器。它直接从带有call *ia32_sys_call_table(, %rax, 8) 的asm 分派,并且这些函数遵循调用约定,因此它们保留rbxrbprspr12-r15。将r8-r11 归零而不是让它们未定义是to avoid info leaks 从 64 位内核到 32 位用户空间(它可以跳转到 64 位代码段以读取内核留在那里的任何内容)。

当前实现 (Linux 4.12) 从 C 分派 32 位 ABI 系统调用,从 pt_regs 重新加载保存的 ebxecx 等。 (64 位本机系统调用直接从 asm 分派,with only a mov %r10, %rcx 需要考虑函数和 syscall 之间调用约定的微小差异。不幸的是它不能总是使用 sysret,因为 CPU 错误使其不安全非规范地址。它确实会尝试,所以快速路径非常快,尽管syscall 本身仍然需要数十个周期。)

无论如何,在当前的 Linux 中,32 位系统调用(包括来自 64 位的 int 0x80)最终会在do_syscall_32_irqs_on(struct pt_regs *regs) 中结束。它分派到一个函数指针ia32_sys_call_table,带有 6 个零扩展参数。这可能避免在更多情况下需要对 64 位本机系统调用函数进行包装以保留该行为,因此更多的 ia32 表条目可以直接是本机系统调用实现。

Linux 4.12 arch/x86/entry/common.c

if (likely(nr < IA32_NR_syscalls)) {
  /*
   * It's possible that a 32-bit syscall implementation
   * takes a 64-bit parameter but nonetheless assumes that
   * the high bits are zero.  Make sure we zero-extend all
   * of the args.
   */
  regs->ax = ia32_sys_call_table[nr](
      (unsigned int)regs->bx, (unsigned int)regs->cx,
      (unsigned int)regs->dx, (unsigned int)regs->si,
      (unsigned int)regs->di, (unsigned int)regs->bp);
}

syscall_return_slowpath(regs);

在从 asm 分派 32 位系统调用的旧版本 Linux 中(就像 64 位在 4.151 之前仍然如此),int80 入口点本身将 args 放入正确的寄存器中,@987654457 @ 和 xchg 指令,使用 32 位寄存器。它甚至使用mov %edx,%edx 将 EDX 零扩展为 RDX(因为 arg3 在两种约定中碰巧使用相同的寄存器)。 code here。此代码在 sysentersyscall32 入口点中重复。

脚注 1:Linux 4.15(我认为)引入了 Spectre / Meltdown 缓解措施,并对入口点进行了重大改造,使其成为崩溃案例的蹦床。它还清理了传入的寄存器,以避免在调用期间(当某些 Spectre 小工具可能运行时)在寄存器中的用户空间值,通过存储它们,将所有内容归零,然后调用重新加载正确宽度的 C 包装器输入时保存的结构中的 args。

我打算留下这个答案来描述更简单的机制,因为这里概念上有用的部分是系统调用的内核端涉及使用 EAX 或 RAX 作为函数指针表的索引,以及其他传入的寄存器值复制到调用约定希望 args 去的地方。即syscall 只是一种调用内核的方法,调用它的调度代码。


简单示例/测试程序:

我写了一个简单的 Hello World(在 NASM 语法中),它将所有寄存器的上半部分设置为非零,然后使用 int 0x80 进行两个 write() 系统调用,其中一个带有指向 .rodata 中的字符串的指针(成功),第二个带有指向堆栈的指针(失败,-EFAULT)。

然后它使用本机 64 位 syscall ABI 到 write() 堆栈中的字符(64 位指针),然后再次退出。

所以所有这些示例都正确使用了 ABI,除了第二个 int 0x80 尝试传递 64 位指针并将其截断。

如果您将其构建为与位置无关的可执行文件,第一个也会失败。 (您必须使用相对于 RIP 的 lea 而不是 mov 才能将 hello: 的地址放入寄存器中。)

我使用了 gdb,但使用您喜欢的任何调试器。使用一个突出显示自上一个单步以来更改的寄存器。 gdbgui 适用于调试 asm 源代码,但不适用于反汇编。尽管如此,它确实有一个寄存器窗格,至少可以很好地用于整数寄存器,并且在这个示例中效果很好。

查看内联 ;;; cmets,描述系统调用如何更改寄存器

global _start
_start:
    mov  rax, 0x123456789abcdef
    mov  rbx, rax
    mov  rcx, rax
    mov  rdx, rax
    mov  rsi, rax
    mov  rdi, rax
    mov  rbp, rax
    mov  r8, rax
    mov  r9, rax
    mov  r10, rax
    mov  r11, rax
    mov  r12, rax
    mov  r13, rax
    mov  r14, rax
    mov  r15, rax

    ;; 32-bit ABI
    mov  rax, 0xffffffff00000004          ; high garbage + __NR_write (unistd_32.h)
    mov  rbx, 0xffffffff00000001          ; high garbage + fd=1
    mov  rcx, 0xffffffff00000000 + .hello
    mov  rdx, 0xffffffff00000000 + .hellolen
    ;std
after_setup:       ; set a breakpoint here
    int  0x80                   ; write(1, hello, hellolen);   32-bit ABI
    ;; succeeds, writing to stdout
;;; changes to registers:   r8-r11 = 0.  rax=14 = return value

    ; ebx still = 1 = STDOUT_FILENO
    push 'bye' + (0xa<<(3*8))
    mov  rcx, rsp               ; rcx = 64-bit pointer that won't work if truncated
    mov  edx, 4
    mov  eax, 4                 ; __NR_write (unistd_32.h)
    int  0x80                   ; write(ebx=1, ecx=truncated pointer,  edx=4);  32-bit
    ;; fails, nothing printed
;;; changes to registers: rax=-14 = -EFAULT  (from /usr/include/asm-generic/errno-base.h)

    mov  r10, rax               ; save return value as exit status
    mov  r8, r15
    mov  r9, r15
    mov  r11, r15               ; make these regs non-zero again

    ;; 64-bit ABI
    mov  eax, 1                 ; __NR_write (unistd_64.h)
    mov  edi, 1
    mov  rsi, rsp
    mov  edx, 4
    syscall                     ; write(edi=1, rsi='bye\n' on the stack,  rdx=4);  64-bit
    ;; succeeds: writes to stdout and returns 4 in rax
;;; changes to registers: rax=4 = length return value
;;; rcx = 0x400112 = RIP.   r11 = 0x302 = eflags with an extra bit set.
;;; (This is not a coincidence, it's how sysret works.  But don't depend on it, since iret could leave something else)

    mov  edi, r10d
    ;xor  edi,edi
    mov  eax, 60                ; __NR_exit (unistd_64.h)
    syscall                     ; _exit(edi = first int 0x80 result);  64-bit
    ;; succeeds, exit status = low byte of first int 0x80 result = 14

section .rodata
_start.hello:    db "Hello World!", 0xa, 0
_start.hellolen  equ   $ - _start.hello

Build it 转换为 64 位静态二进制文件

yasm -felf64 -Worphan-labels -gdwarf2 abi32-from-64.asm
ld -o abi32-from-64 abi32-from-64.o

运行gdb ./abi32-from-64。在gdb 中,运行set disassembly-flavor intellayout reg,如果您的~/.gdbinit 中还没有它。 (GAS .intel_syntax 类似于 MASM,而不是 NASM,但它们足够接近,如果您喜欢 NASM 语法,则很容易阅读。)

(gdb)  set disassembly-flavor intel
(gdb)  layout reg
(gdb)  b  after_setup
(gdb)  r
(gdb)  si                     # step instruction
    press return to repeat the last command, keep stepping

当 gdb 的 TUI 模式混乱时按 control-L。这很容易发生,即使程序本身不打印到标准输出。

【讨论】:

  • @MatteoItalia:它受到this question 的启发,在得到以 32 位模式构建的答案后,它要求解释在 64 位模式下发生了什么。 (错误的系统调用号和错误的strace 解码导致非常混乱的结果)。无论如何,这让我很好奇,让我想写一个规范的答案。
  • @EOF:是的,疯狂且危险(对于您的流程,而不是系统)。你已经可以在没有内核帮助的情况下做到这一点,使用内核段选择器的已知值 (stackoverflow.com/questions/34467092/…) 远 jmp。 AFAIK 它没有用,或者在 Linux 下不受支持,但你可以做到。甚至还有一个 modify_ldt 系统调用可以让您以“标准”方式进行操作:stackoverflow.com/a/13355668/224132
  • r8-r11 的归零我不相信在未打补丁的旧内核上得到保证。似乎记得这是数据泄露漏洞的一部分,当您有一个 32 位程序执行 int 0x80 然后切换到 64 位代码并获得对这些寄存器以前值的访问权限时。我认为他们的修复是在返回之前将它们归零。您在答案中提到了它,但我相信如果您搜索,您会发现一个与之相关的实际漏洞。
  • 啊我终于找到了security issue。有一篇文章here。还有一些demonstrated它的代码。
  • @ameed: -38-ENOSYSman7.org/linux/man-pages/man2/getuid.2.html 解释说,从 Linux 2.4 开始,您应该使用 getuid32。显然内核对旧的getuid 电话号码的支持已经被删除,因为我在我的 Arch Linux 桌面上得到了同样的东西。您将在 32 位代码中看到相同的 int 0x80 系统调用,您可以在其中 strace 它并看到 glibc 使用 getuid32 和您的代码使用 getuid。 PS,你可以像普通人一样使用"=a"(ret)。是的,您确实需要在r8..r11 上使用clobbers,但在任何低8 regs 中都不需要(使用eax 作为输出操作数)
猜你喜欢
相关资源
最近更新 更多