3.3 ELF-betöltés, PLT/GOT és dinamikus szimbólumok

Egy megosztott könyvtár függvényének meghívásakor a gépi kód nem feltétlenül tartalmazza a függvény végleges címét. A könyvtár betöltési címe futásonként változhat, és a névhez tartozó definíció kiválasztásában a dinamikus linker is részt vehet. A hívás működéséhez ezért össze kell kapcsolnunk az ELF-fájl szerkezetét, a virtuális címtér kialakítását és a futásidejű szimbólumfeloldást.

A Statikus és megosztott könyvtárak részben látott linkelési modellre építünk. Az alábbi ELF-, PLT/GOT- és védelmi mechanizmusok nem az ISO C részei: konkrét modellünk a Linux, az x86-64 System V ABI és a glibc dinamikus linkere.

3.3.1 Section és segment: két nézet ugyanarról a fájlról

Az ELF sectionje, vagyis szekciója a fájl linkeléshez és elemzéshez használható logikai egysége. A section header table leírja többek között a szekciók típusát, méretét, fájlon belüli helyét és tulajdonságait. Ilyen egység a gépi kódot tartalmazó .text vagy a szimbólumtábla.

A program header egy programfejléc-bejegyzés: a betöltéshez vagy futáshoz szükséges tartományt, illetve metaadatot ír le. A bejegyzések táblája a program header table. Különösen fontos típusai:

A szekció és a szegmens nem egy-egy megfeleltetésben áll. Egy betölthető szegmens több, összeillő hozzáférési igényű szekciót is tartalmazhat. A betöltő számára a programfejlécek az irányadók; egy megfelelően elkészített futtatható ELF-nek a végrehajtáshoz nincs szüksége section header table-re.

3.3.1.1 Tipikus szekciók a virtuális címtérben

Az alábbi táblázat az x86-64 Linux/GNU eszközlánc szokásos elrendezését szemlélteti, nem univerzális layoutot ír elő. A méretek ELF-beli bájtokban értendők; ebben a laborprofilban egy C bájt nyolc bit.

Szekció Tipikus tartalom Tartalmi bájtok a fájlban Tipikus futásidejű hozzáférés
.text Függvények gépi kódja Igen Olvasás, végrehajtás
.rodata Sztringliterálok, csak olvasható adatok Igen Olvasás
.data Módosítható, fájlból inicializált adatok Igen Olvasás, írás
.bss Nullázással inicializált tároló Nincs megfelelő tartalmi terület Olvasás, írás

A .bss szokásosan SHT_NOBITS típusú: mérete van, de tartalma nem foglal ugyanannyi helyet a fájlban. Egy PT_LOAD szegmensnél p_filesz jelöli a fájlból származó tartomány méretét, p_memsz pedig a memóriabeli méretet. Feltétel:

0≤pfilesz≤pmemsz. 0 \leq p_{\mathrm{filesz}} \leq p_{\mathrm{memsz}}.

A memóriabeli tartomány fájlból már nem fedezett végét nullákkal kell inicializálni. Ez magyarázza, hogyan lehet egy nagy nullázott adattartomány futásidejű memóriaigénye jelentős, miközben a bináris alig növekszik.

A hozzáférési jogokat nem a .text név varázsolja elő: a betölthető szegmens jelzői és az operációs rendszer leképezései határozzák meg őket. A lapméret és az igazítás miatt a szekcióhatár nem feltétlenül lapvédelmi határ.

3.3.2 A betöltéstől a függvényhívásig

Linuxon az exec során a kernel értelmezi a végrehajtható ELF programfejléceit. Dinamikusan linkelt programnál betölti az interpretert is, majd annak adja át a vezérlést.

A dinamikus linker feldolgozza a függőségeket, betölti a szükséges megosztott objektumokat, végrehajtja az előírt relokációkat és megszervezi a szükséges inicializálásokat. Ezután a program belépési pontjára kerül a vezérlés. Ez nem közvetlenül a C main; a folytatást a main, startup, argumentumok és környezet rész tárgyalja.

