1、windows下運行一個exe程序,一般都是直接雙擊exe,然后就能運行了,對於普通小白用戶來說非常簡單易用,所以windows能壟斷桌面個人PC領域幾十年是有原因的!對於業內的人士而言,當用雙擊exe的時候:
- 為啥能運行這個程序了?
- 所謂“運行”的本質到底是什么?
- 為什么能在任務管理器或其他類似process hacker的軟件查到正在運行的程序?
- 對於病毒、木馬而言,怎么才能讓用戶點擊運行后又不會出現在任務管理器中了? 也不會被process hacker這類軟件查找到正在運行的進程了?
眾所周知,windows下可執行文件必須符合一定的格式要求,微軟官方稱之為PE文件(關於PE文件的詳細介紹這里就不贅述了,google一下可以找到大把);用戶在界面雙擊exe時,有個叫做explorer的進程會監測並接受到這個事件,然后根據注冊表中的信息取得文件名,再以Explorer.exe這個文件名調用CreateProcess函數去運行用戶雙擊的exe;PC中用戶一般都是這樣運行exe的,所以很多用戶態的exe都是exlporer的子進程,用process hacker截圖如下:
那么這個explorer究竟是怎么成功“運行”這個exe的了?這里面涉及到大量細枝末節就不深究了,本文先把主干思路捋一遍!
- 分配內存
既然是運行,肯定是需要放在內存的,所以首先要開辟內存空間,才能把exe從磁盤加載進來;以32位為例,由於每個進程都有自己的4GB虛擬空間,所以還涉及到新生成頁表、填充CR3等瑣碎的細節工作;
- 加載到內存
內存分配好后,接着就該把exe從磁盤讀取到內存了;
- 重定位(文章末尾有擴展,詳細介紹imagebase、VA、RVA、PointerToRawData、foa等概念)
這一步我個人覺得是最關鍵、最容易出錯的了!PE文件在編譯器編譯的時候,編譯器是不知道文件會被加載到那個VA的(一般exe默認從40000開始,這個還好;但是dll默認從100000開始,這個就不同了。一個exe一般會調用多個dll,后面加載的dll肯定會和前面加載dll的imagebase沖突),這個時候只能把dll或exe加載到其他虛擬地址;一旦改變了imagebase,涉及到地址硬編碼的地方都要改了,包括:全局/靜態變量、子函數調用;所以PE文件里面單獨有個relc段,標明了需要重新定位和生成VA的地址;由於硬編碼存放的都是相對地址,所以重定位后新VA的計算公式也很簡單,如下:
新imagebase-舊imagebase+RAV
- 填寫導入表
一個exe的運行,很多時候要依賴操作系統提供的函數,舉個最簡單的例子:比如我要打印一段string,console下要用到printf或cout,MFC要用到messagebox,這些都是操作系統提供的API,編譯器編譯時也是不知道這些系統函數究竟被操作系統放在了內存的哪個地方,call的時候該往哪跳轉了?所以只能把需要用到的這些系統函數統一放在一張叫做導入表的表格,explorer加載的時候還要挨個遍歷導入表,一旦發現該PE文件用到了某些系統API,需要用這些API在內存的真實地址替換PE文件中call的地址(這也是用OD、x96dbg這些常見的調試器能找到這些系統函數的根本原因:都是系統提供的嘛,函數名必須保存起來,否則加載的時候沒法替換成內存中真正的地址)!
好了,到此為止exe被加載的核心步驟都縷過了;具體實現上,explorer調用了createPorcess來加載和運行exe,這就直接導致了一個后果:被任務管理器或process hacker檢測到(這里和通過loadLibrary類似:只要是通過windows提供的API使用內存,都會在某些地方被記錄,這也是windows常見的內存管理方式之一,用了必須記錄!所以規避檢測的方式之一就是自己實現exe或dll的加載和運行,不依賴window的API)!為了躲避任務管理器或process hacker的監察,只能不調用createProcess,而是自己模擬PE加載的思路重新實現一遍了(類似於自己重新openProcess函數一樣吧)!
2、自己實現PE loader核心思路代碼如下(參考第5個鏈接):
從代碼很容看出思路和上面講的是一模一樣的:打開文件,得到文件大小 ——> 申請相應的內存空間 ——> 加載exe到內存——> 修復重定位表——> 填寫導入表
int main() { char szFileName[] = "D:\\software\\PELoader-master1\\test.exe"; //打開文件,設置屬性可讀可寫 HANDLE hFile = CreateFileA(szFileName, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_ARCHIVE, NULL); if (INVALID_HANDLE_VALUE == hFile) { printf("文件打開失敗\n"); return 1; } //獲取文件大小 DWORD dwFileSize = GetFileSize(hFile, NULL); //申請空間 char* pData = new char[dwFileSize]; if (NULL == pData) { printf("空間申請失敗\n"); return 2; } //將文件讀取到內存中 DWORD dwRet = 0; ReadFile(hFile, pData, dwFileSize, &dwRet, NULL); CloseHandle(hFile); //將內存中exe加載到程序中 char* chBaseAddress = RunExe(pData, dwFileSize); delete[] pData; system("pause"); return 0; }
其他代碼如下(老規矩:精華都在注釋了):
#include <windows.h> #include <stdio.h> //跳轉到入口點執行 bool CallEntry(char* chBaseAddress) { PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)chBaseAddress; PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(chBaseAddress + pDos->e_lfanew); char* ExeEntry = (char*)(chBaseAddress + pNt->OptionalHeader.AddressOfEntryPoint); // 跳轉到入口點處執行 __asm { mov eax, ExeEntry jmp eax } return TRUE; } //設置默認加載基址 bool SetImageBase(char* chBaseAddress) { PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)chBaseAddress; PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(chBaseAddress + pDos->e_lfanew); pNt->OptionalHeader.ImageBase = (ULONG32)chBaseAddress; return TRUE; } //填寫導入表 bool ImportTable(char* chBaseAddress) { PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)chBaseAddress; PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(chBaseAddress + pDos->e_lfanew); PIMAGE_IMPORT_DESCRIPTOR pImportTable = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD)pDos + pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress); // 循環遍歷DLL導入表中的DLL及獲取導入表中的函數地址 char* lpDllName = NULL; HMODULE hDll = NULL; PIMAGE_THUNK_DATA lpImportNameArray = NULL; PIMAGE_IMPORT_BY_NAME lpImportByName = NULL; PIMAGE_THUNK_DATA lpImportFuncAddrArray = NULL; FARPROC lpFuncAddress = NULL; DWORD i = 0; while (TRUE) { if (0 == pImportTable->OriginalFirstThunk) { break; } // 獲取導入表中DLL的名稱並加載DLL lpDllName = (char*)((DWORD)pDos + pImportTable->Name); //看看這個dll是否已經加載 hDll = GetModuleHandleA(lpDllName); //如果沒有加載,那么先加載到內存 if (NULL == hDll) { hDll = LoadLibraryA(lpDllName); if (NULL == hDll) { pImportTable++; continue; } } i = 0; // 獲取OriginalFirstThunk以及對應的導入函數名稱表首地址 lpImportNameArray = (PIMAGE_THUNK_DATA)((DWORD)pDos + pImportTable->OriginalFirstThunk); // 獲取FirstThunk以及對應的導入函數地址表首地址 lpImportFuncAddrArray = (PIMAGE_THUNK_DATA)((DWORD)pDos + pImportTable->FirstThunk); while (TRUE) { if (0 == lpImportNameArray[i].u1.AddressOfData) { break; } // 獲取IMAGE_IMPORT_BY_NAME結構 lpImportByName = (PIMAGE_IMPORT_BY_NAME)((DWORD)pDos + lpImportNameArray[i].u1.AddressOfData); // 判斷導出函數是序號導出還是函數名稱導出 if (0x80000000 & lpImportNameArray[i].u1.Ordinal) { // 序號導出 // 當IMAGE_THUNK_DATA值的最高位為1時,表示函數以序號方式輸入,這時,低位被看做是一個函數序號 lpFuncAddress = GetProcAddress(hDll, (LPCSTR)(lpImportNameArray[i].u1.Ordinal & 0x0000FFFF)); } else { // 名稱導出 lpFuncAddress = GetProcAddress(hDll, (LPCSTR)lpImportByName->Name); } // 注意此處的函數地址表的賦值,要對照PE格式進行裝載,不要理解錯了!!! // 把需要調用其他dll函數的VA寫回導入表,就能通過call跳轉到這里執行了 lpImportFuncAddrArray[i].u1.Function = (DWORD)lpFuncAddress; i++; } pImportTable++; } return TRUE; } //修復重定位表 bool RelocationTable(char* chBaseAddress) { PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)chBaseAddress; PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(chBaseAddress + pDos->e_lfanew); PIMAGE_BASE_RELOCATION pLoc = (PIMAGE_BASE_RELOCATION)(chBaseAddress + pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress); //判斷是否有重定位表 if ((char*)pLoc == (char*)pDos) { return TRUE; } while ((pLoc->VirtualAddress + pLoc->SizeOfBlock) != 0) //開始掃描重定位表 { WORD* pLocData = (WORD*)((PBYTE)pLoc + sizeof(IMAGE_BASE_RELOCATION)); //計算需要修正的重定位項(地址)的數目 int nNumberOfReloc = (pLoc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); for (int i = 0; i < nNumberOfReloc; i++) { if ((DWORD)(pLocData[i] & 0x0000F000) == 0x00003000) //這是一個需要修正的地址 { DWORD* pAddress = (DWORD*)((PBYTE)pDos + pLoc->VirtualAddress + (pLocData[i] & 0x0FFF)); DWORD dwDelta = (DWORD)pDos - pNt->OptionalHeader.ImageBase;//實際的imageBase減去pe文件里面標識的imagebase得到“移動的距離” *pAddress += dwDelta;//把移動的距離在原地址加上去 } } //轉移到下一個節進行處理 pLoc = (PIMAGE_BASE_RELOCATION)((PBYTE)pLoc + pLoc->SizeOfBlock); } return TRUE; } //將內存中的文件映射到進程內存空間中 bool MapFile(char* pFileBuff, char* chBaseAddress) { PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pFileBuff; PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pFileBuff + pDos->e_lfanew); PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt); //所有頭 + 結表頭的大小 DWORD dwSizeOfHeaders = pNt->OptionalHeader.SizeOfHeaders; //獲取區段數量 int nNumerOfSections = pNt->FileHeader.NumberOfSections; // 將前一部分都拷貝過去 RtlCopyMemory(chBaseAddress, pFileBuff, dwSizeOfHeaders); char* chSrcMem = NULL; char* chDestMem = NULL; DWORD dwSizeOfRawData = 0; for (int i = 0; i < nNumerOfSections; i++) { if ((0 == pSection->VirtualAddress) || (0 == pSection->SizeOfRawData)) { pSection++; continue; } // 拷貝節區 chSrcMem = (char*)((DWORD)pFileBuff + pSection->PointerToRawData); chDestMem = (char*)((DWORD)chBaseAddress + pSection->VirtualAddress); dwSizeOfRawData = pSection->SizeOfRawData; RtlCopyMemory(chDestMem, chSrcMem, dwSizeOfRawData); pSection++; } return TRUE; } //獲取鏡像大小,傳入的是文件的開始地址 DWORD GetSizeOfImage(char* pFileBuff) { DWORD dwSizeOfImage = 0; PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pFileBuff; PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pFileBuff + pDos->e_lfanew); dwSizeOfImage = pNt->OptionalHeader.SizeOfImage; return dwSizeOfImage; } //運行文件 char* RunExe(char* pFileBuff, DWORD dwSize) { char* chBaseAddress = NULL; //獲取鏡像大小 DWORD dwSizeOfImage = GetSizeOfImage(pFileBuff); //根據鏡像大小在進程中開辟一塊內存空間 chBaseAddress = (char*)VirtualAlloc(NULL, dwSizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (NULL == chBaseAddress) { printf("申請進程空間失敗\n"); return NULL; } //將申請的進程空間全部填0 RtlZeroMemory(chBaseAddress, dwSizeOfImage); //將內存中的exe數據映射到peloader進程內存中,避免重新生成一個進程,這是隱藏exe的方式之一 if (FALSE == MapFile(pFileBuff, chBaseAddress)) { printf("內存映射失敗\n"); return NULL; } //修復重定位 if (FALSE == RelocationTable(chBaseAddress)) { printf("重定位修復失敗\n"); return NULL; } //填寫導入表 if (FALSE == ImportTable(chBaseAddress)) { printf("填寫導入表失敗\n"); return NULL; } //將頁屬性都設置為可讀可寫可執行 DWORD dwOldProtect = 0; if (FALSE == VirtualProtect(chBaseAddress, dwSizeOfImage, PAGE_EXECUTE_READWRITE, &dwOldProtect)) { printf("設置頁屬性失敗\n"); return NULL; } //設置默認加載基址 if (FALSE == SetImageBase(chBaseAddress)) { printf("設置默認加載基址失敗\n"); return NULL; } //跳轉到入口點執行 if (FALSE == CallEntry(chBaseAddress)) { printf("跳轉到入口點失敗\n"); return NULL; } return chBaseAddress; }
從代碼看:這個pe loader本質上是在loader的進程開辟空間,然后運行exe的,所以exe的代碼和數據其實都在loader的空間,並未單獨生成一個進程,所以任務管理器、process hacker是都查不到的!這里也是把exe想辦法當成了shellcode在用!整體感覺就像“寄生”一樣!
效果如下:單獨雙擊運行test.exe:這就是最終呈現的效果;
在explorer下面也能查到這里子進程:
通過PEloader加載,能成功運行test.exe,效果如下:
這時只能查到PELoader的進程了,再也查不到test.exe的進程了!進程號和上圖也能對上!
最后,編譯exe的時候出於安全考慮,建議隨機基址選是,編譯生成的exe每次被加載的時候imagebase都是變化的,能在一定程度上增加逆向的難度,讓逆向變得很繁瑣,有效消耗逆向人員的時間和精力!
擴展:很多小伙伴剛接觸PE的時候,分不清楚imagebase、VA、RVA、PointerToRawData、foa等概念,這里來縷一縷;
(1)imageBase:整個文件(比如pe、sys、dll等)在虛擬內存中的起始地址;以32位為例,exe默認都是從400000開始的;OD中查詢PE文件頭就是imageBase;上面說的重定位也是從imageBase這里開始重新計算新地址;
(2)virtualAddress:OD中左邊的地址列就是VA,也就是在虛擬內存中的地址;
(3)RVA: related virtual address,翻譯成中文就是相對虛擬地址;這個“相對”怎么理解了?“相對”就是VA和當前所在區段的距離;比如一個VA=0x401010,很明顯是屬於text段的,由於text段的基址是401000,那么這個地址的RVA=0x401010-0x401000=0x10;
(4)PointerToRawData:我也不知道怎么翻譯成中文合適,所以干脆不翻譯了;為什么會有這么一個概念了? 或則說這個概念想表達啥了?由於歷史原因,很久以前磁盤的價格是很貴的,為了節約磁盤空間,pe文件盡量“壓縮”式地存放在磁盤中。為了標注各個段在磁盤中的位置,就衍生出了PointerToRawData:即磁盤中,每個段頭部相對於文件開始位置的距離;當運行程序時,需要把文件加載到內存。由於采用了虛擬地址、頁交換等技術,虛擬內存空間大很多,沒必要“節約”着用了,為了提高cpu尋址的效率,就需要內存對齊了,直觀感覺就是下圖中綠色的部分;這就導致了另一個問題:同樣一個段,在磁盤中相對文件起始的距離,和內存中相對imageBase的距離是不一樣的(因為地址對齊,拉伸了)! 用010editor這種軟件是可以查到PointerToRawData的,如下:


(5)FOA: file offset address,又叫file address,簡稱FA,也就是磁盤文件內部的地址,計算出這個地址有利於靜態查找和破解打補丁(比如改if跳轉邏輯)。比如我們用OD找到了一個內存虛擬地址,怎么根據這個地址在磁盤的文件中找到同樣的地址了?原理很簡單,如下:
先計算出RAV,也就是當前虛擬地址相對於所在段的距離,比如上面的0x401010-0x401000=0x10,也就是這個地址距離text段的偏移是0x10;現在問題就轉換成了怎么找text段在文件中的起始地址了?也很簡單,直接查PointerToRawData唄!比如這個值是0x200,那么FA=PointerToRawData+RVA=0x200+0x10=0x210!在磁盤文件內部0x210的位置就能找到了!
參考:

