Ingénierie inverse de NativeImage

La restauration et la protection du code Java sont une question ancienne et souvent évoquée. En raison du format de byte-code utilisé pour stocker les fichiers de classe Java, qui contient de nombreuses méta-informations, il peut être facilement restauré dans son code d'origine. Afin de protéger le code Java, l'industrie a adopté de nombreuses méthodes, telles que l'obscurcissement, le cryptage du bytecode, la protection JNI, etc. Cependant, quelle que soit la méthode utilisée, il existe toujours des moyens de le déchiffrer.

La compilation binaire a toujours été considérée comme une méthode relativement efficace de protection du code. La compilation binaire de Java est prise en charge en tant que technologie AOT (Ahead of Time), ce qui signifie précompilation.

Cependant, en raison de la nature dynamique du langage Java, la compilation binaire doit gérer des problèmes tels que la réflexion, le proxy dynamique, le chargement JNI, etc., ce qui pose de nombreuses difficultés. Par conséquent, depuis longtemps, il manque un outil mature, fiable et adaptable pour la compilation AOT en Java, pouvant être largement appliqué dans les environnements de production. (Il existait autrefois un outil appelé Excelsior JET, mais il semble avoir été abandonné maintenant.)

En mai 2019, Oracle a publié GraalVM 19.0, une machine virtuelle prenant en charge plusieurs langues, qui était sa première version prête pour la production. GraalVM fournit un outil NativeImage qui peut réaliser la compilation AOT de programmes Java. Après plusieurs années de développement, NativeImage est désormais très mature et SpringBoot 3.0 peut l'utiliser pour compiler l'intégralité du projet SpringBoot dans un fichier exécutable. Le fichier compilé a une vitesse de démarrage rapide, une faible utilisation de la mémoire et d'excellentes performances.

Donc, pour les programmes Java qui sont entrés dans l'ère de la compilation binaire, leur code est-il toujours aussi facilement réversible qu'à l'époque du bytecode? Quelles sont les caractéristiques des fichiers binaires compilés par NativeImage, et l'intensité de la compilation binaire est-elle suffisante pour protéger le code important?

Pour explorer ces questions, nous avons récemment développé un outil d'analyse NativeImage, qui a atteint un certain degré d'effet inverse.

Projet

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

Génération de NativeImage

Tout d’abord, nous devons générer un NativeImage. NativeImage vient de GraalVM. Pour télécharger GraalVM, rendez-vous surhttps://www.graalvm.org/ et téléchargez la version pour Java 17. Après le téléchargement, définissez la variable d'environnement. Puisque GraalVM contient un JDK, vous pouvez l'utiliser directement pour exécuter la commande java.

Ajoutez $GRAALVM_HOME/bin à la variable d'environnement, puis exécutez la commande suivante pour installer l'outil native-image

gu install native-image

Un programme Java simple

Écrivez un programme Java simple, par exemple:

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

Compilez et exécutez le programme Java ci-dessus:

javac Hello.java
java -cp . Hello

Vous obtiendrez la sortie suivante:

Hello World!

Préparation de l'environnement de compilation

Si vous êtes un utilisateur Windows, vous devez d'abord installer Visual Studio. Si vous êtes un utilisateur Linux ou macOS, vous devez au préalable installer des outils comme gcc et clang.

Pour les utilisateurs Windows, vous devez configurer la variable d'environnement pour Visual Studio avant d'exécuter la commande native-image. Vous pouvez le configurer à l'aide de la commande suivante :

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

Si le chemin d'installation et la version de Visual Studio sont différents, veuillez ajuster les informations de chemin associées en conséquence.

Compiler avec native-image

Utilisez maintenant la commande native-image pour compiler le programme Java ci-dessus dans un fichier binaire. Le format de la commande native-image est le même que le format de la commande java, et elle a également-cp, -jarCes paramètres, comment utiliser la commande java pour exécuter le programme, utilisez la même méthode pour la compilation binaire, remplacez simplement le commande de Java avec image native. Exécutez la commande comme suit

native-image -cp . Hello

Après une période de compilation, il peut consommer plus de CPU et de mémoire. Vous pouvez obtenir un fichier binaire compilé et le nom du fichier de sortie est par défaut la minuscule du nom de la classe principale, qui est "bonjour" dans ce cas. Si c'est sous Windows, ce sera "hello.exe". Utilisez la commande "file" pour vérifier le type de ce fichier, vous pouvez voir qu'il s'agit bien d'un fichier binaire.

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

Exécutez ce fichier et sa sortie sera la même que celle obtenue lors de la précédente use.java -cp . BonjourLe résultat est cohérent

Hello World!

Analyse de NativeImage

Analyse avec IDA

