PARTIE 6 : La table d'importation

Iczelion

Traducteur

Traduction et adaptation par Olivier Lance

Dans ce tutoriel nous verrons les mécanismes sous-jacents à l'importation de fonctions dans les fichiers PE, et comment le format a été organisé en conséquence, autour de la table d'importation.

Article original : Tutorial 6: Import Table

Retour à la liste des tutoriels

Tutoriel précédent : La table des sections
Tutoriel suivant : La table d'exportation

- Titre : PARTIE 6 : La table d'importation

- Auteur : Iczelion

- Traducteur : Traduction et adaptation par Olivier Lance

- Parution : 23 décembre 2005

- Mise à jour : 6 janvier 2020

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2005 Olivier Lance. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- Théorie

- Exemple

- Téléchargements

- Liens

Théorie

Tout d'abord, vous devez savoir ce qu'est une fonction importée. Une fonction importée est une fonction qui est appelée par un module mais qui n'est pas stockée dans ce module, d'où le nom "importée". Les fonctions importées sont en fait situées dans une ou plusieurs DLL. Seules les informations servant à l'appel de ces fonctions sont gardées dans le module appelant. Ces informations incluent le nom de la fonction et le nom de la DLL dans laquelle elle est stockée.
Ceci dit, comment trouver où sont stockées ces informations dans un fichier PE ? Il nous faut nous tourner vers le champ data directory de l'entête facultatif. Petit rafraîchissement de mémoire ; voici l'entête PE :

type

_IMAGE_NT_HEADERS = packed record
Signature: DWORD;
FileHeader: TImageFileHeader;
OptionalHeader: TImageOptionalHeader;
end;

TImageNtHeaders = _IMAGE_NT_HEADERS;

Le dernier membre de l'entête facultatif est le data directory :

const
IMAGE_NUMBEROF_DIRECTORY_ENTRIES = 16;

type
_IMAGE_OPTIONAL_HEADER = packed record
{...}
LoaderFlags: DWORD;
NumberOfRvaAndSizes: DWORD;
DataDirectory: packed array[0..IMAGE_NUMBEROF_DIRECTORY_ENTRIES - 1] of TImageDataDirectory;
end;

Le data directory est un tableau de structures IMAGE_DATA_DIRECTORY, contenant au total 15 membres. Si vous vous souvenez de la table des sections comme le répertoire racine des sections dans un fichier PE, vous devez alors voir le data directory comme le répertoire racine des éléments logiques stockés dans ces sections. Pour être plus précis, chaque membre du data directory contient les positions et tailles des structures de données importantes dans le fichier PE.

Contenu du DataDirectory

Maintenant que vous savez ce que chaque membre contient, nous pouvons nous intéresser plus en détail à ceux-ci. Chacun d'eux est une structure IMAGE_DATA_DIRECTORY définie comme suit :

type

_IMAGE_DATA_DIRECTORY = record
VirtualAddress: DWORD;
Size: DWORD;
end;

TImageDataDirectory = _IMAGE_DATA_DIRECTORY;

VirtualAddress est la RVA(1) de la structure de données. Par exemple, si cette structure est celle des symboles d'importation, ce champ contient la RVA d'un tableau de IMAGE_IMPORT_DESCRIPTOR.
Size contient la taille de la structure pointée par VirtualAddress.

Voici la méthode globale pour trouver des structures importantes dans un fichier PE :

- À partir de l'entête DOS, rendez-vous à l'entête PE

- Récupérez l'adresse du data directory dans l'entête facultatif

- Multipliez la taille de IMAGE_DATA_DIRECTORY par l'index du membre qui vous intéresse : par exemple si vous souhaitez savoir où est la table des symboles d'importation, vous devez multiplier cette taille par 1.

- Ajoutez le résultat à l'adresse du data directory et vous obtenez l'adresse de la structure IMAGE_DATA_DIRECTORY qui contient les informations sur la structure désirée.

Maintenant, nous allons réellement entrer dans le vif du sujet en ce qui concerne la table d'importation. L'adresse de la table d'importation est contenue dans le champ VirtualAddress du second membre du data directory. La table d'importation est en réalité un tableau de structures IMAGE_IMPORT_DESCRIPTOR. Chaque structure contient des informations au sujet d'une DLL à partir de laquelle le fichier PE importe des symboles (par "symbole", on entend variables ou fonctions importées). Par exemple, si le fichier PE importe des fonctions de dix DLL différentes, il y aura dix membres dans ce tableau. Ce dernier est terminé par un membre vide, rempli de zéros.

