Analiza statyczna kodu wykonywalnego ELF — tutorial

Niniejszy dokument jest praktycznym przedstawieniem działań opisanych w artykule Inżynieria odwrotna kodu wykonywalnego ELF w analizie powłamaniowej (Hakin9 01/2005).
Analizowany obiekt: netc. Przed przystąpieniem do badania pliku należy go skopiować do katalogu domowego.

Przygotowania

Oczywiście Hakin9 Live posiada wszystkie potrzebne do przeprowadzenia analizy narzędzia. Jeśli jednak z niego nie korzystamy, do przeprowadzenia wszystkich opisanych działań konieczny jest system Linux i następujące programy:

Programy z pakietu binutils Skrypty i programy z pakietu fenris Pozostałe narzędzia:

Wstępne rozpoznanie analizowanego obiektu

[01] Za pomocą narzędzia file uzyskujemy podstawowe informacje o analizowanym pliku

# file netc
netc: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), for GNU/Linux 2.2.5, statically linked, stripped

Jak widać, analizowany plik został skompilowany statycznie i przeszedł proces strippingu.

[02] Wyszukujemy w pliku interesujące łańcuchy znaków

# strings -a netc > strings.out

Analizując otrzymaną zawartość pliku odnajdziemy następujące informacje, które będą pomocne w dalszych działaniach:

Innym sposobem na odszukanie interesujących łańcuchów znaków może być przeszukanie zawartości wybranych sekcji analizowanego pliku. Na przykład informacje o systemie i wersji kompilatora, która została użyta do kompilacji pliku, standardowo przechowywane są w sekcji .comment.

Zawartość wskazanej sekcji pliku możemy odczytać stosując polecenie:

# objdump -j .comment -s nazwa_programu > comment.out

lub

# objdump -h nazwa_programu > sections.out
i podejrzenie dowolnym edytorem lub przeglądarką zawartości pliku pod wskazanym offsetem.


Próby odtworzenia tablicy symboli

[03] Wyszukujemy i pobieramy z Internetu biblioteki, które mogły być użyte w trakcie kompilacji

Z wyniku działania polecenia strings w kroku [02] wiemy, że plik został skompilowany w systemie Mandrake Linux 10.0 przy zastosowaniu kompilatora GCC 3.3.2. W naszym przypadku ograniczymy się do próby odtworzenia symboli związanych z biblioteką libc, pochodzącą z systemu Mandrake 10.0. Ze względu na przeznaczenie biblioteki libc można z dużym prawdopodobieństwem założyć jej wykorzystanie w analizowanym pliku. Inną biblioteką, którą również można byłoby standardowo wykorzystać w procesie odtwarzania tablicy symboli, jest biblioteka libgcc.a. My jednak ograniczymy się wyłącznie do biblioteki libc, ponieważ elementy biblioteki libgcc.a rozpoznamy w dalszej części, poprzez porównanie z przykładowo skompilowanym programem.

Bibliotekę libc.a zapisujemy w katalogu ~/analysis/libc_components/

Co zrobić, jeśli nie posiadamy informacji, które mogłyby nam pomóc w ustaleniu wersji bibliotek zastosowanych w trakcie kompilacji? W tej sytuacji można pobrać kilka różnych wersji bibliotek nawet dla kilku różnych dystrybucji systemu, wykonać poniższe kroki procesu odtwarzania tablicy symboli oraz ocenić otrzymane wyniki pod względem skuteczności i ilości trafień.

[04] Rozpakowujemy obiekty biblioteki

# ar x libc.a

[05] Sprawdzamy, czy w analizowanym programie zawarty jest kod poszczególnych obiektów biblioteki

# search_static netc ~/analysis/libc_components > obj_file

W tym kroku należy również zwrócić uwagę na kolizje, które mogły się pojawić w trakcie dokonanej weryfikacji. Wykryte kolizje znajdują się w końcowej części pliku obj_file. Jakie znaczenie praktyczne i jaki wpływ na analizę mają kolizje? Takie, że bez szczegółowej analizy kodu funkcji, dla których wystąpiły kolizje nie daje się jednoznacznie określić, która z funkcji w rzeczywistości została w programie zastosowana. Przykład wykrytej kolizji:

# Possible conflict below requiring manual resolution:
# ----------------------------------------------------
# /analysis/libc_components/getsrvbynm.o - match at 0x08057580 (0x000000ea bytes)
# /analysis/libc_components/getsrvbypt.o - match at 0x08057580 (0x000000ea bytes)

[06] Generujemy listę odnalezionych odniesień symbolicznych z poszczególnych obiektów biblioteki

# gensymbols obj_file > symbols_db

Wynikiem działania skryptu jest lista symboli wraz z adresami, pod którymi można znaleźć ich kod.

[07] Przeprowadzamy dessassemblację analizowanego programu

# gendump netc > out1

[08] Usuwamy kod odnalezionych funkcji biblioteki z pliku out1

# decomp_strip obj_file < out1 > out2

