16.4 C biztonsági hibaminták és védekezés

A C program biztonsági hibája gyakran egy megsértett szerződés következménye: a hossz nem fér bele a tárolóba, a pointer mögötti objektum már nem él, vagy a fájlnév ellenőrzése és használata nem ugyanarra az objektumra vonatkozik. A javítás ezért nem pusztán egy újabb feltétel hozzáadása. Meg kell nevezni, milyen invariánst állít helyre, és milyen feltételek mellett.

A formázás és szövegfeldolgozás, a fájlrendszer-műveletek, valamint a tesztelés és fuzzing korábbi szerződéseire építünk. A nyelvi állítások alapja ISO C17; a platformfüggő védekezéseket külön jelöljük.

16.4.1 Bounds és egészaritmetika: ellenőrzés a művelet előtt

A memória-biztonság (memory safety) szükséges feltétele, hogy minden hozzáférés élő objektum megfelelő tartományán belül történjen. A bemenetvalidálás (input validation) ennél tágabb: a bemenetnek a szintaktikai, szemantikai és erőforrás-korlátokat is teljesítenie kell. Egy reprezentálható, de több terabájtos hossz például nem feltétlenül elfogadható kérés.

ISO C17. A size_t előjel nélküli egész típus. Túlcsordulása moduláris aritmetikát követ; ettől az eredmény még lehet hibás allokációs méret. Az előjeles egész túlcsordulása ezzel szemben nem definiált viselkedés.

Legyen CC a kapacitás, oo az eltolás, nn pedig a feldolgozandó C bájtok száma. A kívánt feltétel matematikailag:

0≤o≤C,0≤n≤C−o. 0 \le o \le C,\qquad 0 \le n \le C-o.

C-ben ezért az ellenőrzés:

/* C17-részlet; minden változó size_t típusú. */
if (offset > capacity || length > capacity - offset) {
    /* A tartomány elutasítása. */
}

A rövidzáras kiértékelés miatt a kivonás csak akkor történik meg, amikor nem csordul alul. Az offset + length <= capacity alak önmagában hibás lehet, mert már az összeadás körbefordulhat.

Hasonlóan, kk darab, egyenként ss C bájtos elemhez:

ks≤SIZE_MAX⇔k≤⌊SIZE_MAXs⌋(s>0). k s \le \mathrm{SIZE\_MAX} \quad\Longleftrightarrow\quad k \le \left\lfloor\frac{\mathrm{SIZE\_MAX}}{s}\right\rfloor \qquad(s>0).

Bizonyításvázlat. Pozitív ss esetén az egyenlőtlenség osztható ss-sel; egész kk mellett a felső korlát lefelé kerekítendő. A vizsgálatot a szorzás előtt kell elvégezni. Ez csak a reprezentálhatóságot igazolja, az allokáció sikerét és az alkalmazási kvótát nem.

16.4.1.1 Kidolgozott példa: korlátozott szövegmásolat

A következő teljes program minimum ISO C17-et igényel. A text_copy szerződése szerint nem nulla hossz esetén a src legalább n olvasható C bájtra mutat. Ezt egy nem null pointer ellenőrzése önmagában nem bizonyítja.

/* safe_text.c */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static char *text_copy(const char *src, size_t n)
{
    if ((src == NULL && n != 0) ||
        n == SIZE_MAX || n > 4096) {
        return NULL;
    }

    char *p = malloc(n + 1);
    if (p == NULL) {
        return NULL;
    }

    if (n != 0) {
        memcpy(p, src, n);
    }
    p[n] = '\0';
    return p;
}

int main(void)
{
    const char input[] = "status=ready";
    char *message = text_copy(input, sizeof input - 1);

    if (message == NULL) {
        return EXIT_FAILURE;
    }

    int failed = printf("%s\n", message) < 0;
    free(message);
    message = NULL;

    if (fflush(stdout) == EOF) {
        failed = 1;
    }
    return failed ? EXIT_FAILURE : EXIT_SUCCESS;
}

