Почему мне разрешено выходить из main с помощью ret? - PullRequest
1 голос
/ 10 января 2020

Я собираюсь выяснить, как именно устанавливается стек программ. Я узнал, что вызов функции с помощью

call pointer;

фактически совпадает с:

mov register, pc ;programcounter
add register, 1 ; where 1 is one instruction not 1 byte ...
push register
jump pointer

Однако это будет означать, что когда ядро ​​Unix вызывает основную функцию, основание стека должно указывать на повторный вход в функцию ядра, которая вызывает main.

Следовательно, переход "* rbp-1" в C - код должен повторно ввести основную функцию.

Это, однако, , это не то, что происходит в следующем коде:

#include <stdlib.h>
#include <unistd.h>

extern void ** rbp(); //pointer to stack pointing to function
int main() {
   void ** p = rbp();
   printf("Main: %p\n", main);
   printf("&Main: %p\n", &main); //WTF
   printf("*Main: %p\n", *main); //WTF
   printf("Stackbasepointer: %p\n", p);
   int (*c)(void) = (*p)-4;
   asm("movq %rax, 0");
   c();

   return 0;        //should never be executed...

}

Файл сборки: rsp.asm

...

.intel_syntax

.text:

.global _rbp

_rbp:
  mov rax, rbp
  ret;

Это неудивительно, что может быть, потому что инструкции на этом этапе не совсем 64-битные, может быть, потому что UNIX не позволяет это ...

Но также этот вызов не разрешен:

   void (*c)(void) = (*p);
   asm("movq %rax, 0"); //Exit code is 11, so now it should be 0
   c(); //this comes with stack corruption, when successful

Это означает, что я не обязан выходить из функции основного вызова.

Тогда у меня возникает вопрос: почему я использую ret, как видно в конце каждой основной функции G CC? , который должен делать то же самое, что и код выше. Как unix - система эффективно проверяет такие попытки ... Надеюсь, мой вопрос ясен ...

Спасибо. PS: код компилируется только на macOS, измените сборку на linux

Ответы [ 3 ]

4 голосов
/ 10 января 2020

C main вызывается (косвенно) из кода запуска CRT, а не напрямую из ядра.

После возврата main этот код вызывает atexit функции для выполнения таких вещей, как сброс stdio буферизует, затем передает возвращаемое значение main в необработанный системный вызов _exit. Или exit_group, который выходит из всех потоков.


Вы делаете несколько неправильных предположений, все, что я думаю, основываясь на неправильном понимании того, как работают ядра.

  • Ядро работает на уровне привилегий, отличном от пространства пользователя (кольцо 0 против кольца 3 на x86). Даже если пользовательское пространство знает правильный адрес для перехода, оно не может перейти в код ядра. (И даже если бы это было возможно, он не работал бы с ядром уровень привилегий ).

    ret не маги c, это просто pop %rip и не не позволяйте прыгать туда, куда вы не могли перейти с другими инструкциями. Также не изменяет уровень привилегий 1 .

  • Адреса ядра не отображаются / не доступны, когда выполняется код пользовательского пространства; эти записи таблицы страниц помечены как только для супервизора. (Или они вообще не отображаются в ядрах, которые уменьшают уязвимость Meltdown, поэтому вход в ядро ​​проходит через блок кода-оболочки, который изменяет CR3.)

    Виртуальная память - это то, как ядро защищает себя от пространства пользователя. пространство пользователя не может изменять таблицы страниц напрямую, только попросив ядро ​​сделать это с помощью системных вызовов mmap и mprotect. (И пользовательское пространство не может выполнять привилегированные инструкции, такие как mov cr3, rax, для установки новых таблиц страниц. Именно поэтому кольцо 0 (режим ядра) и кольцо 3 (режим пользователя).)

  • Стек ядра отделен от стека пользовательского пространства для процесса . (В ядре также имеется небольшой стек ядра для каждой задачи (иначе говоря, потока), который используется во время системных вызовов / прерываний во время работы этого потока в пользовательском пространстве. По крайней мере, именно так Linux делает это, IDK о других.)

  • Ядро буквально не call код пользовательского пространства; Стек пользовательского пространства не удерживает обратный адрес в ядре. Переход ядра-> пользователя включает в себя обмен указателями стека, а также изменение уровней привилегий. например, с такой инструкцией, как iret (прерывание-возврат).

    Плюс, оставляя адрес кода ядра везде, где пользовательское пространство может видеть, что это может победить ASLR ядра.

Сноска 1: (Сгенерированный компилятором ret всегда будет нормальным рядом с ret, а не retf, который может вернуться через шлюз вызова или что-то к привилегированному значению cs. x86 обрабатывает уровни привилегий через младшие 2 бита CS, но не обращайте на это внимания. MacOS / Linux не не устанавливает шлюзы вызовов, которые пользовательское пространство может использовать для вызова в ядро; это сделано с syscall или int 0x80 инструкции.)


В процессе fre sh (после того, как системный вызов execve заменил предыдущий процесс на этот PID новым), выполнение начинается с процесса точка входа (обычно обозначается _start), , а не в функции C main напрямую.

