Skip to content

Leçon 11.1 · Analyse automatisée & avancée· 50 min

Instrumentation binaire dynamique

Comment Pin, DynamoRIO, Frida et TinyInst injectent du code d’analyse dans un échantillon pour tracer ses API, mesurer la couverture et voir l’unpacking.

Objectifs

  • Expliquer ce que l’instrumentation observe là où un débogueur devient fastidieux, et distinguer instrumentation statique et dynamique
  • Décrire comment un moteur DBI retraduit le code dans un cache de code et d’où vient son surcoût
  • Choisir entre Intel Pin, DynamoRIO, Frida et TinyInst selon la question d’analyse
  • Hooker des appels de bibliothèque et enregistrer les blocs de base exécutés avec Interceptor et Stalker de Frida
  • Reconnaître comment un malware détecte l’instrumentation et comment le suivi « écrit puis exécuté » révèle le code dépaqueté

Un débogueur répond aux questions un arrêt à la fois. Vous posez un point d’arrêt, le processus s’arrête, vous lisez les registres, vous continuez. C’est parfait pour « que contient rcx au retour de cette routine de déchiffrement ? » et pénible pour « lesquelles de ces 4 000 fonctions se sont exécutées ? », « journalise chaque appel à CreateFileW avec ses arguments pendant toute l’exécution » ou « indique-moi la première fois que l’exécution arrive dans de la mémoire que l’échantillon a écrite lui-même ». Y répondre avec des points d’arrêt suppose des milliers d’arrêts, un script pour les relier, et un échantillon qui remarque ces arrêts.

L’instrumentation binaire renverse la question. Au lieu d’arrêter le programme pour l’observer, vous insérez votre propre code d’analyse dans celui-ci, de sorte que le programme rende compte de lui-même pendant son exécution : un compteur à chaque bloc de base, un callback de journalisation devant chaque appel d’API, une vérification après chaque écriture mémoire. Cette leçon explique comment se fait cette insertion, ce qu’elle coûte, quels moteurs existent, et les trois usages les plus courants chez les analystes : journalisation des API, couverture de code et unpacking. Elle ouvre le module d’analyse automatisée ; les leçons suivantes remplacent l’exécution réelle par l’émulation, puis construisent le suivi de teinte et l’exécution symbolique par-dessus.

Ce que l’instrumentation vous apporte

Voyez l’instrumentation comme un débogueur programmable, toujours actif, qui ne se met jamais en pause. Sa portée est la même — il voit les registres, la mémoire et le flot de contrôle — mais il enregistre au lieu de s’arrêter.

QuestionAvec un débogueurAvec l’instrumentation
Quel code s’est réellement exécuté ?Un point d’arrêt sur chaque bloc, ou un pas-à-pas en mode trace qui se traîneEnregistrer chaque bloc une fois, lors de sa traduction ; exporter l’ensemble à la sortie
Qu’a-t-il passé à chaque API ?Points d’arrêt conditionnels plus un script, fonction par fonctionUn hook par export qui journalise arguments et valeurs de retour
Où a-t-il écrit, et a-t-il exécuté ce qu’il a écrit ?Points d’arrêt matériels sur la mémoire, quatre à la foisInstrumenter chaque écriture et chaque branchement indirect
Combien de fois cette boucle tourne-t-elle ?Compteurs de passages, si le débogueur les gèreUn incrément de compteur compilé dans la boucle

La contrepartie : vous devez décider à l’avance quoi enregistrer, et chaque morceau de code d’analyse inséré a un coût à l’exécution. Un débogueur convient mieux à l’exploration ouverte ; l’instrumentation convient mieux aux questions systématiques portant sur toute une exécution. En pratique, les analystes utilisent les deux : l’instrumentation pour trouver la zone intéressante, le débogueur (la leçon x64dbg du module d’analyse dynamique, à venir) pour l’examiner de près une fois sur place.

Instrumentation statique ou dynamique

Il y a deux endroits où insérer du code : dans le fichier avant son exécution, ou dans le processus pendant son exécution.

Instrumentation binaire statique

L’instrumentation binaire statique (SBI) réécrit l’exécutable sur disque. Insérer de nouvelles instructions décale toutes les adresses qui suivent, ce qui casse chaque saut, appel et pointeur faisant référence à l’ancienne disposition. Comme on ne peut pas trouver de façon fiable toutes ces références dans un binaire (le problème auquel Désassemblage linéaire vs récursif a consacré toute une leçon), les outils SBI évitent de déplacer le code sur place. Deux stratégies dominent :

  • Remplacement par point d’arrêt. Écraser le premier octet de chaque instruction instrumentée par int3. Quand elle s’exécute, le gestionnaire d’interruption lance le code d’analyse, émule ou restaure l’instruction d’origine, puis reprend. C’est simple, mais chaque interruption est un aller-retour dans le noyau, si bien qu’instrumenter chaque bloc est d’une lenteur pénible.
  • Trampolines. Copier chaque fonction dans une nouvelle section, instrumenter librement la copie, et écraser le début de l’original par un jmp vers la copie. Les appels directs vers l’ancien code rebondissent vers le nouveau ; les sauts indirects qui atterrissent au milieu d’une fonction demandent des correctifs supplémentaires. Dyninst est l’outil le plus connu de cette famille.

Les deux dépendent d’un désassemblage suffisamment bon pour trouver le code. Face à un échantillon packé, la SBI est presque inutile : le code qui vous intéresse n’existe pas dans le fichier tant qu’il ne s’exécute pas.

Instrumentation binaire dynamique

L’instrumentation binaire dynamique (DBI) effectue la réécriture à l’exécution, sur le code sur le point de s’exécuter. Comme elle ne traite que du code réellement atteint, elle n’a jamais besoin d’un désassemblage complet, elle voit le code dépaqueté et le code auto-modifiant sous leur forme finale, et elle suit le code que l’échantillon génère à l’exécution. C’est pourquoi tous les outils de cette leçon sont dynamiques, et pourquoi c’est la DBI, et non la SBI, qui compte pour les analystes de malwares.

Fonctionnement d’un moteur DBI

L’idée centrale de Pin, DynamoRIO et Stalker (Frida) est la même : le code d’origine ne s’exécute jamais. Le moteur en exécute des copies instrumentées à la place.

  1. Le moteur prend la main avant la première instruction du programme (en lançant lui-même le processus, ou en s’y injectant).
  2. Un dispatcher prend l’adresse sur le point de s’exécuter et se demande : existe-t-il dans le cache de code une copie traduite du bloc qui commence ici ?
  3. Sinon, un compilateur JIT décode les instructions d’origine jusqu’au prochain transfert de contrôle, y entrelace les callbacks d’analyse demandés par l’utilisateur, réécrit le branchement final pour qu’il revienne au dispatcher, et stocke le résultat dans le cache.
  4. Le bloc traduit s’exécute nativement sur le processeur. À sa fin, le contrôle revient au dispatcher avec l’adresse d’origine suivante, et le cycle recommence.

Deux optimisations maintiennent la vitesse. Une fois les deux extrémités d’un branchement direct traduites, le moteur modifie la copie en cache pour qu’elle saute directement vers la copie en cache de la cible, si bien que les boucles chaudes ne touchent plus du tout le dispatcher. Les branchements indirects (ret, jmp rax, appels via une table) ne peuvent pas être chaînés ainsi ; leur cible n’est connue qu’à l’exécution, donc chacun passe par une recherche rapide de l’adresse d’origine vers l’adresse en cache.

Le moteur travaille aussi beaucoup sa transparence. Le programme doit croire qu’il exécute son propre code à ses propres adresses : un call doit empiler l’adresse de retour d’origine, la lecture des octets de code doit renvoyer les octets d’origine, et les exceptions doivent sembler provenir des adresses d’origine. Chaque faille dans cette illusion est une occasion de détection, comme vous le verrez plus bas.

Ce que cela coûte

CoûtD’où il vient
DémarrageTraduction du code lors de sa première exécution ; pire pour les gros programmes à courte durée de vie
Régime établiRecherches du dispatcher sur les branchements indirects, plus le code d’analyse que vous avez inséré
Code d’analyseDomine généralement : un callback sur chaque instruction ou chaque accès mémoire multiplie de nombreuses fois le temps d’exécution
MémoireLe cache de code et l’état propre du moteur résident dans l’espace d’adressage de la cible

Un moteur sans analyse attachée n’est que modérément plus lent que l’exécution native. Un outil qui déclenche un appel à chaque accès mémoire peut être d’un ou deux ordres de grandeur plus lent. La règle pratique : instrumentez à la granularité la plus grossière qui répond à votre question (la routine plutôt que le bloc, le bloc plutôt que l’instruction), et ne faites le travail lourd dans le callback que lorsqu’un filtre peu coûteux indique que c’est pertinent.

Les moteurs utilisés par les analystes

MoteurPlateformesVous écrivezPoints fortsUsage typique en analyse
Intel PinWindows, Linux (x86, x64)Des « pintools » en C++Mature, hooks fins au niveau instruction, bloc, trace, routine et imageTraçage d’API avec tiny_tracer, unpackers sur mesure, outils de recherche
DynamoRIOWindows, Linux, macOS (partiel), x86/x64/ARMDes clients en COpen source, rapide, riche bibliothèque cliente ; fourni avec drcov et drltraceCouverture (drcov) chargée dans IDA/Binary Ninja via Lighthouse ou dans Ghidra via Cartographer ; journaux d’appels de bibliothèque
FridaWindows, macOS, Linux, iOS, AndroidDu JavaScript (ou du C) dans un agent injectéLe plus rapide pour itérer ; Interceptor pour hooker des fonctions, Stalker pour le traçage DBI ; s’attache à des processus en coursJournalisation rapide des API, modification d’arguments, échantillons mobiles et macOS
TinyInstWindows, macOS, LinuxDu C++N’instrumente que les modules que vous nommez, le reste s’exécute à vitesse nativeCouverture d’un module dans un gros processus ; fuzzing avec Jackalope

Quelques remarques pour choisir :

  • Frida réunit deux outils en un. Interceptor n’est pas du tout de la DBI : il modifie les premières instructions d’une fonction en y plaçant un saut vers un trampoline, un hook d’API inline posé avec une précision chirurgicale. Stalker est le véritable moteur DBI, qui recompile dans un cache les blocs d’un thread suivi. La plupart des tâches rapides n’ont besoin que d’Interceptor ; passez à Stalker quand la question porte sur le flot de contrôle plutôt que sur les appels.
  • Pin et DynamoRIO lancent le programme sous leur contrôle dès la première instruction, ce qu’il vous faut pour un packer ou un loader Windows qui fait son travail intéressant avant qu’un attachement ait pu avoir lieu. tiny_tracer, un outil Pin écrit pour l’analyse de malwares, journalise les appels d’API et les transitions entre modules dans un fichier texte, et propose des options pour neutraliser les vérifications anti-analyse courantes.
  • TinyInst repose sur un principe différent des autres : il exécute le processus sous une interface de débogage, marque le module cible comme non exécutable, et redirige l’exécution vers une copie réécrite quand les exceptions qui en résultent se déclenchent. Tout ce qui est hors du module nommé s’exécute sans modification, ce qui limite le surcoût quand seule une DLL vous intéresse.

Quel que soit votre choix, exécutez-le dans votre VM d’analyse isolée. L’instrumentation observe une exécution réelle ; l’échantillon fait tout ce qu’il ferait autrement.

Usages en analyse

Journalisation des appels d’API

L’usage le plus courant. Hookez les exports que l’échantillon importe (ou, pour un échantillon qui résout ses API par hachage, tous les exports des DLL concernées) et journalisez les arguments à l’entrée et les résultats à la sortie. Par rapport à un moniteur à l’échelle du système, vous obtenez les arguments et valeurs de retour exacts tels que les voit le propre thread de l’échantillon, y compris les chaînes déchiffrées passées à InternetConnectW ou les chemins de registre passés à RegSetValueExW. La leçon sur le traçage d’API du module d’analyse dynamique comparera cette approche au traçage au niveau du noyau.

Couverture de code : quel code s’est réellement exécuté

Enregistrez chaque bloc exécuté dans le module propre de l’échantillon, puis reportez cet ensemble sur le désassemblage. drcov de DynamoRIO écrit un journal des décalages de blocs relatifs au module, que le plugin Lighthouse met en surbrillance dans IDA ou Binary Ninja. La couverture répond à des questions autrement lentes à traiter :

  • Quelles fonctions valent la peine d’être lues, et lesquelles sont mortes ou dormantes lors de cette exécution ?
  • Quelle branche d’une vérification a été prise ? Comparez la couverture de deux exécutions (avec et sans débogueur, artefact de VM, argument de ligne de commande) et les blocs qui diffèrent pointent directement vers la décision.
  • Quelles commandes d’un gestionnaire C2 ont été exercées par le trafic que vous avez rejoué ?

La couverture vaut pour une exécution. Un bloc absent de l’ensemble n’a pas été exécuté cette fois-ci ; ce n’est pas la preuve que le code est inatteignable.

Écrit puis exécuté : attraper le code dépaqueté

Un packer écrit la vraie charge utile en mémoire puis y saute. Un outil d’instrumentation peut surveiller les deux moitiés de cette phrase :

  1. Instrumenter chaque écriture mémoire et enregistrer les adresses de destination (en pratique, les pages) où l’échantillon écrit.
  2. Instrumenter chaque transfert de contrôle, ou simplement noter chaque bloc que le moteur traduit, et vérifier si son adresse se trouve dans une zone écrite.
  3. La première fois que l’exécution entre dans de la mémoire écrite, vous tenez un candidat point d’entrée original. Dumpez la zone (et l’image du processus) à ce moment-là.

Comme un moteur DBI doit retraduire tout bloc dont le code a changé, il détecte déjà les écritures sur du code qu’il a mis en cache ; un unpacker étend cette comptabilité à toute la mémoire. L’approche gère des packers que vous n’avez jamais vus, et les packers multicouches la déclenchent simplement plusieurs fois. Les faux positifs sont les suspects habituels : compilateurs JIT, thunks générés à l’exécution et code auto-modifiant légitime. La leçon d’unpacking manuel du module évasion et unpacking montre la même idée réalisée à la main avec les permissions de pages et des points d’arrêt ; ici, elle est automatisée.

Comment un malware détecte l’instrumentation

La DBI est transparente par conception, pas par garantie. Les échantillons qui la recherchent examinent :

  • Le temps. La traduction et les callbacks ralentissent le code, donc les écarts de rdtsc ou de GetTickCount autour d’une boucle augmentent. Une instrumentation lourde échoue aux vérifications de timing plus souvent qu’un débogueur.
  • Les modules et threads étrangers. Pin, DynamoRIO et Frida vivent tous à l’intérieur de la cible. Leurs DLL ou objets partagés, leurs threads supplémentaires (l’agent de Frida exécute les siens) et les zones mémoire contenant le cache de code apparaissent quand un échantillon énumère les modules, les threads ou la mémoire exécutable.
  • Les prologues modifiés. Les hooks inline comme Interceptor de Frida réécrivent les premiers octets de la fonction hookée. Un échantillon qui compare le début de NtReadVirtualMemory avec la copie sur disque, ou qui cherche un jmp à l’entrée d’une API, les voit. La DBI pure ne modifie pas les octets d’origine : cette vérification attrape donc les hooks plutôt que les traceurs.
  • L’émulation imparfaite des cas limites. Le code auto-modifiant sur la même page, des flux d’exceptions inhabituels et la lecture de l’adresse de retour sur la pile avec du code non standard ont tous servi, à diverses époques, à démasquer des moteurs DBI.

Quand un échantillon se comporte différemment sous instrumentation et sur une VM nue, traitez-le comme n’importe quelle autre branche anti-débogage : comparez la couverture des deux exécutions pour trouver la vérification, puis patchez-la ou hookez-la.

Lab : hooker la libc et tracer les blocs de base avec Frida

Vous allez construire un programme C inoffensif qui lit une variable d’environnement et un petit fichier de configuration puis prend l’un de deux chemins, et utiliser Frida pour journaliser les appels de bibliothèque et enregistrer quels blocs de base se sont exécutés sur chaque chemin. Les sorties ci-dessous sont réelles, obtenues avec Frida 17.19.0 et frida-tools 14.10.4 dans un environnement virtuel Python 3.14 sous macOS 26 (Apple silicon, arm64) avec System Integrity Protection activée ; le programme a été compilé avec l’Apple clang 21 du système. Rien n’est installé à l’échelle du système.

  1. Créez un répertoire de travail et un environnement virtuel :

    bash
    mkdir m11a && cd m11a
    python3 -m venv venv
    ./venv/bin/pip install frida-tools
  2. Enregistrez le programme cible sous labprog.c. Il vérifie LAB_USER, lit lab.cfg, et appelle active_path ou quiet_path selon le contenu du fichier :

    c
    /* labprog.c - a harmless program with one environment check and one file check */
    #include <fcntl.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    
    __attribute__((noinline)) static int config_says_active(const char *path) {
        char buf[32] = {0};
        int fd = open(path, O_RDONLY);
        if (fd < 0)
            return 0;
        read(fd, buf, sizeof buf - 1);
        close(fd);
        return strncmp(buf, "mode=active", 11) == 0;
    }
    
    __attribute__((noinline)) static void quiet_path(void) {
        puts("nothing to do");
    }
    
    __attribute__((noinline)) static void active_path(const char *who) {
        printf("active mode for %s\n", who);
    }
    
    int main(void) {
        const char *who = getenv("LAB_USER");
        if (who == NULL)
            who = "nobody";
        if (config_says_active("lab.cfg"))
            active_path(who);
        else
            quiet_path();
        return 0;
    }
    bash
    clang -O1 -g -o labprog labprog.c
    echo "mode=quiet" > lab.cfg

    clang signe le résultat de façon ad hoc. Cela compte sous macOS : avec SIP activée, Frida a pu lancer (spawn) sans root ce binaire que nous avons compilé nous-mêmes, mais les binaires de la plateforme Apple et les applications compilées avec le hardened runtime sont protégés, et les instrumenter exige d’assouplir SIP sur une machine de test dédiée. Sur les VM de laboratoire Windows et Linux, cette restriction ne s’applique pas.

  3. Commencez par frida-trace, qui génère un gestionnaire de journalisation pour chaque fonction que vous nommez. Passez un chemin absolu : avec un chemin relatif, Frida 17 a cherché un identifiant d’application et a échoué.

    bash
    ./venv/bin/frida-trace -f "$PWD/labprog" -i getenv -i open
    text
    getenv: Auto-generated handler at ".../__handlers__/libsystem_c.dylib/getenv.js"
    open: Auto-generated handler at ".../__handlers__/libsystem_kernel.dylib/open.js"
    Started tracing 2 functions. Web UI available at http://localhost:58207/
    nothing to do
               /* TID 0x103 */
       215 ms  getenv(name="LAB_USER")
       215 ms  open(path="lab.cfg", oflag=0x0, ...)
       215 ms  getenv(name="STDBUF")
       215 ms  getenv(name="STDBUF0")
       ...
       215 ms  getenv(name="_STDBUF_E")
    Process terminated

    Deux leçons en une seule trace. D’abord, les deux appels que fait le programme sont là avec leurs arguments, y compris le fichier qu’il a ouvert. Ensuite, la plupart des appels à getenv ne proviennent pas du tout de labprog.c : ils viennent de la bibliothèque C qui configure la mise en mémoire tampon de stdout lors du premier puts. La trace d’un vrai échantillon contient le même bruit issu de son runtime, c’est pourquoi de bons hooks filtrent selon l’appelant ou l’argument.

  4. Enregistrez hook.js. Il hooke getenv et open avec Interceptor, en journalisant les valeurs de retour en plus des arguments, et quand on entre dans main, il demande à Stalker de suivre le thread, en enregistrant le début de chaque bloc qui appartient à labprog lui-même :

    js
    // hook.js - log two libc calls and record which basic blocks of main ran
    const app = Process.mainModule;
    
    function libc(name) {
      return Module.getGlobalExportByName(name);
    }
    
    Interceptor.attach(libc('getenv'), {
      onEnter(args) { this.name = args[0].readUtf8String(); },
      onLeave(ret) {
        if (this.name.startsWith('LAB_'))
          console.log(`getenv("${this.name}") -> ${ret.isNull() ? 'NULL' : '"' + ret.readUtf8String() + '"'}`);
      }
    });
    
    Interceptor.attach(libc('open'), {
      onEnter(args) { this.path = args[0].readUtf8String(); },
      onLeave(ret) { console.log(`open("${this.path}") -> fd ${ret.toInt32()}`); }
    });
    
    const blocks = new Set();
    const mainFn = app.getExportByName('main');
    
    Interceptor.attach(mainFn, {
      onEnter() {
        Stalker.follow(this.threadId, {
          transform(iterator) {
            let insn = iterator.next();
            const start = insn.address;
            if (start.compare(app.base) >= 0 && start.compare(app.base.add(app.size)) < 0)
              blocks.add(start.sub(app.base).toInt32());
            do { iterator.keep(); } while ((insn = iterator.next()) !== null);
          }
        });
      },
      onLeave() {
        Stalker.unfollow(this.threadId);
        // label each block with the function (or stub section) that contains it
        const stubs = app.enumerateSections().find(s => s.name === '__stubs');
        const stubOff = stubs.address.sub(app.base).toInt32();
        const funcs = app.enumerateSymbols()
          .filter(s => s.type === 'section' && s.name !== '')
          .map(s => ({ off: s.address.sub(app.base).toInt32(), name: s.name }))
          .filter(f => f.off > 0 && f.off < stubOff)
          .sort((a, b) => a.off - b.off);
        const byFunc = {};
        for (const off of [...blocks].sort((a, b) => a - b)) {
          let name = off >= stubOff ? '(__stubs: calls into libc)' : '?';
          if (off < stubOff) for (const f of funcs) if (f.off <= off) name = f.name;
          (byFunc[name] = byFunc[name] || []).push('0x' + off.toString(16));
        }
        console.log(`\n${blocks.size} unique basic blocks of ${app.name} executed:`);
        for (const [name, offs] of Object.entries(byFunc))
          console.log(`  ${name.padEnd(28)} ${offs.join(' ')}`);
      }
    });

    Le callback transform est l’étape JIT vue plus haut, mise à votre disposition : Stalker l’appelle une fois par bloc lorsqu’il compile ce bloc dans son cache, et iterator.keep() copie chaque instruction d’origine dans la traduction. Comme la traduction a lieu une fois par bloc, y enregistrer l’adresse de début donne l’ensemble des blocs uniques exécutés pour un coût quasi nul. Ajouter un callout par bloc (iterator.putCallout) donnerait le nombre d’exécutions, pour un prix plus élevé.

  5. Enregistrez un petit pilote, run_hook.py. La ligne de commande frida -q -f ... -l hook.js lançait le programme et affichait sa sortie, mais le processus se terminait avant l’arrivée des messages de l’agent et le journal revenait vide ; attendre l’événement detached de la session règle le problème :

    python
    # run_hook.py - spawn labprog suspended, load hook.js, resume, wait for exit
    import os, threading, frida
    
    done = threading.Event()
    device = frida.get_local_device()
    pid = device.spawn([os.path.abspath("labprog")], env={**os.environ})
    session = device.attach(pid)
    session.on("detached", lambda reason, crash: (print(f"[detached: {reason}]"), done.set()))
    script = session.create_script(open("hook.js").read())
    script.on("message", lambda msg, data: print(msg))
    script.load()
    device.resume(pid)
    done.wait(20)

    Lancer le processus suspendu, charger le script, puis reprendre l’exécution : c’est aussi le schéma à utiliser pour les malwares, car les hooks sont en place avant la première instruction de l’échantillon.

  6. Exécutez-le une fois sur chaque chemin :

    bash
    echo "mode=quiet" > lab.cfg
    ./venv/bin/python run_hook.py
    echo "mode=active" > lab.cfg
    LAB_USER=analyst ./venv/bin/python run_hook.py
    text
    nothing to do
    getenv("LAB_USER") -> NULL
    open("lab.cfg") -> fd 3
    
    18 unique basic blocks of labprog executed:
      main                         0x470 0x478 0x480 0x49c 0x4a0
      config_says_active           0x4b0 0x4e8 0x4ec 0x4fc 0x504 0x540 0x558
      quiet_path                   0x594
      (__stubs: calls into libc)   0x5ac 0x5b8 0x5c4 0x5dc 0x5e8
    [detached: process-terminated]
    text
    active mode for analyst
    getenv("LAB_USER") -> "analyst"
    open("lab.cfg") -> fd 3
    
    20 unique basic blocks of labprog executed:
      main                         0x470 0x478 0x480 0x484 0x498 0x4a0
      config_says_active           0x4b0 0x4e8 0x4ec 0x4fc 0x504 0x540 0x558
      active_path                  0x56c 0x588
      (__stubs: calls into libc)   0x5ac 0x5b8 0x5c4 0x5d0 0x5e8
    [detached: process-terminated]

    La sortie du programme lui-même arrive en premier parce que Frida transmet les messages de l’agent de façon asynchrone. Les lignes d’Interceptor vous donnent la valeur de l’environnement et le descripteur de fichier, qu’une lecture statique du binaire ne pourrait que deviner.

  7. Comparez les deux ensembles de blocs. config_says_active a exécuté les mêmes sept blocs les deux fois, donc la décision n’est pas à l’intérieur. Dans main, l’exécution « quiet » a visité 0x49c tandis que l’exécution « active » a visité 0x484 et 0x498 à la place ; un stub a aussi changé (0x5dc contre 0x5d0, les stubs de puts et de printf). Vérifiez avec le désassemblage :

    bash
    objdump -d --no-show-raw-insn labprog | awk '/^0000000100000460/,/^00000001000004b0/'
    text
    100000474:     	bl	0x1000005b8 <_read+0x1000005b8>
    100000478:     	mov	x19, x0
    10000047c:     	bl	0x1000004b0 <_config_says_active>
    100000480:     	cbz	w0, 0x10000049c <_main+0x3c>
    100000484:     	adrp	x8, 0x100000000 <_read+0x100000000>
    ...
    100000494:     	bl	0x10000056c <_active_path>
    100000498:     	b	0x1000004a0 <_main+0x40>
    10000049c:     	bl	0x100000594 <_quiet_path>
    1000004a0:     	mov	w0, #0x0                ; =0

    Le branchement est le cbz w0 en 0x480, qui teste la valeur de retour de config_says_active. (objdump étiquette bizarrement les appels de stubs en _read+... ; 0x5b8 est le stub de getenv, 0x5d0 celui de printf et 0x5dc celui de puts.) Deux détails méritent l’attention. Stalker termine un bloc à chaque bl (appel), ses blocs sont donc plus petits que ceux qu’un désassembleur dessine dans un CFG. Et le premier bloc de main, en 0x460, n’apparaît jamais : Interceptor a écrasé ces premières instructions par son propre saut, et leurs copies relocalisées se sont exécutées depuis le trampoline de Frida avant que Stalker ne prenne le relais. Vos outils d’observation modifient ce qu’ils observent.

Questions : Quelle instruction unique décide entre les deux chemins, et quelles deux entrées de couverture le prouvent ? Pourquoi la plupart des appels à getenv dans la sortie de frida-trace ne viennent-ils pas de labprog.c, et comment les filtreriez-vous dans hook.js ? Si un échantillon vérifiait les premiers octets de getenv à la recherche d’une instruction de branchement, lequel des deux mécanismes Frida utilisés ici détecterait-il, et lequel manquerait-il ? Comment étendriez-vous hook.js pour signaler le premier bloc exécuté depuis de la mémoire que le programme a écrite ?

À retenir

  • L’instrumentation insère du code d’analyse dans le programme pour qu’il rende compte de lui-même ; elle répond aux questions portant sur toute l’exécution (chaque appel d’API, chaque bloc, chaque écriture) qu’un débogueur à arrêts rend fastidieuses.
  • L’instrumentation statique réécrit le fichier avec des interruptions int3 ou des trampolines et exige un désassemblage complet ; l’instrumentation dynamique traduit le code au fil de l’exécution, et gère donc le code packé, généré et auto-modifiant.
  • Les moteurs DBI exécutent des copies traduites depuis un cache de code, chaînent les branchements directs, recherchent les indirects et préservent les adresses d’origine pour la transparence ; le code d’analyse que vous insérez domine généralement le surcoût.
  • Pin et DynamoRIO conviennent au traçage Windows et Linux dès le lancement (tiny_tracer, drcov), Frida au hooking scripté rapide multiplateforme, et TinyInst à la couverture à faible surcoût d’un seul module.
  • Les usages centraux pour l’analyste sont la journalisation des API, les comparaisons de couverture pour trouver quelle branche s’est exécutée, et le suivi « écrit puis exécuté » pour attraper le code dépaqueté.
  • Un malware détecte l’instrumentation par le timing, les modules et threads étrangers, les prologues d’API modifiés et le comportement dans les cas limites ; comparez la couverture entre exécutions instrumentée et nue pour trouver la vérification.