Reverse engineering e debug binario in Linux
Tutorial operativo per il reverse engineering e il debugging binario su Linux. Il testo analizza il disassemblaggio con objdump, il tracciamento delle system call con strace e l'ispezione della memoria, dei registri e dello stack mediante GDB.
`objdump -d -M intel` è uno strumento Linux per il disassemblaggio di file eseguibili ELF in istruzioni Assembly leggibili, mostrando indirizzo di memoria, opcode grezzi e istruzioni in sintassi Intel. L'analisi statica di questo output permette di identificare valori immediati esadecimali e segreti temporaneamente caricati nei registri, invisibili durante l'esecuzione dinamica.
Analisi statica con objdump
Cos'è il disassemblaggio
Quando un software scritto in Assembly o C viene compilato, viene convertito in codice macchina, cioè sequenze di byte (opcode) interpretabili direttamente dalla CPU. Il disassemblaggio è il processo inverso, prende un file eseguibile binario e lo riconverte in istruzioni Assembly leggibili dall'essere umano. È la tecnica fondamentale del reverse engineering e del debugging per analizzare programmi di cui non si possiede il codice sorgente.
Lo strumento objdump
objdump è una delle utility a riga di comando più comuni in ambiente Linux per ispezionare i file ELF (eseguibili).
Comando standard per disassemblare un binario in sintassi Intel.
objdump -d -M intel /percorso/del/file
Le due flag fondamentali sono:
-d: esegue il disassemblaggio delle sezioni contenenti codice eseguibile, come la sezione.text.-M intel: forza l'output a usare la sintassi Intel (istruzione, destinazione, sorgente). Senza questa flag,objdumpusa la sintassi predefinita AT&T, che inverte l'ordine degli operandi e aggiunge prefissi come%e$, rendendo la lettura più complessa per chi è abituato alla notazione Intel.
Anatomia di un output di disassemblaggio
Analizzando un esempio di output.
401000: 48 c7 c7 39 05 00 00 mov rdi,0x539
401007: 48 c7 c7 00 00 00 00 mov rdi,0
40100e: 48 c7 c0 3c 00 00 00 mov rax,0x3c
401015: 0f 05 syscall
Un rigo di disassemblaggio si divide in tre componenti principali.
| Indirizzo di memoria | Opcode (byte grezzi in hex) | Istruzione Assembly (Intel) |
|---|---|---|
| 401000: | 48 c7 c7 39 05 00 00 | mov rdi, 0x539 |
| 401007: | 48 c7 c7 00 00 00 00 | mov rdi, 0 |
| 40100e: | 48 c7 c0 3c 00 00 00 | mov rax, 0x3c (syscall 60, sys_exit) |
| 401015: | 0f 05 | syscall |
Due osservazioni sono importanti in fase di analisi statica:
- Tutti i valori immediati sono in esadecimale. Ad esempio,
0x3ccorrisponde a60in decimale, cioè l'ID della syscallsys_exit, mentre0x539corrisponde a1337. - Analisi statica e segreti nascosti. Leggendo il disassemblaggio è possibile notare valori caricati temporaneamente nei registri, come
0x539inrdi, anche se vengono sovrascritti subito dopo conmov rdi, 0e quindi risulterebbero invisibili osservando solo l'esecuzione standard del programma.
Procedura pratica
Un caso tipico di esercizio guidato consiste nel disassemblare un binario, individuare il valore esadecimale caricato in un registro prima che venga sovrascritto, e usarlo come risposta.
Disassembla il binario con sintassi Intel.
objdump -d -M intel /assembly/disassemble
Cerca poi la funzione _start e individua la riga in cui viene eseguito il primo mov rdi, <valore_hex> immediatamente prima dell'istruzione mov rdi, 0. Il valore trovato può essere inviato sia in formato esadecimale sia convertito in decimale.
/assembly/submit
Analisi dinamica con strace
Cos'è e come funziona
Mentre objdump effettua un'analisi statica, strace effettua un'analisi dinamica: esegue il programma e intercetta in tempo reale tutte le chiamate di sistema (syscall) effettuate dal binario verso il kernel Linux, mostrando i parametri forniti e i valori restituiti.
L'output di strace utilizza una sintassi in stile linguaggio C.
nome_syscall(parametro_1, parametro_2, ...) = valore_ritorno
Un esempio concreto.
execve("/tmp/program", ["/tmp/program"], 0x7ffd48ae28b0 ...) = 0
exit(42) = ?
+++ exited with 42 +++
execve(...): è la syscall usata dal sistema operativo per lanciare ed eseguire un nuovo programma, e appare all'inizio perchéstracela rileva durante la fase di avvio del processo.exit(42): il programma invoca la syscall di terminazione fornendo42come argomento.+++ exited with 42 +++: è una notifica distrace, non parte dell'output del programma, che indica il codice di uscita finale restituito alla shell.
La syscall alarm
La chiamata di sistema alarm(seconds), syscall numero 37 su architettura x86_64, viene utilizzata nei sistemi Linux per impostare un timer. Trascorso il numero di secondi specificato come argomento, il kernel invia al processo un segnale di terminazione SIGALRM.
Nelle sfide di reverse engineering, strace è indispensabile per leggere i parametri di syscall altrimenti "invisibili" come alarm o read/write, senza dover analizzare l'intero disassembly.
GDB (GNU Debugger)
Analisi statica, dinamica e controllo completo
Dopo l'analisi statica con objdump e il tracciamento delle syscall con strace, GDB (GNU Debugger) rappresenta lo strumento di analisi dinamica e controllo completo del processo. Mentre strace rileva solo i punti di contatto tra il programma e il kernel, cioè le syscall, GDB permette di entrare all'interno dello spazio di indirizzamento del processo per fermare l'esecuzione, ispezionare i registri della CPU, modificare la memoria e analizzare ogni singola istruzione Assembly in tempo reale.
| Strumento | Tipo di analisi | Livello di visibilità | Uso principale |
|---|---|---|---|
| objdump | Statica (file su disco) | Codice macchina disassemblato | Analizzare il codice senza eseguirlo |
| strace | Dinamica (interfaccia kernel) | Chiamate di sistema e argomenti | Capire cosa chiede il processo al sistema operativo |
| gdb | Dinamica (spazio di memoria) | Registri, stack, RAM, flusso di esecuzione | Ispezionare e manipolare lo stato interno del processo tramite la syscall ptrace |
Avvio e sintassi di base
L'avvio di GDB si effettua passando il percorso del file eseguibile come argomento.
gdb /percorso/del/binario
Una volta avviato, GDB apre la propria shell interattiva, riconoscibile dal prompt.
(gdb)
I due comandi fondamentali per la navigazione di base sono:
quit(oq): esce da GDB e torna alla shell del sistema operativo.run(or): avvia l'esecuzione del programma caricato all'interno del debugger.
Rilevamento del debugger tramite ptrace
Quando si lancia gdb su un eseguibile, il sistema operativo abilita il tracciamento tramite la syscall ptrace. Il programma può quindi verificare se è controllato da un debugger e, in alcuni casi, stampare a schermo un valore proprio in reazione a quella condizione, prima di arrestarsi o terminare.
Controllo del processo
Avvio personalizzato con starti
Diversamente dal comando run, che esegue il programma fino alla fine o al primo breakpoint, starti inserisce un punto di arresto temporaneo sull'istruzione iniziale assoluta del binario e ne interrompe immediatamente l'esecuzione. Questo permette di analizzare lo stato del programma prima di qualsiasi modifica al contesto.
Ispezionare il codice in esecuzione con disassemble
Digitando disassemble all'interno della shell di GDB, viene mostrato il codice Assembly della funzione corrente.
(gdb) disassemble
Dump of assembler code for function _start:
=> 0x0000000000401000 <+0>: mov rdi,0x539
0x0000000000401007 <+7>: mov rdi,0x0
0x000000000040100e <+14>: mov rax,0x3c
0x0000000000401015 <+21>: syscall
End of assembler dump.
Alcuni elementi chiave dell'output:
- Il simbolo
=>(instruction pointer): indica l'esatta istruzione che la CPU sta per eseguire in quel momento, puntata dal registroRIPsu architettura x86_64. - Indirizzi e offset: a sinistra è presente l'indirizzo di memoria virtuale, ad esempio
0x0000000000401000, seguito dall'offset relativo all'inizio della funzione, ad esempio<+0>o<+7>. - Valori immediati esadecimali: i valori caricati nei registri o passati alle istruzioni appaiono in notazione esadecimale, ad esempio
0x539corrisponde al valore decimale1337.
Codice censurato
Il limite dell'analisi statica
Nella sezione precedente bastava disassemblare il codice per leggere visivamente il valore caricato in un registro. L'analisi statica ha però dei limiti evidenti in presenza di codice offuscato o censurato: se l'output del disassemblatore mostra un valore nascosto, ad esempio mov rdi, CENSORED, non è più possibile scoprire il segreto semplicemente leggendo il testo disassemblato.
La CPU, tuttavia, deve per forza ricevere ed eseguire i byte binari reali. Anche se la stringa viene censurata a schermo nel disassemblatore, quando la CPU esegue l'istruzione mov rdi, <segreto> il registro rdi viene effettivamente popolato con il valore reale, osservabile a runtime.
Il comando stepi
Per avanzare l'esecuzione di una singola istruzione Assembly alla volta si utilizza il comando stepi, abbreviabile in si.
(gdb) stepi
È utile distinguere due comandi di avanzamento simili:
stepi/si: avanza di una singola istruzione macchina (Assembly).step/s: avanza di una riga di codice sorgente ad alto livello, ad esempio C o C++, che potrebbe corrispondere a decine di istruzioni assembly. In assenza di sorgenti disponibili, si comporta comestepi.
Flusso operativo
gdb /assembly/program
(gdb) starti
(gdb) stepi
Eseguendo stepi, l'istruzione mov rdi, CENSORED viene processata dalla CPU e il valore reale entra nel registro rdi.
Ispezione manuale dei registri
Dalla lettura assistita alla lettura autonoma
Fino a questo punto è stato sufficiente osservare il valore mostrato automaticamente da GDB dopo uno stepi. Il passo successivo è interrogare direttamente i registri della CPU tramite la shell del debugger, una volta che il valore reale è entrato nell'architettura hardware.
Il comando print e il prefisso $
Per stampare a schermo il contenuto di un registro, o il valore di un'espressione, si utilizza il comando print, abbreviabile con p. I nomi dei registri CPU all'interno di GDB devono sempre essere preceduti dal simbolo $.
(gdb) print $rdi
$1 = 1337
La struttura dell'output è la seguente:
$1: rappresenta una variabile temporanea creata da GDB per memorizzare la cronologia dei valori stampati, riutilizzabile nei comandi successivi.1337: è il valore effettivo memorizzato nel registrordi, mostrato di default in notazione decimale.
Flusso operativo tipico.
gdb /assembly/program
(gdb) starti
(gdb) stepi
(gdb) print $rdi
(gdb) quit
Modifica dei registri
Manipolazione dinamica dello stato della CPU
Osservare lo stato del processo con disassemble e print è un uso passivo di GDB. La vera potenza di un debugger risiede nella capacità di intervenire e modificare l'esecuzione al volo, alterando direttamente i dati contenuti nei registri della CPU prima che le istruzioni successive li utilizzino. Questo permette, ad esempio, di bypassare controlli, forzare un determinato ramo di esecuzione o iniettare valori arbitrari per testare il comportamento del software.
Il comando set
Per assegnare un nuovo valore a un registro durante una pausa dell'esecuzione si utilizza il comando set.
(gdb) set $nome_registro = valore
Alcuni esempi di utilizzo:
set $rdi = 1337: sovrascrive il registrordicon il valore decimale1337.set $rax = 0x3c: imposta il registroraxal valore esadecimale0x3c, cioè60in decimale.
Come per print, anche con set il nome del registro deve sempre essere preceduto dal prefisso $.
Flusso operativo tipico.
gdb /assembly/program
(gdb) starti
(gdb) stepi
(gdb) set $rdi = 1337
(gdb) stepi
(gdb) quit
Debug cooperativo
Debugging preventivo contro debugging cooperativo
Nelle sezioni precedenti è stato utilizzato il debugging preventivo: GDB prende il controllo del programma all'avvio, tramite starti o breakpoint esterni, e ne arresta l'esecuzione senza che il codice ne sia consapevole.
Con il debugging cooperativo, è il programma stesso a decidere dove e quando cedere il controllo al debugger, attraverso un'istruzione Assembly dedicata.
Il flusso logico è il seguente: durante l'esecuzione standard, quando viene raggiunta l'istruzione int3, viene generato un trap con segnale SIGTRAP. Se il processo gira sotto GDB tramite run, l'esecuzione si interrompe e il controllo torna al prompt (gdb). Se invece il processo gira fuori da GDB, direttamente in shell, il sistema operativo lo termina restituendo un errore di tipo "Trace/breakpoint trap".
L'istruzione int3
Sull'architettura x86_64, l'istruzione int3, il cui opcode esadecimale è 0xCC, genera un'interruzione di tipo breakpoint trap, cioè un segnale SIGTRAP inviato al kernel.
Le differenze principali nell'uso di GDB sono:
starti: blocca l'esecuzione prima ancora di eseguire la prima istruzione.run(r): lascia girare il programma a piena velocità finché non incontra un'istruzioneint3, un breakpoint impostato manualmente dall'utente, oppure finché il processo non termina.
Esempio di codice con int3
Un piccolo programma in sintassi Intel che imposta un valore in un registro e invochi immediatamente int3, cedendo il controllo al debugger.
.intel_syntax noprefix
.global _start
_start:
mov rdi, 1337
int3
Passare argomenti da riga di comando in GDB
Il concetto: gli argomenti dell'inferiore
Un programma legge gli argomenti della riga di comando tramite l'array argv (argv[1], argv[2], e così via). Quando si esegue un programma fuori da GDB, gli argomenti vengono specificati direttamente dopo il nome dell'eseguibile nella shell.
dino@localhost:~$ /assembly/program hello
All'interno dell'ambiente interattivo di GDB, per passare argomenti al programma in esecuzione, detto anche "inferiore", è sufficiente scriverli direttamente dopo il comando run (o l'abbreviazione r). Qualsiasi stringa o valore digitato dopo run viene inoltrato al programma esattamente come se fosse stato lanciato da riga di comando bash.
(gdb) run hello
Due modalità di configurazione
Passaggio diretto con run, per passare gli argomenti al momento dell'avvio.
(gdb) run hello
Impostazione preventiva con set args, utile quando serve usare starti o si preferisce impostare gli argomenti prima di lanciare il programma.
(gdb) set args hello
(gdb) starti
Leggere lo stato di runtime dallo stack
Dati dinamici nello stack
Abbiamo visto finora che il valore era cablato direttamente nell'istruzione, ad esempio mov rdi, 0x1337. In uno scenario diverso, il valore non si trova nel codice binario su disco, ma deriva dallo stato di runtime:
argc(argument count): rappresenta il numero di argomenti passati al programma quando viene avviato dalla shell.- Inizializzazione dello stack: all'avvio di un eseguibile Linux x86_64, il sistema operativo deposita in cima allo stack il valore di
argc, seguito dagli argomentiargv. - Istruzione
pop rdi: preleva il valore in cima allo stack, cioèargc, e lo carica nel registrordi.
Poiché il valore di argc dipende da come il programma viene lanciato, nessun disassemblatore statico può rivelare il segreto, è indispensabile eseguirlo sotto debugger.
Il programma tipico di questo scenario è progettato per distruggere il segreto un istante dopo averlo letto.
pop rdi
mov rdi, 0x0
mov rax, 0x3c
syscall
La finestra temporale utile per catturare il valore è quella tra la prima e la seconda istruzione: prima di pop rdi il valore si trova ancora nello stack, subito dopo pop rdi si trova temporaneamente nel registro rdi, mentre dopo mov rdi, 0x0 è perso per sempre.
Flusso operativo tipico.
gdb /assembly/program
(gdb) starti
(gdb) stepi
(gdb) print $rdi
(gdb) quit
Esaminare la memoria con il comando x
Leggere la RAM senza passare dai registri
In alcuni scenari il programma non legge mai il dato in un registro.
mov rdi, 0x0
mov rax, 0x3c
syscall
Tuttavia, all'avvio del programma il kernel Linux deposita comunque argc in cima allo stack. Poiché il registro $rsp (stack pointer) punta esattamente a quell'indirizzo di memoria, è possibile usare GDB per esaminare la RAM direttamente, ignorando il fatto che il programma non usi quel dato.
Il comando x (examine)
Il comando x di GDB permette di leggere il contenuto di una determinata area di memoria a partire da un indirizzo o da un registro puntatore.
(gdb) x <indirizzo_o_registro>
Per impostazione predefinita x mostra i dati in esadecimale. È possibile aggiungere una lettera di formato subito dopo la barra.
| Comando | Formato di visualizzazione | Esempio output |
|---|---|---|
| x/d $rsp | Decimale (d = decimal) | 0x7fffffffd540: 1 |
| x/x $rsp | Esadecimale (x = hex) | 0x7fffffffd540: 0x00000001 |
L'indirizzo a sinistra, ad esempio 0x7fffffffd540:, indica dove si trova il dato nella RAM, mentre il valore a destra è il dato contenuto in quel punto, in questo caso argc.
Flusso operativo tipico.
gdb /assembly/program
(gdb) starti
(gdb) x/d $rsp
(gdb) quit
Estrarre puntatori e stringhe dallo stack
Anatomia dello stack all'avvio del processo
Quando un programma viene lanciato su Linux x86_64, il sistema operativo organizza la parte superiore dello stack, a partire da $rsp, secondo uno schema preciso che contiene le informazioni di avvio.
| Posizione | Contenuto |
|---|---|
| $rsp + 0 | argc, il numero di argomenti |
| $rsp + 8 | argv[0], puntatore al nome del programma |
| $rsp + 16 | argv[1], puntatore al primo argomento |
Lo stack a $rsp + 16 non contiene direttamente il testo cercato, ma un indirizzo di memoria, cioè un puntatore, che indica il punto preciso della RAM in cui si trova memorizzata la stringa corrispondente.
Nuovi modificatori del comando x
| Comando | Modificatore | Descrizione operativa |
|---|---|---|
| x/a $rsp+16 | /a (address) | Legge il valore memorizzato a $rsp + 16 e lo mostra come indirizzo di memoria, cioè come puntatore |
| x/s 0x... | /s (string) | Va all'indirizzo ottenuto e legge i byte come una stringa di testo terminata da null |
Leggere il contenuto puntato da argv[1] richiede quindi due passaggi: prima si ottiene l'indirizzo con x/a, poi si legge la stringa a quell'indirizzo con x/s.
(gdb) x/a $rsp+16
Il valore restituito è l'indirizzo del puntatore, ad esempio 0x7ffc001c4750, da usare come argomento del passo successivo.
(gdb) x/s 0x7ffc001c4750
Reindirizzare lo standard input in GDB
Input da file e redirezione stdin
I programmi in ambiente Linux possono ricevere dati in due modi principali: come argomenti da riga di comando (argv), oppure come standard input, il flusso letto dal programma durante l'esecuzione tramite il file descriptor 0, di default dalla tastiera ma reindirizzabile da file o pipeline.
All'interno di GDB, l'eseguibile sottoposto a tracciamento viene chiamato "inferior". GDB permette di reindirizzare lo stdin dell'inferiore applicando l'operatore di redirezione < direttamente al comando run.
(gdb) run < /percorso/del/file
Quando si esegue questo comando, GDB avvia il programma dal suo punto di inizio, e tutto ciò che il programma tenta di leggere da stdin, ad esempio tramite funzioni come read, scanf o fgets, viene letto automaticamente dal file indicato anziché attendere un input manuale da tastiera.
Flusso operativo tipico.
gdb /assembly/program
(gdb) run < /challenge/secret
(gdb) quit
Tabella riassuntiva di tutti i comandi GDB
| Comando | Abbreviazione | Descrizione operativa |
|---|---|---|
| gdb | nessuna | Avvia GDB caricando il file eseguibile specificato |
| starti | nessuna | Avvia il processo e lo blocca alla primissima istruzione in memoria (entry point) |
| run | r | Avvia l'esecuzione passando eventuali argomenti, e lascia girare il programma fino al termine o al primo breakpoint |
| set args | nessuna | Imposta gli argomenti predefiniti per tutte le esecuzioni successive di run o starti |
| show args | nessuna | Mostra gli argomenti attualmente configurati in GDB |
| run < file | r < file | Avvia il programma reindirizzando il contenuto del file nello stdin |
| disassemble | disas | Disassembla il blocco di codice o funzione attualmente in esecuzione |
| stepi | si | Esegue esattamente l'istruzione corrente e si ferma subito a quella successiva |
| print $reg | p $reg | Stampa il valore contenuto nel registro specificato |
| set $reg = val | nessuna | Modifica il valore del registro specificato con il nuovo valore assegnato |
| x/d | nessuna | Esamina la memoria all'indirizzo indicato mostrando il valore in formato decimale |
| x/x | nessuna | Esamina la memoria all'indirizzo indicato mostrando il valore in formato esadecimale |
| x/a | nessuna | Esamina la memoria interpretando il valore letto come un indirizzo o puntatore |
| x/s | nessuna | Esamina la memoria all'indirizzo indicato interpretandola come stringa C terminata da null |
| quit | q | Termina la sessione del debugger e ritorna alla shell |
Buone pratiche e strumenti complementari
Oltre ai comandi illustrati finora, chi lavora regolarmente con reverse engineering e debug binario su Linux trova utili alcuni strumenti e comandi aggiuntivi.
- Estensioni GDB per il reverse engineering: framework come
pwndbgeGEFarricchiscono l'interfaccia standard di GDB con visualizzazioni avanzate di memoria, stack e registri, pensate specificamente per attività di exploit development e CTF. Sono un naturale passo successivo una volta acquisita familiarità con i comandi base illustrati in questo articolo, incluso l'uso combinato diprint,setex. - Ispezionare lo stato completo dei registri: quando il valore cercato non è nella posizione attesa, può essere utile visualizzare l'intero set di registri della CPU in un colpo solo, invece di interrogarli uno alla volta con
print.
(gdb) info registers
- Disassemblare solo la sezione di interesse: quando un binario è grande, può essere utile limitare l'output di
objdumpa una singola sezione ELF invece di stampare l'intero file.
objdump -d -M intel -j .text /percorso/del/file
- Breakpoint manuali come alternativa a int3: oltre a modificare il codice sorgente per inserire
int3, GDB permette di impostare un breakpoint direttamente su un indirizzo di memoria, senza dover ricompilare nulla.
(gdb) break *0x401000