Skip to content

Leçon 7.6 · Comportements malveillants· 45 min

Hooking et rootkits en mode utilisateur

Repérer les hooks inline, IAT et EAT en comparant mémoire et disque, distinguer produits de sécurité et rootkits, prouver la dissimulation par vue croisée.

Objectifs

  • Expliquer pourquoi le hooking est à double usage et ce qu’un rootkit en mode utilisateur cache, et à quels observateurs
  • Détecter les modifications inline, de l’IAT et de la table des exports en comparant un module en mémoire avec son fichier sur disque
  • Lire un prologue de fonction modifié et classer un hook selon l’endroit où aboutit son saut, l’auteur de la signature de ce code et sa comparaison avec une référence saine
  • Utiliser la détection par vue croisée, des API en direct face aux images mémoire jusqu’à l’analyse hors ligne du disque, pour prouver que quelque chose est caché
  • Collecter la mémoire et le disque avant la remédiation et décrire un hook dans des termes qu’un autre analyste peut vérifier

Tracer les appels d’API et les appels système a mis les hooks à votre service : API Monitor et Frida patchent des fonctions à l’intérieur d’un processus pour que vous puissiez observer chaque appel. Cette leçon retourne le même mécanisme. Quand un malware (logiciel malveillant) patche des fonctions, le processus se met à mentir — au Gestionnaire des tâches, à dir, à netstat et parfois à vos propres outils — et votre travail consiste à le prouver.

L’angle est strictement défensif : à quoi ressemble un hook en mémoire, comment les scanners le trouvent, à qui il appartient et ce qu’il cachait. La méthode s’appuie sur Dumper la mémoire et extraire les charges utiles, qui a reproduit la disposition d’un PE en mémoire, et sur Imports, exports et IAT, qui a décrit les tables que visent les hooks.

Le hooking est à double usage

Un hook redirige un appel pour qu’un autre code s’exécute avant, à la place ou après. L’entrée du glossaire sur le hooking d’API en détaille le fonctionnement ; ce qui compte ici, c’est le nombre de programmes légitimes qui le pratiquent.

Qui pose des hooksCe que l’on trouve généralement
EDR et antivirusDes sauts au début de fonctions de ntdll menant vers une DLL signée de l’éditeur
Traceurs (Instrumentation binaire dynamique)L’agent de Frida ou la DLL d’API Monitor, avec des trampolines en mémoire privée
DébogueursPoints d’arrêt logiciels : un octet int3 (CC) sur une instruction
Les applications elles-mêmesDes navigateurs qui hookent leurs propres modules ; le moteur de shims de Windows qui patche un ancien programme
MalwaresDes sauts vers du code non signé ou non adossé à un fichier depuis des API d’énumération, de réseau ou de télémétrie

La FAQ de pe-sieve est sans détour sur ce point : l’outil détecte les hooks mais ne peut pas dire s’ils sont malveillants, et il sera bruyant sur un hôte équipé d’un antivirus. Un hook est une observation. La classification vient ensuite.

Ce que cache un rootkit en mode utilisateur

Un rootkit en mode utilisateur (ATT&CK T1014) charge du code dans les processus qui observent — l’Explorateur, le Gestionnaire des tâches, cmd.exe, PowerShell, les services — et filtre ce que renvoient leurs appels d’énumération. Il ne modifie rien dans le noyau. Tout ce qu’il cache est toujours là ; seules les réponses fournies aux processus hookés changent.

Artefact cachéÉnumération filtrée par le rootkitQui n’est pas dupé
ProcessusNtQuerySystemInformation et les API d’instantané construites au-dessusLes structures de processus du noyau dans une image mémoire ; les événements de démarrage de processus déjà présents dans le SIEM
Fichiers et dossiersNtQueryDirectoryFile et ses appelantsLa MFT lue depuis une image disque hors ligne
Clés et valeurs de registreNtEnumerateKey, NtEnumerateValueKeyLes fichiers de ruche analysés hors ligne ; les ruches dans une image mémoire
Connexions réseauGetExtendedTcpTable et les requêtes derrière netstatLes objets réseau en mémoire ; les pare-feu, proxys et sondes réseau