Pozíciófüggetlen ELF-objektumnál sok cím számítása szemléletesen:

Afutás=B+V, A_{\text{futás}} = B + V,

ahol BB a betöltési eltolás (load bias), VV az objektumhoz tartozó ELF virtuális cím, AfutásA_{\text{futás}} pedig a tényleges futásidejű cím. Ez nem minden relokáció képlete: külső szimbólum esetén előbb a megfelelő definíciót is meg kell találni.

3.3.2.1 PLT és GOT

A Procedure Linkage Table, röviden PLT, függvényhívásokat közvetítő gépi kódú bejegyzéseket tartalmazhat. A Global Offset Table, vagyis GOT, futásidőben használt címeket tároló táblázat. A GOT nem kizárólag függvénycímekre szolgál.

Egy hagyományos, x86-64 ELF-es, késleltetett feloldású hívás útja:

  1. A hívó kód a függvény PLT-bejegyzését hívja.
  2. A PLT a hozzá tartozó GOT-bejegyzésen keresztül ugrik.
  3. Az első híváskor ez még a dinamikus linker feloldó útvonalához vezet.
  4. A linker megkeresi a megfelelő szimbólumdefiníciót, és rendszerint frissíti a GOT-bejegyzést.
  5. A következő hívások már a feloldott címre jutnak.

Ez a lazy binding, a késleltetett feloldás. Csökkentheti az induláskori munkát, de az első hívás többletköltséget és későn jelentkező szimbólumhibát hozhat.

Az eager binding, vagy azonnali feloldás esetén a függvényhívási relokációk is betöltéskor rendeződnek. A GNU linker -z now kapcsolója ilyen követelményt rögzíthet az objektumban.

A PLT/GOT nem kötelező, változatlan utasítássorozat. A fordító -fno-plt beállítása, a linker optimalizálásai és a célarchitektúra módosíthatják a hívás alakját. A lazy binding elsősorban az erre alkalmas függvényhívási relokációkra vonatkozik: nem azt jelenti, hogy minden címfeloldás elhalasztható.

3.3.3 ASLR, PIE és RELRO

Az ASLR az operációs rendszer címelrendezés-véletlenítési mechanizmusa. A PIE pozíciófüggetlen végrehajtható program: lehetővé teszi, hogy a főprogram is változó címen helyezkedjen el. GNU eszközláncnál ehhez tipikusan -fPIE fordítás és -pie linkelés tartozik.

A PIE és az ASLR nem ugyanaz. Az előbbi a bináris tulajdonsága, az utóbbi futtatási környezeti mechanizmus. Az ELF ET_DYN típusa önmagában sem bizonyítja, hogy az állomány PIE: megosztott könyvtár is lehet ilyen.

A RELRO, vagyis relocation read-only, olyan területet jelöl ki, amelyet a loader a szükséges relokációk után csak olvashatóvá tehet.

GNU linkelési beállítás Szokásos következmény
-z relro A PT_GNU_RELRO tartomány lezárható; a lazy bindinghoz szükséges helyek írhatók maradhatnak
-z now Az érintett objektum függvényhívási relokációinak azonnali feloldása
-z relro -z now Szokásos GNU elrendezésben „full RELRO”, a hívási GOT-terület védelmével is

A -z now önmagában nem tesz memóriát csak olvashatóvá. A főprogram beállításaiból pedig nem szabad a folyamat összes megosztott könyvtárának védelmét levezetni.

3.3.4 Dinamikus szimbólumok, verziózás és interposition

A teljes, gyakran eltávolítható .symtab és a dinamikus linkeléshez használt .dynsym különböző szerepű. A dinamikus linker nem minden forrásbeli függvénynevet lát: az exportálás, a szimbólum láthatósága és a linkelési döntések korlátozzák a halmazt.

A GNU ELF szimbólumverziózás egy névhez verzióazonosítót is társíthat. A vizsgálóeszközök például probe_value@PROBE_1.0 jelöléssel mutathatnak verziózott definíciót; a @@ az alapértelmezett verziót jelölheti. A fogyasztó verziókövetelménye így különböző ABI-generációk megkülönböztetésére használható. Ez nem C-típusellenőrzés, és nem helyettesíti a helyes függvénydeklarációt.

