Les exercices de Red Team sont des classes d’audits simulant le comportement d’un attaquant. L’intérêt principal d’une activité de RedTeam est de déterminer les possibilités d’attaque malveillante sur le SI d’une entreprise, et les capacités de détection et de réponse des équipes défensives. Ces exercices peuvent s’étendre d’une attaque purement extérieure à une intrusion physique des locaux d’une entreprise.
Un des trophées “classique” des activités de RedTeam est de déterminer si un attaquant est en capacité de compromettre le poste d’un collaborateur. Cette compromission peut passer par exemple par l’exécution de commandes système, l’établissement d’une persistance sur le poste, ou bien le pivot sur le réseau interne de l’entreprise. Cette compromission doit se faire évidemment sans lever d’alertes chez les équipes défensives de l’entreprise.
Pour détecter et bloquer ce genre d’attaques, les entreprises se munissent de solutions anti-virus et d’EDRs (Endpoint Detection and Response) permettant d’analyser le comportement des fichiers exécutés sur une machine. Ces mécanismes de sécurité permettent de détecter et bloquer tout comportement suspicieux. L’objectif des auditeurs Red Team va alors être de déterminer les mécanismes de détection utilisés par l’entreprise et de tenter de les contourner.
Dans cet article nous allons voir comment fonctionne un outil que l’on appelle le COFF Loader, ainsi qu’un exemple concret d’application.
Qu’est-ce qu’un COFF Loader ?
Un COFF Loader (COFF pour Common Object File Format) est un programme qui va charger ce qu’on appelle un fichier objet (ou fichier COFF) et l’exécuter en mémoire. Ce fichier objet chargé et exécuté contient la charge utile malveillante qui va servir à compromettre le poste de l’utilisateur. Plus important, ce fichier objet est alors chargé dynamiquement et n’est pas enregistré sur le disque de la victime. Cette méthode permet ainsi d’éviter une partie de l’analyse statique réalisée par les EDRs et solutions d’anti-virus. Le COFF loader contient alors peu ou pas de comportement suspicieux, limitant ainsi les possibilités de détection.
D’autres méthodes d’exécution de charges utiles fonctionnent sur un format « forkNrun ». Par exemple, un outil tel qu’un Reflective DLL Loader va charger et exécuter une librairie DLL en mémoire. Cependant ces familles d’outils utilisent des fonctions de l’API Windows généralement très monitorées par les EDR, notamment les fonctions liées à l’ouverture et la manipulation de processus. À l’inverse, le COFF Loader dans sa version classique n’utilise pas ces fonctions et fonctionne sur un seul thread, réduisant les chances de détection. En revanche, le désavantage de ne fonctionner que sur un seul thread est qu’un fichier objet chargé rencontrant un crash lors de son exécution affecte le Loader entier et le fait crasher également.
Qu’est-ce qu’un fichier COFF exactement ?
Supposons que nous voulons écrire un programme en C, et l’exécuter. Pour cela, on va devoir compiler le programme. Le rôle de cette étape est de traduire le programme écrit en langage C en quelque chose de compréhensible pour notre machine et son système d’exploitation. Le processus se déroule en 3 étapes principales :
- L’assemblage : Traduit le programme écrit dans un langage commun (C, Rust, …) en assembleur et réalise une première passe d’optimisation. Cette étape n’est pas le sujet de cet article.
- La compilation : Transforme le langage assembleur obtenu à l’étape précédente en langage machine, compréhensible par le processeur sous forme de fichiers objet. Ces fichiers ne sont pas encore exécutables par le système car les liens avec d’autres fichiers objet ou avec les librairies dont dépendent le programme ne sont pas encore résolus.
- Le linkage : Prend chaque fichier objet obtenu à l’étape précédente et les lie avec les librairies utilisées par le programme en résolvant les emplacements des fonctions, etc… À l’issue de cette étape, un fichier .exe ou une librairie DLL est produit, prêt à être exécuté.
La compilation peut être schématisée de la sorte :

