Пошаговый запуск программы в Linux x86, или как добраться до main()?

от автора

Статья предназначена для тех, кто хочет понять процесс загрузки программ в Linux. В частности, здесь пойдет речь о динамической загрузке файлов ELF x86. На основе изложенной информации вы сможете лучше понять, как устранять проблемы, возникающие в программе еще до запуска main.

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

Вот предстоящий нам маршрут:


Схема получена с помощью dot-фильтра, используемого для рисования направленных графов

К концу статьи вам все это станет понятно.

Как мы попадаем в main?

Мы соберем простейшую программу Си с пустой функциейmain, а затем рассмотрим ее дизассемблированную версию, чтобы понять весь путь запуска. В ходе этого процесса вы увидите, что первым делом выполняется линкуемая с каждой программой функция _start, которая в конечном итоге приводит к выполнению main.

int main() { }

Если хотите, можете сохранить копию этой программы как prog1.c и повторять за мной. Первым делом я выполню ее сборку:

gcc -ggdb -o prog1 prog1.c

Прежде, чем переходить к отладке последующей версии этой программы (prog2) в gdb, мы ее дизассемблируем и узнаем некоторые детали о процессе запуска. Я покажу вам вывод objdump -d prog1, но не в порядке фактического вывода, а в порядке его выполнения. (В идеале вам следует сделать это самим. Например, сохранить копию с помощью objdump -d prog1 >prog1.dump, чтобы потом просмотреть ее в привычном редакторе).

Для начала разберемся, как мы попадаем в _start

При запуске программы оболочка или GUI вызывают execve(), которая выполняет системный вызов execve(). Если вы хотите побольше узнать об этом системном вызове, то просто введите в оболочке man execve. Он находится в разделе 2 мануала вместе со всеми остальными системными вызовами. Если кратко, то он настраивает стек и передает в него argc, argv и envp.

Дескрипторы файлов 0, 1 и 2 (stdin, stdout, stderr) остаются со значениями, установленными для них оболочкой. Загрузчик проделывает много работы, настраивая релокации и, как мы увидим позднее, вызывая пре-инициализаторы. Когда все готово, управление передается программе через вызов _start().

Вот соответствующий раздел objdump -d prog1:

080482e0 <_start>: 80482e0:       31 ed                   xor    %ebp,%ebp 80482e2:       5e                      pop    %esi 80482e3:       89 e1                   mov    %esp,%ecx 80482e5:       83 e4 f0                and    $0xfffffff0,%esp 80482e8:       50                      push   %eax 80482e9:       54                      push   %esp 80482ea:       52                      push   %edx 80482eb:       68 00 84 04 08          push   $0x8048400 80482f0:       68 a0 83 04 08          push   $0x80483a0 80482f5:       51                      push   %ecx 80482f6:       56                      push   %esi 80482f7:       68 94 83 04 08          push   $0x8048394 80482fc:       e8 c3 ff ff ff          call   80482c4 <__libc_start_main@plt> 8048301:       f4                      hlt

Операция XOR элемента с самим собой устанавливает этот элемент на нуль. Поэтому xor %ebp,%ebp устанавливает %ebp на нуль. Это предполагается спецификацией ABI (Application Binary Interface) для обозначения внешнего фрейма.

Далее мы извлекаем верхний элемент стека. Здесь у нас на входе argc, argv и envp, значит операция извлечения отправляет argc в %esi. Мы планируем просто сохранить его и вскоре вернуть обратно в стек. Так как argc мы извлекли, %esp теперь указывает на argv. Операция mov помещает argv в %ecx, не перемещая указатель стека.

Теперь мы выполняем для указателя стека операцию and с маской, которая обнуляет нижние четыре бита. В зависимости от того, где находился указатель, он переместится ниже на величину от 0 до 15 байт, что приведет к выравниванию кратно 16 байтам. За счет подобного выравнивания элементов стека повышается эффективность обработки памяти и кэша. В частности, это необходимо для SSE (Streaming SIMD Extensions), инструкций, способных одновременно обрабатывать вектора с плавающей точкой одинарной точности.

В конкретно этом случае %esp на входе в _start имел значение 0xbffff770. После того, как мы извлекли argc, %esp стал 0xbffff774, то есть сместился на более высокий адрес (добавление элементов в стек ведет к перемещению по памяти вниз, а их извлечение – вверх). После выполнения and значение указателя стека вновь стало 0xbffff770.

