============================================================================== =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- --------------------[ previous ]---[ index ]---[ next ]--------------------- ---------------------[ JAVA REVERSE ENGEENERiNG: LA JVM ]--------------------- ---------------------------------[ LordFelix ]-------------------------------- 0.0 Prefazione 1.0 Piccola introduzione al funzionamento di Java 1.1 Principi di funzionamento della JVM 1.2 I frame 2.0 Tools 3.0 Le istruzioni della JVM: un esempio 4.0 Conclusioni 0.0 Prefazione ---------- Al giorno d'oggi l'intero web e' infestato da una serie infinita di applet java di ogni genere. Spesso mi e' sorta la curiosita' di capire come funzionasse (almeno a grandi linee) l'interprete contenuto in ogni browser di un certo livello (lynx a parte ;). Questo scritto vuole rappresentare solo una prima lettura per chiunque voglia approfondire il funzionamento della Java Virtual Machine (JVM) e dare un'occhiata al codice delle migliori applet presenti in rete. Non e' mia intenzione sostituirmi a quella che e' la "bibbia" della JVM, ovvero il libro "The Java Virtual Machine Specification" di Tim Lindholm e Frank Yellin disponibile on line su http://java.sun.com In questa breve panoramica ho eliminato tutti quegli aspetti che riguardano l'implementazione di una JVM, focalizzando l'attenzione sul set di istruzioni e sugli strumenti disponibili per il reverse engeenering delle classi java. Nel seguito daro' per scontata la conoscenza di almeno i costrutti fondamentali del linguaggio java. Mi scuso in anticipo per eventuali imprecisioni. Vi prego di segnalarmi ogni incongruenza scrivendomi all'indirizzo lordfelix@dislessici.org 1.0 Piccola introduzione al funzionamento di Java --------------------------------------------- A differenza degli altri linguaggi di programmazione per Java lo scopo fondamentale e' funzionare su ogni tipo di hadware che possegga una implementazione della Java Virtual Machine (JVM). In pratica quando compiliamo un programma in Java il .class che otteniamo non e' codificato per il linguaggio macchina del nostro processore, ma e' "tradotto" in una specie di "macrolinguaggio". Ad eseguire il nostro .class non sara', quindi, il processore ma un programma che interpreta i bytecode e trasmette i comandi corrispondenti al processore. Rivediamo la cosa usando un po' di ascii-art: ---------- ------------ ---------- --------- ------- | File | | Compiler | | File | | JVM | | CPU | | .java |-->| |-->| .class |-->| |-->| | ---------- ------------ ---------- --------- ------- chiaramente l'intermediazione della JVM per l'esecuzione dei programmi rende i .class assai piu' lenti del codice nativo. Tuttavia l'obiettivo dei progettisti e' stato quello di ottenere la portabilita' su diverse piattaforme del compilato, e, devo dire, ci sono riusciti. La JVM puo' essere utilizzata indipendentemente dal linguaggio Java vero e proprio. I costrutti del linguaggio sono noti al solo compiler, mentre alla JVM compete la sola esecuzione del macrocodice prodotto da quest'ultimo. Cosi' come gli assembler consentono di programmare direttamente con il codice della CPU allo stesso modo esistono delle applicazioni che consentono di programmare direttamente la JVM. 1.1 Principi di funzionamento della JVM ----------------------------------- Innanzitutto la JVM non include alcun meccanismo di controllo sulla coerenza dei tipi di dati passati alle sue istruzioni. Si presuppone che il controllo sui tipi sia stato gia' effettuato dal compilatore. Possiamo raggruppare i tipi in due categorie: quelli primitivi e i reference. I dati primitivi sono i classici int, long, float... mentre i reference rappresentano *esplicitamente* gli oggetti che utilizza il programma. Oltre ai tipi primitivi numerici sono presenti anche quelli del tipo returnaddress usati dalle istruzioni di controllo come vedremo in seguito. I returnaddress non hanno alcun tipo corrispondente nel linguaggio java ma sono una produzione "esclusiva" del compilatore. Una attenzione particolare merita il tipo boolean che non ha un corrispettivo nell'ambito della JVM e che viene trattato come se fosse un int. Per quanto riguarda i reference, possono "puntare" tre tipi di oggetti: le classi, le intefacce e gli array. Un reference puo' assumere anche il valore null (cioe' nessun oggetto). Come un qualsiasi processore anche la JVM ha un suo insieme di registri. Il registro pc assume lo stesso significato che ha il registro IP nei processori Intel: e' il "program counter" ovvero contiene la posizione della istruzione eseguita in quel momento. Il pc non e' definito se il metodo eseguito dalla JVM e' "nativo", cioe' fa parte del set di metodi messi a disposizione del linguaggio (come quelli delle awt, per esempio) oppure scritti in altri linguaggi. Tali metodi, infatti, vengono eseguiti direttamente dal microprocessore, bypassando la JVM. La lunghezza del registro pc e' una word. Nella JVM e' presente il cosiddetto Java Stack. Esso contene una serie di sottostrutture, dette frame, che contengono le variabili locali, i risultati parziali e le informazioni per le chiamate ai membri di altre classi. Inoltre e' presente un heap in cui vengono memorizzati gli array e le istanze delle varie classi. Assai importante e' una particolare tabella di simboli, detta constant pool. Essa contiene, per ogni classe, il valore delle costanti e i reference ai metodi che la classe usa. 1.2 I frame ------- Analizziamo con maggiore dettaglio questa particolare struttura. Essa viene creata per ogni metodo che viene invocato. In ogni istante e' definito un metodo corrente e il corrispettivo frame. Un frame cessa di essere il frame corrente quando il metodo corrente chiama un altro metodo. Al ritorno della chiamata il frame torna ad essere il "frame corrente". Ogni frame presenta un'area dedicata alle variabili locali e un'altra che funge da stack per gli operandi che di volta in volta sono necessari alle istruzioni della JVM. Le variabili locali vengono memorizzate in un array i cui elementi possono essere richiamati nel piu' classico dei modi: mediante il loro indice. Ogni locazione di questo array e' sempre lunga una word. Per le variabili che occupano piu' word viene riservata piu' di una locazione. Per quanto riguarda lo stack operandi, esso viene usato per passare parametri ai metodi e per riceverne i risultati, nonche' per fornire gli operandi alle istruzioni della JVM (come vedremo in seguito). 2.0 Tools ----- Prima di procedere all'analisi di un file .class vediamo di quali tools abbiamo bisogno. E' inutile dire che avete bisogno del Java Development Kit, necessario per compilare i file .java (http://java.sun.com). Per convertire i file .class nel corrispondente bytecode avete bisogno di un disassembler: per i nostri scopi esistono due programmi: D-Java e Jdis. Il primo e' scritto in c e lo trovate sia per win che per solaris; tuttavia ho notato un certo "impapocchiamento" del programma nell'assegnazione delle label. Non ci resta che usare Jdis che e' scritto in java ed e' quindi eseguibile in tutte le piattaforme con una JVM. Ho stretto la cerchia dei disassembler a d-java e jdis perche' consentono di avere l'output nel formato di jasmin; jasmin e' un assemblatore che, ricevuto il bytecode in ingresso lo trasforma in un file .class. Infine, ma non ultimi, troviamo due decompilatori veri e propri: jad e mocha. Jad e' scritto in c ed e' reperibile in formato .exe per dos; mocha e' invece scritto in java. Entrambi producono, a partire da un file .class il corrispondente .java . Tutti questi "attrezzi" li trovate sul sito http://Meurrens.ML.org/ip-Links/Java/codeEngineering/ 3.0 Le istruzioni della JVM: un esempio ----------------------------------- Introdurremo ora le varie istruzioni della JVM ricorrendo ad un esempio significativo. Analizzeremo l'applet NervousText fornita nei demo del JDK nella dir /demo/NervousText. Abbiamo gia' a disposizione il file .class quindi non ci resta che disassemblarlo per carpirne i segreti. Dato che e' molto interessante capire la corrispondenza bytecode/istruzioni java utilizzeremo il jad usando il parametro -a (annotate) che pone le istruzioni della JVM come commento alle linee di codice ricostruito: jad -a NervousText.class Quello che otteniamo e' un file di testo con estensione .jad che contiene sorgente e assembly JVM. Analizziamolo. In testa al sorgente troviamo il solito e familiare "preambolo" in cui vengono dichiarate le classi java necessarie al corretto funzionamento dell'applet: import java.applet.Applet; import java.awt.*; import java.awt.event.*; public class NervousText extends Applet implements Runnable, MouseListener { Vengono poi definiti i membri della classe NervousText. Da questo punto in poi jad comincia a sfornare anche il codice della JVM. public void init() { banner = getParameter("text"); // 0 0:aload_0 // 1 1:aload_0 // 2 2:ldc1 #6 <String "text"> // 3 4:invokevirtual #31 <Method String Applet.getParameter(String)> // 4 7:putfield #25 <Field String banner> Nelle prime tre righe abbiamo il codice sorgente mentre nei commenti sono presenti le istruzioni JVM che lo implementano. Vediamo cosa significano e come operano. aload_0 Viene posto in cima allo stack operandi il riferimento alla classe della variabile locale numero 0. In questo caso tale variabile e' Banner ma di essa viene "pushato" solo l'informazione "banner e' una stringa" e non il valore o l'area di memoria ad essa associata. L'istruzione viene eseguita due volte e vedremo fra poco il perche'. ldc1 #6 Viene pescato l'elemento numero 6 del "constant pool". Il constant pool puo' essere visto come un "array" in cui il compilatore java memorizza le costanti usate dal programma (ma non solo). In questo caso il numero 6 corrisponde alla stringa "text" che viene posta in testa allo stack. invokevirtual #31 Ecco l'istruzione che piu' spesso ricorre nell'assembly JVM: invokevirtual. Essenzialmente "chiama" il metodo numero 31 che in questo caso corrisponde alla getParameter della classe applet. Anche questa volta il numero 31 fa riferimento ad una posizione del constant pool che contiene il nome del metodo da "invocare". Inoltre, sempre nel campo 31 del constant pool e' indicato il numero di argomenti necessari alla funzione. Essi devono risiedere nello stack secondo l'ordine: argomento n argomento n-1 ... argomento 1 riferimento all'oggetto che contiene il risultato ... Quindi, appena dopo gli argomenti, in cima allo stack va posto il riferimento all'oggetto che deve contenere il risultato dell'elaborazione del metodo. Nel caso specifico prima di invoke virtual abbiamo la seguente situazione nello stack: riferimento alla stringa "text" riferimento ad un oggetto stringa riferimento ad un oggetto stringa ... dopo l'esecuzione della invokevirtual lo stack conterra' il solo risultato della getParameter (oggetto stringa) riferimento ad un oggetto stringa. In pratica il riferimento "consumato" dalla invoke e' servito a determinare solo il tipo di valore in uscita, in accordo con quello che abbiamo detto in occasione della aload_0. E' importante notare che l'assegnamento del risultato alla variabile Banner non e' ancora avvenuto. putfield #25 Finalmente viene prelevato il risultato della getParameter e viene memorizzato nella posizione 25 del constant pool. Il tipo dell'oggetto memorizzato al 25 viene determinato in base al rifermento successivo al risultato. Nel nostro caso alla fine dell'operazione lo stack e' vuoto. E' per questo motivo che l'aload iniziale e' stato ripetuto due volte. Da queste prime istruzioni si nota subito la cruciale importanza del constant pool e del livello tutto sommato alto del bytecode. Gli oggetti e i metodi vengono trattati in quanto tali e non vengono scomposti in tipi piu' elementari come accade nei compilatori normali. Proseguiamo nell'analisi... if(banner == null) //* 5 10:aload_0 //* 6 11:getfield #25 <Field String banner> //* 7 14:ifnonnull 23 Di aload abbiamo gia' parlato: un riferimento al tipo stringa viene posto in cima allo stack. getfield #25 Si tratta dell'istruzione duale a putfield. Nel nostro caso lo stack contiene unicamente il riferimento ad un oggetto stringa ... dopo il getfield viene preso il campo #25 del constant pool e il suo valore viene trattato come una instanza della classe stringa: lo stack conterra' il risultato della getfield: valore dell'oggetto stringa #25 ... ifnonnull 23 Ecco la prima istruzione di controllo che incontriamo. Il suo uso e' intuitivo. Controlla che il valore in cima allo stack non sia null e se non lo e' salta alla linea 23 altrimenti prosegue nell'esecuzione. Come e' prassi il valore in cima allo stack (utilizzato nel controllo) viene "poppato" via. Chi ha un po' di esperienza nei linguaggi di alto livello notera' una certa somiglianza col costrutto if...goto. Tutti i costrutti if..then..else.. vengono "compilati" ricorrendo all'uso di if...goto e goto semplici. Nel nostro caso il ramo "then" e' costituito dalle istruzioni dalla 17 alla 20: banner = "HotJava"; // 8 17:aload_0 // 9 18:ldc1 #1 <String "HotJava"> // 10 20:putfield #25 <Field String banner> Da queste linee si nota che il ramo then non e' altro che un assegnamento di una costante alla stringa banner. In particolare viene assegnata la stringa contenuta nella posizione #1 del costant pool all'oggetto contenuto nella posizione #25 (che abbiamo visto essere banner). int i = banner.length(); // 11 23:aload_0 // 12 24:getfield #25 <Field String banner> // 13 27:invokevirtual #32 <Method int String.length()> // 14 30:istore_1 Le prime tre istruzioni si incaricano di richiamare il metodo length dell'oggetto banner (in realta' viene invocato il metodo length della classe string che agisce sul valore in testa allo stack). In questo caso la definizione della variabile i e' associata alla sua dichiarazione istore_1 Memorizza il valore in testa allo stack nella variabile locale numero 1. Inoltre specifica che tale variabile e' un intero. Come sempre il valore in testa allo stack viene eliminato. bannerChars = new char[i]; // 15 31:aload_0 // 16 32:iload_1 // 17 33:newarray char[] // 18 35:putfield #26 <Field char[] bannerChars> Viene ora dichiarato un array di caratteri. La chiave dell'operazione e' iload_1 Viene caricato sullo stack il riferimento alla variabile locale intera posta nella posizione numero 1 del frame. Si tratta della i usata per determinare la lunghezza dell'array che si sta per creare. newarray char[] Crea un array di caratteri di lunghezza pari al valore intero che si trova in cima allo stack. Naturalmente e' possibile creare array di altri tipi. Dopo l'esecuzione nello stack troviamo un reference al nuovo array. Il tipo di array da creare viene determinato a partire dal byte successivo all'opcode di newarray, secondo la tabella: Array Type | atype ------------------ T_BOOLEAN 4 T_CHAR 5 T_FLOAT 6 T_DOUBLE 7 T_BYTE 8 T_SHORT 9 T_INT 10 T_LONG 11 putfield #26 Il riferimento al nuovo array va a occupare la posizione #26 del constant pool. banner.getChars(0, banner.length(), bannerChars, 0); // 19 38:aload_0 // 20 39:getfield #25 <Field String banner> // 21 42:iconst_0 // 22 43:aload_0 // 23 44:getfield #25 <Field String banner> // 24 47:invokevirtual #32 <Method int String.length()> // 25 50:aload_0 // 26 51:getfield #26 <Field char[] bannerChars> // 27 54:iconst_0 // 28 55:invokevirtual #30 <Method void String.getChars(int, int, char[], int)> La serie di istruzioni prima dell'invoke finale serve a ricostruire i parametri da passare al metodo getChars della classe string. Considerando quando detto in precedenza non dovrebbe essere difficile rendersi conto di come i vari parametri si avvicendano nello stack. L'unica novita' e' la iconst_0 Inserisce la costante intera 0 in cima allo stack. Esistono diverse alternative per questa istruzione: Istruzione | Costante associata ------------------------------- iconst_m1 -1 iconst_0 0 iconst_1 1 iconst_2 2 iconst_3 3 iconst_4 4 iconst_5 5 Non esistono altri tipi di comandi iconst. threadSuspended = false; // 29 58:aload_0 // 30 59:iconst_0 // 31 60:putfield #42 <Field boolean threadSuspended> Ecco un semplice assegnamento: notiamo che il valore false e' in realta' l'intero 0. resize(15 * (i + 1), 50); // 32 63:aload_0 // 33 64:bipush 15 // 34 66:iload_1 // 35 67:iconst_1 // 36 68:iadd // 37 69:imul // 38 70:bipush 50 // 39 72:invokevirtual #37 <Method void Applet.resize(int, int)> Prima della chiamata alla funzione resize il compilatore produce una serie di istruzioni necessarie a valutare le espressioni passate come argomenti di resize. bipush 15 Bipush inserisce un byte nello stack, nel caso particolare 15 iadd Somma due interi in cima allo stack e il risutato e' posto ancora nello stack. imul Funziona come iadd ma esegue la moltiplicazione Anche in questo caso e' di fondamentale inportanza seguire i valori che si susseguono nello stack (non mi stanchero' mai di dirlo). setFont(new Font("TimesRoman", 1, 36)); // 40 75:aload_0 // 41 76:new #11 <Class Font> // 42 79:dup // 43 80:ldc1 #3 <String "TimesRoman"> // 44 82:iconst_1 // 45 83:bipush 36 // 46 85:invokespecial #23 <Method void Font(String, int, int)> // 47 88:invokevirtual #39 <Method void Component.setFont(Font)> new #11 Crea un nuovo oggetto, in questo caso della classe nella posizione 11 del constant pool. Nello specifico si tratta di un oggetto font. Dopo la creazione e' necessario richiamare il costruttore dell'oggetto con i propri parametri. Dove devono risiedere questi parametri?? Ma nello stack naturalmente! Cosi' si spiega il dup che duplica tutto cio' che si trova in testa allo stack (in questo caso il reference all'oggetto font appena creato) e la serie di aggiustamenti prima della chiamata al costruttore font() mediante invokespecial #23 E' utilizzato, con le stesse regole di invokevirtual, per richiamare il costruttore di un'istanza di una classe. In particolare la posizione 23 del constant pool contiene l'inizializzatore della classe font. addMouseListener(this); // 48 91:aload_0 // 49 92:aload_0 // 50 93:invokevirtual #24 <Method void Component.addMouseListener(MouseListener)> // 51 96:return Questa procedura informa la JVM che l'applet intercetta gli eventi relativi al mouse. Da notare come il riferimento alla variabile locale 0 venga usato anche come riferimento all'oggetto this (cioe' all'istanza della stessa classe che si sta definendo). return Termina il metodo ed elimina tutto cio' che e' contenuto nel frame associato al metodo. Tipicamente si usa quando il metodo ha alcun parametro in uscita. public void destroy() { removeMouseListener(this); // 0 0:aload_0 // 1 1:aload_0 // 2 2:invokevirtual #35 <Method void Component.removeMouseListener(MouseListener)> // 3 5:return } Il distruttore si limita a rilasciare l'intercettazione degli eventi del mouse. Da sottolineare l'uso del reference this. public void start() { runner = new Thread(this); // 0 0:aload_0 // 1 1:new #20 <Class Thread> // 2 4:dup // 3 5:aload_0 // 4 6:invokespecial #22 <Method void Thread(Runnable)> // 5 9:putfield #38 <Field Thread runner> runner.start(); // 6 12:aload_0 // 7 13:getfield #38 <Field Thread runner> // 8 16:invokevirtual #41 <Method void Thread.start()> // 9 19:return } Nulla da dire. Dovreste essere in grado di decifrare il bytecode di questo metodo. Se non ci riuscite... beh... rileggete dall'inizio :D public synchronized void stop() { runner = null; // 0 0:aload_0 // 1 1:aconst_null // 2 2:putfield #38 <Field Thread runner> if(threadSuspended) //* 3 5:aload_0 //* 4 6:getfield #42 <Field boolean threadSuspended> //* 5 9:ifeq 21 { threadSuspended = false; // 6 12:aload_0 // 7 13:iconst_0 // 8 14:putfield #42 <Field boolean threadSuspended> notify(); // 9 17:aload_0 // 10 18:invokevirtual #33 <Method void Object.notify()> } // 11 21:return } Questo stralcio di codice ci consente di esaurire il discorso sulle varianti dell'if nel set di istruzioni della JVM: ifeq 21 Se il contenuto dello stack e' nullo salta all'istruzione 21. Le istruzioni if<cond> operano su interi secondo la tabella: ifeq salta solo se il valore sullo stack e' 0 ifne salta solo se il valore sullo stack e' non 0 iflt salta solo se il valore sullo stack e' minore di 0 ifle salta solo se il valore sullo stack e' minore o uguale a 0 ifgt salta solo se il valore sullo stack e' maggiore di 0 ifge salta solo se il valore sullo stack e' maggiore o uguale a 0 Esiste anche una variante che opera comparando i due interi in cima allo stack (chiamiamoli int1 e int2): if_icmpeq salta se e solo se int1 e' uguale a int2 if_icmpne salta se e solo se int1 e' diverso da int2 if_icmplt salta se e solo se int1 e' minore di int2 if_icmple salta se e solo se int1 e' minore o uguale a int2 if_icmpgt salta se e solo se int1 e' maggiore di int2 if_icmpge salta se e solo se int1 e' maggiore o uguale a int2 Un'ulteriore situazione prevede l'uso di if_acmpeq e if_acmpne che operano sui reference. Chiaramente in questo caso non sono previste le relazioni d'ordine. E' importante ricordare che gli operandi vengono rimossi dallo stack. Il decompilato di NervousText continua, ma a questo punto dovreste essere in grado di seguirlo da soli. Prima di concludere vi accludo il quadro sinottico in cui sono riportati i codici memonici di ogni opcode a seconda del tipo di dato su cui esso opera. Per la maggior parte li abbiamo visti gia' in azione su determianti tipi. Per gli altri vi rimando alla bibbia ;) opcode |byte |short |int |long |float |double |char |reference ------------------------------------------------------------------------------------- Tipush bipush sipush Tconst iconst lconst fconst dconst aconst Tload iload lload fload dload aload Tstore istore lstore fstore dstore astore Tinc iinc Taload baload saload iaload laload faload daload caload aload Tastore bastore sastore iastore lastore fastore dastore castore aastore Tadd iadd ladd fadd dadd Tsub isub lsub fsub dsub Tmul imul lmul fmul dmul Tdiv idiv ldiv fdiv ddiv Trem irem lrem frem drem Tneg ineg lneg fneg dneg Tshl ishl lshl Tshr ishr lshr Tushr iushr lushr Tand iand land Tor ior lor Txor ixor lxor i2T i2b i2s i2l i2f i2d l2T l2i l2f l2d f2T f2i f2l f2d d2T d2i d2l d2f Tcmp lcmp Tcmpl fcmpl dcmpl Tcmpg fcmpg dcmpg if_TcmpOP if_icmpOP if_acmpOP Treturn ireturn lreturn freturn dreturn areturn 5.0 Conclusioni ----------- Come da piu' parti e' stato rilevato i tecnici della sun che hanno progettato java hanno badato esclusivamente alla portabilita' e alla sicurezza della JVM ma non hanno tenuto in conto gli interessi degli sviluppatori. Se e' vero che e' possibile "incasinare" il meccanismo di decompilazione e "offuscare" i nomi dei metodi e delle variabili e' anche vero che pubblicare un .class equivale a fornirne il sorgente. E' sintomatico il fatto che una delle piu' interessanti applicazioni scritte in java (il programma JavaZip 2.0) e' facilmente decompilabile con lo jad e addirittura banale da sproteggere. Per quel poco di esperienza che ho posso affermare che almeno il 90% delle applicazioni sono pienamente decompilabili, un 5% ha come unica protezione l'"offuscamento" dei simboli e l'altro 5% mette in difficolta' i decompilatori. Tuttavia e' sempre possibile disassemblare una applet e, data la "potenza" del set di istruzioni JVM, e' molto facile esaminare e modificare il programma con un approccio molto simile al death-listing. Bene... con questo mi sembra di aver esaurito questa breve guida. Un consiglio: nel momento in cui decidete di cominciare a scrivere applicazioni java tenete bene in mente questo punto debole. LordFelix [Dislessici] --------------------[ previous ]---[ index ]---[ next ]--------------------- =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- ==============================================================================