ELF i dynamic linking — co dzieje się zanim odpali main()

Co dzieje się zanim odpali main()

Między momentem, gdy wpisujesz ./program a wykonaniem pierwszej instrukcji twojego main(), dzieje się więcej inżynierii niż w większości aplikacji webowych podczas całego request lifecycle. Kernel parsuje nagłówek ELF, mapuje segmenty do pamięci, ładuje dynamiczny linker, który rozwiązuje dziesiątki symboli, buduje tablice skoków i dopiero wtedy przekazuje sterowanie.

Większość deweloperów traktuje ./program jak czarną skrzynkę. Inżynier rozumiejący ELF i dynamic linking wie dlaczego pierwszy call do funkcji z biblioteki jest wolniejszy, jak LD_PRELOAD pozwala podmienić dowolną funkcję bez rekompilacji, dlaczego ldd kłamie i jak symbol resolution może być wektorem ataku.

Ten artykuł rozkłada format ELF od nagłówka przez segmenty, mechanikę ld.so, struktury GOT/PLT, lazy binding, aż po praktyczne zastosowania interpozycji. To wiedza, która zamienia „linker errors” w przewidywalny, debugowalny proces.

Anatomia pliku ELF

ELF (Executable and Linkable Format) to format binarny używany przez praktycznie wszystkie systemy uniksowe dla plików wykonywalnych, bibliotek współdzielonych (.so), plików obiektowych (.o) i core dumpów. Struktura ma dwie komplementarne perspektywy: linking view (sekcje, dla linkera) i execution view (segmenty, dla loadera).

# Nagłówek ELF — pierwsze 64 bajty determinują wszystko
$ readelf -h ./program
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x1060
  Start of program headers:          64 (bytes into file)
  Start of section headers:          14688 (bytes into file)

Magic number 7f 45 4c 46 (\x7fELF) identyfikuje plik. Pole Type: DYN oznacza PIE (Position-Independent Executable) — domyślny od lat na nowoczesnych dystrybucjach, kluczowy dla ASLR (Address Space Layout Randomization). Entry point to nie main() — to _start, runtime stub który dopiero wywoła twój main().

Sekcje vs segmenty — dwie perspektywy tego samego pliku

AspektSekcje (linking view)Segmenty (execution view)
OdbiorcaLinker (ld)Loader (kernel + ld.so)
GranularnośćDrobna (.text, .data, .bss, .rodata)Gruba (LOAD, DYNAMIC, INTERP)
CelRelokacja, symbol resolutionMapowanie do pamięci z uprawnieniami
TablicaSection Header TableProgram Header Table
# Segmenty — to co loader faktycznie mapuje do pamięci
$ readelf -l ./program

Program Headers:
  Type           Offset   VirtAddr           Flags  Align
  INTERP         0x000318 0x0000000000000318 R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000000000 R      0x1000   # nagłówki
  LOAD           0x001000 0x0000000000001000 R E    0x1000   # .text (kod)
  LOAD           0x002000 0x0000000000002000 R      0x1000   # .rodata
  LOAD           0x002db8 0x0000000000003db8 RW     0x1000   # .data + .bss
  DYNAMIC        0x002dc8 0x0000000000003dc8 RW     0x8

Kluczowy jest segment INTERP — zawiera ścieżkę do dynamicznego linkera (/lib64/ld-linux-x86-64.so.2). Kernel czyta tę ścieżkę i to linker, nie kernel, dokończy ładowanie programu. Każdy segment LOAD ma flagi uprawnień (R/W/E) mapowane bezpośrednio na bity stron w MMU — segment z kodem jest R E (read+execute, ale nie write), co blokuje samomodyfikujący się kod. Te same flagi stron decydują o tym, czy dostęp do pamięci jest legalny — szczegóły w anatomii segfaulta.

Rola dynamicznego linkera ld.so

Gdy uruchamiasz dynamicznie linkowany program, sekwencja jest następująca:

  1. Kernel parsuje ELF, mapuje segmenty LOAD, czyta segment INTERP
  2. Kernel ładuje ld.so (sam będący plikiem ELF) i przekazuje mu sterowanie — nie do _start programu
  3. ld.so parsuje sekcję .dynamic, znajduje listę potrzebnych bibliotek (DT_NEEDED)
  4. Dla każdej biblioteki: lokalizacja na dysku, mapowanie, rekurencyjne rozwiązanie jej zależności
  5. Relokacje i symbol resolution — wypełnienie GOT
  6. Wywołanie konstruktorów (.init_array)
  7. Skok do _start programu, który ostatecznie wywoła main()
# Prześledź pełen proces ładowania bibliotek przez ld.so
$ LD_DEBUG=libs ./program 2>&1 | head -20
     find library=libc.so.6 [0]; searching
      search path=/lib/x86_64-linux-gnu/tls/haswell/x86_64
       trying file=/lib/x86_64-linux-gnu/libc.so.6
     calling init: /lib/x86_64-linux-gnu/libc.so.6