Un fichier COFF est donc ce qui est produit à l’issue de la 2ème étape du processus de compilation, c’est-à-dire un fichier contenant du langage machine mais dont les librairies externes et les fonctions ne sont pas résolues.
Format d’un fichier objet
De façon globale, un fichier objet est composé de sections qui peuvent contenir des informations diverses. Chaque section porte un nom, et chaque nom correspond par convention à un usage précis. Par exemple :
- .text : Contient tout le code machine destiné à être exécuté par le programme. Dans l’exemple de compilation donné ci-dessus, cela correspondrait à la fonction main du programme.
- .rdata : Contient les données déjà initialisées et n’étant pas modifiées par le programme. Dans l’exemple ci-dessus, cette section contiendrait la chaîne de caractère “Hello, World!”.
- .bss : Un espace mémoire vide destiné à accueillir des données dynamiques pendant l’exécution du programme.
Dans l’exemple donné ci-dessus, la fonction main utilise la fonction printf provenant de la librairie standard du C ainsi que la chaîne de caractères “Hello, World!” (qui comme mentionné précédemment est définie dans la section .rdata). Cependant, le fichier objet tel quel n’a aucune connaissance de l’emplacement de ces éléments (ou symboles). À la place, le compilateur a défini pour chaque section des relocations, des informations pour récupérer les emplacements des symboles et les insérer au bon endroit.
De façon plus précise, le format des fichiers objets (dérivé du format PE utilisé par Windows) se décompose de la façon suivante :
- Un en-tête contenant des informations générales sur le fichier (sa taille, l’architecture cible (x86 ou x64 par exemple), etc…)
- Une table contenant les en-têtes de chaque section. Ces en-têtes contiennent des informations à propos des sections, comme leur emplacement dans le fichier, leur taille, ou bien le nombre de symboles à résoudre pour l’étape de linkage
- Le contenu des sections à proprement parler. Chaque section est scindée en deux parties, une contenant les données brutes de la section, et une partie contenant les relocations
- Une table contenant les symboles présents dans toutes les sections du fichier, avec leur nom et différentes informations nécessaires à leur résolution
- Une table des chaînes de caractères présentes dans le fichier COFF, comme les noms des différentes sections ou bien les noms des symboles. Ces chaînes de caractères sont à différencier des chaînes de caractères qui pourraient être utilisées au sein du programme. Par exemple : un programme qui affiche “Hello World!” stock ce texte dans une section dédiée comme donnée brute et non pas dans la table des chaînes de caractères du fichier COFF.

Résoudre les relocations
Avant la phase de linkage des fichiers objets, les fichiers COFF sont comme des textes à trous, où chaque trou correspond à l’adresse d’un symbole.
Prenons l’exemple de l’appel de la fonction printf. Lors de la compilation, l’instruction “printf(“Hello World!”)” sera traduit en assembleur par (entre autre) l’instruction “call printf”, où “printf” est l’adresse de la fonction. Cette fonction est définie dans la librairie standard du C, considérée comme externe au fichier COFF. Ainsi, avant l’étape finale de linkage, la fonction printf est inconnue, et son adresse ne peut donc pas être connue également, et une valeur temporaire (souvent 0, mais ne l’est pas nécessairement) est insérée à la place de l’adresse de la fonction. Lors de l’étape de linkage, cette valeur temporaire devra être remplacée par l’adresse réelle de la fonction printf.
Pour préparer ce processus, des entrées de relocation sont définies pour chaque symbole inconnu, spécifique à chaque section. La structure les décrivant est simple :
| struct relocation { int virtualAddress, int symbolTableIndex, short relocType } |
Le champ “virtualAddress” représente l’offset dans la section de la valeur temporaire à remplacer, et le champ “symbolTableIndex” indique le symbole qui remplacera cette valeur temporaire.

Le processus de résolution consiste donc à prendre chacune des relocations, récupérer le symbole associé, résoudre l’adresse du symbole (en important les librairies DLL nécessaires si besoin) et coller l’adresse du symbole à l’emplacement indiqué par l’adresse virtuelle pointée par la relocation.
Une fois toutes les relocations résolues, il ne reste plus qu’à trouver l’adresse d’un point d’entrée (par exemple une fonction appelée main) et d’appeler cette fonction comme n’importe quelle autre, le fichier objet sera alors exécuté comme n’importe quel autre fichier exécutable.
Application
Nous allons maintenant tenter d’appliquer ces concepts à l’aide d’un programme concret. Pour cela nous allons prendre un programme simple écrit en C, analyser les symboles produits par le compilateur et inspecter les relocations correspondantes pour une des sections du fichier objet.
Plus globalement, pour exécuter un fichier COFF, il faut :
- Parser le fichier et récupérer les différentes sections, tables des symboles et des chaînes de caractères, etc…
- Résoudre les symboles locaux (définis dans des sections du fichier COFF) et externes (notamment les fonctions à importer)
- Résoudre les relocations dans chaque section
- Exécuter la fonction main (ou tout autre fonction de notre choix).
Résolution des symboles
Une fois le fichier objet chargé en mémoire, et les informations des différentes sections, symboles et chaînes de caractères récupérées, on peut réaliser la résolution des symboles, c’est-à-dire associer une adresse mémoire à chaque symbole présent dans la table.
Prenons un exemple de code en C appelant des fonctions de la librairie standard du C (ici memset), ainsi que des fonctions de l’API Windows (MessageBoxA et CreateProcessA).

