int 0x80
始终调用32位ABI,无论从什么模式调用它:ebx
中的args,ecx
...和/usr/include/asm/unistd_32.h
中的syscall编号。 (否则,在未使用CONFIG_IA32_EMULATION
编译的64位内核上会崩溃)。64位代码应使用
syscall
,其调用编号为/usr/include/asm/unistd_64.h
,而args为rdi
,rsi
等。请参阅调用约定在i386和x86-64上进行UNIX和Linux系统调用。如果您的问题被标记为与此重复,请参阅该链接以获取有关如何以32位或64位代码进行系统调用的详细信息。如果您想了解到底发生了什么,请继续阅读。(有关32位和64位
sys_write
的示例,请参阅在64位Linux上使用中断0x80)syscall
系统调用比int 0x80
系统调用快,因此,请使用本机64位syscall
,除非您编写的多语言机器码在32或64位执行时运行相同。 (sysenter
始终以32位模式返回,因此尽管它是有效的x86-64指令,但它在64位用户空间中没有用。)相关:Linux系统调用权威指南x86),以了解如何进行
int 0x80
或sysenter
32位系统调用,或syscall
64位系统调用,或调用vDSO进行gettimeofday
之类的“虚拟”系统调用。加上有关系统调用的全部内容的背景信息。使用
int 0x80
可以编写可以在32或64位模式下汇编的内容,因此最后使用exit_group()
十分方便微型基准之类的东西。有关标准化功能和syscall调用约定的i386和x86-64 System V正式psABI官方文档的最新PDF链接,网址为https://github.com/hjl-tools/ x86-psABI / wiki / X86-psABI。
有关初学者指南,x86手册,官方文档以及性能优化指南/资源,请参见x86标签Wiki。
但是,由于人们一直在用在64位代码中使用
int 0x80
的代码发布问题,或者意外地从为32位编写的源构建64位二进制文件,所以我想知道在当前的Linux上究竟会发生什么?int 0x80
是否保存/恢复所有64位寄存器?是否将任何寄存器截断为32位?如果传递的上半部分非零的指针args会发生什么?如果传递32位的指针会起作用吗?
#1 楼
TL:DR:只要正确使用32位(堆栈指针不适合)的指针,int 0x80
即可正常工作。但是请注意,除非您使用的是最新的strace +内核,否则strace
会将其解码为错误。int 0x80
将r8-r11归零,并保留其他所有内容。就像在32位代码中使用32位电话号码一样使用它。 (或者更好,不要使用它!)并非所有系统甚至都支持
int 0x80
:Linux的Windows子系统(WSL)严格仅是64位的:int 0x80
根本不起作用。也可以在没有IA-32仿真的情况下构建Linux内核。 (不支持32位可执行文件,不支持32位系统调用。)详细信息:保存/还原了什么,内核使用了哪些部分进行管理
int 0x80
使用eax
(而不是完整的rax
)作为系统调用号,并分派到32位用户空间int 0x80
使用的同一功能指针表中。 (这些指针指向内核内部本机64位实现的sys_whatever
实现或包装。系统调用实际上是跨越用户/内核边界的函数调用。)仅arg寄存器的低32位通过了。
rbx
-rbp
的上半部分被保留,但被int 0x80
系统调用忽略。请注意,将错误的指针传递给系统调用不会导致SIGSEGV。而是系统调用返回-EFAULT
。如果您不检查错误返回值(使用调试器或跟踪工具),则它似乎会以静默方式失败。所有寄存器(当然是eax除外)都被保存/恢复(包括RFLAGS和整数regs的高32位),除了r8-r11被清零。
r12-r15
在x86-64 SysV ABI的函数调用约定中保留了调用,因此在64位中被int 0x80
清零的寄存器是AMD64添加的“新”寄存器的调用子集。通过对内核内部实现寄存器保存的方式进行一些内部更改,保留了此行为,并且内核中的注释提到它可以在64位上使用,因此此ABI可能是稳定的。 (即,您可以指望r8-r11被清零,并且所有其他内容都将保留。)
返回值被符号扩展以填充64位
rax
。 (Linux将32位sys_函数声明为返回带符号的long
。)这意味着,指针返回值(例如void *mmap()
的值)需要在64位寻址模式下使用之前进行零扩展。与
sysenter
不同,它保留了cs
的原始值,因此以与调用它时相同的方式返回到用户空间。(使用sysenter
会导致内核将cs
设置为$__USER32_CS
,从而为32位代码段选择一个描述符。) 较旧的
strace
对于64位进程错误地解码了int 0x80
。它进行解码,就好像该进程已使用syscall
而不是int 0x80
一样。这可能非常令人困惑。例如strace
打印write(0, NULL, 12 <unfinished ... exit status 1>
/ eax=1
的int _exit(ebx)
x80
,实际上是write(rdi, rsi, rdx)
,而不是PTRACE_GET_SYSCALL_INFO
。我不知道添加
int 0x80
功能的确切版本,但是Linux内核5.5 / strace 5.5可以处理它。它误导性地表示该进程“以32位模式运行”,但确实解码正确。 (示例)。只要所有参数(包括指针)都适合寄存器的低32位,0x00000000
都可以工作。 x86-64 SysV ABI中默认代码模型(“小”)中的静态代码和数据就是这种情况。 (第3.5.1节:已知所有符号都位于
0x7effffff
至mov edi, hello
范围内的虚拟地址中,因此您可以执行mov $hello, %edi
(AT&T gcc
)之类的操作来将指针指向具有5个字节的寄存器说明)。但是,与位置无关的可执行文件不是这种情况,许多Linux发行版现在都将
hello.c
配置为默认生成(它们为可执行文件启用了ASLR)。例如,我在Arch Linux上编译了一个puts
,并在main的开始处设置了一个断点。传递给0x555555554724
的字符串常量位于write
,因此32位ABI rsp
系统调用将不起作用。 (默认情况下,GDB禁用ASLR,因此,如果您从GDB内部运行,则每次运行都会看到相同的地址。)Linux将堆栈放在上下限之间的“间隙”附近规范地址,即堆栈顶部在2 ^ 48-1。 (或者是随机的,启用了ASLR)。因此,在典型的静态链接可执行文件中,进入
_start
的0x7fffffffe550
类似于esp
,具体取决于env vars和args的大小。截断指向-EFAULT
的指针不会指向任何有效内存,因此,如果您尝试传递截断的堆栈指针,则带有指针输入的系统调用通常会返回rsp
。 (如果将esp
截断为arch/x86/entry/entry_64_compat.S
,然后对堆栈执行任何操作,例如,如果将32位asm源构建为64位可执行文件,则程序将崩溃。)如何它可以在内核中运行:
在Linux源代码中,
ENTRY(entry_INT80_compat)
定义了int 0x80
。 32位和64位进程在执行entry_64.S
时都使用相同的入口点。syscall
定义了64位内核的本机入口点,其中包括中断/错误处理程序和来自的entry_64_compat.S
本机系统调用长模式(也称为64位模式)进程。int 0x80
定义了从compat模式到64位内核的系统调用入口点,以及在64位进程中sysenter
的特殊情况。 (64位进程中的$__USER32_CS
也可能会到达该入口点,但会推送syscall
,因此它将始终以32位模式返回。)int 0x80
指令有32位版本,在AMD CPU上受支持, Linux也支持32位进程中的快速32位系统调用。如果您要使用自定义代码,我想
modify_ldt
在64位模式下可能的用例是-与int 0x80
一起安装的段描述符。 iret
推送段寄存器本身以供int 0x80
使用,Linux总是通过iret
从syscall
系统调用返回。 64位pt_regs->cs
入口点将->ss
和__USER_CS
设置为常数__USER_DS
和entry_32.S
。 (SS和DS使用相同的段描述符是正常的。权限差异是通过分页而不是分段来完成的。)int 0x80
定义了32位内核的入口点,根本没有涉及。 Linux 4.12的
entry_64_compat.S
中的struct pt_regs
入口点:/*
* 32-bit legacy system call entry.
*
* 32-bit x86 Linux system calls traditionally used the INT 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);
x80
* instruction. INT 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
x80 lands here.
*
* This entry point can be used by 32-bit and 64-bit programs to perform
* 32-bit system calls. Instances of INT yasm -felf64 -Worphan-labels -gdwarf2 abi32-from-64.asm
ld -o abi32-from-64 abi32-from-64.o
x80 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
* (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
x80 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,然后将所有寄存器压入内核堆栈以形成
ptrace
。这是从系统调用返回时恢复的位置。它采用已保存用户空间寄存器(用于任何入口点)的标准布局,因此,如果其他进程(例如gdb或strace
)在系统调用内使用ptrace
时使用它们,则它们将读取和/或写入该内存。 (修改寄存器ptrace
是一回事,它会使其他入口点的返回路径变得复杂。请参阅注释。)但是,它推送了sysenter
而不是r8 / r9 / r10 / r11。 (syscall32
和AMD call *ia32_sys_call_table(, %rax, 8)
入口点为r8-r15存储零。)我认为r8-r11的调零与历史行为相符。在为所有compat syscall设置完整的pt_regs之前,入口点仅保存了C调用密集的寄存器。它使用
rbx
从asm直接调度,并且这些函数遵循调用约定,因此它们保留rbp
,rsp
,r12-r15
和r8-r11
。调零ptrace
而不是使其保持未定义状态可能是避免内核泄漏信息的一种方法。如果用户空间的调用保留寄存器的唯一副本位于C函数将其保存的内核堆栈上,则IDK如何处理ebx
。我怀疑它是否使用堆栈展开元数据在此处找到它们。当前实现(Linux 4.12)从C调度32位ABI系统调用,从
ecx
重新加载保存的pt_regs
,mov %r10, %rcx
等。 。 (64位本机系统调用直接从asm分派,仅需syscall
即可解决函数与sysret
之间的调用约定中的微小差异。不幸的是,它不能始终使用syscall
,因为CPU错误使它在使用非规范时不安全它确实会尝试,所以尽管int 0x80
本身仍需要数十个周期,但快速路径还是非常快。)无论如何,在当前的Linux中,32位系统调用(包括64位的
do_syscall_32_irqs_on(struct pt_regs *regs)
)位)最终以ia32_sys_call_table
结尾。它分派给具有6个零扩展args的函数指针ia32
。这样可以避免在更多情况下需要围绕64位本机syscall函数进行包装以保留该行为,因此,更多arch/x86/entry/common.c
表条目可以直接作为本机系统调用实现。Linux 4.12
mov
q4312078q
在从asm分派32位系统调用的旧版Linux中(就像64位仍然如此),int80入口点本身使用32位寄存器通过
xchg
和mov %edx,%edx
指令将args放入正确的寄存器中。它甚至使用sysenter
将EDX零扩展为RDX(因为arg3在两种约定中碰巧使用相同的寄存器)。代码在这里。此代码在syscall32
和write()
入口点中重复。简单示例/测试程序:
我写了一个简单的Hello World(使用NASM语法),其中将所有寄存器设置为具有非零的上半部分,然后使用
int 0x80
进行两次.rodata
系统调用,一个使用指向-EFAULT
中的字符串的指针(成功),第二个使用指向堆栈的指针(使用syscall
失败)。 然后,它使用本机64位
write()
ABI到int 0x80
堆栈中的字符(64位指针),然后再次退出。所以所有这些示例正在正确使用ABI,除了第二个
lea
尝试传递64位指针并将其截断。如果将其构建为与位置无关的可执行文件,则第一个也将失败。 (您必须使用相对RIP的
mov
而不是hello:
才能将gdbgui
的地址保存到寄存器中。)我使用了gdb,但请使用您喜欢的任何调试器。使用一个突出显示自上一步以来已更改的寄存器的寄存器。
;;;
非常适合调试asm源,但不适用于反汇编。不过,它确实具有一个至少适用于整数reg的寄存器窗格,并且在此示例中效果很好。请参阅内联
gdb ./abi32-from-64
注释,其中描述了系统调用如何更改寄存器q4312078q
使用
将其构建为64位静态二进制文件
q4312078q
运行
gdb
。在set disassembly-flavor intel
中,如果尚未在layout reg
中运行~/.gdbinit
和.intel_syntax
,请运行它们。 (GAS q4312079q就像MASM,而不是NASM,但它们足够接近,如果您喜欢NASM语法,则很容易阅读。)q4312078q
gdb的TUI模式陷入混乱。即使程序无法自行打印输出,也很容易发生这种情况。
评论
@MatteoItalia:受到这个问题的启发,这个问题要求得到关于以32位模式构建的答案的解释,以解释64位模式的情况。 (错误的系统调用号和错误的strace解码导致非常混乱的结果)。无论如何,这让我感到好奇,并让我想写一个规范的答案。
– Peter Cordes
17年9月7日在6:01
@EOF:是的,疯狂和危险(对于您的过程,而不是系统)。在没有内核帮助的情况下,您已经可以做到这一点,只需使用内核的段选择器的已知值(jumpoverflow.com/questions/34467092/…)进行一次jmp。 AFAIK没什么用,或者在Linux下不支持,但是您可以做到。甚至还有一个Modify_ldt系统调用,它可以让您以“标准”方式进行操作:stackoverflow.com/a/13355668/224132
– Peter Cordes
17年7月7日在6:08
我不相信r8-r11的调零功能可以在旧的未修补内核上得到保证。似乎回想起这曾经是数据泄漏漏洞的一部分,当您让32位程序执行int 0x80然后切换到64位代码并获得对那些寄存器的先前值的访问权限时,我认为它们已修复在归还之前将它们归零。您在回答中提到了它,但是我相信如果您进行搜索,将会发现一个与之相关的实际漏洞。
– Michael Petch
17年11月4日在18:16
我终于找到了安全问题。这里有一篇文章。还有一些演示它的代码。
– Michael Petch
17年11月4日在18:32
@ameed:-38是-ENOSYS。 man7.org/linux/man-pages/man2/getuid.2.html解释说,从Linux 2.4开始,您应该使用getuid32。显然,对旧的getuid调用号的内核支持已删除,因为在Arch Linux桌面上也得到了同样的东西。您将在32位代码中看到具有相同int 0x80系统调用的相同内容,可以在其中跟踪它,并看到glibc使用getuid32,而您的代码使用getuid。 PS,您可以像普通人一样使用“ = a”(ret)。是的,您确实需要在r8..r11上使用clipper,但是在任何低8 regs上都不需要(使用eax作为输出操作数)
– Peter Cordes
18-11-23在23:13
评论
@IwillnotexistIdonotexist:破坏r8-r11是最不重要的原因! #1是:仅支持32位args /返回值。 #2是:strace会将其解码为错误,因此很难调试。 #3是:性能低下。 #4是:寄存器args与x86-64函数调用不匹配,并且它使用ebx保留调用。 (排名因使用案例/初学者而异,但我认为所有这些总是比清除r8-r11更为重要。)无论如何,我会考虑一个更好的介绍。