16.1 Debugger, core dump és optimalizált program

Egy C program incidensvizsgálatakor a folyamat állapotát vissza kell kapcsolnunk a forrásprogram objektumaihoz: melyik függvény futott, milyen pointert kapott, meddig élt a hivatkozott objektum, és melyik művelet sértette meg a szerződést? A debugger ezt a kapcsolatot segít helyreállítani, de nem szolgáltat automatikus helyességbizonyítást.

Az ABI- és optimalizálásdiagnosztikai labor alapján a gépi hívási keret nem feltétlenül követi a forrás függvényhatárait. Az élettartam- és memóriahibák vizsgálati laborjából pedig fontos felidézni: az olvasható memóriacím önmagában nem bizonyítja, hogy ott még él a megfelelő C objektum.

16.1.1 Mit mutat a debugger?

GDB-eszközmodell. A debugger a gépi állapotot, a végrehajtható fájlt és a debug információt együtt értelmezi.

Fogalom Jelentés és korlát
Breakpoint, töréspont Megállás függvénynél, forrássornál vagy gépi utasításnál. Optimalizáláskor egy forrássorhoz több hely tartozhat, vagy nem tartozik külön utasítás.
Watchpoint, adatfigyelési pont Egy kifejezés értékváltozását figyeli. A hardveres támogatás méret- és darabszámkorlátos; a szoftveres megoldás lassú lehet.
Backtrace, hívási visszakövetés A debugger által rekonstruált hívási lánc. Nem teljes végrehajtási történet.
Frame, hívási keret Egy híváshoz rendelt vizsgálati környezet: argumentumok, lokális változók, visszatérési hely. Inline függvényhez fizikai stack frame nélkül is megjelenhet logikai frame.

A bt megmutatja a hívási láncot, a frame N kiválaszt egy keretet, az up a hívó felé lép. Ez a kiválasztás nem futtatja a programot: azt változtatja meg, hogy például az info locals melyik függvény változóit értelmezi.

A watchpoint sem időgép. Egy betöltött core dumpban már nem figyelhetjük meg a korábbi írást; ehhez élő, reprodukált futás szükséges.

16.1.2 Kidolgozott példa: szerződéssértés UB nélkül

A következő programban szándékosan hibás a ciklusfeltétel. A hozzáférést ellenőrző függvény azonban még a tömbhatár átlépése előtt megszakítja a végrehajtást.

ISO C17. A példa nem támaszkodik nem definiált viselkedésre. Az abort() rendellenes befejezést kezdeményez; a core fájl létrejötte már platform- és konfigurációfüggő.

Választott, helyben ellenőrizendő laborprofil: Linux, x86-64 System V ABI, LP64, 8 bites C bájt, 4 C bájtos int; GCC 14.2.0, GNU binutils 2.43, glibc 2.40 és GDB 15.2. A parancsok ehhez választott laborutasítások, nem dokumentált futtatási eredmények.

A batch.c teljes tartalma:

#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>

struct batch {
    size_t count;
    int values[3];
};

static int value_at(const struct batch *batch, size_t index)
{
    if (batch == NULL ||
        batch->count > sizeof batch->values / sizeof batch->values[0] ||
        index >= batch->count) {
        fputs("batch bounds contract violated\n", stderr);
        abort();
    }

    return batch->values[index];
}

int main(void)
{
    struct batch owned = {
        .count = 3,
        .values = {10, 20, 30}
    };
    int total = 0;
    size_t i = 0;

    for (; i <= owned.count; ++i) { /* Szándékos logikai hiba. */
        total += value_at(&owned, i);
    }

    printf("%d\n", total);
    return EXIT_SUCCESS;
}

A value_at előfeltétele: a nem null batch pointer élő, olvasható struct batch objektumra mutat, amelyet közben más végrehajtás nem módosít. A nullvizsgálat nem tudja ellenőrizni ezt az élettartam-szerződést.

Szerep Objektum vagy hozzáférés Élettartam és érvénytelenítés
Tulajdonos A main lokális owned objektuma Automatikus tárolású; normál esetben a blokk elhagyásáig él.
Tároló Az owned objektum részeként tárolt values tömb Élettartama az azt tartalmazó objektumhoz kötött.
Kölcsönzött hozzáférés A value_at batch paramétere A hívás alatt használható; nem kerül eltárolásra vagy visszaadásra.
Hibakimenet Diagnosztikai üzenet és abort() Nem ad vissza érvénytelen elemet, és nem olvas tömbhatáron túl.

GNU eszközlánc. Készítsünk diagnosztikai és optimalizált változatot:

