PE-Header: Importierte Module einer DLL oder EXE-Datei

Ausführbare Dateien (Programme und DLLs) werden unter Windows im PE-Format (Portable Executable) gespeichert. Der Aufbau einer PE-Datei hat dabei große Ähnlichkeit mit der Organisation der Daten im Speicher, nachdem das Programm bzw. die Bibliothek geladen wurde. So gibt es mehrere Sections, die beispielsweise die Programmdaten ("text"), initialisierte Daten ("data"), Daten die beim Starten auf 0 gesetzt werden ("bss") oder auch Ressourcen (Dialoge, Bitmaps etc.) enthalten. Eine dieser Sections beinhaltet alle importierten Symbole, also Funktionen, die in anderen DLLs enthalten sind und zum Verwenden der ausführbaren Datei notwendig sind (man könnte sie "externe Abhängigkeiten" nennen). Um diese soll es hier gehen.

Von Headern, Directories, Deskriptoren und Thunks

Wenn man das erste mal mit dem PE-Format zu tun hat fühlt man sich ein bißchen wie ein Wanderer in der Wüste. Nach jeder Düne erhofft man sich sein Ziel und findet zunächst einmal eine weitere Düne. So in etwa geht es mit den Verweisen auf Verweise im PE-Header. Beim PE-Header hat man den großen Vorteil, daß es eine sehr gute Karte gibt, an der man sich orientieren kann. In diesem Fall handelt es sich um einige structs aus der Datei winnt.h. Da man in der Regel mit Zeigern auf Strukturen arbeitet, die man über einen vorhandenen Speicherbereich legt, empfiehlt sich der Einsatz von Memory-Mapped Files. Dabei wird eine Datei in den Adressraum des Prozesses gemappt und fühlt sich wie ein einziger großer Datenbereich an. Man kann also per Zeiger darauf zugreifen und erhält das, was an der entsprechenden Stelle in der Datei steht. Mehr zum Thema findet sich in der MSDN, Stichwort "File Mapping" (einfach dort in die Suche eintippen).

Zu Beginn einer DLL oder EXE-Datei steht ein DOS-Header (PIMAGE_DOS_HEADER):

typedef struct _IMAGE_DOS_HEADER {
        WORD e_magic;
        WORD e_cblp;
        WORD e_cp;
        WORD e_crlc;
        WORD e_cparhdr;
        WORD e_minalloc;
        WORD e_maxalloc;
        WORD e_ss;
        WORD e_sp;
        WORD e_csum;
        WORD e_ip;
        WORD e_cs;
        WORD e_lfarlc;
        WORD e_ovno;
        WORD e_res[4];
        WORD e_oemid;
        WORD e_oeminfo;
        WORD e_res2[10];
        LONG e_lfanew;
} IMAGE_DOS_HEADER,*PIMAGE_DOS_HEADER;

Dabei interessiert zunächst einmal das Element e_magic, in diesem muß der Magic Marker 0x5A4D (IMAGE_DOS_SIGNATURE) stehen, sonst handelt es sich nicht um eine für uns verwertbare Datei. Bei genauerem Hinsehen bemerkt man, daß 0x5A und 0x4D die beiden ASCII-Zeichen 'Z' und 'M' sind. Bedenkt man die Byte Order (Little Endian für i386) ist dies die Zeichenkette "MZ", die vielleicht der eine oder andere bereits gesehen hat, wenn er eine EXE-Datei in einem Hex-Editor geöffnet hat. Es handelt sich um die Initialen von Mark Zbikowski, einem Microsoft-Entwickler der als Schöpfer des EXE-Formats unter DOS gilt.

Als nächstes ist e_lfanew interessant. Dieses Element beinhaltet den Offset des Image Headers vom Beginn der Datei. Der Image Header (PIMAGE_NT_HEADERS) erscheint relativ kurz:

typedef struct _IMAGE_NT_HEADERS {
        DWORD Signature;
        IMAGE_FILE_HEADER FileHeader;
        IMAGE_OPTIONAL_HEADER OptionalHeader;
} IMAGE_NT_HEADERS,*PIMAGE_NT_HEADERS;

Hier ist wiederum eine Signatur zu finden, die in unserem Fall den Wert 0x00004550 (IMAGE_NT_SIGNATURE) enthalten sollte. Eine ASCII-Tabelle verrät uns wiederum, daß es sich hierbei um die Zeichen "PE" handelt. Die beiden anderen Elemente sind selbst wiederum Strukturen, von denen uns OptionalHeader interessiert:

typedef struct _IMAGE_OPTIONAL_HEADER {
        WORD Magic;
        BYTE MajorLinkerVersion;
        BYTE MinorLinkerVersion;
        DWORD SizeOfCode;
        DWORD SizeOfInitializedData;
        DWORD SizeOfUninitializedData;
        DWORD AddressOfEntryPoint;
        DWORD BaseOfCode;
        DWORD BaseOfData;
        DWORD ImageBase;
        DWORD SectionAlignment;
        DWORD FileAlignment;
        WORD MajorOperatingSystemVersion;
        WORD MinorOperatingSystemVersion;
        WORD MajorImageVersion;
        WORD MinorImageVersion;
        WORD MajorSubsystemVersion;
        WORD MinorSubsystemVersion;
        DWORD Reserved1;
        DWORD SizeOfImage;
        DWORD SizeOfHeaders;
        DWORD CheckSum;
        WORD Subsystem;
        WORD DllCharacteristics;
        DWORD SizeOfStackReserve;
        DWORD SizeOfStackCommit;
        DWORD SizeOfHeapReserve;
        DWORD SizeOfHeapCommit;
        DWORD LoaderFlags;
        DWORD NumberOfRvaAndSizes;
        IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER,*PIMAGE_OPTIONAL_HEADER;

Hier verdient das Element DataDirectory unser besonderes Augenmerk. Es handelt sich um ein Array aus Strukturen vom Typ IMAGE_DATA_DIRECTORY:

typedef struct _IMAGE_DATA_DIRECTORY {
        DWORD VirtualAddress;
        DWORD Size;
} IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY;

Es gibt eine Reihe von Konstanten die einem den Umgang mit diesem Array erleichtern. Sie dienen als Index in das Array um einen bestimmten Eintrag zu finden. Sie tragen Namen wie IMAGE_DIRECTORY_ENTRY_EXPORT, IMAGE_DIRECTORY_ENTRY_IMPORT und IMAGE_DIRECTORY_ENTRY_RESOURCE. Wir wollen den Eintrag für IMAGE_DIRECTORY_ENTRY_IMPORT verwenden. Dessen Element VirtualAddress hilft uns weiter. Es handelt sich dabei um eine RVA, eine Relative Virtual Address. Relativ deshalb, weil sie einen Offset auf eine feste Basis-Adresse darstellt. Nachdem die Datei in den Speicher geladen wurde, erreicht man die Einträge indem man die Basis-Adresse nimmt, und die RVA dazuzählt. Solange die Datei nicht geladen ist, also als Datei vorliegt, kann ein RVA nicht ohne weiteres verwendet werden. Der Grund dafür ist, daß die Datei möglichst platzsparend organisiert ist, während das Abbild im Speicher an irgendwelchen Speichergrenzen ausgerichtet sein muß. Deshalb kann es sein, daß das Alignment unterschiedlich ist. Wir verwenden die RVA zunächst direkt als Vergleichswert, um die Section zu finden, in die verwiesen wird. Dazu verwende ich in meinem Programm folgenden Code:

// Get the Relative Virtual Address of the Import Entry of the Image Directory
ImportDataRva = pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;

// Get the pointer to the first Image Section
pCurrentSection = (PIMAGE_SECTION_HEADER)IMAGE_FIRST_SECTION(pNtHeaders);

// Walk through the sections until the Imports are found (or we are running out of sections)
nSectionCounter = pNtHeaders->FileHeader.NumberOfSections;
while ((boFound == false) && (nSectionCounter-- > 0))
{
    if (   (pCurrentSection->VirtualAddress <= ImportDataRva)
        && (pCurrentSection->VirtualAddress + pCurrentSection->Misc.VirtualSize > ImportDataRva))
    {
        // Found the correct section
        boFound = true;
    }
    else
    {
        // Proceed to next section
        pCurrentSection++;
    }
}

IMAGE_FIRST_SECTION liefert einen Zeiger auf den ersten Eintrag aus der Liste der Section-Header. Hierbei handelt es sich um ein Array aus Strukturen vom Typ IMAGE_SECTION_HEADER, das hinter dem OptionalHeader liegt. Ein Eintrag sieht folgendermaßen aus:

typedef struct _IMAGE_SECTION_HEADER {
        BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
        union {
                DWORD PhysicalAddress;
                DWORD VirtualSize;
        } Misc;
        DWORD VirtualAddress;
        DWORD SizeOfRawData;
        DWORD PointerToRawData;
        DWORD PointerToRelocations;
        DWORD PointerToLinenumbers;
        WORD NumberOfRelocations;
        WORD NumberOfLinenumbers;
        DWORD Characteristics;
} IMAGE_SECTION_HEADER,*PIMAGE_SECTION_HEADER

Die Schleife durchläuft nun also dieses Array, auf der Suche nach der Section, in der die Import-Daten liegen. Dabei wird durch Vergleich herausgefunden, ob ImportDataRva größer als die VirtualAddress der Section und kleiner als das Ende dieser ist, also "satt" drin liegt. Ist dies der Fall wird ein Flag gesetzt, andernfalls mit der nächsten Section fortgesetzt, bis alle durchsucht wurden.

Wenn die Section erfolgreich gefunden wurde, kann aus der VirtualAddress und dem PointerToRawData ein unglaublich nützlicher Wert errechnet werden. PointerToRawData gibt den Beginn der Daten als Offset zum Beginn der Datei an, während VirtualAddress den RVA zum Beginn der Section (vom Beginn des Abbilds wenn die Datei in den Speicher geladen wurde) angibt. Dieser Offset ermöglicht uns also im Folgenden die Übersetzung von RVAs dieser Section in Offsets relativ zum Beginn der Datei:

// Calculate section offset
dwSectionOffset = pCurrentSection->VirtualAddress - pCurrentSection->PointerToRawData;

// Get pointer to first Import Descriptor
pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)(Mapping.pBaseAddr + ImportDataRva - dwSectionOffset);

Im nächsten Schritt wird ein Zeiger auf den ersten Import Descriptor ermittelt. Damit sind wir schon sehr nahe am Ziel! Es gibt ein Array aus Strukturen vom Typ IMAGE_IMPORT_DESCRIPTOR, die nun der Reihe nach besucht werden. Diese Struktur sieht so aus:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
        _ANONYMOUS_UNION union {
                DWORD Characteristics;
                DWORD OriginalFirstThunk;
        } DUMMYUNIONNAME;
        DWORD TimeDateStamp;
        DWORD ForwarderChain;
        DWORD Name;
        DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR,*PIMAGE_IMPORT_DESCRIPTOR;

Das Ende des Arrays wird durch einen Eintrag gekennzeichnet, bei dem alle Einträge 0 sind. Man kann also in einer Schleife solange pImportDesc inkrementieren, bis z.B. pImportDesc->Name den Wert 0 hat. Das Element Name verweist hierbei auf den Namen der referenzierten Datei. Es handelt sich wieder um eine RVA die zur Basis-Adresse des File-Mappings addiert und um dwSectionOffset verringert auf einen nullterminierte ASCII-String zeigt:

// Save current import name as std::string
DllName = (const char*)(Mapping.pBaseAddr + pImportDesc->Name - dwSectionOffset);

An die Namen der von einem Programm oder einer DLL benötigten weiteren DLLs kommen wir nun bereits dran. Wenn wir noch einen Schritt weiter gehen, erhalten wir dazu noch die Namen der importierten Funktionen. Dazu greifen wir nicht in das Element Name, sondern FirstThunk und erhalten einen Zeiger auf ein Array aus Strukturen des Typs IMAGE_THUNK_DATA32:

typedef struct _IMAGE_THUNK_DATA32 {
        union {
                DWORD ForwarderString;
                DWORD Function;
                DWORD Ordinal;
                DWORD AddressOfData;
        } u1;
} IMAGE_THUNK_DATA32,*PIMAGE_THUNK_DATA32;

Die Elemente sind wiederum RVAs. Das Ende des Arrays ist wieder dadurch gekennzeichnet, daß der Eintrag 0 ist. Es kann also in einer Schleife über diese IMAGE_THUNK_DATAs gelaufen werden. Allerdings muß bei der Auswertung folgendes beachtet werden: ist das Bit IMAGE_ORDINAL_FLAG (das höchstwertige Bit, also 0x80000000) gesetzt, wird die Funktion über die Ordinalzahl importiert. Das ist so etwas wie die Angabe "die fünfte von der DLL exportierte Funktion". In diesem Fall muß das Bit maskiert werden und man erhält eine relativ kleine Zahl, eben diese Ordinalzahl. Wenn das Bit nicht gesetzt ist, ist das Element Function gültig. Folgt man diesem RVA kommt man zu einer Struktur vom Typ IMAGE_IMPORT_BY_NAME:

typedef struct _IMAGE_IMPORT_BY_NAME {
        WORD Hint;
        BYTE Name[1];
} IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME;

Nicht durch den Typ verwirren lassen, hinter dem Element Name verbirgt sich tatsächlich der Name der Funktion als nullterminierter ASCII-String!

Die Beispielprogramme

Die Beispiele zerfallen in drei Teile: eine Sammlung von Funktionen, die eine Liste der importierten Module bzw. Funktionen ermitteln (ImportList.cpp und ImportList.h), eine Demo-Anwendung für die Konsole (ImportListDemo.cpp) und eine Demo-Anwendung mit graphischer Oberfläche (ImportListDemoGui.cpp). Die Wahl fiel auf C++ als Sprache um von den Containern der STL Gebrauch machen zu können. Es werden intensiv std::string und std::vector benutzt.

Die Funktionen zur Ermittlung der Liste sind in einem Namespace ImportList enthalten. Von Außen wird lediglich die Funktion ImportList::GetList aufgerufen und über einen Parameter angewiesen, entweder eine Liste der importierten Module zu erzeugen, oder zu den Modulen auch die davon genutzten Funktionen aufzulisten. Dabei wird Modulname und Funktionsname durch ImportList::ListDelimiter (": ") getrennt, welcher von außen abgefragt werden kann (ImportListDemoGui macht davon Gebrauch). Wie bereits erwähnt wird ein Memory Mapped File genutzt, ansonsten wurden wesentliche Teile schon als Quellcodeausschnitt gezeigt.

Die Konsolen-Anwendung nutzt ImportList auf recht einfache Weise. Das ganze Programm umfasst etwa 30 Zeilen. Hier eine beispielhafte (unvollständig dargestellte) Ausgabe des Programms auf sich selbst aufgerufen:

Screenshot von ImportDemo

Etwas aufwändiger ist das Beispielprogramm mit graphischer Oberfläche. Hierbei wurde die Windows-API direkt verwendet und ein Fenster der Klasse WC_TREEVIEW erzeugt. Dieses hat als Ex-Style WS_EX_ACCEPTFILES um Drag&Drop zu ermöglichen, sowie die Styles WS_OVERLAPPEDWINDOW und WS_VISIBLE um ein Hauptfenster zu erhalten. Das Aussehen wird durch die Styles TVS_HASLINES, TVS_HASBUTTONS, TVS_LINESATROOT und TVS_SHOWSELALWAYS (das in der Home-Edition von Windows XP scheinbar ignoriert wird) definiert. Um die eigene Funktionalität einbetten zu können, wird die WindowProc mittels SetWindowLong (GWL_WNDPROC) ausgetauscht und mit Handlern für die Fensternachrichten WM_CLOSE (um das Programm beenden zu können), WM_DROPFILES (wenn Dateien gedroppt wurden) sowie WM_CHAR (für STRG+C zum Kopieren des Inhalts) erweitert. Für alle anderen Fensternachrichten wird die ursprüngliche WindowProc via CallWindowProc verwendet.

Screenshot von ImportDemoGui

Beim Droppen von Dateien wird zunächst eine Liste der Pfade ermittelt, und dann via ImportList::GetList die Liste aller importierten Funktionen erfragt. Diese wird rekursiv in Module und Funktionen zerlegt und in den Baum einsortiert. Wenn der Benutzer STRG+C drückt (wobei das C mittels WM_CHAR kommt, und über GetKeyState(VK_LCONTROL) der Zustand der STRG-Taste erfragt wird; mir ist tatsächlich kein besserer Weg untergekommen) wird dieser Inhalt als Text in die Zwischenablage kopiert, wobei die einzelnen Ebenen durch Tabs eingerückt werden.

Datum Datei Beschreibung
2009-01-20 ImportList.src.zip Quellcode mit Makefile für MinGW
2009-01-20 ImportList.win32.zip Binaries für Windows NT
1999-03-20 pe.txt "The PE file format" von Bernd Lüvelsmeyer