Ingegneria inversa di NativeImage

Il ripristino e la protezione del codice Java è una questione vecchia e spesso discussa. A causa del formato bytecode utilizzato per archiviare i file di classe Java, che contiene molte metainformazioni, può essere facilmente ripristinato al codice originale. Per proteggere il codice Java, l'industria ha adottato molti metodi, come l'offuscamento, la crittografia del bytecode, la protezione JNI e così via. Tuttavia, indipendentemente dal metodo utilizzato, ci sono ancora modi e mezzi per risolverlo.

La compilazione binaria è sempre stata considerata un metodo relativamente efficace di protezione del codice. La compilazione binaria di Java è supportata come tecnologia AOT (Ahead of Time), che significa pre-compilazione.

Tuttavia, a causa della natura dinamica del linguaggio Java, la compilazione binaria deve gestire questioni come la riflessione, il proxy dinamico, il caricamento JNI, ecc., il che pone molte difficoltà. Pertanto, da molto tempo, manca uno strumento maturo, affidabile e adattabile per la compilazione AOT in Java che possa essere ampiamente applicato in ambienti di produzione. (C'era uno strumento chiamato Excelsior JET, ma sembra che ora sia stato interrotto.)

Nel maggio 2019, Oracle ha rilasciato GraalVM 19.0, una macchina virtuale con supporto multilingue, che era la sua prima versione pronta per la produzione. GraalVM fornisce uno strumento NativeImage in grado di ottenere la compilazione AOT di programmi Java. Dopo diversi anni di sviluppo, NativeImage è ora molto maturo e SpringBoot 3.0 può utilizzarlo per compilare l'intero progetto SpringBoot in un file eseguibile. Il file compilato ha una velocità di avvio elevata, un basso utilizzo della memoria e prestazioni eccellenti.

Quindi, per i programmi Java che sono entrati nell'era della compilazione binaria, il loro codice è ancora facilmente reversibile come lo era nell'era del bytecode? Quali sono le caratteristiche dei file binari compilati da NativeImage e l'intensità della compilazione binaria è sufficiente per proteggere il codice importante?

Per esplorare questi problemi, abbiamo recentemente sviluppato uno strumento di analisi NativeImage, che ha ottenuto un certo grado di effetto inverso.

Progetto

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

Generazione di NativeImage

Innanzitutto, dobbiamo generare un NativeImage. NativeImage proviene da GraalVM. Per scaricare GraalVM, vai ahttps://www.graalvm.org/ e scarica la versione per Java 17. Dopo il download, imposta la variabile di ambiente. Poiché GraalVM contiene un JDK, puoi usarlo direttamente per eseguire il comando Java.

Aggiungi $GRAALVM_HOME/bin alla variabile d'ambiente e poi esegui il seguente comando per installare lo strumento native-image

gu install native-image

Un semplice programma Java

Scrivi un semplice programma Java, ad esempio:

public class Hello {
	public static void main(String[] args){
		System.out.println("Hello World!");
	}
}

Compilare ed eseguire il programma Java sopra:

javac Hello.java
java -cp . Hello

Otterrai il seguente output:

Hello World!

Preparazione per l'ambiente di compilazione

Se sei un utente Windows, devi prima installare Visual Studio. Se sei un utente Linux o macOS, devi prima installare strumenti come gcc e clang.

Per gli utenti Windows, è necessario impostare la variabile di ambiente per Visual Studio prima di eseguire il comando native-image. Puoi configurarlo utilizzando il seguente comando:

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

Se il percorso di installazione e la versione di Visual Studio sono diversi, modificare di conseguenza le informazioni sul percorso correlato.

Compilare con native-image

Ora usa il comando native-image per compilare il programma Java sopra in un file binario. Il formato del comando native-image è lo stesso del formato del comando Java e ha anche -cp, -jarQuesti parametri, come utilizzare il comando Java per eseguire il programma, utilizzare lo stesso metodo per la compilazione binaria, basta sostituire il comando da Java con immagine nativa. Esegui il comando come segue

native-image -cp . Hello

Dopo un periodo di compilazione, potrebbe consumare più CPU e memoria. È possibile ottenere un file binario compilato e il nome del file di output è predefinito in minuscolo rispetto al nome della classe principale, che in questo caso è "ciao". Se è sotto Windows, sarà "hello.exe". Usa il comando "file" per verificare il tipo di questo file, puoi vedere che è effettivamente un file binario.

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

Esegui questo file e il suo output sarà lo stesso ottenuto nel precedente use.java -cp . CiaoIl risultato è coerente

Hello World!

Analisi di NativeImage

Analisi con IDA

Usa IDA per aprire l'hello compilato dai passaggi precedenti, fai clic su Esporta per visualizzare la tabella dei simboli, puoi vedere il simbolo svm_code_section e il suo indirizzo è l'indirizzo di ingresso della funzione Java Main. image-20230218194013099

Vai a questo indirizzo per visualizzare il codice di assemblaggio

image-20230218194126014

Puoi vedere che è diventata una funzione di assemblaggio standard, usa F5 per decompilare

image-20230218194235234

È possibile vedere alcune chiamate di funzione e passare alcuni parametri, ma non è facile vederne la logica.

Quando facciamo doppio clic su sub_1000C0020, diamo un'occhiata all'interno della chiamata alla funzione. IDA provoca un errore di analisi.

image-20230218194449494

Logica di decompilazione di NativeImage

Poiché la compilazione di NativeImage è basata sulla compilazione JVM, può anche essere intesa come racchiudere il codice binario con uno strato di protezione della VM. Pertanto, strumenti come IDA non sono in grado di decodificarlo adeguatamente in assenza di informazioni corrispondenti e misure di elaborazione mirate.

Tuttavia, indipendentemente dal formato, sia esso bytecode o forma binaria, sono destinati ad esistere alcuni elementi di base dell'esecuzione JVM, come informazioni sulla classe, informazioni sul campo, invocazione di funzioni e passaggio di parametri. Sulla base di questa mentalità, lo strumento di analisi che ho sviluppato può raggiungere un certo livello di effetto del restauro e, con ulteriori miglioramenti, avere la capacità di raggiungere un elevato livello di precisione del restauro.

Analisi con NativeImageAnalyzer

Visitahttps://github.com/vlinx-io/NativeImageAnalyzerper scaricare NativeImageAnalyzer

Esegui il seguente comando per l'analisi inversa, attualmente analizzando solo la funzione Main della classe principale

native-image-analyzer hello

L'output è il seguente

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

Diamo nuovamente un'occhiata al codice originale.

public static void main(String[] args){
		System.out.println("Hello World!");
}

Ora diamo un'occhiata alla definizione di System.out.

public static final PrintStream out = null;

Puoi vedere che la variabile 'out' della classe System è una variabile di tipo PrintStream ed è una variabile statica. Durante la compilazione, NativeImage compila direttamente un'istanza di questa classe in una regione chiamata Heap e il codice binario recupera direttamente questa istanza dalla regione Heap per l'invocazione. Diamo un'occhiata al codice originale dopo il restauro.

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

Questijava.io.PrintStream@0x554fe8È appena letto dall'area Heapjava.io.PrintStream La variabile di istanza si trova all'indirizzo di memoria 0x554fe8.

Diamo un'occhiata di nuovojava.io.PrintStream.writelnDefinizione della funzione

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

Qui possiamo vedere che c'è un argomento String nellawritelinfunzione, ma nel codice ripristinato, perché vengono passati tre argomenti? Primowritelnè un metodo membro di classe che nasconde solo unothisLa variabile punta al chiamante, che è il primo parametro passatojava.io.PrintStream@0x554fe8 Per quanto riguarda il terzo parametro rcx, è perché durante il processo di analisi del codice assembly, è stato stabilito che questa funzione veniva chiamata con tre parametri. Tuttavia, esaminando la definizione, sappiamo che questa funzione in realtà chiama solo due parametri. Anche questa è un’area che necessita di miglioramenti per questo strumento in futuro.

Un programma più complesso

Ora analizzeremo un programma più complesso, come il calcolo di una sequenza di Fibonacci, con il seguente codice

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();
    }
}