Nous pouvons maintenant examiner la structure en détail :

type

_IMAGE_IMPORT_DESCRIPTOR = record
OriginalFirstThunk: DWORD;
TimeDateStamp: DWORD;
ForwarderChain: DWORD;
Name1: DWORD;
FirstThunk: DWORD;
end;

TImageImportDescriptor = _IMAGE_IMPORT_DESCRIPTOR;

Cette structure n'étant pas définie dans les unités Delphi, nous aurons à la déclarer nous-mêmes pour pouvoir exploiter les informations contenues dans la table d'importation.

Le premier membre de la structure contient la RVA d'un tableau de structures IMAGE_THUNK_DATA. Qu'est-ce qu'une structure IMAGE_THUNK_DATA ? Il s'agit d'un record à parties variables de la taille d'un Integer (donc 4 octets). Habituellement, il est interprété comme un pointeur sur une structure IMAGE_IMPORT_BY_NAME. Notez qu'il s'agit bien d'un pointeur, et non de la structure elle-même. Voyez la chose sous cet angle : il y a plusieurs structures IMAGE_IMPORT_BY_NAME. Les RVA de ces structures (les IMAGE_THUNK_DATA) sont rassemblées dans un tableau, terminé par un élément nul. Puis la RVA de ce tableau est placée dans le membre OriginalFirstThunk.

La structure IMAGE_IMPORT_BY_NAME contient les informations d'une fonction importée, et est déclarée comme suit :

type

_IMAGE_IMPORT_BY_NAME = record
Hint: Word;
Name1: Byte;
end;

Cette structure n'étant pas définie dans les unités Delphi, nous aurons à la déclarer nous-mêmes pour pouvoir exploiter les informations contenues dans la table d'importation.

Hint contient l'index de la fonction concernée dans la table d'exportation de la DLL où elle réside. Ce champ est utilisé par le PE Loader pour qu'il puisse chercher rapidement la fonction dans la table d'exportation de sa DLL. Cette valeur n'est pas essentielle et certains lieurs lui assignent parfois une valeur nulle.
Name1 contient le nom de la fonction importée. Le nom est une chaîne de caractères à zéro terminal. Notez que le champ est déclaré comme de la taille d'un octet, mais c'est donc en réalité un champ de taille variable. Étant donné qu'il n'est pas possible de représenter un champ de taille variable dans un record, ce dernier fournit donc le moyen de vous référer aux éléments de la structure de manière nominative.

Dans nos unités Delphi nous n'utiliserons pas cette structure directement dans les processus de lecture ; nous redéfinirons donc Name1 comme un string afin de pouvoir l'utiliser plus aisément.

Revenons à la structure IMAGE_IMPORT_DESCRIPTOR :
TimeDateStamp et ForwarderChain sont des champs d'utilité assez avancée : nous en reparlerons après avoir présenté les autres membres.
Name1 contient la RVA vers le nom de la DLL. Cette chaîne est une chaîne à zéro terminal.
FirstThunk est très similaire à OriginalFirstThunk, c'est-à-dire qu'il contient également une RVA vers un tableau de structures IMAGE_THUNK_DATA. À vrai dire, ces deux tableaux sont même initialement identiques : chaque élément des deux tableaux pointe vers le même IMAGE_IMPORT_DESCRIPTOR.

Tableaux initiaux dans le fichier

Quel est alors l'intérêt d'avoir ces deux tableaux ? La différence se joue au chargement du fichier en mémoire par le PE Loader. Lorsque ce dernier lira la table d'importation du fichier PE, il remplacera en mémoire les RVA du tableau pointé par FirstThunk par les adresses réelles dans les DLL des fonctions importées.
Il y aura ainsi deux tableaux : le premier, pointé par OriginalFirstThunk et resté inchangé (on en comprend ainsi bien le nom) qui permet de conserver les informations de nom des fonctions importées, et le second tableau pointé par FirstThunk qui sert lors de l'exécution pour retrouver la localisation du code des fonctions importées.

Tableaux une fois le fichier PE chargé

