Статья предназначена для тех, кто хочет понять процесс загрузки программ в 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 00 call 0x8048450 <__do_global_ctors_aux>
(gdb) si
0x08048450 in __do_global_ctors_aux ()
=> 0x08048450 <__do_global_ctors_aux+0>: 55 push %ebp
Здесь я ввел
r
, чтобы запустить программу и достичь точки останова. Очередной командой для gdb
стала si
, инструкция шага, указывающая отладчику шагнуть на одну инструкцию вперед. Теперь мы вошли в
_do_global_ctors_aux
. Далее вы заметите моменты, когда я будто бы не ввожу команды для gdb
, хотя это не так. Дело в том, что при нажатии Ввода отладчик повторяет последнюю инструкцию. То есть, если я нажму Ввод сейчас, то еще раз выполню si
.(gdb)
0x08048451 in __do_global_ctors_aux ()
=> 0x08048451 <__do_global_ctors_aux+1>: 89 e5 mov %esp,%ebp
(gdb)
0x08048453 in __do_global_ctors_aux ()
=> 0x08048453 <__do_global_ctors_aux+3>: 53 push %ebx
(gdb)
0x08048454 in __do_global_ctors_aux ()
=> 0x08048454 <__do_global_ctors_aux+4>: 83 ec 04 sub $0x4,%esp
(gdb)
0x08048457 in __do_global_ctors_aux ()
Хорошо, с прологом мы закончили, пришло время реального кода.
(gdb)
=> 0x08048457 <__do_global_ctors_aux+7>: a1 14 9f 04 08 mov 0x8049f14,%eax
(gdb)
0x0804845c in __do_global_ctors_aux ()
=> 0x0804845c <__do_global_ctors_aux+12>: 83 f8 ff cmp $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 13 je 0x8048474 <__do_global_ctors_aux+36>
(gdb)
0x08048461 in __do_global_ctors_aux ()
=> 0x08048461 <__do_global_ctors_aux+17>: bb 14 9f 04 08 mov $0x8049f14,%ebx
(gdb)
0x08048466 in __do_global_ctors_aux ()
=> 0x08048466 <__do_global_ctors_aux+22>: 66 90 xchg %ax,%ax
(gdb)
0x08048468 in __do_global_ctors_aux ()
=> 0x08048468 <__do_global_ctors_aux+24>: 83 eb 04 sub $0x4,%ebx
(gdb)
0x0804846b in __do_global_ctors_aux ()
=> 0x0804846b <__do_global_ctors_aux+27>: ff d0 call *%eax
(gdb)
a_constructor () at prog2.c:3
3 void __attribute__ ((constructor)) a_constructor() {
=> 0x080483b4 <a_constructor+0>: 55 push %ebp
0x080483b5 <a_constructor+1>: 89 e5 mov %esp,%ebp
0x080483b7 <a_constructor+3>: 83 ec 18 sub $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 08 movl $0x80484a5,(%esp)
0x080483c1 <a_constructor+13>: e8 2a ff ff ff call 0x80482f0 <puts@plt>
Мы переместили адрес строки
a_constructor
в стек в качестве аргумента для printf
, но он вызывает puts, поскольку компилятор догадался, что нас интересует только puts
. (gdb) n
a_constructor
5 }
=> 0x080483c6 <a_constructor+18>: c9 leave
0x080483c7 <a_constructor+19>: c3 ret
Раз мы трассируем программу, то она, естественно, выполняется, в связи с чем выше мы видим вывод
a_constructor
. Закрывающая скобка }
соответствует эпилогу, поэтому он выводится сейчас. К слову отмечу, если вам не знакома инструкция leave
, то выполняет она то же, что и: movl %ebp, %esp
popl %ebp
Очередной шаг выводит нас из функции с возвращением ее результата. Здесь мне потребуется снова переключиться на
si
.(gdb) n
0x0804846d in __do_global_ctors_aux ()
=> 0x0804846d <__do_global_ctors_aux+29>: 8b 03 mov (%ebx),%eax
(gdb) si
0x0804846f in __do_global_ctors_aux ()
=> 0x0804846f <__do_global_ctors_aux+31>: 83 f8 ff cmp $0xffffffff,%eax
(gdb)
0x08048472 in __do_global_ctors_aux ()
=> 0x08048472 <__do_global_ctors_aux+34>: 75 f4 jne 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 04 add $0x4,%esp
(gdb)
0x08048477 in __do_global_ctors_aux ()
=> 0x08048477 <__do_global_ctors_aux+39>: 5b pop %ebx
(gdb)
0x08048478 in __do_global_ctors_aux ()
=> 0x08048478 <__do_global_ctors_aux+40>: 5d pop %ebp
(gdb)
0x08048479 in __do_global_ctors_aux ()
=> 0x08048479 <__do_global_ctors_aux+41>: c3 ret
(gdb)
0x080482bc in _init ()
=> 0x080482bc <_init+44>: 58 pop %eax
Заметьте, что мы снова вернулись в
_init
. (gdb)
0x080482bd in _init ()
=> 0x080482bd <_init+45>: 5b pop %ebx
(gdb)
0x080482be in _init ()
=> 0x080482be <_init+46>: c9 leave
(gdb)
0x080482bf in _init ()
=> 0x080482bf <_init+47>: c3 ret
(gdb)
0x080483f9 in __libc_csu_init ()
=> 0x080483f9 <__libc_csu_init+25>: 8d bb 1c ff ff ff lea -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
$
Конец
Еще раз продемонстрирую вам весь путь, который мы прошли, только теперь он должен быть вам уже более понятен.