Strukturyzacja zdeasemblowanego kodu i danych

https://chacker.pl/

Do tej pory pokazałem, jak statyczne i dynamiczne dezasemblery odnajdują instrukcje w pliku binarnym, ale dezasemblacja na tym się nie kończy. Duże, nieustrukturyzowane stosy zdeasemblowanych instrukcji są praktycznie niemożliwe do analizy, dlatego większość dezasemblerów strukturyzuje zdeasemblowany kod w sposób, który jest łatwiejszy do analizy. W tej sekcji omówię typowe struktury kodu i danych odzyskiwane przez dezasemblery oraz ich wpływ na analizę binarną.

Wykonywanie symboliczne

https://chacker.pl/

Wykonywanie symboliczne to szeroka technika o wielu zastosowaniach, nie tylko w zakresie pokrycia kodu. Tutaj przedstawię jedynie ogólny zarys tego, jak wykonywanie symboliczne odnosi się do pokrycia kodu, pomijając wiele szczegółów, więc nie martw się, jeśli jeszcze nie opanowałeś wszystkiego. Zazwyczaj podczas uruchamiania aplikacji używa się konkretnych wartości dla wszystkich zmiennych. W każdym punkcie wykonywania każdy rejestr procesora i obszar pamięci zawiera określoną wartość, a wartości te zmieniają się w czasie w miarę postępu obliczeń w aplikacji. Wykonywanie symboliczne działa inaczej. W skrócie, wykonywanie symboliczne pozwala na uruchomienie aplikacji nie przy użyciu konkretnych wartości, ale wartości symbolicznych. Wartości symboliczne można traktować jako symbole matematyczne. Wykonywanie symboliczne to w zasadzie emulacja programu, w której wszystkie lub niektóre zmienne (lub stany rejestrów i pamięci) są reprezentowane za pomocą takich symboli. Aby lepiej zrozumieć, co to oznacza, rozważ program w pseudokodzie pokazany na Listingu.

Program rozpoczyna się od pobrania dwóch argumentów wiersza poleceń, przekonwertowania ich na liczby i zapisania w dwóch zmiennych o nazwach x i y (1). Na początku wykonania symbolicznego można zdefiniować zmienną x tak, aby zawierała wartość symboliczną alfa1, podczas gdy y może zostać zainicjowane wartością α2. Zarówno α1, jak i α2 to symbole, które mogą reprezentować dowolną możliwą wartość liczbową. Następnie, w miarę postępu emulacji, program zasadniczo oblicza formuły dla tych symboli. Na przykład, operacja z = x + y powoduje, że z przyjmuje wyrażenie symboliczne 1 + α2 (2). Jednocześnie wykonanie symboliczne oblicza również ograniczenia ścieżki, które są po prostu ograniczeniami konkretnych wartości, jakie symbole mogą przyjmować, biorąc pod uwagę dotychczas przebyte gałęzie. Na przykład, jeśli obrana zostanie gałąź if(x < 5), wykonanie symboliczne dodaje ograniczenie ścieżki, twierdząc, że α1 < 5 (3). To ograniczenie wyraża fakt, że jeśli zostanie podjęta gałąź if, to α1 (wartość symboliczna w x) musi być zawsze mniejsza niż 5. W przeciwnym razie gałąź nie zostałaby podjęta. Dla każdej gałęzi wykonanie symboliczne odpowiednio rozszerza listę ograniczeń ścieżki. Jak to wszystko odnosi się do pokrycia kodu? Kluczowe jest to, że mając listę ograniczeń ścieżki, można sprawdzić, czy istnieje jakiś konkretny parametr wejściowy, który spełniałby wszystkie te ograniczenia. Istnieją specjalne programy, zwane rozwiązywaczami ograniczeń, które sprawdzają, mając listę ograniczeń, czy istnieje sposób na spełnienie tych ograniczeń. Na przykład, jeśli jedynym ograniczeniem jest alfa1 < 5, rozwiązywacz może dać rozwiązanie α1 = 4  α2 = 0. Należy zauważyć, że ograniczenia ścieżki nic nie mówią o α2, więc może ona przyjmować dowolną wartość. Oznacza to, że na początku konkretnego wykonania programu można (za pomocą danych wejściowych użytkownika) ustawić wartość 4 dla x i wartość 0 dla y, a wykonanie wykona tę samą serię rozgałęzień, co wykonanie symboliczne. Jeśli nie ma rozwiązania, solver poinformuje o tym. Aby zwiększyć pokrycie kodu, można zmienić ograniczenia ścieżki i zapytać solver, czy istnieje sposób na spełnienie zmienionych ograniczeń. Na przykład można „odwrócić” ograniczenie α1 < 5, na przykład α1 ≥ 5 i poprosić solver o rozwiązanie. Solver poinformuje wówczas o możliwym rozwiązaniu, takim jak α1 = 5     α2 = 0, które można podać jako dane wejściowe do konkretnego wykonania programu, wymuszając w ten sposób na tym wykonaniu przejście do gałęzi else i zwiększając tym samym pokrycie kodu (4). Jeśli solver informuje Cię, że nie ma możliwego rozwiązania, wiesz, że nie ma możliwości „odwrócenia” gałęzi i powinieneś kontynuować poszukiwanie nowych ścieżek, zmieniając inne ograniczenia ścieżki. Jak mogłeś wywnioskować z tej dyskusji, wykonywanie symboliczne (a nawet jego zastosowanie do pokrycia kodu) to złożony temat. Nawet biorąc pod uwagę możliwość „odwrócenia” ograniczeń ścieżki, pokrycie wszystkich ścieżek programu jest nadal niewykonalne, ponieważ liczba możliwych ścieżek rośnie wykładniczo wraz z liczbą instrukcji gałęzi w programie. Co więcej, rozwiązanie zestawu ograniczeń ścieżki jest obliczeniowo intensywne; jeśli nie zachowasz ostrożności, Twoje podejście do wykonywania symbolicznego może łatwo stać się nieskalowalne. W praktyce stosowanie wykonywania symbolicznego w skalowalny i efektywny sposób wymaga dużej staranności.

