Wrażliwość na przepływ

https://chacker.pl/

Analiza binarna może być wrażliwa na przepływ lub niewrażliwa na przepływ. Wrażliwość na przepływ oznacza, że analiza uwzględnia kolejność instrukcji. Aby to wyjaśnić, spójrz na poniższy przykład w pseudokodzie.

Kod pobiera liczbę całkowitą bez znaku z danych wprowadzonych przez użytkownika, a następnie wykonuje na niej pewne obliczenia. W tym przykładzie załóżmy, że chcesz przeprowadzić analizę, która próbuje określić potencjalne wartości, jakie może przyjąć każda zmienna; nazywa się to analizą zbiorów wartości. Wersja tej analizy niewrażliwa na przepływ po prostu ustaliłaby, że x może zawierać dowolną wartość, ponieważ otrzymuje ją z danych wprowadzonych przez użytkownika. Chociaż ogólnie rzecz biorąc, x może przyjąć dowolną wartość w pewnym momencie programu, nie dotyczy to wszystkich punktów programu.

Zatem informacje dostarczane przez analizę niewrażliwą na przepływ nie są zbyt precyzyjne, ale analiza ta jest stosunkowo tania pod względem złożoności obliczeniowej. Wersja analizy wrażliwa na przepływ dałaby dokładniejsze wyniki. W przeciwieństwie do wariantu niewrażliwego na przepływ, zapewnia ona oszacowanie możliwego zbioru wartości x w każdym punkcie programu, biorąc pod uwagę poprzednie instrukcje. W (1) analiza stwierdza, że x może mieć dowolną wartość bez znaku, ponieważ jest pobierana z danych wejściowych użytkownika i nie było jeszcze żadnych instrukcji ograniczających wartość x. Jednak w (2) można doprecyzować oszacowanie: ponieważ wartość 5 jest dodawana do x, wiadomo, że od tego momentu x może mieć wartość co najmniej 5. Podobnie, po instrukcji w (3) wiadomo, że x jest co najmniej równe 15. Oczywiście, w rzeczywistości sprawy nie są tak proste, gdzie trzeba radzić sobie ze złożonymi konstrukcjami, takimi jak rozgałęzienia, pętle i (rekurencyjne) wywołania funkcji, zamiast prostego kodu liniowego. W rezultacie analizy wrażliwe na przepływ są zazwyczaj znacznie bardziej złożone i wymagają większych nakładów obliczeniowych niż analizy niewrażliwe na przepływ.

Analiza międzyproceduralna i wewnątrzproceduralna

https://chacker.pl/

