NativeImage Reverse Engineering
Die Wiederherstellung und der Schutz von Java-Code sind ein altbekanntes und oft diskutiertes Thema. Da Java-Klassendateien im Bytecode-Format gespeichert werden und dieses viele Metainformationen enthält, lässt sich der ursprüngliche Code leicht wiederherstellen. Um Java-Code zu schützen, hat die Branche zahlreiche Methoden eingeführt, darunter Verschleierung, Bytecode-Verschlüsselung und JNI-Schutz. Ungeachtet der verwendeten Methode gibt es jedoch weiterhin Möglichkeiten, den Code zu knacken.
Die Binärkompilierung galt schon immer als relativ effektive Methode zum Schutz des Quellcodes. Java unterstützt die Binärkompilierung als AOT-Technologie (Ahead-of-Time), was Vorkompilierung bedeutet.
Aufgrund der dynamischen Natur der Java-Sprache muss die Binärkompilierung jedoch Aspekte wie Reflection, dynamische Proxys, JNI-Laden usw. berücksichtigen, was zahlreiche Schwierigkeiten mit sich bringt. Daher fehlte es lange Zeit an einem ausgereiften, zuverlässigen und anpassungsfähigen Werkzeug für die AOT-Kompilierung in Java, das sich für den breiten Einsatz in Produktionsumgebungen eignet. (Es gab zwar ein Werkzeug namens Excelsior JET, dessen Entwicklung aber anscheinend eingestellt wurde.)
Im Mai 2019 veröffentlichte Oracle GraalVM 19.0, eine virtuelle Maschine mit Unterstützung für mehrere Sprachen – die erste produktionsreife Version. GraalVM bietet das Tool NativeImage, das die AOT-Kompilierung von Java-Programmen ermöglicht. Nach jahrelanger Entwicklung ist NativeImage mittlerweile ausgereift und kann von Spring Boot 3.0 genutzt werden, um das gesamte Spring-Boot-Projekt in eine ausführbare Datei zu kompilieren. Die kompilierte Datei zeichnet sich durch kurze Startzeiten, geringen Speicherverbrauch und hervorragende Performance aus.
Ist der Code von Java-Programmen, die mittlerweile binär kompiliert werden, noch genauso leicht umkehrbar wie in der Bytecode-Ära? Welche Eigenschaften haben die von NativeImage kompilierten Binärdateien, und ist die Intensität der Binärkompilierung ausreichend, um wichtigen Code zu schützen?
Um diese Problematik zu untersuchen, haben wir kürzlich ein NativeImage-Analysetool entwickelt, das einen gewissen umgekehrten Effekt erzielt hat.
Projekt
https://github.com/vlinx-io/NativeImageAnalyzer
NativeImage generieren
Zuerst müssen wir ein NativeImage generieren. NativeImage wird von GraalVM bereitgestellt. Um GraalVM herunterzuladen, gehen Sie zu https://www.graalvm.org/ Laden Sie die Version für Java 17 herunter. Setzen Sie anschließend die Umgebungsvariable. Da GraalVM ein JDK enthält, können Sie es direkt verwenden, um den Java-Befehl auszuführen.
Fügen Sie $GRAALVM_HOME/bin zur Umgebungsvariablen hinzu und führen Sie anschließend den folgenden Befehl aus, um das Native-Image-Tool zu installieren.
gu install native-image
Ein einfaches Java-Programm
Schreiben Sie ein einfaches Java-Programm, zum Beispiel:
public class Hello {
public static void main(String[] args){
System.out.println("Hello World!");
}
}
Kompilieren und führen Sie das obige Java-Programm aus:
javac Hello.java
java -cp . Hello
Sie erhalten folgende Ausgabe:
Hello World!
Vorbereitung der Kompilierungsumgebung
Windows-Nutzer müssen zuerst Visual Studio installieren. Linux- oder macOS-Nutzer müssen zuvor Tools wie gcc und clang installieren.
Windows-Benutzer müssen die Umgebungsvariable für Visual Studio einrichten, bevor sie den Befehl `native-image` ausführen. Dies kann mit folgendem Befehl erfolgen:
"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Auxiliary\Build\vcvars64.bat"
Falls der Installationspfad und die Version von Visual Studio unterschiedlich sind, passen Sie bitte die entsprechenden Pfadinformationen entsprechend an.
Mit nativem Image kompilieren
Verwenden Sie nun den Befehl `native-image`, um das obige Java-Programm in eine Binärdatei zu kompilieren. Das Format des Befehls `native-image` entspricht dem des Befehls `java` und enthält ebenfalls die Parameter `-cp` und `-jar`. Um das Programm mit dem Befehl `java` auszuführen, verwenden Sie dieselbe Methode zur Binärkompilierung. Ersetzen Sie einfach den Befehl `java` durch `native-image`. Führen Sie den Befehl wie folgt aus:
native-image -cp . Hello
Nach einer gewissen Kompilierungszeit kann der CPU- und Speicherverbrauch steigen. Sie erhalten eine kompilierte Binärdatei. Der Ausgabedateiname entspricht standardmäßig dem Kleinbuchstaben des Hauptklassennamens, in diesem Fall „hello“. Unter Windows lautet die Datei „hello.exe“. Mit dem Befehl „file“ können Sie den Dateityp überprüfen; es handelt sich tatsächlich um eine Binärdatei.
file hello
hello: Mach-O 64-bit executable x86_64
Führen Sie diese Datei aus, und ihre Ausgabe ist dieselbe wie die der vorherigen Verwendung. java -cp . Hello. Das Ergebnis ist konsistent.
Hello World!
Analyse von NativeImage
Analyse mit IDA
Öffnen Sie mit IDA das kompilierte Hello-Programm aus den obigen Schritten. Klicken Sie auf „Exportieren“, um die Symboltabelle anzuzeigen. Dort sehen Sie das Symbol svm_code_section, dessen Adresse die Einstiegsadresse der Java Main-Funktion ist.

