NativeImage Reverse Engineering

Das Wiederherstellen und Schützen von Java-Code ist ein altes und oft diskutiertes Thema. Aufgrund des zum Speichern von Java-Klassendateien verwendeten Bytecode-Formats, das viele Metainformationen enthält, kann der ursprüngliche Code problemlos wiederhergestellt werden. Um Java-Code zu schützen, hat die Branche viele Methoden übernommen, wie z. B. Verschleierung, Bytecode-Verschlüsselung, JNI-Schutz und so weiter. Unabhängig von der verwendeten Methode gibt es jedoch immer noch Mittel und Wege, es zu knacken.

Die binäre Kompilierung galt schon immer als relativ wirksame Methode zum Codeschutz. Die binäre Kompilierung von Java wird als AOT-Technologie (Ahead of Time) unterstützt, was Vorkompilierung bedeutet.

Aufgrund der dynamischen Natur der Java-Sprache muss die Binärkompilierung jedoch Probleme wie Reflektion, dynamischen Proxy, JNI-Laden usw. bewältigen, was viele Schwierigkeiten mit sich bringt. Daher fehlte es lange Zeit an einem ausgereiften, zuverlässigen und anpassungsfähigen Tool für die AOT-Kompilierung in Java, das in Produktionsumgebungen umfassend eingesetzt werden kann. (Früher gab es ein Tool namens Excelsior JET, aber es scheint inzwischen eingestellt worden zu sein.)

Im Mai 2019 veröffentlichte Oracle GraalVM 19.0, eine mehrsprachige unterstützende virtuelle Maschine, die erste produktionsreife Version. GraalVM bietet ein NativeImage-Tool, das eine AOT-Kompilierung von Java-Programmen erreichen kann. Nach mehreren Jahren der Entwicklung ist NativeImage nun sehr ausgereift und SpringBoot 3.0 kann damit das gesamte SpringBoot-Projekt in eine ausführbare Datei kompilieren. Die kompilierte Datei zeichnet sich durch eine schnelle Startgeschwindigkeit, geringe Speichernutzung und hervorragende Leistung aus.

Also, für Java-Programme, die in die Ära der binären Kompilierung eingetreten sind, ist ihr Code immer noch so leicht umkehrbar wie in der Bytecode-Ära? Was sind die Merkmale der von NativeImage kompilierten Binärdateien und reicht die Intensität der binären Kompilierung aus, um wichtigen Code zu schützen?

Um diese Probleme zu untersuchen, haben wir kürzlich ein NativeImage-Analysetool entwickelt, das einen gewissen umgekehrten Effekt erzielt hat.

Projekt

https://github.com/vlinx-io/NativeImageAnalyzer

Generieren von NativeImage

Zuerst müssen wir ein NativeImage generieren. NativeImage stammt von GraalVM. Um GraalVM herunterzuladen, gehen Sie zuhttps://www.graalvm.org/ und laden Sie die Version für Java 17 herunter. Legen Sie nach dem Herunterladen die Umgebungsvariable fest. Da GraalVM ein JDK enthält, können Sie es direkt zum Ausführen des Java-Befehls verwenden.

Fügen Sie $GRAALVM_HOME/bin zur Umgebungsvariable hinzu und führen Sie dann 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 Ausführen des obigen Java-Programms:

javac Hello.java
java -cp . Hello

Sie erhalten die folgende Ausgabe:

Hello World!

Vorbereitung für die Kompilierungsumgebung

Wenn Sie ein Windows-Benutzer sind, müssen Sie zuerst Visual Studio installieren. Wenn Sie Linux- oder macOS-Benutzer sind, müssen Sie zuvor Tools wie gcc und clang installieren.

Für Windows-Benutzer müssen Sie die Umgebungsvariable für Visual Studio einrichten, bevor Sie den Befehl „native-image“ ausführen. Sie können es mit dem folgenden Befehl einrichten:

 "C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Auxiliary\Build\vcvars64.bat"

Wenn der Installationspfad und die Version von Visual Studio unterschiedlich sind, passen Sie bitte die zugehörigen Pfadinformationen entsprechend an.

Mit native-image kompilieren

