Table des matières
Maintenant nous allons rentrer dans le vif du sujet. Exploiter cela veut dire modifier à son avantage le fonctionnement d'un programme. En effet, nous allons voir qu'il est possible, dans certaines conditions, de tirer profit d'un programme, afin de lui faire faire des actions non prévues (comme binder un shell sur une socket). Pour ceux qui ne le savent pas, binder un shell revient à faire tourner un interpreteur de commande (shell), sur un port réseau de la machine, afin de pouvoir éxécuter des commandes à distance (genre make, ou useradd :) )
Avant de commencer à faire segfaulter des programmes, voici ce que vous devez savoir sur le contexte de l'exploitation d'un bout de code.
Chaque cas est relativement précis, et les techniques sont rarement interchangeables, mais on peut dégager deux grandes constantes:
soit l'attaque est réalisee en local, à l'aide d'un shell sur la machine
soit à distance (remote), à l'aide d'une connexion réseau
En local, les programmes ciblés seront les setuid, qui permettent d'obtenir des privilèges plus élevés (root le plus souvent)
En remote, ce seront les services qui seront attaqués, afin de permettre à un attaquant d'obtenir un shell sur votre machine (pour commencer des attaques locales, si il n'a pas déjà un compte uid0).
De manière générale, tous les bouts de codes qui manipulent des données étrangères, tel qu'un browser http, un client mail, un lecteur multimédia, etc, etc, sont des cibles potentielles pour une attaque remote ou local.
Note: nous n'avons pas parlé du kernel. Je vous laisse deviner le contexte d'une faille dans un appel système, ou dans la pile tcp... L'exploitation d'un tel bug est tout à fait réalisable, et aboutit soit à un reboot de la machine, soit à un compte root :)
Voici la liste des erreurs qui peuvent aboutir à une corruption de la mémoire. En effet, quand on parle d'exploiter un logiciel ca revient à lui faire faire quelque chose d'anormal, et le plus souvent cela aboutit à une corruption de la mémoire. Nous verrons juste aprés comment, en écrivant un byte ou deux de trop au mauvais endroit, on faire éxécuter du code arbitraire.
Mauvaise gestion d'un buffer :
for (i = 0; i < sizeof(dest_buf); i++)
{
...
i++;
...
}
dest_buf[i] = '\0';
Mauvaise utilisation de fonctions standard :
strncpy(buf, input_buffer, sizeof(buf)); /* Cela ne copie pas le null byte de fin de chaîne si strlen(input_buffer) > sizeof(buf) */
strncat(buf, input_buffer, sizeof(buf) - strlen(buf)); /* ERREUR: strncat copie le null byte de fin de chaîne */
Mauvaise condition avec des valeurs signées :
void foo(int size)
{
char buf[4096];
if (size > sizeof(buf)) /* ERREUR: si size > 0x80000000 alors le test ne marche pas */
return ;
...
}
Erreur de conception qui peuvent aboutir à de mauvaises manipulations.
Ok, si une de ces erreurs de programmation arrive, et bien il y a de fortes chances que la mémoire soit corrompue. Regardons à présent comment tirer profit de ce genre de situations.
Dans la pile et dans le tas, des données de gestion sont entrallacées avec des données utilisateur. Il ne faut pas que les informations liées au déroulement du programme soit modifiées. Voici la liste des éléments dangereux en mémoire :
Les adresses de retour des fonctions sur la stack sont les valeurs de prédilection pour un pirate ! En effet, si il arrive a modifier cette adresse, alors il peut rediriger l'execution du programme vers du code arbitraire
La sauvegarde du stack frame pointeur est elle aussi dangereuse, elle permet de repositioner la stack à la fin d'une fonction. Si elle est modifiée, alors le retour de fonction de la fonction appellante ne sera pas bon (cf bullet ci-dessus)
Les structures de gestion du mémory allocator sont elles aussi dangereuses. Il est tout à fait possible de manipuler free() ou malloc() afin de lui faire écrire une ou deux valeurs là ou il faut !
Les entrée de la Global Offset Table (GOT). C'est un tableau qui est utilisé pour résoudre les addresse dynamique, tel que les adresses des fonctions partagée (libc par exemple). Si l'entrée d'une fonction qui va être appelé est remplacé par l'adresse d'un shellcode, alors il sera éxécuté lors de l'appel de cette fonction. objdump -R pour avoir l'adresse du tableau
Les destructeur (DTOR) de la libc. C'est aussi un tableau de pointeur de fonctions qui sera éxécuté à la fin du programme.
Note: cette liste n'est pas exhaustive, ce sont juste les élèments les plus courrants; de manière générale, une corruption de la mémoire n'est pas souhaitable en sécurité ! Regardons à présent les vecteurs d'attaques
Lorsqu'un programme au format ELF est lance, le noyau organise la memoire virtuelle allouee au processus : des plages memoire sont reservees pour les besoins du programme (pile, tas, donnees, code, etc). Ainsi chaque programme userland (ring 3) croit être le seul à tourner : c'est ce qu'on appelle le mode protégé
Chaque processus a un espace memoire virtuelle compris entre [0x0; 0xFFFFFFFF]. Voici comment il est divise :
Celle qui nous intéresse est la zone userland. Voici ce qu'on y trouve :
Les variables locales sont regroupees dans une zone memoire reservee a l'execution du programme. Les fonctions pouvant s'invoquer de maniere recurrente, le nombre d'instances d'une variable locale n'est pas connu a l'avance. Elles seront donc placees, au moment de leur definition dans la pile du processus (stack).
La stack commence a partir de 0xBFFFFFFF [1] (linux) et 0xBFBFFFFF (*bsd) et grandit vers le bas.
pour savoir comment est segmenté la mémoire d'un programme : # cat /proc/<PID>/maps. Chaque ligne décrit une zone mappée, vous aurez :
[1]: a partir du noyau linux 2.6.12 les adresses memoire sont randomisées... 'echo 0 > /proc/sys/kernel/randomize_va_space' pour désactiver cette protection.
----------------- 0xffffffff | | | | | KernelLand | | | | | ----------------| 0xbfffffff | | | | | UserLand | | | | | ---------------- 0x00000000
|----------------| 0xbfffffff ||--------------|| || Path || || (/home/...) || ||--------------|| || Env star || ||(PATH=...) || ||--------------|| || Argc star || ||(/home/...) || ||--------------|| || Envp[] || || || ||--------------|| || Argv[] |--> La stack || || ||--------------|| || Vars locals ---> Des fonctions avant celle du main || || ||--------------|| || *Envp[] || ||--------------|| || *Argv[] || ||--------------|| || Argc || ||--------------|| || Return addr ---> Adresse de l'instruction suivant le call de main (cf C calling convention) ||--------------|| || Ebp ---> Base Pointer address de la fonction appelante ||______________|| || Variables || || locales || || de main || ||______________|| | | | | | ---> Grande zone non-allouee | | | | | | ||--------------|| ||mmap() |-> Librairies chargées dynamiquement par le programme ||______________|| | | ||--------------|| ||lib.so |-> Librairies partagées ||--------------|| | | | | | | ||--------------|| || || || |-> Le tas qui contient les zones allouées par malloc en autre. || Heap || ||______________|| | | | | ||--------------|| || Exécutable || ||______________|| |________________| 0x0
Un shellcode est du code executable independant de sa position, destine a etre execute dans le contexte d'une application. Il est generalement copie en memoire grace a des fonctions de manipultation de chaines de caracteres et donc ne peut contenir de caracteres nuls. Il peut contenir plusieurs types de code en fonction de son utilisation, par exemple :
Voici le hello world les shellcodes :
char shellcode[] =
// setuid(0);
"\x31\xdb" // xorl %ebx,%ebx
"\x8d\x43\x17" // leal 0x17(%ebx),%eax
"\xcd\x80" // int $0x80
// exec('/bin/sh');
"\x31\xd2" // xorl %edx,%edx
"\x52" // pushl %edx
"\x68\x6e\x2f\x73\x68" // pushl $0x68732f6e
"\x68\x2f\x2f\x62\x69" // pushl $0x69622f2f
"\x89\xe3" // movl %esp,%ebx
"\x52" // pushl %edx
"\x53" // pushl %ebx
"\x89\xe1" // movl %esp,%ecx
"\xb0\x0b" // movb $0xb,%al
"\xcd\x80"; // int $0x80
Il existe des variantes de shellcode qui sont resistantes a des 'tolower' ou qui ne sont composees que de caracteres ascii, ou qui sont polymorphes, afin de dejouer la plupart des IDS.