MokaByte 56 - 8bre  2001
foto dell'autore non dispnibile
Proteggersi dalla
decompilazione del bytecode
di
Paolo
Mascia
Proteggere il codice dalla decompilazione non è un obbiettivo facilmente raggiungibile. Tuttavia adottando  degli espedienti opportuni, si può limitare l’operazione di decompilazione da parte di utenti meno esperti, o almeno si può complicare la vita dei novelli hacker.

A differenza degli altri linguaggi di programmazione lo scopo fondamentale di Java è quello di  funzionare su ogni tipo di hadware dotato di una implementazione della Virtual Machine. In pratica quando compiliamo un programma Java, il .class che otteniamo non e' codificato nel linguaggio macchina di uno specifico  processore, ma è "tradotto" in una specie di "macrolinguaggio". Ad eseguire il nostro .class non sara', quindi, il processore ma un programma che interpreta i bytecode ed esegue le istruzioni  codificate. 

Analogamente a quanto avviene  per qualsiasi altro linguaggio di programmazione,  il codice generato dalla compilazione  può sempre essere disassemblato. Tuttavia, i file .class creati dal compilatore Java, e destinati ad una macchina virtuale, conservano un numero di informazioni relative al codice sorgente molto maggiore rispetto ad un eseguibile tradizionale. 
Questo facilita la realizzazione di programmi che consentono un processo di reverse engineering molto accurato, che va ben oltre il processo di disassemblamento.  Esistono, infatti in rete svariati programmi sia freeware che commerciali, che consentono la decompilazione vera e propria dei class files. Questi programmi sono in grado di ricreare un codice sorgente che differisce veramente di poco da quello originario.
 
 
 

Offuscare il codice
Per arginare la piaga della decompilazione si ricorre generalmente all’offuscamento del codice. Questa tecnica consiste nel complicare il codice rendendone più difficile la comprensione degli algoritmi. 
Tipico esempio è la modifica dei nomi delle variabili e dei metodi con nomi senza senso, o la sostituzione di operazioni semplici con altre molto complesse. 
Consideriamo come esempio il seguente frammento di codice Java che calcola l’area di un quadrato:

int lato ;

public int area() 
{ b = lato ;
  return ( b * b ) ;
}

La  semplice assegnazione iniziale può essere complicata applicando il principio che la somma dei numeri tra 0 e n (incluso n) è n*(n+1)/2. In base a questa,  l’istruzione lato = b può essere riscritta nel seguente modo: 

int b = 0;
for (int i=0; i<= lato; i++) {

 b += i * lato;
}
b /= (lato * (lato + 1)) >> 1;

Basandoci poi sulla proprietà che  shiftando un numero a sinistra di un bit equivale a moltiplicarlo per due e shiftandolo a destra equivale a dividerlo per due, possiamo moltiplicare un numero per un qualsiasi valore utilizzando una combinazione di addizioni e di shift. Vale, cioè, un equazione del tipo :

x * 12 =  (x<<3) + (x<<2) 

Volendo complicare l’ultima istruzione, possiamo scrivere: 

return ( b * b (13-12) ) ; 

ottenendo il seguente codice :

return (b  * ((13*b) - (b << 4) - (b << 2)) ) ;

A questo punto sostituiamo il nome del metodo calcolaArea con un altro che non possa aiutare a comprendere l’algoritmo. 
In particolare possiamo utilizzare dei nomi costituiti da  una sequenza di caratteri  casuali o sostituire gli stessi con un  numero differente di caratteri di sottolineatura. 
Riassumendo il codice finale diviene:

int ___ ;

public int ______() ;

int b = 0;
for (int i=0; i<= ___; i++) 
{
 b += i * ___;
}
b /= ( ___ * ( ___ + 1)) >> 1;

return (b  * ((13*b) - (b << 4) - (b << 2)) ) ;

}

Come si può notare anche se l’algoritmo è banale, una volta effettuata l’operazione di offuscamento, la comprensione ne risulta molto più difficile. 
Anche se abbiamo visto come realizzare manualmente questo tipo di protezione è molto più semplice ricorrere ad uno degli svariati tool che realizzano queste operazioni in maniera automatica, e soprattutto agendo direttamente sul codice già compilato. Tuttavia, così come gli altri metodi che esamineremo, l’offuscamento non costituisce una protezione reale del codice, ma semplicemente un ostacolo che può scoraggiare l’operazione di reverse engineering.
 
 
 

