W przypadku języków maszyn wirtualnych opartych na kodzie bajtowym, takich jak Java, VB.NET, C #, ActionScript 3.0 itp., Czasami słyszysz o tym, jak łatwo jest pobrać dekompilator z Internetu, uruchomić kod bajtowy za jednym razem, i często, wymyślić coś nie za daleko od oryginalnego kodu źródłowego w ciągu kilku sekund. Podobno ten rodzaj języka jest na to szczególnie podatny.
Niedawno zacząłem się zastanawiać, dlaczego nie słyszysz więcej na ten temat o natywnym kodzie binarnym, kiedy przynajmniej wiesz, w jakim języku został napisany (a więc w jakim języku próbować się dekompilować). Przez długi czas myślałem, że to dlatego, że natywny język maszynowy jest bardziej szalony i bardziej złożony niż typowy kod bajtowy.
Ale jak wygląda kod bajtowy? To wygląda tak:
1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2
A jak wygląda natywny kod maszynowy (szesnastkowo)? Oczywiście wygląda to tak:
1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2
Instrukcje pochodzą z nieco podobnego sposobu myślenia:
1000: mov EAX, 20
1001: mov EBX, loc1
1002: mul EAX, EBX
1003: push ECX
Biorąc pod uwagę język, w którym próbujemy dekompilować jakiś natywny plik binarny, powiedzmy C ++, co jest w tym takiego trudnego? Jedyne dwa pomysły, które od razu przychodzą mi na myśl, to 1) tak naprawdę jest to o wiele bardziej skomplikowane niż kod bajtowy, lub 2) coś w tym, że systemy operacyjne mają tendencję do dzielenia programów na części i rozpraszania ich elementów, powoduje zbyt wiele problemów. Jeśli jedna z tych możliwości jest prawidłowa, proszę wyjaśnić. Ale tak czy inaczej, dlaczego tak naprawdę nigdy o tym nie słyszysz?
UWAGA
Zaraz przyjmuję jedną z odpowiedzi, ale najpierw chciałbym coś wspomnieć. Prawie wszyscy odwołują się do faktu, że różne fragmenty oryginalnego kodu źródłowego mogą być mapowane na ten sam kod maszynowy; nazwy zmiennych lokalnych zostały utracone, nie wiesz, jakiego rodzaju pętli pierwotnie użyto itp.
Jednak przykłady takie jak dwa, które właśnie zostały wspomniane, są dla mnie trochę banalne. Niektóre odpowiedzi twierdzą jednak, że różnica między kodem maszynowym a oryginalnym źródłem jest znacznie większa niż coś tak trywialnego.
Ale na przykład, jeśli chodzi o takie rzeczy, jak lokalne nazwy zmiennych i typy pętli, kod bajtowy również traci tę informację (przynajmniej w przypadku ActionScript 3.0). Wcześniej przeciągałem to z powrotem przez dekompilator i tak naprawdę nie obchodziło mnie, czy zmienna została wywołana strMyLocalString:String
czy loc1
. Nadal mogłem zajrzeć do tego małego, lokalnego zasięgu i zobaczyć, jak jest używany bez większych problemów. A for
pętla jest dokładnie tą samą dokładną rzeczą cowhile
pętla, jeśli się nad tym zastanowić. Również nawet gdybym uruchomił źródło za pomocą funkcji irFuscator (która, w przeciwieństwie do secureSWF, nie robi nic więcej niż tylko losowe nazwy zmiennych i funkcji), nadal wyglądało to tak, jakbyś mógł po prostu zacząć izolować niektóre zmienne i funkcje w mniejszych klasach, rysunek dowiedz się, jak są używane, przypisz im własne imiona i pracuj stamtąd.
Aby to była wielka sprawa, kod maszynowy musiałby stracić o wiele więcej informacji, a niektóre odpowiedzi na to idą.
źródło
Odpowiedzi:
Na każdym etapie kompilacji tracisz informacje, których nie można odzyskać. Im więcej informacji stracisz z oryginalnego źródła, tym trudniej będzie je dekompilować.
Możesz utworzyć przydatny de-kompilator dla kodu bajtowego, ponieważ z oryginalnego źródła zachowanych jest o wiele więcej informacji niż jest zachowywanych podczas tworzenia końcowego docelowego kodu maszynowego.
Pierwszym krokiem kompilatora jest zamiana źródła w pośrednią reprezentację często przedstawianą jako drzewo. Tradycyjnie to drzewo nie zawiera informacji nie semantycznych, takich jak komentarze, białe znaki itp. Po ich wyrzuceniu nie można odzyskać oryginalnego źródła z tego drzewa.
Następnym krokiem jest przekształcenie drzewa w jakąś formę języka pośredniego, który ułatwia optymalizacje. Jest tu wiele możliwości do wyboru i każda infrastruktura kompilatora ma swoje. Zazwyczaj jednak informacje takie jak lokalne nazwy zmiennych, duże struktury przepływu sterowania (takie jak to, czy użyto pętli for czy while) są tracone. Zwykle dzieje się tu kilka ważnych optymalizacji, stała propagacja, niezmienny ruch kodu, wstawianie funkcji itp. Każda z nich przekształca reprezentację w reprezentację, która ma równoważną funkcjonalność, ale wygląda zasadniczo inaczej.
Kolejnym krokiem jest wygenerowanie rzeczywistych instrukcji maszynowych, które mogą obejmować tak zwaną optymalizację „peep-hole”, która tworzy zoptymalizowaną wersję typowych wzorców instrukcji.
Z każdym krokiem tracisz coraz więcej informacji, aż w końcu tracisz tyle, że odzyskanie czegokolwiek przypominającego oryginalny kod staje się niemożliwe.
Z drugiej strony, bajt-kod zazwyczaj zapisuje ciekawe i transformacyjne optymalizacje do fazy JIT (kompilator just-in-time), kiedy produkowany jest docelowy kod maszynowy. Kod bajtowy zawiera wiele metadanych, takich jak lokalne typy zmiennych, struktura klas, aby umożliwić kompilację tego samego kodu bajtowego do wielu docelowych kodów maszynowych. Wszystkie te informacje nie są konieczne w programie C ++ i są odrzucane w procesie kompilacji.
Istnieją dekompilatory różnych kodów maszyn docelowych, ale często nie przynoszą one użytecznych wyników (coś, co można zmodyfikować, a następnie ponownie skompilować), ponieważ utracono zbyt wiele oryginalnego źródła. Jeśli masz informacje debugowania dla pliku wykonywalnego, możesz wykonać jeszcze lepszą pracę; ale jeśli masz informacje debugowania, prawdopodobnie masz również oryginalne źródło.
źródło
Utrata informacji, jak wskazano w innych odpowiedziach, to jeden punkt, ale nie jest to przełom. Po tym wszystkim, nie należy się spodziewać, oryginalny program z powrotem, po prostu chcesz żadnej reprezentacji w języku wysokiego poziomu. Jeśli kod jest wstawiony, możesz po prostu pozwolić mu na to lub automatycznie rozliczyć typowe obliczenia. Zasadniczo można cofnąć wiele optymalizacji. Ale są pewne operacje, które są w zasadzie nieodwracalne (przynajmniej bez nieskończonej ilości obliczeń).
Na przykład gałęzie mogą stać się obliczonymi skokami. Kod taki jak ten:
może zostać skompilowany do (przepraszam, że to nie jest prawdziwy asembler):
Teraz, jeśli wiesz, że x może wynosić 1 lub 2, możesz spojrzeć na skoki i łatwo to odwrócić. Ale co z adresem 0x1012? Czy też powinieneś stworzyć
case 3
dla niego? Będziesz musiał prześledzić cały program w najgorszym przypadku, aby dowiedzieć się, jakie wartości są dozwolone. Co gorsza, być może będziesz musiał wziąć pod uwagę wszystkie możliwe dane wejściowe użytkownika! U podstaw problemu leży to, że nie można rozróżnić danych i instrukcji.Biorąc to pod uwagę, nie byłbym całkowicie pesymistą. Jak można zauważyć w powyższym „asemblerze”, jeśli x pochodzi z zewnątrz i nie ma gwarancji, że wynosi 1 lub 2, to zasadniczo masz zły błąd, który pozwala skakać do dowolnego miejsca. Ale jeśli twój program jest wolny od tego rodzaju błędów, łatwiej jest o tym myśleć. (Nie jest przypadkiem, że „bezpieczne” języki pośrednie, takie jak CLR IL lub kod bajtowy Java, są znacznie łatwiejsze do dekompilacji, nawet odkładając na bok metadane.) W praktyce więc powinna istnieć możliwość dekompilacji pewnych, dobrze zachowanychprogramy. Mam na myśli indywidualne, funkcjonalne procedury, które nie mają żadnych skutków ubocznych i dobrze określonych danych wejściowych. Myślę, że istnieje kilka dekompilatorów, które mogą dać pseudokod dla prostych funkcji, ale nie mam dużego doświadczenia z takimi narzędziami.
źródło
Powodem, dla którego kodu maszynowego nie można łatwo przekonwertować z powrotem na oryginalny kod źródłowy, jest utrata dużej ilości informacji podczas kompilacji. Metody i klasy nieeksportowane można wstawiać, lokalne nazwy zmiennych są tracone, nazwy plików i struktury są całkowicie tracone, kompilatory mogą dokonywać nieoczywistych optymalizacji. Innym powodem jest to, że wiele różnych plików źródłowych może wytworzyć dokładnie ten sam zestaw.
Na przykład:
Można skompilować do:
Mój zestaw jest dość zardzewiały, ale jeśli kompilator może zweryfikować, czy optymalizację można wykonać dokładnie, zrobi to. Wynika to skompilowany binarny nie potrzebuje znać nazwy
DoSomething
iAdd
, jak również fakt, żeAdd
metoda ma dwie nazwanych parametrów, kompilator wie również, żeDoSomething
metoda zasadniczo zwraca stałą, a może to inline zarówno wywołanie metody i sama metoda.Celem kompilatora jest utworzenie zestawu, a nie sposób na pakowanie plików źródłowych.
źródło
ret
prostu powiedzieć, że przyjmujesz konwencję wywoływania C.Ogólne zasady tutaj to mapowania typu „jeden do jednego” i brak kanonicznych przedstawicieli.
Dla prostego przykładu zjawiska wiele do jednego możesz pomyśleć o tym, co dzieje się, gdy weźmiesz funkcję z lokalnymi zmiennymi i skompilujesz ją do kodu maszynowego. Wszystkie informacje o zmiennych zostają utracone, ponieważ stają się tylko adresami pamięci. Coś podobnego dzieje się w przypadku pętli. Możesz wziąć pętlę
for
lubwhile
, a jeśli są one odpowiednio skonstruowane, możesz otrzymać identyczny kod maszynowy zjump
instrukcjami.Powoduje to również brak kanonicznych przedstawicieli oryginalnego kodu źródłowego instrukcji kodu maszynowego. Kiedy próbujesz dekompilować pętle, w jaki sposób mapujesz
jump
instrukcje z powrotem na konstrukcje zapętlające? Czy robisz z nichfor
pętle lubwhile
pętle.Problem ten dodatkowo pogarsza fakt, że współczesne kompilatory wykonują różne formy składania i wstawiania. Tak więc, zanim dotrzesz do kodu maszynowego, prawie niemożliwe jest ustalenie, z jakiej konstrukcji wysokiego poziomu pochodzi kod maszynowy niskiego poziomu.
źródło