Reverse engineering des ausführbaren ELF-Codes – Tutorial

Das folgende Dokument ist ein praktisches Beispiel der Vorgänge, die im Artikel Reverse engineering des ausführbaren ELF-Codes in der Einbruchsanalyse (Hakin9 01/2005) beschrieben wurden.
Analisiertes Objekt: netc. Das genannte Objekt sollte vor der Analyse in das persönliche Verzeichnis kopiert werden.

Vorbereitungen

Selbstverständlich beinhaltet Hakin9 Live alle Werkzeuge, die für die Analyse erforderlich sind. Falls Sie die CD nicht verwenden, alle Tests jedoch durchführen möchten, sollen Sie ausser dem Linux-System über folgende Programme verfügen:

Programme aus dem Paket binutils Skripte und Programme aus dem Paket fenris Andere Werkzeuge:

Objektidentifikation

[01] Mit Hilfe des Werkzeugs file erhalten wir die grundlegenden Informationen über die zu analysierende Datei

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

Wir finden einen Hinweis darauf, dass das analysierte Objekt statisch kompiliert und gestrippt wurde.

[02] Wir suchen nach den uns interessierenden Zeichenketten

# strings -a netc > strings.out

Auf Grund der Analyse des Inhalts der Datei erhalten wir Informationen, die in den nächsten Analyseschritten hilfreich sein können:

Alternativ können wir die uns interessierenden Zeichenketten auch finden, indem wir ausgewählte Sektionen der Datei durchsuchen. Beispielsweise wird die Information über das System und die Compilerversion standardmässig in der Sektion .comment aufbewahrt.

Auf den Inhalt der Sektion können wir mit Hilfe des folgenden Befehls zugreifen

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

oder

# objdump -h Name_des_Programms > sections.out

Das Anzeigen des Inhalts kann mit Hilfe eines Editors oder Browsers unter dem angegebenen Offset durchgeführt werden.

Wiederherstellen des Symbolregisters

[03] Wir suchen im Internet nach den bei der Kompilierung verwendeten Bibliotheken und laden diese herunter.

Nach dem Einsatz des Befehls strings im Schritt [02] wissen wir, dass die Datei unter dem System Mandrake Linux 10.0 und mit Hilfe des Compilers GCC 3.3.2 kompiliert wurde. In unserem Beispiel beschränken wir uns auf die Wiederherstellung der Symbole für die Bibliothek libc, die ein Bestandteil des Systems Mandrake 10.0 ist. Auf Grund der Verwendung der Bibliothek libc können wir ihre Rolle in der analysierten Datei mit grosser Wahrscheinlichkeit voraussagen. Wir könnten im Prozess der Wiederherstellung des Symbolregisters auch die Bibliothek libgcc.a verwenden. Wir beschränken uns erstmal auf die Bibliothek libc, da wir auf die Elemente der Bibliothek libgcc.a in den nächsten Analyseschritten eingehen werden, indem wir ein Beispielprogramm kompilieren und erläutern werden.

Die Bibliothek libc.a speichern wir im Verzeichnis ~/analysis/libc_components/

Was sollen wir tun, wenn wir über keine Informationen verfügen, aus denen auf die Versionen der Bibliotheken, die bei der Kompilierung verwendet wurden, geschlossen werden kann? In diesem Fall können wir verschiedene Versionen der Bibliotheken für verschiedene Systemdistributionen herunterladen und versuchen, das Symbolregister wiederherzustellen. Anschliessend können die Ergebnisse hinsichtlich ihrer Plausibilität und Treffsicherheit miteinander verglichen werden.

[04] Wir entpacken die Objekte der Bibliothek

# ar x libc.a

[05] Wir prüfen, ob das analysierte Programm die Codes einzelner Bibliothekobjekte beinhaltet

# search_static netc ~/analysis/libc_components > obj_file

In diesem Schritt sollte man auf die Kollisionen achten, die im Laufe des Verifizierungsprozesses entstanden sind. Die entdeckten Kollisionen befinden sich am Ende der Datei obj_file. Welche praktische Bedeutung und welchen Einfluss auf die Analyse haben die Kollisionen? Ohne ausführliche Analyse des Codes der Funktionen, auf die die Kollisionen hinweisen, kann man nicht eindeutig feststellen, welche Funktion im analysierten Programm verwendet wurde. Beispiel einer entdeckten Kollision:

# 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] Wir erstellen eine Liste der gefundenen symbolischen Verknüpfungen aus den einzelnen Objekten der Bibliothek

# gensymbols obj_file > symbols_db