Az ellenőrzés után n+1n+1 reprezentálható. Sikeres allokációkor a másolás a [0,n) tartományt írja, a terminátor az n indexre kerül; mindkettő a lefoglalt [0,n+1) tartományon belül van. A függvény legfeljebb lineáris idejű, szekvenciális másolást és egyetlen allokációt végez.

Objektum vagy hozzáférés Tulajdonos és élettartam Érvénytelenítés, hibakimenet
input tömb A main lokális objektuma A blokkból kilépéskor megszűnik
src Kölcsönzött hozzáférés a hívás idejére A forrás megszüntetése vagy tiltott egyidejű módosítása szerződésszegés
Visszaadott tároló Siker után a hívó felelőssége free érvényteleníti; hiba esetén NULL, nincs átadott tároló
message Tulajdonosi szerepű pointerváltozó Nullázása csak ezt a változót módosítja

A tömör példa minden hibát NULL eredménnyel jelez; könyvtári API-ban érdemes különválasztani az érvénytelen argumentumot, a limitet és a memóriahiányt. A másolás nem validál szövegkódolást, és beágyazott nullkaraktert sem tilt: azt a %s megjelenítés már szövegvégként értelmezi.

16.4.2 Use-after-free és double-free: tulajdonlási hibák

ISO C17. A free megszünteti az allokált objektum élettartamát. Az arra támaszkodó kölcsönzött hozzáférések többé nem használhatók. A felszabadított objektum elérése (use-after-free) és ugyanazon allokáció ismételt felszabadítása (double-free) nem definiált viselkedés.

A free(message); message = NULL; hasznos helyi állapotváltás, de nem teljes megoldás: egy korábban elmentett alias ettől még dangling marad. A free(NULL) megengedett, ám ez nem teszi biztonságossá a többi példányt.

A helyes invariáns:

Minden allokációhoz egyértelmű felszabadítási felelősség tartozik; a kölcsönzések befejeződnek a felszabadítás előtt, és minden végrehajtási út legfeljebb egyszer szabadít fel.

Hibaágaknál ezért közös cleanup út vagy egyértelmű tulajdonosátadás szükséges. Az „eddig nem omlott össze” nem élettartam-bizonyíték.

16.4.3 Format string és nem megbízható szöveg

ISO C17. A formátumsztring vezérli, milyen variadikus argumentumokat és típusokat vár a formázó függvény. A printf(input) ezért nem biztonságos, ha az input külső eredetű: az adatot vezérlőnyelvként értelmezi.

A helyes változat printf("%s", input), feltéve, hogy az input élő, olvasható, nullterminált karaktertömbre mutat. Pointer–hossz szeletnél a fwrite(input, 1, length, stream) elkerüli a terminátor keresését, de a tartomány érvényességét továbbra is a hívó garantálja, és a részleges írást kezelni kell.

A formátumhiba megszüntetése nem jelent teljes kimenetbiztonságot. Újsorral meghamisítható egy naplóbejegyzés szerkezete, vezérlőkarakterek befolyásolhatják a terminált. Strukturált naplózáshoz kontextusfüggő escape-elés és hosszkorlát is szükséges. A sztring „biztonságossága” mindig a felhasználás módjához tartozik.

16.4.4 TOCTOU, útvonalak és symlinkek

A time-of-check to time-of-use — TOCTOU — hiba akkor keletkezik, amikor az ellenőrzés és a használat között megváltozhat az ellenőrzött állapot. Fájlrendszernél a lstat(path) után végzett open(path, ...) új névfeloldást indít: a két művelet más objektumot érhet el. Az access sem foglalja le a későbbi megnyitás eredményét.

