Заголовки программы описывает загрузчику, как эффективно перенести двоичный файл ELF в память. Заголовки программы определяют ряд сегментов, которые определяют, как и где загружать данные файла ELF в память, нужен ли программе загрузчик среды выполнения для ее начальной загрузки, и ряд другой информации.
Рассмотрим на примере простейшей программы, которая выводит приветственное соощение на консоль. Допустим, у нас есть файл hello.s со следующим кодом:
.global _start // устанавливаем стартовый адрес программы _start: mov X0, #1 // 1 = StdOut - поток вывода ldr X1, =hello // строка для вывода на экран mov X2, #19 // длина строки mov X8, #64 // устанавливаем функцию Linux svc 0 // вызываем функцию Linux для вывода строки mov X0, #0 // Устанавливаем 0 как код возврата mov X8, #93 // код 93 представляет завершение программы svc 0 // вызываем функцию Linux для выхода из программы .data hello: .ascii "Hello METANIT.COM!\n" // данные для вывода
Пусть она скомпилирована в файл hello.so с помощью следующей последовательности команд:
aarch64-none-elf-as hello.s -o hello.o aarch64-none-elf-ld hello.o -o hello.so
Для получения заголовков программы скомпилированного файла передадим утилите readelf флаг -lW (флаг W добавляется для форматирования):
c:\arm>aarch64-none-elf-readelf hello.so -lW Elf file type is EXEC (Executable file) Entry point 0x400000 There are 2 program headers, starting at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x010000 0x0000000000400000 0x0000000000400000 0x000028 0x000028 R E 0x10000 LOAD 0x010028 0x0000000000410028 0x0000000000410028 0x000013 0x000013 RW 0x10000 Section to Segment mapping: Segment Sections... 00 .text 01 .data c:\arm>
Эта программа имеет два заголовка программы, которые имеют тип LOAD. Каждый тип заголовка указывает, как заголовок должен интерпретироваться.
После таблицы заголовков программы идет список сегментов, который показывает, какие логические разделы лежат внутри каждого данного сегмента. Например, здесь мы видим,
что первый сегмент LOAD содержит раздел .text
, а второй сегмент LOAD содержит раздел .data
.
В зависимости от различных условий файл может включать различное число и типы заголовков. Например, возьмем следующюю программу (пусть она располагается в файле app.s)
.global main main: // функция main STR LR,[SP,#-16]! // сохраняем в стеке текущий адрес из регистра LR LDR X0, =message // загружаем выводимую строку BL printf // вызываем стандартную функцию printf языка С MOV X0, #0 // код возврата LDR LR, [SP], #16 // извлекаем из стека адрес в регистр LR RET // выходим из функции .data message: .asciz "Hello METANIT.COM!\n"
Здесь фактически используется та же самая функциональность, только для вывода данных на консоль применяется внешняя функция printf()
библиотеки языка С. Скомпилируем
из этого файла исполняемый файл app.so
aarch64-none-linux-gnu-gcc -o app app.s -static
Для компиляции применяется компилятор gcc из пакета Arm GNU Toolchain aarch64-none-linux-gnu. После компиляции также прочитаем заголовки программы:
c:\arm>aarch64-none-linux-gnu-readelf app.so -lW Elf file type is EXEC (Executable file) Entry point 0x400500 There are 6 program headers, starting at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x07b240 0x07b240 R E 0x10000 LOAD 0x07c7d0 0x000000000048c7d0 0x000000000048c7d0 0x005878 0x00ad88 RW 0x10000 NOTE 0x000190 0x0000000000400190 0x0000000000400190 0x000020 0x000020 R 0x4 TLS 0x07c7d0 0x000000000048c7d0 0x000000000048c7d0 0x000018 0x000060 R 0x8 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10 GNU_RELRO 0x07c7d0 0x000000000048c7d0 0x000000000048c7d0 0x003830 0x003830 R 0x1 Section to Segment mapping: Segment Sections... 00 .note.ABI-tag .rela.plt .init .plt .text __libc_freeres_fn .fini .rodata .eh_frame .gcc_except_table 01 .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data __libc_subfreeres __libc_IO_vtables __libc_atexit .bss __libc_freeres_ptrs 02 .note.ABI-tag 03 .tdata .tbss 04 05 .tdata .init_array .fini_array .data.rel.ro .got c:\arm>
Здесь мы видим, что определено 6 заголовков, и для каждой из 6 сегментов определен ряд разделов, которые добавляются компилятором GCC. Рассмотрим основные типы заголовков.
Заголовок PHDR (Program HeaDeR) определеяет расположение и размер таблицы заголовков программы и связанные метаданные.
Заголовок INTERP хранит путь к файлу интерпретатора, который должен запускаться, чтобы затем в свою очередь запустить программу. Почти во всех случаях эта программа будет файлом загрузчика операционной системы. Использование внешнего загрузчика необходимо, если программа использует динамически подключаемые библиотеки. Внешний загрузчик управляет глобальной таблицей символов программы, обрабатывает соединение двоичных файлов и в конце обращается к точке входа программы. Заголовок INTERP имеет отношение только к исполняемым файлам; для разделяемых библиотек, загружаемых либо во время начальной загрузки программы, либо динамически во время выполнения программы, это значение игнорируется.
Заголовки LOAD сообщают операционной системе и загрузчику, как максимально эффективно загрузить данные программы в память. Каждый заголовок LOAD указывает загрузчику создать область памяти с заданным размером, правами доступа и критериями выравнивания, а также сообщает загрузчику, какие байты в файле следует поместить в эту область. Если мы снова посмотрим на заголовки LOAD из примера первой программы, то увидим, что программа определяет две области памяти, которые должны быть заполнены данными из файла ELF.
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x010000 0x0000000000400000 0x0000000000400000 0x000028 0x000028 R E 0x10000 LOAD 0x010028 0x0000000000410028 0x0000000000410028 0x000013 0x000013 RW 0x10000
Первая из этих областей начинается в файле с адреса 0x010000
и имеет длину 0x28 байт, требует выравнивания 64 КБ и доступна для чтения (флаг R) и выполнения (флаг E), но недоступна для записи.
Эта область должна быть заполнена байтами от 0 до 0x28 и загружается по адресу 0x400000.
Вторая область имеет длину 0x13 байт, располагается в файле по адресу 0x010028
и должна быть загружена по адресу 0x410028 - сразу после первого раздела. Эта область доступна для чтения (флаг R) и записи (флаг W).
Стоит отметить, что заголовки LOAD не обязательно заполняют всю определяемую ими область байтами из файла. Например, возьмем заголовки из второй программы:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x07b240 0x07b240 R E 0x10000 LOAD 0x07c7d0 0x000000000048c7d0 0x000000000048c7d0 0x005878 0x00ad88 RW 0x10000
Здесь второй заголовок LOAD заполняет только первые 0x5878 байт области размером 0xad88. Остальные байты будут заполнены нулями.
Сегменты LOAD в основном помогают операционной системе и загрузчику получать данные из файла ELF в память, они сопоставляются с логическими разделами двоичного файла. Например, возьмем пример первого файла:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x010000 0x0000000000400000 0x0000000000400000 0x000028 0x000028 R E 0x10000 LOAD 0x010028 0x0000000000410028 0x0000000000410028 0x000013 0x000013 RW 0x10000 Section to Segment mapping: Segment Sections... 00 .text 01 .data
Здесь мы увидим, что первый заголовок LOAD будет загружать данные, соответствующие логическому разделу .text
(то есть область с инструкциями).
А второй заголовок LOAD указывает загрузчику загрузить раздел .data
Мы можем открыть файл в любом hex-редакторе и увидеть, что в случае с первым файлом по адресу второго раздела LOAD, то есть фактически по адресу секции .data
располагается строка
"Hello METANIT.COM!"
Заголовок DYNAMIC используется загрузчиком для динамического связывания программ с общми библиотеками, которые используются в этих программах.
Заголовок NOTE используется для хранения информации, специфичной для производителя программы. Этот раздел по сути представляет словарь, где ключами выступают строки-названия метаданных, а их значениями - последовательности байтов. Например, во второй программе этот заголовок имел следующее содержимое:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align NOTE 0x000190 0x0000000000400190 0x0000000000400190 0x000020 0x000020 R 0x4
Здесь вряд ли можно что-то понять. Однако с помощью опции -n можно получить это же содержимое в более удобочитаемом виде:
c:\arm>aarch64-none-linux-gnu-readelf app.so -n Displaying notes found in: .note.ABI-tag Owner Data size Description GNU 0x00000010 NT_GNU_ABI_TAG (ABI version tag) OS: Linux, ABI: 3.7.0 c:\arm>
Здесь мы видим версию GNU ABI, которую ожидает использовать программа (в данном случае это Linux ABI 3.7.0),
Заголовок TLS ("Thread-Local Storage") определяет таблицу записей TLS, в которых хранится информация о локальных переменных потока, используемых программой.
Заголовок GNU_EH_FRAME определяет расположение в памяти таблиц очистки стека для программы. Таблицы очистки стека используются как отладчиками,
так и функциями C++ для обработки исключений при использовании оператора throw
или конструкции try..catch
.
Заголовок GNU_STACK заголовок указывает операционной системе, является ли стек исполняемым. То есть теоретически мы можем поместить в стек код,
который будет исполняться. За создание заголовка GNU_STACK отвечает программа компоновщика ld. При компиляции программы через GCC мы можем установить,
является ли стек исполняемым или нет. С помощью параметра -z noexecstack
стек указывается как исполняемый, а с помощью
-z execstack
как неисполняемый. Например, в случае со второй программой имеется заголовок:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
Флаг RW
указывает, что стек неисполняемый. В случае исполняемого стека значения флагов были бы RWE
Заголовок GNU_RELRO (RELRO - Relocation Read-Only) указывает загрузчику после загрузки программы (но до того, как она начнет выполняться) пометить определенные критические области двоичного файла программы как доступные только для чтения. Что позволяет предотвратить возможную перезапись и внедрение вредоносного кода. В частности, этот заголовок используется для защиты глобальной таблицы смещений (Global Offset Table или кратко GOT), а также таблиц инициализации и финализации, которые содержат указатели на функции, запускаемые программой до запуска основной функции программы и для выхода.
Например, возьмем вторую программу:
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align .......................................................................................... GNU_RELRO 0x07c7d0 0x000000000048c7d0 0x000000000048c7d0 0x003830 0x003830 R 0x1 Section to Segment mapping: Segment Sections... ......................................................... 05 .tdata .init_array .fini_array .data.rel.ro .got
Здесь мы увидим, что RELRO запрашивает загрузчик пометить разделы .tdata .init_array .fini_array .data.rel.ro и .got бинарного файла как доступные только для чтения перед запуском программы, то есть защитить данные TLS (.tdata), инициализаторы программы (.init_array, деструкторы (.fini_array) и глобальную таблицу смещений (.got).
С помощью параметров компилятора мы можем настроить этот заголовок:
-znow
: включает полную защиту
-zrelro
: включает частичную защиту. То же самое, что и полная защита, только из защиты исключаются часть глобальной таблицы смещений
(Global Offset Table), которая отвечает за управление таблицей связывания процедур (Procedure Linkage Table), которая обычно называется .plt.got
-znorelro
: отключаение защиты (поддерживается не на всех архитектурах).