Przypomnijmy, że funkcje są jedną z podstawowych struktur kodu, które deasemblery próbują odtworzyć, ponieważ analiza kodu na poziomie funkcji jest bardziej intuicyjna. Innym powodem stosowania funkcji jest skalowalność: niektóre analizy są po prostu niewykonalne w przypadku zastosowania do całego programu. Liczba możliwych ścieżek w programie rośnie wykładniczo wraz z liczbą przeniesień sterowania (takich jak skoki i wywołania) w programie. W programie z zaledwie 10 rozgałęzieniami if/else istnieje do 210 = 1;024 możliwych ścieżek w kodzie. W programie ze stu takimi rozgałęzieniami istnieje do 1,27 1030 możliwych ścieżek, a tysiąc rozgałęzień daje do 1,07 x 10301 ścieżek! Wiele programów ma znacznie więcej rozgałęzień, więc analiza każdej możliwej ścieżki w nietrywialnym programie nie jest obliczeniowo wykonalna. Dlatego kosztowne obliczeniowo analizy binarne często mają charakter wewnątrzproceduralny: uwzględniają kod tylko w obrębie jednej funkcji na raz. Zazwyczaj analiza wewnątrzproceduralna analizuje CFG każdej funkcji po kolei. Jest to w przeciwieństwie do analizy międzyproceduralnej, która traktuje cały program jako całość, zazwyczaj łącząc wszystkie CFG funkcji za pomocą grafu wywołań. Ponieważ większość funkcji zawiera zaledwie kilkadziesiąt instrukcji przekazania sterowania, złożone analizy są wykonalne obliczeniowo na poziomie funkcji. Jeśli indywidualnie przeanalizujesz 10 funkcji z 1024 możliwymi ścieżkami każda, przeanalizujesz łącznie 10  x 1024 = 10240 ścieżek; to znacznie więcej niż 102410 ≈  1,27 x  1030 ścieżek, które musiałbyś przeanalizować, gdybyś rozpatrywał cały program naraz. Wadą analizy wewnątrzproceduralnej jest to, że jest niekompletna. Na przykład, jeśli program zawiera błąd, który pojawia się dopiero po wykonaniu ściśle określonej kombinacji wywołań funkcji, narzędzie do wykrywania błędów wewnątrzproceduralnych go nie znajdzie. Po prostu rozważy każdą funkcję osobno i stwierdzi, że wszystko jest w porządku. Natomiast narzędzie międzyproceduralne wykryje błąd, ale może to zająć tak dużo czasu, że wyniki nie będą miały już znaczenia. Jako inny przykład, rozważmy, jak kompilator mógłby zoptymalizować kod pokazany na Listingu , w zależności od tego, czy stosuje optymalizację wewnątrzproceduralną, czy międzyproceduralną.

W tym przykładzie istnieje funkcja o nazwie „dead”, która przyjmuje pojedynczy parametr całkowity x i nic nie zwraca (1). Wewnątrz funkcji znajduje się gałąź, która wydrukuje komunikat tylko wtedy, gdy x jest równe 5 (2). Tak się składa, że funkcja „dead” jest wywoływana tylko z jednej lokalizacji, ze stałą wartością 4 jako argumentem (3). Zatem gałąź w punkcie (2) nigdy nie jest pobierana i żaden komunikat nie jest drukowany. Kompilatory używają optymalizacji zwanej eliminacją martwego kodu, aby znaleźć wystąpienia kodu, do których w praktyce nigdy nie można dotrzeć, dzięki czemu mogą pominąć taki bezużyteczny kod w skompilowanym pliku binarnym. W tym przypadku jednak czysto wewnątrzproceduralny przebieg eliminacji martwego kodu nie wyeliminowałby bezużytecznej gałęzi w punkcie (2). Dzieje się tak, ponieważ gdy przebieg optymalizuje funkcję „dead”, nie wie o żadnym kodzie w innych funkcjach, więc nie wie, gdzie i jak funkcja „dead” jest wywoływana. Podobnie, optymalizując funkcję main, nie może zajrzeć do funkcji dead, aby zauważyć, że konkretny argument przekazany do funkcji dead w punkcie (3) powoduje, że funkcja dead nie wykonuje żadnej czynności. Wymaga to analizy międzyproceduralnej, aby stwierdzić, że funkcja dead jest wywoływana z funkcji main tylko z wartością 4, co oznacza, że gałąź w punkcie (2) nigdy nie zostanie wykonana. Zatem wewnątrzproceduralna eliminacja martwego kodu spowoduje wyświetlenie całej funkcji dead (i jej wywołań) w skompilowanym pliku binarnym, mimo że nie ma ona żadnego celu, podczas gdy przejście międzyproceduralne pominie całą bezużyteczną funkcję.

Metody analizy fundamentalnej

https://chacker.pl/

