Leçon 8.3 · Encodage, crypto & signatures· 55 min
Scripter le déchiffrement des chaînes
Retrouvez des centaines de chaînes chiffrées d’un coup : trouvez la routine, récupérez statiquement les arguments de chaque appel et scriptez l’algorithme.
Objectifs
- Localiser une routine de déchiffrement de chaînes à l’aide des références croisées, du nombre d’appelants et d’une signature d’arguments
- Récupérer les arguments de chaque site d’appel — longueurs immédiates et pointeurs de données relatifs à RIP — en remontant depuis l’appel
- Réimplémenter un algorithme de déchiffrement en Python et annoter chaque site d’appel avec le texte clair
- Choisir entre réimplémentation, émulation et débogage lorsque l’algorithme résiste à un portage propre
- Expliquer ce que FLOSS automatise, et valider le texte clair récupéré avant de s’y fier
Ouvrez un échantillon grand public moderne dans un désassembleur et la fenêtre des chaînes est presque vide : pas d’URL, pas de chemins de registre, pas de messages d’erreur. À la place, une seule petite fonction appelée depuis quelques centaines d’endroits, chaque appel lui passant un pointeur vers un même blob à forte entropie. C’est le chiffrement de chaînes par XOR à grande échelle, et c’est l’obfuscation la plus courante que vous rencontrerez. L’auteur a chiffré chaque chaîne à la compilation et déchiffre chacune juste avant de l’utiliser : le texte clair n’existe donc qu’un instant à l’exécution.
Vous pourriez placer un point d’arrêt et en lire quelques-unes, mais des centaines de sites d’appel relèvent du script, pas du travail manuel. Cette leçon transforme une routine de déchiffrement en l’ensemble des textes clairs, réécrits dans votre base d’analyse. Elle s’appuie directement sur Références croisées et flot de données : le tableau de pivots de cette leçon se terminait sur le constat qu’une fonction avec des dizaines d’appelants, chacun passant un pointeur différent vers un même blob, est très souvent un déchiffreur de chaînes. Ici, vous passez à l’action avec du code.
La forme du problème
Un déchiffreur de chaînes a une signature reconnaissable, quel que soit l’algorithme qu’il contient :
- Une routine, de nombreux appelants. L’auteur l’a écrite une fois et le compilateur l’appelle partout. Un nombre d’appelants de l’ordre de la dizaine ou de la centaine est le signal le plus fort.
- Un argument pointeur de données. Chaque appel passe l’adresse d’un tampon
chiffré, généralement par un
learelatif à RIP vers.rdataou.data, parfois sous forme d’indice dans une table de blobs. - Une longueur ou une clé. Souvent un second argument immédiat : la longueur du tampon, un indice ou une clé propre à chaque chaîne.
- Un petit corps avec une boucle.
xor, addition, soustraction, rotation ou consultation de table, sur un compteur. Entrée à forte entropie, sortie imprimable.
La méthode suit cette forme :
- Trouver la routine. Pivotez depuis le blob ou depuis le nombre d’appelants ; confirmez en lisant le corps.
- Comprendre ses arguments. Quel registre ou emplacement de pile contient le texte chiffré, lequel la longueur ou la clé, selon quelle convention d’appel.
- Récupérer statiquement les arguments de chaque appel. Pour chaque
appelant, remontez depuis le
calljusqu’aux instructions qui définissent ces registres, et relevez les valeurs immédiates et les cibles des pointeurs. - Réimplémenter l’algorithme en Python et déchiffrer chaque tampon.
- Réécrire les résultats dans le désassembleur sous forme de commentaires et de renommages, afin que le texte clair soit visible à chaque site d’utilisation, définitivement.
Les étapes 3 à 5 constituent le script. Les étapes 1 et 2 sont un travail humain que vous ne faites qu’une fois.
Trouver et comprendre la routine
Le point d’ancrage le plus rapide, ce sont les données. Trouvez le blob à forte entropie (le travail sur les chaînes et l’entropie du module 3 l’aura signalé), prenez ses xrefs, et vous atterrissez dans le déchiffreur ou dans la table qu’il indexe. S’il n’y a pas de blob unique — chaque chaîne se trouve dans son propre petit tampon —, pivotez plutôt sur le nombre d’appelants : triez les fonctions par nombre d’appelants et lisez les quelques petites fonctions en tête. Déchiffreurs, allocateurs et fonctions de journalisation se regroupent en haut ; le déchiffreur est celui dont le corps applique un XOR ou une addition dans une boucle.
La lecture du corps répond à l’étape 2. Il vous faut trois informations avant de pouvoir scripter quoi que ce soit : quel argument est le pointeur vers le texte chiffré, lequel (s’il y en a un) est la longueur ou la clé, et ce que calcule exactement la boucle. Trompez-vous d’un seul XOR dans la boucle et chaque chaîne récupérée sera du charabia ; suivez-la donc instruction par instruction et vérifiez-la sur une chaîne que vous déchiffrez à la main.
Astuce : Renommez la routine et ses paramètres dès que vous les comprenez (
decrypt_str,p_cipher,len). Chacun des centaines de sites d’appel devient alors lisible dans le décompilateur avant même que vous ne scriptiez quoi que ce soit, et la sortie de votre script a des noms auxquels se rattacher.
Récupérer les arguments en remontant
Le cœur du script est le même raisonnement définition-utilisation (use-def)
que dans la leçon précédente, appliqué mécaniquement. À chaque site d’appel, les
arguments ont été définis quelques instructions plus tôt : dans l’ABI Windows
x64, les quatre premiers arguments entiers arrivent dans rcx, rdx, r8 et
r9. Pour un appel à decrypt(dst, cipher, len), le pointeur vers le texte
chiffré est donc ce qui a défini rdx en dernier et la longueur ce qui a défini
r8d en dernier, en remontant depuis le call.
Deux motifs couvrent l’essentiel de ce que produisent les compilateurs :
- Valeurs immédiates —
mov r8d, 0x12donne directement une longueur de 18. - Pointeurs relatifs à RIP —
lea rdx, [rip+0x164e]donne un pointeur dont la cible estaddress_of_next_instruction + 0x164e. C’est le même calcul que celui qu’effectue le désassembleur pour obtenir la xref, et vous le faites vous-même grâce au détail des opérandes de Capstone.
La remontée doit s’arrêter quelque part. Limitez la recherche à une petite
fenêtre (une poignée d’instructions) et ne franchissez jamais un autre call,
car un appel peut écraser les registres d’arguments volatils que vous suivez. Si
une valeur provient d’un endroit que votre remontée simple ne peut pas voir —
un compteur de boucle, une valeur sauvegardée sur la pile, un indice calculé —,
consignez le site d’appel comme non résolu plutôt que de deviner. Une poignée de
sites non résolus sur des centaines est un résultat normal et honnête ; vous
terminez ceux-là à la main ou par émulation.
Réimplémenter l’algorithme
Une fois que vous avez (cipher_pointer, length) pour chaque appel, le
déchiffrement est un portage direct de la boucle que vous avez lue. Lisez les
octets chiffrés dans le fichier à l’adresse du pointeur — pefile fait pour vous
la correspondance entre adresse virtuelle et données du fichier — et appliquez
les mêmes opérations que l’échantillon. Gardez le Python fidèle à l’assembleur,
y compris la largeur de chaque opération : un XOR sur un octet que le
compilateur a élargi à des registres 32 bits reste un XOR sur un octet, et la
troncature & 0xFF compte dès qu’un indice courant ou une clé dépasse 255.
Réécrire les résultats
Une liste de textes clairs dans votre terminal sert une fois. Du texte clair écrit dans le désassembleur sert pour tout le reste de l’analyse. Ghidra et IDA exposent tous deux des API de script précisément pour cela, et les concepts se correspondent directement :
| Tâche | Ghidra (Jython/Java ou PyGhidra) | IDAPython |
|---|---|---|
| Parcourir les sites d’appel | getReferencesTo(func.getEntryPoint()) | idautils.CodeRefsTo(ea, 0) |
| Lire la valeur d’un argument | getInstructionBefore, HighFunction du décompilateur | idc.print_operand, ida_ua.decode_insn |
| Commenter avec le texte clair | setPreComment / setEOLComment | idc.set_cmt(ea, text, 0) |
| Renommer le tampon | createLabel(addr, name, true) | idc.set_name(ea, name) |
| Lire des octets de l’image | getBytes(addr, n) | idc.get_bytes(ea, n) |
Le principe est identique au script externe ci-dessous ; seule l’API de lecture des opérandes et d’écriture des commentaires change. S’exécuter dans l’outil présente un gros avantage : le désassembleur a déjà trouvé les fonctions et résolu les sites d’appel, vous sautez donc l’étape de désassemblage et profitez gratuitement du flot de données du décompilateur quand les arguments ne sont pas de simples valeurs immédiates.
Quand la réimplémentation est difficile
Parfois, vous ne pouvez pas porter proprement l’algorithme : il appelle la CRT, dépend d’un cadencement de clé volumineux, se modifie lui-même, ou est tout simplement long et délicat. Deux solutions de repli récupèrent le même texte clair sans réimplémentation fidèle.
- Émuler la routine pour chaque site d’appel. Chargez les octets de l’échantillon dans un émulateur de CPU (Unicorn, ou Qiling pour un environnement plus complet), préparez les arguments comme le fait chaque site d’appel, exécutez la fonction et lisez le tampon de sortie dans la mémoire émulée. Vous réutilisez le vrai code au lieu de le réécrire, ce qui est idéal quand l’algorithme est complexe mais autonome. C’est la technique au cœur de la leçon à venir Émuler du code avec Unicorn et speakeasy, et elle est étroitement liée à l’instrumentation binaire dynamique.
- Journaliser dans un débogueur. Placez un point d’arrêt sur le retour de la routine, scriptez le débogueur pour qu’il enregistre le tampon de sortie et l’adresse de retour, et laissez l’échantillon déchiffrer ses propres chaînes pendant son exécution. Cela passe à travers tout — clés récupérées sur le réseau, code auto-modifiant — mais uniquement pour les chemins réellement exécutés, et cela implique d’exécuter l’échantillon : cela se fait donc au laboratoire.
Réimplémentation, émulation et débogage présentent le même compromis tout au long de ce parcours : le statique est exhaustif mais fragile face à la complexité, le dynamique est robuste mais ne voit que ce qui s’exécute. Pour le déchiffrement de chaînes, réimplémentez quand la boucle est courte, émulez quand elle est complexe mais pure, et déboguez quand elle dépend de l’état à l’exécution.
Ce que FLOSS automatise
Vous avez déjà rencontré FLOSS, dans Chaînes et chaînes obfusquées. Il automatise précisément la solution de repli par émulation : il classe les fonctions décodeuses candidates selon leurs caractéristiques (boucles, XOR avec des opérandes non nuls, nombreux appelants), émule chacune avec les arguments que passeraient ses sites d’appel, et compare la mémoire avant et après pour capturer les données imprimables apparues. Sur de nombreux échantillons, FLOSS récupère les chaînes sans aucun script de votre part, et ce doit être votre premier réflexe.
Scripter vous-même le déchiffreur devient important quand FLOSS n’y parvient pas : quand le décodeur ne correspond pas à ses heuristiques, quand la clé vient de l’extérieur de la fonction, quand il y a des milliers de sites d’appel que vous voulez annoter individuellement avec le texte clair à chaque utilisation, ou quand vous avez besoin des résultats dans Ghidra plutôt que dans un rapport. FLOSS trouve les chaînes ; un script sur mesure relie chaque texte clair à l’endroit où il est utilisé et à l’argument qui l’a sélectionné, ce qui est nécessaire pour comprendre le comportement plutôt que simplement lister des indicateurs.
Valider la sortie
Ne faites jamais aveuglément confiance à une sortie déchiffrée. Une mauvaise clé ou une erreur d’une unité dans la boucle produit du charabia présenté avec aplomb, et une sortie à moitié juste — le bon algorithme avec la mauvaise longueur — est plus dangereuse qu’un échec évident.
- Vérifiez visuellement l’imprimabilité. Les vraies chaînes sont majoritairement imprimables et se terminent là où vous l’attendez. Une suite d’octets à bit de poids fort signifie que l’algorithme ou la longueur est faux.
- Recoupez une chaîne en dynamique. Déchiffrez une valeur dans un débogueur et confirmez que votre script produit les mêmes octets. Une chaîne confirmée valide tout le lot.
- Contrôlez le décompte. Si la routine a 300 appelants et que vous avez récupéré 40 chaînes, les 260 autres sont passées quelque part — une table que vous n’avez pas modélisée, un motif d’arguments que votre remontée a manqué. Expliquez l’écart.
- Surveillez l’encodage. La sortie peut être en UTF-16LE, ou enveloppée dans une autre couche (Base64, un second XOR). Décodez d’abord en octets, choisissez l’encodage du texte ensuite.
Lab : récupérer toutes les chaînes d’un échantillon d’entraînement
Vous allez construire un échantillon inoffensif dont les chaînes sont masquées
par un schéma XOR plus indice et décodées par une seule routine, puis écrire un
script Python avec pefile et Capstone qui trouve chaque appel à cette routine,
récupère chaque pointeur source et chaque longueur, déchiffre les tampons et
affiche la correspondance adresse → texte clair. L’échantillon ne fait
qu’afficher ; sa seule raison d’être est d’être démonté. Il vous faut
mingw-w64 et Python 3.
-
Créez un répertoire de travail et un environnement virtuel (testé avec Capstone 5.0.9 et pefile 2024.8.26) :
bash mkdir -p m8b && cd m8b python3 -m venv .venv .venv/bin/pip install capstone pefile -
Générez les données encodées avec
gen.py. Chaque chaîne est stockée sous la formeplain[i] ^ 0x5A ^ i:python # gen.py: writes lab_data.h with eight index-XOR-encoded strings. STRINGS = ["update.example.com", "/api/v1/beacon", "Mozilla/5.0 (LabLab)", "LAB-CAMPAIGN-2026", "SOFTWARE\\LabVendor\\Agent", "lab_mutex_9f13", "%TEMP%\\lab_agent.tmp", "config.reload"] def enc(s): return bytes((b ^ 0x5A ^ (i & 0xFF)) & 0xFF for i, b in enumerate(s.encode())) lines = ["/* generated */", "#define STR_COUNT %d" % len(STRINGS)] for i, s in enumerate(STRINGS): body = ", ".join("0x%02x" % b for b in enc(s)) lines.append("static const unsigned char s%d[] = { %s };" % (i, body)) open("lab_data.h", "w").write("\n".join(lines) + "\n") print("wrote lab_data.h") -
Écrivez
lab_sample.c. Une seule routinedecode, appelée une fois par chaîne avec un pointeur et une longueur qui sont tous deux des valeurs immédiates — la forme déroulée que prend un vrai déchiffreur :c /* lab_sample.c - harmless: decodes and prints eight obscured strings. */ #include <stdio.h> #include "lab_data.h" __attribute__((noinline)) static void decode(char *dst, const unsigned char *src, int len) { for (int i = 0; i < len; i++) dst[i] = (char)(src[i] ^ 0x5A ^ (i & 0xFF)); dst[len] = '\0'; } #define SHOW(idx) do { decode(buf, s##idx, (int)sizeof(s##idx)); \ printf("string[%d] = %s\n", idx, buf); } while (0) int main(void) { char buf[256]; SHOW(0); SHOW(1); SHOW(2); SHOW(3); SHOW(4); SHOW(5); SHOW(6); SHOW(7); return 0; }Compilez-le et vérifiez qu’il s’exécute (Wine, ou une VM Windows) :
bash .venv/bin/python gen.py x86_64-w64-mingw32-gcc -O2 -s -o lab_sample.exe lab_sample.c -
Examinez un site d’appel et la routine avant de scripter.
objdumpmontre le motif que le script exploitera — une longueur immédiate et un pointeur relatif à RIP juste avant chaquecall:text 140002a8d: lea rcx,[rsp+0xf0] 140002a95: mov r8d,0x12 140002a9b: lea rdx,[rip+0x164e] # 0x1400040f0 140002aa2: call 0x1400014a0Et la routine elle-même, dont la boucle est l’algorithme à porter —
src[i] ^ i ^ 0x5A:text 1400014c0: movzx r9d,BYTE PTR [rdx+rax*1] 1400014c5: xor r9d,eax ; ^ index 1400014c8: xor r9d,0x5a ; ^ key 1400014cc: mov BYTE PTR [rcx+rax*1],r9b 1400014d0: add rax,0x1 1400014d4: cmp r8,rax 1400014d7: jne 0x1400014c0Le binaire est strippé, il n’y a donc pas d’étiquettes
decodenimain— le script doit déduire la routine, ce qui est le cas réaliste. -
Écrivez
decrypt_strings.py. Il déduit le déchiffreur par signature d’arguments (la cible d’appel dont les sites sont le plus souvent précédés d’unlea rdxrelatif à RIP et d’unmov r8d, imm), car un classement par simple nombre d’appelants désigne ici une fonction utilitaire du runtime C :python #!/usr/bin/env python3 """decrypt_strings.py - recover every string, printing address -> plaintext.""" import argparse from collections import Counter import pefile from capstone import Cs, CS_ARCH_X86, CS_MODE_64 from capstone.x86 import X86_OP_MEM, X86_OP_IMM, X86_REG_RIP, X86_REG_RDX, X86_REG_R8D WINDOW = 8 # instructions before a call to search for its arguments def decode(buf): return bytes((b ^ 0x5A ^ (i & 0xFF)) & 0xFF for i, b in enumerate(buf)) def main(): ap = argparse.ArgumentParser() ap.add_argument("exe") ap.add_argument("--func", type=lambda s: int(s, 0), default=None) args = ap.parse_args() pe = pefile.PE(args.exe) base = pe.OPTIONAL_HEADER.ImageBase md = Cs(CS_ARCH_X86, CS_MODE_64) md.detail = True insns = [] for sec in pe.sections: if sec.Characteristics & 0x20000000: # IMAGE_SCN_MEM_EXECUTE code = sec.get_data()[: sec.Misc_VirtualSize] insns.extend(md.disasm(code, base + sec.VirtualAddress)) def recover_args(i): """Walk back for a RIP-relative rdx (src) and an r8d immediate (len).""" src = length = None for j in range(i - 1, max(i - 1 - WINDOW, -1), -1): p = insns[j] if p.mnemonic == "call": # do not cross a call break if p.mnemonic == "lea" and p.operands[0].reg == X86_REG_RDX: m = p.operands[1] if m.type == X86_OP_MEM and m.mem.base == X86_REG_RIP: src = p.address + p.size + m.mem.disp elif p.mnemonic == "mov" and p.operands[0].reg == X86_REG_R8D \ and p.operands[1].type == X86_OP_IMM: length = p.operands[1].imm if src is not None and length is not None: break return src, length calls = [i for i, ins in enumerate(insns) if ins.mnemonic == "call" and ins.operands and ins.operands[0].type == X86_OP_IMM] if args.func is not None: target = args.func else: sig = Counter() for i in calls: s, n = recover_args(i) if s is not None and n is not None: sig[insns[i].operands[0].imm] += 1 target, n = sig.most_common(1)[0] print(f"decode routine inferred at 0x{target:x} ({n} signed call sites)") for i in calls: if insns[i].operands[0].imm != target: continue src, length = recover_args(i) if src is None or length is None: print(f" 0x{insns[i].address:x} <could not recover args>") continue data = pe.get_data(src - base, length) print(f" 0x{insns[i].address:x} src=0x{src:x} len={length:<3} " f"{decode(data).decode('latin-1')!r}") if __name__ == "__main__": main() -
Exécutez-le :
bash .venv/bin/python decrypt_strings.py lab_sample.exetext decode routine inferred at 0x1400014a0 (8 signed call sites) 0x140002aa2 src=0x1400040f0 len=18 'update.example.com' 0x140002ad2 src=0x1400040d8 len=14 '/api/v1/beacon' 0x140002b05 src=0x1400040c0 len=20 'Mozilla/5.0 (LabLab)' 0x140002b38 src=0x1400040a0 len=17 'LAB-CAMPAIGN-2026' 0x140002b6b src=0x140004080 len=24 'SOFTWARE\\LabVendor\\Agent' 0x140002b9e src=0x140004068 len=14 'lab_mutex_9f13' 0x140002bd1 src=0x140004050 len=20 '%TEMP%\\lab_agent.tmp' 0x140002c04 src=0x140004040 len=13 'config.reload'Chaque chaîne est récupérée et rattachée au site d’appel qui l’utilise. Vos adresses différeront avec une autre version du compilateur ; la forme, non.
-
Passez au désassembleur. Importez
lab_sample.exedans Ghidra, lancez l’analyse automatique et adaptez la remontée des arguments à l’API Ghidra : parcourezgetReferencesTola routine de décodage, lisez lelea/movavant chaque appel, et appelezsetEOLCommentavec le texte clair etcreateLabelsur chaque tampon source. Le listing affiche alors la chaîne déchiffrée à côté de chaque utilisation — le vrai bénéfice, puisque cela rend lisible la logique environnante.
Questions : Pourquoi le classement des cibles d’appel par seul nombre
d’appelants désigne-t-il ici la mauvaise fonction, et qu’apporte l’heuristique
de signature d’arguments ? Que renverrait recover_args si la longueur était
calculée dans un compteur de boucle au lieu d’un mov r8d, imm, et vers laquelle
des deux solutions de repli vous tourneriez-vous ? Si les chaînes étaient en
UTF-16LE, à quel endroit du script modifieriez-vous le décodage, et comment
confirmeriez-vous que c’est correct ? Comment adapteriez-vous le script si les
pointeurs vers le texte chiffré étaient des entrées d’une grande table indexée
par un petit argument entier plutôt que des cibles de lea distinctes ?
À retenir
- Un déchiffreur de chaînes est une routine avec de nombreux appelants, un argument pointeur de données et une petite boucle ; trouvez-le en pivotant depuis le blob ou depuis le nombre d’appelants, et confirmez en lisant le corps.
- Récupérez les arguments de chaque appel en remontant de quelques instructions
depuis le
call, en lisant les valeurs immédiates et en calculant les cibles des pointeurs relatifs à RIP, et arrêtez-vous plutôt que de deviner quand une valeur est calculée. - Réimplémentez fidèlement la boucle — largeurs d’octets et troncature de l’indice comprises — et réécrivez le texte clair sous forme de commentaires et d’étiquettes avec l’API de script de Ghidra ou d’IDA, afin que chaque site d’utilisation devienne lisible.
- Quand l’algorithme résiste à un portage propre, émulez la routine pour chaque site d’appel ou journalisez sa sortie dans un débogueur ; FLOSS automatise l’approche par émulation et constitue le bon premier réflexe.
- Validez avant de faire confiance : vérifiez l’imprimabilité, confirmez une chaîne en dynamique, rapprochez le nombre de chaînes récupérées du nombre de sites d’appel, et traitez l’encodage du texte comme une étape à part.