ELF и линковка

Предпосылки: бит, байт, hex, виртуальная память (mmap, demand paging, ASLR), процессы (exec, fork+exec).

Управление памятью ядра | Терминалы

Ядро умеет выделять физические фреймы, строить таблицы страниц, вытеснять страницы в swap. Но когда execve() загружает программу, ядру нужно знать, по каким виртуальным адресам разместить код и данные, где точка входа, какие библиотеки подключить. Эти сведения закодированы в формате исполняемого файла и определяются процессом линковки, который превращает объектные модули в готовый к загрузке бинарник.

Когда процесс вызывает printf("hello\n"), код printf находится не в самом исполняемом файле, а в разделяемой библиотеке libc.so, которую загрузил динамический линкер. Первый вызов printf проходит через два уровня косвенности — PLT (Procedure Linkage Table) и GOT (Global Offset Table) — прежде чем попасть в код libc. Второй вызов обходит линкер и прыгает по адресу напрямую. За этой механикой стоит формат ELF, статическая и динамическая линковка, ленивое связывание и ASLR (Address Space Layout Randomization — рандомизация адресов).

Что внутри исполняемого файла

Скомпилированная программа на диске — не просто последовательность машинных инструкций. Загрузчику ядра нужно знать, куда в виртуальном пространстве отобразить код, где лежат данные, с какого адреса начать выполнение, какие библиотеки подключить. Вся эта информация закодирована в формате ELF (Executable and Linkable Format) — стандартном формате исполняемых файлов, объектных модулей и разделяемых библиотек в Linux.

Первые четыре байта любого ELF-файла — магическая последовательность \x7fELF. Утилита readelf -h /usr/bin/ls покажет ELF-заголовок (ELF header) — компактную структуру размером 64 байта (на 64-битных системах), содержащую:

  • тип файла: ET_EXEC (исполняемый с фиксированными адресами), ET_DYN (позиционно-независимый — сюда попадают и .so, и современные исполняемые файлы, скомпилированные с -fPIE), ET_REL (объектный модуль);
  • архитектуру: EM_X86_64, EM_AARCH64;
  • точку входа (entry point): виртуальный адрес первой инструкции, обычно _start из crt1.o, а не main — до main C runtime должен инициализировать stdlib, вызвать конструкторы глобальных объектов, настроить argc/argv;
  • смещения двух таблиц — program headers и section headers.

Program headers (таблица программных заголовков) описывают сегменты — области файла, которые ядро отображает в виртуальную память при загрузке. Каждый заголовок типа PT_LOAD содержит виртуальный адрес, размер в файле, размер в памяти, права доступа (R/W/X) и выравнивание. Типичный исполняемый файл имеет два PT_LOAD: один для кода (read + execute), другой для данных (read + write). Ядру достаточно program headers, чтобы загрузить и запустить программу — section headers ему не нужны.

Section headers (таблица заголовков секций) предназначены для линкера и отладчика. Секции — более мелкая нарезка: .text (код), .data (инициализированные данные), .bss (неинициализированные данные), .rodata (строковые константы), .symtab (таблица символов), .strtab (строки для имён символов), .rel.plt и .rel.dyn (таблицы перемещений для PLT и данных). Утилита readelf -S покажет все секции, а strip может удалить section headers из исполняемого файла (такой бинарник называют stripped — очищенным от символов) — программа продолжит работать, но gdb потеряет информацию о символах.

Два разных взгляда на один файл: сегменты (для загрузки в память) и секции (для линковки и отладки). Один сегмент PT_LOAD может вмещать несколько секций — .text и .rodata часто попадают в один сегмент с правами R+X. Утилита readelf -l покажет program headers и какие секции в какой сегмент попали.

exec() и загрузка ELF

В процессах мы видели, что execve() заменяет адресное пространство процесса новой программой. Теперь можно разобрать, что именно делает ядро с ELF-файлом.

