Automatyczne wykorzystywanie luki

https://chacker.pl/

Przyjrzyjmy się teraz przykładowi wymagającemu bardziej złożonego rozwiązania ograniczeń niż poprzedni przykład. W tej sekcji nauczysz się używać Tritona do automatycznego generowania danych wejściowych, które wykorzystują lukę w zabezpieczeniach programu, przechwytując miejsce wywołania pośredniego i przekierowując je na wybrany adres. Załóżmy, że wiesz już o istnieniu luki w zabezpieczeniach, która pozwala Ci kontrolować adres docelowy miejsca wywołania, ale nie wiesz jeszcze, jak ją wykorzystać, aby dotrzeć do żądanego adresu, ponieważ adres docelowy jest obliczany na podstawie danych wejściowych użytkownika w nietrywialny sposób. Jest to sytuacja, z którą możesz się spotkać w praktyce, na przykład podczas rozmywania kodu. Jak dowiedziałeś się w rozdziale 12, wykonywanie symboliczne jest zbyt kosztowne obliczeniowo dla metody rozmywania metodą brute force, która próbuje znaleźć lukę dla każdego miejsca wywołania pośredniego w programie. Zamiast tego można zoptymalizować program, najpierw poddając go bardziej tradycyjnej analizie rozmycia (fuzzing), dostarczając mu wiele pseudolosowo generowanych danych wejściowych i stosując analizę skażeń (taint analysis), aby określić, czy te dane wejściowe wpływają na niebezpieczny stan programu, taki jak pośrednie miejsca wywołań. Następnie można użyć symbolicznego wykonywania, aby generować exploity tylko dla tych miejsc wywołań, które analiza skażeń wykazała jako potencjalnie kontrolowalne. To jest przypadek użycia, który zakładam w poniższym przykładzie.

Generowanie nowego wejścia

https://chacker.pl/

Przypomnijmy, że symboliczny plik konfiguracyjny przypisuje wartość 0 zarówno do zmiennej x, jak i y, tworząc linię bazową, na podstawie której funkcja code_coverage może wygenerować nowe wejście obejmujące inną ścieżkę. Po uruchomieniu programu rozgałęzienia z tymi danymi bazowymi, wyświetlany jest komunikat x < 5 && y != 10, jak pokazano na poniższym listingu:

Użyjmy teraz funkcji code_coverage do wygenerowania nowych danych wejściowych, które odwracają zagnieżdżoną gałąź sprawdzającą wartość zmiennej y, dzięki czemu można użyć tych nowych danych wejściowych do ponownego uruchomienia gałęzi i uzyskania wyniku x < 5 && y == 10. Listing  pokazuje, jak to zrobić.

Listing : Znajdowanie danych wejściowych do przejścia do alternatywnej gałęzi pod adresem 0x4005ce

Wywołujesz funkcję code_coverage, podając program rozgałęzienia jako dane wejściowe, a także utworzony symboliczny plik konfiguracyjny (branch.map), adres początkowy 0x4005b6 funkcji rozgałęzienia (punkt wejścia do analizy) oraz adres 0x4005ce zagnieżdżonej gałęzi do odwrócenia (1).Gdy emulacja napotka ten adres gałęzi, funkcja code_coverage oblicza i wyświetla każde z ograniczeń rozgałęzienia wygenerowanych przez Triton jako część ograniczenia PathConstraint powiązanego z gałęzią. Pierwsze ograniczenie dotyczy kierunku rozgałęzienia z adresem docelowym 0x4005dc (zagnieżdżony else), a kierunek ten jest przyjmowany podczas emulacji ze względu na konkretne wartości wejściowe określone w pliku konfiguracyjnym (2). Jak informuje funkcja code_coverage, kierunek rozgałęzienia z adresem docelowym 0x4005d0 (zagnieżdżony if) nie jest przyjmowany (3), więc funkcja code_coverage próbuje obliczyć nowe wartości wejściowe, które prowadzą do tego kierunku rozgałęzienia (4). Chociaż generalnie rozwiązywanie ograniczeń wymagane do znalezienia nowych wartości wejściowych może zająć trochę czasu, w przypadku tak prostych ograniczeń, jak w tym przypadku, powinno zakończyć się w ciągu zaledwie kilku sekund. Po znalezieniu modelu przez solver, funkcja code_coverage wyświetla go na ekranie (5). Jak widać, model przypisuje wartość konkretną 0 do zmiennej rdi (x), a wartość 0xa do zmiennej rsi (y). Uruchommy program rozgałęzienia z tymi nowymi danymi wejściowymi, aby sprawdzić, czy spowodują one odwrócenie zagnieżdżonej gałęzi.

