Autoren: Ph.D. Chernov A.V. ( monsieur_cher ) und Ph.D. Troshina K.N.Wie können Sie unter Verwendung der allgemeinsten Annahmen, die auf dem Wissen über moderne Prozessorarchitekturen basieren, die Programmstruktur aus einem Binärbild einer unbekannten Architektur wiederherstellen und dann Algorithmen und vieles mehr wiederherstellen?In diesem Artikel werden wir über eine interessante Aufgabe sprechen, die uns vor einigen Jahren gestellt wurde. Der Kunde bat darum, sich mit der binären Firmware des Geräts zu befassen, das einen bestimmten physischen Prozess verwaltet. Er brauchte einen Steueralgorithmus in Form eines kompilierten C-Programms sowie Formeln mit einer Erklärung, wie sie funktionieren und warum. Nach Angaben des Kunden war dies erforderlich, um die Kompatibilität mit den "alten" Geräten im neuen System sicherzustellen. Die Art und Weise, wie wir letztendlich mit Physik umgegangen sind, wird im Rahmen dieser Artikelserie weggelassen, aber wir werden den Prozess der Wiederherstellung des Algorithmus im Detail betrachten.
Die fast allgegenwärtige Verwendung programmierbarer Mikrocontroller in Massengeräten (IOT oder SmartHome Internet of Things) erfordert die Berücksichtigung der binären Analyse von eingebettetem Code oder mit anderen Worten der binären Analyse von Gerätefirmware.
Eine binäre Analyse der Gerätefirmware kann folgende Ziele haben:
- Analyse des Codes auf Schwachstellen, die den unbefugten Zugriff auf das Gerät oder die von diesem Gerät übertragenen oder verarbeiteten Daten ermöglichen.
- Codeanalyse für undokumentierte Funktionen, die beispielsweise zu Informationslecks führt.
- Codeanalyse zur Wiederherstellung von Protokollen und Schnittstellen für die Interaktion mit Geräten, um die Kompatibilität dieses Geräts mit anderen Geräten sicherzustellen.
Die oben gestellte Aufgabe zur Analyse des Binärcodes kann als Sonderfall der Aufgabe zur Analyse des Binärcodes betrachtet werden, um die Gerätekompatibilität sicherzustellen.
Analyse des binären Dateiformats
Wenn in der Welt der „echten“ Betriebssysteme ausführbare Dateiformate standardisiert sind, kann in der Welt der eingebetteten Programme jeder Anbieter seine proprietäre Lösung verwenden. Daher muss die Analyse der binären Firmware-Datei mit der Analyse des binären Dateiformats beginnen.
Zu Beginn der Arbeit war die Situation für uns wie folgt: Wir haben eine bestimmte Datei mit der Firmware ohne Begleitdokumentation erhalten. Es gab keine Informationen über das Format der Firmware-Datei oder über die Architektur des Mikrocontrollers.
Die Firmware-Datei stellte sich als Textdatei heraus. Es enthielt Zeilen der folgenden Form:
:04013000260F970CF8 :10020000004D000B043F000B34AD010C002FFE4D30 :02023000FD0BC1 :1004000018001A0000001E0008005E000200190052
Nachdem wir uns diese Zeilen genau angesehen hatten, stellten wir fest, dass dies ein vollständig standardmäßiges Intel HEX-Format für Mikrocontroller ist. Die Datei besteht aus Datensätzen, von denen jeder Typ, Speicherort, Daten und Prüfsumme angibt. Die Verwendung des Intel Hex-Formats bedeutet für sich genommen, dass die Datei höchstwahrscheinlich nicht verschlüsselt ist und ein Image eines Programms ist, das sich im Speicher befindet.
Obwohl das Intel Hex-Format eine Adressierung von bis zu 32-Bit-Speicher unterstützt, enthielt unsere Datei nur 16-Bit-Speicheradressen. Daher ist es einfach, eine Binärdatei eines Speicherbilds aus einer Textdatei zu erstellen, in der Datensätze aus der ursprünglichen Testdatei bereits an den angegebenen Adressen abgelegt werden. Es ist bequemer, eine solche Binärdatei mit den Dienstprogrammen zur Analyse von Binärdateien zu überprüfen, und es ist einfacher, eigene Dienstprogramme dafür zu schreiben als für Intel HEX. Die binäre Bildspeicherdatei bestätigte, dass die Datei nicht verschlüsselt war, da verschiedene aussagekräftige Zeilen an verschiedenen Stellen im Code verstreut gefunden wurden.
Dies beantwortete jedoch nicht die Frage, für welche Architektur diese Datei ist.

