A Control-flow integrity ( CFI ) a számítógépes biztonsági technikák általános elnevezése, amelynek célja a programvégrehajtás lehetséges útvonalainak korlátozása egy előre megjósolt vezérlőfolyamat gráfon belül a biztonság növelése érdekében [1] . A CFI megnehezíti a támadó számára, hogy átvegye a program végrehajtásának irányítását, mivel lehetetlenné teszi a gépi kód már meglévő részeinek újrafelhasználását. Hasonló technikák közé tartozik a kódmutató szétválasztás (CPS) és a kódmutató integritása (CPI) [2] [3] .
A CFI-támogatás megtalálható a Clang [4] és a GCC [5] fordítóprogramokban , valamint a Control Flow Guard [6] és Return Flow Guard [7] a Microsofttól, valamint a Reuse Attack Protector [8] a PaX csapattól.
A tetszőleges kódok végrehajtása elleni védekezési módok feltalálása, mint például az adatvégrehajtás megakadályozása és az NX-bit , olyan új módszerek megjelenéséhez vezetett, amelyek lehetővé teszik a program feletti irányítás megszerzését (például visszatérés-orientált programozás ) . 8] . 2003-ban a PaX Team közzétett egy dokumentumot, amelyben leírja a program feltöréséhez vezető lehetséges helyzeteket, és ötleteket az ellenük való védekezésre [8] [9] . 2005-ben a Microsoft kutatóinak egy csoportja formalizálta ezeket az elképzeléseket, és megalkotta a Control-flow integritás kifejezést, amely a program eredeti vezérlőfolyamatának változásaival szembeni védelem módszereire utal. Ezen túlmenően a szerzők egy módszert javasoltak a már lefordított gépi kódok műszerezésére [1] .
Ezt követően a kutatók a CFI ötlete alapján számos különféle módot javasoltak a program támadásokkal szembeni ellenállásának növelésére. A leírt megközelítéseket nem alkalmazták széles körben, többek között a programok jelentős lelassulása vagy a további információk iránti igény miatt (például profilalkotás révén ) [10] .
2014-ben a Google kutatóinak egy csoportja publikált egy tanulmányt, amely a CFI megvalósítását vizsgálta a GCC és az LLVM ipari fordítók számára a C++ programok műszerezésére. A hivatalos CFI támogatást 2014-ben a GCC 4.9.0 [5] [11] , 2015-ben pedig a Clang 3.7 [12] [13] adta . A Microsoft 2014-ben kiadta a Control Flow Guard programot Windows 8.1 -hez, amely az operációs rendszer támogatásával egészítette ki a Visual Studio 2015-öt [6] .
Ha a programkódban közvetett ugrások vannak , akkor lehetséges, hogy a vezérlést bármilyen címre át lehet vinni, ahol a parancs található (például x86 -on ez bármilyen lehetséges cím lesz, mivel a parancs minimális hossza egy bájt [14] ). Ha egy támadó valamilyen módon módosítani tudja azt az értéket, amellyel az irányítást átadja egy ugrásutasítás végrehajtásakor, akkor a meglévő programkódot újra felhasználhatja saját igényeire.
A valós programokban a nem lokális ugrások általában a függvények elejéhez vezetnek (például eljáráshívási utasítás használata esetén), vagy a hívó utasítást követő utasításhoz (procedure return). Az átmenetek első típusa egy közvetlen (angolul forward-edge ) átmenet, mivel azt a vezérlőfolyamat grafikonon egy közvetlen ív jelöli. A második típust vissza (eng. back-edge ) átmenetnek nevezzük, az elsőhöz hasonlóan - az átmenetnek megfelelő ív fordított lesz [15] .
Közvetlen ugrások esetén a lehetséges címek száma, amelyekre a vezérlés átvihető, megfelel a programban lévő funkciók számának. Továbbá, ha figyelembe vesszük a forráskódot tartalmazó programozási nyelv típusrendszerét és szemantikáját, további korlátozások is lehetségesek [16] . Például a C++ nyelvben egy helyes programban az indirekt hívásban használt függvénymutatónak tartalmaznia kell egy olyan függvény címét, amelynek típusa megegyezik a mutató típusával [17] .
A vezérlés-folyamat integritás megvalósításának egyik módja a közvetlen ugrásoknál az, hogy elemezheti a programot, és meghatározhatja a különböző ági utasítások jogi címeinek halmazát [1] . Egy ilyen halmaz felépítéséhez általában statikus kódelemzést alkalmaznak az absztrakció valamilyen szintjén ( forráskód , az analizátor belső reprezentációja vagy gépi kód [1] [10] ). Ezután a kapott információ felhasználásával a közvetett elágazás utasításai mellé kód kerül beillesztésre, hogy ellenőrizze, hogy a futás közben kapott cím megegyezik-e a statikusan számított címmel. Divergencia esetén a program általában összeomlik, bár az implementációk lehetővé teszik a viselkedés testreszabását az előre jelzett vezérlési folyamat megsértése esetén [18] [19] . Így a vezérlőfolyamat gráf csak azokra az élekre (függvényhívásokra) és csúcsokra (függvénybelépési pontokra) korlátozódik [1] [16] [20] , amelyek kiértékelésre kerülnek a statikus elemzés során, tehát amikor megpróbáljuk módosítani az indirekt ugráshoz használt mutatót. , a támadó megbukik.
Ezzel a módszerrel megakadályozható az ugrásorientált programozás [21] és a hívásorientált programozás [22] , mivel az utóbbiak aktívan használnak direkt indirekt ugrásokat.
A visszafelé történő átmenetekhez a CFI megvalósításának többféle megközelítése lehetséges [8] .
Az első megközelítés ugyanazokon a feltételezéseken alapul, mint a CFI a közvetlen ugrások esetében, vagyis azon, hogy egy függvényből ki lehet számítani a visszatérési címeket [23] .
A második megközelítés a visszaküldési cím speciális kezelése. Amellett, hogy egyszerűen elmentjük a verembe , egy speciálisan erre kijelölt helyre (például a processzor regisztereinek egyikébe) is elmentésre kerül, esetleg némi módosítással. Ezenkívül a return utasítás előtt egy kód kerül hozzáadásra, amely visszaállítja a visszatérési címet, és összeveti a veremben lévővel [8] .
A harmadik megközelítés további támogatást igényel a hardvertől. A CFI-vel együtt árnyékveremet használnak - egy speciális, a támadó számára elérhetetlen memóriaterületet, amelybe a függvények hívásakor visszatérési címek kerülnek tárolásra [24] .
A visszaugrásos CFI-sémák implementálásakor lehetőség nyílik a könyvtárba visszatérő támadások megelőzésére, valamint a verem visszatérési címének megváltoztatásán alapuló visszatérés - orientált programozásra [ 23] .
Ebben a részben a vezérlés-folyamat integritás-megvalósításainak példáit tekintjük át.
A közvetett függvényhívás-ellenőrzés (IFCC) magában foglalja a program közvetett ugrásainak ellenőrzését, néhány "speciális" ugrás kivételével, mint például a virtuális függvényhívások. Amikor olyan címkészletet állítunk össze, amelyre áttérés történhet, figyelembe veszi a függvény típusát. Ennek köszönhetően nemcsak a hibás értékek használata, amelyek nem mutatnak a funkció elejére, megakadályozható, hanem a forráskódban a hibás típusú öntvény is. Az ellenőrzések engedélyezéséhez a fordítóban van egy opció -fsanitize=cfi-icall[4] .
// clang-ifcc.c #include <stdio.h> int összeg ( int x , int y ) { vissza x + y _ } int dbl ( int x ) { return x + x ; } void call_fn ( int ( * fn )( int )) { printf ( "Eredmény értéke: %d \n " , ( * fn )( 42 )); } void erase_type ( void * fn ) { // A viselkedés definiálatlan, ha az fn dinamikus típusa nem egyezik meg az int-vel (*)(int). call_fn ( fn ); } int main () { // Az erase_type meghívásakor a statikus típusinformáció elveszik. erase_type ( összeg ); return 0 ; }Az ellenőrzések nélküli programok hibaüzenetek nélkül fordulnak le, és meghatározatlan eredménnyel futnak, amely futásonként változik:
$ clang -Wall -Wextra clang-ifcc.c $ ./a.out Eredmény értéke: 1388327490A következő opciókkal összeállítva egy olyan programot kapunk, amely a call_fn meghívásakor leáll.
$ clang -flto -fvisibility=rejtett -fsanitize=cfi -fno-sanitize-trap=all clang-ifcc.c $ ./a.out clang-ifcc.c:12:32: futásidejű hiba: az „int (int)” típusú vezérlőfolyam integritásának ellenőrzése sikertelen volt a közvetett függvényhívás során (./a.out+0x427a20): megjegyzés: (ismeretlen) itt van megadvaEz a módszer a virtuális hívások integritásának ellenőrzésére irányul a C++ nyelven. Minden egyes virtuális függvényeket tartalmazó osztályhierarchiához bittérképek készülnek, amelyek megmutatják, hogy mely függvények hívhatók meg az egyes statikus típusokhoz. Ha a programban való végrehajtás során bármely objektum virtuális függvényeinek táblázata megsérül (például hibás típus , ami ledobja a hierarchiát, vagy egyszerűen memóriasérülés a támadó által), akkor az objektum dinamikus típusa nem fog megegyezni a statikusan előrejelzett értékekkel. [10] [25] .
// virtual-calls.cpp #include <cstdio> struct B { virtuális void foo () = 0 ; virtuális ~ B () {} }; struct D : public B { void foo () override { printf ( "Jobb függvény \n " ); } }; struct Rossz : public B { void foo () override { printf ( "Rossz függvény \n " ); } }; int main () { Rossz rossz ; // A C++ szabvány így engedélyezi az öntést: B & b = static_cast < B &> ( rossz ); // Származtatott1 -> Alap -> Származtatott2. D & normál = static_cast < D &> ( b ); // Ennek eredményeként az objektum dinamikus típusa normál normál . foo (); // rossz lesz, és rossz függvény lesz meghívva. return 0 ; }Az ellenőrzések engedélyezése nélküli fordítás után:
$ clang++ -std=c++11 virtual-calls.cpp $ ./a.out Hibás funkcióA programban ahelyett, hogy az osztály foomegvalósítását Da . Ezt a problémát akkor fogja észlelni, ha a programot a következővel fordítja : fooBad-fsanitize=cfi-vcall
$ clang++ -std=c++11 -Wall -flto -fvisibility=rejtett -fsanitize=cfi-vcall -fno-sanitize-trap=all virtual-calls.cpp $ ./a.out virtual-calls.cpp:24:3: futásidejű hiba: a „D” típusú vezérlőfolyam integritásának ellenőrzése nem sikerült a virtuális hívás során (vtable cím: 0x000000431ce0) 0x000000431ce0: Megjegyzés: A vtable "rossz" típusú 00 00 00 00 30 a2 42 00 00 00 00 00 e0 a1 42 00 00 00 00 00 60 a2 42 00 00 00 00 00 00 00 00 00 ^