Fuzzing

https://chacker.pl/

Istnieją również narzędzia, zwane fuzzerami, które próbują automatycznie generować dane wejściowe, aby pokryć nowe ścieżki kodu w danym pliku binarnym. Znane fuzzery to AFL, Project Springfield firmy Microsoft i OSS-Fuzz firmy Google. Ogólnie rzecz biorąc, fuzzery dzielą się na dwie kategorie w zależności od sposobu generowania danych wejściowych.

  1. Fuzzery oparte na generacji: Generują dane wejściowe od podstaw (ewentualnie ze znajomością oczekiwanego formatu danych wejściowych).
  2. Fuzzery oparte na mutacji: Te fuzzery generują nowe dane wejściowe poprzez mutację znanych, prawidłowych danych wejściowych w jakiś sposób, na przykład zaczynając od istniejącego zestawu testów.

Skuteczność i wydajność fuzzerów w dużej mierze zależą od informacji dostępnych dla fuzzera. Na przykład pomocna jest dostępność informacji źródłowych lub znajomość oczekiwanego formatu danych wejściowych programu. Jeśli żadna z tych rzeczy nie jest znana (a nawet jeśli wszystkie są znane), fuzzing może wymagać dużo czasu obliczeniowego i może nie dotrzeć do kodu ukrytego za złożonymi sekwencjami warunków if/else, których fuzzer nie jest w stanie „odgadnąć”. Fuzzery są zazwyczaj używane do wyszukiwania błędów w programach, permutując dane wejściowe aż do wykrycia awarii. Chociaż nie będę w tej książce szczegółowo omawiał fuzzingu, zachęcam do wypróbowania jednego z dostępnych darmowych narzędzi. Każdy fuzzer ma swoją własną metodę użycia. Świetnym wyborem do eksperymentów jest AFL, który jest darmowy i posiada dobrą dokumentację online.

Zestawy testowe

https://chacker.pl/