Verwenden Sie nun den Befehl „native-image“, um das obige Java-Programm in eine Binärdatei zu kompilieren. Das Format des native-image-Befehls ist das gleiche wie das Java-Befehlsformat und verfügt außerdem über -cp, -jarDiese Parameter, wie der Java-Befehl zum Ausführen des Programms verwendet wird, verwenden dieselbe Methode für die Binärkompilierung, ersetzen Sie einfach die Befehl von Java mit Native-Image. Führen Sie den Befehl wie folgt aus

native-image -cp . Hello

Nach einer gewissen Zeit der Kompilierung verbraucht es möglicherweise mehr CPU und Speicher. Sie können eine kompilierte Binärdatei erhalten, und der Name der Ausgabedatei ist standardmäßig der Kleinbuchstabe des Hauptklassennamens, in diesem Fall „Hallo“. Unter Windows heißt es „hello.exe“. Verwenden Sie den Befehl „Datei“, um den Typ dieser Datei zu überprüfen. Sie können sehen, dass es sich tatsächlich um eine Binärdatei handelt.

file hello
hello: Mach-O 64-bit executable x86_64

Führen Sie diese Datei aus und ihre Ausgabe ist dieselbe wie die, die Sie im vorherigen use.java -cp erhalten haben. Hallo, das Ergebnis ist konsistent

Hello World!

Analyse von NativeImage

Analyse mit IDA

Verwenden Sie IDA, um das kompilierte Hallo aus den obigen Schritten zu öffnen, klicken Sie auf Exporte, um die Symboltabelle anzuzeigen. Sie können das Symbol svm_code_section sehen und seine Adresse ist die Eintragsadresse der Java-Hauptfunktion. image-20230218194013099

Navigieren Sie zu dieser Adresse, um den Assemblercode anzuzeigen

image-20230218194126014

Sie können sehen, dass es eine Standard-Montagefunktion geworden ist, verwenden Sie F5 zum Decompilieren

image-20230218194235234

Einige Funktionsaufrufe sind zu sehen und einige Parameter werden übergeben, aber die Logik ist nicht leicht zu erkennen.

Wenn wir auf sub_1000C0020 doppelklicken, werfen wir einen Blick in den Funktionsaufruf. IDA meldet einen Analysefehler.

image-20230218194449494

Entpackungslogik von NativeImage

Da die Kompilierung von NativeImage auf der JVM-Kompilierung basiert, kann sie auch als Einschluss von Binärcode mit einer VM-Schutzschicht verstanden werden. Deshalb können Tools wie IDA ohne entsprechende Informationen und gezielte Verarbeitungsmaßnahmen kein gutes Reverse Engineering durchführen.

Unabhängig vom Format, sei es Bytecode oder Binärform, müssen jedoch einige grundlegende Elemente der JVM-Ausführung vorhanden sein, z. B. Klasseninformationen, Feldinformationen, Funktionsaufrufe und Parameterübergabe. Basierend auf dieser Denkweise kann das von mir entwickelte Analysetool ein gewisses Maß an Wiederherstellungseffekt erzielen und bei weiterer Verbesserung ein hohes Maß an Wiederherstellungsgenauigkeit erreichen.

Analyse mit NativeImageAnalyzer

Besuchenhttps://github.com/vlinx-io/NativeImageAnalyzerNativeImageAnalyzer herunterladen

Führen Sie den folgenden Befehl für die Rückanalyse aus, derzeit wird nur die Main-Funktion der Hauptklasse analysiert

native-image-analyzer hello

Die Ausgabe lautet wie folgt

java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return

Schauen wir uns noch einmal den Originalcode an.

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;

Sie können sehen, dass die Variable „out“ der Systemklasse eine Variable vom Typ PrintStream und eine statische Variable ist. Während der Kompilierung kompiliert NativeImage eine Instanz dieser Klasse direkt in eine Region namens Heap, und der Binärcode ruft diese Instanz zum Aufruf direkt aus der Heap-Region ab. Werfen wir einen Blick auf den Originalcode nach der Wiederherstellung.

java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return

Diesejava.io.PrintStream@0x554fe8Es wird einfach aus dem Heap-Bereich gelesenjava.io.PrintStream Die Instanzvariable befindet sich an der Speicheradresse 0x554fe8.

Lassen Sie uns das noch einmal anschauenjava.io.PrintStream.writelnDefinition der Funktion

private void writeln(String s) {
		......        
}

