Skip to content

Leçon 6.4 · Analyse dynamique· 45 min

Tracer les appels d’API et les appels système

Enregistrez les appels d’API et système d’un échantillon au lieu de les suivre pas à pas, réduisez le bruit, lisez la trace et sachez où elle est aveugle.

Objectifs

  • Choisir entre API Monitor, la journalisation x64dbg, les piles Procmon, frida-trace, ETW et strace/ltrace selon la question posée
  • Restreindre une trace aux appels qui comptent et filtrer le bruit du runtime et du système
  • Reconstituer un comportement à partir d’une trace, en suivant les handles, les valeurs de retour et la chaîne d’appels de Win32 vers l’API native
  • Expliquer comment les appels système directs, le décrochage des hooks et l’attachement tardif mettent en échec le traçage, et quels points d’observation y survivent

Déboguer un malware avec x64dbg s’est terminé sur un point d’arrêt de journalisation qui ne s’arrête jamais et se contente d’enregistrer. Généralisez-le et vous obtenez le traçage : instrumentez un ensemble de fonctions, laissez l’échantillon s’exécuter presque à pleine vitesse, et lisez le journal. Une trace répond à « qu’a-t-il appelé, avec quoi, dans quel ordre, et qu’est-ce qui est revenu ? » pour des milliers d’appels à la fois.

Elle se situe entre les vues dont vous disposez déjà. La surveillance comportementale montre les effets sur le système mais pas la façon dont l’échantillon y est parvenu ; le débogage montre un instant dans son intégralité. Une trace montre les propres requêtes de l’échantillon sur toute l’exécution. Le flux de travail habituel : surveiller, tracer pour expliquer ce que la surveillance a montré, et ne déboguer que ce que la trace ne peut pas expliquer.

Où se place un traceur

Tout traceur intercepte les appels à une certaine couche, et cette couche détermine ce qu’il peut voir et avec quelle facilité on peut le tromper. Sous Windows, une écriture de fichier typique en traverse trois :

text
sample code ──► kernel32 / kernelbase    CreateFileW, WriteFile          (Win32 API)
            ──► ntdll                    NtCreateFile, NtWriteFile        (native API)
            ──► syscall instruction ──► kernel                         (system call)
OutilOù il intercepteVoitAveugle à
API MonitorHooks inline dans le processus cibleAppels Win32 et natifs avec arguments, structures et valeurs de retour décodésAppels système directs ; chemins de code décrochés ou résolus à la main
Points d’arrêt de journalisation x64dbgPoints d’arrêt du débogueur (int3)N’importe quelle adresse choisie, avec n’importe quelle expression formatableTout ce sur quoi vous n’avez pas posé de point d’arrêt ; les vérifications anti-débogage le voient
frida-traceHooks inline posés par un agent JavaScript injectéExports et adresses arbitraires, avec des gestionnaires scriptablesComme API Monitor ; l’agent est visible dans le processus
Process MonitorCallbacks noyau et minifiltre du système de fichiersÉvénements fichier, registre, processus, chargement d’image et réseau, avec une pile par événementTout ce qui n’appartient pas à ces types d’événements
ETW (SilkETW, logman, capteurs EDR)Fournisseurs dans le noyau et dans des composants en mode utilisateurÉvénements de processus, d’images, de réseau et de nombreux sous-systèmes, depuis l’extérieur du processusTout ce que les fournisseurs activés n’émettent pas
strace (Linux)Arrêts ptrace à chaque appel systèmeChaque appel système avec ses arguments décodésLa logique au niveau des bibliothèques qui ne fait aucun appel système
ltrace (Linux)Points d’arrêt sur les entrées PLT des binaires liés dynamiquementAppels de bibliothèque comme strcmp et getenvBinaires statiques ; appels directs qui contournent la PLT

La distinction qui compte le plus est dans le processus ou hors du processus. API Monitor, Frida et les points d’arrêt du débogueur vivent dans l’espace d’adressage de l’échantillon ou s’y attachent : arguments riches, mais l’échantillon peut les voir et les défaire. Procmon, ETW et strace observent depuis le noyau : plus grossiers, mais l’échantillon ne peut pas les supprimer depuis le mode utilisateur. L’entrée API hooking du glossaire couvre la mécanique du hooking ; la façon dont l’Interceptor de Frida réécrit le prologue d’une fonction est traitée en détail dans Instrumentation binaire dynamique.

Les outils en pratique

API Monitor

API Monitor est le moyen le plus rapide d’obtenir une trace Win32 lisible sous Windows. Ses définitions pour des milliers d’API décodent options, structures et codes d’erreur. Utilisez la version 64 bits pour les échantillons 64 bits : cochez les fonctions dans le panneau API Filter, lancez l’échantillon avec Monitor New Process, et lisez le panneau Summary, une ligne par appel avec module appelant, arguments, valeur de retour et erreur. Les panneaux Parameters et Call Stack détaillent l’appel sélectionné, et ses points d’arrêt peuvent marquer une pause avant ou après un appel pour vous permettre d’en modifier les arguments.

x64dbg : points d’arrêt de journalisation et trace

Des points d’arrêt avec une condition d’arrêt à 0 et un texte de journal sont déjà un traceur. Pour un travail au niveau de l’instruction, Trace into/over until condition évalue un texte de journal à chaque instruction jusqu’à ce qu’une condition d’arrêt soit remplie (par exemple, RIP qui quitte le module de l’échantillon) ; TraceSetLogFile l’écrit sur disque. C’est lent, alors visez une seule fonction, comme un décodeur.

Les piles de Process Monitor

La fonction de traçage sous-exploitée de Procmon, c’est la pile : l’onglet Stack d’un événement montre la chaîne d’appels au moment de l’opération, les trames noyau marquées K et les trames utilisateur U. Avec les symboles configurés (Options > Configure Symbols), les trames se résolvent en noms comme KernelBase.dll!CreateFileW, et la première trame située dans le module de l’échantillon vous donne la RVA de la fonction responsable.

frida-trace

frida-trace génère un petit gestionnaire JavaScript pour chaque fonction que vous nommez, injecte l’agent de Frida et affiche une ligne par appel. Il fonctionne de la même façon sous Windows, Linux et macOS :

text
frida-trace -f <program> -i <export glob> [-i ...] [-x <exclude glob>] [-a module!offset]
frida-trace -p <pid> | -n <name>      attach to a running process instead

-i accepte des motifs (-i "Reg*Value*"), -I/-X incluent ou excluent des modules entiers, et -a hooke une fonction non exportée par module et offset. Les gestionnaires générés sous __handlers__/ sont faits pour être modifiés : afficher une valeur de retour, dumper un tampon ou filtrer par appelant. C’est exactement ce que fait le lab ci-dessous.

Traçage basé sur ETW

Event Tracing for Windows est le système de journalisation propre au noyau, et c’est ce que consomment les capteurs EDR. Les fournisseurs noyau comme Microsoft-Windows-Kernel-Process, -Kernel-File et -Kernel-Network émettent des événements quoi que fasse l’échantillon à son propre ntdll. SilkETW (Mandiant) s’abonne à des fournisseurs noyau ou en mode utilisateur, filtre, et écrit du JSON que vous pouvez fouiller ou confronter à des règles YARA. Sous Linux, des outils eBPF comme Tetragon jouent le même rôle avec des sondes noyau définies par des politiques. Le compromis porte sur la granularité : ETW dit qu’un fichier a été créé et par qui, pas quel tampon déchiffré une fonction interne a reçu.

strace et ltrace sous Linux

Pour les échantillons ELF (ELF pour les analystes de malware), strace enregistre chaque appel système et ltrace chaque appel passant par la PLT vers les bibliothèques partagées. Ils répondent à des questions différentes : strace montre openat, connect et execve ; ltrace montre le strcmp contre un mot de passe codé en dur qui n’atteint jamais le noyau.

bash
strace -f -tt -s 256 -y -o strace.txt ./sample      # follow forks, timestamps, long strings, fd paths
strace -f -e trace=%file,%network,%process ./sample  # only the syscall classes you care about
ltrace -f -i ./sample                                # library calls with the caller's address

ltrace ne voit que les appels qui passent par la PLT, si bien que les binaires liés statiquement (courants en Go et pour les malwares (logiciels malveillants) IoT) ne lui offrent rien à hooker ; strace fonctionne toujours sur eux.

Que tracer, et comment limiter le bruit

Une trace complète de tout est illisible : le runtime C à lui seul fait des centaines d’appels avant main, et les DLL système s’appellent constamment les unes les autres. Délimitez-la :

  1. Partez d’une question. « Où obtient-il son C2 ? » implique les API réseau, de chaînes et de cryptographie. « Comment persiste-t-il ? » implique les API de registre, de fichiers, de services et de tâches. La liste des imports de votre triage (voir Lire les capacités dans les imports) est le menu.
  2. Tracez les appels faits par l’échantillon, pas par le système. Filtrez par appelant : ne gardez un appel que si son adresse de retour se trouve dans le module de l’échantillon (ou dans de la mémoire qu’il a allouée). La plupart des outils savent le faire : le filtre de modules d’API Monitor, une condition x64dbg comme mod.user([rsp]), ou un if dans un gestionnaire Frida.
  3. Choisissez une seule couche par question. Tracer à la fois CreateFileW et NtCreateFile double chaque entrée. Tracez Win32 pour la lisibilité ; n’ajoutez la couche native que si vous soupçonnez l’échantillon de court-circuiter Win32.
  4. Sautez le démarrage. Attachez-vous après l’initialisation, ou ignorez tout ce qui précède le premier appel issu du propre code de l’échantillon, si vous savez que le démarrage n’est pas la partie intéressante.
  5. Journalisez les retours autant que les arguments. Une trace d’appels sans résultats ne permet pas de distinguer une vérification échouée d’une vérification réussie.

Le lab montre l’effet de l’étape 2 sur une vraie trace : environ 184 000 lignes réduites à 27 appels, dont trois constituent le comportement propre du programme.

Astuce : Les API résolues à la main (GetProcAddress ou hachage d’API) n’apparaissent jamais dans la table des imports, si bien que les hooks qui patchent l’IAT les manquent. Les hooks inline, qui patchent la fonction elle-même, les attrapent d’où que vienne le pointeur. Sachez quel type utilise votre outil.

Lire une trace

Une trace est une liste de requêtes. La transformer en comportement relève surtout de la comptabilité :

  • Suivez les handles. Un handle renvoyé par un appel est un argument du suivant. CreateFileW renvoie 0x1a4 ; ensuite, WriteFile(0x1a4, …) et CloseHandle(0x1a4) concernent ce fichier. Il en va de même pour les clés de registre, les sockets et les handles de processus.
  • Lisez les valeurs de retour et les erreurs. Un RegOpenKeyExW qui renvoie ERROR_FILE_NOT_FOUND, suivi d’une sortie prématurée, est une vérification de l’environnement. Une valeur que l’échantillon lit puis compare est souvent un interrupteur de configuration.
  • Regroupez les appels en actions. Ouvrir, écrire, fermer, c’est « a déposé un fichier » ; OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread, c’est une injection. Nommez chaque groupe et rédigez la chronologie en ces termes.
  • Surveillez les arguments pour y trouver des données décodées. Les chaînes chiffrées par XOR dans le fichier arrivent en clair à l’API qui les utilise. C’est souvent le moyen le moins coûteux de retrouver une adresse de C2.
  • Faites attention à la chronologie. De longs intervalles autour de Sleep ou WaitForSingleObject sont des délais ; un traceur qui ralentit l’échantillon peut aussi déclencher des vérifications qui mesurent le temps écoulé, comme la détection de l’accélération du sommeil.

API native et Win32 dans une trace

Quand une trace inclut les deux couches, chaque appel Win32 se décompose en appels natifs en dessous. Quelques paires courantes :

Win32 (kernel32 / kernelbase)Native (ntdll)
CreateFileWNtCreateFile / NtOpenFile
WriteFile, ReadFileNtWriteFile, NtReadFile
VirtualAlloc(Ex), VirtualProtect(Ex)NtAllocateVirtualMemory, NtProtectVirtualMemory
CreateProcessWNtCreateUserProcess
RegSetValueExWNtSetValueKey
CreateMutexWNtCreateMutant
GetEnvironmentVariableARtlQueryEnvironmentVariable_U (aucun appel système)

Deux conséquences. Les fonctions ANSI (A) convertissent leurs chaînes en UTF-16 et appellent la variante large, si bien qu’une trace peut montrer les deux. Et un appel natif qui apparaît sans son parent Win32, avec une adresse de retour dans le module de l’échantillon, signifie que l’échantillon a appelé ntdll directement : un choix délibéré qui mérite d’être noté. La prochaine leçon Mode utilisateur, mode noyau et API native (module 5) couvre ces couches en détail.

Là où le traçage échoue

Les traceurs dans le processus supposent que l’échantillon passe par les fonctions qu’ils ont hookées. Un malware qui s’attend à être tracé ne le fait pas :

ÉvasionFonctionnementCe qui la voit encore
Appels système directsL’échantillon charge le numéro d’appel système dans eax et exécute lui-même syscall, sans jamais entrer dans ntdllLes sources côté noyau : ETW, Procmon, callbacks noyau des EDR
Appels système indirectsIdem, mais en sautant vers une instruction syscall située dans ntdll pour que l’adresse de retour paraisse légitimeComme ci-dessus ; un débogueur avec un point d’arrêt sur le stub de transition vers le noyau
Décrochage des hooks (unhooking)Mappe une copie neuve de ntdll depuis le disque ou \KnownDlls et copie sa .text propre par-dessus celle qui est hookée, ou restaure les prologues patchés octet par octetLes sources côté noyau ; la comparaison du code de ntdll en mémoire avec le fichier
Détection des hooksLit les premiers octets d’une API à la recherche d’un jmp, ou cherche le module ou les threads du traceurLes outils hors processus ; ou masquer le traceur et patcher la vérification
Aveuglement d’ETW en mode utilisateurPatche ntdll!EtwEventWrite dans son propre processusLes fournisseurs noyau, qui ne passent pas par cette fonction
Attachement tardif ou absentLe travail intéressant a lieu avant que vous vous attachiez, ou dans un processus enfant que vous ne tracez pasTraçage en mode spawn ; suivi des enfants ; sources à l’échelle du système

Reconnaissez-les dans la trace elle-même : un échantillon dont la trace devient silencieuse alors que Procmon le montre toujours en train d’écrire des fichiers contourne vos hooks. Les appels système directs laissent aussi une empreinte statique : un mov eax, <number> suivi de syscall dans le propre code de l’échantillon, ce que les applications normales ne contiennent jamais. Les neutraliser fait partie du module Évasion et unpacking ; pour l’instant, le réflexe défensif consiste à associer chaque trace dans le processus à une source côté noyau pour la même exécution.

Lab : tracer un échantillon des deux côtés

La partie A trace dbglab.exe de Déboguer un malware avec x64dbg avec le canal relay de Wine, qui journalise chaque appel traversant une frontière de DLL. La partie B exécute frida-trace sur une compilation macOS native de la même logique ; la partie C donne les équivalents pour la VM Windows. Les sorties proviennent de Wine 11.0, Frida 17.19.0 avec frida-tools 14.10.4 (venv Python 3.14) et Apple clang 21 sous macOS 26 arm64, abrégées là où c’est indiqué. Wine réimplémente Windows : les appels du programme sont les mêmes, mais tout ce qui se trouve sous kernel32 est du code Wine, pas du code Microsoft.

Partie A : une trace Win32 de dbglab.exe

  1. Exécutez le programme sous le canal relay et comptez ce que vous obtenez :

    bash
    LAB_GO=1 WINEDEBUG=-all,+relay wine dbglab.exe > relay.txt 2>&1
    wineserver -w                  # wait for Wine's helper processes to finish writing
    wc -l < relay.txt
    grep 'GetEnvironmentVariableA' relay.txt | head -1 | cut -d: -f1
    grep -c '^0024:' relay.txt
    grep '^0024:Call' relay.txt | grep -c 'ret=14000'
    text
      183696
    0024
    868
    27

    Le fichier contient tous les processus Wine démarrés pour l’exécution, si bien que le total varie d’une exécution à l’autre. Filtrer sur le thread du programme (0024) laisse 868 lignes ; ne garder que les appels dont l’adresse de retour se trouve dans l’image (ret=14000…) en laisse 27. La plupart relèvent du code de démarrage MinGW. La fin de la liste :

    text
    0024:Call ucrtbase._crt_atexit(140001520) ret=1400013ad
    0024:Call KERNEL32.GetEnvironmentVariableA(140004000 "LAB_GO",0031fdb8,00000008) ret=1400014fc
    0024:Call KERNEL32.OutputDebugStringA(0031fdf0 "http://update.example.com/lab") ret=140002abb
    0024:Call ucrtbase.puts(140004007 "sent to debugger") ret=140002ac7
    0024:Call ucrtbase.exit(00000000) ret=14000140e

    Les trois du milieu constituent le comportement du programme ; _crt_atexit et exit sont du code de démarrage autour de main. Les adresses de retour correspondent aux sites d’appel trouvés statiquement dans le lab de débogage : 0x1400014fc suit l’appel dans la fonction de vérification, 0x140002abb l’appel avec l’URL dans main. L’URL décodée, absente du fichier, figure en clair dans la trace.

  2. Regardez sous un appel. Les lignes non filtrées du thread montrent ce que chaque appel Win32 a fait dans la couche inférieure (extrait, lignes intermédiaires supprimées là où c’est indiqué) :

    text
    0024:Call KERNEL32.GetEnvironmentVariableA(140004000 "LAB_GO",0031fdb8,00000008) ret=1400014fc
    0024:Call ntdll.RtlCreateUnicodeStringFromAsciiz(0031fcc0,140004000 "LAB_GO") ret=6fffffc17954
    0024:Call ntdll.RtlQueryEnvironmentVariable_U(00000000,0031fcc0,0031fcd0) ret=6fffffc17982
    ...
    0024:Ret  KERNEL32.GetEnvironmentVariableA() retval=00000001 ret=1400014fc
    0024:Call KERNEL32.OutputDebugStringA(0031fdf0 "http://update.example.com/lab") ret=140002abb
    0024:Call ntdll.RtlInitUnicodeString(0031fa10,6fffffee74b6 L"DBWinMutex") ret=6fffffc05703
    ...
    0024:Call ntdll.NtCreateMutant(0031fa08,00100000,0031fa20,00000000) ret=6fffffc05733
    ...
    0024:Call ntdll.RtlInitUnicodeString(0031fa00,6fffffee74cc L"DBWIN_BUFFER") ret=6fffffc2ed51
    ...
    0024:Call ntdll.NtOpenSection(0031f9f8,00000002,0031fa10) ret=6fffffc2edb1
    0024:Ret  ntdll.NtOpenSection() retval=c0000034 ret=6fffffc2edb1
    ...
    0024:Ret  KERNEL32.OutputDebugStringA() retval=7ffc0000 ret=140002abb
    0024:Call ucrtbase.puts(140004007 "sent to debugger") ret=140002ac7
    ...
    0024:Call KERNEL32.WriteFile(0000000c,0031eb50,00000012,0031eb4c,00000000) ret=6fffffa0334d
    0024:Call ntdll.NtWriteFile(0000000c,00000000,00000000,00000000,0031ea40,0031eb50,6fff00000012,00000000,00000000) ret=6fffffc68908

    La fonction A convertit d’abord sa chaîne en UTF-16. GetEnvironmentVariableA ne quitte jamais le mode utilisateur, si bien qu’aucun outil au niveau des appels système ne la verrait. OutputDebugStringA ouvre le mutex DBWinMutex et essaie la section DBWIN_BUFFER que créent les visionneuses de sortie de débogage (c0000034 est STATUS_OBJECT_NAME_NOT_FOUND : aucune visionneuse ne tournait). Et le texte de puts n’atteint NtWriteFile qu’à la sortie, quand le runtime vide ses tampons.

Partie B : frida-trace sur une compilation native

  1. Enregistrez tracelab.c, la même logique pour macOS ou Linux, avec un délai de démarrage facultatif pour vous entraîner à l’attachement :

    c
    /* tracelab.c: harmless program for API tracing — reads a setting, decodes a string, writes a file */
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    
    /* "http://update.example.com/lab" XOR 0x5A */
    static const unsigned char ENC_URL[] = {
        0x32, 0x2e, 0x2e, 0x2a, 0x60, 0x75, 0x75, 0x2f, 0x2a, 0x3e, 0x3b, 0x2e,
        0x3f, 0x74, 0x3f, 0x22, 0x3b, 0x37, 0x2a, 0x36, 0x3f, 0x74, 0x39, 0x35,
        0x37, 0x75, 0x36, 0x3b, 0x38
    };
    
    /* external linkage + noinline: keeps clang from specialising or folding the decoder */
    __attribute__((noinline))
    void decode(char *out, const unsigned char *in, size_t n, unsigned char key) {
        for (size_t i = 0; i < n; i++)
            out[i] = (char)(in[i] ^ key);
        out[n] = '\0';
    }
    
    int main(void) {
        char url[64];
        unsigned delay = getenv("LAB_DELAY") ? (unsigned)atoi(getenv("LAB_DELAY")) : 0;
        if (delay) sleep(delay);                 /* gives you time to attach */
    
        decode(url, ENC_URL, sizeof ENC_URL, 0x5A);
        if (getenv("LAB_GO") == NULL) {
            puts("gate closed");
            return 1;
        }
        FILE *f = fopen("tracelab.out", "w");
        if (f) {
            fprintf(f, "%s\n", url);
            fclose(f);
        }
        puts("wrote tracelab.out");
        return 0;
    }
    bash
    clang -O2 -o tracelab tracelab.c
    python3 -m venv venv && ./venv/bin/pip install frida-tools

    Attention : Dans une première version, decode était static. Clang l’a alors spécialisée pour son unique appelant et a remplacé la boucle par des écritures constantes de l’URL en clair, si bien que la chaîne se trouvait dans le binaire, et un hook sur decode affichait des arguments dénués de sens (n=259, key=0x3) parce que la fonction spécialisée ne les prenait plus. Les fonctions internes n’ont pas à respecter l’ABI ; vérifiez le désassemblage avant de vous fier à un hook posé sur l’une d’elles.

  2. Tracez en mode spawn (-f, avec un chemin absolu), en nommant les appels de la libc et la fonction decode du programme :

    bash
    LAB_GO=1 ./venv/bin/frida-trace -f "$PWD/tracelab" -i getenv -i fopen -i puts -i 'tracelab!decode'

    Modifiez ensuite deux gestionnaires générés. __handlers__/libsystem_c.dylib/getenv.js doit journaliser le résultat, pas seulement le nom :

    js
    defineHandler({
      onEnter(log, args, state) {
        this.name = args[0].readUtf8String();
      },
      onLeave(log, retval, state) {
        const value = retval.isNull() ? "NULL" : `"${retval.readUtf8String()}"`;
        log(`getenv("${this.name}") => ${value}`);
      }
    });

    Et __handlers__/tracelab/decode.js doit afficher le tampon de sortie une fois que la fonction l’a rempli :

    js
    defineHandler({
      onEnter(log, args, state) {
        this.out = args[0];                       // decode(out, in, n, key)
        log(`decode(n=${args[2].toInt32()}, key=${ptr(args[3]).and(0xff)})`);
      },
      onLeave(log, retval, state) {
        log(`decode => "${this.out.readUtf8String()}"`);
      }
    });
  3. Relancez avec les deux chemins. Pour l’exécution avec la vérification ouverte, nous avons aussi ajouté -i write -i __write_nocancel, avec ce gestionnaire dans __handlers__/libsystem_kernel.dylib/__write_nocancel.js, pour attraper les octets que fclose vide dans le fichier :

    js
    defineHandler({
      onEnter(log, args, state) {
        const n = args[2].toInt32();
        log(`__write_nocancel(fd=${args[0].toInt32()}, ${JSON.stringify(args[1].readUtf8String(n))}, ${n})`);
      }
    });

    Vérification ouverte (lignes de démarrage retirées) :

    text
    Started tracing 6 functions. Web UI available at http://localhost:62711/
               /* TID 0x103 */
         7 ms  getenv("LAB_DELAY") => NULL
         7 ms  decode(n=29, key=0x5a)
         7 ms  decode => "http://update.example.com/lab"
         7 ms  getenv("LAB_GO") => "1"
         7 ms  fopen(path="tracelab.out", mode="w")
         7 ms  getenv("STDBUF") => NULL
         7 ms  getenv("STDBUF0") => NULL
         7 ms  getenv("STDBUF1") => NULL
         7 ms  getenv("STDBUF2") => NULL
         7 ms  getenv("_STDBUF_I") => NULL
         7 ms  getenv("_STDBUF_O") => NULL
         7 ms  getenv("_STDBUF_E") => NULL
         7 ms  __write_nocancel(fd=3, "http://update.example.com/lab\n", 30)
         8 ms  puts(s="wrote tracelab.out")
    Process terminated

    Vérification fermée (sans LAB_GO) :

    text
               /* TID 0x103 */
         4 ms  getenv("LAB_DELAY") => NULL
         4 ms  decode(n=29, key=0x5a)
         4 ms  decode => "http://update.example.com/lab"
         4 ms  getenv("LAB_GO") => NULL
         4 ms  puts(s="gate closed")
         4 ms     | getenv("STDBUF") => NULL
         4 ms     | getenv("STDBUF0") => NULL
         4 ms     | getenv("STDBUF1") => NULL
         4 ms     | getenv("STDBUF2") => NULL
         4 ms     | getenv("_STDBUF_I") => NULL
         4 ms     | getenv("_STDBUF_O") => NULL
         4 ms     | getenv("_STDBUF_E") => NULL
    Process terminated

    Les sept recherches de STDBUF sont la libc qui configure son premier flux, pas le programme. | marque les appels faits à l’intérieur d’une autre fonction tracée (puts) ; lors de la première exécution, ils sont au niveau supérieur parce que fprintf, non tracée, a ouvert le flux. L’exécution avec la vérification fermée décode quand même l’URL avant de vérifier, si bien que hooker la fonction interne permet de récupérer l’indicateur même quand l’échantillon refuse d’agir. Une tentative de spawn a échoué avec unexpectedly timed out while initializing suspended process ; une nouvelle tentative a réussi.

  4. Attachez-vous au lieu de lancer le processus. Démarrez le programme avec un délai, puis attachez-vous par nom :

    bash
    LAB_GO=1 LAB_DELAY=8 ./tracelab &
    ./venv/bin/frida-trace -n tracelab -i getenv -i fopen -i puts -i 'tracelab!decode'
    text
    Attaching...
    Started tracing 4 functions. Web UI available at http://localhost:62747/
               /* TID 0x103 */
      6267 ms  decode(n=29, key=0x5a)
      6267 ms  decode => "http://update.example.com/lab"
      6267 ms  getenv("LAB_GO") => "1"
      6267 ms  fopen(path="tracelab.out", mode="w")
      ...
      6268 ms  puts(s="wrote tracelab.out")
    Process terminated

    getenv("LAB_DELAY") manque : il s’est exécuté avant que Frida ne s’attache. L’attachement a fonctionné parce que tracelab est notre propre binaire signé ad hoc ; un binaire système protégé refuse même le mode spawn :

    text
    Spawning `/bin/echo`...
    Failed to attach: unable to access process with pid 92877 from the current user account

    Avec System Integrity Protection activée, les binaires de plateforme d’Apple ne peuvent pas être instrumentés ; les VM de labo Windows et Linux n’ont pas cette restriction pour les processus qui vous appartiennent.

Partie C : la même trace sous Windows

  1. frida-trace dans la VM. Installez frida-tools dans un environnement virtuel de la VM Windows, définissez LAB_GO=1 dans une invite de commandes, et exécutez frida-trace -f C:\lab\dbglab.exe -i "KERNEL32.DLL!OutputDebugStringA" -i "KERNEL32.DLL!GetEnvironmentVariableA". Qualifiez le module : de nombreux exports de kernel32 ont un jumeau dans KernelBase, et un -i non qualifié hooke tous les modules qui exportent ce nom, si bien qu’un même appel peut être journalisé deux fois. Modifiez le fichier généré OutputDebugStringA.js pour journaliser args[0].readUtf8String().
  2. API Monitor dans la VM. Lancez API Monitor x64, cherchez OutputDebugStringA et GetEnvironmentVariableA dans l’API Filter et cochez-les, puis Monitor New Process sur dbglab.exe. Exécutez-le une fois avec LAB_GO non défini et une fois défini, et comparez les deux listes d’appels et leurs panneaux Call Stack avec la trace relay de la partie A.
  3. Vérifiez depuis le côté noyau. Pendant la même exécution, faites enregistrer dbglab.exe par Procmon. Quels appels de la trace API Monitor produisent un événement Procmon, et lesquels n’en produisent jamais ?

Questions : Pourquoi GetEnvironmentVariableA apparaît-elle dans les traces relay et Frida, alors qu’elle ne pourrait jamais apparaître dans une trace strace ou ETW noyau ? Dans la partie A, quel filtre a supprimé le plus de bruit : le thread ou l’adresse de retour, et lequel échouerait sur un échantillon qui s’injecte dans un autre processus ? Si dbglab.exe résolvait OutputDebugStringA par hachage d’API, lesquels des outils de ce lab journaliseraient encore l’appel ? À quoi ressemblerait une trace de tracelab si l’échantillon émettait son write sous forme d’appel système brut ?

À retenir

  • Le traçage enregistre de nombreux appels à pleine vitesse ; le débogage examine un instant en profondeur. Surveillez, puis tracez, puis déboguez ce que la trace ne peut pas expliquer.
  • Les traceurs dans le processus (API Monitor, Frida, points d’arrêt de journalisation) donnent des arguments riches mais peuvent être vus et supprimés ; les sources côté noyau (Procmon, ETW, strace, eBPF) sont plus grossières mais survivent aux astuces en mode utilisateur.
  • Délimitez chaque trace par une question, filtrez selon le module de l’appelant, choisissez une couche et journalisez les valeurs de retour.
  • Lisez une trace en suivant les handles, en regroupant les appels en actions et en lisant les arguments décodés ; la frontière de l’API est l’endroit où les chaînes chiffrées apparaissent en clair.
  • Les appels Win32 se décomposent en appels natifs de ntdll, et certains n’atteignent jamais le noyau. Un appel natif fait directement depuis le code de l’échantillon est une trouvaille.
  • Les appels système directs et indirects, le décrochage des hooks, la détection des hooks et l’attachement tardif mettent en échec le traçage dans le processus ; associez chaque trace fondée sur des hooks à un enregistrement côté noyau de la même exécution.