Techniki dezasemblacji, które poznałeś do tej pory e, stanowią fundament analizy binarnej. Wiele zaawansowanych technik omówionych w kolejnych rozdziałach, takich jak instrumentacja binarna i wykonywanie symboliczne, opiera się na tych podstawowych metodach dezasemblacji. Zanim jednak przejdziemy do tych technik, chciałbym omówić kilka „standardowych” analiz, ponieważ mają one szerokie zastosowanie. Należy pamiętać, że nie są to samodzielne techniki analizy binarnej, ale można je wykorzystać jako składniki bardziej zaawansowanych analiz binarnych. O ile nie zaznaczę inaczej, wszystkie te techniki są zazwyczaj implementowane jako analizy statyczne, choć można je również modyfikować, aby działały w przypadku dynamicznych śladów wykonania.

Reprezentacje pośrednie

https://chacker.pl/

Zestawy instrukcji, takie jak x86 i ARM, zawierają wiele różnych instrukcji o złożonej semantyce. Na przykład, w x86, nawet pozornie proste instrukcje, takie jak add, mają skutki uboczne, takie jak ustawianie flag statusu w rejestrze eflags. Ogromna liczba instrukcji i skutków ubocznych utrudnia automatyczne rozumowanie programów binarnych. Na przykład, jak zobaczysz w rozdziałach od 10 do 13, dynamiczna analiza skażeń i silniki wykonywania symbolicznego muszą implementować jawne procedury obsługi, które przechwytują semantykę przepływu danych wszystkich analizowanych ijnstrukcji. Dokładna implementacja wszystkich tych procedur obsługi jest trudnym zadaniem. Reprezentacje pośrednie (IR), znane również jako języki pośrednie, zostały zaprojektowane w celu usunięcia tego obciążenia. IR to prosty język, który służy jako abstrakcja od języków maszynowych niskiego poziomu, takich jak x86 i ARM. Popularne IR to Reverse Engineering Intermediate Language (REIL) i VEX IR (IR używany w frameworku instrumentacji Valgrind12). Istnieje nawet narzędzie o nazwie McSema, które tłumaczy pliki binarne na kod bitowy LLVM (znany również jako LLVM IR). Idea jjjęzyków IR polega na automatycznym tłumaczeniu rzeczywistego kodu maszynowego, takiego jak kod x86, na kod IR, który uwzględnia całą semantykę kodu maszynowego, ale jest znacznie prostszy w analizie. Dla porównania, REIL zawiera tylko 17 różnych instrukcji, w przeciwieństwie do setek instrukcji w x86. Co wijęcej, języki takie jak REIL, VEX i LLVM IR jawnie wyrażają wszystkie operacje, bez ukrytych efektów ubocznych instrukcji. Implementacja etapu translacji z kodu maszynowego niskiego poziomu na kod IR nadal wymaga dużo pracy, ale po jej wykonaniu znacznie łatwiej jest zaimplementować nowe analizy binarne na podstawie przetłumaczonego kodu. Zamiast pisać procedury obsługi poszczególnych instrukcji dla każdej analizy binarnej, w przypadku języków IR wstarczy to zrobić tylko raz, aby zaimplementować etap translacji. Co więcej, można pisać translatory dla wielu ISA, takich jak x86, ARM i MIPS, i mapować je wszystkie na ten sam IR. W ten sposób każde narzędzie do analizy binarnej, które działa na tym IR, automatycznie dziedziczy obsługę wszystkich ISA obsługiwanych przez IR. Kompromisem związanym z tłumaczeniem złożonego zestawu instrukcji, takiego jak x86, na prosty język, taki jak REIL, VEX lub LLVM IR, jest to, że języki IR są znacznie mniej zwięzłe. Jest to nieodłączna konsekwencja wyrażania złożonych operacji, wraz ze wszystkimi efektami ubocznymi, za pomocą ograniczonej liczby prostych instrukcji. Zazwyczaj nie stanowi to problemu w przypadku analiz automatycznych, ale zazwyczaj utrudnia ludziom odczytanie pośrednich reprezentacji. Aby dać ci wyobrażenie o tym, jak wygląda IR, spójrz na Listing , który pokazuje, jak instrukcja x86-64 add rax,rdx jest tłumaczona na VEX IR.