C реализации поставляются с кодом запуска CRT (C RunTime), который имеет (среди прочего) рукописную реализацию asm * 1 074 * который (косвенно) вызывает main, передавая аргументы в main согласно соглашению о вызовах.

_start само по себе не является функцией. При входе в процесс RSP указывает на argc, и выше, что в стеке пользовательского пространства равно argv[0], argv[1], и др c. (т. е. массив char *argv[] находится прямо по значению и выше массива envp.) _start загружает argc в регистр и помещает указатели на argv и envp в регистры. ( x86-64 System V ABI, в котором MacOS и Linux оба используют документы, все это, включая среду запуска процесса и соглашение о вызовах. )

Если вы попытаетесь до ret из _start, вы просто вставите argc в RIP, а затем получите код с абсолютного адреса 1 или 2 ( или другое небольшое количество) будет segfault. Например, Ошибка сегментации носа на RET в _start показывает попытку ret из точки входа в процесс (связанный без кода запуска CRT). У него есть рукописный текст _start, который просто записывается в main.


Когда вы запускаете gcc main.c, интерфейс gcc запускает несколько других программ (используйте gcc -v показать детали). Вот как код запуска CRT связывается с вашим процессом:

  • g cc препроцессирует (CPP) и компилирует + собирает main.c в main.o (или временный файл). В MacOS команда gcc на самом деле является clang, у которой есть встроенный ассемблер, но настоящий gcc действительно компилируется в asm и затем запускает as на этом. (Тем не менее, препроцессор C встроен в компилятор.)
  • g cc выполняет что-то вроде ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie /usr/lib/Scrt1.o /usr/lib/gcc/x86_64-pc-linux-gnu/9.1.0/crtbeginS.o main.o -lc -lgcc /usr/lib/gcc/x86_64-pc-linux-gnu/9.1.0/crtendS.o. Это на самом деле упрощено много , некоторые файлы CRT пропущены, а пути канонизированы для удаления ../../lib деталей. Кроме того, он не запускает ld напрямую, он запускает collect2, который является оболочкой для ld. Но в любом случае, это статически связывает в тех .o CRT-файлах, которые содержат _start и некоторые другие вещи, и динамически связывает lib c (-lc) и libg cc (для вспомогательных функций G CC, таких как реализация __int128 умножать и делить на 64-битные регистры, если ваша программа их использует).

.intel_syntax

.text:

.global _rbp

_rbp:
  mov rax, rbp
  ret;

Это недопустимо, ...

Единственная причина, по которой не выполняется сборка, заключается в том, что вы пытались объявить .text: как метку вместо использования директивы .text . Если вы удалите конечный :, он собирается с помощью clang (который обрабатывает .intel_syntax так же, как .intel_syntax noprefix).

Для G CC / GAS для его сборки вам также понадобится noprefix, чтобы сообщить ему, что именам регистров не предшествует %. (Да, вы можете иметь Intel op dst, sr c порядок, но все еще с %rsp именами регистров. Нет, вы не должны делать это!) И, конечно, GNU / Linux не использует начальные подчеркивания.

Не то чтобы оно всегда делало то, что вы хотите, если бы вы его назвали! Если вы скомпилировали main без оптимизации (так что -fno-omit-frame-pointer действовал), тогда да, вы получите указатель на слот стека под адресом возврата.


И вы определенно неправильно использовать значение . (*p)-4; загружает сохраненное значение RBP (*p) и затем смещается четырьмя 8-байтовыми пустыми указателями. (Потому что так работает C математика указателя; *p имеет тип void*, потому что p имеет тип void **).

Я думаю, вы пытаетесь получить свой собственный обратный адрес и пере - запустить команду call (в вызывающей программе main), которая достигла main, что в конечном итоге привело к переполнению стека из-за нажатия большего количества адресов возврата. В GNU C используйте void * __builtin_return_address (0) , чтобы получить свой собственный обратный адрес .

