Ingénierie inverse NativeImage

La restauration et la protection du code Java constituent un problème ancien et fréquemment abordé. Le format bytecode utilisé pour stocker les fichiers de classes Java, qui contient de nombreuses métadonnées, permet de restaurer facilement le code original. Afin de protéger le code Java, l'industrie a adopté diverses méthodes, telles que l'obfuscation, le chiffrement du bytecode et la protection JNI. Cependant, quelle que soit la méthode employé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. En Java, la compilation binaire est prise en charge par la technologie AOT (Ahead-of-Time), c'est-à-dire la précompilation.

Cependant, la nature dynamique du langage Java impose, lors de la compilation binaire, de gérer des problématiques telles que la réflexion, les proxys dynamiques et le chargement JNI, ce qui engendre de nombreuses difficultés. Par conséquent, il a longtemps manqué un outil mature, fiable et adaptable pour la compilation AOT en Java, utilisable à grande échelle en production. (Excelsior JET existait, mais son développement semble avoir été abandonné.)

En mai 2019, Oracle a lancé GraalVM 19.0, une machine virtuelle multilingue, sa première version prête pour la production. GraalVM fournit NativeImage, un outil permettant la compilation AOT des programmes Java. Après plusieurs années de développement, NativeImage est désormais très abouti et Spring Boot 3.0 peut l'utiliser pour compiler l'intégralité d'un projet Spring Boot en un fichier exécutable. Ce fichier compilé offre un démarrage rapide, une faible consommation de mémoire et d'excellentes performances.

Ainsi, pour les programmes Java 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 le niveau de compilation binaire est-il suffisant pour protéger le code critique ?

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

Projet

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

Générer une image native

Tout d'abord, nous devons générer une image native. Les images natives sont fournies par GraalVM. Pour télécharger GraalVM, rendez-vous sur : https://www.graalvm.org/ Téléchargez la version compatible avec Java 17. Une fois le téléchargement terminé, configurez la variable d'environnement. GraalVM intégrant 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 le résultat suivant :

Hello World!

Préparation de l'environnement de compilation

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

Pour les utilisateurs Windows, il est nécessaire de configurer la variable d'environnement pour Visual Studio avant d'exécuter la commande native-image. Vous pouvez la 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 correspondantes en conséquence.

Compiler avec l'image native

Utilisez maintenant la commande `native-image` pour compiler le programme Java ci-dessus en un fichier binaire. Le format de la commande `native-image` est identique à celui de la commande `java`, et elle utilise également les paramètres `-cp` et `-jar`. Pour exécuter le programme avec la commande `java`, la méthode est la même que pour la compilation binaire : remplacez simplement la commande `java` par `native-image`. Exécutez la commande comme suit :

native-image -cp . Hello

Après la compilation, la consommation de ressources (CPU et mémoire) peut augmenter. Vous obtiendrez un fichier binaire compilé, dont le nom correspond par défaut aux minuscules du nom de la classe principale, soit « hello » dans ce cas. Sous Windows, il s'agira de « hello.exe ». La commande « file » vous permettra de vérifier le type de ce fichier : il s'agit bien d'un fichier binaire.

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

Exécutez ce fichier, et son résultat sera identique à celui obtenu lors de l'utilisation précédente de `use.java -cp . Hello`. Le résultat est cohérent.

Hello World!

Analyse de NativeImage

Analyse avec IDA

Utilisez IDA pour ouvrir le fichier hello compilé à partir des étapes ci-dessus, cliquez sur Exports 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 Java Main. image-20230218194013099

Rendez-vous à cette adresse pour consulter le code assembleur

image-20230218194126014

Vous pouvez constater qu'il s'agit d'une fonction d'assemblage standard ; utilisez F5 pour décompiler.

image-20230218194235234

On peut observer certains appels de fonction et certains paramètres sont transmis, mais la logique n'est pas facile à comprendre.

Lorsque nous double-cliquons sur sub_1000C0020, examinons l'appel de fonction. IDA signale une erreur d'analyse.

image-20230218194449494

Logique de décompilation de NativeImage

La compilation de NativeImage étant basée sur la compilation JVM, on peut la considérer comme un encapsulage du code binaire dans une couche de protection de la JVM. Par conséquent, des outils comme IDA sont incapables de la rétroconcevoir efficacement sans les informations correspondantes et des mesures de traitement ciblées.

Cependant, quel que soit le format, bytecode ou binaire, certains éléments fondamentaux de l'exécution JVM sont inévitables : informations sur les classes et les champs, appels de fonctions et passage de paramètres. Fort de ce constat, l'outil d'analyse que j'ai développé permet une certaine restauration et, moyennant quelques améliorations, pourrait atteindre une précision de restauration élevée.

Utilisation de NativeImageAnalyzer

Visite https://github.com/vlinx-io/NativeImageAnalyzer télécharger NativeImageAnalyzer

Exécutez la commande suivante pour une analyse inverse ; actuellement, seule la fonction Main de la classe Main est analysée.

native-image-analyzer hello

Le résultat est le suivant

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

Examinons à nouveau le code original.

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

Examinons maintenant la définition de System.out.

public static final PrintStream out = null;

Vous pouvez constater que la variable « out » de la classe System est de type PrintStream et qu'il s'agit d'une variable statique. Lors de la compilation, NativeImage crée directement une instance de cette classe dans une zone appelée Heap, et le code binaire récupère directement cette instance depuis le Heap pour l'exécuter. Examinons maintenant le code original après restauration.

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

Ces java.io.PrintStream@0x554fe8 Cela vient d'être lu depuis la zone du tas java.io.PrintStream La variable d'instance se trouve à l'adresse mémoire 0x554fe8.

我们再来看下java.io.PrintStream.writeln 函数的定义

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