Trois conséquences en découlent. Les hooks sont propres à chaque processus : le rootkit doit être chargé dans chaque processus qu’il veut duper, souvent par un mécanisme d’injection ou de chargement automatique comme les AppInit DLLs ou une DLL SetWindowsHookEx. Un processus qu’il a manqué dit la vérité, si bien qu’un désaccord entre processus constitue une preuve. Et tout ce qui se trouve hors de l’hôte, ou sous le mode utilisateur, n’a jamais été dupé.

Toute dissimulation n’exige pas de hooks : la dissimulation de clés de registre par octet nul exploite une différence de nommage entre les API Win32 et les API natives, et les mêmes vérifications par vue croisée la repèrent. Les hooks sur les fonctions d’envoi d’un navigateur servent plutôt au vol (voir Vol d’identifiants et keylogging). Les rootkits en mode noyau sortent du cadre de cette leçon ; la future leçon Mode utilisateur, mode noyau et API native (module 5) explique cette frontière.

Où vivent les hooks

Chaque type de hook modifie une structure différente, et chacun dispose d’une référence saine connue à laquelle le comparer.

Type de hookCe qui change en mémoireRéférence saine connue
InlineLes premiers octets d’une fonction sont remplacés par un saut ; les instructions déplacées sont copiées dans un trampoline ailleursLes mêmes octets dans le fichier sur disque, après mappage et rebasage
IATUne entrée de l’IAT du module importateur contient une adresse autre que celle de l’export qu’elle nommeL’adresse d’export de ce nom dans la DLL cible chargée
EATUne entrée de la table des adresses d’export d’une DLL pointe ailleurs, si bien que les appels ultérieurs à GetProcAddress obtiennent la mauvaise adresseLa table des exports du fichier sur disque
Hooks de messages WindowsRien n’est patché ; une DLL est chargée dans les processus graphiques par SetWindowsHookExLa liste des modules chargés dans chaque processus

Les hooks inline dominent aussi bien dans les produits de sécurité que dans les malwares, car ils interceptent chaque appelant, quelle que soit la façon dont il a obtenu l’adresse ; les hooks d’IAT ratent le code qui résout les API à la main. La destruction de l’IAT est encore autre chose : un endommagement de la table, pas une redirection.

Comparer la mémoire avec le disque

Les scanners trouvent les hooks avec la méthode que vous avez utilisée dans le lab de dump mémoire, appliquée à l’envers : prendre le fichier du module sur disque, le disposer comme le ferait le loader, appliquer les mêmes relocations pour la base qu’a réellement le module, puis comparer. Ce qui devrait être identique et ne l’est pas relève d’une modification.

  • Les sections de code sont comparées octet par octet. Après rebasage, les sections exécutables d’un module sain correspondent exactement au fichier.
  • L’IAT ne peut pas être comparée au fichier, car c’est le loader qui la remplit. On vérifie plutôt chaque entrée par rapport à l’export que son nom désigne dans la DLL cible. Une entrée qui pointe ailleurs est un hook ; c’est ce que fait le mode /iat de pe-sieve.
  • La table des exports est comparée au fichier, et chaque RVA exportée doit tomber dans le code du module lui-même (ou être une chaîne de redirection, forwarder).

Excluez d’abord les différences attendues : pointeurs relocalisés, entrées de l’IAT, données modifiables et champ ImageBase en mémoire. Puis méfiez-vous de trois pièges. Un fichier mis à jour sur disque après le chargement du module fait différer toutes les fonctions. Un fichier altéré concorde avec une copie patchée : vérifiez donc sa signature ou comparez-le avec la même version provenant d’un hôte sain. Enfin, le code peut être totalement absent du disque — chargement réflectif ou module stomping — ce qui relève de la détection d’injection plutôt que de la détection de hooks ; la future leçon Comprendre l’injection de processus traite ce sujet.

Lire un prologue modifié

Les fonctions compilées commencent de façon prévisible : ajustement de la pile, sauvegarde de registres, ou test et branchement précoces. Sur x64, les stubs d’appel système de ntdll commencent par mov r10, rcx suivi de mov eax, <number>. Quand un désassembleur affiche autre chose au premier octet d’une fonction, cherchez ces formes (les encodages sont détaillés dans l’encodage des instructions x86) :