Dzięki tym nowym danym wejściowym branch wyświetla wynik x < 5 && y == 10, a nie komunikat x < 5 && y != 10, który otrzymałeś w poprzednim przebiegu programu branch. Dane wejściowe wygenerowane przez code_coverage pomyślnie zmieniły kierunek zagnieżdżonej gałęzi!

Budowanie symbolicznego pliku konfiguracyjnego

https://chacker.pl/

Aby użyć funkcji code_coverage, potrzebny jest symboliczny plik konfiguracyjny. Aby go utworzyć, należy wiedzieć, z których rejestrów i lokalizacji pamięci korzysta skompilowana wersja funkcji branch. Listing  przedstawia deasemblację funkcji branch. Przeanalizujmy ją, aby dowiedzieć się, z których rejestrów i lokalizacji pamięci korzysta branch.

Listing : Fragment deasemblacji z pliku ~/code/chapter13/branch

Instalacja Ubuntu na maszynie wirtualnej używa wersji x64 interfejsu binarnego aplikacji (ABI) Systemu V, która dyktuje konwencję wywołań używaną w systemie. W konwencji wywołań Systemu V dla systemów x64 pierwszy i drugi argument wywołania funkcji są przechowywane odpowiednio w rejestrach rdi i rsi. W tym przypadku oznacza to, że parametr x funkcji rozgałęzienia znajduje się w rdi, a parametr y w rsi. Wewnętrznie funkcja rozgałęzienia natychmiast przenosi x do komórki pamięci rbp-0x4 (1), a y do rbp-0x8 (2). Następnie funkcja rozgałęzienia porównuje pierwszą komórkę pamięci zawierającą x z wartością 4 (3), a następnie jg pod adresem 0x4005c8, która implementuje zewnętrzną gałąź if/else (4). Adres docelowy jg 0x4005e8 zawiera przypadek else (x ≥ 5), podczas gdy adres fallthrough 0x4005ca zawiera przypadek if. Wewnątrz instrukcji if znajduje się zagnieżdżona gałąź if/else, która jest zaimplementowana jako instrukcja cmp porównująca wartość zmiennej y z 10 (0xa) (5), a następnie instrukcja jne, która przeskakuje do 0x4005dc, jeśli y ≠ 10 (6) (zagnieżdżona instrukcja else) lub przechodzi do 0x4005d0 w przeciwnym razie (zagnieżdżona instrukcja if). Teraz, gdy wiesz, które rejestry zawierają wejścia x i y oraz adres 0x4005ce zagnieżdżonej gałęzi, którą chcesz odwrócić, utwórzmy symboliczny plik konfiguracyjny. Listing  przedstawia plik konfiguracyjny do użycia w teście.

Listing : branch.map

Plik konfiguracyjny nadaje rdi (reprezentującemu x) charakter symboliczny i przypisuje mu wartość konkretną 0 (1). To samo dotyczy rsi, które zawiera y (2). Ponieważ x i y są symboliczne, podczas generowania modelu dla nowych danych wejściowych, solver ograniczeń poda wartości konkretne dla obu wartości: x i y.

Testowanie narzędzia Code Coverage

https://chacker.pl/

Listing przedstawia prosty program testowy, którego można użyć do sprawdzenia możliwości code_coverage w zakresie generowania danych wejściowych, które badają nowy kierunek rozgałęzienia.

Listing : branch.c

Jak widać, program branch zawiera funkcję o nazwie branch, która przyjmuje dwie liczby całkowite o nazwach x i y jako dane wejściowe. Funkcja branch zawiera zewnętrzną gałąź if/else opartą na wartości x (1) oraz zagnieżdżoną gałąź if/else opartą na wartości y (2). Funkcja jest wywoływana przez funkcję main, a argumenty x i y są podawane z danych wejściowych użytkownika (3).Najpierw uruchommy branch z x = 0 i y = 0, tak aby gałąź zewnętrzna przyjmowała kierunek if, a gałąź zagnieżdżona kierunek else. Następnie możesz użyć code_coverage, aby znaleźć dane wejściowe i odwrócić zagnieżdżoną gałąź, tak aby przyjmowała kierunek if. Najpierw jednak utwórzmy symboliczny plik konfiguracyjny potrzebny do uruchomienia code_coverage.

