Untersuchen von Binärformaten am Beispiel des Bytecodes der .class-Datei.

Bild


Wenn Sie keine Angst vor dem obigen Bild haben, wenn Sie wissen, wie sich Big-Endian von Little-Endian unterscheidet, wenn Sie immer interessiert waren, wie Binärdateien "angeordnet" sind, dann ist dieser Artikel für SIE!


Einleitung


Es gab bereits mehrere Artikel über Habr über das Reverse Engineering von Binärformaten und über das Studium der Struktur des Bytecodes einer .class-Datei:
Pool von Konstanten
Java Bytecode Fundamentals ,
Java-Bytecode "Hallo Welt" ,
Hallo Welt von Bytecode für JVM etc.
Der Forscher hat die Aufgabe, sich entweder mit einem unbekannten Binärprotokoll zu befassen oder eine Binärstruktur zu graben, für die es eine Spezifikation gibt.


Schon als Student interessierte ich mich für Binärformate und schrieb eine Hausarbeit über die Entwicklung des Linux-Dateisystemtreibers. Einige Jahre später hielt ich einen Vortrag über die Grundlagen von Linux für Forensiker - früher war Linux neu und ein junger Spezialist nach dem Studium konnte erwachsenen Experten viel Neues erzählen. Als ich erzählte, wie man mit dd einen Speicherauszug von einer Festplatte entfernt und das Image zu Studienzwecken an einen anderen Computer anschließt, stellte ich fest, dass das Image viele interessante Informationen enthält. Diese Informationen könnten auch ohne Mounten des Images (huh, mount -o loop ...) extrahiert werden, wenn Sie die Spezifikation für das Dateisystemformat kennen und über die entsprechenden Tools verfügen. Leider hatte ich solche Tools nicht.


Nach ein paar Jahren musste ich die Java-Bibliothek dekompilieren. Zu dieser Zeit gab es keine JD-Benutzeroberfläche sowie einen ideologischen Dekompilierer, aber es gab JAD. Für meine Bibliothek hat der JAD eine Mischung aus Java-Opcodes mit Fehlermeldungen erstellt. Außerdem unterstützte JAD keine Annotationen, und in Java 6, das zu diesem Zeitpunkt erschien, wurden sie voll genutzt. Ausgerüstet mit der Java Virtual Machine-Spezifikation begann ich ...


Idee


Ich brauchte einen universellen Mechanismus zur Beschreibung von Binärstrukturen und einen universellen Lader. Der Lader liest anhand der Beschreibung die Binärdaten in den Speicher. Normalerweise müssen Sie sich mit Zahlen, Zeichenfolgen, Datenarrays und zusammengesetzten Strukturen befassen. Alles ist einfach mit Zahlen - sie haben eine feste Länge - 1, 2, 4 oder 8 Bytes und können sofort Datentypen zugeordnet werden, die in der Sprache verfügbar sind. Zum Beispiel: Byte, Short, Int, Long für Java. Für numerische Typen, die länger als ein Byte sind, muss ein Byte-Ordnungsmarker (die sogenannte BigEndian / LittleEndiang-Darstellung) bereitgestellt werden.


Bei Strings ist es schwieriger - sie können in verschiedenen Codierungen (ASCII, UNICODE) vorliegen, eine feste oder variable Länge haben. Eine Zeichenfolge mit fester Länge kann als Array von Bytes betrachtet werden. Für Zeichenfolgen mit variabler Länge können Sie zwei Aufzeichnungsoptionen verwenden: Geben Sie die Länge am Zeilenanfang an (Zeichenfolgen mit Pascal- oder Längenpräfix), oder setzen Sie ein Sonderzeichen am Zeilenende, um das Zeilenende anzugeben. Ein Byte mit dem Wert Null (die sogenannten nullterminierten Ringe) wird als solches Zeichen verwendet. Beide Optionen haben Vor- und Nachteile, deren Erörterung den Rahmen dieses Artikels sprengt. Wenn die Größe zu Beginn angegeben wird, müssen Sie bei der Entwicklung des Formats die maximale Länge der Zeichenfolge festlegen: Wie viele Bytes wir der Längenmarkierung zuordnen müssen, hängt davon ab: 2 8 - 1 für ein Byte, 2 16 - 1 für zwei Bytes usw.


