PARTIE 7 : La table d'exportation

Iczelion

Traducteur

Traduction et adaptation par Olivier Lance

Ce tutoriel est le dernier de la série écrite par Iczelion au sujet du format PE.
Il concerne la table d'exportation, qui permet à des fichiers PE d'exposer des fonctions qui pourront être incluses dans les tables d'importation d'autres fichiers.

Article original : Tutorial 7: Export Table

Retour à la liste des tutoriels

Tutoriel précédent : La table d'importation

Page suivante : Conclusion aux tutoriels d'Iczelion

- Titre : PARTIE 7 : La table d'exportation

- Auteur : Iczelion

- Traducteur : Traduction et adaptation par Olivier Lance

- Parution : 7 février 2006

- 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 ® 2006 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

Quand le PE Loader exécute un programme, il charge les DLL associées dans l'espace d'adressage du processus. Il extrait ensuite les informations des fonctions importées à partir du programme principal. Il utilise ces informations pour rechercher les adresses de ces fonctions dans les DLL, afin de les renseigner dans le code du processus. L'endroit où le PE Loader cherchera ces adresses se trouve être la table d'exportation.

Il existe deux manières pour une DLL ou un EXE d'exporter une fonction afin de la rendre utilisable par un programme externe : elle peut être exportée par nom, ou uniquement par ordinal.
Admettons qu'une DLL possède une fonction nommée "GetSysConfig". Elle peut choisir d'indiquer aux autres DLL/EXE que s'ils veulent appeler cette fonction, ils doivent spécifier son nom, c'est-à-dire "GetSysConfig". L'autre méthode est d'exporter par ordinal.
Un ordinal est un nombre de 16 bits qui identifie de manière unique une fonction au sein d'une DLL. Ce nombre n'est unique que dans le cadre de la DLL. Par exemple, si l'on reprend l'exemple précédent, la DLL peut choisir d'exporter GetSysConfig par ordinal, 16 par exemple. Ensuite les autres DLL/EXE qui voudront appeler cette fonction devront spécifier le nombre 16 dans GetProcAddress(1). Cette méthode constitue la méthode d'exportation par ordinal uniquement.

L'exportation uniquement par ordinal est vivement déconseillée car elle peut causer des problèmes de maintenance pour les DLL. En effet si la DLL est mise à jour ou modifiée, le développeur de cette DLL ne peut changer les ordinaux des fonctions, sans quoi les programmes dépendant de cette DLL ne pourront plus fonctionner.

Nous pouvons à présent examiner la structure liée aux exportations. Comme pour la table d'importation, la localisation de la table d'exportation peut être retrouvée dans le data directory. Ici, la table d'exportation en est le premier membre. La structure d'exportation est appelée IMAGE_EXPORT_DIRECTORY. Elle contient 11 champs, mais seuls quelques-uns sont réellement utilisés.

Nom

Contenu

nName

Le véritable nom du module. Ce champ est nécessaire car le nom du fichier peut être changé. Si tel est le cas, le PE Loader utilisera ce nom interne.

nBase

Un nombre qu'il faut soustraire aux ordinaux pour trouver l'index correspondant des le tableau d'adresses des fonctions.

NumberOfFunctions

Nombre total de fonctions/symboles exportés par ce module. Si ce nombre est nul, la RVA(2) vers la table d'exportation dans le data directory sera nulle.

NumberOfNames

Nombre de fonctions/symboles exportés par nom. Cette valeur n'est pas le nombre de toutes les fonctions exportées par le module. Cette valeur peut être nulle. Dans ce cas, le module n'exporte que par ordinal.

AddressOfFunctions

Une RVA pointant sur un tableau de RVA des fonctions du module.

AddressOfNames

Une RVA pointant sur un tableau de RVA des noms des fonctions du module.

AddressOfNameOrdinals

Une RVA pointant sur un tableau d'entiers de 16 bits représentant les ordinaux associés aux noms des fonctions du tableau AddressOfNames ci-dessus.

La simple lecture du tableau ci-dessus ne vous donnera peut-être pas la bonne image de la table d'exportation. L'explication suivante devrait vous clarifier le concept :

