https://chacker.pl/
W przeciwieństwie do dezasemblacji liniowej, dezasemblacja rekurencyjna jest wrażliwa na przepływ sterowania. Zaczyna od znanych punktów wejścia do pliku binarnego (takich jak główny punkt wejścia i eksportowane symbole funkcji), a następnie rekurencyjnie podąża za przepływem sterowania (takim jak skoki i wywołania), aby odkryć kod. Pozwala to dezasemblacji rekurencyjnej obejść bajty danych we wszystkich przypadkach, z wyjątkiem kilku skrajnych. Wadą tego podejścia jest to, że nie każdy przepływ sterowania jest tak łatwy do śledzenia. Na przykład, często trudno, jeśli nie wręcz niemożliwe, jest statyczne określenie możliwych celów skoków pośrednich lub wywołań. W rezultacie dezasembler może pominąć bloki kodu (lub nawet całe funkcje, takie jak f1 i f2 na rysunku 6-1) będące celem skoków pośrednich lub wywołań, chyba że zastosuje specjalną (specyficzną dla kompilatora i podatną na błędy) heurystykę do określenia przepływu sterowania. Dezasemblacja rekurencyjna jest de facto standardem w wielu zastosowaniach inżynierii wstecznej, takich jak analiza złośliwego oprogramowania. IDA Pro to jeden z najnowocześniejszych i najpowszechniej używanych dezasemblerów rekurencyjnych. IDA Pro, skrót od Interactive DisAssembler, jest przeznaczony do użytku interaktywnego i oferuje wiele funkcji do wizualizacji kodu, jego eksploracji, tworzenia skryptów (w Pythonie), a nawet dekompilacji2, które nie są dostępne w prostych narzędziach, takich jak objdump. Oczywiście, ma to swoją cenę: w momencie pisania tego tekstu licencje na IDA Starter (uproszczoną edycję IDA Pro) zaczynają się od 739 dolarów, a pełne licencje IDA Professional kosztują 1409 dolarów i więcej. Ale bez obaw — nie musisz kupować IDA Pro, aby korzystać z tej książki. Książka koncentruje się nie na interaktywnej inżynierii wstecznej, ale na tworzeniu własnych zautomatyzowanych narzędzi do analizy binarnej opartych na darmowych frameworkach. Rysunek ilustruje niektóre wyzwania, z jakimi w praktyce borykają się rekurencyjne deasemblery, takie jak IDA Pro. W szczególności rysunek pokazuje, jak prosta funkcja z opensshd v7.1p2 jest kompilowana przez gcc v5.1.1 z kodu C do kodu x64.

Jak widać po lewej stronie rysunku, przedstawiającego reprezentację funkcji w języku C, funkcja nie wykonuje żadnych specjalnych operacji. Używa pętli for do iterowania po tablicy, stosując w każdej iteracji instrukcję switch, aby określić, co zrobić z bieżącym elementem tablicy: pominąć nieistotne elementy, zwrócić indeks elementu spełniającego określone kryteria lub wyświetlić błąd i zakończyć działanie, jeśli wystąpi coś nieoczekiwanego. Pomimo prostoty kodu w języku C, skompilowana wersja tej funkcji (pokazana po prawej stronie rysunku) jest daleka od trywialnej do poprawnego rozłożenia. Jak widać na rysunku 6-4, implementacja instrukcji switch w architekturze x64 opiera się na tablicy skoków, konstrukcji powszechnie stosowanej przez nowoczesne kompilatory. Ta implementacja tablicy skoków eliminuje potrzebę skomplikowanego gąszczu skoków warunkowych. Zamiast tego, instrukcja pod adresem 0x4438f9 używa wartości wejściowej switch do obliczenia (w rax) indeksu w tabeli, która przechowuje pod tym indeksem adres odpowiedniego bloku case. W ten sposób, do przeniesienia sterowania na dowolny adres zdefiniowany w tablicy skoków, wymagany jest tylko pojedynczy skok pośredni pod adresem 0x443901. Tabele skoków, choć wydajne, utrudniają rekurencyjny dezasembler, ponieważ wykorzystują pośredni przepływ sterowania. Brak jawnego adresu docelowego w skoku pośrednim utrudnia dezasemblerowi śledzenie przepływu instrukcji za tym punktem. W rezultacie wszelkie instrukcje, do których może odnosić się skok pośredni, pozostają niewykryte, chyba że dezasembler zaimplementuje określoną (zależną od kompilatora) heurystykę do wykrywania i analizy tablic skoków. W tym przykładzie oznacza to, że rekurencyjny dezasembler, który nie implementuje heurystyki wykrywania przełączeń, w ogóle nie wykryje instrukcji pod adresami 0x443903–0x443925. Sprawa komplikuje się jeszcze bardziej, ponieważ w przełączeniu występuje wiele instrukcji ret, a także wywołania funkcji krytycznej, która zgłasza błąd i nigdy nie zwraca wyników. Ogólnie rzecz biorąc, nie można bezpiecznie zakładać, że po instrukcji ret lub wywołaniu niezwracającym następują instrukcje; zamiast tego, po tych instrukcjach mogą następować dane lub bajty uzupełniające, które nie mają być analizowane jako kod. Jednakże odwrotne założenie, że po tych instrukcjach nie następuje dalsza część kodu, może doprowadzić do pominięcia instrukcji przez dezasembler, co z kolei doprowadzi do niekompletnego dezasemblowania. To tylko niektóre z wyzwań, z jakimi borykają się dezasemblery rekurencyjne; istnieje wiele bardziej złożonych przypadków, zwłaszcza w przypadku bardziej skomplikowanych funkcji niż ta pokazana w przykładzie. Jak widać, ani dezasemblacja liniowa, ani rekurencyjna nie są idealne. W przypadku nieszkodliwych plików binarnych ELF dla architektury x86, dezasemblacja liniowa jest dobrym wyborem, ponieważ zapewnia zarówno kompletny, jak i dokładny dezasembler: takie pliki binarne zazwyczaj nie zawierają danych wbudowanych, które mogłyby zakłócić działanie dezasemblera, a podejście liniowe nie spowoduje pominięcia kodu z powodu nierozwiązanego pośredniego przepływu sterowania. Z drugiej strony, jeśli w grę wchodzą dane inline lub złośliwy kod, prawdopodobnie lepszym pomysłem jest użycie dezasemblera rekurencyjnego, który nie daje się tak łatwo oszukać i generuje fałszywe dane wyjściowe jak dezasembler liniowy. W przypadkach, gdy poprawność dezasemblacji jest kluczowa, nawet kosztem kompletności, można zastosować dezasemblację dynamiczną. Przyjrzyjmy się, czym to podejście różni się od omówionych wcześniej metod dezasemblacji statycznej.