Premiers octetsInstructionPortée
E9 xx xx xx xxjmp rel32±2 Go autour du site du patch, si bien que la cible est souvent un trampoline proche
FF 25 00 00 00 00 + adresse de 8 octetsjmp qword ptr [rip]N’importe où ; l’adresse de destination suit l’instruction
48 B8 imm64, FF E0mov rax, imm64 ; jmp raxN’importe où ; 12 octets écrasés
CCint3Un point d’arrêt de débogueur, ou du code qui s’attend à intercepter l’exception

Deux indices confirment la modification même sans le fichier. Les octets qui suivent le saut se décodent souvent en non-sens, parce que le saut a coupé une instruction en deux : dans le lab ci-dessous, mov r9, rcx devient mov ecx, ecx. Et la destination du saut se trouve généralement hors de la fonction, souvent hors du module.

Astuce : Sur Windows 32 bits, de nombreuses fonctions système commencent par mov edi, edi, précédé de cinq octets de remplissage — un point de hot-patch conçu pour être remplacé par un saut court. Un saut à cet endroit est normal pour les outils de correctif et les produits de sécurité, et tout aussi pratique pour un malware. Classez-le d’après sa destination, comme n’importe quel autre.

Légitime ou malveillant

Suivez le saut, puis répondez à ces questions dans l’ordre :

QuestionOriente vers un produit de sécurité ou un outil béninOriente vers un malware
Qu’est-ce qui adosse la destination ?Un module sur disque, dans Program Files ou System32De la mémoire privée ou non adossée à un fichier ; un module dans %TEMP% ou dans un profil utilisateur ; un module dont le fichier a disparu
Qui l’a signé ?Une signature valide d’un éditeur que vous avez déployéNon signé, autosigné, ou une signature qui ne correspond pas au fichier
Quelles fonctions sont hookées ?Les API de mémoire, de processus et de threads (allocation, changements de protection, threads distants)Les API d’énumération (processus, répertoires, registre, connexions) ; les fonctions d’envoi et de chiffrement des navigateurs
Une référence saine concorde-t-elle ?Les mêmes hooks, vers le même module, sur chaque hôte doté de ce produitDes hooks sur un seul hôte, ou dans certains processus seulement

La référence tranche la plupart des cas : scannez un hôte réputé sain doté des mêmes logiciels et conservez le résultat. Le mode /iat 1 de pe-sieve applique un filtre similaire en écartant les hooks d’IAT qui mènent vers des modules système non modifiés, et sa FAQ cite comme inoffensifs les hooks de Firefox menant vers son propre mozglue.dll.

Détection par vue croisée

On ne peut pas forcer un processus hooké à dire la vérité, mais on peut poser la même question à une vue que le rootkit ne contrôle pas. La détection par vue croisée (cross-view detection) compare une réponse de haut niveau à une réponse de plus bas niveau ; tout ce qui est présent en bas et absent en haut est caché.

ArtefactVue de haut niveau (filtrable)Vue de plus bas niveauOutils
ProcessusGestionnaire des tâches, tasklist, instantané de la console EDR pris depuis un processus hookéImage mémoire : liste chaînée des processus, scan des pools, tables de threads et de handlesVolatility windows.pslist, windows.psscan, windows.malware.psxview
Modules chargésListe des modules issue du PEB (ToolHelp, la plupart des outils)Mappages mémoire (VAD) dans le même processuswindows.malware.ldrmodules
FichiersExplorateur, dir, scripts de collecte en directImage disque hors ligne : la MFT et les index de répertoiresMontage en lecture seule sur un hôte d’analyse ; analyseurs de MFT
Registreregedit, reg query, Autoruns sur l’hôte en fonctionnementFichiers de ruche issus de l’image disque ; ruches en mémoireAnalyseurs de ruches hors ligne ; Volatility windows.registry.*
Connexionsnetstat, Moniteur de ressourcesObjets réseau en mémoire ; trafic observé hors de l’hôtewindows.netscan, journaux de pare-feu et de proxy, sondes réseau

Prenez la vue de bas niveau depuis un autre point d’observation — une image mémoire ou disque analysée sur une autre machine — car un second outil exécuté sur l’hôte infecté peut lui aussi être hooké. Attendez-vous aussi à des désaccords bénins : des processus se terminent entre deux collectes, et le scan des pools retrouve des processus terminés dont les structures subsistent. Une différence est une piste, pas un verdict.

Outils

