Skip to content

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 :

text
"Lab" as ASCII / UTF-8        4C 61 62 00
"Lab" as UTF-16LE (wchar_t)   4C 00 61 00 62 00 00 00

Les 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.

OutilASCIIUTF-16LERemarques
GNU strings (binutils)par défaut-e lLongueur minimale par défaut 4 ; -t x affiche les offsets en hexadécimal
Sysinternals strings-a uniquement-u uniquementRecherche les deux par défaut ; longueur minimale 3 ; -o affiche les offsets
FLOSSouiouiRetrouve 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 bruitChaînes typiques
Structure PE!This program cannot be run in DOS mode., .text, .rdata, .reloc
Table des importsNoms de DLL et d’API (à lire dans Lire les capacités à partir des imports)
Runtime mingw-w64Mingw-w64 runtime failure:, Unknown pseudo relocation protocol version %d., GCC: (GNU) ...
CRT MSVCbad allocation, Unknown exception, noms de locales, erreurs d’exécution du type R6016
Bibliothèques liées statiquementMessages d’erreur et chaînes de version d’OpenSSL, zlib ou SQLite
Go et RustDes 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égorieExemples (sans danger pour le lab)Pourquoi c’est important
URL, domaines, IPhttp://update.example.com/lab, 198.51.100.23IOC (indicateurs de compromission) réseau, C2
Chemins de fichiersC:\ProgramData\LabApp\config.ini, %APPDATA%\Lab\Emplacements de dépôt, IOC hôte
Clés de registreSoftware\Microsoft\Windows\CurrentVersion\RunPersistance
Noms de mutex et d’événementsGlobal\LabAppMutexVérification d’instance unique ; excellent IOC hôte
User-agentsMozilla/5.0 (Windows NT 10.0; Win64; x64)Détection réseau, souvent codés en dur et légèrement faux
Commandescmd.exe /c, schtasks /create, vssadmin delete shadowsLe comportement avant même d’exécuter quoi que ce soit
Messages d’erreur et de journalfailed to inject, [+] beacon sentRévèlent l’intention et la logique ; excellent matériau YARA
Chemins PDBC:\Users\dev\...\Release\Updater.pdbRegroupement et attribution (voir Du code source au binaire)
Chaînes de formatid=%s&os=%d&v=%sStructure du protocole d’un check-in C2
Noms d’API sous forme de texteGetComputerNameA, VirtualAllocExRésolution dynamique (voir résolution dynamique des imports)
Artefacts de chiffrement et d’encodageun alphabet Base64 de 64 caractères, de longs blobs hexadécimaux, des en-têtes PEMQuelque 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 :

bash
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.txt

Astuce : 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 mov d’immédiats. Les octets sont dispersés dans les instructions, où strings ne 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 grep naï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 :

TypeCe que c’estComment FLOSS les trouve
StatiquesASCII et UTF-16LE simplesParcours 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
TightChaînes empilées décodées sur place par une boucle serréeÉmule la boucle et lit le cadre ensuite
DécodéesProduites par une fonction de décodage à l’exécutionTrouve 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.

  1. Écrivez le programme. Le tableau encodé est http://update.example.com/lab XORé avec 0x5A :

    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.c

    Si 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.

  2. 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.ini apparaît. Lisez maintenant la sortie complète autour. Vous devriez trouver decoded: %s, une chaîne de format qui indique que quelque chose est décodé, et une ligne de charabia imprimable qui commence par 2..* et contient uu/*. C’est l’URL encodée par XOR, qui se trouve être imprimable parce que les lettres minuscules XORées avec 0x5A tombent dans la plage imprimable.

  3. Chaînes UTF-16LE.

    bash
    strings -n 6 -e l strlab.exe

    lab-wide: Global\LabAppMutex apparaît, et rien d’autre de votre programme. Sous Windows, strings64.exe -n 6 strlab.exe trouve à la fois la chaîne simple et la chaîne large en une seule passe.

  4. Cherchez la stack string. strings -n 4 strlab.exe | grep -i stack ne trouve rien. Désassemblez main et 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.

  5. 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.exe

    FLOSS 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 émulant xor_decode. Lancez aussi un simple floss strlab.exe et vérifiez que sa section statique contient à la fois le marqueur ASCII et le marqueur UTF-16.

  6. 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.exe

    Attendez-vous à une seule occurrence avec key=0x5a, dans la section .data, qui se décode en l’URL suivie de quelques caractères Z. Ce sont les octets nuls situés après le tableau, XORés avec 0x5A (Z). C’est la faiblesse des octets nuls évoquée plus haut.

  7. 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 connu http et constatez que la même clé arrive en tête.

  8. Observez l’optimiseur. Recompilez avec -O2 et 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-stac et même http://update.ex dans .rdata. Le compilateur a remarqué que ENC n’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 tampons volatile ou 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.