# Wszystkie dostępne kategorie debugowania ld.so
$ LD_DEBUG=help ./program
  libs        wyświetl przeszukiwanie bibliotek
  reloc       wyświetl przetwarzanie relokacji
  symbols     wyświetl przeszukiwanie tablicy symboli
  bindings    wyświetl wiązanie symboli
  statistics  wyświetl statystyki relokacji

Dlaczego ldd kłamie

Popularne ldd nie jest niezawodnym narzędziem inspekcji — w istocie uruchamia program (ustawiając LD_TRACE_LOADED_OBJECTS=1), co dla niezaufanego binarna jest niebezpieczne. Co więcej, wynik ldd może różnić się od faktycznego runtime resolution, bo nie uwzględnia dlopen(), LD_PRELOAD czy RPATH ustawianego dynamicznie. Bezpieczna alternatywa:

# Bezpieczna inspekcja zależności — bez uruchamiania binarna
$ objdump -p ./program | grep NEEDED
  NEEDED               libssl.so.3
  NEEDED               libcrypto.so.3
  NEEDED               libc.so.6

# Alternatywnie przez readelf
$ readelf -d ./program | grep NEEDED
 0x0000000000000001 (NEEDED)  Shared library: [libssl.so.3]
 0x0000000000000001 (NEEDED)  Shared library: [libc.so.6]

GOT i PLT — mechanika wywołań funkcji zewnętrznych

Tu zaczyna się sedno. Skoro biblioteki są ładowane pod losowe adresy (ASLR), jak kod może wywołać printf(), skoro jego adres nie jest znany w czasie kompilacji? Odpowiedź to dwie struktury: GOT (Global Offset Table) i PLT (Procedure Linkage Table).

  • GOT — tablica wskaźników. Wpis dla każdego zewnętrznego symbolu, wypełniany przez ld.so faktycznym adresem runtime. Sekcja danych, zapisywalna.
  • PLT — tablica małych stubów kodu. Każda zewnętrzna funkcja ma swój wpis PLT, który wykonuje skok pośredni przez GOT.

Wywołanie printf() w twoim kodzie kompiluje się faktycznie do call printf@plt — skoku do stuba PLT, nie do samej funkcji:

/* Twój kod */
printf("hello\n");

/* Kompiluje się do (uproszczone): */
call printf@plt        /* skok do stuba PLT */

/* printf@plt (uproszczony asembler): */
printf@plt:
    jmp *printf@got     /* skok pośredni przez GOT */
    /* jeśli GOT niewypełniony — fallback do resolvera */
    push $printf_index
    jmp _dl_runtime_resolve

Lazy binding — dlaczego pierwszy call jest wolniejszy

Domyślnie ld.so stosuje lazy binding: adresy funkcji są rozwiązywane dopiero przy pierwszym wywołaniu, nie podczas ładowania. To optymalizacja startu — program ładujący setki funkcji, z których użyje dziesięciu, nie płaci za rozwiązanie pozostałych.

Mechanika: początkowo wpis GOT dla funkcji wskazuje z powrotem na kod PLT, który wywołuje _dl_runtime_resolve. Ten lokalizuje faktyczny adres funkcji, wpisuje go do GOT i skacze do funkcji. Każde kolejne wywołanie idzie już bezpośrednio przez wypełniony GOT — stąd narzut tylko za pierwszym razem.

# Wymuszenie eager binding — wszystkie symbole rozwiązane przy starcie
$ LD_BIND_NOW=1 ./program
# Wolniejszy start, ale brak narzutu przy pierwszym call każdej funkcji
# Również: cała GOT może być read-only (RELRO) — hardening security

# Sprawdzenie czy binarka ma Full RELRO (GOT read-only po starcie)
$ readelf -l ./program | grep GNU_RELRO
  GNU_RELRO  0x002db8 0x0000000000003db8 R   0x1
$ readelf -d ./program | grep BIND_NOW
 0x000000000000001e (FLAGS)  BIND_NOW   # Full RELRO aktywne

Kompromis bezpieczeństwo/wydajność: lazy binding wymaga zapisywalnego GOT przez całe życie procesu — wektor ataku GOT overwrite. Full RELRO (LD_BIND_NOW + -z relro) rozwiązuje wszystko przy starcie i czyni GOT read-only, eliminując ten wektor kosztem wolniejszego startu.

LD_PRELOAD — interpozycja jako supermoc

LD_PRELOAD instruuje ld.so, by załadować wskazaną bibliotekę przed wszystkimi innymi. Ponieważ symbol resolution przeszukuje biblioteki w kolejności ładowania, funkcja zdefiniowana w preloadowanej bibliotece przesłania tę samą funkcję z libc. To pozwala podmienić dowolną funkcję biblioteczną bez rekompilacji programu.

