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 :
- Aller à l'entête PE.
- Lire l'adresse virtuelle de la table d'exportation dans le data directory.
- Aller à la table d'exportation et obtenir le nombre de noms (NumberOfNames).
- 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.
- 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 :
- Aller à l'entête PE.
- Récupérer la RVA de la table d'exportation dans le data directory.
- Aller à la table d'exportation et récupérer la valeur de nBase.
- Soustraire à l'ordinal la valeur de nBase ; cela nous donne l'index dans le tableau AddressOfFunctions.
- Comparer l'index avec la valeur de NumberOfFunctions. Si l'index est plus grand ou égal au nombre de fonctions, l'ordinal est invalide.
- 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 :
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▲
Page suivante : Conclusion aux tutoriels d'Iczelion