Wir werden zusammengesetzte Datenstrukturen in separate Klassen unterteilen und die Zerlegung in Zahlen und Strings fortsetzen.


Struktur der .class-Datei


Wir müssen irgendwie die Struktur der Java .class-Datei beschreiben. Daher hätte ich gerne eine Reihe von Java-Klassen, in denen jede Klasse nur Felder enthält, die der zu untersuchenden Datenstruktur entsprechen, und möglicherweise Hilfsmethoden zum Anzeigen des Objekts in einer für den Menschen lesbaren Form, wenn die Methode toString () aufgerufen wird. Grundsätzlich möchte ich nicht, dass die Logik enthalten ist, die für das Lesen oder Schreiben einer Datei verantwortlich ist.


Wir nehmen die Spezifikation der Java Virtual Machine,
JVM-Spezifikation, Java SE 12 Edition .
Wir werden uns für Abschnitt 4 "Das Dateiformat der Klasse" interessieren.


Um zu bestimmen, welche Felder in welcher Reihenfolge geladen werden sollen, führen wir die Annotation @FieldOrder (index = ...) ein. Wir müssen die Reihenfolge der Felder für den Bootloader explizit angeben, da die Spezifikation keine Garantie für die Reihenfolge gibt, in der sie in einer Binärdatei gespeichert werden.


Eine Java .class-Datei beginnt mit 4 Bytes Magic Number, zwei Bytes der Nebenversion von Java und zwei Bytes der Hauptversion. Wir packen die magische Zahl in die Variable int und die Neben- und Hauptversionsnummer in Kurzform:


@FieldOrder(index = 1) private int magic; @FieldOrder(index = 2) private short minorVersion; @FieldOrder(index = 3) private short majorVersion; 

Weiter in der .class-Datei ist die Größe des Konstantenpools (Zwei-Byte-Variable) und des Konstantenpools selbst angegeben. Wir führen die Annotation @ContainerSize ein, um die Größe von Arrays und Listenstrukturen zu deklarieren. Die Größe kann festgelegt werden (wir setzen sie über das value-Attribut) oder eine variable Länge haben, die durch die zuvor gelesene Variable bestimmt wird. In diesem Fall verwenden wir das Attribut "fieldName", das angibt, aus welcher Variablen die Containergröße gelesen wird. Entsprechend der Spezifikation (Abschnitt 4.1,
"The ClassFile Structure"), die tatsächliche Größe des Konstantenpools weicht um 1 vom Wert ab
was in constant_pool_count geschrieben wird:


 u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; 

Um solche Korrekturen zu berücksichtigen, führen wir ein zusätzliches Korrekturattribut in die @ContainerSize-Annotationen ein.
Nun können wir eine Beschreibung des konstanten Pools hinzufügen:


  @FieldOrder(index = 4) private short constantPoolCount; @FieldOrder(index = 5) @ContainerSize(fieldName = "constantPoolCount", corrector = -1) private List<ConstantPoolItem> constantPoolList = new ArrayList<>(); 

Bei komplexeren Berechnungen können Sie einfach eine get-Methode hinzufügen, die den gewünschten Wert zurückgibt:
  @FieldOrder(index= 1) private int containerSize; @FieldOrder(index = 2) @ContainerSize(filed="actualContainerSize") private List<ContainerItem> containerItems; public int getActualContainerSize(){ return containerSize * 2 + 3; } 

Ständiger Pool


Jedes Element im Konstantenpool ist entweder eine Beschreibung der entsprechenden Konstanten des Typs int, long, float, double, String oder eine Beschreibung einer der Komponenten der Java-Klasse - Klassenfelder, Methoden, Methodensignaturen usw. Der Begriff "Konstante" bedeutet hier einen unbenannten Wert, der im Code verwendet wird:


 if (intValue > 100500) 

Ein Wert von 100500 wird im Konstantenpool als Instanz von CONSTANT_Integer dargestellt. Die JVM-Spezifikation für Java 12 definiert 17 Typen, die sich in einem konstanten Pool befinden können.