Utilisez IDA pour ouvrir le bonjour compilé à partir des étapes ci-dessus, cliquez sur Exportations pour afficher la table des symboles, vous pouvez voir le symbole svm_code_section et son adresse est l'adresse d'entrée de la fonction principale Java. image-20230218194013099

Accédez à cette adresse pour afficher le code d'assemblage

image-20230218194126014

Vous pouvez voir qu'il est devenu une fonction d'assemblage standard, utilisez F5 pour décompiler

image-20230218194235234

Certains appels de fonction peuvent être vus et certains paramètres sont passés, mais il n'est pas facile de voir la logique.

Lorsque nous double-cliquons sur sub_1000C0020, jetons un coup d'œil à l'appel de fonction. IDA provoque un échec d’analyse.

image-20230218194449494

Logique de décompilation de NativeImage

Étant donné que la compilation de NativeImage est basée sur la compilation JVM, elle peut également être comprise comme incluant du code binaire avec une couche de protection VM. Par conséquent, des outils comme IDA sont incapables de procéder à une rétro-ingénierie efficace en l’absence d’informations correspondantes et de mesures de traitement ciblées.

Cependant, quel que soit le format, qu'il s'agisse de bytecode ou de forme binaire, certains éléments de base de l'exécution de la JVM doivent exister, tels que les informations de classe, les informations de champ, l'invocation de fonction et le passage de paramètres. Basé sur cet état d'esprit, l'outil d'analyse que j'ai développé peut atteindre un certain niveau d'effet de restauration et, avec de nouvelles améliorations, avoir la capacité d'atteindre un haut niveau de précision de restauration.

Analyser avec NativeImageAnalyzer

Visitezhttps://github.com/vlinx-io/NativeImageAnalyzerpour télécharger NativeImageAnalyzer

Exécutez la commande suivante pour une analyse inverse, actuellement seulement en train d'analyser la fonction Main de la classe principale

native-image-analyzer hello

Le résultat est le suivant

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

Jetons à nouveau un œil au code original.

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

Jetons maintenant un œil à la définition de System.out.

public static final PrintStream out = null;

Vous pouvez voir que la variable 'out' de la classe System est une variable de type PrintStream, et c'est une variable statique. Lors de la compilation, NativeImage compile directement une instance de cette classe dans une région appelée Heap, et le code binaire récupère directement cette instance de la région Heap pour l'invocation. Jetons un coup d'œil au code original après restauration.

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

Cesjava.io.PrintStream@0x554fe8C'est juste lu depuis la zone du tasjava.io.PrintStream La variable d'instance se trouve à l'adresse mémoire 0x554fe8.

Regardons à nouveaujava.io.PrintStream.writelnLa définition de la fonction

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

Ici, nous pouvons voir qu'il y a un argument String dans lewritelinfonction, mais dans le code restauré, pourquoi y a-t-il trois arguments passés? D'abordwritelnest une méthode membre de classe qui masque seulement unthisLa variable pointe vers l'appelant, qui est le premier paramètre passé,java.io.PrintStream@0x554fe8 Quant au troisième paramètre rcx, c'est parce que lors du processus d'analyse du code assembleur, il a été déterminé que cette fonction était appelée avec trois paramètres. Cependant, en examinant la définition, on sait que cette fonction n’appelle en réalité que deux paramètres. C’est également un domaine qui devra être amélioré pour cet outil à l’avenir.

Un programme plus complexe

Nous allons maintenant analyser un programme plus complexe, tel que le calcul d'une séquence de Fibonacci, avec le code suivant

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

Compiler et exécuter

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

Le code obtenu après restauration à l'aide de NativeImageAnalyzer est le suivant

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

Comparez le code restauré avec le code d'origine.

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

Le correspondant est

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

rdi est le registre utilisé pour passer le premier argument d'une fonction, si c'est Windows, alors rdi = rdi[0], qui correspond à args[0],Ensuite, appelez java.lang.Integer.parseInt pour analyser et obtenir un int, puis attribuez la valeur de retour à une variable de pile sp_0x44.

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

Correspond à.

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)

Dans notre code Java, l'opération simple de concaténation de chaînes est en réalité convertie en trois appels de fonction:StringConcatHelper.mix,StringConcatHelper.prependetStringConcatHelper.newString. Parmi eux,StringConcatHelper.mixcalcule la longueur de la chaîne concaténéeStringConcatHelper.prependcombine le tableau byte[] qui transporte le contenu de chaîne spécifique ensemble, etStringConcatHelper.newString génère un nouvel objet String à partir du tableau byte[].