Compilare ed eseguire

javac Fibonacci.java
native-image -cp . Fibonacci
./fibonacci 10
0 1 1 2 3 5 8 13 21 34

Il codice ottenuto dopo il ripristino utilizzando NativeImageAnalyzer è il seguente

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

Confronta il codice ripristinato con il codice originale.

rdi = rdi[0]
ret_0 = java.lang.Integer.parseInt(rdi, 10)
sp_0x44 = ret_0

Il corrispondente è

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

rdi è il registro utilizzato per passare il primo argomento di una funzione, se è Windows, allora rdi = rdi[0], che corrisponde a args[0], successivamente chiama java.lang.Integer.parseInt per analizzare e ottenere un int, quindi assegnare il valore restituito a una variabile stack sp_0x44.

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

Corrisponde a.

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)

Nel nostro codice Java, l'operazione di concatenazione semplice della stringa viene effettivamente convertita in tre chiamate di funzione:StringConcatHelper.mix,StringConcatHelper.prependeStringConcatHelper.newString. Tra loro,StringConcatHelper.mixcalcola la lunghezza della stringa concatenataStringConcatHelper.prependcombina l'array byte[] che trasporta il contenuto di stringa specifico insieme eStringConcatHelper.newString genera un nuovo oggetto String dall'array byte[] .

