# Buffer Overflows Tout ce qu’on va voir dépend énormément du matériel sur lequel on va exécuter le programme Dans nos programmes on a des centaines d’appels imbriqués les uns dans les autres Dans les processeurs modernes on a 12 / 16 MB, ces informations ne peuvent pas être stockés sur les processeurs. On stocke ça dans la pile, qui se situe dans la mémoire avec les variables locales, les paramètres, etc. Dans la pile : enchaînement de structures - Paramètres - Adresse de retour - Contexte - Permet de bien encadrer les variables locales (un symbole aura une position fixe dans le contexte, plutôt qu’avoir une position variable dans la pile en fonction du nombre d’éléments déclarés) - Variable Locales En réalité avec l’ASLR (Address-Space Layout Randomization) on sait pas à l’avance où est la pile, le loader va à chaque chargement du programme donner une adresse virtuelle différente à chaque section (stack, heap, data, etc.) Si on codait un tas / heap - Une liste chaînée, le premier élément correspond à l’entièreté du tas - Premier maillon va être la première allocation (début du segment mémoire alloué) et pointe vers la prochaine zone mémoire allouée En soi, on peut avoir des débordements partout, mais nous, on va s’intéresser à la pile et à la structure de la pile. Dans le code d’exemple - `string` est une variable locale, donc à l’exécution elle n’existera que quand la fonction `process_string` sera invoquée, et donc il faut que dans le binaire j’ai les informations pour initialiser `string` avec la bonne valeur, donc cette chaîne va être copiée dans une section `.rodata` du binaire - En compilant avec `gcc -g` j’inclus dans le binaire une section dédiée à la table des symboles (au lieu de les strip à la compilation) → Utile pour le debug mais facilite la rétro-conception Machine 64-bit Linux, le code se situe dans telle partie de la mémoire (`0x5555...`), donc si dans la pile je vois une succession de `5`, je sais que c’est certainement une adresse de retour. GDB quand on le lance, il demande au noyau à instancier un autre processus, et comme c’est lui qui l’instancie il a des droits supplémentaires sur ce processus, et demande donc un accès en lecture / écriture sur le code du processus qu’il debug (alors que l’autre processus quand il s’exécute il n’a accès à son propre code qu’en lecture et exécution) ```c for () { char *p = g; for (int i = 0; i < 1024; i++) { printf("%x\n", p[i]); } } ``` Fun fact : Sur les anciens hyperviseurs, les OS virtualisés s’exécutaient comme des processus normaux, et tous les appels systèmes / appels privilégiés étaient interceptés par l’hyperviseur qu’il traitait ensuite. On pouvait se rendre compte qu’on était virtualisés si les additions étaient hyper rapides, mais que l’accès aux registres étaient démesurément plus lents. Aujourd’hui, on a du support matériel (instructions spéciales) pour ça. Si j’exécute `info reg` dans GDB, c’est le code de GDB qui est exécuté dans le contexte du processus GDB, donc si j’essayais de copier les registres, ce seraient les registres avec les valeurs correspondant au processus de GDB. Donc en réalité, c'est un appel système (`ptrace gettrace` quelque chose) pour demander au noyau les infos des registres pour l’autre processus sur lequel il a les droits. Avec notre écriture en indice 9 alors que le tableau a 8 éléments, on a débordé du tableau, on a écrit ailleurs dans la stack frame, et en l’occurrence, on a écrit dans le canary (qui est une valeur placée là et vérifiée pour être sûr qu’elle n’a pas changé et détecter les overflow). En écrasant le *null byte* qui termine la chaîne et en mettant un autre caractère, le caractère est simplement rajouté, et l’affichage se termine immédiatement après ce nouveau caractère, car d’autres *null bytes* sont trouvés après ce nouveau caractère. En particulier, le LSB du canary dans la pile est un *null byte* (\0) pour éviter la lecture de la pile. Le canary était statique mais est actuellement dynamique, généré par le noyau. La limite de la pile est définie à 8 MB (en soi, on n'a pas de contrainte physique, c’est dans la mémoire et on peut ajuster cette limite, mais on a pas besoin de plus) En général à la compilation dans la stack frame on va avoir d’abord les tableaux et ensuite les variables atomiques → On va vouloir protéger les variables atomiques en mettant d’abord les tableaux et essayer de détecter les overflows avec des canaries. Quand on modifie l’adresse de retour plusieurs scénarios : - On sort de l’espace de code ça plante - Par super chance ou malchance, on saute à un emplacement du code aligné avec le reste du programme et ça marche - On saute en plein milieu d’une instruction - Par chance même en plein milieu ça donne une instruction valide (très rare) - Sinon instruction non valide et ça plante `(bad)` → C’est une mauvaise instruction *aux yeux de GDB*, rien ne nous dit que c’est pas une instruction supportée (et potentiellement cachée par le manufactureur) Quand on jump à `exec` on va effectivement donner la main à la fonction `exec`, mais la fonction `exec` vérifie avant que la pile soit dans un bon état (alignés sur 16 bits) Se retrouver dans une pile qui n’est pas alignée - On déroute brutalement → On sort de la fonction et au lieu de continuer la fonction appelante, on commence directement une autre fonction et on est plus alignés On n’est pas obligé de sauter au début de la fonction `exec`, on peut mettre comme adresse de retour une adresse au milieu du code de `exec` et comme ça on reste aligné (au lieu de setup à nouveau une frame, etc. et désaligner, c’est comme si on avait commencé l’exécution de `exec` avant `process_string` et qu’on reprenait l’exécution) Quand est-ce que `exec` s’exécute ? Une fois que `process_string` est terminée (on a modifié l’adresse de retour de `process_string`) Exemple de code menant à des buffer overflows ```c tab[i] = val; // Sans vérifier i strcpy(dst, src); // Sans vérifier la taille de src strlcpy(dst, src, TAILLE_MAX); // Faire attention au type de dst // (dans le cas où c'est un pointeur) // Débordement d'entiers ``` **L’attaquant peut vouloir** - exécuter son propre code - en envoyant son propre code - avec du craft de gadgets (Return Oriented Programming) - modifier des variables locales - lire des infos **Comment se prémunir ?** - Test des valeurs (respect de la taille de tableau, etc.) - ASLR - Les informations qu’on trouve dans une exécution ne sont pas forcément utiles pour une autre exécution - Dans GDB, on désactive cette protection - On randomise les pages (de 1024 octets), on ne randomise pas chaque instruction, chaque ligne, etc. Donc c’est pour ça que même avec l’ASLR notre “”exploit”” fonctionne toujours, on a toujours le même offset entre l’endroit de retour réel et l’endroit où se trouve le code qu’on veut exécuter - Dépend de la capacité du noyau à générer des nombres aléatoires : si on trouve un biais ou qu’on arrive à influencer cette capacité, malgré avoir l’ASLR on peut en réduire l’impact et conduire des attaques. - Bit NX / RELRO / Pagination - Canary - Faiblesse du canary : on peut le bruteforce (octets par octets) -> seulement 1024 possibilités (on fait des forks pour que le canary reste le même) - Réorganisation des variables locales - Vérification formelle du code (impossible) - Shadow Stack - À chaque fois qu’on rentre dans une fonction, on copie l’adresse de retour dans une autre pile distante, et avant de pop la stack frame et d’utiliser l’adresse de retour on compare pour vérifier que les deux adresses de retour sont les même, sinon on sait qu’il y a eu débordement Avec des tests on ne prouve pas l’absence de vulnérabilité, si on fait des audits on va nous allouer du temps (e.g. 2 mois) pour auditer le code, et ça va représenter un “temps attaquant équivalent” (si on trouve rien en 2 mois, on considère qu’un attaquant mettra plus de 2 mois à trouver une vulnérabilité). Dans ce contexte l’obfuscation peut ralentir, mais ne change pas la sémantique du problème, donc pas réellement d’impact : on est toujours aussi vulnérable, juste moins rapidement. Par binôme : développer un petit code vulnérable et un code malveillant et s’assurer qu’on puisse quand même l’exécuter malgré l’application de toutes ces mesures de sécurité Faiblesse d’ASLR : exemple du serveur qui fait des forks - Le noyau ne peut pas s’amuser à changer la pile quand il fait un fork (il peut y avoir des adresses absolues dans la pile, par exemple les adresses de retour, de code, etc.), donc il fait une copie *exacte* du processus, de la pile, de *tout* (copy on write → on ne duplique la page que lors d’une modif, sinon de base c’est la même pile qui est partagée). Une mitigation possible serait de rajouter un delta dans la pile copiée du fork ```c void f(void) { char tab[128]; int i = getsn(tab, 100); printf("%s\n", tab); getsn(tab + i, 128 - i); unsigned int j; getd(dj); if (j <= 128) { tab[j] = j; } } ``` si $tab[i]$ alors $i\in[0;127]$ int $i = [1;100]$ len(tab)$=[1;100]$ int(128-i)[28,127] len(tab+i)=[1;127] Où Lim ne doit pas être dépassée. On voit que sur la variation temporelle d’une valeur pour une seule execution peut dépasser ce qui n’était pas attendu au préalable.
{"tags":"TLSSEC"}