¿Qué sucede si usa la ABI Linux int 0x80 de 32 bits en código de 64 bits?
int 0x80
en Linux siempre invoca la ABI de 32 bits, independientemente del modo desde el que se llame: argumentos en ebx
, ecx
, ... y números de llamada al sistema desde /usr/include/asm/unistd_32.h
. (O falla en kernels de 64 bits compilados sin CONFIG_IA32_EMULATION
).
El código de 64 bits debe usarsyscall
, con números de identificación de /usr/include/asm/unistd_64.h
, y argumentos en rdi
, rsi
, etc. Consulte ¿Cuáles son las convenciones de llamada para llamadas de sistemas UNIX y Linux en i386 y x86-64 ? Si su pregunta fue marcada como un duplicado de esta, consulte ese enlace para obtener detalles sobre cómo debe realizar llamadas al sistema en código de 32 o 64 bits. Si quieres entender qué pasó exactamente, sigue leyendo.
(Para ver un ejemplo de 32 bits frente a 64 bits sys_write
, consulte Uso de la interrupción 0x80 en Linux de 64 bits )
syscall
las llamadas al sistema son más rápidas que int 0x80
las llamadas al sistema, así que use 64 bits nativos syscall
a menos que esté escribiendo código de máquina políglota que se ejecute igual cuando se ejecuta en 32 o 64 bits. ( sysenter
siempre regresa en modo de 32 bits, por lo que no es útil en un espacio de usuario de 64 bits, aunque es una instrucción x86-64 válida).
Relacionado: La guía definitiva para llamadas al sistema Linux (en x86) para saber cómo realizar llamadas int 0x80
al sysenter
sistema de 32 bits, o syscall
llamadas al sistema de 64 bits, o llamar al vDSO para llamadas al sistema "virtuales" como gettimeofday
. Además, información sobre de qué se tratan las llamadas al sistema.
El uso int 0x80
hace posible escribir algo que se ensamblará en modo de 32 o 64 bits, por lo que es útil para exit_group()
al final de un microbenchmark o algo así.
Los archivos PDF actuales de los documentos psABI oficiales de i386 y x86-64 System V que estandarizan las funciones y las convenciones de llamadas al sistema están vinculados desde https://github.com/hjl-tools/x86-psABI/wiki/X86-psABI .
Ver elx86tag wiki para guías para principiantes, manuales x86, documentación oficial y guías/recursos de optimización del rendimiento.
Pero dado que la gente sigue publicando preguntas sobre código que se utiliza int 0x80
en código de 64 bits , o creando accidentalmente archivos binarios de 64 bits a partir de fuentes escritas para 32 bits, me pregunto qué sucede exactamente en el Linux actual.
¿ int 0x80
Guarda/restaura todos los registros de 64 bits? ¿Se trunca algún registro a 32 bits? ¿Qué sucede si pasa argumentos de puntero que tienen mitades superiores distintas de cero?
¿Funciona si le pasas punteros de 32 bits?
TL:DR : int 0x80
funciona cuando se usa correctamente, siempre que los punteros quepan en 32 bits ( los punteros de pila no encajan ). Pero tenga cuidado porque strace
lo decodifica mal a menos que tenga un kernel strace + muy reciente.
int 0x80
pone a cero r8-r11 por motivos y conserva todo lo demás. Úselo exactamente como lo haría con el código de 32 bits, con los números de identificación de 32 bits. (O mejor, ¡no lo uses!)
No todos los sistemas lo admiten int 0x80
: el subsistema de Windows para Linux versión 1 (WSL1) es estrictamente de 64 bits: int 0x80
no funciona en absoluto . También es posible construir kernels de Linux sin emulación IA-32 . (No hay soporte para ejecutables de 32 bits, no hay soporte para llamadas al sistema de 32 bits). Vea esto con respecto a: asegurarse de que su WSL sea en realidad WSL2 (que utiliza un kernel de Linux real en una máquina virtual).
Los detalles: qué se guarda/restaura, qué partes de qué registros usa el kernel
int 0x80
utiliza eax
(no el completo rax
) como número de llamada del sistema, despachando a la misma tabla de punteros de función que utiliza el espacio de usuario de 32 bits int 0x80
. (Estos indicadores son para sys_whatever
implementaciones o envoltorios para la implementación nativa de 64 bits dentro del kernel. Las llamadas al sistema son en realidad llamadas a funciones a través del límite usuario/kernel).
Sólo se pasan los 32 bits inferiores de los registros arg. Las mitades superiores de rbx
- rbp
se conservan, pero int 0x80
las llamadas al sistema las ignoran. Tenga en cuenta que pasar un puntero incorrecto a una llamada al sistema no da como resultado SIGSEGV; en su lugar, la llamada al sistema regresa -EFAULT
. Si no verifica los valores de retorno de error (con un depurador o una herramienta de seguimiento), parecerá que falla silenciosamente.
Todos los registros (excepto eax, por supuesto) se guardan/restauran (incluidos RFLAGS y los 32 registros superiores de números enteros), excepto que r8-r11 se ponen a cero . r12-r15
se conservan las llamadas en la convención de llamada de funciones de x86-64 SysV ABI, por lo que los registros que se ponen a cero int 0x80
en 64 bits son el subconjunto de los registros "nuevos" que agregó AMD64.
Este comportamiento se ha conservado a través de algunos cambios internos en la forma en que se implementó el guardado de registros dentro del kernel, y los comentarios en el kernel mencionan que se puede utilizar desde 64 bits, por lo que esta ABI probablemente sea estable. (Es decir, puede contar con que r8-r11 se pondrá a cero y todo lo demás se conservará).
El valor de retorno tiene extensión de signo para completar 64 bits rax
. (Linux declara que las funciones sys_ de 32 bits regresan con signolong
). Esto significa que los valores de retorno del puntero (como from void *mmap()
) deben extenderse a cero antes de usarse en modos de direccionamiento de 64 bits.
A diferencia de sysenter
, conserva el valor original de cs
, por lo que regresa al espacio de usuario en el mismo modo en que fue llamado. (El uso da como sysenter
resultado la configuración del kernel cs
en $__USER32_CS
, que selecciona un descriptor para un segmento de código de 32 bits).
Los anteriores strace
decodifican int 0x80
incorrectamente procesos de 64 bits. Se decodifica como si el proceso se hubiera utilizado syscall
en lugar de int 0x80
. Esto puede ser muy confuso . por ejemplo, strace
imprime write(0, NULL, 12 <unfinished ... exit status 1>
para eax=1
/ int $0x80
, que en realidad _exit(ebx)
no lo es write(rdi, rsi, rdx)
.
No sé la versión exacta donde PTRACE_GET_SYSCALL_INFO
se agregó la función, pero el kernel de Linux 5.5/strace 5.5 la maneja. Dice engañosamente que el proceso "se ejecuta en modo de 32 bits", pero se decodifica correctamente. ( Ejemplo ).
int 0x80
funciona siempre que todos los argumentos (incluidos los punteros) quepan en el 32 bajo de un registro . Este es el caso del código estático y los datos en el modelo de código predeterminado ("pequeño") en la ABI SysV x86-64 . (Sección 3.5.1: se sabe que todos los símbolos están ubicados en las direcciones virtuales en el rango 0x00000000
de0x7effffff
, por lo que puede hacer cosas como mov edi, hello
(AT&T mov $hello, %edi
) para obtener un puntero a un registro con una instrucción de 5 bytes).
Pero este no es el caso de los ejecutables independientes de la posición , que muchas distribuciones de Linux ahora configuran gcc
de forma predeterminada (y habilitan ASLR para los ejecutables). Por ejemplo, compilé hello.c
en Arch Linux y establecí un punto de interrupción al inicio de main. La constante de cadena pasada a puts
estaba en 0x555555554724
, por lo que una llamada al sistema ABI de 32 bits write
no funcionaría. (GDB deshabilita ASLR de forma predeterminada, por lo que siempre verá la misma dirección de una ejecución a otra, si ejecuta desde GDB).
Linux coloca la pila cerca del "espacio" entre los rangos superior e inferior de direcciones canónicas , es decir, con la parte superior de la pila en 2^48-1. (O en algún lugar aleatorio, con ASLR habilitado). Entonces rsp
, la entrada a _start
un ejecutable típico vinculado estáticamente es algo así como 0x7fffffffe550
, dependiendo del tamaño de env vars y args. Truncar este puntero esp
no apunta a ninguna memoria válida, por lo que las llamadas al sistema con entradas de puntero normalmente regresarán -EFAULT
si intenta pasar un puntero de pila truncado. (Y su programa fallará si trunca rsp
la esp
pila y luego hace algo con ella, por ejemplo, si creó una fuente asm de 32 bits como un ejecutable de 64 bits).
Cómo funciona en el kernel:
En el código fuente de Linux, arch/x86/entry/entry_64_compat.S
se define
ENTRY(entry_INT80_compat)
. Tanto los procesos de 32 como los de 64 bits utilizan el mismo punto de entrada cuando se ejecutan int 0x80
.
entry_64.S
Esto define puntos de entrada nativos para un kernel de 64 bits, que incluye controladores de interrupciones/fallos y syscall
llamadas nativas al sistema desde procesos de modo largo (también conocido como modo de 64 bits) .
entry_64_compat.S
define puntos de entrada de llamadas al sistema desde el modo de compatibilidad en un kernel de 64 bits, más el caso especial de int 0x80
en un proceso de 64 bits. ( sysenter
en un proceso de 64 bits también puede ir a ese punto de entrada, pero empuja $__USER32_CS
, por lo que siempre regresará en modo de 32 bits). Hay una versión de 32 bits de la syscall
instrucción, compatible con CPU AMD y compatible con Linux. también para llamadas rápidas al sistema de 32 bits desde procesos de 32 bits.
Supongo que un posible caso de uso en int 0x80
modo de 64 bits es si desea utilizar un descriptor de segmento de código personalizado que instaló con modify_ldt
. int 0x80
empuja el segmento que se registra para su uso iret
y Linux siempre regresa de int 0x80
las llamadas al sistema a través de iret
. syscall
El punto de entrada de 64 bits establece pt_regs->cs
y ->ss
en constantes __USER_CS
y __USER_DS
. (Es normal que SS y DS utilicen los mismos descriptores de segmento. Las diferencias de permisos se realizan con paginación, no con segmentación).
entry_32.S
define puntos de entrada en un kernel de 32 bits y no está involucrado en absoluto.
El
int 0x80
punto de entrada en Linux 4.12entry_64_compat.S
:/* * 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)
El código extiende cero eax a rax, luego inserta todos los registros en la pila del kernel para formar un archivo struct pt_regs
. Aquí es desde donde se restaurará cuando regrese la llamada al sistema. Tiene un diseño estándar para registros de espacio de usuario guardados (para cualquier punto de entrada), por lo que ptrace
desde otro proceso (como gdb o strace
) leerán y/o escribirán esa memoria si la usan ptrace
mientras este proceso está dentro de una llamada al sistema. ( ptrace
La modificación de los registros es algo que complica las rutas de retorno para los otros puntos de entrada. Ver comentarios).
Pero empuja $0
en lugar de r8/r9/r10/r11. ( sysenter
y syscall32
los puntos de entrada de AMD almacenan ceros para r8-r15).
Creo que esta reducción a cero de r8-r11 debe coincidir con el comportamiento histórico. Antes de la confirmación de configuración de pt_regs completos para todas las llamadas al sistema compatibles , el punto de entrada solo guardaba los registros de llamadas de C. Se envió directamente desde asm con call *ia32_sys_call_table(, %rax, 8)
, y esas funciones siguen la convención de llamada, por lo que conservan rbx
, rbp
, rsp
y r12-r15
. Ponerlos a cero r8-r11
en lugar de dejarlos sin definir fue para evitar fugas de información desde un kernel de 64 bits a un espacio de usuario de 32 bits (que podría pasar a un segmento de código de 64 bits para leer cualquier cosa que el kernel dejara allí).
La implementación actual ( Linux 4.12) envía llamadas al sistema ABI de 32 bits desde C, recargando los archivos ebx
, ecx
etc. pt_regs
(Las llamadas al sistema nativo de 64 bits se envían directamente desde asm, y solo esmov %r10, %rcx
necesario tener en cuenta la pequeña diferencia en la convención de llamadas entre funciones y syscall
. Desafortunadamente, no siempre se puede usar sysret
, porque los errores de la CPU lo hacen inseguro con direcciones no canónicas. lo intenta, por lo que el camino rápido es bastante rápido, aunque syscall
todavía requiere decenas de ciclos).
De todos modos, en el Linux actual, las llamadas al sistema de 32 bits (incluidas int 0x80
las de 64 bits) eventualmente terminan en formato do_syscall_32_irqs_on(struct pt_regs *regs)
. Se envía a un puntero de función ia32_sys_call_table
, con 6 argumentos extendidos a cero. Esto tal vez evite la necesidad de un contenedor alrededor de la función de llamada al sistema nativa de 64 bits en más casos para preservar ese comportamiento, por lo que más ia32
entradas de la tabla pueden ser la implementación de la llamada al sistema nativo directamente.
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);
En versiones anteriores de Linux que envían llamadas al sistema de 32 bits desde asm (como todavía lo hacían los de 64 bits hasta 4.15 1 ), el punto de entrada int80 coloca argumentos en los registros correctos con instrucciones mov
y xchg
, utilizando registros de 32 bits. Incluso se utiliza mov %edx,%edx
para extender EDX a cero en RDX (porque arg3 usa el mismo registro en ambas convenciones). código aquí . Este código está duplicado en los puntos de entrada sysenter
y syscall32
.
Nota al pie 1: Linux 4.15 (creo) introdujo mitigaciones de Spectre/Meltdown y una renovación importante de los puntos de entrada que los convirtió en un trampolín para el caso de fusión. También desinfectó los registros entrantes para evitar que valores de espacio de usuario distintos de los argumentos reales estuvieran en los registros durante la llamada (cuando se podría ejecutar algún dispositivo Spectre), almacenándolos, poniendo a cero todo y luego llamando a un contenedor C que recarga solo los anchos correctos. de argumentos de la estructura guardados al ingresar.
Estoy planeando dejar esta respuesta que describe el mecanismo mucho más simple porque la parte conceptualmente útil aquí es que el lado del núcleo de una llamada al sistema implica el uso de EAX o RAX como índice en una tabla de punteros de función, y otros valores de registro entrantes copiados van a los lugares donde la convención de llamada quiere que vayan los argumentos. es decir, syscall
es sólo una forma de realizar una llamada al kernel, a su código de envío.
Ejemplo simple/programa de prueba:
Escribí un Hola mundo simple (en sintaxis NASM) que configura todos los registros para que tengan mitades superiores distintas de cero, luego realiza dos write()
llamadas al sistema con int 0x80
, una con un puntero a una cadena en .rodata
(éxito), la segunda con un puntero a la pila (falla con -EFAULT
).
Luego usa la syscall
ABI nativa de 64 bits para write()
los caracteres de la pila (puntero de 64 bits) y nuevamente para salir.
Entonces, todos estos ejemplos utilizan las ABI correctamente, excepto el segundo int 0x80
que intenta pasar un puntero de 64 bits y lo trunca.
Si lo construyera como un ejecutable independiente de la posición, el primero también fallaría. (Tendría que usar un pariente de RIP lea
en lugar de mov
obtener la dirección hello:
en un registro).
Usé gdb, pero usa el depurador que prefieras. Utilice uno que resalte los registros modificados desde el último paso. gdbgui
Funciona bien para depurar el código fuente de ASM, pero no es excelente para desensamblarlo. Aún así, tiene un panel de registro que funciona bien al menos para registros enteros, y funcionó muy bien en este ejemplo.
Vea los ;;;
comentarios en línea que describen cómo las llamadas al sistema cambian los registros
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
Constrúyalo en un binario estático de 64 bits con
yasm -felf64 -Worphan-labels -gdwarf2 abi32-from-64.asm
ld -o abi32-from-64 abi32-from-64.o
Correr gdb ./abi32-from-64
. En gdb
, ejecuta set disassembly-flavor intel
y layout reg
si aún no lo tienes en el tuyo ~/.gdbinit
. (GAS .intel_syntax
es como MASM, no NASM, pero son lo suficientemente parecidos como para que sea fácil de leer si te gusta la sintaxis de 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
Presione control-L cuando el modo TUI de gdb se estropee. Esto sucede fácilmente, incluso cuando los programas no se imprimen por sí solos.