tJedną z najłatwiejszych i najpopularniejszych metod zwiększenia pokrycia kodu jest uruchomienie analizowanego pliku binarnego ze znanymi danymi wejściowymi. Twórcy oprogramowania często ręcznie opracowują zestawy testowe dla swoich programów, tworząc dane wejściowe zaprojektowane tak, aby obejmowały jak najwięcej funkcjonalności programu. Takie zestawy testowe idealnie nadają się do analizy dynamicznej. Aby uzyskać dobre pokrycie kodu, wystarczy uruchomić przebieg analizy dla każdego z danych wejściowych testu. Oczywiście wadą tego podejścia jest to, że gotowy zestaw testowy nie zawsze jest dostępny, na przykład w przypadku oprogramowania własnościowego lub złośliwego oprogramowania.

Dokładny sposób wykorzystania zestawów testowych do pokrycia kodu różni się w zależności od aplikacji i struktury zestawu testowego danej aplikacji. Zazwyczaj istnieje specjalny cel testu w pliku Makefile, którego można użyć do uruchomienia zestawu testowego, wpisując polecenie make test w wierszu poleceń. Wewnątrz pliku Makefile cel testu jest często ustrukturyzowany w sposób podobny do Listingu

Zmienna PROGRAM zawiera nazwę testowanej aplikacji, w tym przypadku foo. Cel testu zależy od szeregu przypadków testowych (test1, test2 itd.), z których każdy jest wywoływany po uruchomieniu make test. Każdy przypadek testowy polega na uruchomieniu PROGRAM na danych wejściowych, zarejestrowaniu danych wyjściowych, a następnie sprawdzeniu ich z poprawnymi danymi wyjściowymi za pomocą polecenia diff. Istnieje wiele różnych (i bardziej zwięzłych) sposobów implementacji tego typu frameworka testowego, ale kluczową kwestią jest to, że można uruchomić narzędzie do analizy dynamicznej w każdym przypadku testowym, po prostu nadpisując zmienną PROGRAM. Na przykład, załóżmy, że chcesz uruchomić każdy z przypadków testowych foo za pomocą gdb. (W rzeczywistości, zamiast gdb, prawdopodobnie użyłbyś w pełni zautomatyzowanej analizy dynamicznej, której budowę poznasz w rozdziale 9). Możesz to zrobić w następujący sposób:

make test PROGRAM=”gdb foo”

Zasadniczo redefiniuje to PROGRAM, dzięki czemu zamiast po prostu uruchamiać foo z każdym testem, teraz uruchamiasz foo w gdb. W ten sposób gdb lub dowolne inne narzędzie do analizy dynamicznej uruchamia foo z każdym przypadkiem testowym, umożliwiając dynamiczną analizę całego kodu foo objętego przypadkami testowymi. W przypadkach, gdy nie ma zmiennej PROGRAM do nadpisania, konieczne będzie wyszukanie i zastąpienie, ale idea pozostaje ta sama.

Strategie pokrycia kodu

https://chacker.pl/

Główną wadą wszelkiej analizy dynamicznej, nie tylko dynamicznego demontażu, jest problem pokrycia kodu: analiza obejmuje tylko instrukcje faktycznie wykonywane podczas analizy. Zatem, jeśli jakiekolwiek kluczowe informacje są ukryte w innych instrukcjach, analiza nigdy się o tym nie dowie. Na przykład, jeśli dynamicznie analizujesz program, który zawiera bombę logiczną (np. uruchamiającą złośliwe zachowanie w określonym momencie w przyszłości), nigdy się o tym nie dowiesz, dopóki nie będzie za późno. Z kolei dokładna inspekcja z wykorzystaniem analizy statycznej mogłaby to ujawnić. Jako inny przykład, podczas dynamicznego testowania oprogramowania pod kątem błędów, nigdy nie będziesz mieć pewności, że błąd nie znajduje się w rzadko wykonywanej ścieżce kodu, której nie uwzględniłeś w testach. Wiele próbek złośliwego oprogramowania próbuje nawet aktywnie ukrywać się przed narzędziami do analizy dynamicznej lub debuggerami, takimi jak gdb. Praktycznie wszystkie takie narzędzia generują w środowisku jakiś wykrywalny artefakt; analiza nieuchronnie spowalnia wykonywanie, zazwyczaj na tyle, aby było to wykrywalne. Złośliwe oprogramowanie wykrywa te artefakty i ukrywa swoje prawdziwe zachowanie, jeśli wie, że jest analizowane. Aby włączyć dynamiczną analizę tych próbek, należy przeprowadzić inżynierię wsteczną, a następnie wyłączyć mechanizmy antyanalizacyjne złośliwego oprogramowania (na przykład nadpisując te bajty kodu poprawionymi wartościami). Te sztuczki antyanalizacyjne są powodem, dla którego, jeśli to możliwe, zazwyczaj dobrym pomysłem jest przynajmniej rozszerzenie dynamicznej analizy złośliwego oprogramowania o metody analizy statycznej. Ponieważ znalezienie prawidłowych danych wejściowych obejmujących każdą możliwą ścieżkę programu jest trudne i czasochłonne, dynamiczna deasemblacja prawie nigdy nie ujawni wszystkich możliwych zachowań programu. Istnieje kilka metod, które można wykorzystać do poprawy pokrycia narzędzi do analizy dynamicznej, choć generalnie żadna z nich nie osiąga poziomu kompletności zapewnianego przez analizę statyczną. Przyjrzyjmy się niektórym z najczęściej stosowanych metod.