Pobieranie modelu z modułu rozwiązującego ograniczenia

https://chacker.pl/

Na koniec funkcja find_new_input napotka ograniczenie PathConstraint powiązane z gałęzią, którą chcesz odwrócić. Zawiera ono wiele ograniczeń gałęzi, których adres źródłowy jest równy adresowi gałęzi do odwrócenia (9). Aby wyraźnie pokazać wszystkie możliwe kierunki gałęzi w wynikach funkcji code_coverage, find_new_input wyświetla każdy warunek gałęzi z pasującym adresem źródłowym, niezależnie od jego flagi. Jeśli flaga ma wartość true, find_new_input nie dołącza ograniczenia gałęzi do listy ograniczeń, ponieważ odpowiada ono kierunkowi gałęzi, który już zbadałeś. Jeśli jednak flaga ma wartość false (10), reprezentuje ona nieodkryty kierunek gałęzi, więc find_new_input dołącza to ograniczenie gałęzi do listy ograniczeń i przekazuje listę do modułu rozwiązującego ograniczenia, wywołując api.getModel. Funkcja getModel wywołuje moduł rozwiązujący ograniczenia Z3 i prosi go o model spełniający listę ograniczeń. Jeśli model zostanie znaleziony, getModel zwraca go jako std::map, który mapuje symboliczne identyfikatory zmiennych Triton na obiekty triton::engines::solver::SolverModel. Model reprezentuje nowy zestaw konkretnych danych wejściowych dla analizowanego programu, który spowoduje, że program obierze wcześniej niezbadany kierunek gałęzi. Jeśli model nie zostanie znaleziony, zwrócona mapa jest pusta. Każdy obiekt SolverModel zawiera konkretną wartość, którą solver ograniczeń przypisał do odpowiedniej zmiennej symbolicznej w modelu. Narzędzie code_coverage raportuje model użytkownikowi, przechodząc przez mapę w pętli i wyświetlając identyfikator i komentarz każdej zmiennej symbolicznej, który zawiera czytelną dla człowieka nazwę odpowiedniego rejestru lub lokalizacji pamięci, a także konkretną wartość przypisaną w modelu (zwróconą przez SolverModel::getValue). Aby zobaczyć, jak wykorzystać wynik funkcji code_coverage w praktyce, wypróbujmy ją teraz w programie testowym, aby znaleźć i użyć nowych danych wejściowych do pokrycia wybranej gałęzi.

Kopiowanie i odwracanie ograniczeń rozgałęzień

https://chacker.pl/

Następnie find_new_input wywołuje api.getPathConstraints, aby pobrać listę ograniczeń ścieżki, które Triton zgromadził podczas emulowania kodu (3). Lista ma postać std::vector obiektów triton::engines::symbolic::PathConstraint, gdzie każde ograniczenie PathConstraint jest powiązane z jedną instrukcją rozgałęzienia. Ta lista zawiera wszystkie ograniczenia, które muszą być spełnione, aby obrać właśnie emulowaną ścieżkę. Aby przekształcić to w listę ograniczeń dla nowej ścieżki, kopiujesz wszystkie ograniczenia z wyjątkiem ograniczenia dla gałęzi, którą chcesz zmienić, które odwracasz na inny kierunek rozgałęzienia. Aby to zaimplementować, find_new_input wykonuje pętlę po liście ograniczeń ścieżki (4) i kopiuje lub odwraca każde z nich. Wewnątrz każdego ograniczenia PathConstraint Triton przechowuje jedno lub więcej ograniczeń ścieżki, po jednym dla każdego możliwego kierunku rozgałęzienia. W kontekście pokrycia kodu interesują Cię tylko gałęzie wielokierunkowe, takie jak skoki warunkowe, ponieważ gałęzie jednokierunkowe, takie jak wywołania bezpośrednie lub skoki bezwarunkowe, nie mają nowego kierunku do eksploracji. Aby ustalić, czy ograniczenie PathConstraint o nazwie pc reprezentuje gałąź wielokierunkową, wywołujesz funkcję pc.isMultipleBranches (5), która zwraca wartość true, jeśli gałąź jest wielokierunkowa. W przypadku obiektów PathConstraint zawierających wiele ograniczeń gałęzi, funkcja find _new_input pobiera wszystkie ograniczenia gałęzi, wywołując pc.getBranchConstraints, a następnie wykonuje pętlę po każdym ograniczeniu na liście (6). Każde ograniczenie jest krotką flagi boolowskiej, adresu źródłowego i docelowego (oba triton::uint64) oraz kodu AST kodującego ograniczenie gałęzi. Flaga wskazuje, czy kierunek gałęzi reprezentowany przez ograniczenie gałęzi został obrany podczas emulacji. Rozważmy na przykład następującą gałąź warunkową:

Podczas emulacji jge, Triton tworzy obiekt PathConstraint z dwoma ograniczeniami rozgałęzienia. Załóżmy, że pierwsze ograniczenie rozgałęzienia reprezentuje wybrany kierunek jge (tj. kierunek, który jest wybierany, jeśli warunek jest spełniony) i że jest to kierunek wybierany podczas emulacji. Oznacza to, że pierwsze ograniczenie rozgałęzienia zapisane w PathConstraint ma flagę „true” (ponieważ zostało wybrane podczas emulacji), a adresy źródłowy i docelowy to odpowiednio 0x4055de (adres jge) i 0x4056d8 (adres docelowy jge). Kod AST dla tego warunku rozgałęzienia zakoduje warunek al ≥ 0x25. Drugie ograniczenie rozgałęzienia ma flagę „false”, reprezentującą kierunek rozgałęzienia, który nie został wybrany podczas emulacji. Adresy źródłowy i docelowy to 0x4055de i 0x4055e4 (adres przejścia dla jge), a kod AST koduje warunek al < 0x25 (lub dokładniej, not(al ≥ 0x25)). Teraz, dla każdego ograniczenia PathConstraint, find_new_input kopiuje ograniczenie rozgałęzienia, którego flaga jest prawdziwa, z wyjątkiem ograniczenia PathConstraint powiązanego z instrukcją rozgałęzienia, którą chcesz odwrócić. W tym przypadku zamiast tego kopiuje ograniczenie rozgałęzienia o wartości fałszu, odwracając w ten sposób decyzję o rozgałęzieniu. Aby rozpoznać rozgałęzienie do odwrócenia, find_new_input używa adresu źródłowego rozgałęzienia. W przypadku ograniczeń z adresem źródłowym różnym od adresu rozgałęzienia do odwrócenia (7), kopiuje ograniczenie rozgałęzienia z flagą prawdy (8) i dołącza je do constraint_list za pomocą logicznego AND, zaimplementowanego w ast.land.

Znajdowanie modelu dla nowej ścieżki

https://chacker.pl/

Pętla emulacji (4) jest taka sama jak w backward_slicing, z tą różnicą, że tym razem emuluje, aż pc będzie równy adresowi gałęzi, dla której chcemy znaleźć nowy zestaw danych wejściowych (5). Aby znaleźć te nowe dane wejściowe, code_coverage wywołuje osobną funkcję o nazwie find_new_input, która jest pokazana na listingu

Aby znaleźć wejścia prowadzące do wcześniej niezbadanego kierunku gałęzi, funkcja find_new_input przekazuje programowi rozwiązującemu listę ograniczeń, które muszą zostać spełnione, aby dotrzeć do pożądanej gałęzi, a następnie prosi go o model, który spełnia te ograniczenia. Przypomnijmy, że Triton reprezentuje ograniczenia jako abstrakcyjne drzewa składniowe, więc aby zakodować ograniczenia gałęzi, należy zbudować odpowiednie drzewo AST. Dlatego find_new_input rozpoczyna działanie od wywołania api.getAstContext w celu uzyskania referencji (nazywanej ast) do AstContext (1), która jest klasą budującą formuły AST w Tritonie. Aby zapisać listę ograniczeń, które będą modelować ścieżkę prowadzącą do niezbadanego kierunku gałęzi, find_new_input używa obiektu triton::ast::AbstractNode, osiągalnego za pomocą wskaźnika o nazwie constraint_list (2). AbstractNode to klasa Tritona służąca do reprezentacji węzłów AST. Aby zainicjować constraint_list, należy ustawić ją na formułę ast.equal(ast.bvtrue(), ast.bvtrue()), co oznacza logiczną tautologię true == true, gdzie każda wartość true jest wektorem bitowym. Jest to po prostu sposób na zainicjowanie listy ograniczeń do formuły poprawnej składniowo, która nie narzuca żadnych ograniczeń i do której można łatwo dołączyć dodatkowe ograniczenia.

