Cracking du cryptage octet code Java

9 mai 2003

Q: Si je crypte mes fichiers .class et que j'utilise un chargeur de classe personnalisé pour les charger et les décrypter à la volée, cela empêchera-t-il la décompilation?

R: Le problème de la prévention de la décompilation du code octet Java est presque aussi ancien que le langage lui-même. Malgré une gamme d'outils d'obscurcissement disponibles sur le marché, les programmeurs Java novices continuent de penser à des moyens nouveaux et intelligents de protéger leur propriété intellectuelle. Dans cet épisode de questions-réponses Java , je dissipe certains mythes autour d'une idée fréquemment reformulée dans les forums de discussion.

L'extrême facilité avec laquelle les .classfichiers Java peuvent être reconstruits dans des sources Java qui ressemblent étroitement aux originaux a beaucoup à voir avec les objectifs et les compromis de conception de code octet Java. Entre autres choses, le code d'octet Java a été conçu pour la compacité, l'indépendance de la plate-forme, la mobilité du réseau et la facilité d'analyse par des interpréteurs de code d'octet et des compilateurs dynamiques JIT (juste-à-temps) / HotSpot. On peut soutenir que les .classfichiers compilés expriment si clairement l'intention du programmeur qu'ils pourraient être plus faciles à analyser que le code source d'origine.

Plusieurs choses peuvent être faites, sinon pour empêcher complètement la décompilation, du moins pour la rendre plus difficile. Par exemple, comme étape de post-compilation, vous pouvez masser les .classdonnées pour rendre le code d'octet plus difficile à lire lorsqu'il est décompilé ou plus difficile à décompiler en code Java valide (ou les deux). Des techniques telles que la surcharge extrême des noms de méthode fonctionnent bien pour la première, et la manipulation du flux de contrôle pour créer des structures de contrôle qu'il n'est pas possible de représenter via la syntaxe Java fonctionnent bien pour la seconde. Les obfuscateurs commerciaux les plus réussis utilisent un mélange de ces techniques et d'autres.

Malheureusement, les deux approches doivent en fait changer le code que la JVM exécutera, et de nombreux utilisateurs craignent (à juste titre) que cette transformation puisse ajouter de nouveaux bogues à leurs applications. En outre, le changement de nom de méthode et de champ peut entraîner l'arrêt des appels de réflexion. La modification des noms de classe et de package réels peut casser plusieurs autres API Java (JNDI (Java Naming and Directory Interface), fournisseurs d'URL, etc.). En plus des noms modifiés, si l'association entre les décalages de code d'octet de classe et les numéros de ligne source est modifiée, la récupération des traces de pile d'exceptions d'origine peut devenir difficile.

Ensuite, il y a la possibilité de masquer le code source Java d'origine. Mais fondamentalement, cela pose un ensemble similaire de problèmes.

Crypter, pas obscurcir?

Peut-être que ce qui précède vous a fait penser: "Eh bien, et si au lieu de manipuler du code d'octet je crypte toutes mes classes après compilation et les décrypte à la volée dans la JVM (ce qui peut être fait avec un chargeur de classe personnalisé)? Ensuite, la JVM exécute mon code d'octet original et pourtant il n'y a rien à décompiler ou à désosser, non?

Malheureusement, vous vous trompez, à la fois en pensant que vous avez été le premier à proposer cette idée et en pensant que cela fonctionne réellement. Et la raison n'a rien à voir avec la force de votre schéma de cryptage.

Un encodeur de classe simple

Pour illustrer cette idée, j'ai implémenté un exemple d'application et un chargeur de classe personnalisé très simple pour l'exécuter. L'application se compose de deux classes courtes:

public class Main {public static void main (final String [] args) {System.out.println ("secret result =" + MySecretClass.mySecretAlgorithm ()); }} // Fin du package de classe my.secret.code; import java.util.Random; public class MySecretClass {/ ** * Devinez quoi, l'algorithme secret utilise juste un générateur de nombres aléatoires ... * / public static int mySecretAlgorithm () {return (int) s_random.nextInt (); } final statique privé Random s_random = new Random (System.currentTimeMillis ()); } // Fin de cours

Mon aspiration est de cacher l'implémentation de my.secret.code.MySecretClassen chiffrant les .classfichiers concernés et en les décrypter à la volée au moment de l'exécution. À cet effet, j'utilise l'outil suivant (certains détails omis; vous pouvez télécharger la source complète à partir de Resources):

