Control-flow integrity ( CFI ) este un nume general pentru tehnicile de securitate computerizată care vizează restricționarea căilor posibile de execuție a programului în cadrul unui grafic de flux de control pre-previzat pentru a crește securitatea acestuia [1] . CFI îngreunează un atacator să preia controlul asupra execuției unui program, făcând imposibilă reutilizarea unor părți deja existente ale codului mașină. Tehnici similare includ separarea cod-pointer (CPS) și integritatea cod-pointer (CPI) [2] [3] .
Suportul CFI este prezent în compilatoarele Clang [4] și GCC [5] , precum și în Control Flow Guard [6] și Return Flow Guard [7] de la Microsoft și Reuse Attack Protector [8] de la echipa PaX.
Invenția modalităților de protecție împotriva execuției codului arbitrar, cum ar fi Data Execution Prevention și NX-bit , a condus la apariția unor noi metode care vă permit să obțineți controlul asupra programului (de exemplu, programarea orientată spre returnare ) [ 8] . În 2003, Echipa PaX a publicat un document care descrie posibilele situații care duc la piratarea programului și idei de protecție împotriva acestora [8] [9] . În 2005, un grup de cercetători Microsoft a oficializat aceste idei și a inventat termenul Control-flow Integrity pentru a se referi la metodele de protecție împotriva modificărilor fluxului de control original al unui program. Pe lângă aceasta, autorii au propus o metodă de instrumentare a codului mașină deja compilat [1] .
Ulterior, cercetătorii, pe baza ideii CFI, au propus multe modalități diferite de a crește rezistența programului la atacuri. Abordările descrise nu au fost adoptate pe scară largă din motive care includ încetinirile mari ale programelor sau nevoia de informații suplimentare (de exemplu, obținute prin profilare ) [10] .
În 2014, o echipă de cercetători de la Google a publicat o lucrare care a analizat implementarea CFI pentru compilatoarele industriale GCC și LLVM pentru instrumentarea programelor C++. Suportul oficial CFI a fost adăugat în 2014 în GCC 4.9.0 [5] [11] și în 2015 în Clang 3.7 [12] [13] . Microsoft a lansat Control Flow Guard în 2014 pentru Windows 8.1 , adăugând suport de la sistemul de operare la Visual Studio 2015 [6] .
Dacă există salturi indirecte în codul programului , este posibil să se transfere controlul la orice adresă unde poate fi localizată comanda (de exemplu, pe x86 va fi orice adresă posibilă, deoarece lungimea minimă a comenzii este de un octet [14] ). Dacă un atacator poate modifica cumva valoarea prin care este transferat controlul atunci când execută o instrucțiune de salt, atunci el poate reutiliza codul programului existent pentru propriile nevoi.
În programele reale, salturile non-locale duc de obicei la începutul funcțiilor (de exemplu, dacă se folosește o instrucțiune de apel de procedură) sau la instrucțiunea care urmează instrucțiunii de apelare (întoarcerea procedurii). Primul tip de tranziții este o tranziție directă (în engleză forward-edge ), deoarece va fi notată printr-un arc direct pe graficul fluxului de control. Al doilea tip se numește tranziție înapoi (ing. back-edge ), prin analogie cu primul - arcul corespunzător tranziției va fi invers [15] .
Pentru salturile directe, numărul de adrese posibile la care poate fi transferat controlul va corespunde numărului de funcții din program. De asemenea, luând în considerare sistemul de tip și semantica limbajului de programare în care este scris codul sursă, sunt posibile restricții suplimentare [16] . De exemplu, în C++ , într-un program corect , un pointer de funcție folosit într-un apel indirect trebuie să conțină adresa unei funcții de același tip ca și pointerul însuși [17] .
O modalitate de a implementa integritatea fluxului de control pentru salturile directe este că puteți analiza programul și puteți determina setul de adrese legale pentru diferite instrucțiuni de ramură [1] . Pentru a construi un astfel de set, analiza codului static este de obicei utilizată la un anumit nivel de abstractizare (la nivel de cod sursă , reprezentare internă a analizorului sau cod mașină [1] [10] ). Apoi, folosind informațiile primite, codul este inserat lângă instrucțiunile ramurii indirecte pentru a verifica dacă adresa primită la runtime se potrivește cu cea calculată static. În cazul divergenței, programul se blochează de obicei, deși implementările vă permit să personalizați comportamentul în cazul unei încălcări a fluxului de control prezis [18] [19] . Astfel, graficul fluxului de control este limitat doar la acele margini (apeluri de funcții) și vârfuri (puncte de intrare în funcție) [1] [16] [20] care sunt evaluate în timpul analizei statice, deci atunci când se încearcă modificarea indicatorului utilizat pentru sărituri indirecte , atacatorul va eșua.
Această metodă vă permite să preveniți programarea orientată spre sărituri [21] și programarea orientată către apeluri [22] , deoarece acestea din urmă folosesc în mod activ sărituri indirecte directe.
Pentru tranzițiile înapoi, sunt posibile mai multe abordări ale implementării CFI [8] .
Prima abordare se bazează pe aceleași ipoteze ca CFI pentru salturile directe, adică capacitatea de a calcula adrese de retur dintr-o funcție [23] .
A doua abordare este de a trata adresa de retur în mod specific. Pe lângă simpla salvare pe stivă , se mai salvează, eventual cu unele modificări, într-un loc special alocat pentru el (de exemplu, la unul dintre registrele procesorului). De asemenea, înainte de instrucțiunea de returnare, se adaugă cod care restabilește adresa de retur și o verifică cu cea de pe stivă [8] .
A treia abordare necesită suport suplimentar din partea hardware-ului. Împreună cu CFI, se folosește o stivă umbră - o zonă de memorie specială inaccesibilă unui atacator, în care adresele de returnare sunt stocate atunci când se apelează funcții [24] .
La implementarea schemelor CFI pentru back jump-uri, este posibil să se prevină un atac de întoarcere la bibliotecă și o programare orientată spre returnare bazată pe schimbarea adresei de retur pe stivă [ 23] .
În această secțiune, vor fi luate în considerare exemple de implementări ale integrității fluxului de control.
Verificarea apelurilor de funcții indirecte (IFCC) include verificări pentru salturi indirecte într-un program, cu excepția unor sărituri „speciale”, cum ar fi apelurile de funcții virtuale. La construirea unui set de adrese la care poate avea loc o tranziție, se ia în considerare tipul funcției. Datorită acestui fapt, este posibil să se prevină nu numai utilizarea valorilor incorecte care nu indică începutul funcției, ci și turnarea incorectă a tipului în codul sursă. Pentru a activa verificările în compilator, există o opțiune -fsanitize=cfi-icall[4] .
// clang-ifcc.c #include <stdio.h> int suma ( int x , int y ) { returnează x + y _ } int dbl ( int x ) { returnează x + x ; } void call_fn ( int ( * fn ) ( int )) { printf ( "Valoarea rezultatului: %d \n " , ( * fn )( 42 )); } void erase_type ( void * fn ) { // Comportamentul este nedefinit dacă tipul dinamic al lui fn nu este același cu int (*)(int). call_fn ( fn ); } int main () { // Când apelați erase_type, informațiile de tip static se pierd. tip_tergere ( suma ); returnează 0 ; }Un program fără verificări se compilează fără mesaje de eroare și se execută cu un rezultat nedefinit care variază de la o rulare la alta:
$ clang -Perete -Wextra clang-ifcc.c $ ./a.out Valoarea rezultatului: 1388327490Compilat cu următoarele opțiuni, obțineți un program care se anulează atunci când este apelat call_fn.
$ clang -flto -fvisibility=hidden -fsanitize=cfi -fno-sanitize-trap=all clang-ifcc.c $ ./a.out clang-ifcc.c:12:32: eroare de rulare: verificarea integrității fluxului de control pentru tipul „int (int)” a eșuat în timpul apelului indirect al funcției (./a.out+0x427a20): notă: (necunoscut) definit aiciAceastă metodă are ca scop verificarea integrității apelurilor virtuale în limbajul C++. Pentru fiecare ierarhie de clasă care conține funcții virtuale , sunt construite hărți de biți care arată ce funcții pot fi apelate pentru fiecare tip static. Dacă în timpul execuției în program, tabelul cu funcțiile virtuale ale oricărui obiect este corupt (de exemplu, tipul incorect care distruge ierarhia sau pur și simplu coruperea memoriei de către un atacator), atunci tipul dinamic al obiectului nu se va potrivi cu niciunul dintre cele prezise static. [10] [25] .
// virtual-calls.cpp #include <cstdio> struct B { virtual void foo () = 0 ; virtual ~ B () {} }; struct D : public B { void foo () override { printf ( "Funcția dreapta \n " ); } }; struct Bad : public B { void foo () override { printf ( "Funcție greșită \n " ); } }; int main () { rău rău ; // Standardul C++ permite casting astfel: B & b = static_cast < B &> ( bad ); // Derived1 -> Base -> Derived2. D & normal = static_cast < D &> ( b ); // Ca rezultat, tipul dinamic al obiectului este normal normal . foo (); // va fi rău și va fi apelată funcția greșită. returnează 0 ; }După compilare fără verificări activate:
$ clang++ -std=c++11 virtual-calls.cpp $ ./a.out Funcție greșităÎn program, în loc ca implementarea fooclasei Dsă fie apelată foodin Bad. Această problemă va fi surprinsă dacă compilați programul cu -fsanitize=cfi-vcall:
$ clang++ -std=c++11 -Wall -flto -fvisibility=hidden -fsanitize=cfi-vcall -fno-sanitize-trap=toate apelurile-virtuale.cpp $ ./a.out virtual-calls.cpp:24:3: eroare de rulare: verificarea integrității fluxului de control pentru tipul „D” a eșuat în timpul apelului virtual (adresa vtable 0x000000431ce0) 0x000000431ce0: notă: vtable este de tip „Bad” 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 ^