如果在 64 位代码中使用 32 位 int 0x80 Linux ABI,会发生什么情况?

What happens if you use the 32-bit int 0x80 Linux ABI in 64-bit code?

提问人:Peter Cordes 提问时间:9/7/2017 最后编辑:Peter Cordes 更新时间:11/22/2021 访问量:16226

问:

int 0x80在 Linux 上始终调用 32 位 ABI,无论它从什么模式调用:args in , , ...和 syscall 号码来自 。(或者在没有 的 64 位内核上崩溃)。ebxecx/usr/include/asm/unistd_32.hCONFIG_IA32_EMULATION

64 位代码应使用 syscall,其中的调用号来自 ,参数位于 、 等。请参阅 i386 和 x86-64 上 UNIX 和 Linux 系统调用的调用约定是什么。如果您的问题被标记为重复,请参阅该链接,了解有关如何在 32 位或 64 位代码中进行系统调用的详细信息。如果您想了解到底发生了什么,请继续阅读。/usr/include/asm/unistd_64.hrdirsi

(有关 32 位与 64 位的示例,请参阅在 64 位 Linux 上使用中断0x80sys_write)


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

相关:Linux 系统调用权威指南(在 x86 上),了解如何进行 32 位系统调用或 64 位系统调用,或为“虚拟”系统调用调用 vDSO,例如 .加上系统调用的背景知识。int 0x80sysentersyscallgettimeofday


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

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

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


但是,由于人们不断发布使用 64 位代码中使用 int 0x80 的代码的问题,或者意外地从为 32 位编写的源代码构建 64 位二进制文件,我想知道当前的 Linux 上到底发生了什么?

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

如果您向它传递 32 位指针,它会起作用吗?

x86 Linux 程序集 x86-64 系统调用 abi

评论

1赞 Peter Cordes 9/8/2017
@IwillnotexistIdonotexist:喋喋不休的r8-r11是最不重要的原因!#1 是:仅支持 32 位参数/返回值。#2 是:Strace 解码错误,因此很难调试。#3 是:性能低下。#4 是:寄存器参数与 x86-64 函数调用不匹配,它使用 哪个是调用保留的。(排名因用例/初学者与否而异,但我认为这些都比清除 r8-r11 更重要。无论如何,我会考虑一个更好的介绍。ebx
0赞 Karl Knechtel 6/1/2022
我不使用 x86 汇编,但我将这个问题作为如何编写人工规范的有趣示例添加为书签。
0赞 Peter Cordes 6/1/2022
@KarlKnechtel:谢谢。这个写起来很棘手,因为有很多不同的信息要传授,而且要剥开多层洋葱。(还有相当多的活动部件,比如调试工具,比如与之交互,以及内核支持)。最后,我让问题本身从实际做什么的实用建议开始,并将问题表述为“为什么不”/“如果会发生什么”。这可能适用于类似的主题。strace

答:

79赞 Peter Cordes 9/7/2017 #1

TL:DR:只要任何指针适合 32 位(堆栈指针不适合),正确使用即可工作。但要注意,除非你有最近的 strace + 内核,否则 strace 解码错误int 0x80