OutilPortéeCe qu’il signale en matière de hooks et de modifications
pe-sieveUn processusHooks inline et patches par défaut, dans un fichier .tag par module (RVA;function->destination[details];size) ; hooks d’IAT avec /iat ; dumps du module patché
hollows_hunterDe nombreux processusLe moteur de pe-sieve sur tout le système ; le scan des hooks doit être activé avec /hooks
MonetaRégions mémoireRégions d’image dont les pages de code ont été modifiées, ainsi que la mémoire privée exécutable et les threads non adossés à un fichier (-m ioc -p <pid>)
Volatility 3Une image mémoirewindows.malware.unhooked_system_calls compare les stubs de ntdll d’un processus à l’autre ; windows.etwpatch vérifie la présence de patches ret et jmp dans les fonctions ETW ; les plugins de vue croisée du tableau ci-dessus
Analyse de disque hors ligneUne image disqueFichiers, ruches et entrées de persistance, sans interroger le système infecté

Volatility 3 a déplacé ses plugins de détection de malwares sous windows.malware.* ; les noms courts utilisés dans la leçon sur le dump mémoire sont des alias dépréciés dans la version 2.28. PE-bear charge un fichier .tag de pe-sieve placé à côté du module dumpé et marque chaque offset patché.

Collecter avant de remédier

Les hooks n’existent qu’en mémoire. Redémarrer, tuer le processus ou laisser un antivirus nettoyer l’hôte détruit la seule preuve, et les réponses obtenues en direct sur cet hôte peuvent être filtrées. Collectez par ordre de volatilité :

  1. Une image mémoire complète de l’hôte, avant que quoi que ce soit d’autre ne s’y exécute.
  2. La sortie des scanners pour les processus suspects (dumps et fichiers .tag de pe-sieve ou de hollows_hunter) — ils lisent la mémoire et modifient peu de choses.
  3. Une image disque ou une collecte ciblée hors ligne du système de fichiers et des ruches.
  4. Les empreintes de chaque artefact, avec l’outil, la version et l’heure.

Notez lesquelles de vos commandes en direct ont été exécutées sur l’hôte infecté. Si netstat n’a rien montré et que la mémoire révèle une connexion, cet écart constitue en soi une constatation.

Que consigner dans le rapport

Une constatation de hook doit permettre à un autre analyste de la reproduire à partir de vos artefacts :

ChampExemple
EmplacementHôte, nom du processus, PID, chemin de l’image
CibleModule, fonction, RVA, type de hook (inline, IAT, EAT)
PreuveOctets sur disque et en mémoire ; désassemblage du prologue patché
DestinationAdresse, type de région (image, privée, mappée), chemin du fichier sous-jacent, état de la signature
ClassificationProduit de sécurité, bénin ou malveillant — avec la justification (référence saine, signataire, ensemble de fonctions)
EffetCe que la vue croisée a montré comme caché : quel processus, fichier, clé ou connexion
Artefacts et indicateursImage mémoire, dumps des scanners, fichiers .tag, image disque avec SHA-256 ; chemin et empreinte du module de hooking et de ce qui le charge

Mettez en avant le mécanisme de chargement : la remédiation doit le supprimer, sinon le rootkit revient à l’ouverture de session suivante (voir Mécanismes de persistance).

Lab : trouver un prologue patché en comparant la mémoire avec le disque

