V předchozím dílu jsme se naučili, jak najít bázovou adresu libovolného načteného modulu přes TEB a PEB. Dnes uděláme druhý krok: Z bázové adresy modulu zjistíme adresu konkrétní exportované funkce. Tento díl bude věnován převážně teorii PE struktury, kterou v závěru propojíme s krátkou ukázkou v assembleru.

Předpoklady

Před čtením tohoto dílu je vhodné přečíst a pochopit předchozí dva díly. Zároveň je velmi vhodné mít alespoň základní představu, co je virtuální paměť a ukazatel (pointer).

Každou strukturu, kterou v článku popíšeme, si zároveň ukážeme naživo ve WinDbg na DLL knihovně kernel32.dll v reálném procesu.


Spustíme si 64bitový WinDbg a otevřeme v něm notepad.exe jako nový proces. To můžeme provést buď přes menu File -> Launch Executable, kde v dialogu najdeme soubor C:\Windows\System32\notepad.exe nebo spuštění realizovat přes příkazový řádek:

windbg.exe C:\Windows\System32\notepad.exe

WinDbg proces spustí a sám ho zastaví hned na začátku před tím, než se stihne rozběhnout vlastní kód aplikace. Toto zastavení nazýváme initial breakpoint. Poznáme ho podle promptu 0:000> v příkazovém okně. V tuto chvíli proces stojí, jeho paměť je namapovaná a my ji můžeme začít zkoumat a prohledávat.

Poznámka: WinDbg existuje ve dvou verzích: 32bitové (x86) a 64bitové (x64). Začátečníci je velmi často zaměňují a pak se diví, že použité příkazy nefungují, jak by měly. Protože se série zaměřuje na 64bitové Windows a 64bitové moduly, budeme používat výhradně 64bitovou (x64) verzi. Která verze je aktuálně spuštěna, poznáš v titulku okna nebo přes příkaz version v příkazovém okně. Konkrétní umístění windbg.exe závisí na tom, jak byl WinDbg nainstalován.

PE formát

Každý spustitelný soubor, tedy .exe, .dll, .sys a další, v systému Windows používá takzvaný formát PE (Portable Executable)[1][2][3]. Jedná se o strukturovaný binární formát, který říká systému, jak soubor načíst do paměti, kde začíná kód, jaké závislosti soubor má a jaké funkce modul exportuje pro použití jinými moduly, což je pro nás klíčová informace.

Když operační systém Windows načte DLL knihovnu do paměti, zachová strukturu PE formátu. To znamená, že z bázové adresy modulu v paměti můžeme soubor procházet pomocí PE hlavičky přímo za běhu.

Nyní se podíváme, kde v paměti leží DLL knihovna kernel32.dll. Bázovou adresu, tedy výchozí adresu, ke které budeme přičítat všechny offsety a relativní adresy (RVA), zjistíme příkazem lm (list modules):

0:000> lm m kernel32
Browse full module list
start             end                 module name
00007ffd`bca50000 00007ffd`bcb19000   KERNEL32   (deferred)

Ze tří sloupců uvedených ve výpisu nás bude zajímat hlavně sloupec start, který obsahuje bázovou adresu modulu. Právě na této adrese začíná struktura IMAGE_DOS_HEADER, které se budeme věnovat jako první.

Při testování bude samotná adresa téměř jistě jiná než v tomto textu. Způsobuje to ASLR (pamatujete na předchozí díl?). Kvůli ASLR se modul při každém spuštění mapuje na jinou bázovou adresu. Podobně se mohou drobně lišit i konkrétní hodnoty uvnitř hlaviček. Obrázky v článku jsou ilustrační. Neměnné zůstávají jen offsety (RVA) uvnitř modulu.

Označení (deferred) na konci řádku nás pouze informuje, že WinDbg zatím nenačetl symboly tohoto modulu. Udělá to až ve chvíli, kdy je budeme potřebovat. Zatím si vystačíme s bázovou adresou.


Cesta přes PE hlavičky