Il existe un léger problème avec ce schéma d'organisation. Certaines fonctions ne sont exportées que par Index. Cela signifie que vous n'appelez pas les fonctions par leur nom, mais par leur position. Dans ce cas, il n'y aura pas de structure IMAGE_IMPORT_BY_NAME pour ces fonctions dans le module appelant. À la place, la structure IMAGE_THUNK_DATA pour cette fonction contiendra l'index de la fonction dans le word de poids faible, et son bit de poids fort sera égal à 1. Par exemple, si une fonction est exportée par index uniquement et que cet index vaut $1234, la valeur de IMAGE_THUNK_DATA pour cette fonction sera $80001234. Microsoft fournit une constante pour tester le bit de poids fort d'un DWord : IMAGE_ORDINAL_FLAG32. Sa valeur est $80000000.

Cette constante n'est pas déclarée dans Windows.pas, il nous faudra donc la déclarer nous-mêmes.

Supposons que vous souhaitiez lister toutes les fonctions importées par un fichier PE. Il vous faut alors suivre ces étapes :

1. Vérifiez que le fichier est un fichier PE valide.

2. À partir de l'entête DOS, rendez-vous à l'entête PE.

3. Récupérez l'adresse du data directory dans l'entête facultatif.

4. Rendez-vous au deuxième membre du data directory. Extrayez la valeur de VirtualAddress.

5. Utilisez cette valeur pour aller à la première structure IMAGE_IMPORT_DESCRIPTOR.

6. Vérifiez la valeur de OriginalFirstThunk. Si elle n'est pas nulle, l'utiliser pour se rendre au tableau de RVA. Si OriginalFirstThunk est nul, utiliser à la place la valeur de FirstThunk. Certains lieurs (dont apparemment tous les lieurs Borland) génèrent des fichiers PE avec un champ OriginalFirstThunk nul, ce que nous considèrerons comme un bug. Pour rester du côté le plus sécurisé, vérifiez en premier lieu la valeur de OriginalFirstThunk.

7. Pour chaque membre du tableau, vérifiez sa valeur avec IMAGE_ORDINAL_FLAG32. Si le bit de poids fort du membre est 1, alors la fonction est exportée par index et nous pouvons extraire cet index à partir du word de poids faible de la valeur du membre.

8. Si son bit de poids fort est nul, utilisez la valeur du membre comme une RVA vers une structure IMAGE_IMPORT_BY_NAME. Passez le champ Hint, et vous êtes alors au nom de la fonction.

9. Passez au membre suivant du tableau, et retrouvez les noms jusqu'à ce que la fin du tableau soit atteinte (membre nul). Maintenant que vous avez extrait les noms de toutes les fonctions, vous pouvez passer à la DLL suivante.

10. Rendez-vous à la structure IMAGE_IMPORT_DESCRIPTOR suivante et traitez-la. Répétez ces opérations jusqu'à la fin du tableau (terminé par une structure entièrement remplie de zéros).

Exemple

Une classe TImportTable a été ajoutée au fichier PE.pas. Elle permet de lire la table d'importation d'un fichier PE et d'en stocker toutes les fonctions importées. Celles-ci sont alors accessibles par index ou par nom grâce à des propriétés ou des méthodes de la classe.

En voici la déclaration :

Déclaration de la classe TImportTable

TImportTable = class
private
fDLL: Array of TImportedDLL;
fDLLCount: Integer;
fFCount: Integer;

procedure LireTable(Fichier: String);

function GetDLL(Index: Integer): TImportedDLL;
function GetFunction(DLL, Index: Integer): TImageImportByName;
procedure SetDLL(Index: Integer; const Value: TImportedDLL);
procedure SetFunction(DLL, Index: Integer; const Value: TImageImportByName);
public
constructor Create(Fichier: String);

function IsFunctionImported(Name: String; Hint: WORD = 0): Boolean;
function IsDLLImported(DLL: String): Boolean;
function FindDLLByName(Name: String): Integer;
function FindFunctionByName(Name: String; out DLL: Integer): Integer;

property DLLs[Index: Integer]: TImportedDLL read GetDLL write SetDLL;
property Functions[DLL, Index: Integer]: TImageImportByName read GetFunction write SetFunction;
property DLLCount     : Integer read fDLLCount write fDLLCount;
property FunctionCount: Integer read fFCount   write fFCount;
end;

La procédure qui nous intéresse ici est la procédure LireTable, qui se charge de lire le fichier PE à analyser pour en extraire les informations sur les DLL et les fonctions importées.

Cette procédure suit les étapes données précédemment :

Procédure de lecture de la table d'importation

procedure TImportTable.LireTable(Fichier: String);
var
FichierPE  : TMemoryStream; //Utilisation d'un MemoryStream pour avoir le pointeur Memory
EnteteMZ   : TImageDosHeader;
EntetePE   : TImageNtHeaders;
DataDir    : TImageDataDirectory;
ImportDescs: Array of TImageImportDescriptor;
i, j       : Integer;
k          : Byte;
Nom        : pChar;
Hint       : WORD;
ThunkData  : Cardinal;
begin
FichierPE := TMemoryStream.Create;
FichierPE.LoadFromFile(Fichier);
try
//On se rend à l'entête PE
FichierPE.Read(EnteteMZ, SizeOf(EnteteMZ));
FichierPE.Seek(EnteteMZ._lfanew, soFromBeginning);
FichierPE.Read(EntetePE, SizeOf(EntetePE));

//Dans l'entête facultatif, on récupère le deuxième Data Directory
DataDir := EntetePE.OptionalHeader.DataDirectory[1];

//Grâce à l'adresse contenue  dans le Data Directory on récupère tous les Import Descriptors
FichierPE.Seek(RVAToFileOffset(FichierPE, DataDir.VirtualAddress), soFromBeginning);

SetLength(ImportDescs, 1);
FichierPE.Read(ImportDescs[0], SizeOf(ImportDescs[0]));

//Lecture de tous les Import Descriptors. Un enregistrement vide indique la fin du tableau
while not IsZeroFilled(@ImportDescs[High(ImportDescs)], SizeOf(ImportDescs[High(ImportDescs)])) do
begin
SetLength(ImportDescs, Length(ImportDescs) + 1);
FichierPE.Read(ImportDescs[High(ImportDescs)], SizeOf(ImportDescs[High(ImportDescs)]));
end;

for i := 0 to High(ImportDescs) - 1 do
begin
FichierPE.Seek(RVAToFileOffset(FichierPE, ImportDescs[i].Name1), soFromBeginning);

//Recherche de la taille du nom (= recherche du zéro terminal de la chaine)
k := 0;
while Byte(Pointer(Integer(FichierPE.Memory) + FichierPE.Position + k)^) <> 0 do
Inc(k);

GetMem(Nom, k + 1);
FichierPE.Read(Nom^, k + 1);

//Ajout d'une DLL
SetLength(fDLL, Length(fDLL) + 1);
fDLL[High(fDLL)].Name := Nom;
SetLength(fDll[High(fDLL)].Functions, 0);

Inc(fDLLCount);

FreeMem(Nom);

j := 0;
ThunkData := 1;

while ThunkData <> 0 do
begin
//On vérifie d'abord OriginalFirstThunk, et on n'utilise FirstThunk qu'à défaut.
if ImportDescs[i].OriginalFirstThunk  <> 0 then
FichierPE.Seek(RVAToFileOffset(FichierPE, ImportDescs[i].OriginalFirstThunk) + Cardinal(j) * SizeOf(ThunkData), soFromBeginning)
else
FichierPE.Seek(RVAToFileOffset(FichierPE, ImportDescs[i].FirstThunk) + Cardinal(j) * SizeOf(ThunkData), soFromBeginning);

FichierPE.Read(ThunkData, SizeOf(ThunkData));

if ThunkData = 0 then
Break;

//Exportation par index seulement ?
if ThunkData and IMAGE_ORDINAL_FLAG32 <> 0 then
begin
//Ajout d'une fonction
SetLength(fDLL[High(fDLL)].Functions, Length(fDLL[High(fDLL)].Functions) + 1);
fDLL[High(fDLL)].Functions[High(fDLL[High(fDLL)].Functions)].Hint := Word(ThunkData);
fDLL[High(fDLL)].Functions[High(fDLL[High(fDLL)].Functions)].Name := '';

Inc(fFCount);
end
else
begin
FichierPE.Seek(RVAToFileOffset(FichierPE, ThunkData), soFromBeginning);
FichierPE.Read(Hint, SizeOf(Hint));

//Recherche de la taille du nom (= recherche du zéro terminal de la chaine)
k := 0;
while Byte(Pointer(Integer(FichierPE.Memory) + FichierPE.Position + k)^) <> 0 do
Inc(k);

GetMem(Nom, k + 1);
FichierPE.Read(Nom^, k + 1);