Als Ergebnis erhalten wir eine Liste der Symbole. Sie beinhaltet auch Adressen, unter denen wir ihren Code finden können.

[07] Wir deassemblieren das analysierte Programm

# gendump netc > out1

[08] Anschliessend löschen wir den Code gefundener Funktionen der Bibliothek in der Datei out1

# decomp_strip obj_file < out1 > out2

[09] Der Einfachheit halber fügen wir an den Stellen, wo die Funktionen aufgerufen werden, ihre Namen ein

# decomp_insert_symbols symbols_db < out2 > out3

[10] Zur besseren Übersichlichkeit im Code fügen wir an den Stellen, wo auf die Zeichenketten hingewiesen wird, ihren Inhalt ein.

# decomp_xref_data netc < out3 > out4

Um den Inhalt des Symbolregisters wiederherzustellen, können wir auch die Werkzeuge aus dem Paket fenris verwenden.

Nächste Schritte:
[a] Wir öffnen zum Editieren das Skript getfprints
[b] Im Parameter TRYLIBS tragen wir die Lokalisation und Namen der Bibliotheken ein, aus denen die Signaturen erstellt werden sollen.

# getfprints

[c] Wir ersetzen den Namen der Ergebnisdatei durch den Namen der Signaturendatei.

# mv NEW-fnprints.dat fnprints.dat

[d] Mit Hilfe des Programms dress können wir die gelöschten Symbole wiederherstellen

# dress -F ./fnprints.dat Name_des_Programms > Liste_wiederhergestellter_Symbole

oder

# dress -F ./fnprints.dat Name_des_Programms_mit_dem_angehängten_Symbolregister

Das Auffinden der Funktionen, die vom Compiler hinzugefügt wurden

Wenn die Version des Compilers, die zur Kompilierung des analysierten Programms verwendet wurde, bekannt ist (diese Version kennen wir schon), oder wenn wir auf die Version des Compilers schliessen können, können wir versuchen, die Lokalisierung der Funktionen, die vom Compiler hinzugefügt wurden, zu finden. Ein vergleichbarer Effekt kann bei der Wiederherstellung des Symbolregisters mit Hilfe der Bibliothek libgcc.a erreicht werden. Im nächsten Schritt verwenden wir ein Beispielprogramm, das mit Hilfe desselben Compilers wie unser analysiertes Programm kompiliert wurde.

[11] Wir erstellen ein Beispielprogramm sample.c

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

[12] Danach kompilieren wir das Beispielprogramm

# gcc -static -o sample sample.c

[13] Wir vergleichen die Elemente des kompilierten Programms mit dem Code der analysierten Datei - out4

Nach der Analyse können wir folgende Funktionen feststellen:

Die Funktion _start() - Addresse = 08048100


Die Funktion call_gmon_start() - Addresse = 08048124


Die Funktion __do_global_dtors_aux() - Addresse = 08048150


Die Funktion frame_dummy() - Addresse = 080481b0


Die Lokalisation der Funktion _start() kann auch im entrypoint des ELF-Headers abgelesen werden


Feststellen der Lokalisation der Funktion main()

[14] Wir können die Lokalisation der Fuktion main() feststellen, indem wir die Struktur der Funktion _start() aus dem Beispielprogramm sample mit dem Code derselben Funktion vergleichen, die in der analysierten Datei gefunden wurde.

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 &lt;__libc_start_main&gt;
08048121: hlt

Feststellen der Benutzerfunktionen

[15] Wir können die Benutzerfunktionen (genauer gesagt - die Funktionen, die nicht als Objekte einer Bibliothek erkannt worden sind) auf folgende Art und Weise feststellen

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

[16] Da sich im analysierten Code viele aufgerufene Funktionen wiederholen, versuchen wir, die Adressen der Funktionen nur einmal zu erhalten

# 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


Es stellt sich heraus, dass manche Adressen im unseren Code nicht vorhanden sein können out4. Das Fehlen dieser Adressen resultiert aus den Kollisionen der Signaturen der Funktionen, die im Schritt [8] gelöscht wurden.

Die eigentliche Analyse der Funktionsweise des Programms

[17] In den nächsten Schritten, angefangen bei der Funktion main() können wir den Fluss der Kontrolle zwischen den Benutzerfunktionen und deren Aktionen analysieren. Dabei können wir Informationen darüber erhalten, welche Rolle das analysierte Objekt im System gespielt hat und welche Mechanismen es verwendet. Um die Vorgänge, die durch die Funktionen hervorgerufen werden, richtig interpretieren zu können, sollten wir mindestens über Grundkenntnisse der Sprache Assembler verfügen.

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