Mögliche Instanzen von const pool-Elementen
Konstanter TypTag
CONSTANT_Class7
CONSTANT_Fieldref9
CONSTANT_Methodref10
CONSTANT_InterfaceMethodref11
CONSTANT_String8
CONSTANT_Integer3
CONSTANT_Float4
CONSTANT_Long5
CONSTANT_Double6
CONSTANT_NameAndType12
CONSTANT_Utf81
CONSTANT_MethodHandle15
CONSTANT_MethodType16
CONSTANT_Dynamic17
CONSTANT_InvokeDynamic18
CONSTANT_Module19
CONSTANT_Package20

In unserer Implementierung erstellen wir eine Klasse ConstantPoolItem, in der es ein Einzelbyte-Feld-Tag gibt, das bestimmt, welche Struktur wir gerade lesen. Erstellen Sie für jedes Element in der obigen Tabelle eine Java-Klasse, die von ConstantPoolItem abstammt. Ein universeller Binärdateilader sollte in der Lage sein, basierend auf einem bereits gelesenen Tag zu bestimmen, welche Klasse verwendet werden soll.
(Im Allgemeinen kann ein Tag eine Variable eines beliebigen Typs sein.) Definieren Sie zu diesem Zweck die HasInheritor-Schnittstelle und implementieren Sie diese Schnittstelle in der ConstantPoolItem-Klasse:


 public interface HasInheritor<T> { public Class<? extends T> getInheritor() throws InheritorNotFoundException; public Collection<Class<? extends T>> getInheritors(); } 

 public class ConstantPoolItem implements HasInheritor<ConstantPoolItem> { private final static Map<Byte, Class<? extends ConstantPoolItem>> m = new HashMap<>(); static { m.put((byte) 7, ClassInfo.class); m.put((byte) 9, FieldRefInfo.class); m.put((byte) 10, MethodRefInfo.class); m.put((byte) 11, InterfaceMethodRefInfo.class); m.put((byte) 8, StringInfo.class); m.put((byte) 3, IntegerInfo.class); m.put((byte) 4, FloatInfo.class); m.put((byte) 5, LongInfo.class); m.put((byte) 6, DoubleInfo.class); m.put((byte) 12, NameAndTypeInfo.class); m.put((byte) 1, Utf8Info.class); m.put((byte) 15, MethodHandleInfo.class); m.put((byte) 16, MethodTypeInfo.class); m.put((byte) 17, DynamicInfo.class); m.put((byte) 18, InvokeDynamicInfo.class); m.put((byte) 19, ModuleInfo.class); m.put((byte) 20, PackageInfo.class); } @FieldOrder(index = 1) private byte tag; @Override public Class<? extends ConstantPoolItem> getInheritor() throws InheritorNotFoundException { Class<? extends ConstantPoolItem> clazz = m.get(tag); if (clazz == null) { throw new InheritorNotFoundException(this.getClass().getName(), String.valueOf(tag)); } return clazz; } @Override public Collection<Class<? extends ConstantPoolItem>> getInheritors() { return m.values(); } } 

Der Universallader instanziiert die erforderliche Klasse und liest weiter. Einzige Bedingung: Indizes in Nachfolgeklassen müssen mit der übergeordneten Klasse durchgehend nummeriert sein. Dies bedeutet, dass in allen von ConstantPoolItem, FieldOrder abgeleiteten Klassen, die Annotation einen Index größer als eins haben muss, da wir in der übergeordneten Klasse bereits das Tag-Feld mit der Nummer "1" gelesen haben.


Struktur der .class-Datei (Fortsetzung)


Nach der Liste der Elemente des Konstantenpools in der .class-Datei gibt es einen Zwei-Byte-Bezeichner, der die Details dieser Klasse definiert - ob es sich bei der Klasse um eine Annotation, eine Schnittstelle, eine abstrakte Klasse handelt, ob sie ein endgültiges Flag hat usw. Darauf folgt ein Zwei-Byte-Bezeichner (ein Verweis auf ein Element im Konstantenpool), der diese Klasse definiert. Dieser Bezeichner muss auf ein Element vom Typ ClassInfo verweisen. Die Oberklasse für eine bestimmte Klasse wird auf ähnliche Weise definiert (was in der Klassendefinition nach dem Wort "extended" angegeben ist). Bei Klassen, für die keine explizit definierten Superklassen vorhanden sind, enthält dieses Feld einen Verweis auf die Object-Klasse.