gcc -std=c17 -Wall -Wextra -Wpedantic -Og -g3 \
    -fno-omit-frame-pointer -Wl,--build-id \
    batch.c -o batch-debug

gcc -std=c17 -Wall -Wextra -Wpedantic -O2 -g3 \
    -fno-omit-frame-pointer -Wl,--build-id \
    batch.c -o batch-opt

A -g3 debug információt kér, nem tiltja az optimalizálást. A frame pointer megtartása egyes esetekben segíti a visszakövetést, de nem garantálja a lokális változók elérhetőségét.

16.1.2.1 Élő vizsgálat

Az első value_at hívásnál megálló munkamenet:

gdb -q ./batch-debug \
    -ex 'break value_at' \
    -ex run \
    -ex bt \
    -ex 'info args' \
    -ex 'print *batch' \
    -ex 'print batch->values[0]@3' \
    -ex 'x/3wd batch->values'

A print *batch típusosan értelmezi a struktúrát. A @3 GDB-szintaxis három egymás melletti elemből képez megjelenítendő mesterséges tömböt; nem C operátor.

Az x/3wd nyers memóriavizsgálat: három, egyenként négy bájtos egységet jelenít meg előjeles decimális formában. Ez a választott laborprofilban illeszkedik az int reprezentációméretéhez, de nem általános C-garancia. Az x nem ellenőriz ownershipet, objektumhatárt vagy élettartamot.

Az up után a main keretében az info locals megmutathatja az owned, i és total értékét. A watch i, majd continue segítségével követhetjük az index változását. A töréspont és a watchpoint felváltva is megállíthatja a programot; az info breakpoints listájából kiválasztott pontot a disable N paranccsal kapcsolhatjuk ki.

Levezetés. A logikai tartomány [0,n)[0,n), ahol n=3n=3. A hibás i <= n feltétel az i=3i=3 értéket is beengedi. Ekkor az index >= batch->count igaz, ezért a függvény megszakítja a futást a dereferálás előtt. A forrás alapján várt hibakép tehát index == count == 3; ez nem állítás egy ténylegesen megfigyelt debugger-kimenetről.

16.1.3 Core dump és azonosítható build

Linux. A core dump egy folyamat állapotának utólag vizsgálható pillanatképe: tartalmazhat regisztereket, szálállapotokat és memóriatartományokat. Nem feltétlenül tartalmaz minden leképezést vagy minden szükséges fájlt, és nem őrzi meg az események történetét.

Külön laborban, a debuggeren kívüli futáshoz:

ulimit -c unlimited
cat /proc/sys/kernel/core_pattern
./batch-debug

A ulimit csak az adott shellből induló folyamat korlátját módosítja, a megengedett felső korlátig. A core_pattern, a jogosultságok, a folyamat dumpolhatósága és a szolgáltatáskezelő szabályai továbbra is meghatározók. Konténerben a fogadó mechanizmus akár a hoston működhet.

Systemd-alapú környezetben a coredumpctl list, majd a megfelelő bejegyzéshez tartozó coredumpctl dump PID --output=core.incident használható; a PID helyére a kiválasztott azonosító kerül. Ez systemd-eszköz, nem ISO C vagy POSIX szolgáltatás.

A megfelelő core betöltése:

gdb -q ./batch-debug core.incident \
    -ex 'info files' \
    -ex 'info sharedlibrary' \
    -ex 'thread apply all bt'

A megszakítás helye várhatóan az abort() megvalósításában vagy az alatta futó libc-kódban látszik. Ez nem jelenti azt, hogy a libc hibás. A backtrace-ben keressük meg az első saját kódhoz tartozó keretet, majd a tényleges sorszámával válasszuk ki.

A minimális hibakép:

Ez már javítható állítás, nem pusztán egy stack trace.

16.1.3.1 Bináris, szimbólum és megosztott könyvtár

A forráscommit egyezése kevés. Más fordító, kapcsolók vagy függőségek más gépi kódot és címkiosztást eredményezhetnek. Az eredeti végrehajtható fájlt, megosztott könyvtárakat és a hozzájuk tartozó debug fájlokat kell megőrizni.

GNU ELF-eszközlánc. A build-id egy buildhez rendelt azonosító, amely például a következő paranccsal vizsgálható:

readelf -n batch-opt

Hasznos az artefaktumok összerendeléséhez, de önmagában nem kriptográfiai hitelességigazolás.

Külön debug fájl készítésének példája:

objcopy --only-keep-debug batch-opt batch-opt.debug
objcopy --strip-debug batch-opt
objcopy --add-gnu-debuglink=batch-opt.debug batch-opt

