Espace mémoire et pointeurs invalides
Allons maintenant un peu plus en prondonfeur dans le kernel, afin d'exploiter l'inexploitable : le déréférencement des pointeurs nuls. Le nom parle de lui-même : cette vulnérabilité apparaît dès lors qu'un pointeur nul
est utilisé en tant que pointeur valide dans une expression. En réalité, cette vulnérabilité correspond à l'utilisation d'un
pointeur invalide, c'est-à-dire pointant en dehors de l'espace d'adresses allouées du processus. Si l'attaquant parvient
à réserver l'espace mémoire ainsi référencé, alors il contrôle le pointeur et son utilisation dans le programme.
Ceci se prête tout
particulièrement à l'exploitation du kernel. En effet, lorsqu'un processus effectue un appel au kernel, par exemple à travers
un appel système ou l'utilisation d'un driver quelconque, celui-ci partage complètement son espace mémoire avec celui du
kernel, la séparation s'effectuant simplement par la limite des adresses (userland < 0xc0000000 >= kernel space). En
réalité, si depuis le processus en mode utilisateur on tente d'accéder à des adresses de l'espace kernel, bien sûr une exception
(segmentation fault) sera levée. Par contre, le contraire n'est pas vrai, car le kernel est en mode superviseur et peut
accéder à n'importe quel espace en mémoire, pourvu que celui-ci soit alloué.
Au final, l'exploitation des NULL pointer
dereferences est simple : allouer l'espace mémoire 0x00000000. Puisque un pointeur nul pointe vers 0, alors nous contrôlons
son contenu et sommes à même de modifier le comportement attendu dès que son contenu est utilisé. Très bien, mais est-ce
réellement commun ? La réponse est oui, car il y a plusieurs moyens d'introduire des pointeurs nuls :
- Utilisation d'un pointeur non-initialisé (par exemple, une fonctionnalité non-implémentée).
- Echec forcé d'allocation de mémoire. Lors de la création dynamique de structures mémoires sur le heap (ce qui est courant dans
le kernel puisque la taille de la pile est très réduite), il peut y avoir échec si un argument passé à
kmalloc est invalide, par exemple s'il est contrôlé par l'attaquant. Ainsi, passer une valeur comme 0, une valeur bien trop grande ou un type de page inexistant, forceraient l'échec de l'allocation et peuvent renvoyer un pointeur nul (ou -1 selon les cas).
- Epuisement de la mémoire. Dans ce cas, les kmalloc échoueront quasi systématiquement.
- Erreurs introduites par optimisation du compilateur. Par exemple, certains compilateurs peuvent supprimer des if (variable == NULL), sous prétexte qu'ils sont certains que le pointeur a été initialisé. Mais n'a-t-il pas été initialisé à NULL ??
Dans tous les cas, l'absence de vérification de la valeur avant son utilisation peut donc entraîner l'utilisation du pointeur
invalide.
Illustration: kerneltime_device
Afin de comprendre l'exploitation de ce genre de failles, il faut tout d'abord en recréer une. Celles-ci
peuvent se trouver dans n'importe quel bout de code s'exécutant en mode kernel. Donc, le kernel, mais aussi les patchs
exotiques et surtout les modules kernels, notamment tout ce qui est drivers provenant de sources extérieures. Afin de
reproduire ce genre de vulnérabilités, nous introduisons donc le kernel module kerneltime_device, qui est un driver de
fichier, qui permet de lire l'heure du système :
/* IOCTL. Can be used to read the time, to reset it or to update it */
static int device_ioctl(struct inode *inode, struct file *file, unsigned int ioctl_num, unsigned long ioctl_param) {
L'intégralité du code est disponible ici. Je vous conseille de télécharger l'intégralité du dossier, car j'ai mis toutes les dépendances dans le makefile. L'essentiel est cependant dans
ces deux fonctions ci-dessus. La première méthode est effectuée lorsqu'un read est fait sur un fichier pour lequel le driver
associé est celui-ci. Il vérifie que time_info n'est pas nul, et si ce n'est pas le cas, il recopie son contenu dans le
buffer en espace utilisateur qui a été donné en argument. Il n'y a pas de write, mais l'IOCTL du fichier nous permet
plusieurs commandes spéciales. Il y a la commande RESET_TIME qui alloue un espace mémoire pour time_info et son contenu
time_info->info si time_info est nul. Ensuite, il mets l'adresse de la fonction current_kernel_time() dans
time_info->update. Il y a également la commande UPDATE_TIME (qui est toujours effectuée à la suite du RESET_TIME) qui,
si time_info->info n'est pas nul, exécute la fonction dans time_info->update et place le résultat à l'adresse pointée par
time_info->info. Vérifions tout d'abord la bonne marche de ce driver avec un petit programme de récupération du temps
kernel :
/*
* use_kerneltime.c - Demonstrate the usage of kerneltime_device device driver.
*/
Nous ouvrons donc le fichier DEVICE_NAME, puis nous demandons à l'IOS d'effectuer un appel IOCTL permettant d'allouer les
structures mémoires et de les mettre à jour, puis un deuxième permettant de les lire. Ensuite, on attend une seconde
et on réitère la même opération (car read est un alias dans notre cas d'un appel à IOCTL avec IOCTL_READ).
$ make
make -C /lib/modules/2.6.30.9/build/ M=. modules
make[1]: Entering directory `/usr/src/linux-source-2.6.30.9'
WARNING: Symbol version dump /usr/src/linux-source-2.6.30.9/Module.symvers
is missing; modules will have no dependencies and modversions.
CC [M] kerneltime_device.o
Building modules, stage 2.
MODPOST 1 modules
CC kerneltime_device.mod.o
LD [M] kerneltime_device.ko
make[1]: Leaving directory `/usr/src/linux-source-2.6.30.9'
cc use_kerneltime.c -o use_kerneltime
cc nullderef_oops.c -o nullderef_oops
cc nullderef_panic.c -o nullderef_panic
cc nullderef_func_root.c -o nullderef_func_root
cc nullderef_write_root.c -o nullderef_write_root
$ su -
Password:
# make install_mod
make -C /lib/modules/2.6.30.9/build/ M=. modules
make[1]: Entering directory `/usr/src/linux-source-2.6.30.9'
WARNING: Symbol version dump /usr/src/linux-source-2.6.30.9/Module.symvers
is missing; modules will have no dependencies and modversions.
CC [M] kerneltime_device.o
Building modules, stage 2.
MODPOST 1 modules
LD [M] kerneltime_device.ko
La structure de données struct timespec donne d'une part le temps en secondes écoulées, et d'autres part la fraction
de seconde écoulée en nanosecondes. On remarque d'ailleurs que le sleep d'une seconde n'est pas d'une précision suprême. Ceci
dit, notre programme et notre driver ont l'air de fonctionner correctement.
NULL pointer dereference
La vulnérabilité est évidente ici. En effet, si on fait un appel à ioctl avec comme argument IOCTL_UPDATE_TIME avant d'effectuer un appel à IOCTL_RESET_TIME, alors, time_info vaudra NULL et les contenus time_info->info et time_info->update seront recherchés à l'adresse 0 (qui par défaut n'est pas allouée).
Essayons d'observer ce comportement avec le programme nullderef_oops, qui ouvre un fichier kerneltime_device et qui
effectue un IOCTL UPDATE_TIME sans n'avoir effectué aucun reset :
$ ./nullderef_oops
Killed
$ dmesg
[...]
BUG: unable to handle kernel NULL pointer dereference at (null)
IP: [<f806219a>] device_ioctl+0x4a/0xa4 [kerneltime_device]
*pde = 00000000
Oops: 0000 [#1] SMP
[...]
Pid: 6267, comm: nullderef_oops Tainted: P (2.6.30.9 #1)
EIP: 0060:[<f806219a>] EFLAGS: 00210246 CPU: 1
EIP is at device_ioctl+0x4a/0xa4 [kerneltime_device]
[...]
Process nullderef_oops (pid: 6267, ti=f5dca000 task=f5cead80 task.ti=f5dca000)
[...]
Call Trace:
En effet, le process crash et le call trace nous prouve que c'est à l'intérieur du kernel, dans l'IOS. Le message est
explicite : impossible de prendre en charge le déréférencement d'un pointeur nul. Il est maintenant temps d'étudier les
manières de l'exploiter.