Cracking della crittografia del codice byte Java

9 maggio 2003

D: Se crittografo i miei file .class e utilizzo un classloader personalizzato per caricarli e decrittografarli al volo, questo impedirà la decompilazione?

R: Il problema di impedire la decompilazione del codice byte Java è vecchio quasi quanto il linguaggio stesso. Nonostante una serie di strumenti di offuscamento disponibili sul mercato, i programmatori Java alle prime armi continuano a pensare a modi nuovi e intelligenti per proteggere la loro proprietà intellettuale. In questa puntata di domande e risposte su Java , sfoglio alcuni miti su un'idea spesso rimossa nei forum di discussione.

L'estrema facilità con cui i .classfile Java possono essere ricostruiti in sorgenti Java che assomigliano molto agli originali ha molto a che fare con gli obiettivi di progettazione del codice byte Java e con i compromessi. Tra le altre cose, il codice byte Java è stato progettato per compattezza, indipendenza dalla piattaforma, mobilità di rete e facilità di analisi da parte di interpreti di codice byte e compilatori dinamici JIT (just-in-time) / HotSpot. Probabilmente, i .classfile compilati esprimono l'intento del programmatore in modo così chiaro che potrebbero essere più facili da analizzare rispetto al codice sorgente originale.

Si possono fare diverse cose, se non per impedire completamente la decompilazione, almeno per renderla più difficile. Ad esempio, come passaggio di post-compilazione è possibile massaggiare i .classdati per rendere il codice byte più difficile da leggere quando decompilato o più difficile da decompilare in codice Java valido (o entrambi). Tecniche come l'esecuzione del sovraccarico estremo del nome del metodo funzionano bene per il primo e la manipolazione del flusso di controllo per creare strutture di controllo che non è possibile rappresentare attraverso la sintassi Java funziona bene per il secondo. Gli offuscatori commerciali di maggior successo utilizzano un mix di queste e altre tecniche.

Sfortunatamente, entrambi gli approcci devono effettivamente modificare il codice che verrà eseguito dalla JVM e molti utenti temono (giustamente) che questa trasformazione possa aggiungere nuovi bug alle loro applicazioni. Inoltre, la ridenominazione di metodi e campi può causare il blocco delle chiamate di riflessione. La modifica dei nomi delle classi e dei pacchetti effettivi può interrompere diverse altre API Java (JNDI (Java Naming and Directory Interface), provider di URL, ecc.). Oltre ai nomi alterati, se l'associazione tra gli offset del codice byte di classe e i numeri di riga di origine viene alterata, il ripristino delle tracce dello stack di eccezione originale potrebbe diventare difficile.

Poi c'è la possibilità di offuscare il codice sorgente Java originale. Ma fondamentalmente questo causa una serie simile di problemi.

Crittografare, non offuscare?

Forse quanto sopra ti ha fatto pensare: "Bene, e se invece di manipolare il codice a byte crittografassi tutte le mie classi dopo la compilazione e le decifrassi al volo all'interno della JVM (cosa che può essere eseguita con un caricatore di classi personalizzato)? Allora la JVM esegue il mio codice byte originale e tuttavia non c'è nulla da decompilare o da decodificare, giusto? "

Sfortunatamente, sbaglieresti, sia nel pensare di essere stato il primo a proporre questa idea sia nel pensare che funzioni davvero. E il motivo non ha nulla a che fare con la forza del tuo schema di crittografia.

Un semplice codificatore di classe

Per illustrare questa idea, ho implementato un'applicazione di esempio e un classloader personalizzato molto banale per eseguirlo. L'applicazione è composta da due classi brevi:

public class Main {public static void main (final String [] args) {System.out.println ("secret result =" + MySecretClass.mySecretAlgorithm ()); }} // Pacchetto di fine classe my.secret.code; import java.util.Random; public class MySecretClass {/ ** * Indovina un po ', l'algoritmo segreto utilizza solo un generatore di numeri casuali ... * / public static int mySecretAlgorithm () {return (int) s_random.nextInt (); } finale statico privato Random s_random = new Random (System.currentTimeMillis ()); } // Fine della lezione

La mia aspirazione è nascondere l'implementazione di my.secret.code.MySecretClasscrittografando i .classfile rilevanti e decrittografandoli al volo in fase di esecuzione. A tal fine, utilizzo il seguente strumento (alcuni dettagli omessi; puoi scaricare la fonte completa da Risorse):