Wir haben eine Datei mit dem Speicherabbild eines 16-Bit- oder 8-Bit-Mikrocontrollers. Und was für ein Mikrocontroller ist, ist nicht klar. Wir haben IDA Pro genommen und versucht, die Datei mit allen möglichen Varianten der unterstützten Prozessoren zu zerlegen. Und nichts. Keiner der unterstützten IDA Pro-Prozessoren wurde angezeigt: Die Auflistung wurde entweder nicht generiert oder enthielt offensichtlichen Unsinn. Es war vielleicht ein Programm für einen der unterstützten IDA Pro-Prozessoren, aber wir haben etwas falsch gemacht. Zum Beispiel brauchten Sie nur eine zusätzliche Verarbeitung der Bilddatei. In jedem Fall war es hier möglich, die Arbeit auszusetzen und zusätzliche Informationen über die Binärdatei anzufordern.
Alle Prozessoren sind ungefähr gleich.
Aber es wurde für uns interessant und was wir aus dem Binärprogramm verstehen können, auch wenn der Prozessor, für den es kompiliert wurde, unbekannt ist. Die Antwort lautet „nichts“ - uninteressant, auch wenn wir ein wenig verstehen können, es ist besser als nichts. Natürlich können Textzeichenfolgen Informationen über das Programm liefern, aber wir wollen mehr - etwas aus der Struktur des Programms verstehen.
Verschiedene Prozessorarchitekturen - eine große Anzahl. Die Entwicklung des Computing hat selbst die ungewöhnlichsten Architekturen wie ternäre Computer hervorgebracht. Die derzeit existierenden Mikroprozessoren und Mikrocontroller, zumindest die Massenprozessoren, sind einander jedoch bemerkenswert ähnlich.
Nachfolgend listen wir die Grundprinzipien auf, die modernen Mikroprozessoren gemeinsam sind.
Konsistente Ausführung von Anweisungen. Der Prozessor führt Anweisungen nacheinander im Speicher aus. Es gibt spezielle Anweisungen für das bedingte und bedingungslose Springen und Aufrufen und Zurückkehren von der Unterroutine, mit denen Sie die sequentielle Auswahl von Anweisungen aus dem Speicher unterbrechen und mit der Ausführung einer anderen Anweisung fortfahren können. Der Rest der Anweisungen setzt jedoch eine sequentielle Ausführung voraus und enthält daher nicht die Adresse der nächsten Anweisung.
Binäre Codierung. Zusätzlich zu der Tatsache, dass der Prozessor Daten in binärer Form verarbeitet, werden die Prozessorbefehle, aus denen das ausführbare Programm besteht, im Binärformat codiert, dh die Felder, in denen die Befehlsparameter gespeichert sind, beispielsweise Offsets oder Registernummern, belegen eine ganzzahlige Anzahl von Bits. Man kann sich theoretisch vorstellen, dass Daten und Programme trotz der binären Codierung im Prozessor in einem anderen Zahlensystem verarbeitet werden, aber wir sind uns einer solchen Exotik nicht bewusst.
Die folgenden Prinzipien sind im Allgemeinen keine grundlegenden Architekturprinzipien, werden jedoch praktisch universell implementiert, insbesondere für Maschinencode, der nicht manuell in Assemblersprache geschrieben, sondern von einem Hochsprachen-Compiler generiert wird.
Prozedurale Programmierung. Das Programm ist in Struktureinheiten unterteilt, die unterschiedlich aufgerufen werden können: Prozeduren, Funktionen, Unterprogramme usw. Unterprogramme können andere Unterprogramme aufrufen, Parameter an diese übergeben und das Ergebnis der Ausführung zurückerhalten. Es ist wichtig, dass das Unterprogramm einen Einstiegspunkt hat, dh alle Unterprogramme, die den angegebenen aufrufen, gehen an dieselbe Einstiegspunktadresse.
Normalerweise haben Routinen einen Austrittspunkt, der die Kontrolle an den Aufrufpunkt zurückgibt. Dies ist jedoch weniger wichtig, als dass für jede Routine ein Einstiegspunkt erforderlich ist. Ein solcher Code wird normalerweise durch Kompilieren eines Programms erhalten. Der Verbindungszeitoptimierer kann diese Struktur teilweise zerstören, um die Größe des Programms zu verringern, und die Größe des Programms ist für eingebettete Systeme von entscheidender Bedeutung. Darüber hinaus kann diese Struktur durch den Code-Verschleierer zerstört werden.
Das Verschachteln von Unterprogrammaufrufen kann mithilfe des Stapels organisiert werden, der weiterhin zum Übergeben von Argumenten an das Unterprogramm und zum Speichern lokaler Variablen verwendet werden kann. Auf dem aktuellen Stand der Architekturentwicklung sind diese Informationen jedoch verfrüht.
Wie können diese Prinzipien auf die anfängliche Analyse von Binärcode angewendet werden?
Wir gehen grundsätzlich davon aus, dass es im Prozessorbefehlssystem einen RET-Befehl (Rückkehr von einem Unterprogramm) gibt. Diese Anweisung hat eine Art feste binäre Darstellung, nach der wir suchen werden. Wenn RET nicht der einzige ist, wie in x86, wo RET ein Argument hat - die Größe des Unterprogrammparameterbereichs, oder wenn RET ein Nebeneffekt einer komplizierteren Operation ist, wie in ARMv7, wo der PC-Wert gleichzeitig mit den Werten anderer Register vom Stapel abgerufen werden kann (ldmfd sp! , {fp, pc}), dann wird unsere heuristische Suche höchstwahrscheinlich keine Ergebnisse liefern.
Wir müssen auch sofort vernünftige Annahmen über das Prinzip der Codierung von Anweisungen des untersuchten Prozessors treffen. Bestehende Prozessoren verwenden verschiedene Prinzipien zum Codieren von Anweisungen:
- Ein Strom von Bytes, aus denen Befehle generiert werden, und verschiedene Befehle werden mit einer unterschiedlichen Anzahl von Bytes codiert. In dieser Kategorie ist der bekannteste Vertreter die x86-Familie, von den ersten 8080-Prozessoren bis zu den modernsten 64-Bit-Prozessoren. Ein x86_64-Prozessorbefehl kann in einer Folge von 1 bis 16 Bytes codiert werden. Die gleiche Familie von Prozessoren mit variablen Befehlslängen umfasst 8051, das in Mikrocontrollern verwendet wird.
- Ein Stream von 16-Bit-Werten. Darüber hinaus hat jeder Befehl eine feste Größe - 16 Bit.
- Ein Stream mit 16-Bit-Werten, während die Anweisungen unterschiedlich groß sind. Einer der Vertreter dieser Familie ist die PDP-11-Architektur, bei der der Befehl selbst die ersten 16 Bits belegt und auf die entweder direkte Werte oder Speicheradressen für die direkte Adressierung folgen können. Dies beinhaltet die THUMB-Codierung in der ARM-Architektur.
- Jeder Befehl ist ein Strom von 32-Bit-Werten und hat eine feste Größe von 32 Bit. Dies sind die meisten 32- und 64-Bit-RISC-Prozessoren: ARMv7, ARMv8, MIPS.
Wenn Sie zwischen einem Byte-Stream variabler Länge und einem 16-Bit-Wortstrom wählen, können Sie das Speicherbild „mit dem Auge“ anzeigen. Unabhängig davon, wie Prozessorbefehle codiert sind, werden sie in einem Programm mit ausreichender Länge zwangsläufig wiederholt. Zum Beispiel auf x86-Anweisung
add
Das addiert die Werte der eax- und ebx-Register und setzt das Ergebnis in eax. Es wird in zwei Bytes codiert:
01 d8.
Auf ARMv7-Anweisung
add r0, r0, r1
Das Addieren der Werte der Register r0 und r1 und das Setzen des Ergebnisses in r0 wird durch den 32-Bit-Wert e0800001 codiert.
In einem ausreichend großen Programm werden solche Anweisungen mehr als einmal wiederholt. Wenn eine für uns interessante Folge von Bytes (z. B. 01 d8) an einer beliebigen nicht ausgerichteten Adresse auftritt, können wir davon ausgehen, dass die Prozessorbefehle von einem Strom von Bytes variabler Größe codiert werden. Wenn der Wert beispielsweise e0800001 nur bei Adressen gefunden wird, die ein Vielfaches von 4 sind, können wir eine feste Größe von Prozessoranweisungen annehmen. Natürlich gibt es hier einen Fehler, dass wir Datenbytes für eine Anweisung genommen haben, oder es ist zufällig passiert, dass sich herausstellte, dass eine Anweisung immer ausgerichtet war. Die Auswirkungen eines solchen „Rauschens“ auf ein Programm mit ausreichender Größe sind jedoch gering.
Bei Betrachtung der analysierten Firmware aus diesem Blickwinkel wurde deutlich, dass die Anweisungen für den betreffenden Prozessor höchstwahrscheinlich mit 16-Bit-Werten codiert sind.
Ausgehend von der Annahme, dass die Codierung des RET-Befehls ein fester 16-Bit-Wert ist, versuchen wir, ihn zu finden. Wir finden im Programmbild die häufigsten 16-Bit-Werte. In unserem Fall ist Folgendes passiert:
(hex) 0b01 854 5.1
Wir werden nach dem RET-Befehl unter diesen 16-Bit-Werten suchen, die im Code am häufigsten vorkommen. Sofort werden wir nach dem CALL-Befehl suchen - gepaart mit dem RET-Befehl. Der CALL-Befehl hat mindestens einen Parameter - die Sprungadresse, daher sind feste Werte unverzichtbar.
Wir gehen davon aus, dass in vielen Fällen unmittelbar nach dem Ende eines Unterprogramms, dh nach dem RET-Befehl, ein anderes Unterprogramm beginnt und dieses Unterprogramm vom CALL-Befehl von einem anderen Punkt im Programm aus aufgerufen wird. Eine große Anzahl von Sprüngen zur Adresse unmittelbar nach RET ist eines der Kennzeichen des CALL-Befehls. Natürlich ist diese Regel nicht universell, da auf einigen Plattformen, insbesondere ARMv7, unmittelbar nach Abschluss der Unterroutine normalerweise ein konstanter Pool vorhanden ist. In diesem Fall können wir einen vernünftigen Adressbereich unmittelbar nach RET als Übergangspunkte des RET-Befehls betrachten.
Im Fall des CALL-Befehls kann es eine ganze Reihe von Optionen geben, um ihn in das Unterprogramm zu codieren. Erstens kann der Prozessor eine andere Bytereihenfolge im Wort verwenden: Little-Endian, wie bei den meisten modernen Prozessoren, wenn eine Multibyte-Ganzzahl in den Speicher geschrieben wird, beginnend mit dem niedrigen Byte, und Big-Endian, wenn eine Multibyte-Ganzzahl in den Speicher geschrieben wird, beginnend vom hohen Byte. Fast alle modernen Prozessoren arbeiten im Little-Endian-Modus, aber Sie sollten andere mögliche Bytereihenfolgen nicht in einem Wort verwerfen.
Zweitens kann der CALL-Befehl entweder die absolute Adressierung des Sprungpunkts oder die Adressierung relativ zur aktuellen Adresse verwenden. Bei der absoluten Adressierung enthält der codierte Befehl die Adresse, an die Sie in einigen Bits des codierten Befehls gehen möchten. Um sicherzustellen, dass die Unterroutine von einem beliebigen Punkt im 16-Bit-Adressraum zu einem anderen Punkt an der absoluten Adresse des 16-Bit-Wortes aufgerufen wird, reicht der codierte Befehl nicht aus, da zusätzlich zur Übergangsadresse die Bits des Operationscodes an einer anderen Stelle gespeichert werden müssen. Daher ist es sinnvoll, zwei 16-Bit-Wörter hintereinander zu betrachten und verschiedene Optionen zum Aufteilen der Übergangsadresse zwischen diesen Wörtern auszuprobieren.
Eine Alternative zur absoluten Codierung einer Routineadresse ist die relative Codierung. In der codierten Anweisung zeichnen wir die Differenz zwischen der Adresse des Unterprogramms und dem aktuellen Punkt auf. Relative Codierung ist normalerweise der absoluten vorzuziehen, da zum einen ein Programm mit relativen Übergängen positionsunabhängig ist, dh von jeder Adresse aus im Speicher gespeichert werden kann, ohne dass sich der Binärcode ändert. Zweitens können zum Codieren des Offsets weniger Bits reserviert werden als die Dimension des Adressraums, basierend auf der Tatsache, dass die aufgerufene Routine in vielen Fällen nicht so weit von der aufrufenden entfernt ist. Wenn der Offset für den Anruf jedoch außerhalb des Bereichs der darstellbaren Werte liegt, müssen Sie spezielle Anweisungen einfügen - "Sprünge".
Die relative Codierung einer Unterprogrammadresse kann mit einigen Variationen durchgeführt werden: Erstens kann die Adresse des aktuellen Punkts des Programms entweder als Adresse des aktuellen Befehls oder als Adresse des nächsten Befehls wie in x86-Prozessoren oder als Adresse eines anderen Befehls in der Nähe des aktuellen Punkts verwendet werden. In ARM-Prozessoren ist der Referenzpunkt beispielsweise die Adresse des aktuellen Befehls +8 (dh nicht die Adresse des Befehls nach dem CALL, sondern die Adresse des Befehls nach dem nächsten). Da in unserem Fall das Programm als Strom von 16-Bit-Wörtern geschrieben ist, ist es außerdem logisch zu erwarten, dass der Offset in Wörtern ausgedrückt wird. Das heißt, um die Adresse der aufgerufenen Routine zu erhalten, muss der Offset mit 2 multipliziert werden.
Unter Berücksichtigung all dieser Punkte erhalten wir den folgenden Aufzählungsraum für die Suche nach einem CALL / RET-Paar im Binärcode.
Zunächst nehmen wir 16-Bit-Wörter aus der Liste der häufigsten Werte im Code als Kandidaten für den RET-Befehl. Als nächstes durchsuchen wir die CALL-Anweisung:
- Big-Endian- und Little-Endian-Wortbyte-Reihenfolge
- Absolute und relative Codierung der Routineadresse in der Anweisung.
Für die absolute Codierung betrachten wir zwei 16-Bit-Werte in einer Reihe, dh wir sortieren verschiedene Optionen zum Platzieren eines Bitfelds, in dem eine absolute Adresse in einem 32-Bit-Wort gespeichert ist, und für die relative Codierung betrachten wir sowohl 16-Bit-Werte als auch zwei 16-Bit-Wörter in einer Reihe . Als nächstes sortieren wir die verschiedenen Optionen zum Platzieren eines Bitfelds, in dem Offsets gespeichert sind. Wir prüfen, ob der Offset in Bytes oder in 16-Bit-Wörtern ausgedrückt wird, dh ob der Offset mit 2 multipliziert werden muss. Wir prüfen verschiedene Optionen für den Referenzpunkt: die Adresse des aktuellen Befehls, die Adresse des nächsten Befehls.
Für jede der oben beschriebenen Optionen im Suchraum berechnen wir Statistiken:
- Wie viele angenommene Adressen am Anfang der Unterprogramme sind offensichtlich nicht korrekt, dh sie befinden sich dort, wo nichts vorhanden ist oder wo sich die Daten (explizite Zeichenfolgen oder explizite Wertetabellen) befinden. Selbst für den Wert, der der korrekten Codierung des CALL-Befehls entspricht, ist es durchaus möglich, dass eine kleine Anzahl falscher Adressen am Anfang des Unterprogramms möglich ist, wenn der Wert, der dem CALL-Befehl entspricht, versehentlich in den Daten vorkommt.
- Wie viele mutmaßliche Routine-Startadressen befinden sich unmittelbar nach dem mutmaßlichen RET-Befehl.
- Wie viele hypothetische Startadressen von Routinen werden mehr als einmal verwendet.
Wenn unsere Annahmen über ein Paar von CALL / RET-Anweisungen korrekt sind, sollte sie sich im beschriebenen Aufzählungsraum befinden. Es kann aber auch zu Fehlalarmen kommen. Nun, wir starten die Suche.
Und wir finden nur eine mögliche Option!
Trying 8c0d as RET After-ret-addr-set-size: 430 Matching call opcodes for 1, ff00ff00, 1: 000b003d: total: 1275, hits: 843 (66
Das 16-Bit-Wort 8c0d ist also als Kandidat für den RET-Befehl geeignet. Insgesamt enthält die Firmware unmittelbar nach dieser Anweisung 430 Positionen von Programmadressen. Wir haben 32-Bit-Werte (zwei 16-Bit-Wörter hintereinander) mit einem Adressmaskenwert von ff 00 ff 00 betrachtet und eine Anweisung mit dem Code 00 0b 00 3d gefunden. Es gibt 1275 solcher Anweisungen, von denen 843 (d. H. 66%) die Kontrolle auf den Punkt unmittelbar nach dem Kandidaten für RET übertragen. Somit wurden zwei Anweisungen identifiziert:
- RET: 8c0d (16-Bit-Little-Endian)
- CALL HHLL: 0bHH 3dLL (2 16-Bit-Little-Endian)
Der CALL-Befehl verwendet eine absolute Adressierung, und beim Schreiben der Sprungadresse wird zuerst das High-Byte und dann das Low-Byte geschrieben. Es ist möglich, dass dies in der Realität zwei Prozessorbefehle sind, von denen jeder die Hälfte der Übergangsadresse lädt, aber aus Sicht der Programmanalyse ist dies nicht wichtig. Wenn wir die Anweisungen CALL und RET kennen, können wir Bereiche mit Code- und Programmdaten genauer markieren, die für die weitere Analyse wichtig sind.
Fortsetzung folgt…
Weiter werden wir erzählen, wie bedingte und bedingungslose Übergänge und einige arithmetische und logische Operationen wiederhergestellt wurden.