Tworzenie zmiennych symbolicznych

https://chacker.pl/

Różnica między tym a poprzednim przykładem polega na tym, że kod analizujący symboliczny plik konfiguracyjny przekazuje dwa opcjonalne argumenty (symregs i symmem) (1) do funkcji parse_sym_config. Są to argumenty wyjściowe, gdzie funkcja parse _sym_config zapisuje listy rejestrów i lokalizacji pamięci do symbolizacji zgodnie z plikiem konfiguracyjnym. W pliku konfiguracyjnym należy symbolizować wszystkie rejestry i lokalizacje pamięci zawierające dane wejściowe użytkownika, aby model zwrócony przez moduł rozwiązywania ograniczeń dawał konkretną wartość dla  każdej z tych danych. Po przypisaniu konkretnych wartości z pliku konfiguracyjnego, funkcja main przechodzi przez listę rejestrów do symbolizacji i symbolizuje je za pomocą funkcji api.convertRegisterToSymbolicVariable programu Triton (2). Ten sam wiersz kodu, który symbolizuje rejestr, natychmiast ustawia komentarz do właśnie utworzonej zmiennej symbolicznej, określając czytelną dla człowieka nazwę rejestru. W ten sposób, gdy później otrzymasz model z modułu rozwiązywania ograniczeń, będziesz wiedzieć, jak mapować symboliczne przypisania zmiennych w modelu z powrotem na rzeczywiste rejestry i pamięć.

Pętla symbolizująca lokalizacje pamięci jest podobna. Dla każdej symbolizowanej lokalizacji pamięci tworzony jest obiekt triton::arch::MemoryAccess, który określa adres i rozmiar (w bajtach) danej lokalizacji pamięci. W tym przypadku rozmiar został zakodowany na stałe i wynosi 1 bajt, ponieważ format pliku konfiguracyjnego pozwala na odwoływanie się do lokalizacji pamięci tylko z dokładnością do bajtu. Aby symbolizować adres określony w obiekcie MemoryAccess, należy użyć funkcji Triton api.convertMemoryToSymbolicVariable (3). Następnie pętla ustawia komentarz mapujący nową zmienną symboliczną na czytelny dla człowieka ciąg zawierający adres pamięci.

Wykorzystanie Tritona do zwiększenia pokrycia kodu

https://chacker.pl/

Ponieważ przykład wycinania wstecznego wymagał jedynie możliwości Tritona do śledzenia wyrażeń symbolicznych dla rejestrów i lokalizacji pamięci, nie wykorzystał on głównej siły wykonywania symbolicznego: wnioskowania o właściwościach programu poprzez rozwiązywanie ograniczeń. W tym przykładzie zapoznasz się z możliwościami Tritona w zakresie rozwiązywania ograniczeń w klasycznym przypadku użycia Symbex, jakim jest pokrycie kodu. Listing przedstawia pierwszą część kodu źródłowego narzędzia code_coverage. Zauważysz, że znaczna część kodu jest taka sama lub podobna do kodu z poprzedniego przykładu. W rzeczywistości pominąłem funkcję set_triton_arch w tym listingu, ponieważ jest ona dokładnie taka sama jak w narzędziu wycinania wstecznego.