int 0x80出于某种原因将 R8-R11 归零,并保留其他所有内容。与在 32 位代码中完全相同地使用它,并使用 32 位呼叫号。(或者更好的是,不要使用它!

并非所有系统都支持:适用于 Linux 版本 1 的 Windows 子系统 (WSL1) 严格来说只有 64 位:int 0x80 根本不起作用。也可以在没有 IA-32 仿真的情况下构建 Linux 内核。(不支持 32 位可执行文件,不支持 32 位系统调用)。请参阅回复:确保 WSL 实际上是 WSL2(在 VM 中使用实际的 Linux 内核)。int 0x80


详细信息:保存/恢复的内容,内核使用哪些部分的注册表

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

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

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

这种行为在内核内部实现寄存器保存方式的一些内部更改中得以保留,内核中的注释提到它可以从 64 位使用,因此此 ABI 可能是稳定的。(也就是说,您可以指望 r8-r11 归零,而其他所有内容都保留。

返回值是符号扩展的,以填充 64 位。(Linux 声明 32 位sys_函数返回有符号长整型。这意味着指针返回值(如 from)在 64 位寻址模式下使用之前需要为零扩展raxvoid *mmap()

与 不同,它保留了 的原始值,因此它以与调用它相同的模式返回到用户空间。(使用会导致内核设置为 ,这将为 32 位代码段选择描述符。sysentercssysentercs$__USER32_CS


对于 64 位进程,较旧的 straceint 0x80 进行不正确的解码。它解码,就好像进程使用了 .这可能非常令人困惑。例如: 打印 / ,实际上是 ,而不是 。syscallint 0x80stracewrite(0, NULL, 12 <unfinished ... exit status 1>eax=1int $0x80_exit(ebx)write(rdi, rsi, rdx)

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


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

但对于与位置无关的可执行文件来说,情况并非如此,许多 Linux 发行版现在默认配置 gcc(并且它们为可执行文件启用 ASLR)。例如,我在 Arch Linux 上编译了一个,并在 main 的开头设置了一个断点。传递到的字符串常量位于 ,因此 32 位 ABI 系统调用将不起作用。(默认情况下,GDB 禁用 ASLR,因此,如果您从 GDB 中运行,则始终会在运行中看到相同的地址。hello.cputs0x555555554724write

Linux 将堆栈放在规范地址的上限和下限范围之间的“间隙”附近,即堆栈的顶部为 2^48-1。(或随机的某个地方,启用了 ASLR)。因此,在典型的静态链接可执行文件中,输入类似于 ,具体取决于 env vars 和 args 的大小。截断此指针不会指向任何有效的内存,因此,如果尝试传递截断的堆栈指针,则通常将返回带有指针输入的系统调用。(如果你截断到堆栈,然后对堆栈执行任何操作,你的程序就会崩溃,例如,如果你将 32 位 ASM 源代码构建为 64 位可执行文件。rsp_start0x7fffffffe550esp-EFAULTrspesp


它在内核中的工作原理:

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

entry_64.Sis 定义了 64 位内核的本机入口点,其中包括中断/故障处理程序和来自长模式(又称 64 位模式)进程的本机系统调用。syscall

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

我想在 64 位模式下的一个可能用例是,如果您想使用与 . pushes 段将自身注册为与 IRET 一起使用,Linux 始终通过 从系统调用返回。64 位入口点设置 和 to 常量,以及 。(SS 和 DS 使用相同的段描述符是正常的。权限差异是通过分页完成的,而不是分段。int 0x80modify_ldtint 0x80int 0x80iretsyscallpt_regs->cs->ss__USER_CS__USER_DS

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

Linux 4.12 entry_64_compat的入口点。小号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)

代码 zero-extend eax 到 rax,然后将所有寄存器推送到内核堆栈上以形成结构pt_regs。这是系统调用返回时它将从中恢复的位置。它采用标准布局,用于保存用户空间寄存器(对于任何入口点),因此,如果其他进程(如 gdb 或 )在系统调用中使用该内存,则它们将读取和/或写入该内存。( 寄存器的修改是使其他入口点的返回路径变得复杂的一回事。请参阅注释。ptracestraceptraceptrace

但它推动而不是 r8/r9/r10/r11。(和 AMD 入口点存储 r8-r15 的零。$0sysentersyscall32

我认为 r8-r11 的归零是为了匹配历史行为。在为所有兼容系统调用设置完整pt_regs提交之前,入口点仅保存了 C 调用混乱的寄存器。它使用 直接从 asm 调度,这些函数遵循调用约定,因此它们保留 、 、 和 。归零而不是不定义它们是为了避免从 64 位内核到 32 位用户空间的信息泄漏(这可能会远 jmp 到 64 位代码段来读取内核留下的任何内容)。call *ia32_sys_call_table(, %rax, 8)rbxrbprspr12-r15r8-r11

当前的实现 (Linux 4.12) 从 C 调度 32 位 ABI 系统调用,从 重新加载保存的 、 等。(64 位本机系统调用直接从 ASM 调度,只需一个 mov %r10、%rcx 即可解决函数和 .不幸的是,它不能总是使用 ,因为 CPU 错误使它对非规范地址不安全。它确实尝试过,所以快速路径非常快,尽管它本身仍然需要数十个周期。ebxecxpt_regssyscallsysretsyscall

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

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);

在旧版本的 Linux 中,从 asm 调度 32 位系统调用(就像 4.151 之前的 64 位一样),int80 入口点本身使用 32 位寄存器将 args 放在正确的寄存器中,并带有指令。它甚至用于将 EDX 零扩展到 RDX(因为 arg3 恰好在两种约定中使用相同的寄存器)。代码在这里。此代码在 和 入口点中重复。movxchgmov %edx,%edxsysentersyscall32

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

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


简单示例/测试程序:

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

然后,它使用本机 64 位 ABI 从堆栈(64 位指针)中获取字符,并再次退出。syscallwrite()

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

如果将其构建为与位置无关的可执行文件,则第一个可执行文件也会失败。(您必须使用 RIP 相对值,而不是将 的地址获取到寄存器中。leamovhello:

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

请参阅描述系统调用如何更改寄存器的内联 ;;; 注释

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

将其构建为 64 位静态二进制文件,其中包含

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

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

(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。这很容易发生,即使程序本身没有打印到 stdout。

评论

2赞 Peter Cordes 9/7/2017
@MatteoItalia:它的灵感来自这个问题,在得到以 32 位模式构建的答案后,它要求解释 64 位模式下发生的事情。(错误的系统呼叫号码和错误的解码导致了非常混乱的结果)。无论如何,这让我很好奇,让我想写一个规范的答案。strace
2赞 Peter Cordes 9/7/2017
@EOF:是的,疯狂而危险(针对您的流程,而不是系统)。你已经可以在没有内核帮助的情况下做到这一点,使用内核段选择器的已知值 (stackoverflow.com/questions/34467092/...)。AFAIK 它没有用,也没有在 Linux 下受支持,但你可以做到。甚至还有一个系统调用,可以让你以“标准”的方式做到这一点:stackoverflow.com/a/13355668/224132jmpmodify_ldt
4赞 Michael Petch 11/5/2017
我不认为在旧的未打补丁的内核上可以保证归零。似乎还记得,这是数据泄漏漏洞的一部分,当时您有一个 32 位程序执行一个,然后切换到 64 位代码并访问这些寄存器的先前值。我认为他们的解决方法是在返回之前将它们归零。你在答案中提到了它,但我相信如果你搜索,你会发现一个与之相关的实际漏洞。r8-r11int 0x80
3赞 Michael Petch 11/5/2017
啊,我终于找到了安全问题这里有一篇文章.还有一些代码演示了它。
2赞 Peter Cordes 11/24/2018
@ameed:是.man7.org/linux/man-pages/man2/getuid.2.html 解释说,从 Linux 2.4 开始,您应该使用 .显然,内核对旧呼叫号的支持已被删除,因为我在我的 Arch Linux 桌面上得到了同样的东西。您将在 32 位代码中看到相同的系统调用,您可以看到 glibc 的使用和您的代码使用 。PS,你可以像正常人一样使用。是的,您确实需要 clobbers ,但不需要在任何一个低 8 个 regs 上(以 EAX 作为输出操作数)-38-ENOSYSgetuid32getuidint 0x80stracegetuid32getuid"=a"(ret)r8..r11