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.
Vai a questo indirizzo per visualizzare il codice di assemblaggio
Puoi vedere che è diventata una funzione di assemblaggio standard, usa F5 per decompilare
È 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.
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.writeln
Definizione della funzione
private void writeln(String s) {
......
}
Qui possiamo vedere che c'è un argomento String nellawritelin
funzione, ma nel codice ripristinato, perché vengono passati tre argomenti? Primowriteln
è un metodo membro di classe che nasconde solo unothis
La 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.prepend
eStringConcatHelper.newString
. Tra loro,StringConcatHelper.mix
calcola la lunghezza della stringa concatenataStringConcatHelper.prepend
combina 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_0x18
etlab_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 < count; ++i){
n3 = n1 + n2;
System.out.print(" " + n3);
n1 = n2;
n2 = n3;
}
System.out.println();
Il corrispondente è
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
è 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.