Заголовки программы в файле ELF

Последнее обновление: 08.07.2023

Заголовки программы описывает загрузчику, как эффективно перенести двоичный файл 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

Заголовок PHDR (Program HeaDeR) определеяет расположение и размер таблицы заголовков программы и связанные метаданные.

INTERP

Заголовок INTERP хранит путь к файлу интерпретатора, который должен запускаться, чтобы затем в свою очередь запустить программу. Почти во всех случаях эта программа будет файлом загрузчика операционной системы. Использование внешнего загрузчика необходимо, если программа использует динамически подключаемые библиотеки. Внешний загрузчик управляет глобальной таблицей символов программы, обрабатывает соединение двоичных файлов и в конце обращается к точке входа программы. Заголовок INTERP имеет отношение только к исполняемым файлам; для разделяемых библиотек, загружаемых либо во время начальной загрузки программы, либо динамически во время выполнения программы, это значение игнорируется.

LOAD

Заголовки 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!"

Заголовки программы в файле в формате ELF2 в ARM64

DYNAMIC

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

NOTE

Заголовок 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

Заголовок TLS ("Thread-Local Storage") определяет таблицу записей TLS, в которых хранится информация о локальных переменных потока, используемых программой.

GNU_EH_FRAME

Заголовок GNU_EH_FRAME определяет расположение в памяти таблиц очистки стека для программы. Таблицы очистки стека используются как отладчиками, так и функциями C++ для обработки исключений при использовании оператора throw или конструкции try..catch.

GNU_STACK

Заголовок 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

Заголовок 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: отключаение защиты (поддерживается не на всех архитектурах).

Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850