Uno dei motivi per cui si entra con difficoltà nel mondo del kernel exploit è che spesso non è chiaro come fare debug.
In questa sezione vedremo come usare gdb per debuggare un kernel Linux in esecuzione su qemu.

Per prima cosa scarica i file di LK01.

Ottenere i privilegi root

Quando fai debug di un kernel exploit sulla tua macchina, lavorare come utente normale è spesso scomodo. In particolare, per mettere breakpoint nel kernel o nei driver e per capire a quale funzione corrisponde un indirizzo leakato, servono spesso i privilegi root per accedere alle informazioni sugli indirizzi del kernel.
Quando fai debug di kernel exploit, il primo passo dovrebbe quindi essere ottenere una shell root. Il contenuto di questa sezione coincide con il punto (2) dell’esercizio del capitolo precedente, quindi se l’hai già fatto puoi leggerla solo per conferma.

Quando il kernel termina il boot, avvia un programma iniziale. Il percorso dipende dalla configurazione, ma di solito si tratta di /init o /sbin/init. Se estrai rootfs.cpio di LK01, troverai /init.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/sh
# devtmpfs does not get automounted for initramfs
/bin/mount -t devtmpfs devtmpfs /dev

# use the /dev/console device node from devtmpfs if possible to not
# confuse glibc's ttyname_r().
# This may fail (E.G. booted with console=), and errors from exec will
# terminate the shell, so use a subshell for the test
if (exec 0</dev/console) 2>/dev/null; then
exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console
fi

exec /sbin/init "$@"

Qui non c’è nulla di particolarmente importante, ma vediamo che viene eseguito /sbin/init.
Nei piccoli ambienti distribuiti nei CTF, talvolta /init contiene direttamente logica come installare il driver o aprire una shell. In effetti, se aggiungessi semplicemente /bin/sh prima dell’ultima riga exec, all’avvio otterresti subito una shell root. In questo caso, però, mancherebbero altre inizializzazioni necessarie, quindi non modificheremo questo file.

Partendo da /sbin/init, si arriva infine a eseguire lo script /etc/init.d/rcS, che lancia tutti i file in /etc/init.d il cui nome inizia con S. Nel nostro caso è presente uno script chiamato S99pawnyable. Contiene varie operazioni di inizializzazione, ma verso la fine compare questa riga:

1
setsid cttyhack setuidgid 1337 sh

È la riga che avvia una shell con privilegi utente. cttyhack serve a rendere disponibili input come Ctrl+C. Il comando setuidgid imposta poi UID e GID a 1337 e avvia /bin/sh. Se sostituiamo quel valore con 0, cioè l’UID di root:

1
setsid cttyhack setuidgid 0 sh

otterremo una shell root all’avvio.

Inoltre, come spiegheremo nel capitolo successivo, conviene anche commentare la seguente riga per disattivare una delle protezioni che nascondono gli indirizzi del kernel:

1
2
-echo 2 > /proc/sys/kernel/kptr_restrict    # prima
+#echo 2 > /proc/sys/kernel/kptr_restrict # dopo

Dopo aver fatto la modifica, ricrea il cpio ed esegui run.sh. Dovresti ritrovarti con una shell root simile a quella mostrata nello screenshot seguente. (Per il repack del cpio, vedi il capitolo precedente.)

Shell root avviata correttamente

Attaccarsi a qemu

qemu include già le funzionalità necessarie per il debug via gdb. Passando l’opzione -gdb si può far mettere qemu in ascolto su un protocollo, host e porta scelti. Per esempio, se modifichi run.sh aggiungendo:

1
-gdb tcp::12345

qemu resterà in ascolto su localhost:12345.
Negli esercizi successivi useremo direttamente la porta 12345 senza ribadirlo ogni volta, ma puoi cambiarla come preferisci.

Per collegarti da gdb, usa il comando target:

1
pwndbg> target remote localhost:12345

Se la connessione avviene con successo, hai finito. Da quel momento puoi usare i normali comandi gdb per leggere e scrivere registri e memoria, impostare breakpoint e così via. Gli indirizzi da usare sono semplicemente gli indirizzi virtuali validi nel contesto che stai debuggando. In altre parole, puoi mettere breakpoint sugli stessi indirizzi che useresti nel driver o nel programma userland.

Nel nostro caso il bersaglio è x86-64. Se la tua installazione di gdb non riconosce automaticamente l’architettura, puoi impostarla esplicitamente:

