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