Dans le code ci-dessus, nous voyons deux types de noms de variables.sp_0x18ettlab_0. Variables commençant parsp_indiquer les variables allouées sur la pile, tandis que les variables commençant partlab_ indiquer les variables allouées sur les tampons d'allocation locale de thread. Ceci est juste une explication de l'origine de ces deux types de noms de variables. Dans le code restauré, il n'y a aucune distinction entre ces deux types de variables. Pour plus d’informations sur les tampons d’allocation locale de thread, veuillez les rechercher vous-même.

Ici nous assignonstlab_0 àClass{[B}_1. Le sens deClass{[B}_1 est une instance du type byte[]. [B représente le descripteur Java pour byte[], _1 indique qu'il s'agit de la première variable de ce type. Si des variables ultérieures sont définies pour le type correspondant, l'index augmentera en conséquence, commeClass{[B]}_2,Class{[B]}_3, etc. La même représentation s'applique à d'autres types, tels queClass{java.lang.String}_1,Class{java.util.HashMap}_2, et ainsi de suite.

La logique du code ci-dessus explique simplement la création d'une instance de tableau byte[] et son attribution à tlab0. La longueur du tableau estret_2 << ret_2 >> 32. La raison pour laquelle la longueur du tableau estret_2 << ret_2 >> 32 C'est parce que lors du calcul de la longueur d'une chaîne, il doit convertir la longueur du tableau en fonction de l'encodage. Vous pouvez vous référer au code pertinent dans java.lang.String.java. Ensuite, la fonction préfixe combine 0, 1 et des espaces dans tlab0, puis génère un nouvel objet String ret_30 à partir de tlab_0 et le transmet à la fonction java.io.PrintStream.write pour imprimer la sortie. En fait, ici les paramètres restaurés par la fonction prepend ne sont pas très précis et leurs positions sont également incorrectes. C’est un domaine qui devra encore être amélioré par la suite.

Après avoir converti les deux lignes de code Java en logique d’exécution réelle, cela reste assez complexe. À l'avenir, il pourra être simplifié en analysant et en intégrant sur la base du code actuellement restauré.

Continuer à marcher en avant

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

Le correspondant est

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 est le paramètre que nous saisissons dans le programme, qui est le nombre. La boucle for dans le code Java ne sera exécutée que si count >= 3. Ici, la boucle for est reconvertie en boucle while, ayant essentiellement la même sémantique. En dehors de la boucle while, le programme exécute la logique où count=3. Si le nombre <= 3, le programme termine l'exécution et n'entrera plus dans la boucle while. Cela peut aussi être une optimisation effectuée par GraalVM lors de la compilation.

Regardons à nouveau la condition de sortie de la boucle.

if(sp_0x44<=rcx)
{
		break
}

Cela correspond à

i < count

Dans le même temps, rcx s'accumule également à chaque processus d'itération.

sp_0x34 = rcx
rcx = sp_0x34+1

correspond à

++i

Voyons ensuite comment la logique d'ajout de nombres dans le corps de la boucle se reflète dans le code restauré. Le code d'origine est le suivant :

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

Le code après restauration est

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'autre code dans le corps de la boucle effectue des opérations de concaténation de chaînes et de sortie comme auparavant. Le code restauré reflète essentiellement la logique d'exécution du code d'origine.

Des améliorations supplémentaires sont nécessaires

Actuellement, cet outil est capable de restaurer partiellement le flux de contrôle du programme, d'atteindre un certain niveau d'analyse du flux de données et de restauration du nom de fonction. Pour devenir un outil complet et utilisable, il doit encore accomplir les tâches suivantes :

Nom de fonction plus précis, restauration des paramètres de fonction et de la valeur de retour de la fonction

Informations précises sur les objets et restauration des champs

Expression plus précise et inférence du type d'objet

Intégration et simplification des déclarations

Réflexions sur la protection binaire

Le but de ce projet est d'explorer la faisabilité de la rétro-ingénierie de NativeImage. Sur la base des réalisations actuelles, il est possible de faire de l'ingénierie inverse sur NativeImage, ce qui pose également des défis plus importants en matière de protection du code. De nombreux développeurs pensent que compiler des logiciels en binaires peut garantir la sécurité, négligeant la protection du code binaire. Pour les logiciels écrits en C/C++, de nombreux outils tels que IDA ont déjà d'excellents effets de rétro-ingénierie, exposant même parfois plus d'informations que les programmes Java. J'ai même vu certains logiciels distribués sous forme binaire sans supprimer les informations sur les symboles des noms de fonctions, ce qui équivaut à une exécution nue.

Tout code est composé de logique. Tant qu'il contient de la logique, il est possible de restaurer sa logique par des moyens inverses. La seule différence réside dans la difficulté de restauration. La protection du code consiste à maximiser la difficulté d'une telle restauration.