Далее устанавливаем значения для вызова __libc_start_main

Теперь мы начинаем передавать в стек аргументы для _libc_start_main. Первый, %eax, является мусором, который передается только потому, что в стек мы собираемся поместить 7 элементов, а для 16-байтового выравнивания требуется 8-й. Использоваться он не будет. Сама функция _libc_start_main линкуется из glibc. В дереве исходного кода glibc она находится в файле csu/libc-start.c. Определяется _libc_start_main так:

int __libc_start_main(  int (*main) (int, char * *, char * *),     int argc, char * * ubp_av,     void (*init) (void),     void (*fini) (void),     void (*rtld_fini) (void),     void (* stack_end));

Итак, мы ожидаем, что _start передаст обозначенные аргументы в стек в обратном порядке до вызова _libc_start_main.

Содержимое стека перед вызовом __libc_start_main

__libc_csu_fini линкуется в наш код из glibc и находится в файле csu/elf-init.c дерева исходного кода. Это деструктор нашей программы на уровне Си, и чуть позже я разберу его подробно.

Так, а где переменные среды?

void __libc_init_first(int argc, char *arg0, ...) {     char **argv = &arg0, **envp = &argv[argc + 1];     __environ = envp;     __libc_init (argc, argv, envp); }

Вы заметили, что мы не получили из стека envp, указатель на наши переменные среды? Среди аргументов _libc_start_main его тоже нет. Но мы знаем, что main называется int main(int argc, char** argv, char** envp), так в чем же дело?

Что ж, _libc_start_main вызывает _libc_init_first, которая с помощью секретной внутренней информации находит переменные среды сразу после завершающего нуля вектора аргументов, после чего устанавливает глобальную переменную _environ, которую _libc_start_main при необходимости использует впоследствии, в том числе в вызовах main.

После установки envp функция _libc_start_main использует тот же трюк и…вуаля! Сразу за завершающим нулем в конце массива envp находится очередной вектор, а именно вспомогательный вектор ELF, который загрузчик использует для передачи процессу определенной информации. Для просмотра его содержимого достаточно просто установить перед запуском программы переменную среды LD_SHOW_AUXV=1. Вот результат для нашей prog1:

$ LD_SHOW_AUXV=1 ./prog1 AT_SYSINFO:      0xe62414 AT_SYSINFO_EHDR: 0xe62000 AT_HWCAP:    fpu vme de pse tsc msr pae mce cx8 apic              mtrr pge mca cmov pat pse36 clflush dts              acpi mmx fxsr sse sse2 ss ht tm pbe AT_PAGESZ:       4096 AT_CLKTCK:       100 AT_PHDR:         0x8048034 AT_PHENT:        32 AT_PHNUM:        8 AT_BASE:         0x686000 AT_FLAGS:        0x0 AT_ENTRY:        0x80482e0 AT_UID:          1002 AT_EUID:         1002 AT_GID:          1000 AT_EGID:         1000 AT_SECURE:       0 AT_RANDOM:       0xbff09acb AT_EXECFN:       ./prog1 AT_PLATFORM:     i686

Разве не интересно? Тут полно всяческой информации.

  • Здесь мы видим AT_ENTRY, представляющую адрес _start, где находится наш userid, действующий userid и groupid.
  • Очевидно, что используется платформа 686, а частота times() равна 100 тактов/с.
  • AT_PHDR указывает расположение ELF-заголовка программы, в котором хранится информация о нахождении всех сегментов этой программы в памяти, а также о записях релокаций и всем остальном, что нужно знать загрузчику.
  • AT_PHENT – это просто количество байт в записи заголовка.

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

__libc_start_main в общем

На этом я закончу разбор деталей _libc_start_main и лишь добавлю, что в общем он:

  • Реализует функционал безопасности с помощью вызововsetuid и stgid;
  • Запускает потоковую обработку;
  • Регистрирует аргументы fini (для нашей программы) и аргументы rtld_fini (для загрузчика среды выполнения), которые запустит at_exit для выполнения процедур очистки программы и загрузчика.
  • Вызывает аргумент init;
  • Вызывает main с передаваемыми ей аргументами argc и argv, а также аргументом global_environ, о чем я писал выше;
  • Вызывает exit с возвращаемым main значением.

Вызов аргумента init

