CAPITOLO 4: BASI DI ASSEMBLY x86
Transcript
CAPITOLO 4: BASI DI ASSEMBLY x86
CAPITOLO 4: BASI DI ASSEMBLY x86 Un giorno a qualcuno venne in mente di creare un processore 8086, che si è poi evoluto ed è diventato il più veloce 80286, meglio conosciuto come 286, che a sua volta ha lasciato spazio al 386 che a sua volta ha dovuto lasciare spazio al 486 (66 MHz) e poi è cominciata la serie Pentium (il mio vecchio 133...), fino ad arrivare al Pentium IV (che va a 3,6 GHz – un pochino di più).Un processore esegue un algoritmo (o programma) e fa solo questo per tutta la sua vita. Questo programma gli viene passato con un linguaggio di basso livello comprensibile (solo) al processore e viene interpretato ed eseguito: questo linguaggio, almeno per quanto riguarda le istruzioni base, è ancora quello del vecchio 8086 e comunque ogni programma compilato, dal C o da qualsiasi linguaggio di alto livello (ovvero comprensibile al programmatore), contiene istruzioni scritte in linguaggio macchina, tanto è vero che se apriamo col notepad un file exe (eseguibile) ci capiamo poco o niente. Ora scarichiamo un programma che si chiama disassemblatore (disassembler), il quale traduce in un linguaggio “più comprensibile” (questo è tutto da vedere) il programma compilato: questo linguaggio è l’assembly (e – diciamo – il compilatore è detto assembler). Scrivere un programma in linguaggio macchina non era semplice e il primo linguaggio di programmazione di alto livello è stato appunto l’assembly. Prima di passare del linguaggio però c’è qualcosa che dovete sapere. Prima di tutto, spero conosciate le basi: la base 2 (numeri binari), la base 16 (numeri esadecimali), ma anche la base 8 (numeri ottali)... spero sappiate almeno la base 10... NOTA: Io non vi spiegherò come si fa un programma in assembly, ma vi fornirò le basi necessarie per capire cosa fa un programma, nel caso molto frequente di doverlo decompilare ed esaminare, per esempio per crackarlo o per trovare un possibile exploit. CICLO DEL PROCESSORE Un processore dopo il bootstrap (ovvero una fase di avvio) entra in un ciclo a cui si pone fine solo con lo spegnimento. Questo ciclo è detto ciclo del processore e in linee generali è il seguente Nella fase di FETCH prende la prossima istruzione (incrementando un registro detto PC, Program Counter) e poi la esamina e ne carica gli operandi nella fase detta OPERAND ASSEMBLY ed infine la esegue nella fase di EXECUTE e poi va alla prossima istruzione. Però in questo modo potremmo eseguire solo un programma per volta e, per esempio, dover aspettare ogni volta che termini la lettura/scrittura da/a una periferica. Questo inconveniente è ovviato dalle interruzioni. Dopo EXECUTE c’è un’ulteriore controllo di interruzione: se si è verificato un evento, ad esso è associata un’interruzione (con una priorità pre-assegnata). Se si verifica un’interruzione, viene richiamata la rispettiva funzione (da qualche parte in memoria) e si passa alla sua esecuzione, sospendendo la precedente, se naturalmente ha una precedenza maggiore. REGISTRI Il processore oltre alla memoria RAM (o memoria centrale), ha a disposizione una serie di registri dove memorizza il suo stato (istruzione corrente, contatore di istruzioni, ...) e i dati che devono essere elaborati. Con i registri inoltre esso si interfaccia alle periferiche e alla memoria centrale stessa (la RAM per intenderci). - REGISTRI DI USO GENERALE: AX, BX, CX, DX sono i tipici registri di utilizzo generale a 16 bit, utilizzati per memorizzare operandi e indirizzi di memoria. Nei moderni processori essi rappresentano i primi 16 bit (bassi) dei rispettivi registri a 32 bit (usati nelle applicazioni moderne) EAX, EBX, ECX, EDX. A loro volta possono essere divisi in registri da 8 bit. Prendiamo per esempio AX. Esso può essere suddiviso in AH e AL, ovvero la sua parte alta (high) e la sua parte bassa (low) che corrispondono rispettivamente agli 8 bit più alti (quelli più a sinistra) e a quelli più bassi (quelli più a destra). - REGISTRI STRINGA: o anche registri puntatore, sono registri a 16 bit e contengono puntatori a stringhe. Essi sono SI, DI. Sono usati per copiare, confrontare, spostare stringhe. - REGISTRI ISTRUZIONE: IP (istruction pointer), registro che punta all’istruzione corrente. - REGISTRI DI STACK: ovvero registri che servono per gestire quell’area di memoria che è organizzata secondo la politica a pila. Sono BP (base pointer, punta alla base dello stack) ed SP (stack pointer: punta alla testa dello stack). - REGISTRO FLAG: un registro che non si può utilizzare e che contiene informazioni sullo stato del processore dovuto all’esecuzione delle operazioni precedenti; queste informazioni sono rappresentate dai singoli bit del registro. I flags sono: o, d, i, t, s, z, a, p, c. A seconda se sono 0 o 1 ogni flag assume un significato diverso. c – carry flag – bit che ci dice se c’è riporto p – parity flag – bit di parità a – auxiliary carry flag z – zero flag – l’ultima operazione ha dato zero s – sign flag – bit del segno (positivo o negativo) o – overflow flag – si è verificato un overflow i – interrupt enable – abilitazione generale degli interrupt d – direction flag t – trap flag Lo zero flag viene settato dopo ogni confronto e assume 0/1 a seconda dell’istruzione utilizzata per il confronto ed è quello che si deve vedere, per esempio, se si vuole sapere se una stringa immessa è uguale ad un’altra. - REGISTRI DI SEGMENTO: La memoria di uno 8086 è divisa in blocchi da 64 Kb, poiché può indirizzare solo 16 bit e dunque abbiamo 216 = 65536 = 64 Kb (nei processori moderni un blocco sarebbe 232 = 4Gb). Se vogliamo accedere ad 1 Mb di memoria ci servirebbero 20 bit, ma noi abbiamo registri a 16 bit. Per ovviare a questo inconveniente, si ricorre ai segmenti e agli offset (per indirizzare dobbiamo dunque utilizzare due registri). Un segmento rappresenta l’indirizzo del blocco utilizzato in quel momento. Per conoscere la posizione all’interno del segmento, si usa un offset. Il segmento viene messo in DS e l’offset in SI e si usa la coppia DS:SI e la notazione standard è proprio separare i due valori con i due punti. I registri di segmento sono: CS – code segment – contiene il codice da eseguire DS – data segment – contiene i dati da trattare ES – come DS, usato per confrontare le stringhe SS – stack segment LO STACK DI SISTEMA Lo stack (o pila, tipo quella di libri sulla mia scrivania…) è una parte di memoria fisica, organizzata a livello logico con la politica LIFO su cui si possono fare due operazioni: - immissione in testa (push): inserisce un valore in cima allo stack (come mettere un altro libro sugli altri) - estrazione dalla testa (pop): estrae (ovvero legge e successivamente elimina) un valore dallo stack (è come prendere il libro in cima, leggerlo e poi bruciarlo…) Dal punto di vista della programmazione, ci interessa sapere come cambia il registro che punta alla testa (o cima) dello stack e quali sono i confini di quest’area di memoria. Quando facciamo il push di un valore sullo stack, esso viene salvato nella posizione di memoria a cui punta attualmente il registro SP; dopo aver inserito tale valore, SP viene decrementato di tanti byte quanti sono quelli del valore immesso, di solito, poiché si fa il push di registri, si tratta di 16 bit (o 32 bit). L’altro registro, BP, contiene il più basso valore che può avere SP e quando si giunge a tale valore, si verifica un overflow dello stack. REGISTRI DI SEGMENTO La memoria viene (come già accennato) scomposta in parti dette segmenti: ogni valore si troverà sicuramente in un segmento e disterà da quella posizione di memoria di un certo spiazzamento (offset). Quindi per accedere ad un valore in memoria dobbiamo dare al processore due informazioni, segmento e offset, scrivendole in questo modo: [segmento:offset] Per aiutarci nella programmazione, sono stati realizzati dei registri di segmento, che si riferiscono da un determinato segmento di memoria e ci permettono di accedere alla memoria solo indicando lo spiazzamento. Come detto prima, i registri di segmento sono CS, DS, ES, SS: - Stack Segment (SS) è il segmento in cui si trova il valore che si vuole inserire nello stack; - Data Segment (DS) è il registro che contiene i dati da trattare, ovvero la parte di memoria a cui accedere. Nella pratica, serve per specificare il segmento di memoria a cui accedere, e se non specificato il suo valore (è specificato solo l’offset), si assume quello contenuto in DS come default per il segmento; - Extra Segment (ES) si utilizza come registro di scorta (extra): se voglio accedere ad un altro segmento, senza cambiare i valori degli altri registri di segmento, utilizzo questo registro; - Code Segment (CS) è il segmento di memoria dove si trova il codice eseguibile. LINGUAGGIO ASSEMBLY Un istruzione assembly è formata da un operatore seguito da uno o più operandi. Nel caso di un programma decompilato (o di cui si sta facendo il debug), si ha una cosa del genere: indirizzo operatore operando1,operando2,...,operandoN Gli operandi, che per quello che studiamo sono due o al massimo tre, possono essere registri (che andiamo ad indicare con l’abbreviazione reg), indirizzi di memoria (mem), oppure numeri (scritti in una base qualsiasi: naturalmente dobbiamo dire al processore in quale base ragioniamo). Gli operatori sono di solito abbreviazioni di parole inglesi che rispecchiano l’operazione che eseguono; noi non vedremo come funzionano in pratica, ma daremo solo un cenno al loro funzionamento. OPERATORE DI SPOSTAMENTO Permette di trasferire dei dati dalla memoria ai registri e viceversa oppure spostare in memoria valori costanti. Avrà una sintassi del genere: MOV destinazione,sorgente Quello che non si può fare è uno spostamento diretto memoria-memoria, ma bisogna passare per un registro OPERATORI ARITMETICI ADD destinazione,sorgente Somma i due operandi (sorgente e destinazione) e mette il risultato in destinazione, ovvero fa una cosa del genere: “destinazione = sorgente + destinazione”, cosa che in C++ coincide con l’operatore ‘+=’: “destinazione += sorgente” SUB destinazione,sorgente Sottrae il valore di sorgente da destinazione e mette il risultato in destinazione, ovvero equivale a fare: “destinazione = destinazione – sorgente” MUL sorgente Mentre addizione e sottrazione non danno in teoria numeri più grandi di un registro, il risultato del prodotto tra due numeri può non entrare in un solo registro, ma può occorrere più di un registro per contenere il risultato e per logica questo registro deve essere un accumulatore. L’operatore di moltiplicazione esegue una moltiplicazione tra il valore o la locazione di memoria o il registro sorgente e una parte o tutto il registro EAX. Un registro accumulatore, quale EAX è diviso come segue Quindi se il valore contenuto nella sorgente è lungo 8 bit la sorgente viene moltiplicata per AL, se il valore è lungo16 bit la moltiplicazione verrà fatta per AX, mentre per un valore lungo 32 bit la moltiplicazione è per EAX. Il risultato va a finire in una coppia di registri che a seconda delle dimensioni degli operandi, possono essere AH:AL, DX:AX, oppure EDX:EAX. Per esempio se consideriamo un risultato in DX:AX, DX contiene la parte alta del risultato ovvero i bit [31,16], mentre AX la parte bassa [15,0]. Quindi se scegliamo di moltiplicare MUL BX dove BX è di 16 bit, avremo DX:AX = AX * BX DIV sorgente Divide il contenuto dei registri AH:AL, DX:AX, EDX:EAX per il valore della sorgente e pone il risultato a seconda dei casi in AL, AX o EAX. Quello che viene memorizzato è un valore intero; non c’è arrotondamento, ma troncamento all’intero immediatamente inferiore. Per esempio se facciamo11/2 nel registro di destinazione troveremo 5. Il resto è memorizzato dove prima c’era la parte alta del dividendo, ovvero in AH, DX o EDX. OPERATORI BOOLEANI AND destinazione,sorgente Fa l’AND dei singoli bit di destinazione e sorgente e mette il risultato in destinazione. OR destinazione,sorgente Fa l’OR dei singoli bit di destinazione e sorgente e mette il risultato in destinazione. XOR destinazione,sorgente Fa lo XOR dei singoli bit di destinazione e sorgente e mette il risultato in destinazione. NOT operando Nega logicamente i bit dell’operando e mette il risultato in operando. OPERATORI DI INCREMENTO E DECREMENTO INC registro Incrementa un registro di 1 DEC registro Decrementa un registro di 1 OPERATORI PER LO STACK PUSH operando Mette nella locazione di memoria SS:[SP] il valore dell’operando e modifica il registro SP secondo la dimensione di tale operando. POP destinazione Estrae dallo stack l’ultimo valore inserito (seguendo la politica LIFO: Last-In-Frist-Out) e lo memorizza nella destinazione e modifica il registro SP secondo la dimensione di tale operando. I FLAG E I CONFRONTI Le informazioni sullo stato attuale del programma (errori verificatisi, risultato dell’ultimo confronto,…) sono memorizzati in un registro del processore per cui ogni bit ha un significato particolare. I bit che ci interessano sono 9 e si chiamano rispettivamente O D I T S Z A P C. Ogni lettera è l’iniziale della funzione che descrivono: c – carry flag – bit che ci dice se c’è riporto p – parity flag – bit di parità a – auxiliary carry flag z – zero flag – l’ultima operazione ha dato zero s – sign flag – bit del segno (positivo o negativo) o – overflow flag – si è verificato un overflow i – interrupt enable – abilitazione generale degli interrupt d – direction flag t – trap flag L’operatore CMP esegue un confronto tra due operandi (non entrambi in memoria) e setta il registro flag nel seguente modo: CMP A,B A > B (maggiore) CF = 0 (carry flag) ZF = 0 (zero flag) A < B (minore) CF = 1 ZF = 0 A = B (uguale) ZF = 1 è come se CMP eseguisse una sottrazione, infatti lo zero flag (risultato dell’ultima operazione nullo) è impostato a 1 se i due operandi sono uguali. Un confronto è seguito sicuramente da qualcosa che prenda decisioni riguardo il flusso del programma e questa operazione è eseguita dall’operatore di salto condizionato. Sono definiti tanti salti condizionati i cui operatori cominciano tutti per J e si differenziano secondo i flag su cui operano. A questo proposito copio e incollo una bella tabella in inglese che li elenca e li descrive tutti ricordando che si usano in questo modo: Jxx indirizzo Opcode Mnemonic 77 73 72 76 72 E3 74 7F 7D 7C 7E JA JAE JB JBE JC JCXZ JE JG JGE JL JLE JMP JNA JNAE JNB JNBE JNC JNE JNG JNGE JNL JNLE JNO JNP JNS JNZ JO JP JPE JPO JS JZ 76 72 73 77 73 75 7E 7C 7D 7F 71 7B 79 75 70 7A 7A 7B 78 74 Meaning Jump Condition Jump if Above Jump if Above or Equal Jump if Below Jump if Below or Equal Jump if Carry Jump if CX Zero Jump if Equal Jump if Greater (signed) Jump if Greater or Equal (signed) Jump if Less (signed) Jump if Less or Equal (signed) Unconditional Jump Jump if Not Above Jump if Not Above or Equal Jump if Not Below Jump if Not Below or Equal Jump if Not Carry Jump if Not Equal Jump if Not Greater (signed) Jump if Not Greater or Equal (signed) Jump if Not Less (signed) Jump if Not Less or Equal (signed) Jump if Not Overflow (signed) Jump if No Parity Jump if Not Signed (signed) Jump if Not Zero Jump if Overflow (signed) Jump if Parity Jump if Parity Even Jump if Parity Odd Jump if Signed (signed) Jump if Zero CF=0 and ZF=0 CF=0 CF=1 CF=1 or ZF=1 CF=1 CX=0 ZF=1 ZF=0 and SF=OF SF=OF SF != OF ZF=1 or SF!=OF unconditional CF=1 or ZF=1 CF=1 CF=0 CF=0 and ZF=0 CF=0 ZF=0 ZF=1 or SF!=OF SF != OF SF=OF ZF=0 and SF=OF OF=0 PF=0 SF=0 ZF=0 OF=1 PF=1 PF=1 PF=0 SF=1 ZF=1 ESEMPIO Supponiamo AX = 0000000000000001 (16 bit) BX = 0000000000000001 l’operazione CMP AX,BX setta lo zero flag, ovvero ZF = 1, quindi quando andiamo a fare JNE indirizzo il flusso del programma continuerà, ignorando il salto, perché il salto avviene solo e soltanto se lo zero flag è 0, perché AX – BX = 0 significa AX = BX. CHIAMATA A PROCEDURA Nel capitolo precedente abbiamo dato una descrizione dettagliata di come avviene una chiamata a funzione ora vediamo che la chiamata viene eseguita effettivamente con l’operatore CALL indirizzo che salva il program counter sullo stack e poi esegue un salto incondizionato all’indirizzo. Dunque una CALL equivale a ... PUSH PC JMP indirizzo ... All’indirizzo ci saranno una serie di operazioni e poi quando la funzione (o procedura che sia) è terminata c’è l’istruzione RET, che carica dallo stack il valore precedente del PC ed esegue un salto all’indirizzo dove era stata sospesa l’esecuzione. Avremo dunque che l’istruzione di ritorno equivale a ... POP BX JMP BX ... OPERATORE NOP Si usa semplicemente scrivendo NOP, che ha come opcode 90h. Questa istruzione non fa nulla! Il suo scopo è quello di consumare tempo (3 cicli del processore) o di riservare spazio in memoria (per eventuali aggiunte future di istruzioni). (Vedi Appendice A) INSERIMENTO DI VALORI Un qualsiasi valore può essere inserito come un valore binario, come un valore ottale, come un valore decimale o esadecimale. Per esempio per inserire un valore decimale in AX possiamo utilizzare la seguente sintassi MOV AX,120 ovvero di default un numero verrà riconosciuto come decimale. Se voglio inserire un numero esadecimale devo aggiungere ‘h’ MOV AX,A5h per un numero binario invece devo aggiungere ‘b’. CHIAMATA DI UN INTERRUPT Per chiamare un interruzione sia relative al sistema operativo che al bios, posso utilizzare int numero_interruzione Gli interrupt sono tanti, per esempio il 21h del DOS serve per l’input/output, se non mi sbaglio, e non li elenco qui e sebbene siano molto importanti non li tratterò in questa introduzione, ma se potete imparateli da qualche bel manualone… buon divertimento. APPENDICE A – BASI DI CRACKING: NOP CRACKING Bene ora vediamo come possiamo utilizzare le nostre poche conoscenze in assembly e craccare semplicemente un programma utilizzando una semplice tecnica che consiste nell’inserire a posto di un confronto dei NOP. Ci serviremo del programma Olly Debugger 1.10, che può essere scaricato gratuitamente da http://home.t-online.de/home/Ollydbg/. PROGRAMMA IN C++ Scriviamo il seguente programma in C++, spero che il codice sia semplice. #include <iostream> #include <string> #include <stdlib.h> using namespace std; int main() string string string string string { s = p = g = h = f; "password"; "peppe"; "pippolino"; "sophie"; cout << "password: "; cin >> f; if (f == h) cout << "\nok\n"; else cout << "\nno\n"; system("pause"); return 0; } Compiliamolo e carichiamolo con il nostro caro debugger. Vogliamo fare in modo che, applicando una semplice patch al codice, fatta da due NOP, il programma accetti qualsiasi password e non solo la password “sophie”. Ora procediamo per immagini e soprattutto per intuito. Una volta caricato il programma ci appare un sacco di codice assembly e noi sicuramente non capiamo cosa fa, o meglio possiamo immaginarlo. Ma procediamo intuitivamente, facendo finta di non conoscere il codice sorgente e di non poter leggere la password in chiaro direttamente dal testo del programma. Quando non inseriamo la password corretta il programma ci risponde con un “no” e dunque cerchiamo questa stringa e ad un certo punto del programma troveremo: Abbiamo dunque trovato il “no”. Intuitivamente sopra “ok” è il messaggio che otteniamo se immettiamo la password corretta. Per cui risaliamo il “no” fino a trovare il salto incondizionato (JMP) che, appunto, salta questo messaggio di errore. L’indirizzo successivo a questo JMP sarà quello che ci interessa. Infatti dobbiamo trovare il salto, questa volta condizionato, che porta all’indirizzo prima citato (nel nostro caso 0040149B) ed è quello evidenziato in figura (Jump if Equal) che è preceduto da un TEST. Dobbiamo ora mettere NOP (precisamente 2 NOP) al posto di questo JE. Selezioniamo la riga e premiamo il tasto di spazio (la stessa cosa di cliccare col tasto destro e premere “Assemble”). A questo punto basta sostituire il comando con un NOP e lasciare l’opzione “fill with NOP’s” (lett. riempi con dei NOP). Possiamo a questo punto premere il tasto “Assemble” e otterremo una cosa del genere Quindi non ci sarà il salto e avremo sempre OK. Facciamo eseguire il programma al debugger, premendo play dalla barra degli strumenti e otteniamo il risultato desiderato PROGRAMMA IN VISUAL BASIC Costruiamo un form come in figura, utilizzando una casella di testo che chiameremo Text1 e un Pulsante che chiameremo Command1. Aggiungiamo ora al pulsante Command1 il seguente codice: Private Sub Command1_Click() If Text1 = "micio" Then MsgBox "ok" Else MsgBox "no" End If End Sub Proviamo ora a procedere in un modo meno intuitivo. Visual Basic (chiamiamolo VB) fa uso di librerie esterne per eseguire il programma e il confronto viene fatto da una funzione esterna presa da queste librerie, i cui riferimenti compaiono all’inizio del codice ed è __vbaStrCmp che sta per “String Compare” ovvero testa l’eguaglianza tra due stringhe. Ora dobbiamo trovare quando questa funzione viene richiamata. Per fare questo dobbiamo scegliere dal menù “View” la voce “References” (lett. Riferimenti): ci compariranno i punti in cui viene richiamata tale funzione. Andiamo ad esaminare il codice per ogni chiamata (facendoci doppio click) e ci accorgiamo che quella che ci serve è proprio la terza, proprio per la presenza da quelle parti del messaggio di “ok” che il programma ci darebbe in caso di password corretta. Ora possiamo agire con lo stesso ragionamento di prima, stavolta scendendo fino al primo JMP e poi trovando il salto condizionato che si riferisce all’indirizzo ad esso successivo (oppure troviamo come prima il messaggio di errore “no” e risaliamo fino al primo JMP, etc…). Patchiamo il codice con i nostri bei NOP dove c’è il salto e quando andiamo ad inserire una qualsiasi password avremo il risultato desiderato, il che ci permette di poter accedere anche non inserendo la parola “micio”. Nel caso volessimo rendere questa modifica permanente o andiamo con un editor esadecimale (hex editor) a scrivere all’indirizzo trovato (00401C4E) 90 90 (90 stà per NOP) al posto di 74 43 (JE . . .) oppure creiamo un programma (ovvero un crack) che lo fa al posto nostro, in modo da renderlo disponibile alla comunità. Seguono le immagini relative alle varie fasi: 1) trovato il salto da andare a modificare 2) patch con i NOP 3) programma che funziona come vogliamo CONCLUSIONE Non c’è bisogno di dirvi che questa è una tecnica base, inapplicabile nella pratica dove un programma è superprotetto dalla decompilazione e le password sono codificate o ci sono livelli altissimi di protezione. Per saperne di più, sapete cosa dovete fare: studiare, decompilare e debuggare… buono studio. APPENDICE B – BUFFER OVERFLOW: UN’INTRODUZIONE Guardiamo questo piccolo pezzettino di codice. int main(int argv,char **argc) { char buf[256]; } strcpy(buf,argc[1]); Per prima cosa spieghiamo cosa succede int main(int argv,char **argc) la funzione main può o può non avere due variabili di ingresso e sono parametri passatigli direttamente dal sistema operativo. Quando vogliamo per esempio aprire un file col notepad o con qualsiasi altro programma da dos gli diamo come parametro in ingresso il nome del file notepad nome_file.txt oppure possiamo passare dei parametri a dir per ottenere una visualizzazione differente dir /ad /p /w in questo caso gli abbiamo passato tre argomenti. Questi tre argomenti andranno a finire nel vettore char **argc, che sarà fatto in questo modo argc[0]=dir – ovvero contiene il nome del programma argc[1]=/ad – contiene il primo parametro argc[2]=/p argc[3]=/w quindi abbiamo un array di quattro stringhe e la grandezza di questo array ci è dato proprio dal primo argomento int argc. char buf[256]; strcpy(buf,argc[1]); queste due righe invece copiano in un array di 256 caratteri il primo argomento (dopo il nome del programma) passato al programma, senza fare nient’altro. Non c’è nessun controllo sulla lunghezza della sorgente e allora la funzione di copia si fermerà solo quando troverà il carattere di terminazione della stringa ‘\0’, perciò possiamo immettere ben oltre 256 caratteri e sfruttare questa cosa a nostro piacimento. Vogliamo trovare il modo di richiamare una funzione, scritta, ma non utilizzata esplicitamente dal programma. Principalmente abbiamo una situazione come la precedente: c’è un array di dimensione N e noi gli diamo in input M caratteri (con M > N) in modo tale da andare a sovrascrivere la return address (lett. indirizzo di ritorno – RET di una precedente CALL) salvata nello stack, tramite l’array. Brevemente ricordiamo che quando avviene una chiamata a funzione nello stack è fatto il push dei parametri, poi quando eseguiamo la CALL viene salvato nello stack il return address della funzione e poi dopo di esso ci saranno le variabili locali. Per esempio abbiamo uno stack fatto in questo modo (dove [LOC N] è eventualmente la testa): . . .[ARG 1] . . . [ARG N] [RET] [LOC 1] . . . [LOC N] . . . Vediamo il codice del programma in questione: #include <iostream> #include <stdlib.h> using namespace std; void chiamami() { cout << "\nla funzione e’ stata chiamata\n"; exit(0); } int ciao() { char c[14]; cout << "\ninserisci: "; cin >> c; /* se qui ci fosse cin.getline(c,13); non potremmo fare niente c'è infatti un errore di programmazione, non facilmente individuabile a consentire il buffer overflow */ return 0; } int main() { ciao(); cout << "\nla funzione non e’ stata richiamata\n"; return 0; } Andiamo ora immediatamente a stuzzicare con un input molto lungo il nostro programma e come vediamo dalla figura il programma ci dà errore. Capiamo naturalmente che l’errore è nella return address, poiché è appunto quello che volevamo fare e inoltre notiamo che c’è qualcosa di interessante. Se vediamo infatti i dettagli dell’errore scopriamo che l’offset è 6d6c6968 che praticamente sono a due a due la traduzione in esadecimale di ‘m’ ‘l’ ‘i’ ‘h’. Dunque abbiamo trovato come inserire il nostro indirizzo di ritorno. Proviamo infatti ad inserire una stringa più corta. Oltre all’errore, causato dal nostro input, si vede che il programma ritorna correttamente all’esecuzione del main e dunque mostra il messaggio “la funzione non e’ stata richiamata” che ci aspettavamo. Qualcuno si potrebbe chiedere perché non ho messo ‘g’. Il programma ci avrebbe dato ugualmente errore perché avremmo sovrascritto con 00, ovvero il terminatore di stringa, l’indirizzo di ritorno. Dobbiamo ora trovare l’indirizzo di ritorno che ci interessa, ovvero quello della funzione non chiamata, e fare in modo che il flusso del programma sia reindirizzato in quella direzione. Per fare questo ci avvaliamo del nostro fidato debugger (io uso Olly Debugger 1.10). Come vediamo dalla figura il debugger non ha voluto vedere il codice come un insieme di istruzioni assembly, forse proprio perché non è utilizzata dal programma. Allora cosa facciamo per vedere il codice? Selezioniamo il codice in grigio che intuiamo essere la funzione (e lo intuiamo perché c’è la stringa “la funzione e’ stata chiamata”), clicchiamo con il tasto destro e dal menù a tendina scegliamo “analysis”, poi scegliamo “during next analysis, treat section as” e infine scegliamo “command”. Il codice verrà così tradotto in qualcosa di più leggibile. Abbiamo dunque trovato il nostro return address, che deve essere 4012A0. Andiamo a vedere cosa succede se digitiamo abcdefghilmnopqrstuvzabcdefg@^Rá dove @ = ALT + 64 ^R = ALT + 18 á = ALT + 160 e sono rispettivamente i numeri 40, 12, A0 convertiti dall’esadecimale al decimale. Non abbiamo il risultato sperato, perché non abbiamo considerato il modo di memorizzare i dati nello stack. Come vediamo infatti dall’offset che ci dà nell’immagine è proprio l’inverso di quello che ci interessa. Lo stack, infatti, memorizza tutto come pezzi da 2 byte (16 bit) invertendo l’ordine dell’intera word. Quindi se abbiamo 40 12 A0, dobbiamo per prima cosa disporre i byte in ordine inverso, ottenendo A0 12 40 e dunque convertendo in decimale 160, 18, 64 (dopo c’è ‘\0’). APPENDICE C – COPIA E INCOLLA DI UN SEMPLICE “HELLO WORD!” Ecco di seguito un esempio di utilizzo di un interrupt che costituisce anche un esempio di programma pienamente compatibile col Turbo Assembler (l'assemblatore della Borland): ; ; ; ; ; ; ; ; ; ; ; ; ; Interrupt 21h Sottofunzione per Visualizzare una Stringa AH=9 DS:DX -> stringa ASCII (DS:DX deve puntare alla stringa che vuoi stampare) la stringa deve essere terminata con '$' Interrupt 21h Sottofunzione per Terminare il Programma AH=4Ch AL=code d'errore Segment Data ; Il segmento DATI (DATA) HelloWorld db 'Hello World!$' ; INT 21h / AH=9 usa '$' per ; indicare la fine della stringa EndS ; Fine (End) del Segmento Dati (Data Segment) Segment Code ; Il Segmento di Codice (Code Segment).. il codice ; eseguibile va messo qui dentro Assume CS:Code,DS:Data ; Senza questa direttiva, alcuni ; assemblatori non sono in grado di ; associare ciascuno dei segmenti definiti ; con il suo opportuno registro di segmento. Start: MOV AX,data MOV DS,AX ; ; ; ; ; ; ; ; Definisce il punto a cui il DOS farà puntare inizialmente CS:IP Notiamo che quando il DOS carica un file EXE, non imposta il registro DS all'inizio del segmento dati; perciò dobbiamo farlo noi Ottiene il numero di segmento del nostro segmento DATI (DATA segment) Imposta DS al segmento in cui si trovano i DATI (DATA) MOV AH,9 MOV DX,offset HelloWorld INT 21h MOV AX,4C00h INT 21h EndS End Start ; DOS Visualizza una Stringa (DOS Print String) ; DS:DX deve contenere l'indirizzo della stringa ; (DS il segmento e DX l'offset) ; Chiama (Call) INT 21h per stampare la stringa ; Uscita al DOS (DOS Exit Program) ; Termina il programma - dopo questa istruzione non c'è ; più nulla da fare ; Fine del Segmento Codice (End the Code Segment) ; Questa è una direttiva per TASM (il Turbo Assembler) e ; gli indica la fine del codice da assemblare (cioè da ; tradurre in linguaggio macchina). Ecco perché EndS non ; può comparire dopo End Start, ma deve venire prima ; (altrimenti sarebbe ignorata e l'assemblatore ci ; avvertirebbe che abbiamo lasciato un segmento aperto) Wow! Adesso siamo in grado di scrivere un programma che visualizza "Hello World!" sullo schermo (Ntd: questo significa "Ciao mondo!" o se preferite "Salve, gente!"). Roba da far paura, eh? FONTE: http://net.supereva.it/assemblypagediant.freeweb/indici/alt-0_i.htm?p