La table d'exportation existe pour être utilisée par le PE Loader. Premièrement, le module doit conserver les adresses de toutes les fonctions exportées quelque part afin que le PE Loader puisse les retrouver. Il les garde donc dans un tableau pointé par le champ AddressOfFunctions. Le nombre d'éléments de ce tableau se trouve dans NumberOfFunctions. Ainsi si le module exporte 40 fonctions, il doit y avoir 40 membres dans le tableau pointé par AddressOfFunctions et NumberOfFunctions doit contenir la valeur 40.
Ensuite, si certaines fonctions sont exportées par nom, le module doit garder les noms dans son fichier. Les RVA des noms sont donc enregistrées dans un autre tableau. Ce tableau est pointé par AddressOfNames et le nombre de noms est dans le champ NumberOfNames.
Pensez au travail du PE Loader : il connaît les noms des fonctions, il doit donc d'une manière ou d'une autre obtenir les adresses de ces fonctions. Jusqu'à maintenant, le module a deux tableaux : les noms et les adresses, mais il n'y a aucun lien entre eux. Il faut donc un moyen de relier les noms des fonctions aux adresses correspondantes. La spécification PE utilise les index dans le tableau d'adresses comme liens. Donc, si le PE Loader trouve le nom qu'il recherche dans le tableau de noms, il peut également obtenir son index dans le tableau d'adresses. Les index sont conservés dans un autre tableau (le dernier), pointé par le champ AddressOfNameOrdinals. Puisque ce tableau sert de liaison entre les noms et les adresses, il doit nécessairement avoir le même nombre d'éléments que le tableau de noms, c'est-à-dire que chaque nom ne peut avoir qu'une unique adresse associée. L'inverse n'est cependant pas vrai : une adresse peut avoir plusieurs noms associés, donnant ainsi la possibilité de créer des "alias" référençant la même adresse. Pour que la liaison soit correcte, les tableaux de noms et d'index doivent fonctionner en parallèle : le premier élément du tableau d'index doit contenir l'index de la première fonction du tableau de noms, etc.

Quelques exemples s'imposent. Si nous avons le nom d'une fonction exportée et que nos voulons récupérer son adresse dans le module, il faut suivre ces étapes :

1. Aller à l'entête PE.

2. Lire l'adresse virtuelle de la table d'exportation dans le data directory.

3. Aller à la table d'exportation et obtenir le nombre de noms (NumberOfNames).

4. Parcourir le tableau pointé par AddressOfNames et AddressOfNameOrdinals en parallèle, jusqu'à trouver le bon nom. Si le nom est trouvé dans le tableau AddressOfNames, extraire la valeur dans l'élément associé du tableau AddressOfNameOrdinals. Par exemple si vous trouvez la RVA du nom recherché dans le 77e élément du tableau de noms, vous devez extraire la valeur du 77e élément du tableau d'ordinaux. Si NumberOfNames éléments ont été parcourus sans trouver le nom que recherché, la fonction n'est pas dans ce module.

5. Utiliser la valeur venant du tableau AddressOfNameOrdinals comme index dans le tableau AddressOfFunctions. La valeur à cette position est la RVA de cette fonction.

Nous pouvons dorénavant porter notre attention sur le membre nBase de la structure IMAGE_EXPORT_DIRECTORY. Vous savez maintenant que le tableau AddressOfFunctions contient les adresses de tous les symboles exportés dans le module, et que le PE Loader utilise ces les index de ce tableau pour trouver les adresses des fonctions. Imaginons que nous utilisions les index de ce tableau comme ordinaux. Étant donné que les développeurs peuvent spécifier un ordinal de départ arbitraire, 200 par exemple, cela signifie qu'il y a aurait au moins 200 éléments dans le tableau AddressOfFunctions. Qui plus est les 200 premiers éléments ne seront pas utilisés, mais ils doivent exister pour que le PE Loader puisse utilise les index pour trouver les adresses correctes des fonctions. Cela ne constitue rien de bon.
Le membre nBase est là pour résoudre ce problème. Si le développeur décide d'utiliser un ordinal initial valant 200, nBase vaudra alors 200. Lorsque le PE Loader lit la valeur de nBase, il sait alors que les 200 premiers éléments n'existent pas et qu'il doit donc soustraire à l'ordinal la valeur contenue dans nBase pour obtenir le véritable index dans le tableau AddressOfFunctions. L'utilisation de nBase évite ainsi de fournir 200 éléments vides.

Notez que nBase n'affecte pas les valeurs du tableau AddressOfNameOrdinals. Malgré son nom, ce tableau contient les véritables index dans le tableau AddressOfFunctions, et non des ordinaux.

Le point étant fait sur ce champ nBase, nous pouvons passer à un nouvel exemple :
Supposons maintenant que nous possédons l'ordinal d'une fonction, et que nous avons besoin de l'adresse de celle-ci. Les étapes sont alors les suivantes :

1. Aller à l'entête PE.

2. Récupérer la RVA de la table d'exportation dans le data directory.

3. Aller à la table d'exportation et récupérer la valeur de nBase.