Аргумент init для _libc_start_main устанавливается на _libc_csu_init, который также линкуется в наш код. Он компилируется из программы Си, расположенной в файле csu/elf-init.c дерева исходного кода glibc, и линкуется в нашу программу. Его код Си похож на (но содержит намного больше #ifdef)…

Конструктор нашей программы

void __libc_csu_init (int argc, char **argv, char **envp) {    _init ();    const size_t size = __init_array_end - __init_array_start;   for (size_t i = 0; i < size; i++)       (*__init_array_start [i]) (argc, argv, envp); }

_libc_csu_init для нашей программы очень важен, так как конструирует ее исполняемый файл. Я уже слышу, как вы говорите: «Это же не C++!». Все верно, но принцип конструкторов и деструкторов не принадлежит к C++ и предшествовал этому языку.

Наш исполняемый файл и любой другой его аналог получает на уровне Си конструктор _libc_csu_init и деструктор _libc_csu_fini. Внутри конструктора, как вы увидите далее, исполняемый файл ищет глобальные конструкторы уровня Си и вызывает любой, который найдет. В программе Си они также могут присутствовать, и в ходе статьи я это продемонстрирую. Хотя, если для вас будет удобнее, можете называть их инициализаторы и финализаторы. Вот код ассемблера, сгенерированный для _libc_csu_init:

080483a0 <__libc_csu_init>:  80483a0:       55                      push   %ebp  80483a1:       89 e5                   mov    %esp,%ebp  80483a3:       57                      push   %edi  80483a4:       56                      push   %esi  80483a5:       53                      push   %ebx  80483a6:       e8 5a 00 00 00          call   8048405 <__i686.get_pc_thunk.bx>  80483ab:       81 c3 49 1c 00 00       add    $0x1c49,%ebx  80483b1:       83 ec 1c                sub    $0x1c,%esp  80483b4:       e8 bb fe ff ff          call   8048274 <_init>  80483b9:       8d bb 20 ff ff ff       lea    -0xe0(%ebx),%edi  80483bf:       8d 83 20 ff ff ff       lea    -0xe0(%ebx),%eax  80483c5:       29 c7                   sub    %eax,%edi  80483c7:       c1 ff 02                sar    $0x2,%edi  80483ca:       85 ff                   test   %edi,%edi  80483cc:       74 24                   je     80483f2 <__libc_csu_init+0x52>  80483ce:       31 f6                   xor    %esi,%esi  80483d0:       8b 45 10                mov    0x10(%ebp),%eax  80483d3:       89 44 24 08             mov    %eax,0x8(%esp)  80483d7:       8b 45 0c                mov    0xc(%ebp),%eax  80483da:       89 44 24 04             mov    %eax,0x4(%esp)  80483de:       8b 45 08                mov    0x8(%ebp),%eax  80483e1:       89 04 24                mov    %eax,(%esp)  80483e4:       ff 94 b3 20 ff ff ff    call   *-0xe0(%ebx,%esi,4)  80483eb:       83 c6 01                add    $0x1,%esi  80483ee:       39 fe                   cmp    %edi,%esi  80483f0:       72 de                   jb     80483d0 <__libc_csu_init+0x30>  80483f2:       83 c4 1c                add    $0x1c,%esp  80483f5:       5b                      pop    %ebx  80483f6:       5e                      pop    %esi  80483f7:       5f                      pop    %edi  80483f8:       5d                      pop    %ebp  80483f9:       c3                      ret

Что такое thunk?

Говорить здесь особо не о чем, но я подумал, что вы захотите это увидеть. Функция get_pc_thunk весьма интересна. Она вызывается для настройки позиционно-независимого кода. Чтобы все сработало, указатель базы должен иметь адрес GLOBAL_OFFSET_TABLE. Соответствующий код выглядел так:

__get_pc_thunk_bx: movel (%esp),%ebx return
push %ebx call __get_pc_thunk_bx add  $_GLOBAL_OFFSET_TABLE_,%ebx

Посмотрим на происходящее подробнее. Вызов _get_pc_thunk_bx, как и любой другой, помещает в стек адрес следующей функции, чтобы при возвращении выполнение продолжилось с очередной инструкции. В данном случае нам нужен тот самый адрес. Значит, в _get_pc_thunk_bx мы копируем адрес возврата из стека в %ebx. Когда происходит возврат, очередная инструкция прибавляет к нему _GLOBAL_OFFSET_TABLE_, разрешаясь в разницу между текущим адресом и глобальной таблицей смещений, используемую позиционно-независимым кодом.

В этой таблице хранится набор указателей на данные, к которым мы хотим обратиться, и нам лишь нужно знать их смещения. При этом загрузчик сам фиксирует для нас нужный адрес. Для обращения к процедурам существует аналогичная таблица. Было бы поистине утомительно программировать подобным образом в ассемблере, но можно просто написать нужный код на Си/С++ и передать аргумент -pic компилятору, который сделает это автомагически.
Встречая данный код в ассемблере, вы можете сделать вывод, что исходник был скомпилирован с флагом -pic.

Но что это за цикл?

Цикл из _libc_csu_init мы рассмотрим сразу после вызова init(), который фактически вызывает _init. Пока же просто имейте ввиду, что он вызывает для нашей программы любые инициализаторы уровня Си.

Вызов _init

Хорошо. Загрузчик передал управление _start, которая вызвала _libc_start_main, которая вызвала _libc_csu_init, которая теперь вызывает _init:

08048274 <_init>:  8048274:       55                      push   %ebp  8048275:       89 e5                   mov    %esp,%ebp  8048277:       53                      push   %ebx  8048278:       83 ec 04                sub    $0x4,%esp  804827b:       e8 00 00 00 00          call   8048280 <_init+0xc>  8048280:       5b                      pop    %ebx  8048281:       81 c3 74 1d 00 00       add    $0x1d74,%ebx        (.got.plt)  8048287:       8b 93 fc ff ff ff       mov    -0x4(%ebx),%edx  804828d:       85 d2                   test   %edx,%edx  804828f:       74 05                   je     8048296 <_init+0x22>  8048291:       e8 1e 00 00 00          call   80482b4 <__gmon_start__@plt>  8048296:       e8 d5 00 00 00          call   8048370 <frame_dummy>  804829b:       e8 70 01 00 00          call   8048410 <__do_global_ctors_aux>  80482a0:       58                      pop    %eax  80482a1:       5b                      pop    %ebx  80482a2:       c9                      leave  80482a3:       c3                      ret

Начинается она с регулярного соглашения о вызовах Си

Если вы хотите побольше узнать об этом соглашении, почитайте Basic Assembler Debugging with GDB. Если коротко, то мы сохраняем указатель базы вызывающего компонента в стеке и направляем указатель базы на верхушку стека, после чего резервируем место для своего рода 4-байтовой локальной переменной.

Интересен здесь первый вызов. Его задача во многом аналогична вызову get_pc_thunk, который мы видели ранее. Если посмотреть внимательно, то он направлен к следующему по порядку адресу. Это переносит нас к очередному адресу, как если бы мы просто продолжили, но при этом в качестве побочного эффекта данный адрес оказывается в стеке. Он помещается в %ebx, а затем используется для установки доступа к глобальной таблице доступа.

Покажи мне свой профиль

Далее мы захватываем адрес gmon_start. Если он равен нулю, то мы его просто проскакиваем. В противном случае он вызывается для запуска профилирования. В этом случае происходит запуск процедуры для начала профилирования и вызов at_exit, чтобы по завершению сработала другая процедура и записала gmon.out.

Вызов frame_dummy

В любом из случаев дальше мы вызываем frame_dummy. Вообще нам нужно вызвать _register_frame_info, а frame_dummy просто устанавливает для этой функции аргументы. Конечная цель – настроить разворачивание стековых фреймов для обработки исключений. Это интересно, но к нашему разбору не относится, и в данном случае все равно не используется.

Переходим к конструкторам!

В завершении мы вызываем _do_global_ctors_aux. Если у вас сложности с программой, которые возникают до запуска main, то искать, возможно, нужно именно здесь. Конечно, сюда помещаются конструкторы для глобальных объектов С++, но кроме них тут могут находиться и другие компоненты.

Создадим пример

Теперь давайте изменим prog1, создав prog2. Самая интересная часть – это __attribute__ ((constructor)), который сообщает gcc, что компоновщик должен поместить соответствующий указатель в таблицу, используемую _do_global_ctors_aux. Как видите, наш фиктивный конструктор выполняется. (компилятор заполняет _FUNCTION_ именем функции. Это магия gcc).

#include <stdio.h>  void __attribute__ ((constructor)) a_constructor() {     printf("%s\n", __FUNCTION__); }  int main() {     printf("%s\n",__FUNCTION__); }
$ ./prog2 a_constructor main $

_init в prog2 практически не изменяется

Чуть позже мы подключим к процессу gdb и разберем эту программу. Ну а пока же рассмотрим ее _init.

08048290 <_init>:  8048290:       55                      push   %ebp  8048291:       89 e5                   mov    %esp,%ebp  8048293:       53                      push   %ebx  8048294:       83 ec 04                sub    $0x4,%esp  8048297:       e8 00 00 00 00          call   804829c <_init+0xc>  804829c:       5b                      pop    %ebx  804829d:       81 c3 58 1d 00 00       add    $0x1d58,%ebx  80482a3:       8b 93 fc ff ff ff       mov    -0x4(%ebx),%edx  80482a9:       85 d2                   test   %edx,%edx  80482ab:       74 05                   je     80482b2 <_init+0x22>  80482ad:       e8 1e 00 00 00          call   80482d0 <__gmon_start__@plt>  80482b2:       e8 d9 00 00 00          call   8048390 <frame_dummy>  80482b7:       e8 94 01 00 00          call   8048450 <__do_global_ctors_aux>  80482bc:       58                      pop    %eax  80482bd:       5b                      pop    %ebx  80482be:       c9                      leave  80482bf:       c3                      ret

Как видите, адреса немного отличаются от prog1. Похоже, дополнительный элемент данных сместил все на 28 байт. Итак, здесь у нас имена двух функций, a_constructor (14 байт с завершающим нулем) и main (5 байт с завершающим нулем), а также две форматирующих строки %s\n (2*4 байта с символом переноса строки и завершающим нулем).

Итого получается 14+5+4+4 = 27. Хмм…одного не хватает. Хотя это просто предположение, проверять я не стал. Позже мы все равно сделаем остановку на вызове _do_global_ctors_aux, сделаем один шаг и проанализируем происходящее.

А вот и код, который будет вызван

Чисто в качестве подсказки приведу код для _do_global_ctors_aux, взятый из файла gcc/crtstuff.c исходного кода gcc.

__do_global_ctors_aux (void) {   func_ptr *p;   for (p = __CTOR_END__ - 1; *p != (func_ptr) -1; p--)     (*p) (); }

Как видите, он инициализирует p из глобальной переменной _CTOR_END_ и вычитает из нее 1. Напомню, что это арифметика указателей, а указатель указывает на функцию, значит в данном случае -1 приводит к смещению на один указатель функции назад или на 4 байта. Мы также увидим это в ассемблере.

Несмотря на то, что указатель не имеет значения -1 (приведение к указателю), мы вызовем функцию, на которую указываем, после чего снова переведем указатель функции назад. Очевидно, что таблица начинается с -1, после чего идет некоторое количество (возможно даже 0) указателей функции.

То же самое в ассемблере

Вот код ассемблера, соответствующий полученному из objdump -d результату. Прежде, чем переходить к трассировке с помощью отладчика, мы внимательно по нему пройдемся, чтобы вам было понятнее.

08048450 <__do_global_ctors_aux>:  8048450:       55                      push   %ebp  8048451:       89 e5                   mov    %esp,%ebp  8048453:       53                      push   %ebx  8048454:       83 ec 04                sub    $0x4,%esp  8048457:       a1 14 9f 04 08          mov    0x8049f14,%eax  804845c:       83 f8 ff                cmp    $0xffffffff,%eax  804845f:       74 13                   je     8048474 <__do_global_ctors_aux+0x24>  8048461:       bb 14 9f 04 08          mov    $0x8049f14,%ebx  8048466:       66 90                   xchg   %ax,%ax  8048468:       83 eb 04                sub    $0x4,%ebx  804846b:       ff d0                   call   *%eax  804846d:       8b 03                   mov    (%ebx),%eax  804846f:       83 f8 ff                cmp    $0xffffffff,%eax  8048472:       75 f4                   jne    8048468 <__do_global_ctors_aux+0x18>  8048474:       83 c4 04                add    $0x4,%esp  8048477:       5b                      pop    %ebx  8048478:       5d                      pop    %ebp  8048479:       c3                      ret

Сначала пролог

Здесь у нас типичный пролог с добавлением резервирования %ebx, так как мы собираемся использовать его в функции. Помимо этого, мы резервируем место для указателя p. Вы заметите, что несмотря на резервирование под него места в стеке, хранить мы его там не будем. Вместо этого p будет размещаться в %ebx, а *p в %eax.

Далее подготовка к циклу

Похоже, произошла оптимизация. Вместо загрузки _CTOR_END_ с последующим вычитанием из него 1 и разыменовыванием мы переходим далее и загружаем *(__CTOR_END__ - 1), который представляет непосредственное значение 0x8049f14. Его значение мы помещаем в %eax (помните, что инструкция $0x8049f14 означала бы помещение этого значения, а ее вариант без $ — помещение содержимого этого адреса).

Следом мы сравниваем это первое значение с -1, и если они равны, то заканчиваем и переходим к адресу 0x8049f14, где очищаем стек, извлекая все сохраненные в нем элементы и делая возврат.

Предполагая, что в таблице функций есть хотя бы один элемент, мы также перемещаем непосредственное значение $8049f14 в %ebx, который является нашим указателем функции f, после чего выполняем xchg %ax,%ax.

Что это вообще такое? Эта команда используется в качестве NOP (No OPeration) в 16- и 32-битных x86. По факту она ничего не делает. В нашем случае ее задача в том, чтобы цикл (верхняя его часть – это вычитание на следующей строке) начинался не с 8048466, а с 8048468. Смысл здесь в выравнивании начала цикла с 4-байтовой границей, в результате чего весь цикл с большей вероятностью впишется в одну строку кэша, не разбиваясь на две. Это все ускорит.

И вот мы у вершины цикла

Далее мы вычитаем 4 из %ebx, подготавливаясь к очередному циклу, вызываем функцию, адрес которой получили в %eax, перемещаем следующий указатель функции в %eax и сравниваем его с -1. Если они не равны, возвращаемся к операции вычитания и повторяем цикл.

Эпилог

В противном случае мы достигаем эпилога функции и возвращаемся к _init, которая сразу достигает своего эпилога и возвращается к _libc_csu_init_, о котором вы уже наверняка забыли. Здесь все еще остается один цикл для завершения, но сначала…

Как я и обещал, мы займемся отладкой prog2.

Напомню, что gdb всегда показывает очередную строку или инструкцию, которая будет выполнена.

$ !gdb gdb prog2 Reading symbols from /home/patrick/src/asm/prog2...done. (gdb) set disassemble-next-line on (gdb) b *0x80482b7 Breakpoint 1 at 0x80482b7

Мы запустили программу в отладчике, включили disassemble-next-line, чтобы он всегда показывал очередную строку в дизассемблированном виде, и установили точку останова на строку в _init, где будем вызывать _do_global_ctors_aux.

(gdb) r Starting program: /home/patrick/src/asm/prog2   Breakpoint 1, 0x080482b7 in _init () => 0x080482b7 <_init+39>: e8 94 01 00 00call   0x8048450 <__do_global_ctors_aux> (gdb) si 0x08048450 in __do_global_ctors_aux () => 0x08048450 <__do_global_ctors_aux+0>: 55push   %ebp

Здесь я ввел r, чтобы запустить программу и достичь точки останова. Очередной командой для gdb стала si, инструкция шага, указывающая отладчику шагнуть на одну инструкцию вперед.

Теперь мы вошли в _do_global_ctors_aux. Далее вы заметите моменты, когда я будто бы не ввожу команды для gdb, хотя это не так. Дело в том, что при нажатии Ввода отладчик повторяет последнюю инструкцию. То есть, если я нажму Ввод сейчас, то еще раз выполню si.

(gdb) 0x08048451 in __do_global_ctors_aux () => 0x08048451 <__do_global_ctors_aux+1>: 89 e5mov    %esp,%ebp (gdb)  0x08048453 in __do_global_ctors_aux () => 0x08048453 <__do_global_ctors_aux+3>: 53push   %ebx (gdb)  0x08048454 in __do_global_ctors_aux () => 0x08048454 <__do_global_ctors_aux+4>: 83 ec 04sub    $0x4,%esp (gdb)  0x08048457 in __do_global_ctors_aux ()

Хорошо, с прологом мы закончили, пришло время реального кода.

(gdb) => 0x08048457 <__do_global_ctors_aux+7>: a1 14 9f 04 08mov    0x8049f14,%eax (gdb)  0x0804845c in __do_global_ctors_aux () => 0x0804845c <__do_global_ctors_aux+12>: 83 f8 ffcmp    $0xffffffff,%eax (gdb) p/x $eax $1 = 0x80483b4

После загрузки указателя мне стало любопытно, и я ввел p/x $eax, то есть попросил gdb вывести hex-содержимое регистра %eax. Это не -1, значит можно предположить, что цикл мы проходим.

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

(gdb) si 0x0804845f in __do_global_ctors_aux () => 0x0804845f <__do_global_ctors_aux+15>: 74 13je     0x8048474 <__do_global_ctors_aux+36> (gdb)  0x08048461 in __do_global_ctors_aux () => 0x08048461 <__do_global_ctors_aux+17>: bb 14 9f 04 08mov    $0x8049f14,%ebx (gdb)  0x08048466 in __do_global_ctors_aux () => 0x08048466 <__do_global_ctors_aux+22>: 66 90xchg   %ax,%ax (gdb)  0x08048468 in __do_global_ctors_aux () => 0x08048468 <__do_global_ctors_aux+24>: 83 eb 04sub    $0x4,%ebx (gdb)  0x0804846b in __do_global_ctors_aux () => 0x0804846b <__do_global_ctors_aux+27>: ff d0call   *%eax (gdb)  a_constructor () at prog2.c:3 3void __attribute__ ((constructor)) a_constructor() { => 0x080483b4 <a_constructor+0>: 55push   %ebp    0x080483b5 <a_constructor+1>: 89 e5mov    %esp,%ebp    0x080483b7 <a_constructor+3>: 83 ec 18sub    $0x18,%esp

Вот здесь очень интересно. Мы шагнули в вызов и теперь находимся в функции a_constructor. Поскольку у gdb есть для нее исходный код, он показывает исходник Си для следующей строки. А так как я включил disassemble-next-line, он также покажет нам соответствующий код ассемблера.

В данном случае это пролог функции, и мы получаем все его три строки. Разве не интересно? Далее я переключусь на команду n (next), потому что скоро покажется printf. Первая n пропустит пролог, вторая printf, а третья эпилог. Если вас когда-нибудь интересовало, почему при пошаговом продвижении с помощью gdb нужно делать дополнительный шаг в начале и конце функции, то теперь вы знаете почему.

(gdb) n 4    printf("%s\n", __FUNCTION__); => 0x080483ba <a_constructor+6>: c7 04 24 a5 84 04 08movl   $0x80484a5,(%esp)    0x080483c1 <a_constructor+13>: e8 2a ff ff ffcall   0x80482f0 <puts@plt>

Мы переместили адрес строки a_constructor в стек в качестве аргумента для printf, но он вызывает puts, поскольку компилятор догадался, что нас интересует только puts.

 (gdb) n a_constructor 5} => 0x080483c6 <a_constructor+18>: c9leave      0x080483c7 <a_constructor+19>: c3ret    

Раз мы трассируем программу, то она, естественно, выполняется, в связи с чем выше мы видим вывод a_constructor. Закрывающая скобка } соответствует эпилогу, поэтому он выводится сейчас. К слову отмечу, если вам не знакома инструкция leave, то выполняет она то же, что и:

    movl %ebp, %esp     popl %ebp

Очередной шаг выводит нас из функции с возвращением ее результата. Здесь мне потребуется снова переключиться на si.

(gdb) n 0x0804846d in __do_global_ctors_aux () => 0x0804846d <__do_global_ctors_aux+29>: 8b 03mov    (%ebx),%eax (gdb) si 0x0804846f in __do_global_ctors_aux () => 0x0804846f <__do_global_ctors_aux+31>: 83 f8 ffcmp    $0xffffffff,%eax (gdb)  0x08048472 in __do_global_ctors_aux () => 0x08048472 <__do_global_ctors_aux+34>: 75 f4jne    0x8048468 <__do_global_ctors_aux+24> (gdb) p/x $eax $2 = 0xffffffff

Мне снова стало интересно, и я решил еще раз проверить значение указателя функции. На этот раз он равен -1, значит из цикла мы выходим.

(gdb) si 0x08048474 in __do_global_ctors_aux () => 0x08048474 <__do_global_ctors_aux+36>: 83 c4 04add    $0x4,%esp (gdb)  0x08048477 in __do_global_ctors_aux () => 0x08048477 <__do_global_ctors_aux+39>: 5bpop    %ebx (gdb)  0x08048478 in __do_global_ctors_aux () => 0x08048478 <__do_global_ctors_aux+40>: 5dpop    %ebp (gdb)  0x08048479 in __do_global_ctors_aux () => 0x08048479 <__do_global_ctors_aux+41>: c3ret     (gdb)  0x080482bc in _init () => 0x080482bc <_init+44>: 58pop    %eax

