Leçon 3.2 · Triage statique· 45 min
Chaînes et chaînes obfusquées
Extrayez les chaînes ASCII et UTF-16, isolez les indicateurs du bruit du runtime, retrouvez les chaînes empilées, XOR et décodées avec FLOSS et CyberChef.
Objectifs
- Extraire les chaînes ASCII et UTF-16LE avec GNU strings, Sysinternals strings et FLOSS
- Séparer les chaînes utiles à l’analyste du bruit du compilateur et du runtime
- Reconnaître les stack strings, le XOR sur un octet, Base64 et RC4 comme moyens de cacher des chaînes
- Expliquer dans les grandes lignes comment FLOSS retrouve les chaînes empilées, tight et décodées
- Casser par force brute un XOR sur un octet à l’aide d’un texte clair connu
Après l’identité et les hashs, l’étape suivante du triage consiste à lire le texte que contient le fichier. Une URL de C2, un nom de mutex, une note de rançon ou le chemin PDB d’un développeur peuvent vous en apprendre plus en trente secondes qu’une heure dans un désassembleur. C’est aussi pour cela que les auteurs de malwares (logiciels malveillants) cachent leurs chaînes, et pourquoi remarquer les chaînes absentes est aussi important que lire celles qui sont présentes.
Vous avez déjà utilisé strings dans les labs précédents. Cette leçon en fait une méthode : quels encodages rechercher, comment réduire le bruit, que chercher, et que faire lorsque le texte intéressant n’est pas présent dans le fichier sous une forme lisible.
Ce qu’est une « chaîne » pour un outil
Un extracteur de chaînes ne connaît rien au format du fichier. Il parcourt les octets bruts à la recherche de suites de caractères imprimables d’au moins N caractères et affiche chaque suite. Deux encodages comptent sous Windows :
"Lab" as ASCII / UTF-8 4C 61 62 00
"Lab" as UTF-16LE (wchar_t) 4C 00 61 00 62 00 00 00Les API Windows qui se terminent par W (CreateFileW, RegSetValueExW) prennent de l’UTF-16LE, tout comme les littéraux L"..." en C, les tables de chaînes .NET, les ressources et le bloc d’informations de version. Un scanner ASCII voit L, un octet nul, a, un octet nul, et abandonne, car chaque suite ne fait qu’un caractère. Recherchez toujours dans les deux encodages.
| Outil | ASCII | UTF-16LE | Remarques |
|---|---|---|---|
GNU strings (binutils) | par défaut | -e l | Longueur minimale par défaut 4 ; -t x affiche les offsets en hexadécimal |
Sysinternals strings | -a uniquement | -u uniquement | Recherche les deux par défaut ; longueur minimale 3 ; -o affiche les offsets |
| FLOSS | oui | oui | Retrouve aussi les chaînes empilées, tight et décodées |
Sous macOS, le strings intégré est celui d’Apple et n’a pas d’option -e. Utilisez celui de binutils ou de mingw-w64 (x86_64-w64-mingw32-strings).
Longueur minimale et bruit
La longueur minimale est un réglage du bruit. Le code machine et les données compressées contiennent de courtes suites imprimables par pur hasard. Sur un petit programme de test mingw-w64, le nombre de suites ASCII passe d’environ 200 avec -n 4 à environ 115 avec -n 6 et moins de 100 avec -n 8, et aucune des chaînes perdues n’était intéressante. Commencez à 6, et descendez à 4 uniquement lorsque vous cherchez quelque chose de précis, comme des noms de mutex courts ou des commandes de deux lettres.
Les courtes suites d’apparence aléatoire comme AWAVAUATUWVSH ou [^_]A\A]A^A_ sont des instructions x86-64 : les push et pop préfixés REX des registres préservés par l’appelé (callee-saved), dans les prologues et épilogues de fonctions, se trouvent être imprimables. Apprenez à les reconnaître et à les ignorer.
Triage : signal contre bruit
La plupart des chaînes d’un binaire n’ont pas été écrites par l’auteur. Elles viennent du compilateur, du runtime C et des bibliothèques liées statiquement. Reconnaître ce bruit de fond, c’est la moitié du savoir-faire :
| Source de bruit | Chaînes typiques |
|---|---|
| Structure PE | !This program cannot be run in DOS mode., .text, .rdata, .reloc |
| Table des imports | Noms de DLL et d’API (à lire dans Lire les capacités à partir des imports) |
| Runtime mingw-w64 | Mingw-w64 runtime failure:, Unknown pseudo relocation protocol version %d., GCC: (GNU) ... |
| CRT MSVC | bad allocation, Unknown exception, noms de locales, erreurs d’exécution du type R6016 |
| Bibliothèques liées statiquement | Messages d’erreur et chaînes de version d’OpenSSL, zlib ou SQLite |
| Go et Rust | Des milliers de chaînes du runtime, chemins de paquets, messages de panic |
Les chaînes des bibliothèques ne sont pas inutiles : inflate 1.2.13 Copyright vous indique que zlib est lié, et un chemin de paquet Go comme main.encryptFiles est un cadeau. Mais ce sont du contexte, pas des constats.
Ce que vous cherchez, c’est le contenu de l’auteur :
| Catégorie | Exemples (sans danger pour le lab) | Pourquoi c’est important |
|---|---|---|
| URL, domaines, IP | http://update.example.com/lab, 198.51.100.23 | IOC (indicateurs de compromission) réseau, C2 |
| Chemins de fichiers | C:\ProgramData\LabApp\config.ini, %APPDATA%\Lab\ | Emplacements de dépôt, IOC hôte |
| Clés de registre | Software\Microsoft\Windows\CurrentVersion\Run | Persistance |
| Noms de mutex et d’événements | Global\LabAppMutex | Vérification d’instance unique ; excellent IOC hôte |
| User-agents | Mozilla/5.0 (Windows NT 10.0; Win64; x64) | Détection réseau, souvent codés en dur et légèrement faux |
| Commandes | cmd.exe /c, schtasks /create, vssadmin delete shadows | Le comportement avant même d’exécuter quoi que ce soit |
| Messages d’erreur et de journal | failed to inject, [+] beacon sent | Révèlent l’intention et la logique ; excellent matériau YARA |
| Chemins PDB | C:\Users\dev\...\Release\Updater.pdb | Regroupement et attribution (voir Du code source au binaire) |
| Chaînes de format | id=%s&os=%d&v=%s | Structure du protocole d’un check-in C2 |
| Noms d’API sous forme de texte | GetComputerNameA, VirtualAllocEx | Résolution dynamique (voir résolution dynamique des imports) |
| Artefacts de chiffrement et d’encodage | un alphabet Base64 de 64 caractères, de longs blobs hexadécimaux, des en-têtes PEM | Quelque chose est décodé à l’exécution |
Filtrez d’abord les catégories à forte valeur avec grep, puis lisez le reste de haut en bas :
strings -n 6 sample.bin > ascii.txt
strings -n 6 -e l sample.bin > wide.txt
grep -hiE 'https?://|[0-9]{1,3}(\.[0-9]{1,3}){3}|\.pdb|\\software\\|global\\|mozilla|cmd\.exe|powershell' ascii.txt wide.txtAstuce : Conservez les offsets (
-t x) lorsqu’une chaîne compte. Un offset vous permet d’aller directement à la chaîne dans un éditeur hexadécimal ou de la rattacher à une section et à une RVA, et de là au code qui la référence dans un désassembleur.
Une bonne chaîne sert deux fois : c’est un IOC pour le SOC et une ancre YARA pour les ingénieurs détection. Les messages d’erreur et les chaînes de format constituent souvent un meilleur contenu de détection que les URL, car les opérateurs changent d’infrastructure bien plus souvent que de code. Vous transformerez précisément ces chaînes en règles dans Écrire vos premières règles YARA (voir aussi l’entrée de glossaire YARA).
Pourquoi et comment les auteurs cachent leurs chaînes
Un binaire dont la liste de chaînes se lit comme sa liste de fonctionnalités est facile à détecter et facile à analyser. Les malwares cachent donc les chaînes qui comptent, généralement avec des astuces bon marché plutôt qu’avec une cryptographie robuste :
- Stack strings (chaînes empilées). La chaîne n’existe jamais sous forme de littéral contigu. Le code l’écrit dans un tampon sur la pile un caractère, ou quelques caractères, à la fois avec des
movd’immédiats. Les octets sont dispersés dans les instructions, oùstringsne les trouve pas. Voir stack strings. - XOR sur un octet. Chaque octet est XORé avec une clé unique. C’est trivial à écrire, et cela supprime à la fois le texte littéral et les correspondances de signature simples. Sa faiblesse : les octets nuls s’encodent en la clé elle-même, si bien que le remplissage après une chaîne encodée apparaît souvent comme une suite d’un même caractère répété. Voir chiffrement de chaînes par XOR.
- XOR multi-octets ou glissant. Une clé répétée ou une clé qui change à chaque octet. C’est toujours faible, mais on ne peut plus le casser en essayant 255 clés.
- Base64 et alphabets personnalisés. Base64 ne cache rien à un humain, mais il met en échec un
grepnaïf. Un alphabet mélangé de 64 caractères dans la sortie de strings est un indice fort d’un Base64 personnalisé. - RC4 et vrais algorithmes de chiffrement. RC4 est court à implémenter et courant dans les configurations de malwares. Cherchez une boucle qui initialise un tableau de 256 octets avec les valeurs 0 à 255 (la préparation de clé, ou key schedule) et un blob à forte entropie à proximité. AES apparaît sous forme de constantes de S-box que des outils comme capa ou signsrch reconnaissent.
- Aucune chaîne du tout. Le hachage d’API remplace les noms d’API par des constantes 32 bits : il ne reste alors rien à décoder.
Les signes que des chaînes sont cachées sont indirects : un gros binaire quasiment dépourvu de contenu lisible de l’auteur, du charabia imprimable près d’une boucle de décodage, un blob à forte entropie dans .data ou dans une ressource, et la paire LoadLibraryA et GetProcAddress sans aucun nom d’API en vue.
FLOSS : les chaînes que le programme construirait
FLOSS, le FLARE Obfuscated String Solver de Mandiant, étend strings avec l’émulation. Il signale quatre types de chaînes :
| Type | Ce que c’est | Comment FLOSS les trouve |
|---|---|---|
| Statiques | ASCII et UTF-16LE simples | Parcours des octets, comme strings |
| Empilées (stack) | Construites sur la pile par des instructions mov | Émule chaque fonction et extrait les chaînes de son cadre de pile |
| Tight | Chaînes empilées décodées sur place par une boucle serrée | Émule la boucle et lit le cadre ensuite |
| Décodées | Produites par une fonction de décodage à l’exécution | Trouve les décodeurs candidats et émule leurs appels |
Pour les chaînes décodées, l’idée est simple, même si l’implémentation ne l’est pas. FLOSS désassemble le fichier (à l’aide de vivisect) et classe les fonctions selon des caractéristiques que les décodeurs ont tendance à présenter : boucles, XOR avec un opérande non nul, décalages, nombreux appelants. Pour chaque candidat, il émule les sites d’appel avec les arguments que le programme passerait, puis compare la mémoire avant et après. Les nouvelles données imprimables qui apparaissent dans la mémoire émulée sont signalées comme chaînes décodées. Rien ne s’exécute nativement : l’outil peut donc être utilisé sans danger sur de vrais échantillons dans votre laboratoire.
L’émulation a ses limites. Les décodeurs qui dépendent d’une clé récupérée sur le réseau, dans l’environnement ou dans un fichier, un flot de contrôle fortement obfusqué ou des astuces anti-émulation ne produiront rien. Un résultat FLOSS vide ne signifie pas « aucune chaîne cachée ». Quand FLOSS échoue, un point d’arrêt dans un débogueur après la routine de décodage est la solution de repli.
CyberChef : décoder à la main
Lorsque vous trouvez un blob encodé et sa clé, CyberChef est le moyen le plus rapide d’essayer des décodages sans écrire de code. Opérations utiles :
- From Hex, From Base64 (avec une option d’alphabet pour les tables personnalisées)
- XOR avec une clé connue, et XOR Brute Force avec un texte clair connu (crib) comme
http - RC4 avec une clé en hexadécimal, UTF-8 ou Base64
- Magic, qui essaie automatiquement les décodages probables et classe les résultats
Les recettes s’enchaînent : « From Base64, puis RC4 avec la clé X, puis Gunzip » devient une seule recette enregistrée que vous pouvez réutiliser sur chaque échantillon d’une famille. Exécutez CyberChef hors ligne (c’est un simple fichier HTML) lorsque les données proviennent d’un dossier sensible.
Lab : quatre chaînes, quatre résultats
Vous allez compiler un programme bénin qui stocke quatre chaînes de quatre façons différentes, et voir quels outils peuvent retrouver chacune d’elles.
-
Écrivez le programme. Le tableau encodé est
http://update.example.com/labXORé avec0x5A:c // strlab.c - four ways to store a string (benign lab program) #include <windows.h> #include <stdio.h> /* 1. Plain ASCII: sits in .rdata as-is */ static const char PLAIN[] = "lab-plain: C:\\ProgramData\\LabApp\\config.ini"; /* 2. UTF-16LE: every character followed by a zero byte */ static const wchar_t WIDE[] = L"lab-wide: Global\\LabAppMutex"; /* 4. Single-byte XOR with key 0x5A */ static unsigned char ENC[] = { 0x32, 0x2e, 0x2e, 0x2a, 0x60, 0x75, 0x75, 0x2f, 0x2a, 0x3e, 0x3b, 0x2e, 0x3f, 0x74, 0x3f, 0x22, 0x3b, 0x37, 0x2a, 0x36, 0x3f, 0x74, 0x39, 0x35, 0x37, 0x75, 0x36, 0x3b, 0x38 }; static void xor_decode(unsigned char *buf, size_t len, unsigned char key) { for (size_t i = 0; i < len; i++) buf[i] ^= key; } int main(void) { /* 3. Stack string: built one character at a time at runtime */ char stack[16]; stack[0] = 'l'; stack[1] = 'a'; stack[2] = 'b'; stack[3] = '-'; stack[4] = 's'; stack[5] = 't'; stack[6] = 'a'; stack[7] = 'c'; stack[8] = 'k'; stack[9] = '-'; stack[10] = 'k'; stack[11] = 'e'; stack[12] = 'y'; stack[13] = '\0'; char url[sizeof(ENC) + 1]; memcpy(url, ENC, sizeof(ENC)); xor_decode((unsigned char *)url, sizeof(ENC), 0x5A); url[sizeof(ENC)] = '\0'; printf("%s\n", PLAIN); wprintf(L"%ls\n", WIDE); printf("%s\n", stack); printf("decoded: %s\n", url); return 0; }bash x86_64-w64-mingw32-gcc -O0 -s -o strlab.exe strlab.cSi vous l’exécutez dans votre VM Windows (ou sous Wine), il affiche les quatre chaînes en clair. Le programme a toujours le texte en clair en mémoire à l’exécution ; la question est de savoir ce que révèle un examen statique.
-
Chaînes ASCII. Recherchez les marqueurs du lab :
bash strings -n 6 strlab.exe | grep -iE 'lab|http|update'Seule
lab-plain: C:\ProgramData\LabApp\config.iniapparaît. Lisez maintenant la sortie complète autour. Vous devriez trouverdecoded: %s, une chaîne de format qui indique que quelque chose est décodé, et une ligne de charabia imprimable qui commence par2..*et contientuu/*. C’est l’URL encodée par XOR, qui se trouve être imprimable parce que les lettres minuscules XORées avec0x5Atombent dans la plage imprimable. -
Chaînes UTF-16LE.
bash strings -n 6 -e l strlab.exelab-wide: Global\LabAppMutexapparaît, et rien d’autre de votre programme. Sous Windows,strings64.exe -n 6 strlab.exetrouve à la fois la chaîne simple et la chaîne large en une seule passe. -
Cherchez la stack string.
strings -n 4 strlab.exe | grep -i stackne trouve rien. Désassemblezmainet regardez comment elle est construite :bash x86_64-w64-mingw32-objdump -d -M intel strlab.exe | grep -m6 'BYTE PTR \[rbp-0x'text c6 45 f0 6c mov BYTE PTR [rbp-0x10],0x6c ; 'l' c6 45 f1 61 mov BYTE PTR [rbp-0xf],0x61 ; 'a' c6 45 f2 62 mov BYTE PTR [rbp-0xe],0x62 ; 'b'Chaque caractère est séparé du suivant par trois octets d’opcode et de déplacement, si bien qu’aucune suite imprimable ne dépasse un caractère.
-
Lancez FLOSS. Installez-le dans un environnement virtuel (
pip install flare-floss) ou téléchargez un binaire publié, puis demandez uniquement les types non statiques :bash floss --only stack tight decoded -- strlab.exeFLOSS signale une chaîne empilée,
lab-stack-key, et une chaîne décodée,http://update.example.com/lab, qu’il a retrouvée en émulantxor_decode. Lancez aussi un simplefloss strlab.exeet vérifiez que sa section statique contient à la fois le marqueur ASCII et le marqueur UTF-16. -
Cassez par force brute le XOR sur un octet. Écrivez un scanner qui essaie les 255 clés. Il encode le texte clair connu avec chaque clé et recherche le résultat, ce qui est bien plus rapide que de décoder tout le fichier 255 fois :
python # xorscan.py - brute-force single-byte XOR over a file, looking for a crib import re, sys data = open(sys.argv[1], "rb").read() crib = sys.argv[2].encode() if len(sys.argv) > 2 else b"http" printable = re.compile(rb"[\x20-\x7e]{6,}") for key in range(1, 256): target = bytes(b ^ key for b in crib) start = data.find(target) while start != -1: chunk = bytes(b ^ key for b in data[start:start + 128]) m = printable.match(chunk) text = m.group().decode() if m else chunk[:len(crib)].decode() print(f"key=0x{key:02x} offset=0x{start:x} {text}") start = data.find(target, start + 1)bash python3 xorscan.py strlab.exeAttendez-vous à une seule occurrence avec
key=0x5a, dans la section.data, qui se décode en l’URL suivie de quelques caractèresZ. Ce sont les octets nuls situés après le tableau, XORés avec0x5A(Z). C’est la faiblesse des octets nuls évoquée plus haut. -
Décodez-la dans CyberChef. Copiez les 29 octets situés à cet offset sous forme hexadécimale, et appliquez From Hex puis XOR avec la clé
5a. Essayez ensuite XOR Brute Force avec le texte clair connuhttpet constatez que la même clé arrive en tête. -
Observez l’optimiseur. Recompilez avec
-O2et répétez les étapes 2 et 4 :bash x86_64-w64-mingw32-gcc -O2 -s -o strlab_O2.exe strlab.c strings -n 4 strlab_O2.exe | grep -iE 'lab|http'Avec GCC 15, vous devriez voir des fragments comme
lab-stacet mêmehttp://update.exdans.rdata. Le compilateur a remarqué queENCn’est jamais modifié et a replié une partie du décodage « à l’exécution » en une constante, et il a également déplacé la première moitié de la stack string dans.rdata. Les vrais malwares contournent cela avec des tamponsvolatileou des clés calculées à l’exécution. Pour vous, c’est un rappel que la qualité de l’obfuscation varie d’un build à l’autre, et que des échantillons frères d’une même famille peuvent laisser fuiter ce qu’un autre cache.
Questions : Laquelle des quatre chaînes une règle YARA écrite à partir de la seule sortie de strings manquerait-elle ? Pourquoi un XOR sur un octet laisse-t-il fuiter sa clé à travers les octets nuls, et comment un XOR glissant l’éviterait-il ? Que signalerait FLOSS si la clé XOR était lue dans une valeur de registre à l’exécution ? Lesquelles des chaînes retrouvées transmettriez-vous au SOC comme IOC, et lesquelles garderiez-vous pour la logique de détection ?
À retenir
- Extrayez à la fois les chaînes ASCII et UTF-16LE ; les programmes Windows conservent beaucoup de texte en UTF-16. Utilisez une longueur minimale d’environ 6 pour réduire le bruit.
- La plupart des chaînes proviennent de la structure PE, des imports, du runtime C et des bibliothèques liées. Apprenez à connaître ce bruit de fond pour que les chaînes de l’auteur ressortent.
- URL, chemins, clés de registre, mutex, user-agents, commandes, messages d’erreur, chemins PDB et chaînes de format sont les catégories à forte valeur pour les IOC et YARA.
- Les stack strings, le XOR, Base64, RC4 et le hachage d’API cachent du texte à
strings. L’absence de chaînes de l’auteur dans un gros binaire est un constat en soi. - FLOSS émule le code pour retrouver les chaînes empilées, tight et décodées. CyberChef et quelques lignes de Python couvrent les encodages simples, et un débogueur couvre le reste.