//Ajout d'une fonction
SetLength(fDLL[High(fDLL)].Functions, Length(fDLL[High(fDLL)].Functions) + 1);
fDLL[High(fDLL)].Functions[High(fDLL[High(fDLL)].Functions)].Hint := Hint;
fDLL[High(fDLL)].Functions[High(fDLL[High(fDLL)].Functions)].Name := Nom;

Inc(fFCount);

FreeMem(Nom);
end;

Inc(j);
end;
end;
finally
SetLength(ImportDescs, 0);
FichierPE.Free;
end;
end;

Cette procédure en utilise deux autres, également déclarées dans PE.pas : IsZeroFilled et RVAToFileOffset.
La première ne fait que comparer un certain nombre d'octets à partir du pointeur fourni pour vérifier que la mémoire est bien à zéro à cet endroit. En voici l'implémentation :

function IsZeroFilled(Mem: pByte; Taille: Integer): Boolean;
begin
Result := False;

while (pByte(Integer(Mem) + Taille - 1)^ = 0) and (Taille >= 0) do
Dec(Taille);

if Taille = -1 then
Result := True;
end;

RVAToFileOffset est plus importante, et resservira sans aucun doute par la suite. Elle permet de convertir une adresse virtuelle relative en offset fichier.
L'astuce est simple : nous avons vu dans le tutoriel précédent que la table des sections fournit à la fois la RVA et l'offset fichier de chacune des sections. Nous allons donc comparer la RVA donnée à la RVA de début et de fin de chaque section. Une fois que nous saurons à l'intérieur de quelle section mène notre RVA, il restera à calculer l'offset fichier correspondant à partir de l'offset fichier du début de la section.

Voici le code résultant de cette analyse :

Conversion d'une RVA en offset fichier

function RVAToFileOffset(FichierPE: TStream; RVA: Cardinal): Cardinal;
var
OrgPos: Int64;
EnteteMZ: TImageDosHeader;
EntetePE: TImageNtHeaders;
Section : TImageSectionHeader;
i, Nbr  : Integer;
begin
(*
Par défaut on renvoie la RVA donnée en paramètre, dans le cas où celle-ci
pointe sur une zone qui n'est pas mappée en mémoire (et qui est donc déjà
un offset de fichier)
*)
Result := RVA;

//Sauvegarde de la position actuelle du stream
OrgPos := FichierPE.Position;
try
FichierPE.Seek(0, soFromBeginning);
FichierPE.Read(EnteteMZ, SizeOf(EnteteMZ));
FichierPE.Seek(EnteteMZ._lfanew, soFromBeginning);
FichierPE.Read(EntetePE, SizeOf(EntetePE));

//Nombre de sections dans le fichier
Nbr := EntetePE.FileHeader.NumberOfSections;

if Nbr <> 0 then
for i := 0 to Nbr - 1 do
begin
FichierPE.Read(Section, SizeOf(Section));

//Si la RVA fournie se situe dans la section actuelle
if (RVA >= Section.VirtualAddress) and (RVA < Section.VirtualAddress + Section.SizeOfRawData) then
begin
//On calcule l'offset de fichier à partir des informations d'adresses de la section
Result := RVA - Section.VirtualAddress + Section.PointerToRawData;
Break;
end;
end;
finally
//Retour à la position initiale
FichierPE.Seek(OrgPos, soFromBeginning);
end;
end;

Vous trouverez ci-dessous en téléchargement une application qui liste toutes les DLL importées, et les fonctions qui y sont utilisées.

Téléchargements

Vous pouvez télécharger le projet utilisant cette classe ici :
miroir 1
miroir 2 (si le miroir 1 ne fonctionne pas)

Vous aurez également besoin du fichier PE.pas :
miroir 1
miroir 2 (si le miroir 1 ne fonctionne pas)

Placez PE.pas dans un répertoire de votre choix et dézippez l'application dans un sous-répertoire de celui-ci pour pouvoir compiler.
Vous pouvez écraser l'unité PE du tutoriel précédent.

Liens

- MSDN : Image Help Library

- MSDN : IMAGE_DATA_DIRECTORY

Tutoriel suivant : La table d'exportation

(1)Pour rappel : RVA signifie Relative Virtual Address. Il s'agit d'une adresse mémoire relative à l'origine en mémoire du fichier PE. Voir ici pour plus de détails.