Ядро читает первые 64 байта файла — ELF-заголовок. Проверяет магическую последовательность \x7fELF, архитектуру, тип. Если магические байты не совпадают — ядро пробует другие обработчики: скрипты с #!/bin/bash обрабатывает binfmt_script, Java .class файлы можно зарегистрировать через binfmt_misc. Для ELF ядро разбирает program headers и для каждого сегмента PT_LOAD вызывает mmap(): создаёт VMA (Virtual Memory Area) с виртуальным адресом и правами из заголовка, связывая VMA с файлом на диске. Физических фреймов не выделяется — работает demand paging: страницы загрузятся при первом обращении.

Если ELF-заголовок содержит PT_INTERP (а для динамически слинкованных программ он есть всегда), ядро загружает ещё и динамический линкер — и передаёт управление ему, а не entry point программы. Для статически слинкованных бинарников PT_INTERP отсутствует, и ядро устанавливает регистр rip (instruction pointer) на значение entry point из ELF-заголовка напрямую. Первое обращение вызовет page fault, ядро загрузит страницу с диска — и программа побежит. Программа размером 50 МБ начинает работать после загрузки одной страницы (4 КБ) — остальные загрузятся по требованию.

Статическая линковка: всё в одном файле

Программа hello.c вызывает printf(). Код printf живёт в libc — библиотеке стандартных функций C. Компилятор создаёт объектный файл hello.o, где вызов printf — просто незаполненная ссылка (relocation entry — запись перемещения, указание линкеру «подставь сюда адрес»). Кто-то должен подставить реальный адрес.

Статический линкер ld (вызываемый через gcc -static) решает задачу в лоб: берёт hello.o, находит нужные функции в статической библиотеке libc.a (архив из объектных файлов, по сути tar-подобный контейнер с .o файлами), копирует их машинный код в итоговый исполняемый файл и заполняет все ссылки конкретными адресами. Линкер копирует не всю libc.a целиком — только те объектные файлы, от которых зависит программа. Результат — самодостаточный бинарник, которому не нужны внешние библиотеки.

/* hello.c */
#include <stdio.h>
int main(void) {
    printf("hello\n");
    return 0;
}
$ gcc -static -o hello hello.c
$ ls -lh hello
-rwxr-xr-x 1 alice alice 880K Mar 15 10:00 hello

$ ldd hello
    not a dynamic executable

$ file hello
hello: ELF 64-bit LSB executable, x86-64, statically linked

Пять строк исходного кода — 880 КБ бинарника. Линкер скопировал printf, puts, write, __libc_start_main, код форматирования строк, обработку локалей, буферизацию stdio, реализацию malloc для внутренних нужд — всё, от чего транзитивно зависит printf. Утилита ldd подтверждает: динамических зависимостей нет.

Статическая линковка даёт предсказуемость: бинарник работает на любой Linux-системе с совместимым ядром, независимо от установленных библиотек. Go по умолчанию линкует статически — go-бинарники переносимы между дистрибутивами без зависимостей. В Docker-контейнерах статически слинкованные бинарники позволяют использовать образы FROM scratch (пустой базовый образ) без установки каких-либо пакетов.

Обратная сторона — размер и дублирование. На сервере с 400 процессами, каждый из которых использует libc, статическая линковка означает 400 копий одного и того же кода printf в RAM — 400 x 2 МБ = 800 МБ только на libc. Обновление тоже становится проблемой: при обнаружении уязвимости в libc (например, CVE в getaddrinfo()) приходится пересобирать и деплоить каждый бинарник. С динамической линковкой достаточно обновить один файл libc.so.6 и перезапустить сервисы. Нужен способ разделять код библиотеки между процессами.

Динамическая линковка: разделяемые библиотеки

Разделяемая библиотека (shared library, .so — shared object) — ELF-файл типа ET_DYN, содержащий скомпилированный код и данные. В отличие от статической библиотеки .a, .so не копируется в исполняемый файл. Вместо этого исполняемый файл хранит запись: «мне нужна libc.so.6».

$ gcc -o hello hello.c
$ ls -lh hello
-rwxr-xr-x 1 alice alice 16K Mar 15 10:00 hello

$ ldd hello
    linux-vdso.so.1 (0x00007ffd3a5f2000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8a1c200000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f8a1c5f0000)

16 КБ вместо 880 КБ. ldd показывает три зависимости: linux-vdso.so.1 (виртуальная библиотека ядра для быстрых syscall), libc.so.6 (стандартная библиотека C) и /lib64/ld-linux-x86-64.so.2 — динамический линкер.

При загрузке ядро отображает код .so через mmap() с флагами MAP_PRIVATE и PROT_READ|PROT_EXEC. Как описано в виртуальной памяти, несколько процессов, использующих одну библиотеку, разделяют одни и те же физические фреймы кода. Те же 400 процессов, использующих printf — одна копия libc.so в RAM (~2 МБ), а не 400 копий (800 МБ).

Данные библиотеки (.data, .bss) отображаются с MAP_PRIVATE — каждый процесс получает свою копию через copy-on-write при первой записи. Глобальная переменная errno в libc — пример: каждый поток хранит свою копию, и запись в errno одним процессом не затрагивает другие.

Динамический линкер: ld-linux

Кто загружает .so в память? Не само ядро. Ядро знает только об ELF-формате исполняемого файла — оно отображает его сегменты и ищет в program headers запись типа PT_INTERP. Эта запись содержит путь к динамическому линкеру (runtime linker) — обычно /lib64/ld-linux-x86-64.so.2. Ядро загружает линкер в память точно так же, как основную программу — через mmap() его PT_LOAD сегментов — и передаёт ему управление вместо entry point программы.

Динамический линкер (ld-linux.so, часть glibc) выполняет работу до того, как программа начнёт выполняться:

  1. Читает секцию .dynamic исполняемого файла — список необходимых библиотек (DT_NEEDED).
  2. Ищет каждую библиотеку по комбинации путей: DT_RPATH/DT_RUNPATH из ELF, переменная LD_LIBRARY_PATH, кеш /etc/ld.so.cache (собранный ldconfig из путей в /etc/ld.so.conf), стандартные /lib и /usr/lib.
  3. Загружает найденные .so через mmap(). Если у библиотеки есть свои DT_NEEDED — рекурсивно загружает и их.
  4. Разрешает символы: сопоставляет неопределённые символы программы (вроде printf) с экспортированными символами библиотек. Для этого обходит хеш-таблицы символов (.gnu.hash или .hash) каждой загруженной библиотеки.
  5. Применяет перемещения (relocations): записывает финальные адреса в GOT, заполняет указатели на функции и данные.
  6. Вызывает конструкторы библиотек (__attribute__((constructor)), .init_array).
  7. Передаёт управление на entry point программы.

На практике шаги 4 и 5 для функций выполняются лениво — при первом вызове, а не при старте. Это экономит время загрузки: программа может импортировать тысячи символов, но за время работы использовать лишь десятки. Механизм ленивого связывания реализуется через PLT и GOT.

PLT и GOT: ленивое связывание

Когда компилятор генерирует код с вызовом printf, он не знает, по какому виртуальному адресу окажется printf в libc.so — ASLR рандомизирует адреса при каждом запуске. Вместо прямого вызова компилятор генерирует вызов заглушки в PLT (Procedure Linkage Table, таблица связывания процедур). PLT-заглушка, в свою очередь, прыгает по адресу, записанному в GOT (Global Offset Table, глобальная таблица смещений).

GOT — массив указателей в секции .got.plt, расположенной в сегменте данных (read/write). Каждой внешней функции соответствует одна запись в GOT. При первом вызове запись ещё не содержит реального адреса — в ней лежит адрес второй части PLT-заглушки, которая вызывает динамический линкер. Линкер находит адрес printf в libc.so, записывает его в GOT и прыгает на printf. Эта вторая часть PLT-заглушки называется resolve stub (заглушка разрешения) — код, который передаёт управление динамическому линкеру для поиска символа. При втором вызове GOT уже содержит реальный адрес — прыжок через PLT сразу попадает в printf, без участия линкера.