POSIX/Linux alkalmazási minta. Egy meglévő szabályos fájl olvasására használható szűk szerződés:

  1. A hívó birtokol egy megbízható könyvtárhoz tartozó, nyitott dirfd descriptort.
  2. A bemenet egyetlen nem üres névkomponens; nem ".", nem "..", és nem tartalmaz / karaktert.
  3. A program openat(dirfd, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) hívással nyit.
  4. A visszaadott descriptoron fstat segítségével ellenőrzi a típust és az alkalmazási metaadatfeltételeket.
  5. Elutasításkor lezárja; siker esetén a hívó lesz a descriptor tulajdonosa.

Az O_NONBLOCK például FIFO esetén segít elkerülni a megnyitás miatti várakozást; nem általános időkorlát. A minta feltétele, hogy a könyvtár és környezete ne engedjen támadó által létrehozott, veszélyes eszközobjektumokat: a megnyitásnak már az utólagos típusellenőrzés előtt lehet hatása.

Az O_NOFOLLOW a végső komponens symlinkként követését tiltja. Többkomponensű útvonal köztes symlinkjeire nem ad általános védelmet. Linuxon az openat2 megfelelő RESOLVE_* korlátozásai erősebb feloldási szerződést adhatnak, de kernel- és interfésztámogatásukat külön ellenőrizni kell.

A megnyitott descriptor stabil objektumhivatkozás, nem változhatatlan tartalompillanatkép. Más folyamat továbbra is módosíthatja a fájlt. A hard linkek, mount-határok és hozzáférési jogosultságok szintén külön szabályzatot igényelnek. A realpath és egy sztringprefix-vizsgálat nem helyettesíti ezt.

16.4.5 Inicializálatlan adatok, padding és titkok

ISO C17. A struktúratagok értéke és a teljes objektumreprezentáció nem azonos fogalom. A kitöltőbájtok (padding) nem alkalmazási mezők. A fwrite(&record, sizeof record, 1, stream) ezért nem megfelelő általános szerializálás: paddinget és kihagyott inicializálás esetén korábbi memóriaadatokat is kivihet.

A javítás mezőnként felépített kimenet, amelynek minden elküldött C bájtját explicit értékadás előzi meg. Például egy 8 bites oktetteket használó protokollban egy címke és egy 32 bites számláló öt oktettként kódolható, rögzített bájtsorrenddel. Ehhez a profilban CHAR_BIT == 8 és a választott egész típus megfelelő tartománya szükséges. A struktúra kezdeti nullázása önmagában nem hordozható padding-szerződés későbbi tagírások után.

A titkok törlésének más a problémája. Egy használat utáni memset(secret, 0, size) eltávolítható, ha az optimalizáló szerint az írásnak nincs megfigyelhető hatása. A free pedig nem ígér törlést.

Platform- és verziófüggő megoldás: glibc környezetben az explicit_bzero erre szolgáló interfész; a deklaráció elérhetőségéhez megfelelő feature-test profil kell. C23-ban a memset_explicit külön lehetőség, de nem része a C17 alapnak. A memset_s opcionális Annex K interfész, nem feltételezhető általánosan.

A törlés előfeltétele élő, írható tároló és helyes méret; a sorrend: használat befejezése, explicit törlés, felszabadítás. Ez sem garantálja a regiszterekben, korábbi másolatokban, naplókban vagy core dumpban maradt titkok eltűnését.

16.4.6 Fordítási védelem: következménycsökkentés, nem javítás

A következő választott laborprofil: Linux x86-64 System V, LP64, GCC 14.2.0, glibc 2.40, GNU binutils 2.43. A parancs nem itt végzett fordítás eredménye; helyi támogatását és a létrejött bináris tulajdonságait ellenőrizni kell.

gcc -std=c17 -O2 -g \
  -Wall -Wextra -Wpedantic -Wformat=2 -Werror=format-security \
  -fstack-protector-strong -D_FORTIFY_SOURCE=3 \
  -fPIE -pie -Wl,-z,relro,-z,now \
  safe_text.c -o safe_text
