Articoli Manifesto Tools Links Canali Libri Contatti ?
Linux / Kernel

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
di Marco Lamberto Discuti sul forum   Stampa

Perché?

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.

[ko]~$ cat /proc/ksyms | head -n 10
e0a78b20 deflateOutputPending   [ppp_deflate]
e0a7a990 _tr_stored_block       [ppp_deflate]
e0a7b790 inflateEnd     [ppp_deflate]
e0a7d6f0 inflate_trees_bits     [ppp_deflate]
e0a7ad20 _tr_flush_block        [ppp_deflate]
e0a7dab0 inflate_codes  [ppp_deflate]
e0a7b8e0 inflateInit_   [ppp_deflate]
e0a80380 ppp_deflate    [ppp_deflate]
e0a7aac0 _tr_align      [ppp_deflate]
e0a7e320 inflate_codes_free     [ppp_deflate]
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.
080481e0 <test1>:
 80481e0:	55                   	push   %ebp
 80481e1:	89 e5                	mov    %esp,%ebp
 80481e3:	83 ec 08             	sub    $0x8,%esp
 80481e6:	83 ec 08             	sub    $0x8,%esp
 80481e9:	ff 75 08             	pushl  0x8(%ebp)
 80481ec:	68 88 e3 08 08       	push   $0x808e388
 80481f1:	e8 c2 05 00 00       	call   80487b8 <_IO_printf>
 80481f6:	83 c4 10             	add    $0x10,%esp
 80481f9:	b8 01 00 00 00       	mov    $0x1,%eax
 80481fe:	c9                   	leave  
 80481ff:	c3                   	ret
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.
  1. #include <stdio.h> 
  2. #include <stdlib.h> 
  3. #include <string.h> 
  4.  
  5.  
  6. int test1(char *msg); 
  7. int test2(char *msg); 
  8. void _jmp(void); 
  9. void _hook_init(char *buf, int n); 
  10. void _hook(char *ptr, char *hook, char *bak, int n); 
  11.  
  12.  
  13. unsigned int t2_ptr    = (unsigned int)test2; 
  14. unsigned int *t2_pptr  = &t2_ptr; 
  15. char buf1[10]; 
  16. char buf2[10]; 
  17.  
  18.  
  19. int test1(char *msg) 
  20.   printf("test1 call arg:'%s'\n", msg); 
  21.   return 1; 
  22.  
  23. int test2(char *msg) 
  24.   printf("test2 call arg:'%s'\n", msg); 
  25.   return 2; 
  26.  
  27. int main(void) 
  28.   _hook_init(buf1, sizeof(buf1)); 
  29.  
  30.   printf("rv: %d\n", test1("no hook")); 
  31.   _hook((char *)test1, buf1, buf2, sizeof(buf1)); 
  32.   printf("rv: %d\n", test1("hooked")); 
  33.   _hook((char *)test1, buf2, buf1, sizeof(buf1)); 
  34.   printf("rv: %d\n", test1("no hook")); 
  35.  
  36.   return 0; 
  37.  
  38. void _hook_init(char *buf, int n) 
  39.   buf[0] = 0xff; 
  40.   buf[1] = 0x25; 
  41.   *(int *)(buf + 2) = (int)&t2_ptr; 
  42.  
  43. void _hook(char *ptr, char *hook, char *bak, int n) 
  44.   memcpy(bak, ptr, n); 
  45.   memcpy(ptr, hook, n); 
  46.  
  47. void _jmp(void) 
  48. { 
  49.   __asm__ __volatile__ ("jmp *t2_ptr;"); 
  50.   __asm__ __volatile__ ("nop; nop; nop; nop;"); 
  51. } 
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.