Hier können wir sehen, dass es ein String-Argument gibt in derwritelinFunktion, aber im wiederhergestellten Code, warum werden drei Argumente übergeben? Erstenswritelnist eine Klassenmembermethode, die nur eine verstecktthisDie Variable zeigt auf den Aufrufer, der der erste übergebene Parameter ist.java.io.PrintStream@0x554fe8 Der dritte Parameter rcx liegt daran, dass bei der Analyse des Assemblercodes festgestellt wurde, dass diese Funktion mit drei Parametern aufgerufen wurde. Wenn wir uns jedoch die Definition ansehen, wissen wir, dass diese Funktion eigentlich nur zwei Parameter aufruft. Dies ist auch ein Bereich, der für dieses Tool in Zukunft verbessert werden muss.

Ein komplexeres Programm

Wir werden jetzt ein komplexeres Programm analysieren, wie zum Beispiel die Berechnung einer Fibonacci-Folge, mit dem folgenden Code

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

Die entsprechende ist

 int count = Integer.parseInt(args[0]);

rdi ist das Register, das zum Übergeben des ersten Arguments einer Funktion verwendet wird. Wenn es sich um Windows handelt, ist rdi = rdi[0], was args[0] entspricht. Rufen Sie anschließend java.lang.Integer.parseInt auf, um ein zu analysieren und zu erhalten int-Wert, und weisen Sie dann den Rückgabewert einer Stapelvariablen sp_0x44 zu.

int n1 = 0, n2 = 1, n3;
System.out.print(n1 + " " + n2);

Korrespondierend zu.

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 Zeichenkettenverknüpfung tatsächlich in drei Funktionsaufrufe umgewandelt: StringConcatHelper.mix,StringConcatHelper.prependundStringConcatHelper.newString. Darunter,StringConcatHelper.mixberechnet die Länge des konkatenierten StringsStringConcatHelper.prependkombiniert das byte[] Array, das den spezifischen Zeichenketteninhalt enthält, zusammen undStringConcatHelper.newString generiert ein neues String-Objekt aus dem Byte[]-Array.

Im obigen Code sehen wir zwei Arten von Variablennamen.sp_0x18undtlab_0. Variablen beginnend mitsp_geben Sie Variablen an, die auf dem Stapel zugewiesen sind, während Variablen mittlab_ Geben Sie Variablen an, die auf Thread-Lokalzuordnungspuffern zugewiesen sind. Dies ist lediglich eine Erklärung des Ursprungs dieser beiden Arten von Variablennamen. Im wiederhergestellten Code gibt es keinen Unterschied zwischen diesen beiden Variablentypen. Für Informationen zu Thread Local Allocation Buffers suchen Sie bitte selbst danach.