Przykład: Śledzenie wykonania pliku binarnego za pomocą gdb

https://www.chacker.pl/

Co zaskakujące, w systemie Linux nie ma powszechnie akceptowanego standardowego narzędzia do śledzenia wykonania typu „wystrzel i zapomnij” (w przeciwieństwie do systemu Windows, gdzie dostępne są doskonałe narzędzia, takie jak OllyDbg4). Najprostszym sposobem, korzystając wyłącznie ze standardowych narzędzi, jest użycie kilku poleceń gdb, jak pokazano na Listingu

.

Ten przykład ładuje /bin/ls do gdb i generuje ślad wszystkich wykonanych instrukcji podczas wyświetlania zawartości bieżącego katalogu. Po uruchomieniu gdb możesz wyświetlić informacje o plikach załadowanych do gdb (w tym przypadku jest to tylko plik wykonywalny /bin/ls) (1). To pokazuje adres punktu wejścia pliku binarnego (2), dzięki czemu możesz ustawić tam punkt przerwania, który zatrzyma wykonywanie natychmiast po uruchomieniu pliku binarnego (3). Następnie wyłączasz stronicowanie (4) i konfigurujesz gdb tak, aby logował do pliku, a nie na standardowe wyjście (5). Domyślnie plik dziennika nazywa się gdb.txt. Stronicowanie oznacza, że gdb zatrzymuje się po wyprowadzeniu określonej liczby wierszy, pozwalając użytkownikowi przeczytać wszystkie dane wyjściowe na ekranie przed przejściem dalej. Jest to domyślnie włączone. Ponieważ logujesz do pliku, nie chcesz tych przerw, ponieważ musiałbyś ciągle naciskać klawisz, aby kontynuować, co szybko staje się irytujące. Po skonfigurowaniu wszystkiego uruchamiasz plik binarny (6). Zatrzymuje się natychmiast, gdy tylko zostanie osiągnięty punkt wejścia. Daje to możliwość polecenia gdb, aby zalogował tę pierwszą instrukcję do pliku (7), a następnie rozpoczął pętlę while (8), która nieprzerwanie wykonuje pojedynczą instrukcję na raz (9) (nazywa się to pojedynczym krokiem), aż nie będzie już żadnych instrukcji do wykonania. Każda instrukcja wykonana pojedynczym krokiem jest automatycznie zapisywana w pliku dziennika w tym samym formacie co poprzednio. Po zakończeniu wykonywania otrzymujesz plik dziennika zawierający wszystkie wykonane instrukcje. Jak można się spodziewać, dane wyjściowe są dość obszerne; nawet proste uruchomienie małego programu obejmuje dziesiątki lub setki tysięcy instrukcji, jak pokazano na Listingu