Sfruttare i bug dei decompilatori
Un’altra tecnica adoperata per proteggersi dalla decompilazione è quella di modificare il bytecode dei class file  in maniera tale non comprometterne la funzionalità ma generare degli errori nei programmi di decompilazione. 
Come esempio consideriamo il seguente programma Java. 

public class Test 

public static void main(String args[] ) 
{
  System.out.println("Test di decompilazione."  );
    }
}

Il file Test.class generato può essere  facilmente decompilato da Mocha, uno dei primi programmi di reverse engeenering apparsi nel web. Il codice ottenuto dalla decompilazione è il seguente:

/* Decompiled by Mocha from Test.class */
/* Originally compiled from Test.java */

import java.io.PrintStream;

public synchronized class Test
{
    public Test()
    {
    }

    public static void main(String astring[])
    { System.out.println("Test di decompilazione.");
    }

Effettuiamo ora una piccola modifica direttamente al bytecode del file class utilizzando un  disassemblatore e un assemblatore Java.  Come disassemblatore utilizziamo d-java disponibile sul sito http://www.cat.nyu.edu/meyer/jvm/djava.
Disassemblando il file della classe Test con il comando

d-java –o jasmin Test.class >Test.j 

otteniamo il seguente codice:
 

;
; Output created by D-Java (mailto:umsilve1@cc.umanitoba.ca)
;

;Classfile version:
;    Major: 45
;    Minor: 3

.source Test.java
.class  public synchronized Test
.super  java/lang/Object

; >> METHOD 1 <<
.method public <init>()V
    .limit stack 1
    .limit locals 1
.line 1
    aload_0
    invokenonvirtual java/lang/Object/<init>()V
    return
.end method

; >> METHOD 2 <<
.method public static main([Ljava/lang/String;)V
    .limit stack 2
    .limit locals 1
.line 5
    getstatic java/lang/System/out Ljava/io/PrintStream;
    ldc "Test di decompilazione."
    invokevirtual java/io/PrintStream/println(Ljava/lang/String;)V
.line 6
    return 
.end method

A questo punto possiamo modificare il bytecode  ottenuto aggiungendo una istruzione dopo l’ultimo return.


return
pop
.end method 

L’istruzione dopo il return non è mai raggiungibile, quindi non ha nessun effetto sul codice finale, ma d'altronde  non ha neanche senso. 
Compiliamo ora il file modificato  tramite l’assemblatore java Jasmin disponibile sul sito http://cat.nyu.edu/meyer/jasmin.
La nuova classe così ottenuta funziona  correttamente, tuttavia se proviamo ad eseguire nuovamente la decompilazione, otteniamo da Mocha una eccezione del tipo OutOfBoundException. 
Questo espediente, da noi realizzato manualmente è quanto effettua in automatico il noto  programma di protezione del codice Crema.

Non è comunque necessario ricorrere direttamente alla modifica del bytecode per fermare i decompilatori.
Sulla base che questi ultimi svolgono comunque un compito complesso, partiamo dal presupposto che presentino sempre dei bug e cerchiamo di individuarli e sfruttarli. 
Se il decompilatore è un eseguibile, realizzato in C o in un altro linguaggio compilato, possiamo tentare di sfruttare uno dei principali punti deboli di tali linguaggi: gli overflow. 
Una prima idea che viene in mente, analizzando il linguaggio Java, e che quest’ultimo  non pone limiti alla lunghezza dei  nomi delle variabili. Effettuiamo quindi questo  semplice test: inseriamo una variabile membro, all’interno della classe da proteggere con un nome decisamente lungo.

public class TestClass

     // Variabile di protezione dalla decompilazione.
     //  Il nome è costituito da 4K caratteri  ;
     int xxxxxxxxxxxxxxxxxxxxxxx………………xxxxxx ;

     // Metodi della classe

Effettuiamo quindi il test con i 6 decompilatori seguenti:

- DJ versione 2.3.3.38 
- jad versione 1.5.8
- Mocha versione 1
- Jascii 1.0.12
- NMI’s versione 5.1
- Decafe Pro 3.6

Come era auspicabile, uno dei decompilatori, il DJ, va in crash.
Proviamo quindi con un secondo espediente, che tra l’altro provoca un incremento del codice di poco superiore al Kbyte. Inseriamo, all’interno della classe da proteggere, un metodo inutile che dichiara 513 variabili locali e ripetiamo il test.

public void metodoDiProtezione()
{  int A000 = 0 ;
   int A001 = 0 ;
   ……
   ……
   int A512 = 0 ; 
}

Questa volta tutti i decompilatori  ad eccezione di Mocha e Jascii, scritti interamente in Java, si  bloccano. E’ possibile, quindi, ottenere una grossolana protezione del nostro codice semplicemente inserendo un metodo fittizio nella nostra classe.

Questi espedienti, anche se efficaci, sono basati sui bug dei singoli decompilatori, quindi possono non funzionare con versioni differenti dello stesso decompilatore o contro svariati tentativi di decompilazione con programmi diversi. 
 
 
 

Criptare le classi
Così come i metodi precedenti, la soluzione di criptare le classi  Java costituisce sicuramente un ostacolo alla decompilazione, ma non può considerarsi un metodo di protezione totale. 
Infatti, per poter essere eseguito dalla Java Virtual Machine un programma deve comunque essere convertito in un bytecode in chiaro. Sia la chiave di codifica che l’algoritmo, per quanto ben nascosti, devono quindi trovarsi sulla macchina che  esegue  il codice.  Un qualsiasi malintenzionato può così decifrare l’algoritmo di codifica, disassemblando o decompilando il codice, e decodificare tutte le classi.
Nonostante ciò, criptare le classi Java risulta essere il metodo più flessibile per proteggere il proprio lavoro. Infatti se si protegge ad arte il codice dell’algoritmo di decodifica, utilizzando le  tecniche analizzate o realizzando lo stesso con metodi nativi, si rende sicuramente laborioso il processo di decompilazione.
Vediamo quindi come si realizza un programma in grado di utilizzare delle classi criptate. 
 
 
 

Un ClassLoader di classi criptate
Prima che la JVM possa eseguire un progamma Java, è necessario che essa trovi e carichi in memoria tutte le classi che costituiscono l’applicazione.
In un ambiente di esecuzione tradizionale, questo servizio è effettuato dal sistema operativo che carica il codice dal file system in un  modo dipendente dalla piattaforma.  Il sistema operativo ha accesso a tutte le funzioni di I/O a basso livello e dispone di un set di operazioni per ricercare all’interno del file system i programmi o le librerie condivise. 
Nel Java Runtime Environment le cose sono complicate del fatto che non tutti i file delle classi sono caricati dallo stesso tipo di locazione. In generale le classi possono essere divise in tre categorie:

  • Le classi che formano le API Java. Queste sono le classi distribuite con la JVM e sono parte delle specifiche Java. Per questo motivo sono delle classi particolarmente fidate e non sono soggette allo stesso grado  di esame delle classi caricate da una sorgente esterna.
  • Le classi installate nel file system locale. Anche se non fanno parte del set delle classi Java, queste classi sono assunte essere sicure in virtù del fatto che l’utente le ha installate e ha accettato i possibili rischi. Queste classi sono trattate in molti casi alla stessa stregua delle API  Java.
  • Le classi caricate da altre sorgenti. Ad esempio, In un browser Web, queste potrebbero essere le classi che costituiscono una applet scaricata da un server remoto. Queste classi sono le  meno fidate di tutte, poiché sono caricate all’interno dell’ambiente sicuro della Virtual Machine da una sorgente potenzialmente ostile e senza l’esplicito consenso dell’utente. Per questa ragione, queste classi devono essere soggette ad un alto grado di controllo prima di essere rese disponibili per l’esecuzione.
Data la varietà di sorgenti e il differente tipo di controllo richiesto è chiaro che esistono diversi meccanismi per caricare le classi, ottenuti estendendo la classe ClassLoader delle API java.
Un utente può, quindi, implementare un suo caricatore di classi personalizzato per caricare il codice da particolari locazioni o per realizzare dei controlli  più rigorosi di quelli normalmente utilizzati per le classi provenienti da fonti fidate.

Nel nostro caso realizzeremo  un class loader che si occuperà di leggere dei class file criptati. Il loader, dopo aver letto il file, lo sottoporrà al processo di decodifica e provvederà a  passarlo alla virtual machine. 
La seguente classe  si occuperà poi di istanziare il nostro class loader, di caricare la classe specificata come parametro dalla linea di comando, e di invocarne il metodo main.

public class CryptoLoader 

public static void main(String[] args)
{   try
  { if (args.length==0)
{ System.out.println("Usare: Laoder <nome classe> [<elenco
parametri>]") ;
       System.exit(0) ;
     }

    String[] appArgs = new String[args.length-1] ;
    System.arrayCopy(args,1,appArgs,0,args.length-1) ;

    ClassLoader loader = new CryptoClassLoader();
    Class c  = loader.loadClass(args[0]);

    Method m = c.getMethod("main", new Class[] 
{ appArgs.getClass() 
} );
    m.invoke(null, new Object[] { appArgs });
    }
    catch (Throwable e)
    {  System.out.println(e) ;
    }
}

Per realizzare il caricatore di classi dobbiamo estendere la classe ClassLoader ed implementare il metodo loadClass.
Il metodo deve eseguire le seguenti operazioni:

  • verificare se la classe è già stata caricata
  • se la classe non è stata caricata, verificare se si tratta di una classe di sistema. In caso contrario, caricare la classe ed effettuare dal decodifica
  • chiamare il metodo defineClass della superclasse ClasseLoader per trasferire i bytecode alla macchina virtuale


class CryptoClassLoader extends ClassLoader

  private Map classes = new HashMap();

   protected synchronized Class loadClass(String name, boolean
    resolve) throws ClassNotFoundException
    { // verifichiamo  se la classe 
      Class cl = (Class)classes.get(name);

      if (cl == null) // new class
      {  try
         {  // check if system class
            return findSystemClass(name);
         }
         catch (ClassNotFoundException e) {}
         catch (NoClassDefFoundError e) {}

         // load class bytes--details depend on class loader

         byte[] classBytes = loadClassBytes(name);
         if (classBytes == null)
            throw new ClassNotFoundException(name);

         cl = defineClass(name, classBytes,
            0, classBytes.length);
         if (cl == null)
            throw new ClassNotFoundException(name);

         classes.put(name, cl); // remember class
      }

      if (resolve) resolveClass(cl);

      return cl;
   }

   private byte[] loadClassBytes(String name)
   {  String cname = name.replace('.', '/') + ".crypt";
      FileInputStream in = null;
      try
      {  in = new FileInputStream(cname);
         ByteArrayOutputStream buffer = new 
        ByteArrayOutputStream();

         // Leggiamo il file e lo decodifichiamo copiando i bytes
         // in buffer 

      // Implementare qui il codice di decodifica
      //

      …………
      …………

         in.close();
         return buffer.toByteArray();
      }
      catch (IOException e) 
   { 
       …… 
       return null ;
    }
  }
 
 
 

Conclusioni
Proteggere il codice dalla decompilazione non è un obbiettivo facilmente raggiungibile e tutte le soluzioni esaminate presentano dei punti deboli. Tuttavia, gli espedienti visti, sicuramente complicano l’operazione, sin troppo banale,  di decompilazione, scoraggiando almeno gli hacker meno esperti. 
 
 
 

Bibliografia
[1] Cay S.Horstman, Gary Cornell  - Java2 Tecniche Avanzate - Mc Graw Hill 
[2] Mark Wutka  - Java Expert Solutions 
[3] Tim Lindholm, Frank Yellin  - The Java Virtual Machine Specification 

I tools menzionati  in questo articolo sono disponibili ai seguenti indirizzi:

[1] jasmin - http://cat.nyu.edu/meyer/jasmin
[2] d-java - http://www.cat.nyu.edu/meyer/jvm/djava
[3] DecafèPro - http://decafe.hypermart.net
[4] DJ - http://members.fortunecity.com/neshkov/dj.html
[5] NMI’s – http://njcv.htmlplanet.com
[6] Jascii - http://www.jascii.com
[7] Mocha - http://www.inter.nl.net/users/H.P.van.Vliet/mocha.htm
[8] Jad - http://www.geocities.com/kpdus/jad.html

Vai alla Home Page di MokaByte
Vai alla prima pagina di questo mese


MokaByte® è un marchio registrato da MokaByte s.r.l.
Java®, Jini®  e tutti i nomi derivati sono marchi registrati da Sun Microsystems; tutti i diritti riservati
E' vietata la riproduzione anche parziale
Per comunicazioni inviare una mail a info@mokabyte.it