Jak widać, pojedyncza instrukcja dodawania generuje 10 instrukcji VEX plus kilka metadanych. Po pierwsze, są tam metadane mówiące, że jest to superblok IR (IRSB) (1) odpowiadający jednej instrukcji maszynowej. IRSB zawiera cztery wartości tymczasowe oznaczone od t0 do t3, wszystkie typu Ity_I64 (64-bitowa liczba całkowita) (2). Następnie mamy IMark (3), czyli metadane określające między innymi adres i długość instrukcji maszynowej. Następnie mamy rzeczywiste instrukcje IR modelujące dodawanie. Po pierwsze, są to dwie instrukcje GET, które pobierają 64-bitowe wartości z rax i rdx do pamięci tymczasowych odpowiednio t2 i t1 (4). Należy zauważyć, że w tym przypadku rax i rdx to jedynie symboliczne nazwy części stanu VEX używanych do modelowania tych rejestrów — instrukcje VEX nie pobierają danych z rzeczywistych rejestrów rax lub rdx, lecz ze stanu lustrzanego tych rejestrów w VEX. Aby wykonać faktyczne dodawanie, IR używa instrukcji Add64 VEX, dodając dwie 64-bitowe liczby całkowite t2 i t1 i zapisując wynik w t0 (5). Po dodaniu występują pewne instrukcje PUT, które modelują efekty uboczne instrukcji add, takie jak aktualizacja flag stanu x86 (6). Następnie kolejna instrukcja PUT zapisuje wynik dodawania w stanie VEX reprezentującym rax (7). Na koniec IR VEX modeluje aktualizację licznika programu do następnej instrukcji (8). Ijk_Boring (Jump Kind Boring) (9) to wskazówka dotycząca przepływu sterowania, która mówi, że instrukcja add nie wpływa na przepływ sterowania w żaden interesujący sposób; ponieważ add nie jest żadnym rodzajem rozgałęzienia, sterowanie po prostu „przechodzi” do następnej instrukcji w pamięci. Natomiast instrukcje rozgałęzienia mogą być oznaczone wskazówkami takimi jak Ijk_Call lub Ijk_Ret, aby poinformować analizę, na przykład, że ma miejsce wywołanie lub powrót. Wdrażając narzędzia na bazie istniejącego frameworka analizy binarnej, zazwyczaj nie trzeba zajmować się reakcją na analizę binarną (IR). Framework sam zajmie się wszystkimi kwestiami związanymi z IR. Warto jednak wiedzieć o reakcjach na analizę binarną, jeśli planujesz wdrożyć własny framework analizy binarnej lub zmodyfikować istniejący.

Dekompilacja

https://chacker.pl/

Jak sama nazwa wskazuje, dekompilatory to narzędzia, które próbują „odwrócić proces kompilacji”. Zazwyczaj zaczynają od zdeasemblowanego kodu i tłumaczą go na język wyższego poziomu, zazwyczaj pseudokod podobny do C. Dekompilatory są przydatne przy odwracaniu dużych programów, ponieważ zdekompilowany kod jest łatwiejszy w odczycie niż wiele instrukcji asemblera. Dekompilatory ograniczają się jednak do ręcznego odwracania, ponieważ proces dekompilacji jest zbyt podatny na błędy, aby stanowić wiarygodną podstawę do jakiejkolwiek automatycznej analizy. Chociaż w tej książce nie będziemy korzystać z dekompilacji, spójrzmy na Listing 6-6, aby dać Ci wyobrażenie o tym, jak wygląda zdekompilowany kod. Najczęściej używanym dekompilatorem jest Hex-Rays, wtyczka dostarczana z IDA Pro. Listing 6-6 przedstawia wynik działania Hex-Rays.

