Integrita řídicího toku ( CFI ) je obecný název pro počítačové bezpečnostní techniky zaměřené na omezení možných cest provádění programu v rámci předem predikovaného grafu toku řízení za účelem zvýšení jeho bezpečnosti [1] . CFI znesnadňuje útočníkovi převzít kontrolu nad prováděním programu tím, že některým způsobům znemožňuje opětovné použití již existujících částí strojového kódu. Mezi podobné techniky patří separace ukazatelů kódu (CPS) a integrita ukazatelů kódu (CPI) [2] [3] .
Podpora CFI je přítomna v kompilátorech Clang [4] a GCC [5] , stejně jako Control Flow Guard [6] a Return Flow Guard [7] od Microsoftu a Reuse Attack Protector [8] od týmu PaX.
Vynález způsobů ochrany před spuštěním libovolného kódu, jako je Data Execution Prevention a NX-bit , vedl ke vzniku nových metod, které umožňují získat kontrolu nad programem (například programování orientované na návrat ) [ 8] . V roce 2003 tým PaX publikoval dokument popisující možné situace, které vedou k hacknutí programu, a nápady na ochranu proti nim [8] [9] . V roce 2005 skupina výzkumníků Microsoftu tyto myšlenky formalizovala a vytvořila termín Control-flow Integrity , který odkazuje na metody ochrany proti změnám původního toku ovládání programu. Kromě toho autoři navrhli metodu instrumentace již zkompilovaného strojového kódu [1] .
Následně výzkumníci na základě myšlenky CFI navrhli mnoho různých způsobů, jak zvýšit odolnost programu vůči útokům. Popsané přístupy nebyly široce přijaty z důvodů zahrnujících velké zpomalení programu nebo potřebu dalších informací (například získaných profilováním ) [10] .
V roce 2014 tým výzkumníků z Google zveřejnil článek, který se zabýval implementací CFI pro průmyslové kompilátory GCC a LLVM pro instrumentaci programů C++. Oficiální podpora CFI byla přidána v roce 2014 v GCC 4.9.0 [5] [11] a v roce 2015 v Clang 3.7 [12] [13] . Společnost Microsoft vydala Control Flow Guard v roce 2014 pro Windows 8.1 a přidala podporu operačního systému do Visual Studia 2015 [6] .
Pokud v kódu programu dochází k nepřímým skokům , je potenciálně možné přenést řízení na libovolnou adresu , kde může být příkaz umístěn (například na x86 to bude jakákoli možná adresa, protože minimální délka příkazu je jeden bajt [14] ). Pokud může útočník nějakým způsobem upravit hodnotu, o kterou je řízení přeneseno při provádění instrukce skoku, pak může znovu použít existující programový kód pro své vlastní potřeby.
V reálných programech nelokální skoky obvykle vedou k začátku funkcí (například pokud je použita instrukce volání procedury) nebo k instrukci následující po volající instrukci (návrat procedury). Prvním typem přechodů je přímý (anglicky forward-edge ) přechod, protože bude na grafu řídicího toku označen přímým obloukem. Druhý typ se nazývá zpětný (angl. back-edge ) přechod, analogicky k prvnímu - oblouk odpovídající přechodu bude obrácený [15] .
U přímých skoků bude počet možných adres, na které lze přenést řízení, odpovídat počtu funkcí v programu. Rovněž při zohlednění typového systému a sémantiky programovacího jazyka , ve kterém je zdrojový kód napsán, jsou možná další omezení [16] . Například v C++ ve správném programu musí ukazatel funkce použitý v nepřímém volání obsahovat adresu funkce stejného typu jako ukazatel samotný [17] .
Jedním ze způsobů, jak implementovat integritu řídicího toku pro přímé skoky, je to, že můžete analyzovat program a určit sadu legálních adres pro různé instrukce větve [1] . K sestavení takové sady se obvykle používá analýza statického kódu na určité úrovni abstrakce (na úrovni zdrojového kódu , vnitřní reprezentace analyzátoru nebo strojového kódu [1] [10] ). Poté se pomocí přijatých informací vedle instrukcí nepřímé větve vloží kód pro kontrolu, zda adresa přijatá za běhu odpovídá staticky vypočtené. Při divergenci program obvykle spadne, i když implementace umožňují přizpůsobit chování v případě narušení předpokládaného řídicího toku [18] [19] . Graf řídicího toku je tedy omezen pouze na ty hrany (volání funkcí) a vrcholy (vstupní body funkcí) [1] [16] [20] , které jsou vyhodnocovány během statické analýzy, takže při pokusu o úpravu ukazatele používaného pro nepřímé skákání , útočník selže.
Tato metoda vám umožňuje zabránit programování orientovanému na skok [21] a programování orientovanému na volání [22] , protože toto programování aktivně využívá přímé nepřímé skoky.
U zpětných přechodů je možných několik přístupů k implementaci CFI [8] .
První přístup je založen na stejných předpokladech jako CFI pro přímé skoky, tedy na schopnosti vypočítat návratové adresy z funkce [23] .
Druhým přístupem je zacházet konkrétně se zpáteční adresou. Kromě prostého uložení do zásobníku se také uloží, případně s určitými úpravami, na místo, které je pro to speciálně přiděleno (například do jednoho z registrů procesoru). Před návratovou instrukci je také přidán kód, který obnoví návratovou adresu a porovná ji s tou na zásobníku [8] .
Třetí přístup vyžaduje další podporu ze strany hardwaru. Spolu s CFI se používá shadow stack - speciální paměťová oblast nepřístupná útočníkovi, do které se ukládají návratové adresy při volání funkcí [24] .
Při implementaci CFI schémat pro zpětné skoky je možné zabránit útoku typu return -to-library a programování orientovanému na návrat na základě změny návratové adresy na zásobníku [ 23] .
V této části budou zváženy příklady implementací integrity řídicího toku.
Indirect Function Call Checking (IFCC) zahrnuje kontroly nepřímých skoků v programu, s výjimkou některých „speciálních“ skoků, jako jsou volání virtuálních funkcí. Při konstrukci množiny adres, na které může dojít k přechodu, se bere v úvahu typ funkce. Díky tomu je možné předejít nejen použití nesprávných hodnot, které neukazují na začátek funkce, ale také nesprávnému přetypování ve zdrojovém kódu. Pro povolení kontrol v kompilátoru existuje volba -fsanitize=cfi-icall[4] .
// clang-ifcc.c #include <stdio.h> int součet ( int x , int y ) { vrátit x + y _ } int dbl ( int x ) { návrat x + x ; } void call_fn ( int ( * fn )( int )) { printf ( "Výsledná hodnota: %d \n " , ( * fn )( 42 )); } void erase_type ( void * fn ) { // Chování není definováno, pokud dynamický typ fn není stejný jako int (*)(int). call_fn ( fn ); } int main () { // Při volání erase_type se informace o statickém typu ztratí. typ_mazat ( součet ); návrat 0 ; }Program bez kontrol se zkompiluje bez jakýchkoli chybových zpráv a spustí se s nedefinovaným výsledkem, který se liší běh od běhu:
$ clang -Wall -Wextra clang-ifcc.c $ ./a.out Výsledná hodnota: 1388327490Po zkompilování s následujícími možnostmi získáte program, který se přeruší při volání call_fn.
$ clang -flto -fvisibility=hidden -fsanitize=cfi -fno-sanitize-trap=all clang-ifcc.c $ ./a.out clang-ifcc.c:12:32: chyba běhu: kontrola integrity řídicího toku pro typ „int (int)“ selhala během nepřímého volání funkce (./a.out+0x427a20): poznámka: (neznámé) zde definovánoTato metoda je zaměřena na kontrolu integrity virtuálních volání v jazyce C++. Pro každou hierarchii tříd, která obsahuje virtuální funkce , jsou vytvořeny bitmapy ukazující, které funkce lze volat pro každý statický typ. Pokud je během provádění v programu poškozena tabulka virtuálních funkcí libovolného objektu (například nesprávný typ sestřelení hierarchie nebo jednoduše poškození paměti útočníkem), pak dynamický typ objektu nebude odpovídat žádné z předpovídaných staticky [10] [25] .
// virtual-calls.cpp #include <cstdio> struktura B { virtuální void foo () = 0 ; virtuální ~ B () {} }; struktura D : public B { void foo () override { printf ( "Správná funkce \n " ); } }; struct Bad : public B { void foo () override { printf ( "Špatná funkce \n " ); } }; int main () { Špatný špatný ; // Standard C++ umožňuje přetypování takto: B & b = static_cast < B &> ( bad ); // Derived1 -> Base -> Derived2. D & normal = static_cast < D &> ( b ); // Výsledkem je, že dynamický typ objektu je normal normal . foo (); // bude špatné a bude zavolána špatná funkce. návrat 0 ; }Po kompilaci bez povolených kontrol:
$ clang++ -std=c++11 virtual-calls.cpp $ ./a.out Špatná funkceV programu namísto volání implementace footřídy z . Tento problém bude zachycen, pokud zkompilujete program s : DfooBad-fsanitize=cfi-vcall
$ clang++ -std=c++11 -Wall -flto -fvisibility=hidden -fsanitize=cfi-vcall -fno-sanitize-trap=all virtual-calls.cpp $ ./a.out virtual-calls.cpp:24:3: chyba běhu: kontrola integrity řídicího toku pro typ 'D' selhala během virtuálního volání (adresa vtable 0x000000431ce0) 0x000000431ce0: poznámka: vtable je typu „Špatný“ 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 ^