Les programmes que nous utilisons au quotidien sont majoritairement écrits par des humains. Malheureusement, l’erreur est humaine et il est commun pour le programmeur d’introduire des bugs dans les programmes qu’il écrit. Ces bugs prennent des formes aussi diverses qu’étonnantes, allant du comportement imprévisible et déroutant d’une fonctionnalité toute entière du programme, à la modification involontaire d’un emplacement mémoire passant inaperçue. Si les bugs les plus visibles sont facilement détectés et donc corrigés, les plus sournois sont plus difficiles à déclencher et leur effet n’est pas forcément visible du point de vue de l’utilisateur. Malheureusement, le caractère spectaculaire d’un bug n’est en rien un indicateur de sa gravité. Bien que certains bugs ne soient pas immédiatement visibles par l’utilisateur, ils peuvent avoir des conséquences désastreuses sur la sûreté ou la cohérence du système. C’est-à-dire que le programme peut modifier des données du système hôte ou encore effectuer des opérations qu’il n’était pas censé effectuer, comme afficher des informations sensibles ou les modifier. Par exemple, si un bug introduit une importante fuite de mémoire, l’utilisateur ne le verra probablement pas tout de suite, néanmoins, au fur et à mesure de l’exécution du programme, la mémoire que ce dernier gaspille aura un impact croissant sur les performances du système tout entier, jusqu’à possiblement l’empêcher complètement de fonctionner.
Par ailleurs, il est possible d’exploiter ces bugs afin de détourner le programme et d’en tirer profit. Un programme qui permet des lectures ou écritures en mémoire à des emplacements que le programmeur n’a pas prévu est vulnérable face aux attaques par corruption de mémoire. Ces attaques sont particulièrement dangereuses et peuvent permettre de prendre complètement le contrôle du processus cible, le forçant à exécuter du code arbitraire décidé par l’attaquant (Szekeres et al. 2013). Ces attaques prennent de nombreuses formes en fonction de la manière dont le programme est détourné. Il est par exemple possible de provoquer des fuites de données sensible en modifiant l’adresse d’un pointeur que le programme est censé afficher, tout comme il est possible de forcer le programme à exécuter une portion de code en modifiant des données de contrôle, le poussant à prendre un branchement plutôt qu’un autre. Le classement 2021 des Common Weakness Enumeration (CWE) de l’organisation américaine MITRE place ces attaques parmi les plus dangereuses, avec les écritures en dehors des bornes en première position et les lectures en dehors des bornes en troisième position (MITRE 2021). Naturellement, de nombreuses recherches ont été effectuées afin de classifier et documenter précisément ce type d’attaques et pouvoir s’en prémunir efficacement (Abadi et al. 2005 ; Shoshitaishvili et al. 2016 ; Szekeres et al. 2013). Toutefois, malgré la diversité et la richesse des approches utilisées pour contrer ces attaques, elles restent un problème encore aujourd’hui. En effet, les approches de défense contre les attaques par corruption de mémoire souffrent toutes de limitations : leur impact sur les performances, leur manque de précision, des faiblesses inhérentes au modèle d’attaque envisagé, le besoin de recompiler le programme ou encore, l’impossibilité de greffer une protection sur un processus en cours d’exécution. De plus, les attaquants découvrent en permanence de nouvelles manières de contourner ces protections, forçant les défenseurs à se réinventer sans cesse. Cette course à l’armement a pour conséquence de devoir faire un compromis entre performances et sécurité. Or ce compromis devrait être renégocié sans cesse par l’utilisateur en fonction des risques encourus et de ses ressources.
Les attaques par corruption de mémoire
Les attaques par corruption de mémoire sont des attaques consistant à tirer profit d’un bug du programme cible sur ses accès à la mémoire. D’après Szekeres et al. 2013, il suffit, pour déclencher une attaque, que l’attaquant prenne le contrôle d’un pointeur afin d’effectuer une lecture ou une écriture arbitraire dans l’espace d’adressage. Une des techniques les plus simples et répandues pour corrompre un pointeur est le dépassement de tampon (buffer overflow). Il s’agit de profiter de l’absence de tests de bornes lors d’écriture dans un tampon afin d’écrire plus loin dans la mémoire que dans le tampon prévu à cet effet. C’est typiquement ce qui se produit lors de l’appel à des fonctions dépréciées de la bibliothèque C qui ne vérifient pas les bornes des tampons dans lesquels elles écrivent.
Il est même possible de détourner le flot de contrôle du programme de manière plus radicale. En effet, il est possible de modifier la valeur de l’adresse de retour de la fonction prompt_and_print2 de manière à forcer le processus à retourner à l’adresse souhaitée par l’utilisateur. Un exemple serait de remplacer cette adresse de retour par l’adresse du tampon afin d’en exécuter le contenu. L’utilisateur pourrait remplir le tampon avec du code malveillant, toujours au moyen de la fonction gets, et le faire exécuter par le processus. De cette manière il serait possible, par exemple, d’ouvrir un shell ou de lire un emplacement mémoire arbitraire du processus.
Cette attaque consistant à modifier le contenu de la pile pour modifier le comportement du processus s’appelle le stack smashing (One 1996). Elle n’est cependant pas le seul type d’attaque possible grâce à la corruption de la mémoire. Szekeres et al. 2013 définit un modèle décrivant les étapes successives nécessaires au déroulement des différents types d’attaques par corruption de mémoire, ainsi que les politiques de sécurité qui permettraient de s’en prémunir. Ce modèle présente quatre grandes familles d’attaques : les attaques par corruption de code, les attaques par détournement du flot de contrôle, les attaques sur les données seulement et les fuites d’informations. Ces attaques n’ont pas toutes le même objectif, par exemple les fuites d’informations ne visent pas à induire un comportement arbitraire du processus, mais à provoquer des fuites de données sensibles alors qu’une attaque par corruption de code cherche précisément à faire exécuter du code arbitraire au processus. De plus, elles n’utilisent pas les mêmes moyens pour corrompre le processus, ainsi toutes les protections développées ne permettent pas de contrer toutes les attaques.
Les attaques par corruption de code
Les attaques par corruption de code consistent en l’ajout de nouveau code dans le processus ou la modification du code existant afin de modifier le comportement du processus cible. Ce type d’attaque permet d’obtenir exactement le code désiré par l’attaquant pour que celui-ci soit exécuté. Parmi les attaques de cette catégorie, on retrouve l’injection de shellcode exécuté grâce à la corruption d’un pointeur à l’instar du stack smashing, présenté précédemment. Ces attaques sont particulièrement puissantes puisqu’elles permettent à l’attaquant d’exécuter du code arbitraire. De plus, elles sont relativement simples à mettre en place puisque l’attaquant écrit le code malveillant lui-même. Néanmoins, des protections ont rapidement été mises en place pour lutter contre ce type d’attaques. La politique de sécurité intégrité du code telle que définie par Szekeres et al. 2013 a été implémentée sous la forme du W⊕X. Cette politique de sécurité considère qu’une page mémoire peut être soit inscriptible soit exécutable, mais jamais les deux en même temps. Grâce au W⊕X, les pages mémoire contenant le code du programme ne sont pas accessibles en écriture, empêchant le code du programme d’être modifié, et les pages de données accessibles en écriture ne peuvent pas être exécutées, empêchant l’attaquant d’exécuter du code qu’il aura écrit en mémoire en détournant l’utilisation normale du programme.
Bien que les attaques par injection de code aient semblé être contrées définitivement grâce à W⊕X, elles ont regagné l’intérêt des attaquants grâce à l’utilisation de plus en plus fréquente de compilateurs juste-à-temps (en anglais, Just-In-Time (JIT)). Ces compilateurs génèrent du code pendant l’exécution du processus, typiquement dans le but d’améliorer les performances de programmes écrits dans des langages de scripts traditionnellement interprétés (Aycock 2003 ; Chevalier-Boisvert et Feeley 2015a,b). Bien que cette technique permette d’améliorer significativement les performances et d’optimiser la compilation en fonction du contexte d’exécution, elle élargit aussi grandement la surface d’attaque du programme qui en fait usage. Par définition, afin de permettre l’utilisation de la compilation juste-à-temps, il est nécessaire d’assouplir la politique W⊕X, sinon ilne serait jamais possible d’exécuter le code généré par le compilateur. En effet, l’espace mémoire dans lequel le code compilé est écrit par le compilateur doit être accessible en écriture et exécutable ou bien rendu exécutable ultérieurement. Cela permet ainsi à un attaquant de modifier le contenu de cet espace mémoire durant la phase de compilation juste-à-temps pour injecter le code qu’il souhaite exécuter (Gawlik et Holz 2018).
Les attaques sur les données
Les attaques par injection de code ne sont pas toujours possibles comme présenté précédemment. De plus, exécuter du code arbitraire n’est pas nécessairement la volonté de l’attaquant, il peut se contenter de détourner le processus sans modifier son code mais en modifiant ses données. L’attaquant peut notamment modifier la valeur d’une variable pour accéder à une fonctionnalité du programme cible.
On suppose que cette fonction permet d’afficher une page de profil pour l’utilisateur dont l’identifiant est donné en paramètre. En fonction de si l’utilisateur est un administrateur ou non, elle affiche une page d’administration ou une page de profil classique. Un attaquant possédant un compte utilisateur non privilégié sur le logiciel en question pourrait tenter de modifier la variable id.type correspondant à son compte afin de faire afficher une page d’administration au lieu de la page de profil attendue. Dans ce type de scénarios, illustré par l’exemple précédent, le code du programme n’est pas impacté. Les failles exploitées servent à modifier les données d’une manière non prévue par le programmeur afin d’induire le processus en erreur. Cela conduit à violer un invariant sur les données du programme tout en suivant un flot de contrôle défini par le programme. Dans l’exemple précédent, unmot de passe d’un utilisateur qui n’est pas administrateur ne devrait pas conduire à une valeur ADMIN pour la variable id.type. Ce type d’attaques est la base du Data-Oriented Programming (DOP) présenté par Hu et al. 2016, dont le principe est de corrompre les données du processus tout en suivant son flot de contrôle. Les auteurs montrent que ce type d’attaque permet en réalité de d’influencer significativement le comportement de l’application vulnérable. Pour ce faire, l’attaquant utilise des gadgets de données qui sont des petits bouts de code dont le but est de manipuler des données qui sont sous le contrôle de l’attaquant ainsi que des gadget dispatchers dont le travail est de chaîner les gadgets de manière arbitraire dans l’objectif de faire exécuter le code d’exploitation voulu par l’attaquant par le processus attaqué. Les gadget dispatchers étant la plupart du temps des portions de code qui bouclent, il est possible de mener ces attaques soit d’un seul coup en corrompant des données via une seule entrée soit de manière interactive en entrant de nouvelles données malveillantes à chaque itération de la boucle. Une attaque interactive peut permettre à l’attaquant de provoquer des fuites informations, qui peuvent être ré-utilisées dans la suite de l’attaque, ou d’effectuer une attaque nécessitant des corruptions de mémoire à chaque itération.
Ces attaques sont néanmoins particulièrement difficiles à mettre en place manuellement. En effet, elles nécessitent de trouver dans le code des gadgets utilisables ainsi que des gadget dispatchers capables de fonctionner ensemble, c’est-à-dire que les dispatchers doivent pouvoir diriger le flot de contrôle vers les bons gadgets sans dévier du ControlFlow Graph (CFG) du programme . De plus, il est évidemment nécessaire de trouver une faille qui permette d’exploiter le programme cible avec une telle attaque. Néanmoins, Ispoglou et al. 2018 ont mis au point une approche et un compilateur leur permettant d’automatiser les attaques sur les données. Pour ce faire, le compilateur utilise une trace d’exécution d’un programme vulnérable ainsi qu’un programme d’exploitation de la faille écrit dans le Domain Specific Language (DSL) du compilateur, SPloit Language (SPL). À partir de ces entrées, le compilateur est capable de générer une trace codée sous la forme d’écritures mémoires qui correspondent au code SPL donné en entrée.Cette approche simplifie grandement la mise en place de ce genre d’attaques puisqu’elle automatise la découverte de vulnérabilités exploitables là où une recherche manuelle aurait pu être infaisable.
Castro, Costa et Harris 2006 définit une implémentation de la politique de sécurité Data-Flow Integrity (DFI) dont l’objectif est de protéger l’intégrité des données et donc d’éviter d’utiliser des données corrompues pour détourner l’exécution du programme. L’approche proposée consiste à associer à chaque utilisation (lecture) d’un emplacement mémoire les définitions (écritures) correspondantes, de manière à définir un Data-Flow Graph (DFG) grâce à des analyses statiques. Le programme est également instrumenté afin de s’assurer à l’exécution que le processus définit et utilise des données conformes au DFG déterminé précédemment. Néanmoins, cette politique de sécurité n’est pas parfaite. Typiquement, son impact sur les performances empêche son adoption à grande échelle (Castro, Costa et Harris 2006 ; Szekeres et al. 2013). De plus, l’efficacité du DFI dépend grandement de la qualité du DFG que l’implémentation de la politique de sécurité est capable de construire. Moins le DFG est précis plus il laisse de possibilité à un attaquant de trouver un moyen de le détourner. C’est autour de cette faiblesse de DFI que Lu et Wang 2019 ont développé Data-Flow Bending (DFB). Il s’agit d’une attaque qui cherche à construire une attaque sur les données qui respecte le DFG du programme, lui permettant de ne pas être détectée par une approche de type DFI.
|
Table des matières
Introduction
1 État de l’art
1.1 Les attaques par corruption de mémoire
1.1.1 Les attaques par corruption de code
1.1.2 Les attaques sur les données
1.1.3 Les fuites d’informations
1.1.4 Les attaques par détournement du flot de contrôle
1.2 L’intégrité du flot de contrôle
1.2.1 Control-Flow Integrity (CFI) basés sur les sources
1.2.2 CFI basés sur le code binaire
1.2.3 L’isolation des données de contrôle
1.3 La modification dynamique de binaire
1.3.1 La Dynamic Binary Modification (DBM) dans la sécurité
1.4 Limites de l’état de l’art
1.4.1 Notre contribution
2 Damas : Un framework d’injection de protection à l’exécution
2.1 Sorry : notre bibliothèque de DBM
2.1.1 Philosophie et fonctionnalités
2.1.2 Lectures et écritures via les tampons
2.2 Control-Data Isolation à l’exécution
2.2.1 Contraintes spatiales dans le binaire originel
2.2.2 Désassemblage et rebasement en mémoire
2.2.3 Traductions des instructions
2.3 Suppression des branchements indirects
2.3.1 Les tobbogans de Control-Data Isolation
2.3.2 Les classes d’équivalence de branchements
2.3.3 Les tables de délégation
2.3.4 Les appels et retours de fonctions contournant la Procedure Linkage Table (PLT)
2.4 Optimisation des tables de délégation
2.4.1 Un tri par utilisation décroissante
2.4.2 Une représentation en arbre binaire
3 Évaluation
3.1 Évaluation de sécurité
3.2 Évaluation des performances
3.2.1 Protocole expérimental
3.2.2 Résultats
4 Travaux futurs
4.1 Tirer un meilleur profit de Rust
4.2 Meilleure couverture des programmes existants
4.3 Optimisations du code actuel
4.4 Réduction de la taille des classes d’équivalence
Conclusion