[09] Dla ułatwienia analizy dodajemy nazwy odnalezionych funkcji w miejsca ich wywołań

# decomp_insert_symbols symbols_db < out2 > out3

[10] Dla zwiększenia czytelności kodu w miejsca odniesień do łańcuchów znaków wstawimy ich treść

# decomp_xref_data netc < out3 > out4

Do odtworzenia zawartości tablicy symboli można również zastosować narzędzia z pakietu fenris.

Kolejne kroki:
[a] Otwieramy do edycji skrypt getfprints
[a] W parametrze TRYLIBS wpisujemy ścieżki oraz nazwy bibliotek, z których chcemy utworzyć bazę sygnatur

# getfprints

[b] Zmieniamy nazwę pliku wynikowego na domyślną nazwę pliku sygnatur

# mv NEW-fnprints.dat fnprints.dat

[c] Wykorzystując program dress odtwarzamy usunięte symbole

# dress -F ./fnprints.dat nazwa_programu > lista_odtworzonych symboli
lub
# dress -F ./fnprints.dat nazwa_programu nazwa_programu_z_dodana_lista_symboli

Ustalenie funkcji dołączonych przez kompilator

Jeżeli znana jest wersja kompilatora zastosowanego do kompilacji analizowanego programu (a my ją znamy) lub możemy się domyślać tej informacji, można podjąć próbę określenia lokalizacji funkcji dodanych przez kompilator (podobny efekt powinniśmy uzyskać wykorzystując bibliotekę libgcc.a w procesie odtwarzania tablicy symboli). Do przeprowadzenia tego działania wykorzystamy porównanie przykładowego programu skompilowanego tym samym kompilatorem co analizowany plik.

[11] Tworzymy przykładowy program sample.c

int main(int argc, char **argv[])
{
return 0;
}

[12] Kompilujemy przykładowy program

# gcc —static —o sample sample.c

[13] Porównujemy elementy skompilowanego programu z kodem analizowanego pliku - out4

W wyniku porównania udało się ustalić następujące funkcje:

Funkcja _start() - Adres = 08048100


Funkcja call_gmon_start() - Adres = 08048124


Funkcja __do_global_dtors_aux() - Adres = 08048150


Funkcja frame_dummy() - Adres = 080481b0


Lokalizację funkcji _start() uzyskamy również odczytując wartość entrypoint nagłówka ELF


Ustalenie lokalizacji funkcji main()

[14] Porównując budowę funkcji _start() z przykładowego programu sample z kodem tej funkcji odnalezionej w analizowanym pliku ustalamy lokalizację funkcji main().

08048100: xor %ebp,%ebp
08048102: pop %esi
08048103: mov %esp,%ecx
08048105: and $0xfffffff0,%esp
08048108: push %eax
08048109: push %esp
0804810a: push %edx
0804810b: push $0x804aa90
08048110: push $0x804aa30
08048115: push %ecx
08048116: push %esi
08048117: push $0x804994f
0804811c: call 0x0804a3b0 <__libc_start_main>
08048121: hlt


Ustalenie funkcji użytkownika

[15] Ustalamy funkcje użytkownika (dla ścisłości - funkcje, które nie zostały rozpoznane jako obiekty biblioteki)

# grep 'call 0x' out4 | grep -v '<' > user_f.out

[16] Ponieważ w analizowanym kodzie wiele wywoływanych funkcji się powtarza, spróbujemy uzyskać wyłącznie unikalne adresy funkcji

# grep 'call 0x' out4 | grep -v '<' | awk '{print $3}' | sort -u

0x0804812d
0x080481bd
0x08048204
0x080482a5
0x08048303
0x0804834b
0x080483b5
0x080483fd
0x080486b8
0x080488ab
0x08048951
0x080489b3
0x08048a3b
0x08048d9d
0x08049235
0x08049311
0x0804950b
0x0804aed0
0x0804bf70
0x0804bf90
0x08057370
0x08057580


Jak się okazuje, część z uzyskanych adresów może nie istnieć w naszym kodzie out4. Ich brak wynika z istnienia kolizji sygnatur funkcji, które w kroku [8] zostały usunięte.


Właściwa analiza działań realizowanych przez program

[17] W kolejnych krokach, zaczynając od funkcji main() powinniśmy dokonać analizy przepływu kontroli pomiędzy funkcjami użytkownika oraz realizowanych przez nich działań. Przechodząc ten krok powinniśmy uzyskać odpowiedź na pytania typu: jaką rolę pełnił analizowany obiekt w systemie oraz jakie mechanizmy wykorzystuje. Do właściwej interpretacji działań realizowanych przez poszczególne funkcje wymagana jest znajomość języka Assembler prznajmniej w stopniu podstawowym.

Funkcja main()
0x0804812d
0x080481bd
0x08048204
0x080482a5
0x08048303
0x0804834b
0x080483b5
0x080483fd
0x080486b8
0x080488ab
0x08048951
0x080489b3
0x08048a3b
0x08048d9d
0x08049235
0x08049311
0x0804950b