Jak widać na listingu, zdekompilowany kod jest znacznie łatwiejszy do odczytania niż surowy kod asemblera. Dekompilator zgaduje sygnaturę funkcji (1) i zmienne lokalne (2). Co więcej, zamiast mnemoników asemblera, operacje arytmetyczne i logiczne są wyrażane bardziej intuicyjnie, za pomocą operatorów standardowych języka C (3). Dekompilator próbuje również zrekonstruować konstrukcje sterowania przepływem, takie jak rozgałęzienia if/else (4), pętle (5) i wywołania funkcji (6). Dostępna jest również instrukcja return w stylu języka C, ułatwiająca zobaczenie wyniku końcowego funkcji (7). Choć to wszystko jest przydatne, należy pamiętać, że dekompilacja to nic więcej niż narzędzie pomagające zrozumieć działanie programu. Zdekompilowany kod jest daleki od oryginalnego kodu źródłowego w języku C, może jawnie zawieść i jest obarczony wszelkimi niedokładnościami w samym procesie dekompilacji, a także w samym procesie dekompilacji. Dlatego też nakładanie bardziej zaawansowanych analiz na dekompilację nie jest generalnie dobrym pomysłem.

Strukturyzacja danych

https://chacker.pl/

Jak widzieliście, deasemblery automatycznie identyfikują różne typy struktur kodu, aby ułatwić analizę binarną. Niestety, nie można tego samego powiedzieć o strukturach danych. Automatyczne wykrywanie struktur danych w plikach binarnych z usuniętymi fragmentami to niezwykle trudny problem i pomijając prace badawcze, deasemblery zazwyczaj nawet nie podejmują się tego zadania. Istnieją jednak pewne wyjątki. Na przykład, jeśli odwołanie do obiektu danych zostanie przekazane do znanej funkcji, takiej jak funkcja biblioteczna, deasemblery takie jak IDA Pro mogą automatycznie wywnioskować typ danych na podstawie specyfikacji funkcji bibliotecznej. Rysunek 6-8 przedstawia przykład.

W dolnej części bloku podstawowego znajduje się wywołanie znanej funkcji send używanej do wysyłania komunikatu przez sieć. Ponieważ IDA Pro zna parametry funkcji send, może nadać etykiety nazwom parametrów (flagi, len, buf, s) i wywnioskować typy danych rejestrów i obiektów pamięci używanych do ładowania parametrów. Ponadto typy prymitywne można czasami wywnioskować na podstawie rejestrów, w których są przechowywane, lub instrukcji używanych do manipulowania danymi. Na przykład, jeśli widzisz używany rejestr zmiennoprzecinkowy lub instrukcję, wiesz, że dane, o których mowa, są liczbą zmiennoprzecinkową. Jeśli widzisz instrukcję lodsb (załaduj bajt ciągu) lub stosb (zapisz bajt ciągu), prawdopodobnie manipuluje ona ciągiem znaków. W przypadku typów złożonych, takich jak struktury czy tablice, wszystkie założenia są nieaktualne i musisz polegać na własnej analizie. Jako przykład, dlaczego automatyczna identyfikacja typów złożonych jest trudna, spójrz, jak poniższy wiersz kodu C jest kompilowany do kodu maszynowego:

ccf->user = pwd->pw_uid;

To jest wiersz kodu źródłowego nginx v1.8.0, w którym pole całkowite z jednej struktury jest przypisywane do pola w innej strukturze. Po kompilacji z gcc v5.1 na poziomie optymalizacji -O2, daje to następujący kod maszynowy:

mov eax,DWORD PTR [rax+0x10]

mov DWORD PTR [rbx+0x60],eax

Przyjrzyjmy się teraz poniższemu wierszowi kodu C, który kopiuje liczbę całkowitą z tablicy przydzielonej na stercie o nazwie b do innej tablicy o nazwie a:

a[24] = b[4];

Oto wynik kompilacji przy użyciu gcc v5.1, ponownie na poziomie optymalizacji -O2:

mov eax,DWORD PTR [rsi+0x10]