4. Soustraire à l'ordinal la valeur de nBase ; cela nous donne l'index dans le tableau AddressOfFunctions.

5. Comparer l'index avec la valeur de NumberOfFunctions. Si l'index est plus grand ou égal au nombre de fonctions, l'ordinal est invalide.

6. Utiliser l'index pour obtenir la RVA de la fonction dans le tableau AddressOfFunctions.

Notez qu'obtenir l'adresse d'une fonction à partir de son ordinal est beaucoup plus simple et beaucoup plus rapide que d'utiliser le nom de la fonction. Il n'y a pas besoin de parcourir les tableaux AddressOfNames ou AddressOfNameOrdinals. Le gain de performance est toutefois atténué par la difficulté de maintenance du code due à l'utilisation des ordinaux.

Pour résumer, si vous souhaitez obtenir l'adresse d'une fonction à partir de son nom, vous devez parcourir les deux tableaux AddressOfNames et AddressOfNameOrdinals pour avoir l'index de l'adresse dans le tableau AddressOfFunctions. Si vous avez l'ordinal de la fonction, vous pouvez directement chercher dans AddressOfFunctions après avoir soustrait nBase à l'ordinal.

Si une fonction est exportée par nom, vous pouvez utiliser indifféremment son nom ou son ordinal avec l'API GetProcAddress.
Mais si la fonction n'est exportée que par ordinal ? C'est ce dont nous allons parler à présent.
"Une fonction n'est exportée que par ordinal" signifie que la fonction ne possède aucune entrée dans les tableaux AddressOfNames et AddressOfNameOrdinals. Rappelez-vous les deux champs NumberOfFunctions et NumberOfNames. La présence de ces deux champs montre à l'évidence que certaines fonctions n'ont pas de nom. Le nombre de fonctions doit être au moins égal au nombre de noms. Les fonctions qui n'ont pas de nom sont exportées par ordinal uniquement. Ainsi, s'il y a par exemple 70 fonctions exportées mais seulement 40 entrées dans le tableau AddressOfNames, alors il y a 30 fonctions dans le module qui ne sont exportées que par leur ordinal.
Maintenant, comment trouver quelles sont les fonctions qui ne sont exportées que par ordinal ? Ce n'est pas si simple. Il faut procéder par élimination : les entrées dans le tableau AddressOfFunctions qui ne sont pas référencées par le tableau AddressOfNameOrdinals contiennent les RVA des fonctions exportées par ordinal uniquement.

Exemple

Notre unité PE.pas se trouve augmentée d'une nouvelle classe : TExportTable. Vous vous en doutez, elle permet de récupérer toutes les fonctions exportées d'un fichier PE.
Voici la déclaration de cette classe :

Classe TExportTable

TExportedFunction = record
Name: String;
Ordinal: Word;
Address: Cardinal;
end;

type
TExportTable = class
private
fFunctions: array of TExportedFunction;
function GetCount: Integer;

procedure LireTable(Fichier: String);

function GetFunction(Index: Integer): TExportedFunction;
procedure SetFunction(Index: Integer; const Value: TExportedFunction);
public
constructor Create(Fichier: String);

function IsFunctionExported(Name: string; Ordinal: SmallInt = -1) : Boolean;
function FindFunctionOrdinal(Name: String): SmallInt;
function FindFunctionName(Ordinal: SmallInt): String;
function GetFunctionIndex(Name: String; Ordinal: SmallInt = -1): Integer;

property Functions[Index: Integer]: TExportedFunction read GetFunction write SetFunction; default;
property Count                    : Integer           read GetCount;
end;

Quelques méthodes permettent de trouver l'ordinal d'une fonction en connaissant son nom et vice versa, ou bien de connaitre l'index d'une fonction en donnant son nom ou son ordinal.

La méthode qui se charge d'extraire les fonctions du fichier est la méthode LireTable :

procedure TExportTable.LireTable(Fichier: String);
var
Stream    : TMemoryStream;
EnteteMZ  : TImageDosHeader;
EntetePE  : TImageNtHeaders;
ExportDir : TImageExportDirectory;
i, NamePos: Cardinal;
j         : Integer;
Address   : Cardinal;
Presente  : Boolean;
begin
SetLength(fFunctions, 0);

Stream := TMemoryStream.Create;
try
Stream.LoadFromFile(Fichier);

//Récupération des entêtes MZ et PE
Stream.Read(EnteteMZ, SizeOf(EnteteMZ));
Stream.Seek(EnteteMZ._lfanew, soFromBeginning);
Stream.Read(EntetePE, SizeOf(EntetePE));