public class EncryptedClassLoader extends URLClassLoader {public static void main (final String [] args) genera Eccezione {if ("-run" .equals (args [0]) && (args.length> = 3)) {// Crea un custom caricatore che utilizzerà il caricatore corrente come // delegation parent: final ClassLoader appLoader = new EncryptedClassLoader (EncryptedClassLoader.class.getClassLoader (), new File (args [1])); // Anche il caricatore del contesto del thread deve essere regolato: Thread.currentThread () .setContextClassLoader (appLoader); app di classe finale = appLoader.loadClass (args [2]); metodo finale appmain = app.getMethod ("main", new Class [] {String [] .class}); stringa finale [] appargs = new String [args.length - 3]; System.arraycopy (args, 3, appargs, 0, appargs.length); appmain.invoke (null, nuovo oggetto [] {appargs}); } altrimenti se ("-encrypt".equals (args [0]) && (args.length> = 3)) {... crittografa classi specificate ...} altrimenti lancia una nuova IllegalArgumentException (USAGE); } / ** * Sovrascrive java.lang.ClassLoader.loadClass () per cambiare le solite regole di delega genitore-figlio * quel tanto che basta per essere in grado di "strappare" le classi dell'applicazione * dal naso del classloader del sistema. * / public Class loadClass (nome stringa finale, risoluzione booleana finale) genera ClassNotFoundException {if (TRACE) System.out.println ("loadClass (" + name + "," +ublesho + ")"); Classe c = null; // Per prima cosa, controlla se questa classe è già stata definita da questo classloader // instance: c = findLoadedClass (name); if (c == null) {Class parentVersion = null; prova {// Questo è leggermente poco ortodosso:eseguire un caricamento di prova tramite // il caricatore genitore e annotare se il genitore ha delegato o meno; // ciò che si ottiene è una delega adeguata per tutte le classi di base // e di estensione senza che io debba filtrare il nome della classe: genitoriVersion = getParent () .loadClass (name); if (genitoriVersion.getClassLoader ()! = getParent ()) c = genitoriVersion; } catch (ClassNotFoundException ignore) {} catch (ClassFormatError ignore) {} se (c == null) {try {// OK, "c" è stato caricato dal caricatore di sistema (non bootstrap // o estensione) (in quale caso voglio ignorare quella // definizione) o il genitore ha fallito del tutto; in ogni caso // tento di definire la mia versione: c = findClass (name); } catch (ClassNotFoundException ignore) {// Se fallisce, ripiega sulla versione del genitore // [che potrebbe essere nulla a questo punto]: c = parentVersion;}}} if (c == null) genera una nuova ClassNotFoundException (nome); if (risoluzione) resolClass (c); ritorno c; } / ** * Sostituisce java.new.URLClassLoader.defineClass () per poter chiamare * crypt () prima di definire una classe. * / protected Class findClass (final String name) genera ClassNotFoundException {if (TRACE) System.out.println ("findClass (" + name + ")"); // Non è garantito che i file .class siano caricabili come risorse; // ma se il codice di Sun lo fa, forse è possibile estrarre ... final String classResource = name.replace ('.', '/') + ".class"; URL finale classURL = getResource (classResource); if (classURL == null) genera una nuova ClassNotFoundException (nome); altro {InputStream in = null; prova {in = classURL.openStream (); byte finale [] classBytes = readFully (in); // "decrypt": crypt (classBytes);if (TRACE) System.out.println ("decrypted [" + name + "]"); return defineClass (name, classBytes, 0, classBytes.length); } catch (IOException ioe) {lancia una nuova ClassNotFoundException (nome); } infine {if (in! = null) try {in.close (); } catch (Exception ignore) {}}}} / ** * Questo classloader è in grado di eseguire il caricamento personalizzato solo da una singola directory. * / private EncryptedClassLoader (genitore finale di ClassLoader, classpath file finale) genera MalformedURLException {super (nuovo URL [] {classpath.toURL ()}, genitore); if (parent == null) genera una nuova IllegalArgumentException ("EncryptedClassLoader" + "richiede una delega genitore non null"); } / ** * De / crittografa i dati binari in un dato array di byte. Chiamando di nuovo il metodo * si inverte la crittografia. * / private static void crypt (final byte [] data) {for (int i = 8;i <data.length; ++ i) dati [i] ^ = 0x5A; } ... altri metodi di supporto ...} // Fine della classe

EncryptedClassLoaderha due operazioni di base: crittografare un determinato insieme di classi in una determinata directory del percorso di classe ed eseguire un'applicazione precedentemente crittografata. La crittografia è molto semplice: consiste fondamentalmente nel capovolgere alcuni bit di ogni byte nel contenuto della classe binaria. (Sì, il buon vecchio XOR (OR esclusivo) non è quasi affatto crittografato, ma abbi pazienza. Questa è solo un'illustrazione.)

Il carico di classe di EncryptedClassLoadermerita un po 'più di attenzione. La mia implementazione sottoclasse java.net.URLClassLoadere sovrascrive entrambi loadClass()e defineClass()per raggiungere due obiettivi. Uno è piegare le solite regole di delega del classloader Java 2 e avere la possibilità di caricare una classe crittografata prima che lo faccia il classloader del sistema, e un altro è invocare crypt()immediatamente prima della chiamata defineClass()che altrimenti avviene all'interno URLClassLoader.findClass().

Dopo aver compilato tutto nella bindirectory:

> javac -d bin src / *. java src / mio / segreto / codice / *. java 

I "cifrare" entrambi Maine MySecretClassclassi:

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

Queste due classi binsono state ora sostituite con versioni crittografate e per eseguire l'applicazione originale, devo eseguire l'applicazione tramite EncryptedClassLoader:

> java -cp bin Main Eccezione nel thread "main" java.lang.ClassFormatError: Main (tipo di pool costante non valido) su java.lang.ClassLoader.defineClass0 (metodo nativo) su java.lang.ClassLoader.defineClass (ClassLoader.java: 502) su java.security.SecureClassLoader.defineClass (SecureClassLoader.java:123) su java.net.URLClassLoader.defineClass (URLClassLoader.java:250) su java.net.URLClassLoader.access00 (URLClassLoader.java .:54) net.URLClassLoader.run (URLClassLoader.java:193) su java.security.AccessController.doPrivileged (metodo nativo) su java.net.URLClassLoader.findClass (URLClassLoader.java:186) su java.lang.ClassLoader.loadClass (ClassLoader. java: 299) su sun.misc.Launcher $ AppClassLoader.loadClass (Launcher.java:265) su java.lang.ClassLoader.loadClass (ClassLoader.java:255) su java.lang.ClassLoader.loadClassInternal (ClassLoader.java:315 )>java -cp bin EncryptedClassLoader -run bin Principale decrittografato [Main] decrittografato [my.secret.code.MySecretClass] risultato segreto = 1362768201

Abbastanza sicuro, l'esecuzione di qualsiasi decompilatore (come Jad) su classi crittografate non funziona.

È ora di aggiungere un sofisticato schema di protezione con password, avvolgerlo in un eseguibile nativo e addebitare centinaia di dollari per una "soluzione di protezione del software", giusto? Ovviamente no.

ClassLoader.defineClass (): l'inevitabile punto di intercettazione

Tutti ClassLoaderdevono fornire le loro definizioni di classe alla JVM tramite un punto API ben definito: il java.lang.ClassLoader.defineClass()metodo. L' ClassLoaderAPI ha diversi overload di questo metodo, ma tutti chiamano nel defineClass(String, byte[], int, int, ProtectionDomain)metodo. È un finalmetodo che richiama il codice nativo di JVM dopo aver eseguito alcuni controlli. È importante capire che nessun classloader può evitare di chiamare questo metodo se vuole crearne uno nuovo Class.

Il defineClass()metodo è l'unico luogo in cui Classpuò avere luogo la magia della creazione di un oggetto da un array di byte piatto. E indovina un po ', l'array di byte deve contenere la definizione di classe non crittografata in un formato ben documentato (vedere la specifica del formato del file di classe). Rompere lo schema di crittografia è ora una semplice questione di intercettare tutte le chiamate a questo metodo e decompilare tutte le classi interessanti secondo i desideri del tuo cuore (menziono un'altra opzione, JVM Profiler Interface (JVMPI), più avanti).