Первый вызов printf():
 
  main()                PLT[printf]           GOT[printf]       ld-linux
    |                       |                     |                 |
    |-- call PLT[printf] -->|                     |                 |
    |                       |-- jmp *GOT[printf]->|                 |
    |                       |                     | (адрес = PLT    |
    |                       |<-- возврат в PLT ---|  resolve stub)  |
    |                       |                     |                 |
    |                       |-- call ld-linux ----|---------------->|
    |                       |                     |    поиск printf |
    |                       |                     |    в libc.so    |
    |                       |                     |<-- запись адреса-|
    |                       |                     | (адрес = printf |
    |                       |                     |  в libc.so)     |
    |                       |                     |                 |
    |<-- printf() выполняется----------------------|                 |
 
 
Второй вызов printf():
 
  main()                PLT[printf]           GOT[printf]
    |                       |                     |
    |-- call PLT[printf] -->|                     |
    |                       |-- jmp *GOT[printf]->|
    |                       |                     | (адрес = printf
    |                       |                     |  в libc.so)
    |<-- printf() выполняется---------------------|

Первый вызов стоит ~1-5 мкс (поиск символа по хеш-таблицам). Каждый последующий — один косвенный прыжок через GOT: jmp *[адрес в GOT], порядка нескольких наносекунд. На программе, вызывающей printf миллион раз, накладные расходы ленивого связывания — однократные 5 мкс на фоне миллиона вызовов.

PLT-заглушка для printf на x86-64 — всего три инструкции:

printf@plt:
    jmp    *printf@GOTPCREL(%rip)   ; прыжок по адресу из GOT
    push   $index                    ; индекс в таблице перемещений
    jmp    PLT[0]                    ; вызов resolve-стаба ld-linux

Первая инструкция прыгает по адресу из GOT. До разрешения GOT указывает на вторую инструкцию — push $index. Это помещает индекс перемещения на стек и прыгает на PLT[0], где вызывается _dl_runtime_resolve() из ld-linux. После разрешения GOT содержит адрес printf в libc.so, и первая инструкция прыгает туда напрямую — вторая и третья инструкции больше никогда не выполняются.

LD_BIND_NOW: связывание при запуске

Ленивое связывание экономит время старта, но создаёт непредсказуемые задержки: первый вызов каждой функции работает медленнее. Для систем с жёсткими требованиями к латентности (финансовые системы, real-time обработка) это неприемлемо — нельзя, чтобы первый запрос клиента проходил через резолв символов.

Переменная окружения LD_BIND_NOW=1 (или флаг -z now при компиляции) заставляет ld-linux разрешить все символы при загрузке. Все записи GOT заполняются реальными адресами до вызова main(). Время старта увеличивается — программа с 500 импортированными функциями потратит ~0.5-2.5 мс на разрешение (при стоимости ~1-5 мкс на символ) — но после старта каждый вызов стабильно быстр.

Есть ещё одно преимущество: если GOT заполнен при старте, его можно пометить как read-only (-z relro -z now, full RELRO — RELocation Read-Only). Это закрывает вектор атаки GOT overwrite — атакующий, нашедший уязвимость записи в произвольную память, не сможет подменить адрес в GOT, потому что страница помечена read-only на уровне MMU (Memory Management Unit).

LD_PRELOAD: подмена библиотек

Динамический линкер ищет символы в определённом порядке: сначала в самой программе, затем в библиотеках по порядку загрузки. Переменная LD_PRELOAD позволяет загрузить библиотеку раньше всех остальных — и её символы перекроют одноимённые символы из libc или любой другой библиотеки.

/* mymalloc.c */
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
 
void *malloc(size_t size) {
    static void *(*real_malloc)(size_t) = NULL;
    if (!real_malloc)
        real_malloc = dlsym(RTLD_NEXT, "malloc");
 
    void *ptr = real_malloc(size);
    fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
    return ptr;
}
$ gcc -shared -fPIC -o mymalloc.so mymalloc.c -ldl
$ LD_PRELOAD=./mymalloc.so ls
malloc(472) = 0x55a3c4e012a0
malloc(120) = 0x55a3c4e01480
...

LD_PRELOAD=./mymalloc.so загружает mymalloc.so до libc.so. Когда ls вызывает malloc(), динамический линкер находит malloc в mymalloc.so первым. Внутри нашей реализации dlsym(RTLD_NEXT, "malloc") находит «следующий» malloc — из libc — чтобы выполнить реальное выделение памяти.

Это не просто отладочный трюк. Высокопроизводительные аллокаторы jemalloc (используется в Firefox, Rust) и tcmalloc (Google) подключаются именно через LD_PRELOAD — никакой перекомпиляции приложения. Серверное приложение на Ruby или Python, страдающее от фрагментации памяти стандартного glibc malloc, часто запускают с LD_PRELOAD=/usr/lib/libjemalloc.so.2 — и фрагментация снижается благодаря арена-аллокатору jemalloc, который оптимизирован под многопоточные нагрузки.

Ограничение: для setuid/setgid-бинарников (/usr/bin/sudo, /usr/bin/passwd) динамический загрузчик (ld-linux) игнорирует LD_PRELOAD. Механизм: ядро при exec setuid-бинарника устанавливает AT_SECURE=1 в auxiliary vector (вспомогательный вектор — массив пар ключ-значение, который ядро передаёт процессу при exec), загрузчик видит это и переходит в secure-execution mode — отбрасывает LD_PRELOAD, LD_LIBRARY_PATH и другие опасные переменные. Без этого непривилегированный пользователь мог бы подменить malloc или getuid в программе, работающей с правами root.

Другое применение LD_PRELOAD — инструментирование. Библиотека libfaketime перехватывает clock_gettime() и gettimeofday(), возвращая поддельное время — это позволяет тестировать логику, зависящую от системных часов, без изменения времени на машине. strace и ltrace используют другой механизм (ptrace), но LD_PRELOAD часто проще и быстрее для конкретных функций.

dlopen и dlsym: загрузка в runtime

PLT/GOT и LD_PRELOAD работают при запуске программы — список библиотек определён заранее. Но иногда библиотеку нужно загрузить по требованию: система плагинов загружает расширения по имени файла, Ruby подключает C-расширения через require, Nginx загружает модули из конфигурации.

#include <dlfcn.h>
#include <stdio.h>
 
int main(void) {
    /* загрузка библиотеки */
    void *handle = dlopen("libm.so.6", RTLD_LAZY);
    if (!handle) {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        return 1;
    }
 
    /* поиск символа */
    double (*cosine)(double) = dlsym(handle, "cos");
    if (!cosine) {
        fprintf(stderr, "dlsym: %s\n", dlerror());
        return 1;
    }
 
    printf("cos(0) = %f\n", cosine(0.0));
 
    dlclose(handle);
    return 0;
}

dlopen() загружает .so в адресное пространство процесса через mmap() — тот же механизм, что использует ld-linux при старте. Флаг RTLD_LAZY включает ленивое связывание для внутренних зависимостей библиотеки; RTLD_NOW — немедленное. dlsym() ищет символ по имени в загруженной библиотеке и возвращает его адрес — указатель на функцию, который можно вызвать напрямую. dlclose() уменьшает счётчик ссылок; когда он достигает нуля, ядро вызывает munmap() и освобождает виртуальные страницы.

Ошибки dlopen не вызывают SIGSEGV — функция возвращает NULL, а dlerror() описывает причину: библиотека не найдена, неразрешённый символ, несовместимая архитектура. Это позволяет реализовать graceful degradation: попытаться загрузить оптимизированную библиотеку, при неудаче — использовать fallback.

Когда в Ruby пишут require 'json/ext/parser', интерпретатор вызывает dlopen("parser.so", ...), находит через dlsym функцию Init_parser и вызывает её — C-расширение регистрирует свои классы и методы в Ruby VM. Тот же механизм используют Python (ctypes, C-расширения), Nginx (модули), PostgreSQL (расширения вроде PostGIS).

