Skip to content

Leçon 2.3 · Formats binaires· 45 min

Imports, exports et IAT

Comment un PE déclare les fonctions d’API qu’il utilise et fournit, comment le chargeur remplit l’IAT, et comment les malwares vous cachent leurs imports.

Objectifs

  • Parcourir le répertoire d’imports depuis IMAGE_IMPORT_DESCRIPTOR jusqu’à l’ILT, l’IAT et les entrées hint/nom
  • Expliquer comment le chargeur résout les imports, y compris par ordinal et par chargement différé
  • Analyser un répertoire d’exports, résoudre un nom en adresse et reconnaître les forwarders
  • Calculer et interpréter un imphash en connaissant ses limites
  • Reconnaître la résolution dynamique et l’API hashing comme moyens de cacher les imports

Seul, un programme Windows ne peut presque rien faire : il ne peut ni ouvrir un fichier, ni envoyer un paquet, ni créer un processus sans appeler des DLL système comme kernel32.dll, ntdll.dll, advapi32.dll ou ws2_32.dll. Le format PE consigne ces dépendances dans la table des imports, et les DLL publient ce qu’elles proposent dans la table des exports.

Pour un analyste, la table des imports est souvent l’artefact le plus riche d’un fichier. Un échantillon qui importe VirtualAllocEx, WriteProcessMemory et CreateRemoteThread annonce ses intentions avant même que vous ayez désassemblé un octet. C’est précisément pour cela que les auteurs de malwares (logiciels malveillants) s’acharnent à la cacher — et c’est pour cela que vous devez comprendre ces structures assez bien pour remarquer leur absence.

Le répertoire d’imports

Le répertoire de données 1 (voir En-têtes PE) pointe vers un tableau de structures IMAGE_IMPORT_DESCRIPTOR de 20 octets, une par DLL importée, terminé par une entrée entièrement nulle :

OffsetChampSignification
0x00OriginalFirstThunkRVA de l’Import Lookup Table (ILT), aussi appelée Import Name Table
0x04TimeDateStamp0 sauf si les imports sont liés (bound)
0x08ForwarderChainChamp de liaison historique ; généralement 0 ou -1
0x0CNameRVA du nom de la DLL, p. ex. "KERNEL32.dll"
0x10FirstThunkRVA de l’Import Address Table (IAT)

L’ILT et l’IAT sont des tableaux parallèles de thunks : 8 octets chacun en PE32+, 4 octets en PE32, terminés par une entrée nulle. Chaque thunk s’interprète ainsi :

  • Bit de poids fort à 0 — la valeur est une RVA vers une structure IMAGE_IMPORT_BY_NAME : un hint de 2 octets suivi du nom de la fonction terminé par NUL.
  • Bit de poids fort à 1 (0x8000000000000000 en PE32+, 0x80000000 en PE32) — import par ordinal ; les 16 bits de poids faible sont le numéro d’ordinal et il n’y a pas de nom.
text
IMAGE_IMPORT_DESCRIPTOR (KERNEL32.dll)
 ├─ Name ──────────────► "KERNEL32.dll"
 ├─ OriginalFirstThunk ─► ILT                      hint/name table
 │                        [0] 0x8450 ────────────► 0x0127 "DeleteCriticalSection"
 │                        [1] 0x8468 ────────────► 0x014D "EnterCriticalSection"
 │                        [2] 0x8480 ────────────► 0x0239 "GetCurrentProcessId"
 │                        [3] 0
 └─ FirstThunk ─────────► IAT   (on disk: same values as the ILT)
                          [0] 0x8450   ── after loading ──► 0x7FFA1B2C3D40
                          [1] 0x8468                        0x7FFA1B2C1180
                          [2] 0x8480                        0x7FFA1B2C0A20
                          [3] 0

Le hint est l’index où l’éditeur de liens s’attendait à trouver le nom dans la table des noms exportés de la DLL. Le chargeur essaie d’abord cet emplacement, puis se rabat sur une recherche dichotomique si le hint est faux : les hints sont donc une optimisation, pas une obligation.

Astuce : L’import par ordinal masque le nom de la fonction. Pour les DLL système courantes comme ws2_32.dll et oleaut32.dll, les ordinaux sont stables et des outils comme pefile les retraduisent en noms. Pour des DLL quelconques, un import par ordinal reste opaque tant que vous n’avez pas examiné la table des exports de la DLL cible.

Comment le chargeur remplit l’IAT

