Dlaczego natywnego kodu maszynowego nie można łatwo zdekompilować?


16

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:Stringczy loc1. Nadal mogłem zajrzeć do tego małego, lokalnego zasięgu i zobaczyć, jak jest używany bez większych problemów. A forpętla jest dokładnie tą samą dokładną rzeczą cowhilepę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ą.


35
Trudno jest zrobić krowę z hamburgerów.
Kaz Dragon

4
Głównym problemem jest to, że natywny plik binarny zachowuje bardzo mało metadanych dotyczących programu. Nie przechowuje żadnych informacji o klasach (co sprawia, że ​​C ++ jest szczególnie trudny do dekompilacji) i nie zawsze nawet nic o funkcjach - nie jest to konieczne, ponieważ CPU z natury wykonuje kod w dość liniowy sposób, jedna instrukcja na raz. Ponadto niemożliwe jest rozróżnienie między kodem a danymi ( link ). Aby uzyskać więcej informacji, może warto rozważyć poszukiwanie lub ponownie z prośbą o RE.SE .
ntoskrnl

Odpowiedzi:


39

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.


5
Kluczem jest fakt, że informacje są przechowywane, aby JIT mógł lepiej działać.
btilly,

Czy w takim razie biblioteki DLL C ++ można łatwo dekompilować?
Panzercrisis

1
Nie w nic, co uważam za przydatne.
chuckj

1
Metadane nie służą „do kompilacji tego samego kodu bajtowego do wielu obiektów docelowych”, lecz służą do refleksji. Reprezentowalna pośrednia reprezentacja nie musi mieć żadnych z tych metadanych.
SK-logic

2
To nie jest prawda. Wiele danych służy do refleksji, ale refleksja nie jest jedynym zastosowaniem. Na przykład definicje interfejsu i klas są używane do tworzenia definicji przesunięcia pola, konstruowania tabel wirtualnych itp. Na maszynie docelowej, umożliwiając ich konstruowanie w najbardziej efektywny sposób dla maszyny docelowej. Tabele te są konstruowane przez kompilator i / lub konsolidator podczas tworzenia kodu natywnego. Po wykonaniu tej czynności dane użyte do ich skonstruowania są odrzucane.
chuckj

11

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:

select (x) {
case 1:
    // foo
    break;
case 2:
    // bar
    break;
}

może zostać skompilowany do (przepraszam, że to nie jest prawdziwy asembler):

0x1000:   jump to 0x1000 + 4*x
0x1004:   // foo
0x1008:   // bar
0x1012:   // qux

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 3dla 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.


9

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:

int DoSomething()
{
    return Add(5, 2);
}

int Add(int x, int y)
{
    return x + y;
}

int main()
{
    return DoSomething();
}

Można skompilować do:

main:
mov eax, 7;
ret;

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 DoSomethingi Add, jak również fakt, że Addmetoda ma dwie nazwanych parametrów, kompilator wie również, że DoSomethingmetoda 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.


Zastanów się nad zmianą ostatniej instrukcji, aby po retprostu powiedzieć, że przyjmujesz konwencję wywoływania C.
chuckj

3

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ę forlub while, a jeśli są one odpowiednio skonstruowane, możesz otrzymać identyczny kod maszynowy z jumpinstrukcjami.

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 jumpinstrukcje z powrotem na konstrukcje zapętlające? Czy robisz z nich forpętle lub whilepę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.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.