mov DWORD PTR [rdi+0x60],eax

Jak widać, schemat kodu jest dokładnie taki sam, jak w przypadku przypisania struktury! To pokazuje, że żadna automatyczna analiza nie jest w stanie określić na podstawie serii instrukcji, czy reprezentują one wyszukiwanie w tablicy, dostęp do struktury, czy coś zupełnie innego. Takie problemy utrudniają, a w ogólnym przypadku wręcz uniemożliwiają, dokładne wykrywanie złożonych typów danych. Należy pamiętać, że ten przykład jest dość prosty; wyobraź sobie odwracanie programu zawierającego tablicę typów struktur lub struktur zagnieżdżonych i próbę ustalenia, które instrukcje indeksują daną strukturę danych! To oczywiście złożone zadanie, wymagające dogłębnej analizy kodu. Biorąc pod uwagę złożoność dokładnego rozpoznawania nietrywialnych typów danych, można zrozumieć, dlaczego dezasemblery nie podejmują prób automatycznego wykrywania struktur danych. Aby ułatwić ręczne strukturowanie danych, IDA Pro pozwala definiować własne typy złożone (które należy wywnioskować, odwracając kod) i przypisywać je do elementów danych.

Kod obiektowy

https://chacker.pl/

Wiele narzędzi do analizy binarnej, w tym w pełni funkcjonalne dezasemblery, takie jak IDA Pro, jest przeznaczonych dla programów napisanych w językach proceduralnych, takich jak C. Ponieważ kod jest strukturyzowany głównie poprzez użycie funkcji w tych językach, narzędzia do analizy binarnej i dezasemblery oferują funkcje takie jak wykrywanie funkcji w celu odtworzenia struktury funkcji programów oraz wywołują grafy w celu zbadania relacji między funkcjami. Języki obiektowe, takie jak C++, strukturują kod za pomocą klas grupujących logicznie powiązane funkcje i dane. Zazwyczaj oferują również złożone funkcje obsługi wyjątków, które pozwalają dowolnej instrukcji zgłosić wyjątek, który jest następnie przechwytywany przez specjalny blok kodu obsługujący wyjątek. Niestety, obecne narzędzia do analizy binarnej nie umożliwiają odzyskiwania hierarchii klas i struktur obsługi wyjątków. Co gorsza, programy w C++ często zawierają wiele wskaźników do funkcji ze względu na sposób implementacji metod wirtualnych. Metody wirtualne to metody klas (funkcje), które można nadpisywać w klasie pochodnej. W klasycznym przykładzie można zdefiniować klasę o nazwie Shape, która ma klasę pochodną o nazwie Circle. Klasa Shape definiuje metodę wirtualną o nazwie area, która oblicza pole powierzchni kształtu, a klasa Circle nadpisuje tę metodę własną implementacją odpowiednią dla klas Circle. Podczas kompilacji programu w C++ kompilator może nie wiedzieć, czy wskaźnik będzie wskazywał na obiekt bazowy klasy Shape, czy na obiekt pochodny klasy Circle w czasie wykonywania, więc nie może statycznie określić, która implementacja metody area powinna zostać użyta w czasie wykonywania. Aby rozwiązać ten problem, kompilatory emitują tablice wskaźników do funkcji, zwane tablicami wirtualnymi (vtable), które zawierają wskaźniki do wszystkich funkcji wirtualnych danej klasy. Tabele wirtualne (vtable) są zazwyczaj przechowywane w pamięci tylko do odczytu, a każdy obiekt polimorficzny ma wskaźnik (vptr) do tablicy wirtualnej (vtable) dla typu obiektu. Aby wywołać metodę wirtualną, kompilator emituje kod, który podąża za tablicą wirtualną (vptr) obiektu w czasie wykonywania i pośrednio wywołuje odpowiedni wpis w jego tablicy wirtualnej (vtable). Niestety, wszystkie te pośrednie wywołania jeszcze bardziej utrudniają śledzenie przepływu sterowania w programie. Brak wsparcia dla programów obiektowych w narzędziach do analizy binarnej i deasemblerach oznacza, że jeśli chcesz ustrukturyzować swoją analizę wokół hierarchii klas, jesteś zdany na siebie. Podczas ręcznej inżynierii wstecznej programu C++ często można połączyć funkcje i struktury danych należące do różnych klas, ale wymaga to znacznego wysiłku. Nie będę tutaj zagłębiał się w szczegóły tego tematu, aby skupić się na (pół)zautomatyzowanych technikach analizy binarnej.  W przypadku analizy zautomatyzowanej możesz (jak większość narzędzi do analizy binarnej) po prostu udawać, że klasy nie istnieją i traktować programy obiektowe tak samo jak programy proceduralne. W rzeczywistości to „rozwiązanie” działa odpowiednio w przypadku wielu rodzajów analizy i oszczędza Ci trudu implementacji specjalnego wsparcia C++, chyba że jest to naprawdę konieczne.