public class EncryptedClassLoader étend URLClassLoader {public static void main (final String [] args) throws Exception {if ("-run" .equals (args [0]) && (args.length> = 3)) {// Créer un personnalisé chargeur qui utilisera le chargeur actuel comme // parent de délégation: final ClassLoader appLoader = new EncryptedClassLoader (EncryptedClassLoader.class.getClassLoader (), new File (args [1])); // Le chargeur de contexte de thread doit également être ajusté: Thread.currentThread () .setContextClassLoader (appLoader); Classe finale app = appLoader.loadClass (args [2]); Méthode finale appmain = app.getMethod ("main", new Class [] {String [] .class}); chaîne finale [] appargs = nouvelle chaîne [args.length - 3]; System.arraycopy (args, 3, appargs, 0, appargs.length); appmain.invoke (null, nouvel objet [] {appargs}); } else if ("-encrypt".equals (args [0]) && (args.length> = 3)) {... crypter les classes spécifiées ...} else throw new IllegalArgumentException (USAGE); } / ** * Remplace java.lang.ClassLoader.loadClass () pour changer les règles habituelles de délégation parent-enfant * juste assez pour pouvoir «arracher» les classes d'application * sous le nez du chargeur de classe système. * / public Classe loadClass (nom final de la chaîne, résolution booléenne finale) jette ClassNotFoundException {if (TRACE) System.out.println ("loadClass (" + nom + "," + resolution + ")"); Classe c = nulle; // Tout d'abord, vérifiez si cette classe a déjà été définie par ce chargeur de classe // instance: c = findLoadedClass (name); if (c == null) {Classe parentsVersion = null; try {// Ceci est légèrement peu orthodoxe:faites un chargement d'essai via le // chargeur parent et notez si le parent a délégué ou non; // ce que cela accomplit est une délégation appropriée pour toutes les classes // principales et d'extension sans que je doive filtrer sur le nom de la classe: parentsVersion = getParent () .loadClass (nom); if (parentsVersion.getClassLoader ()! = getParent ()) c = parentsVersion; } catch (ClassNotFoundException ignore) {} catch (ClassFormatError ignore) {} if (c == null) {try {// OK, soit 'c' a été chargé par le chargeur système (et non par le bootstrap // ou l'extension) (dans quel cas je veux ignorer cette // définition) ou le parent a échoué complètement; de toute façon, je // tente de définir ma propre version: c = findClass (nom); } catch (ClassNotFoundException ignore) {// En cas d'échec, revenez à la version du parent // [qui pourrait être nulle à ce stade]: c = parentsVersion;}}} if (c == null) throw new ClassNotFoundException (nom); if (résoudre) résoudreClasse (c); retour c; } / ** * Remplace java.new.URLClassLoader.defineClass () pour pouvoir appeler * crypt () avant de définir une classe. * / protected Class findClass (nom final de la chaîne) jette ClassNotFoundException {if (TRACE) System.out.println ("findClass (" + name + ")"); // Les fichiers .class ne sont pas garantis pour être chargeables en tant que ressources; // mais si le code de Sun le fait, alors peut-être peut-il miner ... String final classResource = name.replace ('.', '/') + ".class"; URL finale classURL = getResource (classResource); if (classURL == null) throw new ClassNotFoundException (nom); else {InputStream in = null; essayez {in = classURL.openStream (); octet final [] classBytes = readFully (in); // "décrypter": crypt (classBytes);if (TRACE) System.out.println ("déchiffré [" + nom + "]"); renvoie defineClass (nom, classBytes, 0, classBytes.length); } catch (IOException ioe) {throw new ClassNotFoundException (nom); } enfin {if (in! = null) try {in.close (); } catch (Exception ignore) {}}}} / ** * Ce chargeur de classe n'est capable de charger de façon personnalisée qu'à partir d'un seul répertoire. * / private EncryptedClassLoader (parent de ClassLoader final, chemin de classe de fichier final) jette MalformedURLException {super (nouvelle URL [] {classpath.toURL ()}, parent); if (parent == null) throw new IllegalArgumentException ("EncryptedClassLoader" + "nécessite un parent de délégation non nul"); } / ** * De / crypte les données binaires dans un tableau d'octets donné. Le fait de rappeler la méthode * annule le cryptage. * / private static void crypt (octet final [] données) {for (int i = 8;i <data.length; ++ i) données [i] ^ = 0x5A; } ... plus de méthodes d'assistance ...} // Fin de la classe

EncryptedClassLoadera deux opérations de base: chiffrer un ensemble donné de classes dans un répertoire de chemin de classe donné et exécuter une application précédemment chiffrée. Le cryptage est très simple: il consiste essentiellement à retourner certains bits de chaque octet dans le contenu de la classe binaire. (Oui, le bon vieux XOR (OU exclusif) n'est pratiquement pas de chiffrement, mais soyez indulgents. Ceci n'est qu'une illustration.)

Le chargement de classe par EncryptedClassLoadermérite un peu plus d'attention. Ma mise en œuvre sous java.net.URLClassLoader- classe et remplace les deux loadClass()et defineClass()pour atteindre deux objectifs. La première consiste à contourner les règles de délégation habituelles du chargeur de classe Java 2 et à avoir la possibilité de charger une classe chiffrée avant que le chargeur de classe système ne le fasse, et une autre consiste à invoquer crypt()immédiatement avant que l'appel defineClass()ne se produise à l'intérieur URLClassLoader.findClass().

Après avoir tout compilé dans le binrépertoire:

> javac -d bin src / *. java src / mon / secret / code / *. java 

I « Chiffrer » les deux Mainet MySecretClassclasses:

> java -cp bin EncryptedClassLoader -encrypt bin Main my.secret.code.MySecretClass encrypted [Main.class] encrypted [my \ secret \ code \ MySecretClass.class] 

Ces deux classes dans binont maintenant été remplacées par des versions chiffrées, et pour exécuter l'application d'origine, je dois exécuter l'application via EncryptedClassLoader:

> java -cp bin Exception principale dans le thread "main" java.lang.ClassFormatError: Main (type de pool constant illégal) sur java.lang.ClassLoader.defineClass0 (méthode native) sur java.lang.ClassLoader.defineClass (ClassLoader.java: 502) sur java.security.SecureClassLoader.defineClass (SecureClassLoader.java:123) sur java.net.URLClassLoader.defineClass (URLClassLoader.java:250) sur java.net.URLClassLoader.access00 (URLClassLoader.java:54) sur java. net.URLClassLoader.run (URLClassLoader.java:193) à java.security.AccessController.doPrivileged (méthode native) à java.net.URLClassLoader.findClass (URLClassLoader.java:186) à java.lang.ClassLoader.loaderClass (Classoader. java: 299) à sun.misc.Launcher $ AppClassLoader.loadClass (Launcher.java:265) à java.lang.ClassLoader.loadClass (ClassLoader.java:255) à java.lang.ClassLoader.loadClassInternal (ClassLoader.java:315) )>java -cp bin EncryptedClassLoader -run bin Main déchiffrée [Main] déchiffrée [my.secret.code.MySecretClass] résultat secret = 1362768201

Effectivement, exécuter un décompilateur (tel que Jad) sur des classes chiffrées ne fonctionne pas.

Il est temps d'ajouter un système de protection par mot de passe sophistiqué, de l'envelopper dans un exécutable natif et de facturer des centaines de dollars pour une «solution de protection logicielle», non? Bien sûr que non.

ClassLoader.defineClass (): Le point d'interception inévitable

Tous ClassLoaderdoivent fournir leurs définitions de classe à la JVM via un point API bien défini: la java.lang.ClassLoader.defineClass()méthode. L' ClassLoaderAPI a plusieurs surcharges de cette méthode, mais toutes appellent la defineClass(String, byte[], int, int, ProtectionDomain)méthode. C'est une finalméthode qui appelle le code natif JVM après quelques vérifications. Il est important de comprendre qu'aucun classloader ne peut éviter d'appeler cette méthode s'il veut en créer un nouveau Class.

La defineClass()méthode est le seul endroit où la magie de la création d'un Classobjet à partir d'un tableau d'octets plat peut avoir lieu. Et devinez quoi, le tableau d'octets doit contenir la définition de classe non chiffrée dans un format bien documenté (voir la spécification du format de fichier de classe). Briser le schéma de chiffrement est maintenant une simple question d'intercepter tous les appels à cette méthode et de décompiler toutes les classes intéressantes à votre guise (je mentionne une autre option, JVM Profiler Interface (JVMPI), plus tard).