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

Co dzieje się zanim odpali main()

Wpisujesz ./program, ale pierwszą wykonaną instrukcją nie jest Twój main(). Dla typowego dynamicznie linkowanego programu ELF kernel mapuje segmenty i przekazuje sterowanie interpreterowi wskazanemu w pliku. Dynamiczny linker ładuje zależności i wykonuje relokacje, zanim runtime dojdzie do kodu aplikacji.

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 przechwycić wiele funkcji bez rekompilacji, dlaczego ldd nie pokazuje całego obrazu 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 Linux i wiele innych systemów uniksowych 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 identyfikuje ELF. ET_DYN występuje zarówno w bibliotekach współdzielonych, jak i w plikach wykonywalnych PIE — samo pole typu ich nie odróżnia. Entry point typowego programu C prowadzi do kodu startowego, zwykle _start, a nie bezpośrednio do 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

Co pokazuje ldd i kiedy mu nie wystarczy

ldd zwykle uruchamia dynamiczny linker w trybie śledzenia zależności. Nie pokazuje przyszłych wywołań dlopen(), a wynik zależy od środowiska uruchomienia. Dokumentacja odradza używanie go na niezaufanych plikach wykonywalnych. objdump -p lub readelf -d pozwalają odczytać wpisy DT_NEEDED bez uruchamiania programu, ale pokazują tylko bezpośrednie zależności.

# 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. Zapisywalna podczas relokacji; RELRO może później chronić ją przed zapisem.
  • PLT — tablica małych stubów kodu. Funkcja wywoływana tym mechanizmem ma wpis PLT, który wykonuje skok pośredni przez GOT.

W uproszczonym modelu opartym na PLT wywołanie printf() kompiluje się do call printf@plt — skoku do stuba PLT, nie do samej funkcji:

/* Twój kod */
printf("%s", message);

/* 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

Przy włączonym lazy binding adres funkcji wywoływanej przez PLT może zostać rozwiązany dopiero przy pierwszym wywołaniu. Opcje linkowania i LD_BIND_NOW mogą wymusić wcześniejsze wiązanie, więc koszt pierwszego wywołania zależy od sposobu zbudowania programu.

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

Lazy binding wymaga możliwości aktualizacji odpowiednich wpisów GOT. Typowe pełne RELRO uzyskuje się podczas linkowania przez -Wl,-z,relro,-z,now: relokacje są wykonywane przed ochroną tego obszaru przed zapisem. Samo ustawienie LD_BIND_NOW nie zmienia układu segmentów już zbudowanego pliku.

LD_PRELOAD — interpozycja jako supermoc

LD_PRELOAD ładuje dodatkowe biblioteki przed zwykłymi zależnościami i umożliwia interpozycję wielu symboli. Nie podmieni jednak dowolnego wywołania: ograniczają ją m.in. widoczność symboli, statyczne linkowanie, optymalizacje kompilatora i tryb secure-execution.

/* puts_tracker.c — prosty przykład interpozycji, Linux/glibc */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <unistd.h>

int puts(const char *text) {
    int (*next_puts)(const char *) = dlsym(RTLD_NEXT, "puts");
    if (!next_puts) return EOF;
    const char notice[] = "[puts] wywolanie przechwycone\n";
    (void)write(STDERR_FILENO, notice, sizeof notice - 1);
    return next_puts(text);
}
gcc -shared -fPIC -o puts_tracker.so puts_tracker.c -ldl

# Własny program testowy powinien wywoływać puts("hello").
LD_PRELOAD=./puts_tracker.so ./hello

RTLD_NEXT szuka następnego symbolu w kolejności wyszukiwania, dzięki czemu wrapper może przekazać wywołanie dalej. Przechwytywanie malloc() jest trudniejsze: dlsym() lub logowanie przez stdio mogą same alokować pamięć i wywołać rekursję. Prosty wrapper z fprintf() nie jest więc bezpiecznym profilerem alokacji.

Statyczne vs dynamiczne linkowanie — trade-off

WymiarStatyczne (-static)Dynamiczne (domyślne)
Rozmiar binarnaWiększy; wymagany kod bibliotek jest dołączonyZwykle mniejszy; pozostają zależności od bibliotek
Zużycie RAM (N procesów)Procesy tego samego pliku mogą współdzielić czyste strony koduBiblioteki mogą współdzielić czyste strony między różnymi programami
Czas startuSzybki (brak resolution)Wolniejszy (ld.so + relokacje)
Aktualizacje bibliotekWymaga rekompilacjiWymagana zgodność ABI; działające procesy mogą potrzebować restartu
Patche bezpieczeństwaRebuild każdego binarnaAktualizacja biblioteki i odświeżenie korzystających z niej procesów
PrzenośnośćMniej zależności od bibliotek; pozostają wymagania systemu i ABIZależ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.

Rozróżniaj brak deklarowanej zależności od braku ścieżki jej wyszukiwania. Dopisanie RPATH nie tworzy wpisu DT_NEEDED. Najpierw sprawdź, czego program wymaga i gdzie linker tego szuka; dopiero potem zmieniaj konfigurację.

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


Powiązane artykuły

Weryfikacja techniczna: ELF, ld.so, ldd.

Dodaj komentarz

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

Przewijanie do góry