Au démarrage d’un processus (voir Comment un binaire est chargé et exécuté), le chargeur parcourt les descripteurs d’imports un par un :

  1. Charger la DLL désignée par Name, en résolvant récursivement ses propres imports.
  2. Pour chaque entrée de l’ILT, chercher la fonction dans la table des exports de cette DLL, par nom (en essayant d’abord le hint) ou par ordinal.
  3. Écrire l’adresse absolue résolue dans l’emplacement correspondant de l’IAT.

L’ILT reste intacte ; elle conserve la trace de ce qui a été demandé. L’IAT, une fois remplie, n’est qu’un tableau de pointeurs de fonctions. Le code compilé n’appelle jamais une API directement ; il l’appelle à travers l’IAT :

asm
call  qword ptr [rip + 0x6E3A]   ; → IAT slot for GetCurrentProcessId
; or, via a small stub the linker generates:
call  GetCurrentProcessId_stub
...
GetCurrentProcessId_stub:
jmp   qword ptr [rip + 0x6F12]   ; → the same IAT slot

Cette unique indirection explique pourquoi les désassembleurs peuvent nommer les appels d’API même dans des binaires dépouillés de leurs symboles : ils savent quel emplacement de l’IAT chaque appel lit. C’est aussi pourquoi l’IAT est une cible de choix pour le hooking — il suffit d’écraser un pointeur pour que chaque appel à cette API aille ailleurs (voir API hooking).

Le répertoire de données 12 (IAT) indique où se trouvent les IAT. Certains très anciens éditeurs de liens mettent OriginalFirstThunk à 0 et ne laissent que l’IAT ; le chargeur lit alors les noms dans l’IAT elle-même avant de l’écraser. Les outils doivent gérer les deux cas.

Imports liés et imports à chargement différé

  • Les imports liés (bound imports, répertoire 11) précalculent les valeurs de l’IAT au moment de l’édition de liens pour une version précise de la DLL. Avec l’ASLR, ils sont presque toujours périmés et sont rares dans les binaires modernes.
  • Les imports à chargement différé (delay-load imports, répertoire 13) utilisent un tableau de descripteurs distinct, IMAGE_DELAYLOAD_DESCRIPTOR. La DLL n’est pas chargée au démarrage ; chaque emplacement de l’IAT pointe initialement vers une fonction auxiliaire (__delayLoadHelper2 avec MSVC) qui charge la DLL et résout la fonction au premier appel. MSVC l’active avec /DELAYLOAD:name.dll.

Les API à chargement différé passent facilement inaperçues, car certains outils les listent à part, voire pas du tout. Dans pefile, vérifiez pe.DIRECTORY_ENTRY_DELAY_IMPORT en plus de pe.DIRECTORY_ENTRY_IMPORT.

Les exports

Une DLL (et parfois un EXE) publie des fonctions via le répertoire de données 0, qui pointe vers un IMAGE_EXPORT_DIRECTORY de 40 octets :

OffsetChampSignification
0x0CNameRVA du nom interne de la DLL
0x10BaseOrdinal de la première entrée de AddressOfFunctions
0x14NumberOfFunctionsNombre d’entrées du tableau d’adresses de fonctions
0x18NumberOfNamesNombre d’entrées des tableaux de noms (parfois moins)
0x1CAddressOfFunctionsRVA d’un tableau de RVA sur un DWORD (l’EAT)
0x20AddressOfNamesRVA d’un tableau trié de RVA de noms
0x24AddressOfNameOrdinalsRVA d’un tableau d’index sur un WORD dans l’EAT

(Les 12 premiers octets contiennent Characteristics, TimeDateStamp et une version.)

Trois tableaux fonctionnent ensemble :

text
 AddressOfNames[i]      AddressOfNameOrdinals[i]      AddressOfFunctions[j]
 (sorted, ASCII order)  (index j into EAT)            (RVA)
 ┌───────────────┐      ┌─────┐                        ┌────────┐ j=0  ordinal Base+0
 │ "LabBeep"     │ ───► │  2  │ ──────────┐     ┌────► │ 0x1360 │ add_numbers
 │ "add_numbers" │ ───► │  0  │ ──────────┼─────┘      ├────────┤ j=1  ordinal Base+1
 │ "lab_version" │ ───► │  1  │ ──────────┼──────────► │ 0x1374 │ lab_version
 └───────────────┘      └─────┘           │            ├────────┤ j=2  ordinal Base+2
                                          └──────────► │ 0x806C │ inside export dir:
                                                       ├────────┤ forwarder string
                                                       │  0 ... │ j=3..8 unused
                                                       ├────────┤ j=9  ordinal Base+9
                                                       │ 0x1381 │ (no name)
                                                       └────────┘

Résoudre un nom : effectuer une recherche dichotomique dans AddressOfNames (trié par valeur d’octet brute, si bien que les noms en majuscules précèdent les noms en minuscules) pour obtenir l’index i, lire j = AddressOfNameOrdinals[i], et la RVA de la fonction est AddressOfFunctions[j]. Résoudre un ordinal n : utiliser directement AddressOfFunctions[n - Base]. Les entrées sans nom sont des exports par ordinal uniquement.

Les forwarders

Si la RVA d’une fonction pointe à l’intérieur du répertoire d’exports lui-même (entre la RVA du répertoire et RVA + Size), ce n’est pas du code : elle pointe vers une chaîne comme "NTDLL.RtlAllocateHeap", et le chargeur résout cette cible à la place. Windows utilise massivement la redirection (forwarding) — de nombreux exports de kernel32.dll sont redirigés vers kernelbase.dll ou ntdll.dll. Les malwares s’en servent pour le DLL proxying : une DLL malveillante placée là où une DLL légitime serait trouvée redirige chaque export vers la vraie DLL afin que le programme hôte continue de fonctionner, tandis que son propre DllMain exécute la charge utile.

Ce que les exports apprennent à l’analyste

  • Le nom interne survit souvent au renommage : un fichier appelé update.dat dont le répertoire d’exports indique loader_x64.dll vous a appris quelque chose.
  • Les noms d’exports révèlent les points d’entrée prévus : une DLL destinée à rundll32.exe dropped.dll,Start doit exporter Start, et une DLL enregistrée comme serveur COM exporte DllRegisterServer.
  • Une DLL qui exporte les mêmes noms qu’une DLL système, pour la plupart sous forme de forwarders, est un proxy — examinez de près les quelques exports qui ne sont pas redirigés.
  • Les DLL chargées de manière réflective exportent souvent une unique fonction de chargement comme ReflectiveLoader (voir l’injection réflective de DLL).

Imphash

L’imphash, introduit par Mandiant en 2014, est un MD5 calculé sur la table des imports sous une forme normalisée : chaque import devient dllname.function en minuscules, l’extension est retirée du nom de la DLL et les ordinaux connus sont traduits en noms ; les entrées sont jointes par des virgules dans leur ordre d’origine, puis hachées.

Comme la liste des imports et son ordre dépendent du code source et du build, des échantillons compilés à partir de la même base de code partagent souvent un imphash même quand leurs hashs de fichier diffèrent. Il est donc utile pour pivoter dans VirusTotal et les plateformes similaires, et dans YARA via la fonction pe.imphash().

Connaissez ses limites :

  • Les échantillons packés partagent la minuscule table d’imports du packer : tous les fichiers packés avec la même version d’UPX se ressemblent.
  • Les tables d’imports minuscules (une poignée d’imports de la CRT) entrent en collision entre programmes sans rapport.
  • Les binaires Go, Delphi et liés statiquement produisent des imphashs partagés par des milliers de fichiers sans rapport.
  • Réordonner les imports ou ajouter une seule API inutilisée change complètement le hash.

Considérez l’imphash comme un indice de regroupement à confirmer par d’autres éléments, pas comme une attribution.

Ce que révèlent les imports, et comment les malwares les cachent

Les imports d’un programme normal se lisent comme un résumé de ses capacités. Quelques combinaisons que les analystes apprennent à reconnaître :

ImportsSuggère
OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThreadInjection de processus
RegCreateKeyExW, RegSetValueExW avec une chaîne de clé RunPersistance via le registre
CryptAcquireContext, CryptEncrypt, FindFirstFileWChiffrement de fichiers (type rançongiciel)
InternetOpenA, InternetOpenUrlA, URLDownloadToFileWTéléchargement
SetWindowsHookExA, GetAsyncKeyStateEnregistrement de frappes (keylogging)
IsDebuggerPresent, CheckRemoteDebuggerPresentAnti-débogage

Comme cette information est très parlante, les malwares cherchent à garder une table d’imports banale :

  • Résolution dynamique. Appeler LoadLibraryA et GetProcAddress à l’exécution pour obtenir des pointeurs de fonctions. L’API sensible n’apparaît jamais dans la table des imports, même si son nom apparaît généralement sous forme de chaîne. Voir la résolution dynamique des imports.
  • Chaînes masquées. Même principe, mais avec des noms construits sur la pile ou déchiffrés à l’exécution, de sorte que ni la table des imports ni strings ne les révèlent. Voir les stack strings.
  • API hashing. Parcourir les modules chargés via le PEB, analyser en mémoire la table des exports de chaque DLL, hacher chaque nom exporté et le comparer à des constantes précalculées. Pas de GetProcAddress, pas de chaînes — seulement des nombres comme 0x726774C. Voir l’API hashing.
  • Packing. Les vrais imports sont reconstruits par l’unpacker ; le fichier packé ne montre que ce dont le stub a besoin. Une table réduite à peu près à LoadLibraryA, GetProcAddress, VirtualProtect et ExitProcess est le signe classique, et c’est exactement ce que laisse UPX.
  • Destruction de l’IAT. Après la résolution des imports, effacer ou corrompre les structures d’imports en mémoire pour qu’une image dumpée ne puisse pas être reconstruite facilement. Voir la destruction de l’IAT.

Une table d’imports étonnamment courte dans un gros binaire est en soi une observation. Ici, l’absence de preuve est une preuve — celle d’une dissimulation.

Attention : Un import est une capacité, pas un comportement. De nombreux programmes légitimes importent WriteProcessMemory (débogueurs, lanceurs de jeux, outils de sécurité). Servez-vous des imports pour décider où regarder, puis confirmez dans le code.

Lab : fonctions visibles, cachées et exportées

Vous allez compiler un programme inoffensif qui appelle certaines API normalement et une dynamiquement, comparer ce que voient différents outils, puis compiler une DLL et disséquer ses exports. Les exemples utilisent mingw-w64 ; les équivalents MSVC sont indiqués.

  1. Écrivez le programme :

    c
    // imports.c
    #include <windows.h>
    #include <stdio.h>
    
    typedef BOOL (WINAPI *pGetComputerNameA)(LPSTR, LPDWORD);
    
    int main(void) {
        char name[MAX_COMPUTERNAME_LENGTH + 1];
        DWORD len = sizeof(name);
    
        /* Static imports: resolved by the loader, listed in the import table */
        DWORD pid = GetCurrentProcessId();
        Sleep(10);
        MessageBoxA(NULL, "benign lab program", "imports demo", MB_OK);
    
        /* Dynamic import: absent from the import table */
        HMODULE k32 = LoadLibraryA("kernel32.dll");
        pGetComputerNameA fn =
            (pGetComputerNameA)GetProcAddress(k32, "GetComputerNameA");
        if (fn && fn(name, &len))
            printf("pid %lu on %s\n", pid, name);
        return 0;
    }
    bash
    x86_64-w64-mingw32-gcc -O0 -s -o imports.exe imports.c -luser32
    # MSVC: cl imports.c user32.lib
  2. Listez les imports avec pefile, y compris les adresses de l’ILT et de l’IAT :

    python
    # imports.py
    import sys, pefile
    pe = pefile.PE(sys.argv[1])
    for desc in pe.DIRECTORY_ENTRY_IMPORT:
        s = desc.struct
        print(f"{desc.dll.decode()}  ILT={s.OriginalFirstThunk:#x} IAT={s.FirstThunk:#x}")
        for imp in desc.imports:
            label = imp.name.decode() if imp.name else f"ordinal {imp.ordinal}"
            print(f"    {imp.address:#x}  hint={imp.hint}  {label}")
    print("imphash:", pe.get_imphash())
    bash
    python3 imports.py imports.exe

    Repérez GetCurrentProcessId, Sleep, LoadLibraryA et GetProcAddress sous KERNEL32.dll, et MessageBoxA sous USER32.dll. La colonne address donne l’adresse virtuelle de l’emplacement de l’IAT. GetComputerNameA n’y figure pas.

  3. Cherchez maintenant dans les chaînes :

    bash
    strings imports.exe | grep -iE "computername|messagebox|kernel32|sleep"

    GetComputerNameA apparaît — sous forme de simple chaîne passée à GetProcAddress. Notez la règle que cela illustre : un nom de fonction présent dans les chaînes mais absent des imports, à côté de GetProcAddress, signale une résolution dynamique.

  4. Vérifiez l’IAT sur disque. Confirmez qu’avant le chargement, l’IAT contient les mêmes RVA hint/nom que l’ILT :

    python
    d = pe.DIRECTORY_ENTRY_IMPORT[0].struct
    for k in range(3):
        ilt = pe.get_qword_at_rva(d.OriginalFirstThunk + 8 * k)
        iat = pe.get_qword_at_rva(d.FirstThunk + 8 * k)
        print(hex(ilt), hex(iat), pe.get_string_at_rva(ilt + 2))
  5. Comparez les imphashs. Notez l’imphash, puis ajoutez un appel à GetTickCount() dans imports.c, recompilez et recalculez-le. Comparez avec l’imphash de hello.exe des leçons précédentes et, si vous l’avez encore, de hello_upx.exe.

  6. Compilez une DLL avec des exports. Utilisez un fichier de définition de module pour contrôler les ordinaux, ajoutez un forwarder et un export par ordinal uniquement :

    c
    // mylib.c
    #include <windows.h>
    
    int add_numbers(int a, int b) { return a + b; }
    const char *lab_version(void) { return "1.0"; }
    int hidden_helper(void) { return 42; }
    
    BOOL WINAPI DllMain(HINSTANCE inst, DWORD reason, LPVOID reserved) {
        return TRUE;
    }
    text
    ; mylib.def
    LIBRARY mylib
    EXPORTS
        add_numbers    @1
        lab_version    @2
        LabBeep = kernel32.Beep @3
        hidden_helper  @10 NONAME
    bash
    x86_64-w64-mingw32-gcc -shared -s -o mylib.dll mylib.c mylib.def
    # MSVC: cl /LD mylib.c /link /DEF:mylib.def
  7. Examinez les exports :

    python
    import pefile
    pe = pefile.PE("mylib.dll")
    ed = pe.DIRECTORY_ENTRY_EXPORT
    d = pe.OPTIONAL_HEADER.DATA_DIRECTORY[0]
    print("internal name:", pe.get_string_at_rva(ed.struct.Name))
    print("Base", ed.struct.Base, "functions", ed.struct.NumberOfFunctions,
          "names", ed.struct.NumberOfNames)
    print(f"export dir {d.VirtualAddress:#x}-{d.VirtualAddress + d.Size:#x}")
    for s in ed.symbols:
        print(s.ordinal, hex(s.address), s.name, s.forwarder)
    print(hex(pe.FILE_HEADER.Characteristics))

    Vous devriez obtenir Base = 1, NumberOfFunctions = 10 (les ordinaux 4 à 9 sont des emplacements vides), NumberOfNames = 3, une entrée LabBeep dont la RVA tombe dans la plage du répertoire d’exports avec le forwarder kernel32.Beep, et l’ordinal 10 sans nom. La valeur de Characteristics inclut 0x2000, le drapeau DLL.

  8. Ouvrez imports.exe et mylib.dll dans PE-bear et regardez les onglets Imports et Exports. Cliquez sur un import et comparez les colonnes Thunk et Original Thunk. Si vous disposez d’une VM Windows, ouvrez mylib.dll dans Dependencies pour voir le forwarder résolu vers kernel32.dll.

Questions : Pourquoi NumberOfFunctions diffère-t-il de NumberOfNames dans votre DLL ? Comment appelleriez-vous hidden_helper depuis un autre programme sans nom ? Qu’est-ce qui changerait à l’étape 3 si la chaîne "GetComputerNameA" était construite caractère par caractère sur la pile ? Quels imports de KERNEL32 dans imports.exe n’avez-vous pas écrits vous-même, et d’où viennent-ils ?

À retenir

  • Chaque IMAGE_IMPORT_DESCRIPTOR nomme une DLL et pointe vers deux tableaux de thunks parallèles : l’ILT (ce qui a été demandé) et l’IAT (écrasée par le chargeur avec les vraies adresses).
  • Les thunks dont le bit de poids fort est à 1 sont des imports par ordinal ; sinon, ils pointent vers un hint et un nom. Les imports à chargement différé se trouvent dans un répertoire distinct.
  • Les exports reposent sur trois tableaux — noms, ordinaux des noms et RVA des fonctions ; une RVA qui tombe dans le répertoire d’exports est une chaîne de forwarder.
  • L’imphash regroupe les échantillons compilés à partir du même code, mais provoque des collisions pour les binaires packés, minuscules ou dominés par leur runtime.
  • L’absence d’imports est un signal : résolution dynamique, API hashing, packing et destruction de l’IAT existent tous pour garder la table des imports silencieuse.