Что произойдет, если вы используете 32-битный int 0x80 Linux ABI в 64-битном коде?

int 0x80 в Linux всегда вызывает 32-битный ABI, независимо от того, из какого режима он вызывается: аргументы в ebx, ecx ,... и числа системных вызовов из /usr/include/asm/unistd_32.h. (Или происходит сбой в 64-битных ядрах, скомпилированных без CONFIG_IA32_EMULATION).

64-битный код должен использовать syscall с номерами вызовов из /usr/include/asm/unistd_64.h и аргументами в rdi, rsi и т.д. См. Каковы соглашения о вызовах для системных вызовов UNIX & Linux на i386 и x86-64, Если ваш вопрос был помечен как дубликат этого, см. Эту ссылку для получения подробных сведений о том, как выполнять системные вызовы в 32- или 64-разрядном коде. Если вы хотите понять, что именно произошло, продолжайте читать.

(Для примера 32-битного и 64-битного sys_write см. Использование прерывания 0x80 в 64-битном Linux)


syscall вызовы syscall выполняются быстрее, чем системные вызовы int 0x80, поэтому используйте собственный 64-разрядный syscall если только вы не пишете машинный код полиглота, который выполняется одинаково при выполнении как 32- или 64-разрядный. (sysenter всегда возвращается в 32-битном режиме, поэтому он бесполезен из 64-битного пространства пользователя, хотя это действительная инструкция x86-64.)

Связанный: sysenter системным вызовам Linux (на x86), как делать int 0x80 или 32-разрядные системные вызовы syscall, или 64-разрядные системные вызовы syscall, или вызывать vDSO для "виртуальных" системных вызовов, таких как gettimeofday. Плюс справочная информация о том, что системные вызовы все.


Использование int 0x80 позволяет написать что-то, что будет собираться в 32- или 64-битном режиме, так что это удобно для exit_group() в конце микробенчмарка или чего-то еще.

Текущие PDF файлы официальных документов psABI для i386 и x86-64 System V, которые стандартизируют соглашения о вызовах функций и системных вызовов, приведены по адресу https://github.com/hjl-tools/x86-psABI/wiki/X86-psABI.

Смотрите теги вики для начинающих гидов, х86 руководств, официальной документации, а также оптимизации производительности гидов/ресурсов.


Но поскольку люди продолжают публиковать вопросы с кодом, использующим int 0x80 в 64-битном коде, или случайно собирают 64-битные двоичные файлы из исходного кода, написанного для 32-битных, мне интересно, что именно происходит в нынешнем Linux?

int 0x80/восстанавливает ли int 0x80 все 64-битные регистры? Обрезает ли он какие-либо регистры до 32-битного? Что произойдет, если вы передадите аргументы-указатели с ненулевыми верхними половинами?

Это работает, если вы передаете это 32-битные указатели?

Ответ 1

TL: DR: int 0x80 работает при правильном использовании, если любые указатели умещаются в 32 бита (указатели стека не умещаются). Кроме того, strace неправильно его декодирует, декодируя содержимое регистра, как если бы это был 64-битный syscall ABI. (Там нет никакого простого/надежного способа для strace сказать, пока.)

int 0x80 нули r8-r11 и сохраняет все остальное. Используйте его точно так же, как в 32-битном коде, с 32-битными номерами вызовов. (Или лучше, не используйте его!)

Не все системы поддерживают даже int 0x80: подсистема Windows Ubuntu строго 64-разрядная: int 0x80 вообще не работает. Также возможно собрать ядра Linux без эмуляции IA-32. (Нет поддержки 32-битных исполняемых файлов, нет поддержки 32-битных системных вызовов).


Подробности: что сохранено/восстановлено, какие части каких рег использует ядро

int 0x80 использует eax (не полный rax) в качестве номера системного вызова, отправляя его в ту же таблицу указателей функций, которую использует 32-разрядное пространство пользователя int 0x80. (Эти указатели относятся к sys_whatever реализациям или оболочкам для родной 64-битной реализации внутри ядра. Системные вызовы - это действительно вызовы функций через границу пользователя/ядра.)

Передаются только младшие 32 бита регистров arg. Верхняя половина rbx - rbp сохраняются, но игнорируются int 0x80 системных вызовов. Обратите внимание, что передача неверного указателя на системный вызов не приводит к SIGSEGV; вместо этого системный вызов возвращает -EFAULT. Если вы не проверяете возвращаемые значения ошибок (с помощью отладчика или инструмента трассировки), то, по-видимому, произойдет сбой.