ASLR: рандомизация адресов

Если адрес printf в libc.so один и тот же при каждом запуске, атакующий, нашедший уязвимость buffer overflow, может заранее вычислить адрес нужной функции (например, system()) и перенаправить на неё выполнение — это return-to-libc атака. Нужен способ сделать адреса непредсказуемыми.

ASLR (Address Space Layout Randomization, рандомизация размещения адресного пространства) — механизм ядра, рандомизирующий базовые адреса стека, кучи, mmap-области и разделяемых библиотек при каждом запуске процесса. Включён по умолчанию (/proc/sys/kernel/randomize_va_space = 2). Позиционно-независимый исполняемый файл (PIE, Position-Independent Executable, скомпилированный с -fPIE) рандомизируется целиком — включая секции кода и данных самой программы.

$ cat /proc/self/maps | grep libc.so
7f3a1c200000-7f3a1c3c0000 r-xp  libc.so.6  # первый запуск
$ cat /proc/self/maps | grep libc.so
7f8b4e600000-7f8b4e7c0000 r-xp  libc.so.6  # второй запуск

Базовый адрес libc сдвинулся на ~20 ТБ между двумя запусками. Атакующий не знает, где в памяти находится system(), и return-to-libc перестаёт работать — прыжок по угаданному адресу попадёт в невалидную память (SIGSEGV).

Значение randomize_va_space определяет степень рандомизации: 0 — отключено (полезно для отладки: setarch x86_64 -R ./hello запустит процесс без ASLR), 1 — рандомизируются стек, mmap, VDSO (Virtual Dynamically-linked Shared Object), 2 — добавляется рандомизация brk (кучи). Энтропия рандомизации на x86-64 — 28 бит для mmap-области и 22 бита для стека, что даёт порядка 256 миллионов возможных базовых адресов для библиотеки.

Именно ASLR делает GOT необходимым: поскольку адреса библиотек неизвестны во время компиляции и меняются при каждом запуске, код не может содержать прямые вызовы функций из .so. Косвенный вызов через GOT — записал адрес в таблицу после загрузки, прыгай по указателю — единственный способ вызвать функцию, чей адрес определяется в runtime.

Сценарий целиком: printf от вызова до libc

Соберём всю цепочку. Программа скомпилирована динамически (gcc -o hello hello.c). Пользователь запускает ./hello.

Shell вызывает fork(), потомок вызывает execve("./hello", ...). Ядро читает ELF-заголовок, находит PT_INTERP/lib64/ld-linux-x86-64.so.2, отображает и линкер, и PT_LOAD-сегменты hello через mmap(), передаёт управление ld-linux.

Динамический линкер разбирает .dynamic секцию hello: DT_NEEDED: libc.so.6. Ищет libc по путям, находит /lib/x86_64-linux-gnu/libc.so.6, загружает через mmap(). ASLR сдвигает базовый адрес libc на случайную величину. Линкер заполняет GOT для глобальных переменных, но записи для функций (PLT) оставляет ленивыми. Вызывает __libc_start_main, которая инициализирует stdlib и вызывает main().

main() выполняет printf("hello\n"). Компилятор сгенерировал call printf@plt. PLT-заглушка прыгает по адресу из GOT — там адрес resolve-стаба. Стаб кладёт индекс printf на стек и вызывает _dl_runtime_resolve(). Линкер ищет printf в хеш-таблице символов libc.so, находит адрес (с учётом ASLR-смещения), записывает его в GOT и прыгает на printf. Функция форматирует строку и вызывает write(1, "hello\n", 6) — системный вызов, который доставляет данные в терминал.

Если программа вызовет printf снова — PLT-заглушка прыгнет по адресу из GOT, где уже лежит адрес printf в libc. Один косвенный прыжок, никакого линкера.

Sources


Управление памятью ядра | Терминалы