Заметьте, что мы снова вернулись в _init.

 (gdb)  0x080482bd in _init () => 0x080482bd <_init+45>: 5bpop    %ebx (gdb)  0x080482be in _init () => 0x080482be <_init+46>: c9leave   (gdb)  0x080482bf in _init () => 0x080482bf <_init+47>: c3ret     (gdb)  0x080483f9 in __libc_csu_init () => 0x080483f9 <__libc_csu_init+25>: 8d bb 1c ff ff fflea    -0xe4(%ebx),%edi (gdb) q A debugging session is active.  Inferior 1 [process 17368] will be killed.  Quit anyway? (y or n) y $

Обратите внимание, что мы перепрыгнули обратно к _libc_csu_init, и здесь я ввел q для выхода из gdb. Вот и вся отладка, которую я обещал.

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

Возвращаемся в __libc_csu_init__

Поскольку мы итак провели немало времени за работой с циклом в ассемблере, а ассемблерный код для этого конструктора еще более утомителен, то я оставлю эту задачу для тех, кому она будет интересна. Просто напомню, как он выглядит в Си:

void __libc_csu_init (int argc, char **argv, char **envp) {    _init ();    const size_t size = __init_array_end - __init_array_start;   for (size_t i = 0; i < size; i++)       (*__init_array_start [i]) (argc, argv, envp); }