A symbol interposition során egy névhez a keresési szabályok alapján másik objektum definíciója is kiválasztható. Linux/glibc alatt az LD_PRELOAD tipikus eszköz erre, például instrumentációhoz.

Nem minden hívás téríthető el így. A rejtett láthatóság, a helyi kötés, egyes linkerbeállítások és a közvetlenre optimalizált hívások korlátozhatják az interpositiont. Biztonságos végrehajtási módban a loader a környezeti befolyásolás egy részét is korlátozza.

3.3.5 Kidolgozott labor: közvetlen hívás és explicit betöltés

A választott reprodukciós célprofil: Linux x86-64, System V ABI, LP64, GCC 14.2.0, GNU binutils 2.43.1 és glibc 2.40. A példák C17-et használnak; a dinamikus betöltési példa külön jelölt platformkiterjesztést is igényel. A parancsok és az elvárt megfigyelések nem dokumentált futtatási eredmények.

A probe.h fájl:

#ifndef PROBE_H
#define PROBE_H

int probe_value(void);

#endif

A probe.c könyvtári implementáció:

#include "probe.h"

int probe_value(void)
{
    return 42;
}

A direct.c közvetlen fogyasztó:

#include "probe.h"

int main(void)
{
    return probe_value() == 42 ? 0 : 1;
}

Fordítás és linkelés:

gcc -std=c17 -Wall -Wextra -Wpedantic -O0 -g \
    -fPIC -shared probe.c \
    -Wl,-soname,libprobe.so -Wl,-z,relro,-z,now \
    -o libprobe.so

gcc -std=c17 -Wall -Wextra -Wpedantic -O0 -g \
    -fPIE -fplt -pie direct.c -L. -lprobe \
    -Wl,-rpath,'$ORIGIN' -Wl,-z,relro,-z,now \
    -o direct

Az idézőjelezett $ORIGIN a loader számára marad meg: ebben a beállításban a futtatható állomány könyvtárához viszonyított keresést tesz lehetővé.

A vizsgálat lépései:

readelf -SW direct
readelf -lW direct
readelf -dW direct
readelf -rW direct
readelf --dyn-syms -W libprobe.so
readelf -V direct
objdump -d direct

A section–segment hozzárendelést, a NEEDED függőséget, a relokációkat és a probe_value hívási útját kell összekapcsolni. Konkrét címérték vagy PLT-utasítássorozat nincs előírva.

3.3.5.1 dlopen és dlsym

A POSIX dlopen futás közben betöltött objektumhoz ad handle-t; a dlsym a handle keresési tartományában keres szimbólumot. A RTLD_NOW azonnali feloldást kér, a RTLD_LOCAL pedig nem teszi általánosan globálissá az új objektum szimbólumait.

A dynamic.c teljes platformpélda:

#include <dlfcn.h>
#include <stdio.h>

typedef int (*probe_fn)(void);

int main(void)
{
    void *handle = dlopen("./libprobe.so", RTLD_NOW | RTLD_LOCAL);
    if (handle == NULL) {
        fputs("dlopen failed\n", stderr);
        return 1;
    }

    (void)dlerror(); /* Korabbi hiba torlese. */
    void *address = dlsym(handle, "probe_value");
    const char *error = dlerror();

    if (error != NULL || address == NULL) {
        fputs(error != NULL ? error : "NULL symbol address", stderr);
        fputc('\n', stderr);
        (void)dlclose(handle);
        return 1;
    }

    /* Linux/POSIX szerzodes; nem hordozhato ISO C konverzio. */
    probe_fn function = (probe_fn)address;
    int value = function();

    if (dlclose(handle) != 0) {
        return 1;
    }
    return value == 42 ? 0 : 1;
}
gcc -std=c17 -Wall -Wextra -O0 -g \
    -fPIE -pie dynamic.c -ldl \
    -Wl,-z,relro,-z,now -o dynamic