Védelem Szerepe Korlátja
PIE Helyfüggetlen végrehajtható állomány; ASLR mellett változhat a betöltési cím Nem végez bounds- vagy élettartam-ellenőrzést
RELRO és azonnali kötés A relocációk után kijelölt ELF-területek írásvédelme Nem védi az összes módosítható adatot
Stack protector Bizonyos stack frame-ek sérülésének észlelése ellenőrzőértékkel Nem minden írást vagy objektumot ellenőriz
Fortify Kiválasztott könyvtári hívások erősített ellenőrzése ismert vagy meghatározható objektummérettel Nem általános pointervalidátor

Ezek rétegzett védelmek. Egyik sem állítja helyre a hibás ownership-szerződést vagy a TOCTOU miatt megsértett bizalmi határt.

16.4.7 Feladatok

  1. Magyarázó: Miért hibás az offset + length <= capacity ellenőrzés? Vizsgáld a laborprofilban az offset = SIZE_MAX - 3, length = 8, capacity = 16 esetet.

  2. Kódolási: Írj checked_bytes függvényt, amely elemszámból és elemméretből ellenőrzött szorzatot készít. Hiba esetén a kimeneti érték maradjon változatlan.

  3. Diagnosztikai: Egy szolgáltatás lstat után open hívással olvas felhasználói fájlnevet, majd nyers struktúrát küld válaszként. Sanitizeres tesztje hibamentes. Nevezz meg két egymástól független hibát, javításukat és ellenőrzési feltételüket.

16.4.8 Megoldások

16.4.8.1 Tartományellenőrzés

A megadott profilban az előjel nélküli összeadás eredménye 4, ezért a hibás feltétel igaz. A helyes ellenőrzés először az offset > capacity feltétellel elutasít. Ellenőrzési pontok: nulla hossz, pontos végpont, egy bájttal túllépett végpont és SIZE_MAX közeli értékek.

16.4.8.2 Ellenőrzött szorzat

Az alábbi C17-részlethez <stdbool.h>, <stddef.h> és <stdint.h> szükséges:

bool checked_bytes(size_t count, size_t element_size, size_t *out)
{
    if (out == NULL ||
        (element_size != 0 && count > SIZE_MAX / element_size)) {
        return false;
    }

    *out = count * element_size;
    return true;
}

Az out nem null esetben élő, írható size_t objektumra mutat a hívás végéig; a függvény nem tárolja el. Hiba esetén nincs írás. Ellenőrizendő a nulla szorzat, a SIZE_MAX * 1 elfogadása és a SIZE_MAX * 2 elutasítása. A nulla eredmény későbbi allokációs jelentését külön kell meghatározni.

16.4.8.3 Fájlnévverseny és adatszivárgás

Az első hiba a külön névfeloldások közötti TOCTOU. Javítása a korlátozott descriptorrelatív megnyitás és a megnyitott objektum ellenőrzése. Tesztelendő a symlink, tiltott névkomponens és nem szabályos fájl elutasítása; a teljes feltételrendszert kódreview-val is vizsgálni kell.

A második hiba a nyers objektumreprezentáció kiküldése. Javítása explicit szerializálás. Ellenőrzési feltétel, hogy minden kiküldött bájt meghatározott mezőből származzon, és a kimenet ne függjön paddingtől. A sanitizer hibamentes futása egyik szerződést sem bizonyítja.

16.4.9 Összefoglaló

A biztonságos javítás megnevezi az ellenőrzött tartományt, az aritmetikai korlátot, a tulajdonost és a bizalmi határt. A külső szöveg adat marad, a fájlművelet megnyitott objektumhoz kötődik, a kimenet nem tartalmaz véletlen memóriareprezentációt. A fordítási védelmek és tesztek ezt támogatják, de a helyes szerződést nem helyettesítik.