Dirottamento (hooking) di funzioni nel kernel di Linux (prima parte)
Abstract
Rimpiazzare una primitiva qualsiasi del kernel di Linux, in qualsiasi
momento, con una propria può essere una soluzione per testare delle
modifiche allo stesso senza richiedere ricompliazioni e riavvii.
In questa prima parte vedremo come effettuare l'hooking di una funzione.
Data di stesura: 28/10/2002
Data di pubblicazione:
02/02/2004
Ultima modifica: 04/04/2006
Il processo di sviluppo di una nuova feature del kernel passa per una
fase in più rispetto allo sviluppo di altri programmi in C, oltre alla
scrittura, fra la compilazione, l'esecuzione ed il debug si interpone il
riavvio (manuale o meno!) del sistema.
Poter verificare e confrontare il comportamento del sistema con una
funzione modificata e la sua originale può diventare un procedimento
tedioso e dispendioso di tempo.
Poter caricare la nuova versione di una funzione ed al contempo mantenere
la vecchia permettendone la possibilità d'uso a discrezione dello
sviluppatore è sicuramente qualche cosa di più pratico.
Come?
Il caricamento di una funzione corrisponde ad inserire del codice e dei
dati all'interno del segmento di memoria normalmente riservato al kernel.
Questa operazione è estremamente semplice da effettuare su Linux in
quanto il sistema prevede la possibilità di caricare funzionalità
aggiuntive tramite quelli che normalmente vengono chiamati moduli.
Scrivere un modulo è solo il primo passo, più avanti nell'articolo
verranno illustrati un paio di metodi per dirottare in maniera efficace
le funzioni del kernel.
Pericolo!
Lo sviluppo del kernel è qualcosa di potenzialmente pericoloso, è
sufficiente una leggerezza del programmatore per mandare in crash il
sistema o arrivare addirittura alla corruzione (trashing) del file
system.
I moduli del kernel (lkm)
Un non-problema: il kernel non è modulare
Assumo per semplicità che il kernel su cui stiamo lavorando sia stato
configurato e compilato per supportare i moduli, in caso contrario
l'hooking non è impossibile, diviene solo un po' più complesso in
quanto bisogna andare a lavorare direttamente su /dev/kmem, ovvero la
porzione di memoria riservata al kernel!
Dirottamento semplice: la tabella delle syscalls
Nel kernel è mantenuta una lista di simboli (funzioni o variabili), per
ciascuno di questi è disponibile la locazione di memoria in cui risiede.
Questo elenco è facilmente accessibile attraverso il proc filesystem ed
è contenuto nel file /proc/ksyms. Ogni riga contiene l'indirizzo, il
nome ed eventualmente il modulo esportante il simbolo.
Il modo più elementare per operare una sostituzione di una primitiva del
kernel consiste nell'alterare la rispettiva voce nella tabella delle
syscalls sostituendone il valore con un nuovo puntatore a funzione.
Questo procedimento è trattato in dettaglio nei testi elencati nelle
risorse alla fine dell'articolo.
Dirottamento complesso: rimpiazzo dell'entry point di una funzione
Le funzioni disponibili nella tabella delle syscalls sono solo una
piccolissima parte di quelle utilizzate dal kernel.
Statisticamente parlando, è quindi più facile che la funzione che si
voglia modificare faccia parte del gruppo delle funzioni "escluse".
In questo caso la sola soluzione è alterare l'entry point della funzione
obiettivo in modo che l'esecuzione venga giudata all'interno nel nuovo
codice.
Cosa si intende per entry point
L'entry point è la locazione di memoria dove comincia il codice macchina
costituente il corpo della funzione stessa. Solitamente contiene delle
istruzioni che manipolano lo stack.
La funzione "test1" comincia all'indirizzo 0x080481e0, il corpo della
funzione termina all'indirizzo 0x08481ff con l'istruzione di ritorno
"ret".
Come sostituire l'entry point
Sia la versione originale che la versione modificata della funzione
bersaglio avranno la stessa signature (valore di ritorno, numero e tipo di
argomenti), non è dunque necessario operare sullo stack per sistemare gli
argomenti passati alla funzione ed il rispettivo valore di ritorno.
Dove toccare
La sostituzione dell'entry point originale comporta l'inserimento di un
codice operativo di salto (jump) necessario per arrivare all'entry point
della nostra funzione.
080481e0 <test1>:
80481e0: ff 25 xx xx xx xx jmp *0xxxxxxxx
[...]
08048200 <test2>:
8048200: 55 push %ebp
8048201: 89 e5 mov %esp,%ebp
[...]
Cosa sostituire
Lavorando in ASM per processori x86, l'istruzione adottata è una jump
con indirizzamento indiretto (op code 0x25ff).
L'argomento della jump sarà una locazione di memoria contenente
l'indirizzo reale a dove saltare.
È stato adottato questo sistema perché gli indirizzi delle funzioni da
dirottare saranno contenute in un vettore.
Un esempio di hooking in user space
Il sorgente in C qui elencato illustra la sostituzione dell'entry point
per dirottare le chiamate alla funzione test1 sulla funzione test2.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int test1(char *msg);
int test2(char *msg);
void _jmp(void);
void _hook_init(char *buf, int n);
void _hook(char *ptr, char *hook, char *bak, int n);
unsigned int t2_ptr = (unsigned int)test2;
unsigned int *t2_pptr = &t2_ptr;
char buf1[10];
char buf2[10];
int test1(char *msg)
{
printf("test1 call arg:'%s'\n", msg);
return 1;
}
int test2(char *msg)
{
printf("test2 call arg:'%s'\n", msg);
return 2;
}
int main(void)
{
_hook_init(buf1, sizeof(buf1));
printf("rv: %d\n", test1("no hook"));
_hook((char *)test1, buf1, buf2, sizeof(buf1));
printf("rv: %d\n", test1("hooked"));
_hook((char *)test1, buf2, buf1, sizeof(buf1));
printf("rv: %d\n", test1("no hook"));
return 0;
}
void _hook_init(char *buf, int n)
{
buf[0] = 0xff;
buf[1] = 0x25;
*(int *)(buf + 2) = (int)&t2_ptr;
}
void _hook(char *ptr, char *hook, char *bak, int n)
{
memcpy(bak, ptr, n);
memcpy(ptr, hook, n);
}
void _jmp(void)
{
__asm__ __volatile__ ("jmp *t2_ptr;");
__asm__ __volatile__ ("nop; nop; nop; nop;");
}
L'esempio è piuttosto banale, la main chiama tre volte la funzione test1,
la seconda volta però viene operato lo scambio con test2 ed infine,
nell'ultimo passaggio, ripristinata all'originale test1.
Entrambe le funzioni accettano una stringa come argomento e ritornano un
intero. Il valore di ritorno è differente e viene stampato all'interno del
corpo della main come ulteriore conferma dell'avvenuto scambio.
Le parti coinvolte direttamente nella procedura di hooking sono marcate in
rosso. In blu invece sono evidenziate una variabile ed una funzione che ci
serviranno fra poco per fare alcuni confronti sull'organizzazione delle
istruzioni ed degli indirizzi.
Per compilarlo in modo che funzioni è necessario passare lo switch -N al
linker del gcc in modo che venga creato un eseguibile con la sezione text
(le istruzioni del programma) modificabile. Il segmento di codice (text)
solitamente è read-only, senza l'uso del "-N" il programma d'esempio
genererebbe una violazione di segmento.
Qui sotto è possibile vedere l'output del programma d'esempio.
La funzione _jmp contiene all'indirizzo 0x8048333 l'istruzione
che viene inserita "manualmente" dalla funzione _hook_init.
L'istruzione di salto andrà ad eseguire le istruzioni a partire
dall'indirizzo contenuto nella variabile t2_ptr.
Ciò che avviene a run time quando viene operato l'hooking possiamo vederlo
direttamente grazie al debugger gdb. Lanciando gdb con argomento il
programma d'esempio e disponiamo un breakpoint all'inizio della
main.
[ko]/tmp$ gdb jmp_test
GNU gdb Red Hat Linux (5.3post-0.20021129.18rh)
Copyright 2003 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show warranty" for details.
This GDB was configured as "i386-redhat-linux-gnu"...
(gdb) break main
Breakpoint 1 at 0x80481e0: file jmp_test.c, line 33.
L'esecuzione di jmp_test si interrompe come voluto alla prima
istruzione della main. Dopo aver proceduto con
l'inizializzazione delle variabili per l'hook (funzione
_hook_init) disassembliamo test1 e
test2 per verificare le istruzioni che contengono.
(gdb) run
Starting program: /tmp/jmp_test
Breakpoint 1, main () at jmp_test.c:33
33 _hook_init(buf1, sizeof(buf1));
(gdb) next
35 printf("rv: %d\n", test1("no hook"));
(gdb) disassemble test1
Dump of assembler code for function test1:
0x08048190 <test1+0>: push %ebp
0x08048191 <test1+1>: mov %esp,%ebp
0x08048193 <test1+3>: sub $0x8,%esp
0x08048196 <test1+6>: sub $0x8,%esp
0x08048199 <test1+9>: pushl 0x8(%ebp)
0x0804819c <test1+12>: push $0x808f8a8
0x080481a1 <test1+17>: call 0x8048994 <printf>
0x080481a6 <test1+22>: add $0x10,%esp
0x080481a9 <test1+25>: mov $0x1,%eax
0x080481ae <test1+30>: leave
0x080481af <test1+31>: ret
End of assembler dump.
(gdb) disassemble test2
Dump of assembler code for function test2:
0x080481b0 <test2+0>: push %ebp
0x080481b1 <test2+1>: mov %esp,%ebp
0x080481b3 <test2+3>: sub $0x8,%esp
0x080481b6 <test2+6>: sub $0x8,%esp
0x080481b9 <test2+9>: pushl 0x8(%ebp)
0x080481bc <test2+12>: push $0x808f8bd
0x080481c1 <test2+17>: call 0x8048994 <printf>
0x080481c6 <test2+22>: add $0x10,%esp
0x080481c9 <test2+25>: mov $0x2,%eax
0x080481ce <test2+30>: leave
0x080481cf <test2+31>: ret
End of assembler dump.
Successivamente l'esecuzione della funzione _hook opera la
modifica dell'entry point di test1. Le istruzioni da
test1+6 a test1+10 vanno ignorate in quanto sono istruzioni "finte" (non
verranno mai eseguite) dovute al contenuto del buffer di swap
sovradimensionato.
(gdb) next
test1 call arg:'no hook'
rv: 1
36 _hook((char *)test1, buf1, buf2, sizeof(buf1));
(gdb) next
37 printf("rv: %d\n", test1("hooked"));
(gdb) disassemble test1
Dump of assembler code for function test1:
0x08048190 <test1+0>: jmp *0x80a342c
0x08048196 <test1+6>: add %al,(%eax)
0x08048198 <test1+8>: add %al,(%eax)
0x0804819a <test1+10>: jne 0x80481a4 <test1+20>
0x0804819c <test1+12>: push $0x808f8a8
0x080481a1 <test1+17>: call 0x8048994 <printf>
0x080481a6 <test1+22>: add $0x10,%esp
0x080481a9 <test1+25>: mov $0x1,%eax
0x080481ae <test1+30>: leave
0x080481af <test1+31>: ret
End of assembler dump.
L'ispezione della variabile t2_ptr
mostra l'indirizzo della variabile
t2_ptr usato come argomento per la jmp ed il valore che
contiene corrispondente all'indirizzo dell'entry point di
test2.
(gdb) print (unsigned int *)&t2_ptr
$1 = (unsigned int *) 0x80a342c
(gdb) print (unsigned int *)t2_ptr
$2 = (unsigned int *) 0x80481b0
A questo punto possiamo anche controllare cosa contengono i due buffers
utilizzati per salvare e scambiare gli entry points.
Uso il comando "x" di gdb con il formato "i" per mostrare i dati contenuti
nella variabile come istruzioni.
L'ispezione di buf1 rivela il codice della jmp usata per
saltare a test2 (successive a questa ci sono le istruzioni
"spurie", ma non le mostro per brevita`). Mentre buf2 contiene
una copia delle istruzioni iniziali di test1 che verranno
usate per ripristinare la funzione, disattivando il dirottamento.
Conclusioni
In questa prima parte abbiamo visto come operare il dirottamento di una
funzione ad un'altra con prototipo analogo in maniera trasparente.
Nel prossimo articolo vedremo come opera KMW per trasformare un sorgente
qualsiasi del kernel in un modulo ed agganciarsi alle primitive del kernel.
Informazioni sull'autore
Marco Lamberto, laureato in Informatica presso la Statale di Milano, con diversi anni di esperienza sistemistica, di sicurezza e sviluppo prevalentemente in ambienti UNIX (Linux in primis) con linguaggi come C, Java, Perl, PHP, XML, HTML, ...