
Presented at Recon Montreal 2018
oder fortgeschrittenes Shotgun-Parsing für Narren und Rebellen
Es war einmal, liebe Leserin, lieber Leser, in einer dunklen und stürmischen Nacht: Dein treuer Autor stieß auf eine verwirrende und geheimnisvolle Situation – ein System, in dem das Einzige, was die Chip-Off-Speicheranalyse und das Reverse Engineering verhinderte, ein unbekanntes, fragmentiertes proprietäres Dateisystem in Verbindung mit Kompression war, die durch den Einsatz von eingebettetem Java die Wirksamkeit vorhandener Werkzeuge zum File Carving verringerte.
Damals wurde vielleicht eine Ad-hoc-Lösung zusammengebastelt, indem man Chunks, die zusammenzupassen schienen, manuell aneinanderfügte – mit ein bisschen grauenhafter Python-Konsolenarbeit und hässlichem Ad-hoc-Bash-Scripting. Es war gut genug, aber zeitintensiv.
Während das Extrahieren von einfachen, unkomprimierten Daten bei der Chip-Off-Analyse im Alltag eines Hardware-Hackers eine ziemlich alltägliche Aufgabe ist, stellt Kompression erhebliche Probleme dar, wenn ihre Bruchstücke auf unvernünftige und unerfreuliche Weise überall verstreut sind, selbst wenn keine weiteren Schutzmaßnahmen gegen die Extraktion vorhanden sind.
Insbesondere bei Zip-Dateien gibt es einige interessante Dinge (Zip ist das grundlegende Format, das für JAR-Dateien verwendet wird). Als ernsthafter Anhänger des International Journal of PoC||GTFO seit geraumer Zeit und insbesondere als Verfolger der Arbeiten zum File-Format-Stunting von Ange Albertini, vermutete ich, dass eine Zip-Datei genügend Daten über sich selbst enthält, um sie wieder einigermaßen zusammensetzen zu können.
Nachdem wir die Referenzen hinter uns haben, kommen wir zu den technischen Details.
Das Wichtigste zuerst: Möglicherweise kennen wir die Besonderheiten des Dateisystems nicht (und aus der Perspektive meiner Forschung, unabhängig von dem System, auf dem ich auf das Problem gestoßen war, entschied ich, dass es schlicht besser ist, sich nicht darum zu kümmern). Aber wir wissen einiges darüber, wie die meisten Dateisysteme implementiert sind. Insbesondere wissen wir, dass sie tendenziell in Chunks geschrieben werden. Die Chunks haben eine Mindestgröße – bekannt als Seiten (Pages) – und wir können diese Seitengröße ermitteln, indem wir den Dump durchsuchen und die kleinste geschriebene Blockgröße identifizieren.
Einige davon liegen möglicherweise zusammenhängend, andere nicht, ohne ein klares Muster dafür, wann Blöcke zusammenhängend sind.
Das alles bedeutet: Unser Problem ist, wie wir die Datenseiten so anordnen, dass sie uns gültige (oder annähernd genaue) Images der Dateien liefern, die wir extrahieren wollen.
Zip-Dateien sind so geschrieben, dass sie eine Art umgekehrte Hierarchie implementieren. Zuerst kommen die komprimierten Dateidaten (verpackt in lokalen Dateiköpfen, die sie beschreiben). Dann folgt ein zentrales Verzeichnis (Central Directory), das die Offsets der lokalen Dateiköpfe auflistet, und dann ein End-of-Central-Directory-Datensatz (der unter anderem die Anzahl der in der Zip gespeicherten Dateien, den Offset, an dem das zentrale Verzeichnis beginnt, und die Größe des zentralen Verzeichnisses beschreibt).
Betrachten wir das rückwärts und gehen etwas mehr ins Detail:
Das End of Central Directory verrät uns:
Jeder Central-Directory-Datensatz verrät uns:
Jeder Local-File-Datensatz verrät uns:
All das führt dazu, dass wir die überwiegende Mehrheit der Datei rekonstruiert haben.
Zunächst benötigen wir einen Schritt, um Daten verschiedener Firmwares voneinander zu unterscheiden. Der Grund dafür ist, dass alle Offsets nur innerhalb ihrer jeweiligen Zip-Dateien relevant sind – jeder Konflikt führt zu nicht zusammenpassenden Zip-Streams und Korruption, und wir möchten unbedingt so viele unverfälschte Daten wie möglich herausholen. Außerdem wollen wir die Gewissheit, dass beispielsweise etwaige Schwachstellen, die wir in der Ziel-Firmware diagnostizieren, diejenige betreffen, die wir normalerweise laufen sehen, und nicht eine andere Datei, die nur herumliegt.
Die benötigte Lösung ist der k-means-Algorithmus (auch bekannt als „Lloyd-Algorithmus“). Hier gibt es ein großartiges Video, das erklärt, wie er funktioniert. SciPy hatte eine gute Version parat, aber ich musste die einzige Clustering-/Analytics-Crate, die den Algorithmus implementiert, identifizieren/patchen, damit es mit der Rust-Implementierung funktioniert. Glücklicherweise musste ich es nicht von Grund auf neu schreiben.
Danach ist die Sache gut.
Wir können dafür eine Reihe von Merkmalen nutzen. Die Felder Flags, Method und Version variieren je nach dem Zip-Stack, der zum Komprimieren der Datei verwendet wurde. Außerdem besitzen die Header Zeitstempel, und es ist allgemein unwahrscheinlich, dass alle Firmwares exakt zur gleichen Zeit kompiliert und komprimiert wurden.
Übrigens ist erwähnenswert, dass Zip-Dateien Zeitstempel im MS-DOS-Format verwenden, die als bitgepackte Shorts Jahr-Monat-Tag und Stunde-Minute-zwei-Sekunden darstellen. Wenn diese nicht in einen absoluten Skalarwert umgewandelt werden, bevor man sie als Klassifikationsdaten verwendet, könnte man einem Jahr Unterschied genauso viel Gewicht beimessen wie einer Sekunde – und das ist überhaupt nicht gut!
Obwohl ich das als ziemlich skizzenhaftes Python-Skript zum Prototyping dieser Methode geschrieben habe und mit dem PoC eine Wiederherstellungsrate des JAR-Inhalts von etwa 70–80 % erreicht habe, habe ich beschlossen, dort aufzuhören und zu einer schnellen Version in Rust überzugehen.
Rust hat eine Crate namens nom, die absolut fantastisch zum Schreiben von Parser-Verifizierern ist. Das war übrigens einer der Hauptgründe, es in Rust neu zu schreiben. Die Fähigkeit, klare und extrem strenge Parser zu schreiben, macht das in mancher Hinsicht viel einfacher, als alles in Python zu handhaben (das tendenziell viel toleranter ist – so sehr, dass es manchmal eine ziemliche Herausforderung ist, sicher zu sein, dass man nicht über fehlerhafte Fehlschläge hinweggeht, um einen Randfall zu erwischen).
Wenn großartige, schnelle, gut lesbare Parser interessant klingen, schau dir Folgendes an:
Abgesehen davon ist die Durchführung solcher Analysen in Python von Natur aus eher langsam und war nie wirklich als mehr gedacht denn als Weg zu einem PoC, um die Machbarkeit dieses Ansatzes zu untersuchen.
Wie auch immer, genug davon ...
Ansätze zur Rekonstruktion der verbleibenden Chunks umfassen: zuerst die verbleibenden Seiten nach Kandidaten mit hoher Entropie zu filtern, zuerst kleinere Lücken zu füllen (wobei so viele Seiten wie möglich aus der Suchliste entfernt werden, da das Testen von Permutationen im schlimmsten Fall eine Aufgabe mit exponentieller Laufzeit ist – schnelles Lösen der einfacheren Fälle hat daher Priorität und vereinfacht unser Problem mit der Zeit exponentiell!).
Wir können auch schnell Erfolge erzielen, indem wir Fälle finden, in denen ein lokaler Dateikopf aufgrund einer Seitengrenze nicht parsbar ist (wir sollten ihn einem identisch ausgerichteten Gegenstück zuordnen können, zumindest in Fällen, in denen ähnliche Ausrichtungen eindeutig sind und nicht mit anderen Korruptionsartefakten kollidieren).
Wie prüfen wir Kandidaten für fehlende Seiten? Nun, wir haben unsere CRC32-Prüfsummen für die Dateien direkt in unserem Central Directory! Anstatt CRC32 über die Datei zu berechnen, ist der wahrscheinlich beste Weg, CRC32 über die Chunks zu berechnen, die wir bereits kennen (vorwärts von den Daten am Ende der Seite vor Beginn unserer Lücke und rückwärts von den Daten (oder dem DataDescriptor-Chunk nach dem Deflate-Stream)) und daraus abzuleiten, welches Zwischen-CRC32 wir für jeden Block fehlender Seiten erwarten sollten.
Im Grunde machen wir die schwierigeren Probleme erheblich einfacher, wann immer wir das einfachste/schnellste zu lösende Problem wählen, indem wir die Spreu entfernen. Genau das ist der Grund, warum wir die Shannon-Entropie nutzen, um leere oder nahezu leere Seiten einfach komplett zu verwerfen – es ist nicht garantiert, dass wir nur Zip-Seiten mit hoher Entropie haben, aber selbst wenn es Ausreißer gibt, ist es eine massive Beschleunigung, diese Komplikation von vornherein nicht behandeln zu müssen.
Wenn du damit herumspielen möchtest, installiere Rust (empfohlen mit dem großartigen rustup nightly). Dann:
$ git clone [repo]
...
$ cd zipdefrag
...
$ cargo build --release
Du kannst das Release-Flag weglassen, um das Debugging zu aktivieren.
Die Build-Artefakte befinden sich in /target/{debug,release}
Erstelle die Dokumentation mit cargo doc (Diese Crate ist stark dokumentiert. Ich schreibe gern.)
Derzeit gibt es für die Rust-Version standardmäßig keine Terminalausgabe. Wenn du die CLI-Testumgebung ausführen möchtest, musst du die Umgebungsvariable RUST_LOG=zipdefrag setzen, die eine ausführliche Terminalprotokollierung aktiviert und die bisherige Analyse veranschaulicht.
Eine schnelle portable native ausführbare Datei (mit Python-Hooks) zum Lösen von Puzzle-Zip-Dumps aus unbekannten Dateisystemen.
Ein Demonstrations-Dump
Die Performance der Rust-Implementierung ist derzeit stark beeinträchtigt, weil bei der Suche nach passenden LFH-Chunks verschwenderisch vorgegangen wird. Ich werde das beheben und meine Lektion daraus lernen.
Diese Technik funktioniert nicht gut, wenn viele Dateien im JAR deutlich größer als die Seitengröße sind. Da sie stark auf die in Zip-Dateien inhärente Struktur angewiesen ist, funktionieren datenlastige Dateien einfach nicht so gut.
Praktischerweise sind Klassendateien für J2ME-Midlets im Allgemeinen ziemlich klein, aber große gepackte Binärdateien sind wahrscheinlich nicht wiederherstellbar.
Außerdem enthält der Python-PoC eine Reihe arithmetischer Fehler.