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.
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 a kapacitás, az eltolás, pedig a feldolgozandó C bájtok száma. A kívánt feltétel matematikailag:
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, darab, egyenként C bájtos elemhez:
Bizonyításvázlat. Pozitív esetén az egyenlőtlenség osztható -sel; egész 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.
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
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.
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.
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.
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:
dirfd descriptort.".", nem
"..", és nem tartalmaz / karaktert.openat(dirfd, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)
hívással nyit.fstat segítségével ellenőrzi
a típust és az alkalmazási metaadatfeltételeket.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.