Nel codice sopra, vediamo due tipi di nomi di variabili.sp_0x18etlab_0. Variabili che iniziano consp_indicare le variabili allocate nello stack, mentre le variabili che iniziano contlab_ indicano le variabili allocate sui buffer di allocazione locale del thread. Questa è solo una spiegazione dell'origine di questi due tipi di nomi di variabili. Nel codice ripristinato non c'è distinzione tra questi due tipi di variabili. Per informazioni relative ai buffer di allocazione locale dei thread, cercarle personalmente.

Qui assegniamotlab_0 AClass{[B}_1. Il significato diClass{[B}_1 è un'istanza del tipo byte[]. [B rappresenta il descrittore Java per byte[], _1 indica che è la prima variabile di questo tipo. Se sono presenti variabili successive definite per il tipo corrispondente, l'indice aumenterà di conseguenza, ad esempioClass{[B]}_2,Class{[B]}_3, ecc. La stessa rappresentazione si applica ad altri tipi, comeClass{java.lang.String}_1,Class{java.util.HashMap}_2, e così via.

La logica del codice precedente spiega semplicemente la creazione di un'istanza di array byte[] e l'assegnazione a tlab0. La lunghezza dell'array èret_2 << ret_2 >> 32. Il motivo per cui la lunghezza dell'array èret_2 << ret_2 >> 32 è perché quando si calcola la lunghezza di una stringa, è necessario convertire la lunghezza dell'array in base alla codifica. È possibile fare riferimento al codice pertinente in java.lang.String.java. Successivamente, la funzione prepend combina 0, 1 e spazi in tlab0, quindi genera un nuovo oggetto String ret_30 da tlab_0 e lo passa alla funzione java.io.PrintStream.write per la stampa dell'output. In realtà qui i parametri ripristinati dalla funzione prepend non sono molto accurati e anche le loro posizioni sono errate. Questa è un’area che necessita di ulteriori miglioramenti in seguito.

Dopo aver convertito le due righe di codice Java nella logica di esecuzione effettiva, è ancora piuttosto complesso. In futuro potrà essere semplificato analizzando ed integrando sulla base del codice attualmente ripristinato.

Continua a camminare avanti

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

Il corrispondente è

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 è il parametro che inseriamo nel programma, ovvero count. Il ciclo for nel codice Java verrà eseguito solo se count >= 3. Qui, il ciclo for viene trasformato nuovamente in un ciclo while, essenzialmente avente la stessa semantica. Al di fuori del ciclo while, il programma esegue la logica dove count=3. Se count <= 3, il programma completa l'esecuzione e non entrerà nuovamente nel ciclo while. Potrebbe anche trattarsi di un'ottimizzazione eseguita da GraalVM durante la compilazione.

Diamo nuovamente un'occhiata alla condizione di uscita del ciclo.

if(sp_0x44<=rcx)
{
		break
}

Questo corrisponde a

i < count

Allo stesso tempo, anche rcx si accumula durante ogni processo di iterazione.

sp_0x34 = rcx
rcx = sp_0x34+1

corrisponde a

++i

Successivamente, diamo un'occhiata a come la logica dell'aggiunta di numeri nel corpo del loop si riflette nel codice ripristinato. Il codice originale è il seguente:

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

Il codice dopo il ripristino è

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
  ......
}

L'altro codice nel corpo del ciclo esegue la concatenazione di stringhe e le operazioni di output come prima. Il codice ripristinato riflette sostanzialmente la logica di esecuzione del codice originale.

Sono necessari ulteriori miglioramenti

Attualmente, questo strumento è in grado di ripristinare parzialmente il flusso di controllo del programma, raggiungere un certo livello di analisi del flusso di dati e ripristinare il nome della funzione. Per diventare uno strumento completo e utilizzabile, deve ancora realizzare quanto segue:

Nome della funzione più accurato, ripristino dei parametri della funzione e del valore di ritorno della funzione

Informazioni accurate sugli oggetti e ripristino dei campi

Espressione più accurata e inferenza del tipo di oggetto

Integrazione e semplificazione delle dichiarazioni

Pensieri sulla protezione binaria

Lo scopo di questo progetto è esplorare la fattibilità del reverse engineering di NativeImage. Sulla base dei risultati attuali, è possibile effettuare il reverse engineering di NativeImage, il che comporta anche sfide più impegnative per la protezione del codice. Molti sviluppatori ritengono che la compilazione del software in formato binario possa garantire la sicurezza, trascurando la protezione del codice binario. Per il software scritto in C/C++, molti strumenti come IDA hanno già eccellenti effetti di reverse engineering, a volte addirittura esponendo più informazioni rispetto ai programmi Java. Ho anche visto alcuni software distribuiti in forma binaria senza rimuovere le informazioni sui simboli dei nomi delle funzioni, il che equivale a correre nudi.

Qualsiasi codice è composto da logica. Finché contiene logica, è possibile ripristinarla con mezzi inversi. L'unica differenza sta nella difficoltà del restauro. La protezione del codice consiste nel massimizzare la difficoltà di tale ripristino.