Unter dieser Adresse finden Sie den Assembly-Code.

Sie können sehen, dass es sich um eine Standard-Assemblerfunktion handelt; verwenden Sie F5 zum Dekompilieren.

Einige Funktionsaufrufe sind sichtbar, und einige Parameter werden übergeben, aber die Logik ist nicht leicht zu erkennen.
Wenn wir auf sub_1000C0020 doppelklicken, schauen wir uns den Funktionsaufruf genauer an. IDA meldet einen Analysefehler.

Dekompilationslogik von NativeImage
Da die Kompilierung von NativeImage auf der JVM-Kompilierung basiert, kann man sie auch als Einkapselung des Binärcodes mit einer VM-Schutzschicht verstehen. Daher können Tools wie IDA ihn ohne entsprechende Informationen und gezielte Verarbeitungsmethoden nicht effektiv analysieren.
Ungeachtet des Formats, ob Bytecode oder Binärdatei, sind einige grundlegende Elemente der JVM-Ausführung jedoch immer vorhanden, wie z. B. Klasseninformationen, Feldinformationen, Funktionsaufrufe und Parameterübergaben. Ausgehend von dieser Erkenntnis erzielt das von mir entwickelte Analysetool eine gewisse Wiederherstellungswirkung und kann durch weitere Verbesserungen eine hohe Wiederherstellungsgenauigkeit erreichen.
Die Verwendung von NativeImageAnalyzer ist nicht möglich
Besuchen https://github.com/vlinx-io/NativeImageAnalyzer NativeImageAnalyzer herunterladen
Führen Sie den folgenden Befehl zur Rückwärtsanalyse aus. Aktuell wird nur die Main-Funktion der Hauptklasse analysiert.
native-image-analyzer hello
Die Ausgabe sieht wie folgt aus:
java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return
Werfen wir noch einmal einen Blick auf den Originalcode.
public static void main(String[] args){
System.out.println("Hello World!");
}
Schauen wir uns nun die Definition von System.out an.
public static final PrintStream out = null;
Man sieht, dass die Variable „out“ der Systemklasse vom Typ PrintStream und somit statisch ist. NativeImage kompiliert während der Kompilierung direkt eine Instanz dieser Klasse in einen Bereich namens Heap, und der Binärcode ruft diese Instanz anschließend direkt aus dem Heap-Bereich ab. Betrachten wir nun den ursprünglichen Code nach der Wiederherstellung.
java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return
Diese java.io.PrintStream@0x554fe8 Es wurde gerade aus dem Heap-Gebiet abgelesen. java.io.PrintStream Die Instanzvariable befindet sich an der Speicheradresse 0x554fe8.
我们再来看下java.io.PrintStream.writeln 函数的定义
private void writeln(String s) {
......
}
Hier können wir sehen, dass es ein String-Argument gibt. writelin Die Funktion funktioniert, aber warum werden im wiederhergestellten Code drei Argumente übergeben? writeln ist eine Klassenmethode, die nur eine ausblendet thisDie Variable verweist auf den Aufrufer, der als erster Parameter übergeben wird. java.io.PrintStream@0x554fe8 Der dritte Parameter `rcx` ergibt sich daraus, dass bei der Analyse des Assembler-Codes festgestellt wurde, dass diese Funktion mit drei Parametern aufgerufen wird. Bei genauerer Betrachtung der Definition stellt sich jedoch heraus, dass die Funktion tatsächlich nur zwei Parameter benötigt. Auch hier besteht zukünftig Verbesserungsbedarf für dieses Tool.
Ein komplexeres Programm
Wir werden nun ein komplexeres Programm analysieren, beispielsweise die Berechnung einer Fibonacci-Folge, anhand des folgenden Codes.
class Fibonacci {
public static void main(String[] args) {
int count = Integer.parseInt(args[0]);
int n1 = 0, n2 = 1, n3;
System.out.print(n1 + " " + n2);
for (int i = 2; i < count; ++i){
n3 = n1 + n2;
System.out.print(" " + n3);
n1 = n2;
n2 = n3;
}
System.out.println();
}
}
Kompilieren und ausführen
javac Fibonacci.java
native-image -cp . Fibonacci
./fibonacci 10
0 1 1 2 3 5 8 13 21 34
Der nach der Wiederherstellung mit NativeImageAnalyzer erhaltene Code lautet wie folgt:
rdi = rdi[0]
ret_0 = java.lang.Integer.parseInt(rdi, 10)
sp_0x44 = ret_0
ret_1 = java.lang.StringConcatHelper.mix(1, 1)
ret_2 = java.lang.StringConcatHelper.mix(ret_1, 0)
sp_0x20 = java.io.PrintStream@0x554fe8
sp_0x18 = Class{[B}_1
tlab_0 = Class{[B}_1
tlab_0.length = ret_2<<ret_2>>32
sp_0x10 = tlab_0
ret_28 = ?java.lang.StringConcatHelper.prepend(tlab_0, " ", ret_2)
ret_29 = java.lang.StringConcatHelper.prepend(ret_28, sp_0x10, 0)
ret_30 = ?java.lang.StringConcatHelper.newString(sp_0x10, ret_29)
java.io.PrintStream.write(sp_0x20, ret_30)
if(sp_0x44>=3)
{
ret_7 = java.lang.StringConcatHelper.mix(1, 1)
tlab_1 = sp_0x18
tlab_1.length = ret_7<<ret_7>>32
sp_0x10 = " "
sp_0x8 = tlab_1
ret_22 = ?java.lang.StringConcatHelper.prepend(tlab_1, " ", ret_7)
ret_23 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_22)
rsi = ret_23
java.io.PrintStream.write(sp_0x20, ret_23)
rdi = 1
rdx = 1
rcx = 3
while(true)
{
if(sp_0x44<=rcx)
{
break
}
else
{
sp_0x34 = rcx
rdi = rdi+rdx
r9 = rdi
sp_0x30 = rdx
sp_0x2c = r9
ret_11 = java.lang.StringConcatHelper.mix(1, r9)
tlab_2 = sp_0x18
tlab_2.length = ret_11<<ret_11>>32
sp_0x8 = tlab_2
ret_17 = ?java.lang.StringConcatHelper.prepend(tlab_2, sp_0x10, ret_11)
ret_18 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_17)
rsi = ret_18
java.io.PrintStream.write(sp_0x20, ret_18)
rcx = sp_0x34+1
rdi = sp_0x30
rdx = sp_0x2c
}
}
}
java.io.PrintStream.newLine(sp_0x20, rsi)
return
Vergleichen Sie den wiederhergestellten Code mit dem Originalcode.
rdi = rdi[0]
ret_0 = java.lang.Integer.parseInt(rdi, 10)
sp_0x44 = ret_0
Das entsprechende ist
int count = Integer.parseInt(args[0]);
rdi ist das Register, das verwendet wird, um das erste Argument einer Funktion zu übergeben. Unter Windows entspricht dies rdi = rdi[0], was args[0] entspricht. Anschließend wird java.lang.Integer.parseInt aufgerufen, um den Wert zu parsen und einen int-Wert zu erhalten. Der Rückgabewert wird dann der Stack-Variablen sp_0x44 zugewiesen.
int n1 = 0, n2 = 1, n3;
System.out.print(n1 + " " + n2);
Entsprechend.
ret_1 = java.lang.StringConcatHelper.mix(1, 1)
ret_2 = java.lang.StringConcatHelper.mix(ret_1, 0)
sp_0x20 = java.io.PrintStream@0x554fe8
sp_0x18 = Class{[B}_1
tlab_0 = Class{[B}_1
tlab_0.length = ret_2<<ret_2>>32
sp_0x10 = tlab_0
ret_28 = ?java.lang.StringConcatHelper.prepend(tlab_0, " ", ret_2)
ret_29 = java.lang.StringConcatHelper.prepend(ret_28, sp_0x10, 0)
ret_30 = ?java.lang.StringConcatHelper.newString(sp_0x10, ret_29)
java.io.PrintStream.write(sp_0x20, ret_30)
In unserem Java-Code wird die einfache Stringverkettungsoperation tatsächlich in drei Funktionsaufrufe umgewandelt: StringConcatHelper.mix, StringConcatHelper.prepend, Und StringConcatHelper.newStringDarunter: StringConcatHelper.mix berechnet die Länge der verketteten Zeichenkette, StringConcatHelper.prepend kombiniert das Byte-Array, das den spezifischen String-Inhalt enthält, und StringConcatHelper.newString Erzeugt ein neues String-Objekt aus dem Byte-Array.
Im obigen Code sehen wir zwei Arten von Variablennamen. sp_0x18 Und tlab_0Variablen, die mit beginnen sp_ Variablen, die auf dem Stack zugewiesen sind, während Variablen, die mit beginnen, mit tlab_ Diese Bezeichnungen kennzeichnen Variablen, die in Thread-lokalen Allokationspuffern (TLAPs) gespeichert sind. Dies ist lediglich eine Erklärung für den Ursprung dieser beiden Variablennamen. Im wiederhergestellten Code wird nicht zwischen diesen beiden Variablentypen unterschieden. Weitere Informationen zu TLAPs finden Sie in der entsprechenden Dokumentation.
Hier weisen wir zu tlab_0 Zu Class{[B}_1Die Bedeutung von Class{[B}_1 ist eine Instanz des Typs byte[]. [B repräsentiert den Java-Deskriptor für byte[], _1 gibt an, dass es sich um die erste Variable dieses Typs handelt. Falls weitere Variablen desselben Typs definiert sind, erhöht sich der Index entsprechend. Class{[B]}_2, Class{[B]}_3usw. Die gleiche Darstellung gilt auch für andere Typen, wie zum Beispiel Class{java.lang.String}_1, Class{java.util.HashMap}_2, und so weiter.
Die Logik des obigen Codes erklärt einfach, wie man eine Byte[]-Array-Instanz erstellt und sie tlab0 zuweist. Die Länge des Arrays ist ret_2 << ret_2 >> 32Der Grund dafür, warum die Länge des Arrays ret_2 << ret_2 >> 32 Das liegt daran, dass bei der Berechnung der Stringlänge die Länge des Arrays anhand der Kodierung angepasst werden muss. Den entsprechenden Code finden Sie in `java.lang.String.java`. Die Funktion `prepend` kombiniert anschließend 0, 1 und Leerzeichen zu `tlab0`, erzeugt daraus ein neues String-Objekt `ret_30` und übergibt dieses an die Funktion `java.io.PrintStream.write` zur Ausgabe. Die von `prepend` wiederhergestellten Parameter sind hierbei jedoch nicht sehr genau und ihre Positionen stimmen nicht. Dies ist ein Bereich, der später noch verbessert werden muss.
Auch nach der Umwandlung der zwei Java-Codezeilen in tatsächliche Ausführungslogik ist diese noch recht komplex. Zukünftig kann sie durch Analyse und Integration des aktuell wiederhergestellten Codes vereinfacht werden.
Gehen Sie weiter geradeaus
for (int i = 2; i < count; ++i){
n3 = n1 + n2;
System.out.print(" " + n3);
n1 = n2;
n2 = n3;
}
System.out.println();
Das entsprechende ist
if(sp_0x44>=3)
{
ret_7 = java.lang.StringConcatHelper.mix(1, 1)
tlab_1 = sp_0x18
tlab_1.length = ret_7<<ret_7>>32
sp_0x10 = " "
sp_0x8 = tlab_1
ret_22 = ?java.lang.StringConcatHelper.prepend(tlab_1, " ", ret_7)
ret_23 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_22)
rsi = ret_23
java.io.PrintStream.write(sp_0x20, ret_23)
rdi = 1
rdx = 1
rcx = 3
while(true)
{
if(sp_0x44<=rcx)
{
break
}
else
{
sp_0x34 = rcx
rdi = rdi+rdx
r9 = rdi
sp_0x30 = rdx
sp_0x2c = r9
ret_11 = java.lang.StringConcatHelper.mix(1, r9)
tlab_2 = sp_0x18
tlab_2.length = ret_11<<ret_11>>32
sp_0x8 = tlab_2
ret_17 = ?java.lang.StringConcatHelper.prepend(tlab_2, sp_0x10, ret_11)
ret_18 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_17)
rsi = ret_18
java.io.PrintStream.write(sp_0x20, ret_18)
rcx = sp_0x34+1
rdi = sp_0x30
rdx = sp_0x2c
}
}
}
java.io.PrintStream.newLine(sp_0x20, rsi)
return
sp_0x44 Der Parameter, den wir dem Programm übergeben, ist `count`. Die `for`-Schleife im Java-Code wird nur ausgeführt, wenn `count` >= 3 ist. Hier wird die `for`-Schleife in eine `while`-Schleife umgewandelt, die im Wesentlichen dieselbe Semantik aufweist. Außerhalb der `while`-Schleife führt das Programm die Logik aus, für die `count` = 3 ist. Ist `count` <= 3, beendet das Programm die Ausführung und tritt nicht erneut in die `while`-Schleife ein. Dies könnte auch eine Optimierung sein, die GraalVM während der Kompilierung vornimmt.
Schauen wir uns die Abbruchbedingung der Schleife noch einmal an.
if(sp_0x44<=rcx)
{
break
}
Dies entspricht
i < count
Gleichzeitig akkumuliert sich rcx auch während jedes Iterationsprozesses.
sp_0x34 = rcx
rcx = sp_0x34+1
entspricht
++i
Schauen wir uns nun an, wie die Logik der Zahlenaddition im Schleifenkörper im wiederhergestellten Code widergespiegelt wird. Der ursprüngliche Code lautet wie folgt:
for(......){
......
n3 = n1 + n2;
n1 = n2;
n2 = n3;
......
}
Der Code nach der Wiederherstellung lautet:
while(true){
......
rdi = rdi+rdx -> n3 = n1 + n2
r9 = rdi -> r9 = n3
sp_0x30 = rdx -> sp_0x30 = n2
sp_0x2c = r9 -> sp_0x2c = n3
rdi = sp_0x30 -> n1 = sp_0x30 = n2
rdx = sp_0x2c -> n2 = sp_0x2c = n3
......
}
Der übrige Code im Schleifenkörper führt wie zuvor Stringverkettungs- und Ausgabeoperationen durch. Der wiederhergestellte Code spiegelt im Wesentlichen die Ausführungslogik des ursprünglichen Codes wider.
Weitere Verbesserungen sind erforderlich
Aktuell kann dieses Tool den Programmablauf teilweise wiederherstellen, eine gewisse Datenflussanalyse durchführen und Funktionsnamen wiederherstellen. Um ein vollständiges und nutzbares Tool zu werden, muss es jedoch noch Folgendes leisten:
Genauere Wiederherstellung von Funktionsnamen, Funktionsparametern und Funktionsrückgabewerten
Genaue Objektinformationen und Feldrestaurierung
Präzisere Ausdrucks- und Objekttyperkennung
Integration und Vereinfachung von Aussagen
Gedanken zum binären Schutz
Ziel dieses Projekts ist die Untersuchung der Machbarkeit von Reverse Engineering für NativeImage. Basierend auf den bisherigen Ergebnissen ist Reverse Engineering von NativeImage möglich, stellt aber gleichzeitig höhere Anforderungen an den Schutz des Codes. Viele Entwickler glauben, dass die Kompilierung von Software in Binärdateien Sicherheit garantiert und vernachlässigen dabei den Schutz des Binärcodes. Für in C/C++ geschriebene Software erzielen viele Tools wie IDA bereits hervorragende Ergebnisse beim Reverse Engineering und liefern mitunter sogar mehr Informationen als Java-Programme. Ich habe sogar Software gesehen, die in Binärform ohne Entfernung der Symbolinformationen aus Funktionsnamen verbreitet wurde, was einem Betrieb ohne diese Informationen gleichkommt.
Jeder Code besteht aus Logik. Solange er Logik enthält, kann diese durch Rückwärtsverarbeitung wiederhergestellt werden. Der einzige Unterschied liegt im Schwierigkeitsgrad der Wiederherstellung. Ziel des Codeschutzes ist es, diese Wiederherstellung maximal zu erschweren.