Wykresy wywołań

https://chacker.pl/

Grafy wywołań są podobne do wykresów CFG, z tą różnicą, że pokazują relacje między miejscami wywołań i funkcjami, a nie podstawowymi blokami. Innymi słowy, wykresy CFG pokazują, jak sterowanie może przepływać w ramach funkcji, podczas gdy wykresy wywołań pokazują, które funkcje mogą wywoływać się nawzajem. Podobnie jak w przypadku wykresów CFG, wykresy wywołań często pomijają pośrednie krawędzie wywołań, ponieważ nie jest możliwe dokładne określenie, które funkcje mogą być wywoływane przez dane pośrednie miejsce wywołania. Lewa strona rysunku 6-6 przedstawia zestaw funkcji (oznaczonych od f1 do f4) i relacje wywołań między nimi. Każda funkcja składa się z kilku podstawowych bloków (szare kółka) i krawędzi gałęzi (strzałki). Odpowiedni wykres wywołań znajduje się po prawej stronie rysunku. Jak widać, wykres wywołań zawiera węzeł dla każdej funkcji i krawędzie pokazujące, że funkcja f1 może wywołać zarówno f2, jak i f3, a także krawędź reprezentującą wywołanie z f3 do f1. Wywołania ogonowe, które są w rzeczywistości implementowane jako instrukcje skoku, są pokazane na wykresie wywołań jako zwykłe wywołanie. Należy jednak zauważyć, że pośrednie wywołanie z f2 do f4 nie jest pokazane na wykresie wywołań

IDA Pro może również wyświetlać częściowe wykresy wywołań, które pokazują tylko potencjalnych użytkowników wybranej funkcji. Do analizy ręcznej są one często bardziej przydatne niż pełne wykresy wywołań, ponieważ pełne wykresy wywołań często zawierają zbyt wiele informacji. Rysunek 6-7 przedstawia przykład częściowego wykresu wywołań w IDA Pro, który ujawnia odwołania do funkcji sub_404610.

Jak widać, wykres pokazuje, skąd funkcja jest wywoływana; na przykład funkcja sub_404610 jest wywoływana przez funkcję sub_4e1bd0, która z kolei jest wywoływana przez funkcję sub_4e2fa0. Ponadto wykresy wywołań generowane przez IDA Pro pokazują instrukcje, które przechowują adres funkcji w jakimś miejscu. Na przykład pod adresem 0x4e072c w sekcji .text znajduje się instrukcja, która przechowuje adres funkcji sub_4e2fa0 w pamięci. Nazywa się to „pobieraniem adresu” funkcji sub_4e2fa0. Funkcje, których adres jest pobierany w dowolnym miejscu kodu, nazywane są funkcjami pobierającymi adres. Warto wiedzieć, które funkcje są pobierane z adresu, ponieważ wskazuje to, że mogą być wywoływane pośrednio, nawet jeśli nie wiadomo dokładnie, z której strony. Jeśli adres funkcji nigdy nie jest pobierany i nie pojawia się w żadnej sekcji danych, wiadomo, że nigdy nie zostanie ona wywołana pośrednio. Jest to przydatne w przypadku niektórych rodzajów analizy binarnej lub aplikacji zabezpieczających, na przykład gdy próbujesz zabezpieczyć plik binarny, ograniczając wywołania pośrednie tylko do dozwolonych celów.