Hier weisen wir zutlab_0 ZuClass{[B}_1. Die Bedeutung vonClass{[B}_1 ist eine Instanz des Typs byte[]. [B stellt den Java-Deskriptor für Byte[] dar, _1 gibt an, dass es sich um die erste Variable dieses Typs handelt. Wenn für den entsprechenden Typ nachfolgende Variablen definiert sind, erhöht sich der Index entsprechend, zClass{[B]}_2,Class{[B]}_3usw. Die gleiche Darstellung gilt für andere Typen, zClass{java.lang.String}_1,Class{java.util.HashMap}_2, und so weiter.

Die Logik des obigen Codes erklärt das einfache Erstellen einer byte[]-Array-Instanz und deren Zuweisung zu tlab0. Die Länge des Arrays beträgtret_2 << ret_2 >> 32. Der Grund für die Länge des Arrays istret_2 << ret_2 >> 32 Dies liegt daran, dass bei der Berechnung der Länge eines Strings die Länge des Arrays basierend auf der Codierung konvertiert werden muss. Sie können auf relevanten Code in java.lang.String.java verweisen. Als nächstes kombiniert die Prepend-Funktion 0, 1 und Leerzeichen in tlab0, generiert dann ein neues String-Objekt ret_30 aus tlab_0 und übergibt es an die Funktion java.io.PrintStream.write zum Drucken der Ausgabe. Tatsächlich sind hier die durch die Präpendierungsfunktion wiederhergestellten Parameter nicht sehr genau und ihre Positionen sind auch falsch. Dies ist ein Bereich, der später noch verbessert werden muss.

Nach der Konvertierung der beiden Zeilen Java-Code in tatsächliche Ausführungslogik ist es immer noch recht komplex. Zukünftig kann dies durch Analyse und Integration auf Basis des aktuell wiederhergestellten Codes vereinfacht werden.

Weiter geradeaus gehen

for (int i = 2; i &lt; count; ++i){
  	n3 = n1 + n2;
  	System.out.print(" " + n3);
  	n1 = n2;
  	n2 = n3;
}
System.out.println();

Die entsprechende ist

if(sp_0x44&gt;=3)
{
	ret_7 = java.lang.StringConcatHelper.mix(1, 1)
	tlab_1 = sp_0x18
	tlab_1.length = ret_7&lt;&lt;ret_7&gt;&gt;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&lt;=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&lt;&lt;ret_11&gt;&gt;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 ist der Parameter, den wir in das Programm eingeben, nämlich count. Die for-Schleife im Java-Code wird nur ausgeführt, wenn count >= 3 ist. Hier wird die for-Schleife wieder in eine while-Schleife umgewandelt, die im Wesentlichen die gleiche Semantik hat. Außerhalb der while-Schleife führt das Programm eine Logik aus, bei der count=3 ist. Wenn count <= 3, schließt das Programm die Ausführung ab und tritt nicht erneut in die while-Schleife ein. Dies kann auch eine Optimierung sein, die GraalVM während der Kompilierung vorgenommen hat.

Schauen wir uns noch einmal die Ausgangsbedingung der Schleife an.

if(sp_0x44<=rcx)
{
		break
}

Dies entspricht

i < count

Gleichzeitig sammelt sich bei jedem Iterationsprozess auch rcx an.

sp_0x34 = rcx
rcx = sp_0x34+1

entspricht

++i

Schauen wir uns als Nächstes an, wie sich die Logik des Hinzufügens von Zahlen im Schleifenkörper im wiederhergestellten Code widerspiegelt. Der Originalcode lautet wie folgt:

for(......){
	......
  n3 = n1 + n2;
	n1 = n2;
	n2 = n3;
  ......
}

Der Code nach der Wiederherstellung ist

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 andere Code im Schleifenkörper führt wie zuvor Zeichenfolgenverkettungen und Ausgabevorgänge durch. Der wiederhergestellte Code spiegelt im Wesentlichen die Ausführungslogik des Originalcodes wider.

Weitere Verbesserungen sind erforderlich

Derzeit ist dieses Tool in der Lage, den Programmkontrollfluss teilweise wiederherzustellen, ein gewisses Maß an Datenflussanalyse zu erreichen und Funktionsnamen wiederherzustellen. Um ein vollständiges und nutzbares Tool zu werden, muss es noch Folgendes leisten:

Genauere Funktionsnamen, Funktionsparameter und Funktionsrückgabewertwiederherstellung

Genaue Objektinformationen und Feldwiederherstellung

Genauere Ausdrucksweise und Typinferenz von Objekten

Aussageintegration und -vereinfachung

Gedanken zum binären Schutz

Der Zweck dieses Projekts besteht darin, die Machbarkeit des Reverse Engineering von NativeImage zu untersuchen. Basierend auf den aktuellen Erkenntnissen ist es möglich, NativeImage zurückzuentwickeln, was auch größere Herausforderungen an den Codeschutz mit sich bringt. Viele Entwickler glauben, dass das Kompilieren von Software in Binärdateien die Sicherheit gewährleisten kann, und vernachlässigen dabei den Schutz des Binärcodes. Für in C/C++ geschriebene Software verfügen viele Tools wie IDA bereits über hervorragende Reverse-Engineering-Effekte und legen manchmal sogar mehr Informationen offen als Java-Programme. Ich habe sogar einige Software gesehen, die in binärer Form verteilt wurde, ohne die Symbolinformationen von Funktionsnamen zu entfernen, was dem nackten Ausführen entspricht.

Jeder Code besteht aus Logik. Solange es Logik enthält, ist es möglich, seine Logik durch umgekehrte Maßnahmen wiederherzustellen. Der einzige Unterschied liegt in der Schwierigkeit der Wiederherstellung. Der Codeschutz soll die Schwierigkeit einer solchen Wiederherstellung maximieren.