Все регистры (кроме, конечно, eax) сохраняются/восстанавливаются (включая RFLAGS и верхние 32 целочисленных регистров), за исключением того, что r8-r11 обнуляются. r12-r15 сохраняются в соответствии с соглашением о вызовах функций SysV ABI в x86-64, поэтому регистры, которые обнуляются с помощью int 0x80 в 64-битной системе, являются подмножеством вызовов "новых" регистров, добавленных AMD64.

Это поведение было сохранено после некоторых внутренних изменений в том, как сохранение регистров было реализовано в ядре, и в комментариях в ядре упоминается, что его можно использовать из 64-битной версии, поэтому этот ABI, вероятно, стабилен. (То есть вы можете рассчитывать на обнуление r8-r11 и сохранение всего остального.)

Возвращаемое значение знакового расширения для заполнения 64-битной rax. (Linux объявляет 32-битные функции sys_ как возвращающие long знаком.) Это означает, что возвращаемые значения указателя (например, из void *mmap()) должны быть расширены до нуля перед использованием в 64-битных режимах адресации

В отличие от sysenter, он сохраняет исходное значение cs, поэтому он возвращается в пользовательское пространство в том же режиме, в котором был вызван. (Использование sysenter приводит к тому, что ядро устанавливает для cs значение $__USER32_CS, которое выбирает дескриптор для 32-разрядного сегмент кода.)


strace неправильно декодирует int 0x80 для 64-битных процессов. Он декодирует, как если бы процесс использовал syscall вместо int 0x80. Это может быть очень запутанным. например, поскольку strace печатает write(0, NULL, 12 <unfinished... exit status 1> для eax=1/int $0x80, что на самом деле _exit(ebx), а не write(rdi, rsi, rdx).


int 0x80 работает до тех пор, пока все аргументы (включая указатели) помещаются в младшие 32 регистра. Это относится к статическому коду и данным в модели кода по умолчанию ("small") в x86-64 SysV ABI. (Раздел 3.5.1: известно, что все символы расположены в виртуальных адресах в диапазоне от 0x00000000 до 0x7effffff, поэтому вы можете делать такие вещи, как mov edi, hello (AT & T mov $hello, %edi), чтобы получить указатель на регистр с 5-байтовой инструкцией).

Но это не относится к позиционно-независимым исполняемым файлам, которые многие дистрибутивы Linux сейчас настраивают для создания gcc по умолчанию (и они разрешают ASLR для исполняемых файлов). Например, я скомпилировал hello.c в Arch Linux и установил точку останова в начале main. Постоянная строка передается puts был 0x555555554724, поэтому 32-битный ABI write системный вызов не будет работать. (GDB по умолчанию отключает ASLR, поэтому вы всегда видите один и тот же адрес от запуска к запуску, если вы запускаете его из GDB.)

Linux помещает стек около "промежутка" между верхним и нижним диапазоном канонических адресов, то есть с вершиной стека в 2 ^ 48-1. (Или где-то случайно, с включенным ASLR). Таким образом, rsp при входе в _start в типичном статически связанном исполняемом файле выглядит как 0x7fffffffe550, в зависимости от размера переменных env и аргументов. Усечение этого указателя до 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-битного ядра, которая включает в себя прерывание/обработчики сбоев и syscall родные системных вызовов от длительного режима ( так называемого 64-битный режим) процессы.

entry_64_compat.S определяет точки входа системного вызова из режима Compat в 64-битное ядро, а также особый случай int 0x80 в 64-битном процессе. (sysenter в 64-битном процессе может также перейти к этой точке входа, но он выдвигает $__USER32_CS, поэтому он всегда будет возвращаться в 32-битном режиме.) Существует 32-битная версия инструкции syscall, поддерживаемая на процессорах AMD и Linux тоже поддерживает это для быстрых 32-битных системных вызовов из 32-битных процессов.

Я предполагаю, что возможный вариант использования int 0x80 в 64-битном режиме - это если вы хотите использовать собственный дескриптор сегмента кода, который вы установили с помощью modify_ldt. int 0x80 выдвигает сегментные регистры для использования с iret, и Linux всегда возвращается из системных вызовов int 0x80 через iret. 64-битная syscall входа syscall устанавливает pt_regs->cs и ->ss в константы, __USER_CS и __USER_DS. (Обычно SS и DS используют одни и те же дескрипторы сегментов. Различия в разрешениях выполняются с помощью подкачки, а не сегментации.)

entry_32.S определяет точки входа в 32-битное ядро и не участвует вообще.

Точка входа int 0x80 в Linux 4.12 entry_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)