x86 call rel32 инструкции имеют 5 байтов, но call, который вызывал main, был возможно косвенный вызов с использованием указателя в регистре . Так что это может быть 2-байтовый call *%rax или 3-байтовый call *%r12, вы не узнаете, пока не разберете своего вызывающего. (Я бы предложил пошаговое выполнение инструкций (GDB / LLDB stepi) до конца main с использованием отладчика в режиме разборки. Если у него есть какая-либо символьная информация для вызывающего абонента main, вы сможете прокрутить назад и посмотрите, что было в предыдущей инструкции.

Если нет, то вам, возможно, придется попробовать и посмотреть, что выглядит вменяемым; машинный код x86 не может быть однозначно декодирован в обратном направлении, потому что он имеет переменную длину. Разница между байтом в инструкции (например, немедленным или ModRM) и началом инструкции. Все зависит от того, откуда вы начинаете разборку. Если вы попытаетесь сместить несколько байтов, обычно только один производить все, что выглядит вменяемым.


   asm("movq %rax, 0"); //Exit code is 11, so now it should be 0

Это хранилище RAX по абсолютному адресу 0 в синтаксисе AT & T. Это, конечно, segfaults. код выхода 11 от SIGSEGV, который является сигналом 11. (Используйте kill -l, чтобы увидеть номера сигналов).

Возможно, вы хотели mov $0, %eax. Хотя это все еще бессмысленно, вы собираетесь вызывать через указатель на функцию. В режиме отладки компилятор может загрузить его в RAX и изменить ваше значение.

Кроме того, запись регистра в операторе asm никогда не бывает безопасной, если вы не сообщаете компилятору о том, какие регистры вы используете. изменение (использование ограничений).


   printf("Main: %p\n", main);
   printf("&Main: %p\n", &main); //WTF

main и &main - это одно и то же, потому что main является функцией. Вот как работает синтаксис C для имен функций. main не является объектом, который может получить свой адрес. & оператор необязательный при назначении указателя на функцию

Это аналогично для массивов: голое имя массива может быть назначено указателю или передано в функции в качестве аргумента-указателя. Но &array тоже тот же указатель, что и &array[0]. Это верно только для массивов , таких как int array[10], но не для указателей, таких как int *ptr; в последнем случае сам объект-указатель имеет место для хранения и может иметь собственный адрес.

3 голосов
/ 10 января 2020

Я думаю, что у вас здесь довольно много недоразумений. Во-первых, ядро ​​не вызывает main. Ядро выделяет процесс и загружает наш двоичный файл в память - обычно из файла ELF, если вы используете ОС на основе Unix. Этот файл ELF содержит все разделы, которые необходимо отобразить в память, и адрес, который является «точкой входа» для кода в ELF (среди прочего). ELF может указать любой адрес для перехода к загрузчику, чтобы начать запуск программы. В приложениях, созданных с помощью G CC, эта функция называется _start. _start затем устанавливает стек и выполняет любую другую инициализацию, необходимую для вызова __libc_start_main, которая является функцией lib c, которая может выполнить дополнительную настройку перед вызовом main main.

. пример функции запуска:

00000000000006c0 <_start>:


 6c0:   31 ed                   xor    %ebp,%ebp
 6c2:   49 89 d1                mov    %rdx,%r9
 6c5:   5e                      pop    %rsi
 6c6:   48 89 e2                mov    %rsp,%rdx
 6c9:   48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
 6cd:   50                      push   %rax
 6ce:   54                      push   %rsp
 6cf:   4c 8d 05 0a 02 00 00    lea    0x20a(%rip),%r8        # 8e0 <__libc_csu_fini>
 6d6:   48 8d 0d 93 01 00 00    lea    0x193(%rip),%rcx        # 870 <__libc_csu_init>
 6dd:   48 8d 3d 7c ff ff ff    lea    -0x84(%rip),%rdi        # 660 <main>
 6e4:   ff 15 f6 08 20 00       callq  *0x2008f6(%rip)        # 200fe0 <__libc_start_main@GLIBC_2.2.5>
 6ea:   f4                      hlt    
 6eb:   0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

Как видите, эта функция устанавливает значение стека и указатель базы стека. Следовательно, в этой функции нет действительного стекового фрейма. Для стекового фрейма даже не установлено ничего, кроме 0, пока вы не вызовете main (по крайней мере, этим компилятором)

Теперь важно увидеть, что стек был инициализирован в этом коде и загрузчик, это не продолжение стека ядра. Каждая программа имеет свой собственный стек, и все они отличаются от стека ядра. Фактически, даже если вы знали адрес стека в ядре, вы не могли читать из него или записывать в него из вашей программы, потому что ваш процесс может видеть только те страницы памяти, которые были выделены ему MMU, который является контролируется ядром.

Просто чтобы уточнить, когда я сказал, что стек "создан", я не имел в виду, что он был выделен. Я имею в виду только то, что указатель стека и база стека установлены здесь. Память для него выделяется при загрузке программы, и страницы добавляются в нее по мере необходимости, когда сбой страницы инициируется записью в нераспределенную часть стека. При входе в start очевидно, что существует некоторый стек в качестве доказательства из инструкции pop rsi, однако это не стек окончательных значений стека, которые будут использоваться программой. это переменные, которые настраиваются в _start (возможно, они будут изменены в __libc_start_main позже, я не уверен.)

1 голос
/ 10 января 2020

Однако это будет означать, что когда ядро ​​Unix вызывает основную функцию, база стека должна указывать на повторный вход в функцию ядра, которая вызывает main.

Абсолютно нет.

Этот конкретный вопрос охватывает детали для MacOS, пожалуйста, посмотрите. В любом случае main, скорее всего, возвращается к функции запуска стандартной библиотеки C. Детали реализации различаются в разных операционных системах * nix.

Поэтому при переходе по «* rbp-1» в C -Коде необходимо повторно войти в основную функцию.

У вас нет гарантии, что будет генерировать компилятор и как будет выглядеть состояние rsp / rbp при вызове функции rbp(). Вы не можете делать такие предположения.

Кстати, если вы хотите получить доступ к записи стека в 64-битной системе, вы должны делать это с шагом + -8 (поэтому rbp+8 rbp-8 rsp+8 rsp-8 соответственно).

...