In Java kann jede Klasse nur eine Oberklasse haben, aber die Nummer
Diese Klasse kann mehrere Schnittstellen implementieren:


  @FieldOrder(index = 9) private short interfacesCount; @FieldOrder(index = 10) @ContainerSize(fieldName = "interfacesCount") private List<Short> interfaceIndexList; 

Jedes Element in interfaceIndexList stellt eine Verknüpfung zu einem Element im Konstantenpool dar (wie angegeben)
Der Index sollte ein Element vom Typ ClassInfo sein.
Klassenvariablen (Eigenschaften, Felder) und Methoden werden durch die entsprechenden Listen dargestellt:


  @FieldOrder(index = 11) private short fieldsCount; @FieldOrder(index = 12) @ContainerSize(fieldName = "fieldsCount") private List<Field> fieldList; @FieldOrder(index = 13) private short methodsCount; @FieldOrder(index = 14) @ContainerSize(fieldName = "methodsCount") private List<Method> methodList; 

Das letzte Element in der Beschreibung der Java .class-Datei ist die Liste der Klassenattribute. Hier können Attribute aufgelistet werden, die die Quelldatei in Bezug auf die Klasse, verschachtelte Klassen usw. beschreiben.


Java-Bytecode arbeitet mit numerischen Daten in einer Big-Endian-Darstellung. Diese Darstellung wird standardmäßig verwendet. Für Binärformate mit Little-Endian-Zahlen verwenden wir die LittleEndian- Annotation. Für Zeichenfolgen, die keine vordefinierte Länge haben, aber
werden vor dem Terminalzeichen gelesen (wie C-ähnliche nullterminierte Zeichenfolgen), das wir verwenden werden
@StringTerminator-Anmerkung:


  @FieldOrder(index = 2) @StringTerminator(0) private String nullTerminatedString; 

Manchmal müssen Sie in den zugrunde liegenden Klassen Informationen von einer höheren Ebene weiterleiten. Das Method-Objekt in methodList enthält keine Informationen zum Namen der Klasse, in der es sich befindet, und das Method-Objekt enthält auch nicht den Namen und die Liste der Parameter. Alle diese Informationen werden als Indizes für die Elemente im Konstantenpool dargestellt. Dies ist für eine virtuelle Maschine ausreichend, aber wir möchten die toString () -Methoden so implementieren, dass sie Informationen über die Methode in einer benutzerfreundlichen Form und nicht in Form von Indizes für Elemente im Konstantenpool anzeigen. Dazu muss die Method-Klasse einen Verweis auf die ConstantPoolList und auf eine Variable mit dem Wert thisClassIndex erhalten. Um Links zu den zugrunde liegenden Verschachtelungsebenen übergeben zu können, verwenden wir die Inject- Annotation:


  @FieldOrder(index = 14) @ContainerSize(fieldName = "methodsCount") @Inject(fieldName = "constantPoolList") @Inject(fieldName = "thisClassIndex") private List<Method> methodList; 

In der aktuellen Klasse (ClassFile) werden Getter-Methoden für die Variablen constantPoolList und thisClassIndex aufgerufen und in der empfangenden Klasse (in diesem Fall Method) Setter-Methoden (sofern vorhanden).


Universeller Bootloader


Wir haben also eine HasInheritor-Schnittstelle und fünf Annotationen @FieldOrder, @ContainerSize, LittleEndian, Inject und @StringTerminator, mit denen wir binäre Strukturen auf einer hohen Abstraktionsebene beschreiben können. Mit einer formalen Beschreibung können wir sie an den Universal Loader übergeben, der die beschriebene Struktur instanziieren, die Binärdatei analysieren und in den Speicher einlesen kann.


Infolgedessen sollten wir diesen Code verwenden können:


  ClassFile classFile; try (InputStream is = new FileInputStream(inputFileName)) { Loader loader = new InputStreamLoader(is); classFile = (ClassFile) loader.load(); } 

Leider sind Java-Plattformentwickler für 8-Byte-Werte im Pool etwas zu hoch entwickelt.
Für zwei Zellen sind Konstanten vorgesehen, die erste Zelle muss einen Wert enthalten, die zweite bleibt bestehen
leer. Dies gilt für lange und doppelte Konstanten.