//On se rend au début de la table d'exportation
Stream.Seek(RVAToFileOffset(Stream, EntetePE.OptionalHeader.DataDirectory[0].VirtualAddress), soFromBeginning);
Stream.Read(ExportDir, SizeOf(ExportDir));

//On retrouve toutes les fonctions
if ExportDir.NumberOfFunctions <> 0 then
begin
Stream.Seek(RVAToFileOffset(Stream, Cardinal(ExportDir.AddressOfNames)), soFromBeginning);

if ExportDir.NumberOfNames <> 0 then
for i := 0 to ExportDir.NumberOfNames - 1 do
begin
//Nouvelle fonction
SetLength(fFunctions, Length(fFunctions) + 1);

//On se positionne dans le tableau de noms
Stream.Seek(RVAToFileOffset(Stream, Cardinal(ExportDir.AddressOfNames)) + i * SizeOf(NamePos), soFromBeginning);

//Lecture de la RVA du nom
Stream.Read(NamePos, SizeOf(NamePos));
//Lecture du nom
Stream.Seek(RVAToFileOffset(Stream, NamePos), soFromBeginning);

//Recherche de la taille du nom (= recherche du zéro terminal de la chaîne) et lecture
j := 0;
while Byte(Pointer(Integer(Stream.Memory) + Stream.Position + j)^) <> 0 do
Inc(j);

SetLength(fFunctions[High(fFunctions)].Name, j);
Stream.Read(fFunctions[High(fFunctions)].Name[1], j);

//On récupère l'ordinal qui correspond (il se situe au même index que le nom)
Stream.Seek(RVAToFileOffset(Stream, Cardinal(ExportDir.AddressOfNameOrdinals)) + i * SizeOf(Word), soFromBeginning);
Stream.Read(fFunctions[High(fFunctions)].Ordinal, SizeOf(Word));
fFunctions[High(fFunctions)].Ordinal := fFunctions[High(fFunctions)].Ordinal + ExportDir.Base;

//Enfin, on récupère l'adresse de la fonction
Stream.Seek(RVAToFileOffset(Stream, Cardinal(ExportDir.AddressOfFunctions)) +
(fFunctions[High(fFunctions)].Ordinal - ExportDir.Base) * SizeOf(Cardinal), soFromBeginning);
Stream.Read(fFunctions[High(fFunctions)].Address, SizeOf(Cardinal));
end;

//Cas des fonctions exportées par ordinal seulement :
//chaque adresse de AddressOfFunctions n'ayant pas son correspondant dans le tableau Fonctions est ajoutée
if ExportDir.NumberOfFunctions > ExportDir.NumberOfNames then
for i := 0 to ExportDir.NumberOfFunctions do
begin
Stream.Seek(RVAToFileOffset(Stream, Cardinal(ExportDir.AddressOfFunctions)) + i * SizeOf(Cardinal), soFromBeginning);

Stream.Read(Address, SizeOf(Address));

//On cherche si l'adresse a déjà été récupérée
Presente := False;
for j := Low(fFunctions) to High(fFunctions) do
if fFunctions[j].Address = Address then
begin
Presente := True;
Break;
end;

//Si non et qu'elle n'est pas nulle, on ajoute la fonction
if not Presente and (Address <> 0) then
begin
SetLength(fFunctions, Length(fFunctions) + 1);

fFunctions[High(fFunctions)].Address := Address;
fFunctions[High(fFunctions)].Name     := '';
fFunctions[High(fFunctions)].Ordinal := i + ExportDir.Base;
end;
end;
end;

finally
Stream.Free;
end;
end;

Le fonctionnement est simple : après avoir lu la table d'exportation, on parcourt tous les noms dans le tableau AddressOfNames et on récupère les adresses correspondantes dans AddressOfFunctions en utilisant AddressOfNameOrdinals. Une fois cela fait, il reste à trouver les fonctions qui ne sont exportées que par ordinal. Pour cela, on parcourt tout le tableau AdressOfFunctions et pour chaque adresse qui n'est pas déjà présente dans les adresses récupérées, on crée une nouvelle entrée dans notre tableau de fonctions exportées.

Toutes les fonctions sont ainsi enregistrées et prêtes à être exploitées.

Téléchargements

Vous pouvez télécharger le projet utilisant cette classe ici. Il liste les fonctions exportées ainsi que leur ordinal et leur adresse :
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

Page suivante : Conclusion aux tutoriels d'Iczelion

(1)GetProcAddress est l'API utilisée pour retrouver l'adresse d'une fonction dans une DLL lors d'une liaison dynamique. Voir : Création et utilisation de DLL avec Delphi pour plus d'informations.

(2)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.