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
| Aspekt | Sekcje (linking view) | Segmenty (execution view) |
|---|---|---|
| Odbiorca | Linker (ld) | Loader (kernel + ld.so) |
| Granularność | Drobna (.text, .data, .bss, .rodata) | Gruba (LOAD, DYNAMIC, INTERP) |
| Cel | Relokacja, symbol resolution | Mapowanie do pamięci z uprawnieniami |
| Tablica | Section Header Table | Program 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 0x8Kluczowy 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:
- Kernel parsuje ELF, mapuje segmenty LOAD, czyta segment INTERP
- Kernel ładuje
ld.so(sam będący plikiem ELF) i przekazuje mu sterowanie — nie do_startprogramu ld.soparsuje sekcję.dynamic, znajduje listę potrzebnych bibliotek (DT_NEEDED)- Dla każdej biblioteki: lokalizacja na dysku, mapowanie, rekurencyjne rozwiązanie jej zależności
- Relokacje i symbol resolution — wypełnienie GOT
- Wywołanie konstruktorów (
.init_array) - Skok do
_startprogramu, który ostatecznie wywołamain()
# 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 relokacjiDlaczego 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.sofaktycznym 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_resolveLazy 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 aktywneKompromis 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
| Wymiar | Statyczne (-static) | Dynamiczne (domyślne) |
|---|---|---|
| Rozmiar binarna | Duży (cała libc wkompilowana) | Mały (tylko własny kod) |
| Zużycie RAM (N procesów) | N × pełna kopia | 1 × biblioteka współdzielona |
| Czas startu | Szybki (brak resolution) | Wolniejszy (ld.so + relokacje) |
| Aktualizacje bibliotek | Wymaga rekompilacji | Podmiana .so wystarczy |
| Patche bezpieczeństwa | Rebuild każdego binarna | Jeden update libc |
| Przenośność | Pełna (zero zależności) | Zależna od wersji .so |
| Przypadek użycia | Kontenery scratch, statyczne CLI | Standardowy 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ędzie | Zastosowanie |
|---|---|
readelf -h/-l/-d | Inspekcja nagłówka, segmentów, sekcji dynamicznej |
objdump -d/-p | Disasembler + nagłówki programu (bezpieczny dla zależności) |
nm -D | Symbole dynamiczne eksportowane/importowane przez .so |
LD_DEBUG=all | Pełny trace procesu ładowania i resolution w runtime |
LD_TRACE_LOADED_OBJECTS=1 | Lista zależności (to co robi ldd, ale świadomie) |
patchelf | Modyfikacja RPATH, interpretera, SONAME istniejącego binarna |
strace -e trace=openat | Któ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
- Anatomia segfaulta — od MMU przez kernel po core dump w gdb — mapowanie segmentów i uprawnienia stron
- malloc internals — dlaczego free() nie oddaje pamięci systemowi — LD_PRELOAD jako mechanizm podmiany allocatora
- fork() Linux — co naprawdę robi pod spodem — współdzielenie bibliotek przez copy-on-write
- strace — czytanie syscalli, kiedy stack trace nie wystarcza — LD_DEBUG i strace jako narzędzia analizy ładowania


