Skip to content

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œuvreCible typiqueEffet
Inverser un saut conditionnelLe jcc qui suit une vérification anti-analysePrendre le chemin que l’échantillon évite
Remplacer un appel par des NOPUne vérification ou un délai que vous ne voulez pas exécuterFaire que l’instruction ne fasse rien
Forcer une valeur de retourUne fonction de détection qui renvoie « trouvé »Lui faire toujours renvoyer « non trouvé »
Sauter une mise en sommeilUn long Sleep ou une temporisationAtteindre 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 :

text
file_offset = RVA − section.VirtualAddress + section.PointerToRawData

pour 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 .text remarquera 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 :

PatchOctetsRemarques
nop90Un octet, ne fait rien ; le remplissage par excellence
Inverser je↔jne74↔75Un octet d’opcode de saut conditionnel ; offset inchangé
Forcer le branchement74→ebje → jmp court ; même longueur de 2 octets
xor eax, eax31 c0Met le registre de retour à 0 (voir xor)
retc3Retourne immédiatement
Forcer « return 0 »31 c0 c3xor 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 jcc est 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 :

ChampExemple
EmplacementRVA 0x14d6 (file offset 0x8d6), dans main
Avant / après74 16 (je) → 75 16 (jne)
Pourquoi« inverser la porte LAB_UNLOCK pour que la branche cachée s’exécute »
Portéepatch 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.

  1. Enregistrez gate.c. L’assistant is_unlocked est tenu hors de main pour qu’un seul saut conditionnel dans main dé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 value
    text
    check failed - hiding behaviour
    check failed - hiding behaviour
    unlocked - real behaviour would run here

    Le comportement reste caché tant que la vérification échoue — exactement la forme d’un échantillon qui refuse d’agir hors de son environnement attendu.

  2. Enregistrez patch.py. Il identifie main comme la fonction qui référence la chaîne « hiding behaviour », prend le premier jcc court 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.py
    text
    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 je qui sautait vers le bloc « check failed » est devenu un jne, 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.

  3. 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 variable
    text
    2263 164 165
    check failed - hiding behaviour
    unlocked - real behaviour would run here

    cmp -l signale un seul octet différent (l’offset 2263 en décimal vaut 0x8d6 plus un, dans le décompte à partir de 1 de cmp ; 164/165 en octal valent 0x74/0x75). Même entrée, comportement opposé : le binaire patché révèle la branche que l’original cachait. Si wine n’est pas disponible, relancez la dernière étape de patch.py sur le fichier patché pour afficher jne à la place de je — le désassemblage suffit à prouver que la porte est inversée.

  4. Comparez les deux approches au niveau de l’octet. Dans x64dbg, vous obtiendriez le même résultat en vous arrêtant sur le je et en appuyant sur Space pour assembler jne — 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 version jne avant 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 (ou pefile.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 un call entier par des 90, ou neutralisez une fonction avec 31 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.