Код ноль расширяет eax в rax, затем помещает все регистры в стек ядра, чтобы сформировать struct pt_regs. Это где он будет восстанавливать, когда системный вызов возвращается. Он находится в стандартной компоновке для сохраненных регистров пользовательского пространства (для любой точки входа), поэтому ptrace из другого процесса (например, gdb или strace) будет считывать и/или записывать эту память, если они используют ptrace пока этот процесс находится внутри системного вызова. (Модификация регистров ptrace - это одна вещь, которая усложняет пути возврата для других точек входа. См. комментарии.)

Но это толкает $0 вместо r8/r9/r10/r11. (sysenter входа sysenter и AMD syscall32 хранят нули для r8-r15.)

Я думаю, что обнуление r8-r11 соответствует историческому поведению. Перед установкой полного pt_regs для всех фиксаций системных вызовов, точка входа сохраняла только регистры с C-clobbered. Он отправляется непосредственно из ассемблера с call *ia32_sys_call_table(, %rax, 8), и эти функции следует соглашению о вызовах, поэтому они сохраняют rbx, rbp, rsp и r12-r15. Обнуление r8-r11 вместо того, чтобы оставлять их неопределенными, было, вероятно, способом избежать утечки информации из ядра. IDK, как он обрабатывает ptrace если единственная копия регистров, сохраняющих вызовы в пространстве пользователя, находится в стеке ядра, где функция C их сохранила. Я сомневаюсь, что он использовал метаданные для размотки стека, чтобы найти их там.

Текущая реализация (Linux 4.12) отправляет системные вызовы 32-битного ABI из C, перезагружая сохраненные ebx, ecx и т.д. Из pt_regs. (64-битные собственные системные вызовы диспетчеризируются напрямую из asm, только с mov %r10, %rcx необходимо учитывать небольшую разницу в соглашении о вызовах между функциями и syscall. К сожалению, он не всегда может использовать sysret, потому что ошибки процессора делают его небезопасен с неканоническими адресами. Он пытается, поэтому быстрый путь чертовски быстр, хотя сам syscall все еще занимает десятки циклов.)

Так или иначе, в текущем Linux 32-битные системные вызовы (включая int 0x80 из 64-битных) в конечном итоге заканчиваются на do_syscall_32_irqs_on(struct pt_regs *regs). Он отправляет указателю на функцию ia32_sys_call_table с 6 расширенными нулями аргументами. Это, возможно, позволяет избежать необходимости в обертке вокруг 64-битной собственной функции syscall в большем количестве случаев, чтобы сохранить это поведение, поэтому большее количество ia32 таблице ia32 может быть напрямую реализовано в собственной реализации системного вызова.

Linux 4.12 arch/x86/entry/common.c

if (likely(nr < IA32_NR_syscalls)) {
  /*
   * It 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, которые отправляют 32-битные системные вызовы из asm (как это делает 64-битная версия), точка входа int80 сама помещает аргументы в нужные регистры с xchg команд mov и xchg, используя 32-битные регистры. Он даже использует mov %edx,%edx для расширения нуля EDX в RDX (поскольку в обоих соглашениях arg3 используется один и тот же регистр). код здесь. Этот код дублируется в sysenter входа sysenter и syscall32.


Простой пример/тестовая программа:

Я написал простой Hello World (в синтаксисе NASM), в котором все регистры имеют ненулевую верхнюю половину, затем выполняет два системных вызова write() с int 0x80, один с указателем на строку в .rodata (успешно), второй с указателем на стек (завершается с -EFAULT).

Затем он использует собственный 64-разрядный ABI syscall для write() символов из стека (64-разрядный указатель) и снова для выхода.

Таким образом, все эти примеры используют ABI правильно, за исключением 2-го типа int 0x80 который пытается передать 64-битный указатель и урезает его.

Если вы построите его как независимый от позиции исполняемый файл, первый тоже не получится. (Чтобы получить адрес hello: в регистр, вы должны использовать RIP-родственник lea вместо mov.)

Я использовал 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 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

Запустите gdb./abi32-from-64. В gdb запустите ~/.gdbinit set disassembly-flavor intel и layout 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

Нажмите control-L, когда режим GDB TUI испортится. Это происходит легко, даже когда программы не печатают сами.