Hex dump prvních 0x40 bajtů DOS hlavičky se zvýrazněným e_magic (4D 5A, \"MZ\") na offsetu 0x00 a e_lfanew (F8 00 00 00) na offsetu 0x3C; šipka vede na hodnotu 0x000000F8, kde začíná signatura PE (50 45 00 00).
DOS hlavička s vyznačeným e_magic (\"MZ\") a e_lfanew odkazujícím na NT hlavičky.

DOS Header

Na bázové adrese každého PE souboru začíná struktura IMAGE_DOS_HEADER[4]. Formát pochází z původního MS-DOS EXE souboru (tzv. "MZ executable"), který byl zaveden jako výkonnější alternativa k jednoduššímu formátu .COM (oba formáty přitom po celou éru DOSu koexistovaly). Signatura "MZ" jsou iniciály Marka Zbikowského, jednoho z hlavních architektů MS-DOS, který tento formát v roce 1983 navrhl. Jméno inženýra z roku 1983 je stále na prvních dvou bajtech každého dnešního .exe, .dll, .sys a dalších, a jedná se tak o jeden z nejtrvalejších easter eggů v historii softwaru.

Během návrhu nového formátu PE (odvozeného od COFF z Unixu) ve Windows NT se společnost Microsoft rozhodla z důvodu zachování zpětné kompatibility ponechat starou DOS hlavičku na začátku. Za ni se vkládá takzvaný DOS stub. Jedná se o malý reálný DOS program, který se spustí, pokud se někdo pokusí pustit PE soubor v operačním systému MS-DOS, a vypíše typickou hlášku:

This program cannot be run in DOS mode.

Klíčovou novinkou oproti čistě DOSovému EXE je poslední pole e_lfanew, které DOS ignoruje, ale Windows loader z něj přečte, kde v souboru najde skutečnou PE hlavičku (IMAGE_NT_HEADERS se signaturou "PE\0\0").

Struktura vypadá následovně:

typedef struct _IMAGE_DOS_HEADER        // size 0x40
{
    WORD   e_magic;                     // offset 0x00
    WORD   e_cblp;                      // offset 0x02
    WORD   e_cp;                        // offset 0x04
    WORD   e_crlc;                      // offset 0x06
    WORD   e_cparhdr;                   // offset 0x08
    WORD   e_minalloc;                  // offset 0x0A
    WORD   e_maxalloc;                  // offset 0x0C
    WORD   e_ss;                        // offset 0x0E
    WORD   e_sp;                        // offset 0x10
    WORD   e_csum;                      // offset 0x12
    WORD   e_ip;                        // offset 0x14
    WORD   e_cs;                        // offset 0x16
    WORD   e_lfarlc;                    // offset 0x18
    WORD   e_ovno;                      // offset 0x1A
    WORD   e_res[4];                    // offset 0x1C
    WORD   e_oemid;                     // offset 0x24
    WORD   e_oeminfo;                   // offset 0x26
    WORD   e_res2[10];                  // offset 0x28
    LONG   e_lfanew;                    // offset 0x3C
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

Hex dump prvních 0x40 bajtů DOS hlavičky se zvýrazněným e_magic (4D 5A, "MZ") na offsetu 0x00 a e_lfanew (F8 00 00 00) na offsetu 0x3C; šipka vede na hodnotu 0x000000F8, kde začíná signatura PE (50 45 00 00).
DOS hlavička s vyznačeným e_magic ("MZ") a e_lfanew odkazujícím na NT hlavičky.

Ověřme si nejdřív, že na bázové adrese modulu opravdu leží signatura „MZ", jak jsme viděli v hex dumpu výše. Příkazem db (display bytes) vypíšeme první dva bajty na bázové adrese kernel32:

0:000> db kernel32 L2
00007ffd`bca50000  4d 5a                                            MZ

Vlevo je adresa, uprostřed dva bajty 4d 5a a vpravo jejich interpretace v ASCII, tedy MZ. Přesně tohle je pole e_magic, jehož hodnota je 0x5A4D (jedná se o datový typ WORD, takže pořadí bajtů v paměti je 4d 5a - viz endianita). Tím jsme si prakticky potvrdili, že na začátku modulu opravdu leží MZ signatura, tedy že stojíme na začátku DOS hlavičky.

Většina polí (e_cblpe_ovno, plus rezervy) je pro moderní Windows naprosto ignorovaná. Reálně na PE souboru záleží pouze dvě věci:

  • e_magic musí být 0x5A4D ("MZ")
  • e_lfanew je znaménková 32-bitová hodnota (file offset) na offsetu 0x3C, která u validního PE ukazuje na PE hlavičku (IMAGE_NT_HEADERS)

Kontrolou e_magic ověříme, že jsme na začátku MZ souboru, a přičtením e_lfanew k bázi získáme adresu struktury IMAGE_NT_HEADERS, jejíž platnost potvrdíme až kontrolou PE signatury.

adresa NT Headers = bázová adresa + e_lfanew

Nyní potřebujeme zjistit hodnotu pole e_lfanew, které má offset 0x3C, a které říká, na jaké adrese v modulu najdeme NT hlavičky. Hodnotu přečteme jako 32bitový datový typ příkazem dd (display dwords), a to přímo z adresy báze + 0x3C:

0:000> dd kernel32+0x3c L1
00007ffd`bca5003c  00000100

WinDbg za nás rovnou složil čtyři bajty do jedné hodnoty ve správném pořadí (little-endian), takže rovnou čteme 0x00000100. To je RVA, respektive file offset, na kterém začíná struktura IMAGE_NT_HEADERS. Její adresu v paměti dostaneme přičtením k bázi:

0:000>  ? kernel32 + 0x100
Evaluate expression: 140727768383744 = 00007ffd`bca50100

Pro zajímavost, proč není vhodné se bezhlavě spoléhat na dokumentaci, ještě uvedu, že prostor mezi koncem struktury IMAGE_DOS_HEADER a začátkem IMAGE_NT_HEADERS (tedy oblast DOS stubu, na kterou ukazuje e_lfanew) není systémovým loaderem kontrolován a je možné ho nahradit vlastními daty nebo dokonce vlastním kódem. Offset uložený v e_lfanew přitom může ukazovat prakticky kamkoli, včetně této oblasti. Na možnosti podobných manipulací se strukturou PE souborů se soustředila challenge, jejímž cílem bylo vytvořit co možná nejmenší funkční PE soubor. Detailně pak vytvoření takzvaného TinyPE popsal Alexander Sotirov[5].


NT Headers

IMAGE_NT_HEADERS[6] je struktura, na kterou ukazuje e_lfanew z DOS hlavičky, tedy skutečné "jádro" PE souboru. Zatímco DOS hlavičku můžeme vnímat jako fosilii, která v PE souborech přetrvává kvůli kompatibilitě, v NT hlavičkách začínají data, které Windows loader reálně využívá. Struktura je následující:

typedef struct _IMAGE_NT_HEADERS                // size 0x108
{
    DWORD                   Signature;          // offset 0x00
    IMAGE_FILE_HEADER       FileHeader;         // offset 0x04
    IMAGE_OPTIONAL_HEADER   OptionalHeader;     // offset 0x18
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS;

Jedná se vlastně pouze o jakýsi obal pro signaturu a dvě vnořené struktury. Položka Signature definuje začátek PE souboru a je definována konstantou "PE\0\0", tedy 0x00004550.

V předchozí sekci jsme spočítali, že NT hlavičky začínají na adrese báze + e_lfanew. Ověříme si, že na ní opravdu leží signatura „PE". Přečteme první 32bitovou hodnotu na této adrese:

0:000> dd kernel32+0x100 L1
00007ffd`bca50100  00004550

Hodnota 0x00004550 odpovídá znakům P, E, 0, 0 (v paměti 50 45 00 00). To je pole Signature struktury IMAGE_NT_HEADERS, a pro nás potvrzení, že jsme skutečně na jejím začátku.

Struktura IMAGE_FILE_HEADER[7] je ve své podstatě čistá COFF hlavička a zmiňuji ji zde pouze pro úplnost, protože žádné hodnoty z ní používat nebudeme. Struktura vypadá následovně:

typedef struct _IMAGE_FILE_HEADER  // size 0x14
{
    WORD  Machine;                 // offset 0x00
    WORD  NumberOfSections;        // offset 0x02
    DWORD TimeDateStamp;           // offset 0x04
    DWORD PointerToSymbolTable;    // offset 0x08
    DWORD NumberOfSymbols;         // offset 0x0C
    WORD  SizeOfOptionalHeader;    // offset 0x10
    WORD  Characteristics;         // offset 0x12
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

Zato struktura IMAGE_OPTIONAL_HEADER[8] je i přes svůj matoucí název pro naše potřeby naprosto stěžejní a je v PE souborech přítomna vždy. Historicky tento název vychází z COFF formátu, kde u objektových souborů (.obj) je tato část skutečně volitelná, avšak u spustitelných souborů (.exe, .dll) je povinná. Nejedná se tedy o žádný bláznivý výstřelek vývojáře, ale o historický artefakt. Podobně podivně může působit i fakt, že společnost Microsoft v roce 2003 s příchodem 64-bitových Windows nevytvořila žádný PE64 formát, ale prostě jen upravila a rozšířila stávající PE32 formát a uděla z něj PE32+ formát. Proto se dnes pod označením IMAGE_OPTIONAL_HEADER ve skutečnosti skrývají IMAGE_OPTIONAL_HEADER32 (32-bit varianta) a IMAGE_OPTIONAL_HEADER64 (64-bit varianta). Struktura vypadá následovně:

typedef struct _IMAGE_OPTIONAL_HEADER64                                   // size 0xF0 (16 dirs)
{
    WORD      Magic;                                                      // offset 0x00
    BYTE      MajorLinkerVersion;                                         // offset 0x02
    BYTE      MinorLinkerVersion;                                         // offset 0x03
    DWORD     SizeOfCode;                                                 // offset 0x04
    DWORD     SizeOfInitializedData;                                      // offset 0x08
    DWORD     SizeOfUninitializedData;                                    // offset 0x0C
    DWORD     AddressOfEntryPoint;                                        // offset 0x10
    DWORD     BaseOfCode;                                                 // offset 0x14
    ULONGLONG ImageBase;                                                  // offset 0x18
    DWORD     SectionAlignment;                                           // offset 0x20
    DWORD     FileAlignment;                                              // offset 0x24
    WORD      MajorOperatingSystemVersion;                                // offset 0x28
    WORD      MinorOperatingSystemVersion;                                // offset 0x2A
    WORD      MajorImageVersion;                                          // offset 0x2C
    WORD      MinorImageVersion;                                          // offset 0x2E
    WORD      MajorSubsystemVersion;                                      // offset 0x30
    WORD      MinorSubsystemVersion;                                      // offset 0x32
    DWORD     Win32VersionValue;                                          // offset 0x34
    DWORD     SizeOfImage;                                                // offset 0x38
    DWORD     SizeOfHeaders;                                              // offset 0x3C
    DWORD     CheckSum;                                                   // offset 0x40
    WORD      Subsystem;                                                  // offset 0x44
    WORD      DllCharacteristics;                                         // offset 0x46
    ULONGLONG SizeOfStackReserve;                                         // offset 0x48
    ULONGLONG SizeOfStackCommit;                                          // offset 0x50
    ULONGLONG SizeOfHeapReserve;                                          // offset 0x58
    ULONGLONG SizeOfHeapCommit;                                           // offset 0x60
    DWORD     LoaderFlags;                                                // offset 0x68
    DWORD     NumberOfRvaAndSizes;                                        // offset 0x6C
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; // offset 0x70
} IMAGE_OPTIONAL_HEADER64, *PIMAGE_OPTIONAL_HEADER64;

Zatímco IMAGE_DOS_HEADER i IMAGE_FILE_HEADER mají pevnou velikost, IMAGE_OPTIONAL_HEADER je jediná hlavička, která je záměrně navržena jako struktura s proměnnou velikostí, i když v hlavičkovém souboru winnt.h vypadá, že má pevnou velikost. To z ní dělá typické místo, kde naivní přístup postavený na konstantách a fixních velikostech jednotlivých členů struktury může velmi snadno způsobit problémy s funkčností kódu.

Zdroje proměnlivosti jsou celkem tři.

Prvním je počet Data Directories definované polem NumberOfRvaAndSizes. Toto pole udává, kolik položek IMAGE_DATA_DIRECTORY skutečně obsahuje. Standardně je tato hodnota nastavena na 16. Je však možné tuto hodnotu měnit a tím měnit i výslednou velikost struktury IMAGE_OPTIONAL_HEADER, čímž se změní i potenciální umístění sekcí, které se běžně vyskytují za strukturou IMAGE_OPTIONAL_HEADER.

Druhým je deklarovaná velikost struktury IMAGE_OPTIONAL_HEADER uložená jako položka SizeOfOptionalHeader ze struktury IMAGE_FILE_HEADER. Systémový loader se řídí touto položkou, nikoliv compile-time velikostí struktury.

Třetím je pak pole Magic udávající bitovost, tedy zda se jedná o 32-bitovou (PE32) nebo 64-bitovou (PE32+) variantu struktury IMAGE_OPTIONAL_HEADER. V rámci této série se omezujeme na nativní 64-bitové moduly (PE32+), typicky obsah C:\Windows\System32 nebo moduly namapované ve vlastním 64-bitovém procesu, kam se 32-bitová knihovna za normálních okolností ani nenamapuje. V takto zúženém vstupu je bitovost pro vývoj shellcodu fakticky konstanta a kontrolu Magic můžeme vynechat.

Pokud bychom vytvářeli opravdu robustní shellcode, který by měl fungovat i na budoucích verzích Windows, na kterých by se změnil počet Data Directories, měli bychom místo konstant jako 0x10 nebo 0xF0 proaktivně získat tyto hodnoty dynamicky parsováním PE hlaviček. V praxi se však tento přístup příliš nenosí.

Ruční čtení jednotlivých polí je sice poučné, ale zdlouhavé a necháme jako cvičení čtenáři. Využijeme toho, že WinDbg umí celé hlavičky rozparsovat sám. Slouží k tomu rozšíření !dh (dump headers) s přepínačem -f (full):

0:000> !dh kernel32 -f

File Type: DLL
FILE HEADER VALUES
    8664 machine (X64)
       8 number of sections
B7DAF818 time date stamp Fri Sep 30 06:01:28 2067

       0 file pointer to symbol table
       0 number of symbols
      F0 size of optional header
    2022 characteristics
            Executable
            App can handle >2gb addresses
            DLL

OPTIONAL HEADER VALUES
     20B magic #
   14.38 linker version
   86000 size of code
   42000 size of initialized data
       0 size of uninitialized data
   2E1A0 address of entry point
    1000 base of code
         ----- new -----
00007ffdbca50000 image base
    1000 section alignment
    1000 file alignment
       3 subsystem (Windows CUI)
   10.00 operating system version
   10.00 image version
   10.00 subsystem version
   C9000 size of image
    1000 size of headers
   DAC97 checksum
0000000000040000 size of stack reserve
0000000000001000 size of stack commit
0000000000100000 size of heap reserve
0000000000001000 size of heap commit
    4160  DLL characteristics
            High entropy VA supported
            Dynamic base
            NX compatible
            Guard
   A4D30 [    EC78] address [size] of Export Directory
   B39A8 [     834] address [size] of Import Directory
   C7000 [     520] address [size] of Resource Directory
   C1000 [    477C] address [size] of Exception Directory
   C8000 [    4288] address [size] of Security Directory
   C8000 [     5D8] address [size] of Base Relocation Directory
   9D944 [      70] address [size] of Debug Directory
       0 [       0] address [size] of Description Directory
       0 [       0] address [size] of Special Directory
       0 [       0] address [size] of Thread Storage Directory
   890D0 [     148] address [size] of Load Configuration Directory
       0 [       0] address [size] of Bound Import Directory
   89218 [    2B10] address [size] of Import Address Table Directory
   A4810 [      80] address [size] of Delay Import Directory
       0 [       0] address [size] of COR20 Header Directory
       0 [       0] address [size] of Reserved Directory

Výstup obsahuje desítky polí. Pro nás jsou podstatná jen některá, zbytek příkaz !dh vypisuje pro úplnost. Pole machine má hodnotu 8664, modul tedy cílí na architekturu x64. To, že jde konkrétně o PE32+ variantu, potvrzuje pole Magic s hodnotou 20B. Řádek size of optional header ukazuje F0, to je přesně hodnota SizeOfOptionalHeader, kterou jsme v teorii zmiňovali jako jeden ze zdrojů proměnlivé velikosti Optional Headeru. Hodnota image base odpovídá bázové adrese, kterou nám dřív vypsal příkaz lm.

Ze všech položek struktury IMAGE_OPTIONAL_HEADER je pro naše účely nejdůležitější pole DataDirectory.


Data Directory a Export Directory

Posledním polem IMAGE_OPTIONAL_HEADER je DataDirectory, což je pole struktur IMAGE_DATA_DIRECTORY[], které funguje jako seznam s adresami a velikostí klíčových částí PE souboru. PE formát sám ukládá kód a data do jednotlivých sekcí. Ale systémový loader potřebuje také rychle najít konkrétní tabulky jako například importy, exporty, relokace, resources a podobně, aniž by musel sekce procházet a hádat, co se v nich nachází. Právě pro tyto účely vzniklo pole DataDirectory. Každá jeho položka je ukazatel na jednu takovou část. Jednotlivé položky tohoto pole jsou struktury IMAGE_DATA_DIRECTORY v následujícím formátu:

typedef struct _IMAGE_DATA_DIRECTORY // size 0x08
{
    DWORD VirtualAddress;          // offset 0x00
    DWORD Size;                    // offset 0x04
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

Pole VirtualAddress je takzvané RVA (Relative Virtual Address), což je offset od bázové adresy modulu. Pole Size pak udává velikost tohoto adresáře. Pokud jsou v rámci struktury IMAGE_DATA_DIRECTORY obě položky nastaveny na 0, znamená to, že se daný adresář nepoužívá.


Export Directory

Ze všech adresářů nás bude zajímat ten úplně první s indexem 0. Jedná se o adresář obsahující seznam všech funkcí, které modul exportuje, tedy funkcí, které nabízí k použití jiným modulům. Právě tento adresář je tím, co operační systém prochází pokaždé, když nějaký modul žádá o adresu funkce z cizí knihovny. Jeho adresu získáme sečtením bázové adresy a offsetu DataDirectory[0].VirtualAddress:

adresa Export Directory = bázová adresa + DataDirectory[0].VirtualAddress

Z výstupu příkazu !dh výše víme, že Export Directory má RVA 0xA4D30 (řádek address of Export Directory). Je to RVA, tedy offset od báze. Adresu v paměti proto dostaneme přičtením k bázi modulu:

0:000> ? kernel32 + 0xa4d30
Evaluate expression: 140727769058608 = 00007ffd`bcaf4d30

Na této adrese začíná struktura IMAGE_EXPORT_DIRECTORY.

typedef struct _IMAGE_EXPORT_DIRECTORY  // size 0x28
{
    DWORD Characteristics;              // offset 0x00
    DWORD TimeDateStamp;                // offset 0x04
    WORD  MajorVersion;                 // offset 0x08
    WORD  MinorVersion;                 // offset 0x0A
    DWORD Name;                         // offset 0x0C
    DWORD Base;                         // offset 0x10
    DWORD NumberOfFunctions;            // offset 0x14
    DWORD NumberOfNames;                // offset 0x18
    DWORD AddressOfFunctions;           // offset 0x1C
    DWORD AddressOfNames;               // offset 0x20
    DWORD AddressOfNameOrdinals;        // offset 0x24
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

Většina polí na začátku struktury (Characteristics, TimeDateStamp, MajorVersion, MinorVersion) je pro nás nepodstatná. Pole Name je RVA na řetězec se jménem samotné knihovny (např. "KERNEL32.dll") a slouží hlavně diagnosticky. Zajímavé pro nás začíná být pole Base, takzvaný ordinal base, tedy hodnota, od které začíná číslování ordinálů (typicky 1) a na kterou za chvíli narazíme jako na zdroj častých chyb.

Klíčovou informací je, že Export Directory sama o sobě žádné adresy funkcí neobsahuje. Pouze ukazuje pomocí RVA na tři paralelní tabulky, mezi kterými je rozdělená veškerá informace o exportech:

  • EAT (Export Address Table): Pole RVA adres jednotlivých funkcí. Indexem do tohoto pole je ordinální index funkce (tedy skutečný ordinál minus hodnota Base). Jeho RVA obsahuje pole AddressOfFunctions a počet položek je dán polem NumberOfFunctions

  • ENT (Export Name Table): Pole RVA řetězců s názvy funkcí. Index do tohoto pole odpovídá indexu do EOT. Jeho RVA obsahuje pole AddressOfNames a počet položek je dán polem NumberOfNames

  • EOT (Export Ordinal Table): Pole 16bitových ordinálních indexů. Slouží jako most mezi ENT a EAT. Jeho RVA obsahuje pole AddressOfNameOrdinals a počet položek je dán polem NumberOfNames

Rozdělení do tří tabulek není samoúčelné a vysvětluje jednu na první pohled zvláštní věc: Proč jsou v Export Directory dvě různá pole s počtem (NumberOfFunctions a NumberOfNames). Důvodem je, že ne každá exportovaná funkce musí nutně mít jméno. Funkce může být exportována pouze podle ordinálu, a pak v ENT vůbec není, tedy existuje jen v EAT. Proto EAT (NumberOfFunctions) bývá stejně velká nebo větší než tabulka jmen (NumberOfNames), a proto jsou jména oddělena od adres do samostatné tabulky, kterou s EAT propojuje až EOT.

Ruční čtení polí bychom teď mohli opakovat jako u DOS hlavičky, ale WinDbg umí zobrazit celou strukturu pojmenovaně, pokud zná její typ. Typ _IMAGE_EXPORT_DIRECTORY bývá součástí veřejných symbolů, které si ale musíme nejdřív načíst (vzpomínáš na (deferred) u prvního výpisu?) pomocí dvojice příkazů:

0:000> .symfix C:\symbols
0:000> .reload

Teď zjistíme, ve kterém z načtených modulů je typ _IMAGE_EXPORT_DIRECTORY dostupný:

0:000> dt *!_IMAGE_EXPORT_DIRECTORY
          combase!_IMAGE_EXPORT_DIRECTORY

V mém případě byl typ _IMAGE_EXPORT_DIRECTORY součástí DLL knihovny combase.dll. Strukturu na vypočtené adrese pak zobrazíme následovně:

0:000> dt combase!_IMAGE_EXPORT_DIRECTORY kernel32+0xa4d30
   +0x000 Characteristics       : 0
   +0x004 TimeDateStamp         : 0xb7daf818
   +0x008 MajorVersion          : 0
   +0x00a MinorVersion          : 0
   +0x00c Name                  : 0xa8f7a
   +0x010 Base                  : 1
   +0x014 NumberOfFunctions     : 0x69d
   +0x018 NumberOfNames         : 0x69d
   +0x01c AddressOfFunctions    : 0xa4d58
   +0x020 AddressOfNames        : 0xa67cc
   +0x024 AddressOfNameOrdinals : 0xa8240

Můžeme provést porovnání s definicí struktury výše. Každé pole sedí, včetně offsetů. Naživo tu vidíme tři ukazatele na tabulky, o kterých byla řeč: AddressOfFunctions (EAT), AddressOfNames (ENT) a AddressOfNameOrdinals (EOT). Pole Base má hodnotu 1 (ordinal base). Zde jsou NumberOfFunctions a NumberOfNames shodné (0x69d). To znamená, že každá exportovaná funkce tohoto modulu má i jméno. Obecně ale platí, že funkcí může být víc než jmen, pokud jsou některé exportované jen podle ordinálu.


Jak probíhá překlad funkce podle jména

Algoritmus je následující:

  1. Procházej ENT (AddressOfNames) od indexu 0 do NumberOfNames - 1
  2. Pro každý index porovnej řetězec z ENT s hledaným názvem funkce
  3. Pokud se shodují, přečti ordinál z EOT (AddressOfNameOrdinals) na stejném indexu
  4. Pomocí ordinálů indexuj do EAT (AddressOfFunctions) a přečti RVA funkce
  5. Výsledná adresa funkce = bázová adresa modulu + RVA z EAT

Poznámka: Hodnota z EOT je přímo ordinální index do EAT, tedy nikoliv ordinál posunutý o Base. Pokud bys naopak hledal funkci podle jejího ordinálu, musíš nejdřív odečíst Base:

index = ordinál − Base

Podtečení při převodu ordinálu: Vztah index = ordinál − Base pracuje s bezznaménkovými hodnotami. Pokud by někdo hledal funkci s ordinálem menším než Base, odečtení by podteklo a index by se stal nepoužitelným. Proto je potřeba v implementaci vytvořit vhodné kontroly odvozené z hodnot získaných se struktury IMAGE_EXPORT_DIRECTORY.

ENT → EOT → EAT resolution
Překlad exportu podle jména: z ENT přes EOT do EAT.

Pozor! Mnoho lidí žije v mylné představě, že jméno na indexu i odpovídá EAT na indexu i. To je ale úplně špatný předpoklad a povede k chybě. Musíme vycházet z toho, že Export Name Table a Export Ordinal Table jsou spárovány po i stylem i-tý řetězec == i-tý ordinální index. Teprve tento ordinální index je indexem do Export Address Table. Jedná se o nejčastější zdroj chyb začátečníků.

I když popis tohoto trojstupňového mechanismu zní vcelku komplikovaně, samotná implementace v assembleru je přímočará:

; RCX = bázová adresa modulu
; R8  = RVA tabulky EOT (AddressOfNameOrdinals)
; R9  = RVA tabulky EAT (AddressOfFunctions)
; RAX = index shody jména z ENT (např. i-tá položka)

movzx edx, word ptr [r8 + rax * 2]  ; 1. Krok: Přečti z EOT 16-bitový index (ordinál)
mov   eax, dword ptr [r9 + rdx * 4] ; 2. Krok: Přečti z EAT 32-bitové RVA funkce
add   rax, rcx                      ; 3. Krok: Přičti bázovou adresu modulu
; RAX nyní obsahuje výslednou absolutní adresu funkce (VA)

Násobení v hranatých závorkách odpovídá velikosti položek v jednotlivých polích. V případě tabulky EOT se jedná o 16bitové hodnoty (WORD), proto násobíme dvěma. U tabulky EAT jde o 32bitové hodnoty (DWORD), takže násobíme čtyřmi.

Pro úplnost ještě doplním situace, které pro náš dnešní cíl řešit nemusíme, ale v budoucnu na ně narazit můžeme. Jejich ošetření znatelně zvýší spolehlivost shellcodu. Popsaný algoritmus funguje pro drtivou většinu běžných funkcí, ale existují situace, na kterých tato naivní implementace selže.

  1. Forwardery: V některých případech RVA přečtené z EAT neukazuje na kód funkce, ale na řetězec typu "NTDLL.RtlAllocateHeap". Takový export je ve skutečnosti pouze přesměrování na funkci v jiné knihovně. V takovém případě musíme rekurzivně opakovat celý proces překladu v cílovém modulu. Forwardované funkce poznáme podle toho, že se RVA z EAT nachází uvnitř rozsahu Export Directory, tedy do intervalu VirtualAddress <= RVA < VirtualAddress + Size z DataDirectory[0]. Pokud bychom tuto kontrolu vynechali, místo skoku na kód funkce bychom skočili na obyčejný ASCII řetězec reprezentující kombinace jména knihovny a jména funkce, které reálně řeší funkčnost.

  2. Exporty jen podle ordinálu: Jak už bylo zmíněno, funkce nemusí být v ENT vůbec. Pokud takovou funkci hledáme podle jména, nikdy ji nenajdeme, protože jméno neexistuje. Jediná cesta k ní vede přímo přes ordinál.

  3. Díry v EAT: Ne každá položka EAT musí být platná. Sloty odpovídající neobsazeným ordinálům v rozsahu mohou obsahovat RVA rovné 0, což znamená, že na daném ordinálu žádná funkce není. Tuto nulu je potřeba ošetřit, jinak bychom vrátili bázovou adresu modulu jako „adresu funkce".

Celý výše popsaný mechanismus je velmi podobný tomu, co interně dělá funkce GetProcAddress. Vlastní implementace GetProcAddress je v oblasti vývoje shellcodů úplně základní dovednost, takříkajíc stavební kámen, na kterém stojí prakticky všechno ostatní. Právě proto je nezbytné celý algoritmus pochopit do detailu.

Teorii o překladu podle jména si teď ověříme přímo na exportní tabulce kernel32. Ze struktury IMAGE_EXPORT_DIRECTORY výše víme, že EAT (AddressOfFunctions) má RVA 0xa4d58. Vypíšeme prvních několik jejích položek. Každá je RVA jedné exportované funkce:

0:000> dd kernel32+0xa4d58 L8
00007ffd`bcaf4d58  000a8f9f 000a8fd5 00037af0 0000e4e0
00007ffd`bcaf4d68  00057fe0 00045830 00037e70 00057d00

Výše jsme si řekli, že forwarder poznáme podle toho, že jeho RVA padá dovnitř rozsahu Export Directory, tedy do intervalu [VirtualAddress, VirtualAddress + Size). Pro kernel32 je to [0xa4d30, 0xa4d30 + 0xec78), tedy [0xa4d30, 0xb39a8).

Podívejme se na první položku EAT, RVA 0xa8f9f. Ta se v tomto rozsahu nachází. Mělo by tedy jít o forwarder. Ověříme to tak, že si z této adresy necháme vypsat textový řetězec pomocí příkazu da (display ASCII):

0:000> da kernel32+0xa8f9f
00007ffd`bcaf8f9f  "NTDLL.RtlAcquireSRWLockExclusive"

Jak vidíme, na dané adrese není kód, ale ASCII řetězec NTDLL.RtlAcquireSRWLockExclusive. Tento export je jen přesměrování na funkci RtlAcquireSRWLockExclusive v knihovně ntdll. Pokud bychom sem naivně skočili jako na kód, znamenalo by to pád aplikace.

Pro kontrast se podívejme na třetí položku, RVA 0x37af0, která do rozsahu Export Directory nepadá. Vypíšeme, co na ní je, příkazem u (unassemble):

0:000> u kernel32+0x37af0 L3
KERNEL32!ActivateActCtxStub:
00007ffd`bca87af0 48ff25b9300500  jmp     qword ptr [KERNEL32!_imp_ActivateActCtx]
00007ffd`bca87af7 cc              int     3
00007ffd`bca87af8 cc              int     3

Zde už RVA míří na skutečný kód. Vidíme instrukci jmp. Rozdíl je tím pádem názorný: RVA uvnitř rozsahu Export Directory ukazuje na forwarder (řetězec), RVA mimo něj na spustitelný kód. Právě tuto kontrolu musíme při vývoji shellcodu provádět pro jednotlivé funkce, abychom nezaměnili forwarder za adresu funkce.


Shrnutí offsetů které potřebujeme

Pro přehlednost – všechny offsety které budeme používat v implementaci:

bázová adresa + 0x3C                →  e_lfanew
bázová adresa + e_lfanew + 0x88     →  DataDirectory[0].VirtualAddress (x64)
bázová adresa + RVA Export Dir      →  IMAGE_EXPORT_DIRECTORY
Export Dir + 0x18                   →  NumberOfNames
Export Dir + 0x1C                   →  AddressOfFunctions (RVA)
Export Dir + 0x20                   →  AddressOfNames (RVA)
Export Dir + 0x24                   →  AddressOfNameOrdinals (RVA)

Shrnutí

  • PE soubor začíná DOS Header kde e_lfanew odkazuje na NT Headers
  • NT Headers obsahují Optional Header s polem DataDirectory
  • DataDirectory[0] odkazuje na Export Directory
  • Export Directory obsahuje tři tabulky: EAT, ENT a EOT
  • Resolution funkce podle jména probíhá přes ENT → EOT → EAT

Co přijde v příštím dílu

Máme všechny stavební kameny – víme jak najít modul v paměti a jak z jeho PE struktury extrahovat adresu funkce. V příštím dílu tyto kroky spojíme do funkční implementace vlastní GetProcAddress v assembleru.


Reference