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
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;
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_cblp až e_ovno, plus rezervy) je pro moderní Windows naprosto ignorovaná. Reálně na PE souboru záleží pouze dvě věci:
e_magicmusí být 0x5A4D ("MZ")e_lfanewje 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 poleAddressOfFunctionsa počet položek je dán polemNumberOfFunctions -
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
AddressOfNamesa počet položek je dán polemNumberOfNames -
EOT (Export Ordinal Table): Pole 16bitových ordinálních indexů. Slouží jako most mezi ENT a EAT. Jeho RVA obsahuje pole
AddressOfNameOrdinalsa počet položek je dán polemNumberOfNames
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í:
- Procházej ENT (
AddressOfNames) od indexu 0 doNumberOfNames - 1 - Pro každý index porovnej řetězec z ENT s hledaným názvem funkce
- Pokud se shodují, přečti ordinál z EOT (
AddressOfNameOrdinals) na stejném indexu - Pomocí ordinálů indexuj do EAT (
AddressOfFunctions) a přečti RVA funkce - 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.
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.
-
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 intervaluVirtualAddress <= RVA < VirtualAddress + SizezDataDirectory[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. -
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.
-
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_lfanewodkazuje 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
- [1] PE Format - Microsoft
- [2] Peering Inside the PE: A Tour of the Win32 Portable Executable File Format - Microsoft
- [3] An In-Depth Look into the Win32 Portable Executable File Format, Part 2 - Microsoft
- [4] struct IMAGE_DOS_HEADER - NirSoft
- [5] Tiny PE - phreedom.org
- [6] struct IMAGE_NT_HEADERS64 - Microsoft
- [7] struct IMAGE_FILE_HEADER - Microsoft
- [8] struct IMAGE_OPTIONAL_HEADER64 - Microsoft
- [9] struct IMAGE_DATA_DIRECTORY - Microsoft