/* malloc_tracker.c — przechwytywanie każdej alokacji */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

/* Wskaźnik na oryginalny malloc z libc */
static void *(*real_malloc)(size_t) = NULL;

void *malloc(size_t size) {
    if (!real_malloc) {
        /* Pobierz oryginalny symbol z następnej biblioteki w łańcuchu */
        real_malloc = dlsym(RTLD_NEXT, "malloc");
    }

    void *ptr = real_malloc(size);

    /* Instrumentacja — log do stderr, by nie zaburzyć stdout programu */
    fprintf(stderr, "[malloc] %zu bajtów → %p\n", size, ptr);

    return ptr;
}
# Kompilacja jako biblioteka współdzielona
$ gcc -shared -fPIC -o malloc_tracker.so malloc_tracker.c -ldl

# Wstrzyknięcie do dowolnego programu — bez jego rekompilacji
$ LD_PRELOAD=./malloc_tracker.so ls
[malloc] 5 bajtów → 0x55a3c2b04260
[malloc] 120 bajtów → 0x55a3c2b042a0
[malloc] 1024 bajtów → 0x55a3c2b04320
...

To nie hack — to fundament wielu profesjonalnych narzędzi. AddressSanitizer, jemalloc i tcmalloc jako podmienne allocatory, biblioteki mockujące w testach, narzędzia profilujące — wszystkie używają tego mechanizmu. RTLD_NEXT w dlsym to klucz: pobiera następny symbol w łańcuchu, pozwalając wrapperowi wywołać oryginał.

Statyczne vs dynamiczne linkowanie — trade-off

WymiarStatyczne (-static)Dynamiczne (domyślne)
Rozmiar binarnaDuży (cała libc wkompilowana)Mały (tylko własny kod)
Zużycie RAM (N procesów)N × pełna kopia1 × biblioteka współdzielona
Czas startuSzybki (brak resolution)Wolniejszy (ld.so + relokacje)
Aktualizacje bibliotekWymaga rekompilacjiPodmiana .so wystarczy
Patche bezpieczeństwaRebuild każdego binarnaJeden update libc
PrzenośnośćPełna (zero zależności)Zależna od wersji .so
Przypadek użyciaKontenery scratch, statyczne CLIStandardowy deployment systemowy

Renesans statycznego linkowania widać w Go (domyślnie statyczne) i Rust (musl target) — głównie dla kontenerów scratch (zero-dependency images) i dystrybucji pojedynczych binarów. Dynamiczne pozostaje standardem systemowym ze względu na współdzielenie pamięci i centralne patche bezpieczeństwa (jeden CVE w OpenSSL = jeden update libssl.so, nie rebuild tysiąca aplikacji). Samo współdzielenie stron kodu między procesami opiera się na copy-on-write — tym samym mechanizmie, co wywołanie fork().

Praktyczna diagnostyka — narzędzia

NarzędzieZastosowanie
readelf -h/-l/-dInspekcja nagłówka, segmentów, sekcji dynamicznej
objdump -d/-pDisasembler + nagłówki programu (bezpieczny dla zależności)
nm -DSymbole dynamiczne eksportowane/importowane przez .so
LD_DEBUG=allPełny trace procesu ładowania i resolution w runtime
LD_TRACE_LOADED_OBJECTS=1Lista zależności (to co robi ldd, ale świadomie)
patchelfModyfikacja RPATH, interpretera, SONAME istniejącego binarna
strace -e trace=openatKtóre pliki .so faktycznie są otwierane przy starcie

Podsumowanie: linker jako część kontraktu wykonania

Dynamic linking to nie magia, która „po prostu działa” — to precyzyjnie zdefiniowany protokół między kompilatorem, linkerem, kernelem i ld.so. Zrozumienie formatu ELF, mechaniki GOT/PLT i lazy binding zamienia całą klasę problemów (symbol not found, version mismatch, wrong library loaded, wolny start) z tajemniczych w deterministyczne i debugowalne.

Inżynier który wie, że ./program uruchamia najpierw ld.so, że pierwszy call funkcji bibliotecznej przechodzi przez resolver, że LD_PRELOAD pozwala wstrzyknąć dowolną instrumentację — ma w ręku narzędzia, których większość deweloperów nie wie, że istnieją. To różnica między „linker error, sprawdzę na Stack Overflow” a „brakuje DT_NEEDED dla libssl.so.3, dodam ścieżkę do RPATH przez patchelf„.

Mechanika ładowania programu jest fundamentem, na którym stoi wszystko inne. Warto wiedzieć co dzieje się, zanim odpali main().


Powiązane artykuły

Zostaw komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry