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 |