Ce lab reproduit ce que fait pe-sieve, sur une DLL inoffensive et une image mémoire simulée ; vous n’avez besoin d’un hôte Windows qu’à la dernière étape. Il utilise un environnement virtuel avec pefile et capstone, ainsi que mingw-w64. Les sorties proviennent de GCC 15.2.0 (mingw-w64), Python 3.14, pefile 2024.8.26 et capstone 5.0.9 sous macOS.

  1. Compilez une DLL qui exporte deux petites fonctions :

    c
    /* hooklab.c: harmless DLL with two exported functions, used as a hook-scanning target */
    #include <windows.h>
    
    /* Adler-32-style checksum over a buffer */
    __declspec(dllexport) unsigned lab_checksum(const unsigned char *p, unsigned n) {
        unsigned a = 1, b = 0;
        for (unsigned i = 0; i < n; i++) {
            a = (a + p[i]) % 65521;
            b = (b + a) % 65521;
        }
        return (b << 16) | a;
    }
    
    /* count the ASCII letters in a NUL-terminated string */
    __declspec(dllexport) int lab_count_letters(const char *s) {
        int n = 0;
        for (; *s; s++)
            if ((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z'))
                n++;
        return n;
    }
    
    BOOL WINAPI DllMain(HINSTANCE h, DWORD reason, LPVOID r) {
        (void)h; (void)reason; (void)r;
        return TRUE;
    }
    bash
    python3 -m venv venv && ./venv/bin/pip install pefile capstone
    x86_64-w64-mingw32-gcc -O1 -shared -s -o hooklab.dll hooklab.c
    shasum -a 256 hooklab.dll
    text
    caad00014b67513d3f5d3418c01f3a9af1d895877999690b64189ad65bcb71d8  hooklab.dll

    Votre empreinte sera différente : l’éditeur de liens écrit un horodatage dans l’en-tête PE et dans le répertoire des exports, si bien que même une recompilation sur la même machine la modifie. Le code, lui, ne change pas, et les exports sont lab_checksum à la RVA 0x1360 et lab_count_letters à 0x13cd.

  2. Enregistrez la simulation du loader sous mapimage.py. C’est la fonction map_image du lab de dump mémoire, sans le décodage de la configuration : en-têtes et sections copiés à leurs RVA, relocations DIR64 appliquées pour une nouvelle base, et cette base écrite dans l’en-tête.

    python
    # mapimage.py: lay a PE out as the loader would (shared by both scripts)
    import struct
    import pefile
    
    def align(x, a):
        return (x + a - 1) // a * a
    
    def map_image(data, new_base):
        """Copy headers and sections to their RVAs, then rebase to new_base."""
        pe = pefile.PE(data=data)
        oh = pe.OPTIONAL_HEADER
        mem = bytearray(oh.SizeOfImage)
        mem[:oh.SizeOfHeaders] = data[:oh.SizeOfHeaders]
        for s in pe.sections:
            n = min(s.SizeOfRawData, align(s.Misc_VirtualSize, oh.SectionAlignment))
            mem[s.VirtualAddress:s.VirtualAddress + n] = data[s.PointerToRawData:s.PointerToRawData + n]
        delta = new_base - oh.ImageBase
        for block in getattr(pe, "DIRECTORY_ENTRY_BASERELOC", []):
            for e in block.entries:
                if e.type == pefile.RELOCATION_TYPE["IMAGE_REL_BASED_DIR64"]:
                    v, = struct.unpack_from("<Q", mem, e.rva)
                    struct.pack_into("<Q", mem, e.rva, v + delta)
        struct.pack_into("<Q", mem, oh.get_field_absolute_offset("ImageBase"), new_base)
        return mem
  3. Produisez les preuves : une image mémoire saine, et une copie dans laquelle le début de lab_checksum a été écrasé par un jmp relatif vers un espace inutilisé à la fin de .text, où trois octets (xor eax, eax ; ret) tiennent lieu de gestionnaire. C’est ce qu’un scanner lirait dans un processus modifié, obtenu ici en éditant des octets dans un fichier, sans rien hooker.

    python
    # mkimage.py: produce a clean "memory image" and a tampered copy of hooklab.dll
    import struct
    import pefile
    from mapimage import map_image
    
    BASE = 0x7FFB2C410000
    disk = open("hooklab.dll", "rb").read()
    pe = pefile.PE(data=disk)
    exports = {e.name.decode(): e.address for e in pe.DIRECTORY_ENTRY_EXPORT.symbols}
    
    clean = map_image(disk, BASE)
    open("hooklab.mem.bin", "wb").write(clean)
    
    # Simulate what a scanner finds in a tampered process: a 5-byte relative jmp
    # at the start of lab_checksum, to a stub in unused space at the end of .text.
    tampered = bytearray(clean)
    src = exports["lab_checksum"]
    dst = 0x2800                                   # .text ends at 0x23b0; page slack
    tampered[dst:dst + 3] = bytes([0x31, 0xC0, 0xC3])       # xor eax, eax ; ret
    tampered[src:src + 5] = b"\xE9" + struct.pack("<i", dst - (src + 5))
    open("hooklab.tampered.bin", "wb").write(tampered)
    print(f"base {BASE:#x}, SizeOfImage {len(clean):#x}")
    print(f"lab_checksum RVA {src:#x} -> jmp to RVA {dst:#x}")
    text
    base 0x7ffb2c410000, SizeOfImage 0xc000
    lab_checksum RVA 0x1360 -> jmp to RVA 0x2800

    cmp -l hooklab.mem.bin hooklab.tampered.bin confirme huit octets modifiés : cinq à partir de l’octet 4961 et trois à partir de l’octet 10241. cmp compte à partir de 1 en décimal ; il s’agit donc des RVA 0x1360 et 0x2800.

  4. Écrivez le détecteur sous hookscan.py. Il lit la base dans l’en-tête en mémoire, mappe et rebase le fichier pour qu’il corresponde, compare chaque section exécutable sur toute sa taille mappée, regroupe les différences en plages, nomme chaque plage par export et offset, et désassemble les deux versions. Si le code modifié commence par un jmp ou un call, il en suit la cible. Enfin, il vérifie chaque export et la table des adresses d’export.

    python
    # hookscan.py: compare a module's in-memory image with its file on disk
    import struct, sys
    import pefile
    from capstone import Cs, CS_ARCH_X86, CS_MODE_64
    from mapimage import map_image, align
    
    EXEC = 0x20000000                              # IMAGE_SCN_MEM_EXECUTE
    
    disk = open(sys.argv[1], "rb").read()
    mem = open(sys.argv[2], "rb").read()
    pe = pefile.PE(data=disk)
    oh = pe.OPTIONAL_HEADER
    base, = struct.unpack_from("<Q", mem, oh.get_field_absolute_offset("ImageBase"))
    ref = map_image(disk, base)                    # the file, laid out and rebased like memory
    md = Cs(CS_ARCH_X86, CS_MODE_64)
    exports = sorted((e.address, e.name.decode()) for e in pe.DIRECTORY_ENTRY_EXPORT.symbols)
    
    def sname(s):
        return s.Name.rstrip(b"\0").decode()
    
    def section_of(rva):
        for s in pe.sections:
            if s.VirtualAddress <= rva < s.VirtualAddress + align(s.Misc_VirtualSize, oh.SectionAlignment):
                return s
        return None
    
    def describe(rva):
        """Name an RVA the way a report would: export+offset, or section and slack."""
        s = section_of(rva)
        if s is None:
            return "outside the image"
        if rva >= s.VirtualAddress + s.Misc_VirtualSize:
            return f"{sname(s)}+{rva - s.VirtualAddress:#x} (past VirtualSize {s.Misc_VirtualSize:#x}: slack)"
        owner = [(a, n) for a, n in exports if a <= rva and section_of(a) is s]
        if owner:
            a, n = owner[-1]
            return f"{n}+{rva - a:#x}"
        return f"{sname(s)}+{rva - s.VirtualAddress:#x}"
    
    def disasm(buf, rva, count=3):
        return [(i.address, i.bytes.hex(" "), f"{i.mnemonic} {i.op_str}".strip())
                for i in md.disasm(bytes(buf[rva:rva + 16]), base + rva, count)]
    
    print(f"module base {base:#x}, reference mapped from {sys.argv[1]}")
    diff_rvas = []
    for s in pe.sections:
        if not s.Characteristics & EXEC:
            continue
        lo, hi = s.VirtualAddress, s.VirtualAddress + align(s.Misc_VirtualSize, oh.SectionAlignment)
        d = [r for r in range(lo, hi) if mem[r] != ref[r]]
        print(f"section {sname(s)} [{lo:#x}-{hi:#x}): {len(d)} bytes differ")
        diff_rvas += d
    
    runs = []                                      # group adjacent differences
    for r in diff_rvas:
        if runs and r - runs[-1][1] <= 8:
            runs[-1][1] = r
        else:
            runs.append([r, r])
    
    for lo, hi in runs:
        print(f"\n[modified] RVA {lo:#x}, {hi - lo + 1} bytes, at {describe(lo)}")
        print(f"  on disk  : {ref[lo:hi + 1].hex(' ')}")
        print(f"  in memory: {mem[lo:hi + 1].hex(' ')}")
        print("  disk code:")
        for a, b, t in disasm(ref, lo):
            print(f"    {a:#x}  {b:<22} {t}")
        print("  mem code :")
        for a, b, t in disasm(mem, lo):
            print(f"    {a:#x}  {b:<22} {t}")
        first = next(md.disasm(bytes(mem[lo:lo + 16]), base + lo, 1), None)
        if first and first.mnemonic in ("jmp", "call") and first.op_str.startswith("0x"):
            target = int(first.op_str, 16)
            trva = target - base
            where = describe(trva) if 0 <= trva < len(mem) else "outside the image"
            print(f"  => {first.mnemonic} to {target:#x} (RVA {trva:#x}): {where}")
    
    print("\nexports:")
    edir = pe.DIRECTORY_ENTRY_EXPORT.struct
    for a, n in exports:
        touched = any(a <= lo < a + 16 for lo, _ in runs)
        print(f"  {n:<18} RVA {a:#x}  {'MODIFIED' if touched else 'clean'}")
    eat_disk = ref[edir.AddressOfFunctions:edir.AddressOfFunctions + 4 * edir.NumberOfFunctions]
    eat_mem = mem[edir.AddressOfFunctions:edir.AddressOfFunctions + 4 * edir.NumberOfFunctions]
    print(f"export address table: {'identical' if eat_disk == eat_mem else 'DIFFERS'} "
          f"({edir.NumberOfFunctions} entries)")
  5. Scannez d’abord l’image saine. Un détecteur qui signale un module non modifié est inutile ; ceci est donc le témoin :

    bash
    ./venv/bin/python hookscan.py hooklab.dll hooklab.mem.bin
    text
    module base 0x7ffb2c410000, reference mapped from hooklab.dll
    section .text [0x1000-0x3000): 0 bytes differ
    
    exports:
      lab_checksum       RVA 0x1360  clean
      lab_count_letters  RVA 0x13cd  clean
    export address table: identical (2 entries)
  6. Scannez l’image modifiée :

    bash
    ./venv/bin/python hookscan.py hooklab.dll hooklab.tampered.bin
    text
    module base 0x7ffb2c410000, reference mapped from hooklab.dll
    section .text [0x1000-0x3000): 8 bytes differ
    
    [modified] RVA 0x1360, 5 bytes, at lab_checksum+0x0
      on disk  : 85 d2 74 62 49
      in memory: e9 9b 14 00 00
      disk code:
        0x7ffb2c411360  85 d2                  test edx, edx
        0x7ffb2c411362  74 62                  je 0x7ffb2c4113c6
        0x7ffb2c411364  49 89 c9               mov r9, rcx
      mem code :
        0x7ffb2c411360  e9 9b 14 00 00         jmp 0x7ffb2c412800
        0x7ffb2c411365  89 c9                  mov ecx, ecx
        0x7ffb2c411367  89 d2                  mov edx, edx
      => jmp to 0x7ffb2c412800 (RVA 0x2800): .text+0x1800 (past VirtualSize 0x13b0: slack)
    
    [modified] RVA 0x2800, 3 bytes, at .text+0x1800 (past VirtualSize 0x13b0: slack)
      on disk  : 00 00 00
      in memory: 31 c0 c3
      disk code:
        0x7ffb2c412800  00 00                  add byte ptr [rax], al
        0x7ffb2c412802  00 00                  add byte ptr [rax], al
        0x7ffb2c412804  00 00                  add byte ptr [rax], al
      mem code :
        0x7ffb2c412800  31 c0                  xor eax, eax
        0x7ffb2c412802  c3                     ret
        0x7ffb2c412803  00 00                  add byte ptr [rax], al
    
    exports:
      lab_checksum       RVA 0x1360  MODIFIED
      lab_count_letters  RVA 0x13cd  clean
    export address table: identical (2 entries)

    Lisez-la comme un rapport. Le patch se trouve à l’offset 0x0 de lab_checksum : un hook de prologue. Les cinq octets coupent mov r9, rcx en deux, si bien que ses deux derniers octets se décodent désormais en mov ecx, ecx : l’indice de l’instruction tronquée. Le déplacement 0x149b est relatif à l’instruction suivante (0x1365 + 0x149b = 0x2800). La destination se trouve dans l’image mais au-delà de la VirtualSize de .text, dans l’espace libre de fin de page qu’aucune fonction compilée n’occupe, et son code fait renvoyer 0 à la fonction quelle que soit l’entrée : un filtre qui répond « rien ». lab_count_letters et la table des exports sont sains. Un vrai gestionnaire se trouve généralement hors du module, là où s’applique le tableau de classification.

  7. Constatez pourquoi le rebasage compte. Mappez plutôt le fichier à sa base préférée et comparez chaque section, pas seulement le code, avec l’image saine :

    python
    # norebase.py: what a naive whole-image comparison sees without rebasing
    import pefile
    from mapimage import map_image
    disk = open("hooklab.dll", "rb").read()
    mem = open("hooklab.mem.bin", "rb").read()
    pe = pefile.PE(data=disk)
    naive = map_image(disk, pe.OPTIONAL_HEADER.ImageBase)       # preferred base, no rebase
    for s in pe.sections:
        lo, hi = s.VirtualAddress, s.VirtualAddress + s.Misc_VirtualSize
        d = sum(1 for r in range(lo, hi) if naive[r] != mem[r])
        if d:
            print(f"{s.Name.rstrip(b'\0').decode():7} {d:3} bytes differ")
    print("headers", sum(1 for r in range(0x400) if naive[r] != mem[r]), "bytes differ")
    text
    .data     8 bytes differ
    .rdata   88 bytes differ
    headers 4 bytes differ

    Les pointeurs relocalisés et le champ ImageBase diffèrent, et une comparaison naïve de l’image entière les signalerait tous. .text n’en contient aucun : le code x64 adresse les données de façon relative à RIP. Le code d’une DLL 32 bits est rempli d’adresses absolues ; y omettre le rebasage noierait donc aussi la comparaison du code.

  8. Dans votre VM Windows, rencontrez des hooks que vous n’avez pas écrits :

    • Ouvrez le Bloc-notes dans x64dbg, placez un point d’arrêt logiciel sur kernelbase.CreateFileW et, sans reprendre l’exécution, lancez pe-sieve64.exe /pid <pid> depuis une seconde invite. Cherchez un patch dans kernelbase.dll et lisez son fichier .tag : le int3 d’un débogueur est une modification au sens de cette définition. Placez ensuite un point d’arrêt matériel à la place et comparez.
    • Lancez hollows_hunter64.exe /hooks avec l’antivirus de la VM activé. Pour chaque module hooké, ouvrez le fichier .tag, suivez la destination et remplissez le tableau de classification ci-dessus.
    • Suspendez la VM, puis exécutez windows.pslist, windows.psscan et windows.malware.psxview sur son fichier mémoire. Expliquez chaque processus qui n’apparaît pas dans toutes les vues.

Questions : Pourquoi le détecteur compare-t-il .text sur toute sa taille mappée plutôt que sur sa VirtualSize, et qu’aurait-il manqué sinon ? Si le saut de l’image modifiée avait mené vers une DLL signée dans Program Files, que vérifieriez-vous avant de la déclarer bénigne ? Un rootkit en mode utilisateur cache un processus au Gestionnaire des tâches : quelles sont les deux sources de vue croisée ci-dessus qui le montreraient encore, et pourquoi ? Pourquoi une empreinte de hooklab.tampered.bin est-elle inutile comme indicateur, et que fourniriez-vous au SOC à la place ?

À retenir

  • Le hooking est à double usage : les EDR, les débogueurs, les traceurs et les applications posent des hooks ; un hook détecté est donc une observation qui demande une classification.
  • Un rootkit en mode utilisateur filtre les résultats d’énumération dans les processus où il est chargé ; ce qu’il cache existe toujours dans le noyau, sur le disque et sur le réseau.
  • Détectez les modifications en comparant la mémoire avec un mappage rebasé du fichier : le code octet par octet, les entrées de l’IAT par rapport aux exports qu’elles nomment, les tables d’exports par rapport au fichier. Un saut au premier octet d’une fonction suivi d’instructions tronquées est un hook inline.
  • Classez un hook selon l’endroit où il aboutit, l’auteur de la signature de ce code, les fonctions qu’il couvre et la présence ou non du même hook sur une référence saine.
  • Prouvez la dissimulation par une détection par vue croisée menée depuis un autre point d’observation : images mémoire, analyse de disque hors ligne et sources réseau.
  • Collectez la mémoire, les dumps des scanners et le disque avant la remédiation, et consignez le hook, sa destination, sa classification, son effet et son mécanisme de chargement.