Używając wc do zliczenia wierszy w pliku dziennika, można zauważyć, że plik zawiera 614 390 wierszy – zdecydowanie za dużo, aby je tutaj wymienić (1). Aby dać Ci wyobrażenie o wyglądzie danych wyjściowych, możesz użyć head do przejrzenia pierwszych 20 wierszy w dzienniku (2). Rzeczywisty ślad wykonania zaczyna się od (3). Dla każdej wykonanej instrukcji gdb wyświetla polecenie użyte do jej zarejestrowania, następnie samą instrukcję, a na końcu kontekst dotyczący jej lokalizacji (która jest nieznana, ponieważ plik binarny jest oddzielony). Używając grep, możesz odfiltrować wszystko poza wierszami pokazującymi wykonane instrukcje, ponieważ to wszystko, co Cię interesuje, uzyskując dane wyjściowe pokazane na Listingu

Jak widać, jest to o wiele bardziej czytelne niż niefiltrowany dziennik gdb.

Dezasemblacja dynamiczna

https://chacker.pl/

W poprzednich sekcjach przedstawiono wyzwania, z jakimi borykają się dezasemblery statyczne, takie jak odróżnianie danych od kodu, rozwiązywanie wywołań pośrednich itd. Analiza dynamiczna rozwiązuje wiele z tych problemów, ponieważ dysponuje bogatym zestawem informacji z czasu wykonania, takich jak konkretna zawartość rejestrów i pamięci. Gdy wykonanie dociera do określonego adresu, można mieć absolutną pewność, że znajduje się tam instrukcja, więc dezasemblacja dynamiczna nie cierpi na problemy z niedokładnością występujące w dezasemblacji statycznej. Pozwala to dezasemblerom dynamicznym, znanym również jako śledzące wykonania lub śledzące instrukcje, na proste zrzucanie instrukcji (i ewentualnie zawartości pamięci/rejestrów) podczas wykonywania programu. Główną wadą tego podejścia jest problem pokrycia kodu: fakt, że dezasemblery dynamiczne nie widzą wszystkich instrukcji, a tylko te, które wykonują. Do problemu pokrycia kodu wrócę później w tej sekcji. Najpierw przyjrzyjmy się konkretnemu śladowi wykonania.

Dezasemblacja rekurencyjna

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.

Dezasemblacja liniowa

https://chacker.pl/

Zacznijmy od dezasemblacji liniowej, która jest koncepcyjnie najprostszym podejściem. Iteruje ona wszystkie segmenty kodu w pliku binarnym, dekodując kolejno wszystkie bajty i przetwarzając je na listę instrukcji. Wiele prostych dezasemblerów, w tym objdump z rozdziału 1, korzysta z tego podejścia. Ryzyko związane z dezasemblacją liniową polega na tym, że nie wszystkie bajty mogą być instrukcjami. Na przykład niektóre kompilatory, takie jak Visual Studio, przeplatają dane, takie jak tabele skoków, z kodem, nie pozostawiając żadnych wskazówek co do dokładnego miejsca ich występowania. Jeśli dezasemblery przypadkowo przetworzą te dane inline jako kod, mogą napotkać nieprawidłowe kody operacji. Co gorsza, bajty danych mogą przypadkowo odpowiadać prawidłowym kodom operacji, co prowadzi do wygenerowania przez dezasembler błędnych instrukcji. Jest to szczególnie prawdopodobne w gęstych systemach ISA, takich jak x86, gdzie większość wartości bajtów reprezentuje prawidłowy kod operacji. Ponadto, w procesorach ISA z kodami operacyjnymi o zmiennej długości, takich jak x86, dane inline mogą nawet spowodować desynchronizację deasemblera względem rzeczywistego strumienia instrukcji. Chociaż deasembler zazwyczaj resynchronizuje się samoczynnie, desynchronizacja może spowodować pominięcie kilku pierwszych rzeczywistych instrukcji następujących po danych inline, jak pokazano na rysunku .

