0x02 關鍵步驟
因為exe以文件形式存儲的時候區段間的對齊方式與在內存中的對齊方式不盡相同,因此在手動加載exe時不能單純的將文件格式的 exe 直接拷貝到內存中,而是應當根據內存區段(page size)的對齊方式做對齊處理。
為了驗證一下,隨便找一個 exe 文件作為學習資料。
FileAlignment 為 0x200,實際的Section也均是以 0x200 為單位進行對齊的,實際調試時就會發現section的對齊變為了SectionAlignment的大小:0x1000
導入表是PE文件從其它第三方程序中導入API,以供本程序調用的機制(與導出表對應),在exe運行起來的時, 加載器會遍歷導入表, 將導入表中所有dll 都加載到進程中,被加載的DLL的DllMain就會被調用,通過導入表可以知道程序使用了哪些函數,導入表是一個數組,以全為零結尾。
要理解導入表,首先要理解PE文件分為兩種編譯方式:動態鏈接、靜態鏈接。
靜態鏈接方式:在程序執行之前完成所有的組裝工作,生成一個可執行的目標文件(EXE文件)。
動態鏈接方式:在程序已經為了執行被裝入內存之后完成鏈接工作,并且在內存中一般只保留該編譯單元的一份拷貝。
靜態鏈接優勢在于其可移植性較強,基本上不依賴于系統的dll(自己全打包好了),動態鏈接的優勢在于程序主體較小,占用系統資源不多。
動態鏈接庫的兩種鏈接方法:
(1) 裝載時動態鏈接(Load-time Dynamic Linking):這種用法的前提是在編譯之前已經明確知道要調用DLL中的哪幾個函數,編譯時在目標文件中只保留必要的鏈接信息,而不含DLL函數的代碼;當程序執行時,調用函數的時候利用鏈接信息加載DLL函數代碼并在內存中將其鏈接入調用程序的執行空間中(全部函數加載進內存),其主要目的是便于代碼共享。(動態加載程序,處在加載階段,主要為了共享代碼,共享代碼內存)
(2) 運行時動態鏈接(Run-time Dynamic Linking):這種方式是指在編譯之前并不知道將會調用哪些DLL函數,完全是在運行過程中根據需要決定應調用哪個函數,將其加載到內存中(只加載調用的函數進內存),并標識內存地址,其他程序也可以使用該程序,并用LoadLibrary和GetProcAddress動態獲得DLL函數的入口地址。(dll在內存中只存在一份,處在運行階段)
相較于靜態鏈接所有函數均在一個exe文件里,要調用某個函數時只需要按照寫死的偏移進行調用,動態鏈接就存在一個找函數的問題:
當程序運行起來需要某個系統函數時,哪個dll包含該函數?dll加載到內存里之后地址是不確定的,如何按照從內存中定位到所需的函數地址。
PE文件中的導入表就可以解決上述問題。
要理解導入表首先要了解以下這幾個結構:
IMAGE_DATA_DIRECTORY 位于 IMAGE_Optional_header 中的最后一個字段,是一個由16個_IMAGE_DATA_DIRECTORY 結構體構成的結構體數組,每個結構體由兩個字段構成,分別為VirtualAddress和Size字段:
Offset (PE/PE32+) Description
96/112 Export table address and size
104/120 Import table address and size
112/128 Resource table address and size
120/136 Exception table address and size
128/144 Certificate table address and size
136/152 Base relocation table address and size
144/160 Debugging information starting address and size
152/168 Architecture-specific data address and size
160/176 Global pointer register relative virtual address
168/184 Thread local storage (TLS) table address and size
176/192 Load configuration table address and size
184/200 Bound import table address and size
192/208 Import address table address and size
200/216 Delay import descriptor address and size
208/224 The CLR header address and size
216/232 Reserved
根據微軟提供的信息,IMAGE_DATA_DIRECTORY 的第二項指向的就是導入表了。
既然已經找到了導入表,就需要根據導入表內的元素來加載對應的dll,獲取不同的函數地址了,此時就需要用到 IMAGE_IMPORT_DESCRIPTOR 結構了,該結構的詳細內容如下:
Offset Size Field
0 4 Import Lookup Table RVA
4 4 Time/Date Stamp
8 4 Forwarder Chain
12 4 Name RVA
16 4 Import Address Table RVA
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;//(1) 指向導入名稱表(INT)的RAV*
};
DWORD TimeDateStamp; // (2) 時間標識
DWORD ForwarderChain; // (3) 轉發鏈,如果不轉發則此值為0
DWORD Name; // (4) 指向導入映像文件的名字*
DWORD FirstThunk; // (5) 指向導入地址表(IAT)的RAV*
} IMAGE_IMPORT_DESCRIPTOR;
在修復IAT的過程中最重要的兩個字段就是 OriginalFirstThunk 和 FirstThunk了:
根據OriginalFirstThunk獲取要用到的函數名,將獲取到的函數地址填到FirstThunk中。
0x03 具體分析
為了方便理解和記憶,默認讀取的 PE 文件格式不存在問題,不做錯誤處理。
相較于dll的內存加載,exe的內存加載簡化了很多,其中省略掉的一個大步驟就是導出表的修復。
文件讀取步驟基本上可以說條條大路通羅馬,只要將 PE 文件完整的讀取到內存中可供后續處理即可,除了把一個文件放在目錄中進行讀取以外還有很多種方式,比如將要加載的 exe 轉換成shellcode進行加載、將shellcode進行簡單xor后在內存xor回來再加載。。。
ifstream inFile("nc.exe", ios::in | ios::binary);
stringstream tmp;
tmp << inFile.rdbuf();
unsigned char* content = (unsigned char*)tmp.str().c_str();
因為 exe 文件默認有加載基址,一般情況下在運行 exe 的時候會首先嘗試加載到默認地址上去,因此就要根據 exe 的默認加載基址和映像大小(exe加載到內存后的大小:SizeOfImage)來分配內存
SIZE_T SizeOfImage = pFileNtHeader->OptionalHeader.SizeOfImage;// 獲取加載基址
DWORD base = pFileNtHeader->OptionalHeader.ImageBase;
// 分配內存
unsigned char* memExeBase = (unsigned char*)VirtualAlloc((LPVOID)base, SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memcpy(memExeBase, content, pFileNtHeader->OptionalHeader.SizeOfHeaders);
分配內存時的?VirtualAlloc
指定的頁類型為?MEM_COMMIT|MEM_RESERVE
這里是一個小小的延遲分配的知識點,如果是?MEM_RESERVE
的話只有當對該段內存進行內存操作時才會被真正Load進入物理內存中。頁權限使用的是PAGE_EXECUTE_READWRITE
,這在實際編碼過程中是一個很不好的習慣,為了更清晰的理解 exe 內存加載的核心流程,就省略了根據section來確定內存權限的步驟。
分配內存完畢后首先要將 PE header 拷貝到相應的地址空間去,因為后續的操作均需要用到。
memcpy(memExeBase, content, pFileNtHeader->OptionalHeader.SizeOfHeaders);
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)memExeBase;
PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)(memExeBase + pDosHeader->e_lfanew);
之后要根據新分配的內存地址計算新的 DOS頭 和 NT頭。
因為已經根據ImageBase分配了內存,所以需要將拷貝后的OptionalHeader中的ImageBase字段根據實際內存地址進行更新,如果開啟了aslr的話需要根據實際的內存地址更新ImageBase,分配到默認基址上的話沒有必要。
pNtHeader->OptionalHeader.ImageBase = (DWORD)memExeBase;
拷貝區段這部分是內存加載的第一個關鍵點,要根據內存頁的大小來將原本的文件區段進行處理。在文件中Section通常以 0x200 進行對齊,內存中頁大小單位為 0x1000, 因此內存對齊單位為 0x1000,所以當PE文件加載到內存中后需要對Section進行變換。
// 拷貝區段
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(pNtHeader);
int sectionSize;
for (int i = 0; i < pNtHeader->FileHeader.NumberOfSections; i++, section++) {
if (section->SizeOfRawData == 0) {
sectionSize = pNtHeader->OptionalHeader.SectionAlignment; // 最小內存Seciton單位為 SectionAlignment的大小
}
else {
sectionSize = section->SizeOfRawData;
}
if (sectionSize > 0) {
void* dest = memExeBase + section->VirtualAddress;
memcpy(dest, content + section->PointerToRawData, sectionSize);
}
}
OriginalFirstChunk指向的INT表表項以4字節為單位,全0結尾,如果最高位為1則代表是函數序號,反之則是一個RVA,指向IMAGE_IMPORT_BY_NAME結構,INT表實際上的功能是獲取要導入的函數名。目前沒有遇到最高位為1的情況。
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1]; //函數名稱,0結尾.
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
IAT表項則直接是一個指向真實函數地址的指針。
PIMAGE_IMPORT_DESCRIPTOR pImportDesc;
bool result = true;
PIMAGE_DATA_DIRECTORY pDataDir = (PIMAGE_DATA_DIRECTORY)(&pNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]); // 獲取IMAGE_DATA_DIRECTORY 位置
pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)(memExeBase + pDataDir->VirtualAddress);// 獲取第一個IMAGE_IMPORT_DESCRIPTOR
for (;!IsBadReadPtr(pImportDesc,sizeof(IMAGE_IMPORT_DESCRIPTOR)) && pImportDesc->Name;pImportDesc++) {
uintptr_t* thunkRef;
FARPROC* funcRef;
HMODULE handle = LoadLibraryA((LPCSTR)(memExeBase + pImportDesc->Name)); // 加載dll(此處也是可以手工加載的)
if (pImportDesc->OriginalFirstThunk) {
thunkRef = (uintptr_t*)(memExeBase + pImportDesc->OriginalFirstThunk);
funcRef = (FARPROC*)(memExeBase + pImportDesc->FirstThunk);
}
else {
thunkRef = (uintptr_t*)(memExeBase + pImportDesc->FirstThunk);
funcRef = (FARPROC*)(memExeBase + pImportDesc->FirstThunk);
}
for (; *thunkRef; thunkRef++, funcRef++) {
if (IMAGE_SNAP_BY_ORDINAL(*thunkRef)) { // 判斷OriginalFirstThunk表項最高位為1的情況
*funcRef = GetProcAddress(handle, (LPCSTR)(IMAGE_ORDINAL(*thunkRef)));//修復導入表
}
else {
PIMAGE_IMPORT_BY_NAME thunkData = (PIMAGE_IMPORT_BY_NAME)(memExeBase + (*thunkRef));
*funcRef = GetProcAddress(handle, (LPCSTR)&thunkData->Name); //修復導入表
}
if (*funcRef == 0) {
cout << " error import " << endl;
exit(0);
}
}
}
上述修復導入表的代碼實際完成的工作就是遍歷導入表中的?IMAGE_IMPORT_DESCRIPTOR
結構,根據dll名稱將對應的dll加載到內存中,并根據OriginalFirstThunk字段來獲取所需的函數名進而使用GetProcAddress來獲取該函數在內存中的實際地址填充到FirstThunk字段指向的空間中。
if (pNtHeader->OptionalHeader.AddressOfEntryPoint != 0) {
ExeEntryProc exeEntry = (ExeEntryProc)(LPVOID)(memExeBase + pNtHeader->OptionalHeader.AddressOfEntryPoint);
exeEntry();
}
結語
至此exe的內存加載就已經結束了,誘發我寫下這篇文章的一個主要原因是回憶起之前看過的幾篇APT相關的分析文章,涉及到主機的遠控目前內存加載已經是標配,殺軟動態檢測的對抗方式種類繁多,靜態對抗的方法以內存加載為王。
參考鏈接
https://www.cnblogs.com/iBinary/p/9740757.html
https://github.com/fancycode/MemoryModule
https://blog.csdn.net/Apollon_krj/article/details/77417063
https://www.cnblogs.com/tracylee/archive/2012/10/15/2723816.html
轉載自安全客:https://www.anquanke.com/post/id/260054