Еще один цикл вызова функции

Что такое массив _init_? Я уж думал, вы и не спросите. На этом этапе вы также можете выполнять код. Поскольку идет он сразу после возвращения из _init, которая запускала наши конструкторы, то содержимое этого массива будет выполняться после завершения конструкторов. Вы можете сообщить компилятору, что хотите выполнить на этом этапе функцию, которая в результате получит те же аргументы, что и main.

void init(int argc, char **argv, char **envp) {  printf("%s\n", __FUNCTION__); }  __attribute__((section(".init_array"))) typeof(init) *__init = init;

Мы пока этого делать не будем, потому что есть и другие подобные моменты. Давайте просто вернем результат из _lib_csu_init. Помните, куда это нас приведет?

Мы вернемся аж к __libc_start_main__

Теперь он вызывает наш main, а результат передает в exit().

exit() выполняет функции, зарегистрированные с помощью at_exit в порядке их добавления. Затем она выполняет очередной цикл функций, на этот раз из массива fini. Далее она выполняет еще один цикл функций, теперь уже деструкторов. (В реальности она находится во вложенном цикле и работает с массивом списков функций, но поверьте мне, завершаются они именно в этом порядке). Вот смотрите.

Эта программа, hooks.c, связывает все воедино

#include <stdio.h>  void preinit(int argc, char **argv, char **envp) {  printf("%s\n", __FUNCTION__); }  void init(int argc, char **argv, char **envp) {  printf("%s\n", __FUNCTION__); }  void fini() {  printf("%s\n", __FUNCTION__); }  __attribute__((section(".init_array"))) typeof(init) *__init = init; __attribute__((section(".preinit_array"))) typeof(preinit) *__preinit = preinit; __attribute__((section(".fini_array"))) typeof(fini) *__fini = fini;  void  __attribute__ ((constructor)) constructor() {  printf("%s\n", __FUNCTION__); }  void __attribute__ ((destructor)) destructor() {  printf("%s\n", __FUNCTION__); }  void my_atexit() {  printf("%s\n", __FUNCTION__); }  void my_atexit2() {  printf("%s\n", __FUNCTION__); }  int main() {  atexit(my_atexit);  atexit(my_atexit2); }

Если собрать и выполнить эту программу (я зову ее hook.c), то выводом будет:

$ ./hooks preinit constructor init my_atexit2 my_atexit fini destructor $

Конец

Еще раз продемонстрирую вам весь путь, который мы прошли, только теперь он должен быть вам уже более понятен.


ссылка на оригинал статьи https://habr.com/ru/articles/588102/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *