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 :
| Offset | Champ | Signification |
|---|---|---|
0x00 | OriginalFirstThunk | RVA de l’Import Lookup Table (ILT), aussi appelée Import Name Table |
0x04 | TimeDateStamp | 0 sauf si les imports sont liés (bound) |
0x08 | ForwarderChain | Champ de liaison historique ; généralement 0 ou -1 |
0x0C | Name | RVA du nom de la DLL, p. ex. "KERNEL32.dll" |
0x10 | FirstThunk | RVA 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 (
0x8000000000000000en PE32+,0x80000000en PE32) — import par ordinal ; les 16 bits de poids faible sont le numéro d’ordinal et il n’y a pas de nom.
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] 0Le 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.dlletoleaut32.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 :
- Charger la DLL désignée par
Name, en résolvant récursivement ses propres imports. - 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.
- É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 :
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 slotCette 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 (__delayLoadHelper2avec 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 :
| Offset | Champ | Signification |
|---|---|---|
0x0C | Name | RVA du nom interne de la DLL |
0x10 | Base | Ordinal de la première entrée de AddressOfFunctions |
0x14 | NumberOfFunctions | Nombre d’entrées du tableau d’adresses de fonctions |
0x18 | NumberOfNames | Nombre d’entrées des tableaux de noms (parfois moins) |
0x1C | AddressOfFunctions | RVA d’un tableau de RVA sur un DWORD (l’EAT) |
0x20 | AddressOfNames | RVA d’un tableau trié de RVA de noms |
0x24 | AddressOfNameOrdinals | RVA 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 :
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.datdont le répertoire d’exports indiqueloader_x64.dllvous 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,Startdoit exporterStart, et une DLL enregistrée comme serveur COM exporteDllRegisterServer. - 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 :
| Imports | Suggère |
|---|---|
OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread | Injection de processus |
RegCreateKeyExW, RegSetValueExW avec une chaîne de clé Run | Persistance via le registre |
CryptAcquireContext, CryptEncrypt, FindFirstFileW | Chiffrement de fichiers (type rançongiciel) |
InternetOpenA, InternetOpenUrlA, URLDownloadToFileW | Téléchargement |
SetWindowsHookExA, GetAsyncKeyState | Enregistrement de frappes (keylogging) |
IsDebuggerPresent, CheckRemoteDebuggerPresent | Anti-débogage |
Comme cette information est très parlante, les malwares cherchent à garder une table d’imports banale :
- Résolution dynamique. Appeler
LoadLibraryAetGetProcAddressà 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
stringsne 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 comme0x726774C. 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,VirtualProtectetExitProcessest 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.
-
É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 -
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.exeRepérez
GetCurrentProcessId,Sleep,LoadLibraryAetGetProcAddresssousKERNEL32.dll, etMessageBoxAsousUSER32.dll. La colonneaddressdonne l’adresse virtuelle de l’emplacement de l’IAT.GetComputerNameAn’y figure pas. -
Cherchez maintenant dans les chaînes :
bash strings imports.exe | grep -iE "computername|messagebox|kernel32|sleep"GetComputerNameAapparaî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é deGetProcAddress, signale une résolution dynamique. -
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)) -
Comparez les imphashs. Notez l’imphash, puis ajoutez un appel à
GetTickCount()dansimports.c, recompilez et recalculez-le. Comparez avec l’imphash dehello.exedes leçons précédentes et, si vous l’avez encore, dehello_upx.exe. -
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 NONAMEbash x86_64-w64-mingw32-gcc -shared -s -o mylib.dll mylib.c mylib.def # MSVC: cl /LD mylib.c /link /DEF:mylib.def -
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éeLabBeepdont la RVA tombe dans la plage du répertoire d’exports avec le forwarderkernel32.Beep, et l’ordinal 10 sans nom. La valeur deCharacteristicsinclut0x2000, le drapeau DLL. -
Ouvrez
imports.exeetmylib.dlldans 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, ouvrezmylib.dlldans Dependencies pour voir le forwarder résolu verskernel32.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_DESCRIPTORnomme 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.