[ko]/tmp$ gcc -g -Wall -Wl,-N -o jmp_test jmp_test.c
[ko]/tmp$ ./jmp_test
test1 call arg:'no hook'
rv: 1
test2 call arg:'hooked'
rv: 2
test1 call arg:'no hook'
rv: 1
[ko]/tmp$
Tramite l'utility objdump possiamo disassemblare l'eseguibile dell'esempio ed andare a controllare le istruzioni assembler generate.
Contents of section .data:
 80a0cc0 00000000 00000000 302c0a08 00000000  ........0,......
 80a0cd0 00820408 d00c0a08 a02c0a08 e0130a08  .........,......
 80a0ce0 60150a08 e0160a08 00000000 00000000  `...............

[...]

Disassembly of section .init:

[...]

080481e0 <test1>:
 80481e0:	55                   	push   %ebp
 80481e1:	89 e5                	mov    %esp,%ebp
 80481e3:	83 ec 08             	sub    $0x8,%esp
 80481e6:	83 ec 08             	sub    $0x8,%esp
 80481e9:	ff 75 08             	pushl  0x8(%ebp)
 80481ec:	68 88 e3 08 08       	push   $0x808e388
 80481f1:	e8 c2 05 00 00       	call   80487b8 <_IO_printf>
 80481f6:	83 c4 10             	add    $0x10,%esp
 80481f9:	b8 01 00 00 00       	mov    $0x1,%eax
 80481fe:	c9                   	leave  
 80481ff:	c3                   	ret    

08048200 <test2>:
 8048200:	55                   	push   %ebp
 8048201:	89 e5                	mov    %esp,%ebp
 8048203:	83 ec 08             	sub    $0x8,%esp
 8048206:	83 ec 08             	sub    $0x8,%esp
 8048209:	ff 75 08             	pushl  0x8(%ebp)
 804820c:	68 9d e3 08 08       	push   $0x808e39d
 8048211:	e8 a2 05 00 00       	call   80487b8 <_IO_printf>
 8048216:	83 c4 10             	add    $0x10,%esp
 8048219:	b8 02 00 00 00       	mov    $0x2,%eax
 804821e:	c9                   	leave  
 804821f:	c3                   	ret    

[...]

08048330 <_jmp>:
 8048330:	55                   	push   %ebp
 8048331:	89 e5                	mov    %esp,%ebp
 8048333:	ff 25 d4 0c 0a 08    	jmp    *0x80a0cd4
 8048339:	90                   	nop    
 804833a:	90                   	nop    
 804833b:	90                   	nop    
 804833c:	90                   	nop    
 804833d:	5d                   	pop    %ebp
 804833e:	c3                   	ret    
 804833f:	90                   	nop    

[...]

080a0cd0 <t2_ptr>:

[...]
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.
(gdb) x/i buf1
0x80a4e10 <buf1>:       jmp    *0x80a342c
(gdb) x/i buf2
0x80a4e1a <buf2>:       push   %ebp
(gdb) 
0x80a4e1b <buf2+1>:     mov    %esp,%ebp
(gdb) 
0x80a4e1d <buf2+3>:     sub    $0x8,%esp
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, ...

È possibile consultare l'elenco degli articoli scritti da Marco Lamberto.

Altri articoli sul tema Linux / Kernel.

Risorse

  1. Il sorgente per l'esempio di hooking in user space.
    http://www.siforge.org/articles/2004/02/lkmw/jmp_test.c (2Kb)
  2. Il pacchetto completo del Linux Kernel Module Wrapper 0.1.1 per i kernel Linux della serie 2.2.x e 2.4.x.
    http://www.siforge.org/articles/2004/02/lkmw/kmw-0.1.1.tar.bz2 (31Kb)
  3. Linux Kernel Module Wrapper project at SourceForge.net.
    http://sourceforge.net/projects/kmw/
  4. LDP: The Linux Programmer's Guide
    http://www.tldp.org/LDP/lpg/index.html
  5. LDP: The Linux Kernel
    http://www.tldp.org/LDP/tlk/tlk.html
  6. LDP: Linux Kernel Hackers' Guide
    http://www.tldp.org/LDP/khg/HyperNews/get/khg.html
  7. LDP: Linux Kernel Module Programming Guide
    http://www.tldp.org/LDP/lkmpg/mpg.html
  8. LDP: Linux Kernel 2.4 Internals
    http://www.tldp.org/LDP/lki/index.html
Discuti sul forum   Stampa

Cosa ne pensi di questo articolo?

Discussioni

Questo articolo o l'argomento ti ha interessato? Parliamone.