On peut voir ici qu'il y a un argument de type chaîne de caractères dans le writelin fonction, mais dans le code restauré, pourquoi trois arguments sont-ils passés ? writeln est une méthode membre de classe qui ne masque qu'une seule thisLa variable pointe vers l'appelant, qui est le premier paramètre passé. java.io.PrintStream@0x554fe8 Concernant le troisième paramètre rcx, il est dû au fait que, lors de l'analyse du code assembleur, il a été constaté que cette fonction était appelée avec trois paramètres. Or, après examen de sa définition, nous savons qu'elle n'en requiert en réalité que deux. Il s'agit là d'un point à améliorer pour cet outil.

Un programme plus complexe

Nous allons maintenant analyser un programme plus complexe, tel que le calcul d'une suite 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 original.

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 ; sous Windows, alors rdi = rdi[0], ce qui correspond à args[0]. Ensuite, appelez java.lang.Integer.parseInt pour analyser et obtenir une valeur int, puis assignez 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, la simple opération de concaténation de chaînes est en réalité convertie en trois appels de fonction : StringConcatHelper.mix, StringConcatHelper.prepend, et StringConcatHelper.newStringParmi eux, StringConcatHelper.mix calcule la longueur de la chaîne concaténée, StringConcatHelper.prepend combine le tableau byte[] qui contient le contenu de la chaîne spécifique, et StringConcatHelper.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_0x18 et tlab_0. Variables commençant par sp_ indique les variables allouées sur la pile, tandis que les variables commençant par tlab_ Ces variables sont allouées dans des tampons d'allocation locale de thread. Cette explication porte uniquement sur l'origine de ces deux types de noms de variables. Dans le code restauré, aucune distinction n'est faite entre ces deux types de variables. Pour plus d'informations sur les tampons d'allocation locale de thread, veuillez effectuer vos propres recherches.

Ici, nous assignons tlab_0 à Class{[B}_1La signification de Class{[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 d'autres variables sont définies pour le même type, l'index s'incrémentera en conséquence, comme ceci : Class{[B]}_2, Class{[B]}_3, etc. La même représentation s'applique à d'autres types, tels que Class{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&#39;une instance de tableau byte[] et son affectation à tlab0. La longueur du tableau est ret_2 << ret_2 >> 32La raison pour laquelle la longueur du tableau est ret_2 << ret_2 >> 32 En effet, le calcul de la longueur d&#39;une chaîne de caractères nécessite une conversion de la longueur du tableau en fonction de l&#39;encodage. Vous pouvez consulter le code correspondant dans le fichier `java.lang.String.java`. Ensuite, la fonction `prepend` combine les 0, les 1 et les 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 l&#39;affichage. Or, les paramètres restaurés par `prepend` sont ici imprécis et leurs positions incorrectes. Ce point devra être amélioré ultérieurement.

Après la conversion des deux lignes de code Java en logique d&#39;exécution, le code reste assez complexe. Il sera possible de le simplifier ultérieurement en analysant et en intégrant le code restauré.

Continuez à marcher vers l&#39;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 Le paramètre que nous fournissons au programme, à savoir `count`, est `count`. La boucle `for` du code Java ne sera exécutée que si `count &gt;= 3`. Ici, la boucle `for` est transformée en une boucle `while`, conservant essentiellement la même sémantique. En dehors de la boucle `while`, le programme exécute la logique lorsque `count = 3`. Si `count &lt;= 3`, le programme termine son exécution et n&#39;entre plus dans la boucle `while`. Il peut s&#39;agir d&#39;une optimisation effectuée par GraalVM lors de la compilation.

Examinons à nouveau la condition de sortie de la boucle.

if(sp_0x44<=rcx)
{
		break
}

Cela correspond à

i < count

Dans le même temps, rcx s&#39;accumule également au cours de chaque itération.

sp_0x34 = rcx
rcx = sp_0x34+1

correspond à

++i

Voyons maintenant comment la logique d&#39;addition des nombres dans le corps de la boucle se reflète dans le code restauré. Voici le code original :

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

Le reste du code dans la boucle effectue les opérations de concaténation de chaînes et d&#39;affichage comme auparavant. Le code restauré reflète globalement la logique d&#39;exécution du code original.

Des améliorations supplémentaires sont nécessaires

Actuellement, cet outil permet de restaurer partiellement le flux de contrôle du programme, d&#39;effectuer une analyse partielle du flux de données et de rétablir les noms des fonctions. Pour devenir un outil complet et fonctionnel, il doit encore accomplir les tâches suivantes :

Restauration plus précise du nom de la fonction, des paramètres de la fonction et de la valeur de retour de la fonction

Informations précises sur l&#39;objet et restauration sur le terrain

Inférence plus précise de l&#39;expression et du type d&#39;objet

Intégration et simplification des énoncés

Réflexions sur la protection binaire

Ce projet vise à explorer la faisabilité de la rétro-ingénierie de NativeImage. D&#39;après les résultats actuels, cette rétro-ingénierie est possible, mais elle soulève des défis importants en matière de protection du code. De nombreux développeurs pensent que la compilation en binaire garantit la sécurité, négligeant ainsi la protection du code binaire. Pour les logiciels écrits en C/C++, des outils comme IDA offrent déjà d&#39;excellentes performances en rétro-ingénierie, révélant parfois même plus d&#39;informations que les programmes Java. J&#39;ai même vu des logiciels distribués sous forme binaire sans suppression des symboles des noms de fonctions, ce qui revient à les exécuter sans protection.

Tout code est composé de logique. Tant qu&#39;il contient de la logique, il est possible de la reconstituer par des moyens inverses. La seule différence réside dans la difficulté de cette reconstitution. La protection du code vise à maximiser cette difficulté.