Grafy przepływu sterowania

https://chacker.pl/

Podzielenie zdeasemblowanego kodu na funkcje to jedno, ale niektóre funkcje są dość duże, co oznacza, że analiza nawet jednej funkcji może być złożonym zadaniem. Aby uporządkować wewnętrzną strukturę każdej funkcji, deasemblery i frameworki analizy binarnej wykorzystują inną strukturę kodu, zwaną grafem przepływu sterowania (CFG). Grafy CFG są przydatne zarówno do analizy automatycznej, jak i ręcznej. Oferują również wygodną graficzną reprezentację struktury kodu, co ułatwia szybkie zorientowanie się w strukturze funkcji. Rysunek  przedstawia przykład CFG funkcji zdeasemblowanej za pomocą IDA Pro.

Jak widać na rysunku, bloki CFG reprezentują kod wewnątrz funkcji jako zestaw bloków kodu, zwanych blokami podstawowymi, połączonych krawędziami gałęzi, pokazanymi tutaj jako strzałki. Blok podstawowy to sekwencja instrukcji, gdzie pierwsza instrukcja jest jedynym punktem wejścia (jedyną instrukcją, do której skierowany jest skok w kodzie binarnym), a ostatnia instrukcja jest jedynym punktem wyjścia (jedyną instrukcją w sekwencji, która może przeskoczyć do innego bloku podstawowego). Innymi słowy, nigdy nie zobaczysz bloku podstawowego ze strzałką połączoną z jakąkolwiek instrukcją inną niż pierwsza lub ostatnia. Krawędź w bloku CFG prowadząca z bloku podstawowego B do innego bloku podstawowego C oznacza, że ostatnia instrukcja w bloku B może przeskoczyć na początek bloku C. Jeśli blok B ma tylko jedną krawędź wychodzącą, oznacza to, że z pewnością przekaże sterowanie do celu tej krawędzi. Na przykład, tak wygląda to w przypadku skoku pośredniego lub instrukcji wywołania. Z drugiej strony, jeśli blok B kończy się skokiem warunkowym, będzie miał dwie krawędzie wychodzące, a to, która krawędź zostanie wykorzystana w czasie wykonywania, zależy od wyniku warunku skoku. Krawędzie wywołań nie są częścią CFG, ponieważ dotyczą kodu spoza funkcji. Zamiast tego CFG pokazuje jedynie krawędź „fallthrough”, która wskazuje na instrukcję, do której sterowanie zostanie zwrócone po zakończeniu wywołania funkcji. Istnieje inna struktura kodu, zwana grafem wywołań, która ma reprezentować krawędzie między instrukcjami wywołań a funkcjami. Grafy wywołań omówię później. W praktyce deasemblery często pomijają krawędzie pośrednie w CFG, ponieważ trudno jest statycznie określić potencjalne cele takich krawędzi. Deasemblery czasami definiują globalny CFG zamiast CFG dla poszczególnych funkcji. Taki globalny CFG nazywany jest międzyproceduralnym CFG (ICFG), ponieważ jest w istocie sumą wszystkich CFG dla poszczególnych funkcji (procedura to inne słowo oznaczające funkcję). ICFG eliminują potrzebę wykrywania funkcji podatnych na błędy, ale nie oferują korzyści z kompartmentacji, jakie oferują CFG dla poszczególnych funkcji.