Leçon 9.4 · Évasion & unpacking· 45 min
Patcher un binaire pour contourner les vérifications
Patcher pour analyser : inverser un saut, neutraliser une vérification par des NOP ou forcer un retour pour qu’un échantillon révèle ce qu’il cache.
Objectifs
- Présenter le patching comme un moyen de faire révéler à un échantillon un comportement caché, distinct du cracking de logiciels
- Choisir entre un patch en mémoire dans le débogueur et un patch du fichier, et convertir un RVA en offset de fichier pour ce dernier
- Encoder les patchs courants — jcc inversé, remplissage de NOP, retour forcé — sans casser les frontières d’instructions
- Reconnaître les contrôles d’intégrité qu’un patch de fichier peut déclencher, de la somme de contrôle PE à l’auto-vérification
- Déterminer quand le patching n’est pas le bon outil et qu’un hook ou une modification de l’environnement convient mieux
Déboguer un malware avec x64dbg se terminait en basculant un flag pour prendre la branche qu’un échantillon refusait, et promettait que le patching persistant aurait sa propre leçon. La voici. Patcher, c’est modifier les octets d’un programme — dans un débogueur le temps d’une exécution, ou dans le fichier de façon définitive — pour qu’il fasse quelque chose qu’il ne ferait pas de lui-même.
Pour un analyste de malwares, le patching n’a rien à voir avec le cracking de logiciels. Il s’agit de faire parler un échantillon récalcitrant : un binaire conditionne son comportement intéressant à une vérification — présence d’un débogueur, d’une sandbox (bac à sable), d’un argument de ligne de commande, d’un mutex ou d’une réponse du C2 —, et plutôt que de satisfaire chaque vérification, vous supprimez la vérification et observez la suite. La capacité que vous mettez au jour est réelle ; la séquence d’événements est forcée, et vous la signalez comme telle. Tout ce qui suit s’exécute dans le laboratoire isolé de Construire un laboratoire d’analyse sûr.
Ce qu’un patch vous apporte
La plupart des patchs d’analyse se ramènent à l’une de quatre petites manœuvres. Chacune neutralise une catégorie de vérification déjà rencontrée dans ce parcours.
| Manœuvre | Cible typique | Effet |
|---|---|---|
| Inverser un saut conditionnel | Le jcc qui suit une vérification anti-analyse | Prendre le chemin que l’échantillon évite |
| Remplacer un appel par des NOP | Une vérification ou un délai que vous ne voulez pas exécuter | Faire que l’instruction ne fasse rien |
| Forcer une valeur de retour | Une fonction de détection qui renvoie « trouvé » | Lui faire toujours renvoyer « non trouvé » |
| Sauter une mise en sommeil | Un long Sleep ou une temporisation | Atteindre le comportement ultérieur sans attendre |
Concrètement, c’est ainsi que vous franchissez les techniques d’évasion du reste de ce module. Un appel à IsDebuggerPresent ou à CheckRemoteDebuggerPresent dont le résultat pilote un jcc — inversez le saut ou forcez le retour à zéro. Une vérification CPUID de l’hyperviseur ou une vérification de sandbox par l’activité utilisateur — même traitement. Un piège temporel RDTSC ou une temporisation d’exécution différée — remplacez par des NOP le délai ou la comparaison. Dans tous les cas, vous avez d’abord trouvé la vérification avec les méthodes statiques et dynamiques de Références croisées et flot de données et de Lire les capacités à partir des imports ; le patching n’est que la dernière étape.
En mémoire ou dans le fichier
Il existe deux endroits où patcher, et bien choisir fait gagner du temps.
Un patch en mémoire se fait dans le débogueur. Dans x64dbg, vous sélectionnez l’instruction, appuyez sur Space et tapez la remplaçante ; la modification vit dans le processus et disparaît au redémarrage, sauf si vous l’enregistrez. C’est le bon outil tant que vous explorez : il est instantané, trivialement réversible, et il fonctionne sur du code qui n’existe qu’après unpacking (dépaquetage) ou déchiffrement — du code qui n’est pas du tout dans le fichier. Toute la leçon Comment fonctionnent les packers rappelle que les octets qui vous intéressent ne vivent souvent qu’en mémoire ; vous ne pouvez pas patcher dans le fichier ce qui est compressé sur disque. Patchez ce code en mémoire, ou patchez-le après l’avoir dumpé et réparé comme dans Dumper la mémoire et extraire les charges utiles.
Un patch de fichier modifie l’exécutable sur disque : la modification persiste d’une exécution à l’autre et peut être partagée avec un collègue ou soumise à une sandbox. Le prix à payer : vous devez localiser l’octet vous-même, et vous risquez de déclencher un contrôle d’intégrité. Optez pour un patch de fichier quand la vérification s’exécute de nombreuses fois (basculer un flag à la main à chaque passage est un calvaire), quand vous voulez un échantillon « désarmé » réutilisable, ou quand un autre outil — pas un débogueur — exécutera le binaire.
Du RVA à l’offset de fichier
Votre désassembleur indique l’adresse d’une instruction sous forme de RVA (ou d’adresse virtuelle dont vous soustrayez la base de l’image). Le fichier n’a pas de RVA ; il a des offsets bruts. Chaque section PE enregistre les deux, d’où la conversion :
file_offset = RVA − section.VirtualAddress + section.PointerToRawDatapour la section dont la plage virtuelle contient le RVA. pefile le fait pour vous avec pe.get_offset_from_rva(rva), ce qu’utilise le lab. Si vous vous trompez, vous écrasez un octet sans rapport — désassemblez toujours ensuite les octets à cet offset pour confirmer que vous avez touché l’instruction voulue.
Attention : Un patch de fichier peut casser des choses au-delà de l’instruction. L’en-tête PE contient un champ facultatif CheckSum ; le loader Windows le vérifie pour les pilotes et certaines images système, qui refuseront donc de se charger tant que vous ne l’aurez pas recalculé (PE-bear et de nombreux outils savent le faire). Toute modification d’octet invalide une signature Authenticode — sans conséquence pour l’analyse en laboratoire, mais le fichier apparaîtra comme altéré. Et le malware peut calculer sa propre somme de contrôle : une routine d’auto-vérification qui hache sa propre section
.textremarquera votre patch et changera de comportement. Quand un patch de fichier se comporte mal, soupçonnez d’abord l’auto-vérification d’intégrité.
Encoder un patch à la main
Un patch n’est valide que s’il laisse le code environnant se désassembler correctement. Règle cardinale : préserver les frontières d’instructions et la longueur totale. Si votre remplacement est plus court que ce qu’il remplace, complétez le reste pour que l’instruction suivante commence toujours au même endroit. S’il est plus long, vous ne pouvez pas le loger sur place — il vous faut une code cave (zone de code inutilisée) ou une autre approche. Voir encodage des instructions x86 pour comprendre pourquoi les longueurs sont fixées par les octets, et non par le mnémonique.
Les briques de base :
| Patch | Octets | Remarques |
|---|---|---|
nop | 90 | Un octet, ne fait rien ; le remplissage par excellence |
Inverser je↔jne | 74↔75 | Un octet d’opcode de saut conditionnel ; offset inchangé |
| Forcer le branchement | 74→eb | je → jmp court ; même longueur de 2 octets |
xor eax, eax | 31 c0 | Met le registre de retour à 0 (voir xor) |
ret | c3 | Retourne immédiatement |
| Forcer « return 0 » | 31 c0 c3 | xor eax, eax; ret — une fonction entière neutralisée |
Deux encodages du même saut comptent. Un saut conditionnel court fait deux octets : un opcode 0x70–0x7F suivi d’un offset signé d’un octet (74 16 est je +0x16). Un saut conditionnel near fait six octets : 0F, puis 0x80–0x8F, puis un offset de quatre octets. Le jmp inconditionnel est EB rel8 (2 octets) ou E9 rel32 (5 octets). Comme un je court et un jmp court font tous deux deux octets, transformer l’un en l’autre revient à échanger proprement un octet d’opcode en laissant l’offset intact — le patch le plus sûr qui soit. Inverser je en jne ne touche de même qu’un octet et garde le saut conditionnel, ce que vous voulez quand vous comptez seulement inverser une décision unique. Pour tout ce qui est plus rare, consultez une référence des opcodes plutôt que de vous fier à votre mémoire.
Remplacer un call par des NOP est l’autre patch du quotidien. Un call near est E8 suivi d’un déplacement de quatre octets — cinq octets en tout —, donc vous remplacez les cinq par des 90. N’en remplacez que quatre, et l’octet de déplacement orphelin devient une instruction bidon qui fait dérailler le désassembleur et, pire, le CPU. Pour neutraliser une vérification et maîtriser son résultat, il est souvent plus propre de garder le call et de patcher plutôt la fonction de vérification elle-même en 31 c0 c3, ou de patcher le jcc qui consomme son résultat.
Astuce : Préférez le patch le plus petit et le plus local qui réponde à votre question. Inverser un seul
jccest plus facile à raisonner, à documenter et à annuler que de remplacer un bloc entier par des NOP. Si vous vous surprenez à remplacer de nombreuses instructions par des NOP, prenez du recul — un hook ou une modification de l’environnement (voir plus bas) est peut-être le bon outil.
Documenter chaque patch
Un échantillon patché n’est plus l’échantillon. Si vous ne notez pas ce que vous avez modifié, vous vous embrouillerez une heure plus tard et induirez en erreur quiconque lira vos notes. Pour chaque patch, consignez :
| Champ | Exemple |
|---|---|
| Emplacement | RVA 0x14d6 (file offset 0x8d6), dans main |
| Avant / après | 74 16 (je) → 75 16 (jne) |
| Pourquoi | « inverser la porte LAB_UNLOCK pour que la branche cachée s’exécute » |
| Portée | patch de fichier, enregistré sous gate_patched.exe (SHA-256 …) |
| Conséquence | « force le chemin déverrouillé quelle que soit la variable d’environnement » |
Hachez le fichier patché, conservez l’original et — comme l’exigeait la leçon sur le débogage — signalez tout comportement atteint grâce à un patch comme forcé. La capacité est authentique ; le chemin pour y parvenir n’est pas celui que suivrait une victime.
Quand le patching n’est pas le bon outil
Le patching est chirurgical et permanent, ce qui en fait aussi souvent le mauvais choix que le bon.
- La vérification s’exécute en permanence, ou vous avez besoin d’un comportement normal par ailleurs. Si un échantillon appelle sa routine anti-débogage dans une boucle critique mais que vous voulez que tout le reste reste intact, hooker l’API (comme le fait le plugin anti-débogage ScyllaHide) est plus propre que de charcuter le binaire — cela change ce que la fonction renvoie sans toucher aux sites d’appel. Voir Anti-débogage.
- Le problème vient de l’environnement, pas du code. Quand un échantillon abandonne parce que la machine ressemble à une VM, patcher une à une une douzaine de vérifications de sandbox est fragile, et il est facile d’en oublier une. Faire ressembler la VM à un vrai poste de travail — plus de RAM, de vrais noms de fichiers, de l’activité utilisateur — les neutralise toutes d’un coup et garde le comportement de l’échantillon fidèle.
- Du code ultérieur dépend de ce que vous avez sauté. Remplacez un appel de déchiffrement par des NOP, et le code qui lit le tampon déchiffré plantera ou lira des déchets. Si une vérification a des effets de bord, forcer son résultat est plus sûr que la supprimer.
- Le code est packé ou auto-modifiant. Vous ne pouvez pas patcher utilement sur disque des octets compressés ou réécrits à l’exécution ; travaillez en mémoire une fois qu’ils sont restaurés (l’unpacking manuel fera l’objet d’une prochaine leçon).
Une bonne règle : basculez un flag pendant l’exploration, hookez quand une vérification est bavarde, modifiez l’environnement quand c’est l’environnement qui trahit, et patchez le fichier quand vous voulez un résultat durable et partageable et que vous avez vérifié que l’échantillon ne se contrôle pas lui-même.
Lab : patcher une porte pour révéler un comportement caché
Vous allez compiler un programme qui cache son comportement derrière la vérification d’une variable d’environnement, puis écrire un patcheur qui trouve la porte avec capstone, convertit son RVA en offset de fichier avec pefile, inverse un octet et produit une copie patchée — que vous vérifierez avec wine. Il vous faut mingw-w64, un environnement virtuel avec pefile et capstone, et wine (s’il n’est pas disponible, vérifiez plutôt en désassemblant les octets patchés). Les listings proviennent de MinGW-w64 GCC, pefile 2024.8.26, Capstone 5.0.7 et Wine 11.0 ; vos adresses seront différentes.
-
Enregistrez
gate.c. L’assistantis_unlockedest tenu hors demainpour qu’un seul saut conditionnel dansmaindépartage les deux chemins :c #include <stdio.h> #include <stdlib.h> #include <string.h> /* 1 only when LAB_UNLOCK holds the expected value */ __attribute__((noinline)) static int is_unlocked(void) { const char *v = getenv("LAB_UNLOCK"); return v != NULL && strcmp(v, "open-sesame") == 0; } int main(void) { if (is_unlocked()) puts("unlocked - real behaviour would run here"); else puts("check failed - hiding behaviour"); return 0; }bash python3 -m venv venv && ./venv/bin/pip install pefile capstone x86_64-w64-mingw32-gcc -O1 -s -o gate.exe gate.c wine gate.exe # no variable set LAB_UNLOCK=nope wine gate.exe # wrong value LAB_UNLOCK=open-sesame wine gate.exe # correct valuetext check failed - hiding behaviour check failed - hiding behaviour unlocked - real behaviour would run hereLe comportement reste caché tant que la vérification échoue — exactement la forme d’un échantillon qui refuse d’agir hors de son environnement attendu.
-
Enregistrez
patch.py. Il identifiemaincomme la fonction qui référence la chaîne « hiding behaviour », prend le premierjcccourt qu’elle contient comme porte, convertit son RVA en offset de fichier, inverse l’octet d’opcode et écrit une copie patchée :python # patch.py — flip the conditional jump that gates the hidden branch import pefile from capstone import Cs, CS_ARCH_X86, CS_MODE_64 from capstone.x86 import X86_OP_MEM, X86_REG_RIP, X86_GRP_JUMP SRC, DST = "gate.exe", "gate_patched.exe" JCC = {0x74: ("je", 0x75, "jne"), 0x75: ("jne", 0x74, "je")} pe = pefile.PE(SRC) base = pe.OPTIONAL_HEADER.ImageBase img = pe.get_memory_mapped_image() md = Cs(CS_ARCH_X86, CS_MODE_64); md.detail = True def refs_string(insns, needle): for i in insns: for op in i.operands: if op.type == X86_OP_MEM and op.mem.base == X86_REG_RIP: ea = i.address + i.size + op.mem.disp - base s = img[ea:img.find(b"\0", ea)] if needle in s: return True return False # x64 PE files list every non-leaf function's bounds in .pdata funcs = [(base + e.struct.BeginAddress, base + e.struct.EndAddress) for e in pe.DIRECTORY_ENTRY_EXCEPTION] start, end = next((s, e) for s, e in funcs if refs_string(list(md.disasm(img[s-base:e-base], s)), b"hiding behaviour")) insns = list(md.disasm(img[start-base:end-base], start)) gate = next(i for i in insns if X86_GRP_JUMP in i.groups and i.bytes[0] in JCC) rva = gate.address - base was, opp, opp_name = JCC[gate.bytes[0]] print(f"gate: {gate.mnemonic} at VA {gate.address:#x} (RVA {rva:#x}) bytes {gate.bytes.hex(' ')}") off = pe.get_offset_from_rva(rva) print(f"RVA {rva:#x} -> file offset {off:#x}") data = bytearray(open(SRC, "rb").read()) assert data[off] == gate.bytes[0], "opcode at file offset does not match" print(f"file byte at {off:#x}: {data[off]:#04x} ({was}) -> {opp:#04x} ({opp_name})") data[off] = opp open(DST, "wb").write(data) print(f"wrote {DST} ({len(data)} bytes)") patched = next(md.disasm(bytes(data[off:off+2]), gate.address)) print(f"patched instruction: {patched.mnemonic} {patched.op_str} (bytes {patched.bytes.hex(' ')})")bash ./venv/bin/python patch.pytext gate: je at VA 0x1400014d6 (RVA 0x14d6) bytes 74 16 RVA 0x14d6 -> file offset 0x8d6 file byte at 0x8d6: 0x74 (je) -> 0x75 (jne) wrote gate_patched.exe (16896 bytes) patched instruction: jne 0x1400014ee (bytes 75 16)Un seul octet a changé : le
jequi sautait vers le bloc « check failed » est devenu unjne, si bien que le sens de la décision est inversé tandis que sa cible et sa longueur restent identiques. Le re-désassemblage confirme que la frontière d’instruction a survécu. -
Vérifiez qu’un seul octet diffère, puis exécutez les deux copies :
bash cmp -l gate.exe gate_patched.exe wine gate.exe # unpatched, no variable wine gate_patched.exe # patched, no variabletext 2263 164 165 check failed - hiding behaviour unlocked - real behaviour would run herecmp -lsignale un seul octet différent (l’offset2263en décimal vaut0x8d6plus un, dans le décompte à partir de 1 decmp;164/165en octal valent0x74/0x75). Même entrée, comportement opposé : le binaire patché révèle la branche que l’original cachait. Siwinen’est pas disponible, relancez la dernière étape depatch.pysur le fichier patché pour afficherjneà la place deje— le désassemblage suffit à prouver que la porte est inversée. -
Comparez les deux approches au niveau de l’octet. Dans x64dbg, vous obtiendriez le même résultat en vous arrêtant sur le
jeet en appuyant surSpacepour assemblerjne— un patch en mémoire qui disparaît au redémarrage. Le patch de fichier que vous venez de faire persiste. Essayez aussi de transformer la porte en saut inconditionnel (0x74→0xeb) et prévoyez en quoi le comportement diffère de la versionjneavant de le tester.
Questions : Avec la porte patchée en jne, qu’affiche désormais LAB_UNLOCK=open-sesame wine gate_patched.exe, et pourquoi ? Pourquoi inverser je en jne est-il ici plus sûr que de remplacer ensemble le test et le je par des NOP ? Si gate.exe recalculait au démarrage un hachage de sa propre section .text et refusait de s’exécuter en cas de non-correspondance, lequel de vos deux patchs (en mémoire ou dans le fichier) survivrait, et comment traiteriez-vous l’autre ? À quel offset de fichier correspondrait la porte si son RVA tombait dans une section dont le PointerToRawData différait de son VirtualAddress ?
À retenir
- Le patching est un outil d’analyse : il force un échantillon à révéler un comportement caché derrière une vérification, et tout chemin atteint de cette façon est signalé comme forcé.
- Patchez en mémoire (débogueur, réversible, fonctionne sur le code dépaqueté) pendant l’exploration ; patchez le fichier quand vous avez besoin d’un résultat durable et partageable.
- Convertissez un RVA en offset de fichier avec
RVA − VirtualAddress + PointerToRawData(oupefile.get_offset_from_rva), et re-désassemblez toujours pour confirmer que vous avez visé juste. - Préservez les frontières et la longueur des instructions : inversez un opcode
jcc(74↔75), forcez un branchement (74→eb), remplacez uncallentier par des90, ou neutralisez une fonction avec31 c0 c3. - Les patchs de fichier peuvent déclencher les sommes de contrôle PE, invalider les signatures et être détectés par le propre contrôle d’intégrité d’un échantillon — soupçonnez l’auto-vérification quand un patch échoue.
- Hookez plutôt que patcher quand une vérification est bavarde, modifiez l’environnement quand c’est l’environnement qui trahit, et ne patchez jamais dans le fichier du code packé ou auto-modifiant — travaillez sur les octets restaurés en mémoire.