A dlsym eredményének függvénypointerré alakítását az ISO C17 önmagában nem garantálja; itt a Linux/POSIX platform szerződésére támaszkodunk. A -Wpedantic ezért szándékosan hiányzik ebből a parancsból. A konverzió nem ellenőrzi a függvény típusát: az int (void) szerződésnek ténylegesen egyeznie kell.

Érték Tulajdon és élettartam Érvénytelenítés, hibakimenet
handle A program felel a sikeres megnyitás lezárásáért NULL jelzi a megnyitási hibát; lezárás után nem használjuk
address, function Kölcsönzött hozzáférés a betöltött kódhoz dlclose után nem támaszkodunk az érvényességükre
error A runtime által kezelt, kölcsönzött szöveg A következő dlerror felülírhatja; nem szabad free-vel felszabadítani

A dlsym null eredménye önmagában nem általános hibateszt; ezért használjuk a dlerror protokollját. A példa ettől függetlenül elutasítja a null címet, mert meghívható függvényt vár. A dlclose nem feltétlenül távolítja el azonnal a könyvtárat, de erre nem építhető élettartam-garancia.

3.3.6 Gyakori félreértések

A section nem önálló memória-leképezés: a betöltési nézetet a programfejlécekből kell kiolvasni. A .bss kis fájlköltsége nem jelent nulla futásidejű memóriaigényt.

A sikeres linkelés nem garantálja, hogy futáskor minden könyvtár és megfelelő szimbólumverzió rendelkezésre áll. Lazy binding esetén egy hiányzó függvénydefiníció hibája akár az első érintett hívásig is késhet.

Végül a sikeres dlsym csak névfeloldást igazol. Nem bizonyítja a függvénytípus, az ABI vagy az élettartam helyességét.

3.3.7 Feladatok

  1. Magyarázat: Miért nem szükséges, hogy minden .text szekcióhoz külön PT_LOAD tartozzon? Miért lehet a .bss memóriabeli mérete nagyobb a fájlbeli tartalmánál?
  2. Kódolás: Készíts a direct programból lazy bindingot kérő változatot. Melyik linkeropciót változtatod meg, és mit vizsgálsz meg?
  3. Diagnosztika: Egy pluginból kapott függvénypointert eltárolnak, lezárják a handle-t, majd később meghívják. Miért nem bizonyít helyességet, ha ez néhány futásban működik?

3.3.8 Megoldások

  1. A szekció linkelési, a szegmens betöltési egység; egy szegmens több szekciót foghat össze. A .bss tartalmát nullázás állítja elő, nem azonos méretű fájlbeli bájtsorozat. Ellenőrzés: a readelf -lW hozzárendelése és a megfelelő p_memsz, p_filesz értékek összevetése.
  2. A fogyasztó linkelésében a -z now helyére -z lazy kerül, például -Wl,-z,relro,-z,lazy alakban. Ellenőrzés: a dinamikus jelzők és a hívási relokációk vizsgálata. Az azonnali feloldási jelző hiánya még nem bizonyít tényleges lazy működést: például a glibc LD_BIND_NOW környezeti beállítása felülírhatja a várakozást.
  3. Más hivatkozás vagy loaderbeállítás életben tarthatta a könyvtárat. Ez esetleges körülmény, nem a tárolt pointer szerződése. A javítás a handle életben tartása minden lehetséges hívás végéig. Ellenőrzési feltétel: lezárás után sem közvetlen, sem eltárolt callbacken keresztüli hívás nem maradhat lehetséges.

3.3.9 Összefoglaló

Az ELF szekciói a linkelési, programfejlécei a betöltési nézetet adják. A dinamikus linker a leképezett objektumokhoz címeket és szimbólumdefiníciókat rendel; a PLT/GOT ennek egyik tipikus hívási mechanizmusa. A PIE, ASLR és RELRO különböző feladatokat lát el. Explicit betöltéskor pedig a névfeloldáson túl a függvénytípus és a betöltött kód élettartama is az alkalmazás szerződésének része.