Aby użyć narzędzia code_coverage, należy podać argumenty wiersza poleceń określające plik binarny do analizy, symboliczny plik konfiguracyjny, adres punktu wejścia analizy oraz adres instrukcji bezpośredniego rozgałęzienia. Narzędzie zakłada, że ​​plik konfiguracyjny zawiera konkretne dane wejściowe, które powodują, że rozgałęzienie obierze jedną z dwóch możliwych ścieżek (nie ma znaczenia, która ścieżka). Następnie używa solvera ograniczeń do obliczenia modelu zawierającego nowy zestaw konkretnych danych wejściowych, które spowodują, że rozgałęzienie pójdzie w drugą stronę. Aby solver zakończył działanie, należy zadbać o symbolizację wszystkich rejestrów i lokalizacji pamięci, od których zależy rozgałęzienie, które ma zostać odwrócone. Jak widać na listingu, code_coverage zawiera te same narzędzia i pliki nagłówkowe Triton, co poprzedni przykład. Co więcej, główna funkcja code_coverage jest niemal identyczna z główną funkcją backward_slicing. Podobnie jak w tym przykładzie, rozpoczyna się od załadowania pliku binarnego i skonfigurowania architektury Triton, a następnie włącza optymalizację ALIGNED_MEMORY.

Obliczanie wycinka wstecznego

https://chacker.pl/

Aby obliczyć i wydrukować rzeczywisty wycinek, backward_slicing wywołuje funkcję print_slice, gdy emulacja osiągnie adres, pod którym ma zostać utworzony wycinek. Implementację print_slice można zobaczyć na listingu .

Listing : backward_slicing.cc (ciąg dalszy)

Przypomnijmy, że wycinki są obliczane w odniesieniu do konkretnego rejestru,

zgodnie z parametrem reg. Aby obliczyć wycinek, potrzebujesz  wyrażenia symbolicznego powiązanego z tym rejestrem tuż po emulowaniu instrukcji pod adresem wycinka. Aby uzyskać to wyrażenie, print_slice wywołuje api.getSymbolicRegisters, które zwraca mapę wszystkich rejestrów do powiązanych z nimi wyrażeń symbolicznych, a następnie indeksuje tę mapę, aby uzyskać wyrażenie powiązane z rejestrem (1). Następnie uzyskuje wycinek wszystkich wyrażeń symbolicznych, które składają się na wyrażenie rejestru, za pomocą api.sliceExpressions (2), które zwraca wycinek w postaci std::map, który mapuje identyfikatory wyrażeń całkowitych na obiekty triton::engines::symbolic::SymbolicExpression*. Masz teraz wycinek wyrażeń symbolicznych, ale tak naprawdę potrzebujesz wycinka instrukcji asemblera x86. Właśnie to jest celem komentarzy do wyrażeń symbolicznych, które kojarzą każde wyrażenie z mnemonikiem asemblera i ciągami operandów instrukcji, która wygenerowała to wyrażenie. Zatem, aby wydrukować wycinek, print_slice po prostu przechodzi przez wycinek wyrażeń symbolicznych, pobiera ich komentarze za pomocą getComment i drukuje je na ekranie (3). Dla kompletności, print_slice deasembluje również instrukcję, w której obliczany jest wycinek, i drukuje ją również na ekranie (4). Możesz wypróbować program backward_slice na maszynie wirtualnej, uruchamiając go zgodnie z Listingiem .

Listing : Obliczanie wycinka wstecznego pod adresem 0x404b1e względem rcx

(1) $ ./backward_slicing /bin/ls empty.map 0x404b00 0x404b1e rcx

(2) mov rcx, qword ptr [rdi]

not rcx

(slice for rcx @ 0x404b1e: mov r9, rcx)

W tym przypadku użyłem metody backward_slicing do obliczenia wycinka na podstawie fragmentu kodu z /bin/ls, który widziałeś wcześniej. Użyłem pustego pliku konfiguracji symbolicznej (empty.map) i określiłem odpowiednio adresy 0x404b00, 0x404b1e i rcx jako adres punktu wejścia, adres wycinka i rejestr do wycinka. Jak widać, daje to taki sam wynik, jak ręcznie obliczony wycinek, który widziałeś wcześniej (2). Powód, dla którego użycie pustego pliku konfiguracji symbolicznej w tym przykładzie jest dopuszczalne, jest taki, że analiza nie opiera się na symbolicznym charakterze konkretnych rejestrów lub lokalizacji pamięci i nie potrzebujesz żadnych konkretnych wartości do sterowania wykonaniem, ponieważ analizowany fragment kodu nie zawiera żadnych rozgałęzień. Przyjrzyjmy się teraz innemu przykładowi, w którym potrzebna będzie niepusta konfiguracja symboliczna do eksploracji wielu ścieżek w tym samym programie.