A .gnu_debuglink a megfelelő debug fájl megtalálását és ellenőrzését segíti. A core mellé ne utólag újrafordított „azonos verziót”, hanem az adott kiadási artefaktumot adjuk.

Más gépről származó core esetén az eredeti gyökérfájlrendszer másolatához a set sysroot /srv/incident/rootfs, a debug fájlokhoz a set debug-file-directory /srv/incident/symbols állítható be, lehetőleg a core betöltése előtt. Az info sharedlibrary segít ellenőrizni a felismert könyvtárakat. A build-id-t és a szimbólumegyezést minden releváns megosztott könyvtárnál vizsgáljuk; a helyi libc nem automatikusan megfelelő.

A core érzékeny adatokat, például hitelesítő adatokat is tartalmazhat. Ugyanúgy kontrollált hozzáféréssel és megőrzési idővel kezeljük, mint más incidensartefaktumot.

16.1.4 Optimalizált változók, inline keretek és assembly

GDB és compiler debug információ. Az <optimized out> nem azt jelenti, hogy a változó nulla vagy inicializálatlan. Azt jelzi, hogy a debugger az adott ponton nem tudja rekonstruálni az értékét. A fordító megszüntethette a tárolását, összevonhatta más számítással, vagy a debug információ nem írja le megfelelően az aktuális helyét.

Az optimalizált példában a fordító akár a számítások jelentős részét is elhagyhatja, mert azok nem befolyásolják az elérhető megfigyelhető hatásokat. A value_at inline beépítése után külön gépi hívás nélkül is szerepelhet a backtrace-ben.

Hasznos parancsok:

A kevert nézet nem állítja vissza a forrás szerinti végrehajtási sorrendet. A next és step ezért kihagyhat sorokat, vagy látszólag visszafelé léphet. Gépi szinten a stepi használható, de a regisztereket az ABI és az adott utasítássorozat alapján kell értelmezni.

Gyakori hiba a debugger által olvasható pointert érvényesnek tekinteni, a legfelső frame-et gyökéroként kezelni, vagy hiányzó értékeket feltételezésekkel kitölteni. Helyette válasszuk külön a megfigyelt tényt, a forrásból levezetett állítást és a még ellenőrizendő hipotézist.

16.1.5 Feladatok

  1. Magyarázó: Miért nem tömbtúlolvasás bizonyítéka ebben a példában a SIGABRT? Miért nem elegendő egy pointer olvashatósága az érvényességéhez?

  2. Kódolási: Javítsd a ciklust. Vizsgáld meg a count értékeit: 0, 1 és 3. Adj meg ellenőrzési feltételt az eredményre és a hozzáférésekre.

  3. Diagnosztikai: Az optimalizált core vizsgálatakor az i értéke nem érhető el, és a libc szimbólumaihoz eltérésjelzés tartozik. Milyen sorrendben folytatod az elemzést?

16.1.6 Megoldások

16.1.6.1 Magyarázó feladat

Az ellenőrzés az elemelérés előtt hívja az abort() függvényt, ezért ezen az útvonalon nem történik tömbtúlolvasás. Az olvasható memória lehet már megszűnt objektum tárolója is. Az ellenőrzés alapja a vezérlési út és az élettartam-szerződés, nem egy önmagában sikeres memóriakiolvasás.

16.1.6.2 Kódolási feladat

A javított feltétel i < owned.count. A forrásból várt összegek rendre 0, 10 és 60.

Bizonyításvázlat: a ciklusmag csak i<ni<n mellett fut; az index nulláról indul és egyesével nő. Ezért minden meghívott hozzáférésre 0≤i<n0\le i<n. Ellenőrzési feltétel: a megfelelő összeg jelenik meg, és egyik érvényes eset sem éri el az abort() ágat.

16.1.6.3 Diagnosztikai feladat

Először az artefaktumegyezést kell helyreállítani: eredeti bináris, libc, további könyvtárak és debug fájlok. Ezután újra ellenőrizhető a backtrace. A hiányzó i helyett megvizsgálható a hívási argumentum, a struktúra állapota és az utasítássorozat, ha ezek rekonstruálhatók. Ha nem, ezt korlátként kell rögzíteni. Másik builddel történő reprodukció kiegészítő bizonyíték, nem az eredeti futás állapota.

16.1.7 Összefoglaló

A megbízható hibaképhez pontos artefaktumegyezés, megfelelő frame-választás és a C objektumszerződéseinek ismerete kell. A debugger pillanatképet értelmez; az optimalizálás és a hiányzó adatok korlátozzák, mit tudhatunk meg belőle.