Beschreibung aus der JVM-Spezifikation

Alle 8-Byte-Konstanten belegen zwei Einträge in der constant_pool-Tabelle der Klasse
Datei. Wenn eine CONSTANT_Long_info- oder CONSTANT_Double_info-Struktur der Eintrag ist
am Index n in der constant_pool-Tabelle ist dann der nächste verwendbare Eintrag in der Tabelle
befindet sich am Index n + 2. Der constant_pool-Index n + 1 muss gültig sein, wird aber berücksichtigt
unbrauchbar.


Anscheinend wollten die Java-Entwickler eine Art Low-Level-Optimierung anwenden, aber später
Es wurde erkannt, dass sich diese Entwurfsentscheidung wandelte
erfolglos

Im Nachhinein war es keine gute Wahl, 8-Byte-Konstanten zwei konstante Pool-Einträge zuzuweisen.


Um diese speziellen Fälle zu behandeln, fügen wir die @EntrySize-Annotation hinzu, die wir verwenden werden.
So kennzeichnen Sie Acht-Byte-Konstanten:


 @EntrySize(value = 2, index = 1) public class EightByteNumberInfo extends ConstantPoolItem { @FieldOrder(index = 2) private int highBytes; @FieldOrder(index = 3) private int lowBytes; } 

Das value-Attribut gibt die Anzahl der Zellen an, die das Element belegt. Index - Der Index des Elements.
welches den Wert enthält. Die Klassen LongInfo und DoubleInfo erweitern die Klasse EightByteNumberInfo.
Der universelle Bootloader muss um eine Funktion erweitert werden, die die Annotation @EntrySize unterstützt.


  public ClassFileLoader(String fileName) { try { File f = new File(fileName); FileInputStream fis = new FileInputStream(f); loader = new EntrySizeSupportLoader(fis); } catch (FileNotFoundException e) { throw new RuntimeException(e); } } 

Nachdem Sie die Klasse mit ClassFileLoader geladen haben, können Sie den Debugger stoppen und die geladene Klasse im Variableninspektor in der IDE untersuchen.


Die Klassendatei sieht folgendermaßen aus:
Bild


Und Constant Pool sieht so aus:
Bild


Fazit


Jeder, der bis zum Ende lesen kann, kann Java-Bytecode mit eigenen Händen heraussuchen. Besuchen Sie den Github und laden Sie die Beschreibung der Java-Klassendatei als Satz von Java-Klassen herunter: https://github.com/esavin/annotate4j-classfile . Der Universal Loader und die Anmerkungen finden Sie hier: https://github.com/esavin/annotate4j-core .


Verwenden Sie zum Herunterladen einer kompilierten Klassendatei das Ladeprogramm annotate4j.classfile.loader.ClassFileLoader.


Der größte Teil des Codes wurde für Java 6 geschrieben, ich habe nur den konstanten Pool an moderne Versionen angepasst. Ich hatte nicht die Kraft und den Wunsch, den Java-Loader für Java-Opcodes vollständig zu implementieren, daher gibt es in diesem Teil nur kleine Entwicklungen.


Unter Verwendung dieser Bibliothek (Hauptteil) gelang es mir, die Binärdatei mit Holter-Überwachungsdaten (EKG-Studie der täglichen Herzaktivität) zurückzusetzen. Andererseits konnte ich das in Delphi geschriebene Binärprotokoll eines Buchhaltungssystems nicht entschlüsseln. Ich habe nicht verstanden, wie die Daten übertragen werden, und manchmal trat eine Situation auf, in der die tatsächlichen Daten nicht der auf den vorherigen Werten aufgebauten Struktur entsprachen.


Ich habe versucht, ein der Java-Klassendatei ähnliches Modell für das ELF-Format (ausführbares Format unter Unix / Linux) zu erstellen, konnte die Spezifikation jedoch nicht vollständig verstehen - sie stellte sich für mich als zu vage heraus. Das gleiche Schicksal ereignete sich für JPEG- und BMP-Formate - die ganze Zeit stieß ich auf einige Schwierigkeiten beim Verständnis der Spezifikation.

Source: https://habr.com/ru/post/de481260/


All Articles