* API函數地址的擷取
在能夠正確重定位之後,病毒就可以運行自己代碼了。但是這還遠遠不夠,要搜尋檔案、讀寫檔案、進行進程枚舉等操作總不能在有Win32 API的情況下自己用彙編完全重新實現一套吧,那樣的編碼量過大而且相容性很差。Win9X/NT/2000/XP/2003系統都實現了同一套在各個不同的版本上都高度相容的Win32 API,因此調用系統提供的Win32 API實現各種功能對病毒而言就是自然而然的事情了。
所以接下來要解決的問題就是如何動態擷取Win32 API的地址。最早的PE病毒採用的是預編碼的方法,比如Windows 2000中CreateFileA的地址是0x7EE63260,那麼就在病毒代碼中使用call [7EE63260h]調用該API,但問題是不同的Windows版本之間該API的地址並不完全相同,使用該方法的病毒可能只能在Windows 2000的某個版本上運行。因此病毒作者自然而然地回到PE結構上來探求解決方案,我們知道系統載入PE檔案的時候,可以將其引入的特定DLL中函數的運行時地址填入PE的引入函數表中,那麼系統是如何為PE引入表填入正確的函數地址的呢?答案是系統解析引入DLL的匯出函數表,然後根據名字或序號搜尋到相應引出函數的的RVA(相對虛擬位址),然後再和模組在記憶體中的實際載入地址相加,就可以得到API函數的運行時真正地址。
在研究作業系統是如何?動態PE檔案連結的過程中,病毒作者找到了以下兩種解決方案:
A)在感染PE檔案的時候,可以搜尋宿主的函數引入表的相關地址,如果發現要使用的函數已經被引入,則將對該API的調用指向該引入表函數地址,若未引入,則修改引入表增加該函數的引入表項,並將對該API的調用指向新增加的引入函數地址。這樣在宿主程式啟動的時候,系統載入器已經把正確的API函數地址填好了,病毒代碼即可正確地直接調用該函數。
B)系統可以解析DLL的匯出表,自然病毒也可以通過這種手段從DLL中擷取所需要的API地址。要在運行時解析搜尋DLL的匯出表,必須首先擷取DLL在記憶體中的真實載入地址,只有這樣才能解析從PE的頭部資訊中找到匯出表的位置。應該首先解析哪個DLL呢?我們知道Kernel32.DLL幾乎在所有的Win32進程中都要被載入,其中包含了大部分常用的API,特別是其中的LoadLibrary和GetProcAddress兩個API可以擷取任意DLL中匯出的任意函數,在迄今為止的所有Windows平台上都是如此。只要擷取了Kernel32.DLL在進程中載入的基址,然後解析Kernel32.DLL的匯出表擷取常用的API地址,如需要可進一步使用Kernel32.DLL中的LoadLibrary和GetProcAddress兩個API更簡單地擷取任意其他DLL中匯出函數的地址並進行調用。
* 擷取Kernel32.DLL基址
擷取Kernel32.DLL基址的方法很多,最常見的一種是搜尋法,如果已知Kernel32.DLL載入的大致地址,那麼可由該地址向高地址或低地址進行搜尋可以找到其基址。另外一種方法是搜尋NT PEB結構中的模組列表擷取Kernel32.DLL的準確載入基址。下面看一下具體的實現代碼:
方法1:暴力搜尋擷取Kernel32.DLL的基址
最初的病毒是指定一個大致的載入地址,比如根據實驗在9X下其載入地址是0xBFF70000;在Windows 2000下載入基址是0x77E80000;在XP和2003下其載入基址是0x77E60000,因此在NT系統下就可以從0x77e00000開始向高地址搜尋,在9X下可以從0xBFF00000開始向高地址搜尋,如果搜尋到Kernel32.DLL的載入地址,其頭部一定是“MZ”標誌,由模組起始位移0x3C的雙字確定的PE頭部標誌必然是“PE”標誌,因此可根據這兩個標誌判斷是否找到了模組載入地址,也許有人認為該方法不可靠,因為如果恰好有某段資料符合這兩個特徵,那麼找到的基址可能就是錯誤的,但經實驗證明,該判斷方法非常可靠,基本不會出現錯誤。有一點需要注意的是,在所有版本的Windows系統下Kernel32.DLL的載入基址都是按照0x10000對齊的,根據這一特點可以不必逐位元組搜尋,按照64K對齊的邊界地址搜尋即可。
從大致的一個地址開始搜尋Kernel32.DLL基址可能會出現讀寫到未映射記憶體地區的情況,因此需要和SEH配合使用。如果有在各個版本下準確擷取Kernel32.DLL中某地址的通用方法,那麼就可以更可靠地從該地址開始向低地址搜尋,顯然會更加通用。事實上,這種方法是存在的。在系統載入PE檔案跳轉到PE進入點第一條指令的時候,堆棧頂儲存的就是Kernel32.DLL中的某個地址,Elkern中採用的就是這種方法:
_start:
pushfd ;If some flags,especial DF,changed,some APIs can crash down!!!
pushad
_start_@1 equ $
;......
mov ebx,[esp+9*4] ;前面已經由pushfd和pushad壓入了9個雙字
and ebx,0ffe00000h ;該地址為Kernel32.dll模組下方的某個地址
;先減去0x100000確保該地址處於Kernel32.dll的下方
;向高地址搜尋如果將來Windows的發行版本中Kernel32.dll
;大小和代碼結構發生變化,該方法可能無效
ebx中現在已經是Kernel32.DLL基址之前某個地址了,後續代碼可以向高地址搜尋其基址。該方法有一個缺點,就是必須明確知道程式入口的堆棧指標值,或間接可計算出該值,對於那些在程式入口擷取控制權的病毒代碼而言,是可以的,但對於採用EPO技術的病毒而言,該方法則不適用。事實上還有另外一種更加通用的方法,我們知道在Win32程式執行過程中fs段寄存器的基址總是指向進程的TEB,TEB的第一個成員指向SEH鏈表,該鏈表每個節點都是一個EXCEPTION_REGISTRATION結構,該結構定義如下:
struct EXCEPTION_REGISTRATION{
struct EXCEPTION_REGISTRATION *prev;
void* handler;
};
在Windows下SEH鏈表最後一個成員的handler指向Kernel32.DLL中函數UnhandledExceptionFilter的起始地址,利用這一特性我們可以寫出更通用的代碼:
xor esi,esi
lods dword [fs:esi];取得SEH鏈表的頭指標
@@:
inc eax ;是否是最後一個SEH節點,檢查prev是否為0xFFFFFFFF
je @F
dec eax
xchg esi,eax
LODSD ;下一個SEH節點
jmp near @B
@@:
LODSD ;取得Kernel32.dll中UnhandledExceptionFilter的地址
在有的病毒直接以0x7FFDE000作為TEB的指標值,其原因在於在Windows 2003 SP1、Windows XP SP2以前的NT類系統上,該值是固定的,這樣的確可以節省一兩個位元組。但是在Windows 2003 SP1、Windows XP SP2中,情況已經發生了變化,出於安全性的考慮,Windows系統開始動態映射TEB了,也就是說,指向TEB的指標值不再固定,因此這種硬式編碼方法也就走到了盡頭。此時可以按照前面的方法向低地址搜尋判斷直到找到Kernel32.dll的基址為止。Elkern中判斷是否找到了Kernel32.dll基址的相關代碼如下:
search_api_addr_@1:
add ebx,10000h
jz short search_api_addr_seh_restore
cmp word ptr [ebx],'ZM' ;是否是MZ標誌
jnz short search_api_addr_@1
mov eax,[ebx+3ch]
add eax,ebx
cmp word ptr [eax],'EP' ;是否具有PE標誌
jnz short search_api_addr_@1
;找到了kernel32.dll的基址
方法2:搜尋PEB的相關結構擷取Kernel32.DLL的基址
前述TEB位移0x30處,亦即FS:[0x30]地址處儲存著一個重要的指標,該指標指向PEB(進程環境塊),PEB成員很多,這裡並不介紹PEB的詳細結構。我們只需要知道PEB結構的位移0xC處儲存著另外一個重要指標ldr,該指標指向PEB_LDR_DATA結構:
typedef struct _PEB_LDR_DATA
{
ULONG Length; // +0x00
BOOLEAN Initialized; // +0x04
PVOID SsHandle; // +0x08
LIST_ENTRY InLoadOrderModuleList; // +0x0c
LIST_ENTRY InMemoryOrderModuleList; // +0x14
LIST_ENTRY InInitializationOrderModuleList;// +0x1c
} PEB_LDR_DATA,*PPEB_LDR_DATA; // +0x24
該結構的後三個成員是指向LDR_MODULE鏈表結構中相應三條雙向鏈表頭的指標,分別是按照載入順序、在記憶體中的地址順序和初始化順序排列的模組資訊結構的指標。LDR_MODULE結構如下所示:
typedef struct _LDR_MODULE
{
LIST_ENTRY InLoadOrderModuleList; // +0x00
LIST_ENTRY InMemoryOrderModuleList; // +0x08
LIST_ENTRY InInitializationOrderModuleList; // +0x10
PVOID BaseAddress; // +0x18
PVOID EntryPoint; // +0x1c
ULONG SizeOfImage; // +0x20
UNICODE_STRING FullDllName; // +0x24
UNICODE_STRING BaseDllName; // +0x2c
ULONG Flags; // +0x34
SHORT LoadCount; // +0x38
SHORT TlsIndex; // +0x3a
LIST_ENTRY HashTableEntry; // +0x3c
ULONG TimeDateStamp; // +0x44
// +0x48
} LDR_MODULE, *PLDR_MODULE;
Peb->Ldr->InInitializationOrderModuleList指向按照初始化順序排序的第一個LDR_MODULE節點的InInitializationOrderModuleList成員的指標,在WinNT平台(不包含Win9X)下,該鏈表前端節點的LDR_MODULE結構包含的是NTDLL.DLL的相關資訊,而鏈表的下一個節點所包含的就是Kernel32.dll相關的資訊了,該節點LDR_MODULE結構中的BaseAddress不正是我們所苦苦尋找的嗎。注意InInitializationOrderModuleList是LDR_MODULE的第3個成員,因此要擷取BaseAddress的地址,只需將其指標加8再derefrence即可。因此下面的彙編代碼即可擷取Kernel32.DLL的基址:
mov eax, dword ptr fs:[30h] ;擷取PEB基址
mov eax, dword ptr [eax+0ch] ;擷取PEB_LDR_DATA結構指標
mov esi, dword ptr [eax+1ch]
;擷取InInitializationOrderModuleList鏈表頭第一個LDR_MODULE節點
InInitializationOrderModuleList成員的指標
lodsd ;擷取雙向鏈表當前節點後繼的指標
mov ebx, dword ptr [eax+08h] ;取其基地址,該結構當前包含的是
;kernel32.dll相關的資訊
該方法在所有的Windows NT(包括Windows 2003 SP1和Windows XP SP2)作業系統上都是有效,唯一的缺憾是由於PEB結構不同,該方法在Win9X系統上無效。聽起來可能比較費解,還是用一張圖更加清晰一些:
圖6 利用PEB搜尋kernel32.dll基地址的過程