1
pwndbg> set arch i386:x86-64:intel

Debug del kernel

Attraverso /proc/kallsyms puoi vedere l’elenco dei simboli esportati dal kernel Linux e i relativi indirizzi. Come spiegheremo anche nel paragrafo su KADR, alcune protezioni possono nascondere questi indirizzi persino a root.
Lo abbiamo già fatto nella sezione precedente, ma vale la pena ripeterlo: ricorda di commentare la riga seguente nello script di inizializzazione, altrimenti non vedrai i puntatori del kernel.

1
2
echo 2 > /proc/sys/kernel/kptr_restrict     # prima
#echo 2 > /proc/sys/kernel/kptr_restrict # dopo

Ora proviamo davvero a guardare kallsyms. Poiché l’output è enorme, usiamo head.

Prime righe di /proc/kallsyms

L’output contiene, nell’ordine, indirizzo del simbolo, sezione e nome del simbolo. Per esempio T indica la sezione .text, D la .data, e le lettere maiuscole indicano simboli esportati globalmente. Il significato preciso delle lettere si trova in man nm.
Nel nostro screenshot, per esempio, 0xffffffff81000000 corrisponde al simbolo _stext, cioè al base address del kernel caricato in memoria.

Adesso cerca con grep l’indirizzo della funzione commit_creds. Dovresti trovare 0xffffffff8106e390. Prova a mettere un breakpoint e a continuare l’esecuzione:

1
2
pwndbg> break *0xffffffff8106e390
pwndbg> conti

Questa funzione viene chiamata, per esempio, quando nasce un nuovo processo. Se dalla shell esegui ls o un altro comando, gdb dovrebbe fermarsi sul breakpoint.

Breakpoint su commit_creds

Nel registro RDI, che è il primo argomento, si trova un puntatore in kernel space. Proviamo a osservare la memoria puntata.

Contenuto della memoria puntata da RDI in commit_creds

Come vedi, in kernel space si possono usare gli stessi comandi gdb che si usano in userland. Estensioni come pwndbg funzionano, ma ovviamente solo nella misura in cui sanno gestire il kernel. Esistono anche debugger con supporto specifico al kernel, come questa variante di gef, quindi usa pure l’ambiente che preferisci.

Debug di un driver

Passiamo ora al debug di un modulo del kernel.
In LK01 viene caricato un modulo vulnerabile chiamato vuln. L’elenco dei moduli caricati e i rispettivi base address si trovano in /proc/modules.

Contenuto di /proc/modules

Da qui si vede che il modulo vuln è stato caricato a 0xffffffffc0000000. Il sorgente e il binario del modulo si trovano nella directory src dei file distribuiti. L’analisi dettagliata la faremo più avanti; per ora limitiamoci a mettere un breakpoint su una funzione del modulo.
Aprendo src/vuln.ko in IDA o in un altro disassemblatore, vedrai varie funzioni. Per esempio, module_close si trova a offset relativo 0x20f.

La funzione module_close vista in IDA

Quindi, nel kernel in esecuzione, l’inizio della funzione dovrebbe trovarsi a 0xffffffffc0000000 + 0x20f. Prova a mettere un breakpoint su quell’indirizzo.

Breakpoint su module_close in gdb

Analizzeremo questo modulo nei capitoli successivi, ma per ora basta sapere che è esposto tramite il file /dev/holstein. Se esegui cat su quel device, andrai a chiamare module_close. Verifica che il breakpoint venga colpito.

Lupetto

Se vuoi caricare i simboli del driver, usa add-symbol-file: come primo argomento passi il binario del driver che hai sul tuo filesystem, come secondo il base address a cui è caricato nel kernel. Così potrai mettere breakpoint direttamente per nome di funzione.

1
# cat /dev/holstein

Puoi usare anche comandi come stepi e nexti. In definitiva, il debug in kernel space non è diverso dal debug in userland: cambia solo il modo in cui ti colleghi al bersaglio.


In questo capitolo abbiamo fermato l'esecuzione su commit_creds e osservato la memoria puntata da RDI. Prova a rifare la stessa cosa con una shell non privilegiata (per esempio con UID 1337 impostato tramite cttyhack).
Confronta poi il contenuto del primo argomento di commit_creds fra il caso root (UID 0) e il caso utente normale (UID 1337 ecc.) e osserva quali differenze compaiono.