Rysunek ilustruje desynchronizację deasemblera w części sekcji kodu pliku binarnego. Widać szereg bajtów danych inline (0x8e 0x20 0x5c 0x00), po których następują instrukcje (push rbp, mov rbp,rsp itd.). Prawidłowe dekodowanie wszystkich bajtów, takie jak w przypadku idealnie zsynchronizowanego deasemblera, pokazano po lewej stronie rysunku w sekcji „synchronized”. Jednak naiwny deasembler liniowy interpretuje dane inline jako kod, dekodując bajty w sposób pokazany w sekcji „-4 bytes off”. Jak widać, dane inline są dekodowane jako instrukcja mov fs,[rax], po której następują instrukcje pop rsp i add [rbp+0x48],dl. Ta ostatnia instrukcja jest szczególnie uciążliwa, ponieważ wykracza poza obszar danych inline i obejmuje rzeczywiste instrukcje! W ten sposób instrukcja add „zjada” część rzeczywistych bajtów instrukcji, powodując, że deasembler całkowicie pomija pierwsze dwie rzeczywiste instrukcje. Deasembler napotyka podobne problemy, jeśli rozpoczyna o 3 bajty za wcześnie („-3 bajty poza”), co może się zdarzyć, gdy deasembler próbuje pominąć dane inline, ale nie rozpoznaje ich wszystkich. Na szczęście w architekturze x86 strumień zdeasemblowanych instrukcji ma tendencję do automatycznej resynchronizacji już po kilku instrukcjach. Jednak pominięcie nawet kilku instrukcji może być złą wiadomością, jeśli wykonujesz jakąkolwiek automatyczną analizę lub chcesz zmodyfikować plik binarny na podstawie zdeasemblowanego kodu. Jak zobaczysz w rozdziale 8, złośliwe programy czasami celowo zawierają bajty zaprojektowane w celu desynchronizacji deasemblerów, aby ukryć rzeczywiste działanie programu. W praktyce liniowe deasemblery, takie jak objdump, są bezpieczne w użyciu do deasemblowania plików binarnych ELF skompilowanych za pomocą nowszych wersji kompilatorów, takich jak gcc lub clang LLVM. Wersje x86 tych kompilatorów zazwyczaj nie emitują danych inline. Z drugiej strony, Visual Studio to robi, dlatego warto zwracać uwagę na błędy dezasemblacji podczas korzystania z objdump do analizy plików binarnych PE. To samo dotyczy analizy plików binarnych ELF dla architektur innych niż x86, takich jak ARM. A jeśli analizujesz złośliwy kod za pomocą liniowego dezasemblera, cóż, wszystko jest możliwe, ponieważ może on zawierać zaciemnienia znacznie gorsze niż dane inline!

Dezasemblacja statyczna

https://chacker.pl/

Całą analizę binarną można podzielić na analizę statyczną, analizę dynamiczną lub kombinację obu. Mówiąc o „dezasemblacji”, zazwyczaj mamy na myśli dezasemblację statyczną, która polega na wyodrębnianiu instrukcji z pliku binarnego bez jego wykonywania. Natomiast dezasemblacja dynamiczna, powszechnie znana jako śledzenie wykonania, rejestruje każdą wykonaną instrukcję podczas uruchamiania pliku binarnego. Celem każdego dezasemblera statycznego jest przetłumaczenie całego kodu w pliku binarnym na formę czytelną dla człowieka lub przetwarzalną przez maszynę (w celu dalszej analizy). Aby osiągnąć ten cel, dezasemblery statyczne muszą wykonać następujące kroki:

  1. Załadować plik binarny do przetworzenia za pomocą programu ładującego pliki binarne.
  2. Znaleźć wszystkie instrukcje maszynowe w pliku binarnym.
  3. Zdezasemblować te instrukcje do formy czytelnej dla człowieka lub maszyny.

Niestety, krok 2 jest często bardzo trudny w praktyce, co prowadzi do błędów dezasemblacji. Istnieją dwa główne podejścia do demontażu statycznego, z których każde stara się uniknąć błędów demontażu na swój sposób: demontaż liniowy i demontaż rekurencyjny. Niestety, żadne z podejść nie jest idealne w każdym przypadku. Omówmy kompromisy tych dwóch technik demontażu statycznego.

Do demontażu dynamicznego wrócę w dalszej części tego rozdziału. Rysunek  ilustruje podstawowe zasady demontażu liniowego i rekurencyjnego. Wskazuje również niektóre rodzaje błędów demontażu, które mogą wystąpić w każdym z podejść.