Exemple de fichier C
Après avoir compilé le fichier, la table de symboles générée est la suivante :

Exemple de table de symboles
Ces symboles comportent un identifiant (situé dans la colonne de gauche), des informations diverses sur le symbole, ainsi qu’un nom pour celui-ci (dans la dernière colonne). Par exemple, le symbole numéro 00C (en hexadécimal) porte le nom _main.
Certains symboles sont présents uniquement à but informatif et ne sont pas utilisés pour l’exécution à proprement parler du COFF (comme @comp.id ou @feat.00 donnant des informations sur le compilateur, ou bien les symboles comme le symbole numéro 003 suivi d’une ligne sans identifiant, correspondant à une section et donnant des métadonnées sur cette section, comme sa longueur, le nombre de relocation, ou bien le checksum de la section si ce mécanisme est activé).
Ainsi, les symboles qui nous intéressent ici sont les symboles 009 à 00C et 00F à 011.
La première chose à regarder pour résoudre un symbole est sa Storage Class (la 5ème colonne dans la table ci-dessus). Il y a deux classes principales : Static et External. La classe Static signifie que le symbole est une valeur statique définie dans une des sections du fichier COFF. Par exemple, le symbole 010 répondant au doux nom de $SG71969 a une Storage Class Static, est défini dans la section 4 du fichier (la 3ème colonne de la table des symboles, SECT4), et a un offset dans de 00000008 (la 2ème colonne de la table) dans la section 4.
Et en regardant la section 4, on retrouve les chaînes de caractères utilisées dans le code C original :

Détails de la section 4
Dans la section RAW DATA #4, on retrouve le contenu utilisé, et notre symbole $SG71969 situé à l’offset 8 dans la section correspond au texte “Content”.
La Storage Class External est utilisée pour les fonctions dans des sections différentes ou des symboles définis dans d’autres fichiers COFF. Par exemple, les symboles 009 et 00C correspondent aux fonctions _memset et _main (l’information du type du symbole est notype(), les parenthèses indiquant que ce sont bien des fonctions). La fonction _main est définie dans la section SECT3 et suit les mêmes règles que les symboles Static. En revanche, la fonction _memset (et les symboles __imp_MessageBoxA et __imp_CreateProcessA reconnaissables en tant que fonctions par leur préfixe __imp_) ne sont pas définies dans ce fichier COFF (avec la section UNDEF) et doivent être importées depuis une librairie dynamique, une DLL. La résolution de ce symbole se fait alors en chargeant la DLL nécessaire et en récupérant l’adresse de la fonction correspondante.
Résolution des relocations
Une fois les symboles résolus, il ne reste plus qu’à les remplacer dans les sections nécessaires. Dans l’exemple du code C précédent, la section de code exécutable générée est la suivante :

Exemple de relocation
On retrouve dans la liste des relocations en bas de la section les symboles résolus lors de l’étape précédente. Par exemple, la deuxième relocation se fait à l’offset 26 dans la section (section encadrée en rouge comprenant 00 00 00 00) et est remplacée par le symbole _memset (que nous avons résolus au préalable à une fonction dans une DLL externe). Il ne nous reste plus qu’à coller l’adresse du symbole à l’offset indiqué.
En procédant ainsi pour toutes les relocations, le chargement du fichier COFF est fini et celui-ci est prêt à être exécuté !
Pour aller plus loin
La technique décrite ici est un premier pas vers un outillage d’exercice Red Team, cependant aucune stratégie d’obfuscation et de dissimulation ne sont employées, et la plupart des EDRs vont détecter un tel Loader comme un programme suspicieux. Parmi les signaux potentiellement malveillants figurent par exemple :
- L’exécution de code ne faisant pas partie de l’exécutable original,
- La manipulation des droits de lecture et écriture des pages mémoires souvent utilisé pour les maliciels,
- La non protection des charges utiles en mémoire si un EDR devait faire une analyse du processus au moment de son exécution.
Autant de techniques à implémenter afin de rendre le Loader le plus discret possible.
De plus, des solutions commerciales d’outil Red Team renommées comme Cobalt Strike offrent la possibilité d’utiliser un COFF Loader (pour le chargement de Beacon Object File ou BOF, l’équivalent du COFF). Ainsi, leur communauté ont construit des librairies de fichiers objets pouvant être utilisés par la solution, à disposition de n’importe qui pouvant les exécuter. En revanche, les fichiers BOF utilisés par Cobalt Strike comportent des fonctions utilisant une API spéciale de ce dernier, qu’il est nécessaire de réimplémenter dans un COFF Loader avant de pouvoir les utiliser.
