- 原文地址:Userland API Monitoring and Code Injection Detection
- 原文作者:dtm
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:Xekin-FE
- 校對者:Starrier,sunhaokk
使用者領域 API 監控和程式碼注入檢測
文件簡介
本文實屬作者對惡意程式(或者病毒)是如何與 Windows 應用程式程式設計介面(WinAPI)進行互動的研究成果。當中詳細贅述了惡意程式如何能夠將 Payload [譯註:Payload 指一種對裝置造成傷害的程式]植入到其他程式中的基本概念,以及如何通過監控與 Windows 作業系統的通訊來檢測此類功能。並且通過函式鉤子鉤住某些函式的方式來介紹觀察 API 呼叫的過程,而這些函式正被用來實現程式碼注入功能。
閱前宣告:由於時間方面的原因,這是一個相對來說比較短促的專案。所以各位在閱讀時如若發現了可能相關的錯誤資訊,我先在此表示十分抱歉,還請儘快地通知我以便及時修正。除此之外,文章隨附的程式碼部分在專案延展性上有一定的設計缺陷,也可能會因為版本落後而無法成功在當下執行。
目錄
序言
在當下,惡意軟體是由網路罪犯開發並針對在網路上那些容易洩露資訊的計算機,通過在這些計算機系統上執行惡意任務以謀取利益。在大多數惡意軟體入侵事件中,這些惡意程式都生存於人們的視野之外,因為它們的行動必須保持隱蔽才能不讓管理員發現同時阻止系統防毒軟體檢測。因此,通過程式碼注入讓自身“隱形”成為了常用的入侵手段。
第一章:基礎概念
內聯掛鉤
內聯掛鉤是通過熱補丁修復過程來繞過程式碼流的一種行為。熱補丁修復被定義為一種可以通過在程式執行時修改二進位制程式碼來改變應用行為的方法[1]。其主要的目的就是為了能夠捕捉程式呼叫函式的時段,從而實現對程式進行監控和呼叫。下面是模擬內聯掛鉤在程式正常工作時的過程:
正常呼叫函式時的程式+---------+ +----------+| Program | ----------------------- calls function ----------------------------->
| Function | | execution+---------+ | . | | of | . | | function | . | | | | v +----------+複製程式碼
與執行了一個鉤子函式後的程式相比:
程式中呼叫鉤子函式+---------+ +--------------+ + ------->
+----------+| Program | -- calls function -->
| Intermediate | | execution | | Function | | execution+---------+ | Function | | of calls | . | | of | . | | intermediate normal | . | | function | . | | function function | . | | | . | v | | | v +--------------+ ------------------+ +----------+複製程式碼
此過程可以分成三個執行步驟。在這裡我們可以以 WinAPI 方法 MessageBox 來演示整個過程。
- 在函式中掛鉤
如果我們要想在函式中掛鉤,我們首先需要一個必須能複製目標函式引數的中間函式。 MessageBox
方法在微軟開發者網路(MSDN)中是這樣定義的:
int WINAPI MessageBox( _In_opt_ HWND hWnd, _In_opt_ LPCTSTR lpText, _In_opt_ LPCTSTR lpCaption, _In_ UINT uType);
複製程式碼
所以我們的中間函式也可以像這樣:
int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
// our code in here
}複製程式碼
一旦觸發中間函式,程式碼執行流將被重定向至某個特定位置。要想在 MessageBox
方法中進行掛鉤,我們可以補充程式碼的前幾個位元組(請記住,我們必須備份原本的位元組,以便於在中間函式執行後恢復原始函式)。以下是在MsgBox方法中相應模組 user32.dll
中的原始編碼指令:
;
MessageBox8B FF mov edi, edi55 push ebp8B EC mov ebp, esp複製程式碼
與掛鉤後的函式相比:
;
MessageBox68 xx xx xx xx push <
HookedMessageBox>
;
our intermediate functionC3 ret複製程式碼
基於以往的經驗以及對隱蔽性可靠程度的考慮,這裡我會選擇使用 push-ret
指令組合而不是一個絕對的 jmp
語句。xx xx xx xx
表示 HookedMessageBox
中的低位元組序順序地址。
- 捕獲函式呼叫
當程式呼叫 MessageBox
方法時,它將會執行 push-ret
相關指令並馬上插入 HookedMessageBox
函式中,如若執行成功,就可以呼叫該函式來完全控制程式引數和呼叫本身。例如如果要替換即將在訊息對話方塊中顯示的文字內容,可以在 HookedMessageBox
中宣告以下內容:
int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
TCHAR szMyText[] = TEXT("This function has been hooked!");
}複製程式碼
其中 szMyText
可以用來替換 MessageBox
中的 LPCTSTR lpText
引數。
- 恢復正常執行
要想將替換後的引數轉發,需要讓程式碼執行流中的 MessageBox
方法回退到原始狀態,才能讓作業系統顯示對話方塊。由於繼續呼叫 MessageBox
方法只會導致無限遞迴,所以我們必須要恢復原始位元組(正如前面所提到的)。
int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
TCHAR szMyText[] = TEXT("This function has been hooked!");
// 還原 MessageBox 中的原始位元組 // ... // 使用已替換引數的 MessageBox 方法,並將值返回給程式 return MessageBox(hWnd, szMyText, lpCaption, uType);
}複製程式碼
如果需要拒絕呼叫 MessageBox
方法,那就跟返回一個值一樣簡單,最好這個值曾在文件中被定義過。例如要在一個“確認/取消”對話方塊中返回“取消”選項,在中間函式中就可以這樣宣告:
int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
return IDNO;
// IDNO defined as 7
}複製程式碼
API 監控
基於函式掛鉤的方法機制,我們完全可以控制函式呼叫的過程,同時也可以控制程式裡的所有引數,這也就是我們實現文件中標題裡也提到過的 API 監控的概念原理。然而,這裡仍有一個小問題,那就是由於不同的深層 API 實用性也不盡相同,導致這些 API 的呼叫將是獨一無二的,只不過在淺層呼叫中它們可能都使用同一組 API,這被稱為函式巢狀,被定義為在子程式中呼叫次級子程式。回到 MessageBox
的例子中,在方法裡,我們宣告瞭兩個函式 MessageBoxA
和 MessageBoxW
,前者用來包含 ASCII 字元的引數,後者用來包含寬字元的引數。在實際應用中,如果我們在 MessageBox
方法中掛鉤,就需要對 MessageBoxA
和 MessageBoxW
的前幾個位元組都進行補充。而其實遇到這樣的問題時,我們只需要在函式呼叫等級最低的公共點進行掛鉤就可以了。
+---------+ | Program | +---------+ / \ | | +------------+ +------------+ | Function A | | Function B | +------------+ +------------+ | | +-------------------------------+ | user32.dll, kernel32.dll, ... | +-------------------------------+ +---------+ +-------- hook ----------------->
| | API | <
---- + +-------------------------------------+ | Monitor | <
-----+ | ntdll.dll | +---------+ | +-------------------------------------+ +-------- hook ----------------->
| User mode ----------------------------------------------------- Kernel mode複製程式碼
下面是模擬呼叫 Message 方法的層級順序:
在 MessageBoxA
中:
user32!MessageBoxA ->
user32!MessageBoxExA ->
user32!MessageBoxTimeoutA ->
user32!MessageBoxTimeoutW複製程式碼
在 MessageBoxW
中:
user32!MessageBoxW ->
user32!MessageBoxExW ->
user32!MessageBoxTimeoutW複製程式碼
上面方法中的層層呼叫最後都會合併到 MessageBoxTimeoutW
函式中,這會是個合適的掛鉤點。對於處在過深層次的函式,伴隨著函式引數的複雜化,對在任何底層的點進行掛鉤都只會帶來沒必要的麻煩。MessageBoxTimeoutW
是一個沒有在 WinAPI 文件中說明的一個函式,它的定義如下:
int WINAPI MessageBoxTimeoutW( HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType, WORD wLanguageId, DWORD dwMilliseconds);
複製程式碼
用法:
int WINAPI MessageBoxTimeoutW(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType, WORD wLanguageId, DWORD dwMilliseconds) {
std::wofstream logfile;
// declare wide stream because of wide parameters logfile.open(L"log.txt", std::ios::out | std::ios::app);
logfile <
<
L"Caption: " <
<
lpCaption <
<
L"\n";
logfile <
<
L"Text: " <
<
lpText <
<
L"\n";
logfile <
<
L"Type: " <
<
uType <
<
:"\n";
logfile.close();
// 恢復原始位元組 // ... // pass execution to the normal function and save the return value int ret = MessageBoxTimeoutW(hWnd, lpText, lpCaption, uType, wLanguageId, dwMilliseconds);
// rehook the function for next calls // ... return ret;
// 返回原始函式的值
}複製程式碼
只要在 MessageBoxTimeoutW
掛鉤成功,MessageBoxA
和 MessageBoxW
的行為就都可以被我們捕獲了。
程式碼注入技術入門
就本文而言,我們將程式碼注入技術定義為一種嵌入行為,它可以將程式內部可執行程式碼在外部甚至是遠端進行呼叫修改。在 WinAPI 本身就擁有一些可以讓我們實現嵌入的功能。當其中某些函式方法被組合封裝在一起時,就可能實現訪問現有程式,篡改寫入資料然後隱藏在程式碼流中遠端執行。在本節中,作者將會介紹在研究中涉及到的程式碼注入的相關技術。
DLL 注入技術
在計算機中,程式碼可以存在於多種形式的檔案下,其中之一就是 Dynamic Link Library (動態連結庫 DLL)。DLL 檔案又被稱為應用程式擴充庫,顧名思義,它就是通過匯出應用子程式後用來給其他程式進行擴充。本文其餘部分將都以此 DLL 檔案示例:
extern "C" void __declspec(dllexport) Demo() {
::MessageBox(nullptr, TEXT("This is a demo!"), TEXT("Demo"), MB_OK);
}bool APIENTRY DllMain(HINSTANCE hInstDll, DWORD fdwReason, LPVOID lpvReserved) {
if (fdwReason == DLL_PROCESS_ATTACH) ::CreateThread(nullptr, 0, (LPTHREAD_START_ROUTINE)Demo, nullptr, 0, nullptr);
return true;
}複製程式碼
當一個 DLL 檔案在程式中載入並初始化後,載入程式將會呼叫 DllMain
這個方法並判斷 fdwReason
引數是否設定為 DLL_PROCESS_ATTACH
。在這個例子中,當在程式中載入 DLL 檔案時,它將通過 Demo
這個方法顯示一個帶有 Demo
標題和 This is a demo!
文字內容的訊息框。要想正確地完成對 DLL 檔案地初始化,訊息框必須返回 true
值,否則檔案就會被拒絕執行。
建立遠端執行緒
CreateRemoteThread 是實現 DLL 注入的方法之一,它可以被使用在某個程式的虛擬空間中執行遠端執行緒。正如之前所提到過的,我們所做的一切都是為了通過注入 DLL 檔案使其程式強制執行 LoadLibrary
函式。通過以下程式碼我們將實現這點:
void injectDll(const HANDLE hProcess, const std::string dllPath) {
LPVOID lpBaseAddress = ::VirtualAllocEx(hProcess, nullptr, dllPath.length(), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
::WriteProcessMemory(hProcess, lpBaseAddress, dllPath.c_str(), dllPath.length(), &
dwWritten);
HMODULE hModule = ::GetModuleHandle(TEXT("kernel32.dll"));
LPVOID lpStartAddress = ::GetProcAddress(hModule, "LoadLibraryA");
// LoadLibraryA for ASCII string ::CreateRemoteThread(hProcess, nullptr, 0, (LPTHREAD_START_ROUTINE)lpStartAddress, lpBaseAddress, 0, nullptr);
}複製程式碼
MSDN 對 LoadLibrary 是這樣定義的:
HMODULE WINAPI LoadLibrary( _In_ LPCTSTR lpFileName);
複製程式碼
使用上面這個函式時,我們需要傳入一個引數那就是載入庫的路徑。而在 LoadLibrary
例程中宣告的這個引數將會被傳遞給 CreateRemoteThread
方法中相匹配的路徑引數。這種行為的目的是為了能在目標程式的虛擬地址空間中傳遞字串引數,然後將 CreateRemoteThread
方法的自變數引數分配給空間地址以便呼叫 LoadLibrary
來載入 DLL。
- 在目標程式中分配虛擬記憶體
使用 VirtualAllocEx
函式可以指定程式的虛擬空間保留或提交記憶體區域,執行完畢後函式將返回分配記憶體的首地址。
目標程式的虛擬地址空間: +--------------------+ | | VirtualAllocEx +--------------------+ Allocated memory --->
| Empty space | +--------------------+ | | +--------------------+ | Executable | | Image | +--------------------+ | | | | +--------------------+ | kernel32.dll | +--------------------+ | | +--------------------+複製程式碼
- 在分配記憶體中寫入 DLL 檔案路徑
只要記憶體初始化成功, DLL 的路徑就可以被注入到 VirtualAllocEx
使用 WriteProcessMemory
返回的分配記憶體裡。
目標程式的虛擬地址空間 +--------------------+ | | WriteProcessMemory +--------------------+ Inject DLL path ---->
| "..\..\myDll.dll" | +--------------------+ | | +--------------------+ | Executable | | Image | +--------------------+ | | | | +--------------------+ | kernel32.dll | +--------------------+ | | +--------------------+複製程式碼
- 找到
LoadLibrary
地址
由於所有的系統 DLL 檔案都會被對映到所有程式的相同地址空間,所以 LoadLibrary
的地址不需要到目標程式中檢索。只需呼叫 GetModuleHandle(TEXT("kernel32.dll"))
和 GetProcAddress(hModule, "LoadLibraryA")
就可以了。
- 載入 DLL 檔案
如果我們需要載入 DLL 檔案,LoadLibrary
地址以及 DLL 檔案路徑是我們必須知道的兩個主要引數。在使用 CreateRemoteThread
函式時,LoadLibrary
將會以 DLL 檔案路徑作為引數在目標程式的程式碼流中被執行。
目標程式的虛擬地址空間 +--------------------+ | | +--------------------+ +--------- | "..\..\myDll.dll" | | +--------------------+ | | | | +--------------------+ <
---+ | | myDll.dll | | | +--------------------+ | | | | | LoadLibrary | +--------------------+ | loads | | Executable | | and | | Image | | initialises | +--------------------+ | myDll.dll | | | | | | | | CreateRemoteThread v +--------------------+ | LoadLibraryA("..\..\myDll.dll") -->
| kernel32.dll | ----+ +--------------------+ | | +--------------------+複製程式碼
SetWindowsHookEx 鉤子函式
SetWindowsHookEx 函式是 Windows 提供給程式開發人員的一個 API,通過對某一事件流程掛鉤實現對訊息攔截的功能,雖然這個函式經常被使用來監視鍵盤按鍵輸入和記錄,但其實也可以被用於 DLL 注入。以下程式碼將演示如何將 DLL 注入事件本身。
int main() {
HMODULE hMod = ::LoadLibrary(DLL_PATH);
HOOKPROC lpfn = (HOOKPROC)::GetProcAddress(hMod, "Demo");
HHOOK hHook = ::SetWindowsHookEx(WH_GETMESSAGE, lpfn, hMod, ::GetCurrentThreadId());
::PostThreadMessageW(::GetCurrentThreadId(), WM_RBUTTONDOWN, (WPARAM)0, (LPARAM)0);
// 捕捉事件的訊息佇列 MSG msg;
while (::GetMessage(&
msg, nullptr, 0, 0) >
0) {
::TranslateMessage(&
msg);
::DispatchMessage(&
msg);
} return 0;
}複製程式碼
SetWindowsHookEx
在 MSDN 中是這樣定義的:
HHOOK WINAPI SetWindowsHookEx( _In_ int idHook, _In_ HOOKPROC lpfn, _In_ HINSTANCE hMod, _In_ DWORD dwThreadId);
複製程式碼
在上面的定義中, HOOKPROC
是由使用者宣告的鉤子函式,當特定的掛鉤事件被觸發時它就會被執行。在我們的示例中,這一事件指的是 WH_GETMESSAGE
鉤子,它主要負責處理進隊訊息的工作[譯註:Windows 中訊息分為進隊訊息和不進隊訊息]。這段程式碼是一個回撥函式,它會先將 DLL 檔案載入到它自己的虛擬程式空間中,再獲得之前匯出的 Demo
函式地址,最後在 SetWindowsHookEx
函式中宣告並呼叫。要想強制執行這個鉤子函式,我們只需呼叫 PostThreadMessage
函式並將訊息賦值為 WM_RBUTTONDOWN
就可以觸發 WH_GETMESSAGE
鉤子之後就能顯示之前所說的訊息框了。
QueueUserAPC 介面
使用 QueueUserAPC 介面方法的 DLL 注入和 CreateRemoteThread
類似,都是在分配和注入 DLL 地址到目標程式的虛擬地址空間中後在程式碼流中強制呼叫 LoadLibrary
函式。
int injectDll(const std::string dllPath, const DWORD dwProcessId, const DWORD dwThreadId) {
HANDLE hProcess = ::OpenProcess(PROCESS_ALL_ACCESS, false, dwProcessId);
HANDLE hThread = ::OpenThread(THREAD_ALL_ACCESS, false, dwThreadId);
LPVOID lpLoadLibraryParam = ::VirtualAllocEx(hProcess, nullptr, dllPath.length(), MEM_COMMIT, PAGE_READWRITE);
::WriteProcessMemory(hProcess, lpLoadLibraryParam, dllPath.data(), dllPath.length(), &
dwWritten);
::QueueUserAPC((PAPCFUNC)::GetProcAddress(::GetModuleHandle(TEXT("kernel32.dll")), "LoadLibraryA"), hThread, (ULONG_PTR)lpLoadLibraryParam);
return 0;
}複製程式碼
這個方法和 CreateRemoteThread
有一個主要區別,QueueUserAPC
是隻能在警告狀態下執行呼叫的。也就是說在 QueueUserAPC
佇列中的非同步程式只有在當線上程處於警告狀態時才能呼叫 APC 函式。
傀儡程式技術(Prosess hollowing)
Process hollowing(傀儡程式),又稱為 RunPE,這是一個常見的用於躲避反病毒檢測的方法。它可以做到把整個可執行檔案注入到目標程式中並在其程式碼流中執行。通常我們會在加密的應用程式中看到,存在 Payload 的磁碟上的某個檔案會被選舉為 host 並且被作為程式建立,而這個檔案的主要執行模組都被挖空並且替換掉了。這樣一個過程可以分解為四步來執行。
- 建立主程式
為了將 Payload 注入,首先載入程式必須找到適合引導的主檔案。如果 Payload 是一個 .NET 應用程式,那麼主檔案也必須是 .NET 應用程式。如果 Payload 是一個可以呼叫控制檯子系統的本地可執行程式,則主檔案也要具有與其相同的屬性。不管是32位還是64位的程式都必須要滿足這一條件。一旦主檔案找到了之後,系統函式 CreateProcess(PATH_TO_HOST_EXE, ..., CREATE_SUSPENDED, ...)
便可建立一個掛起狀態的程式。
主程式中的可執行映像 +--- +--------------------+ | | PE | | | Headers | | +--------------------+ | | .text | | +--------------------+ CreateProcess + | .data | | +--------------------+ | | ... | | +--------------------+ | | ... | | +--------------------+ | | ... | +--- +--------------------+複製程式碼
- 將主程式掛起
為了使注入後的 Paylaod 正常工作,我們必須將其對映到與 PE 映像頭的 optional header 的 ImageBase
值相同的虛擬地址空間。
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
// <
---- this is required later DWORD BaseOfCode;
DWORD BaseOfData;
DWORD ImageBase;
// <
---- DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
// <
---- size of the PE file as an image DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER, *PIMAGE_OPTIONAL_HEADER;
複製程式碼
這一點非常重要,因為絕對地址很有可能會涉及完全依賴其記憶體位置的程式碼。為了安全地對映該可執行映像,必須從描述的 ImageBase
值開始的虛擬記憶體空間解除安裝對映。由於許多可執行檔案共享通用的基地址(通常為 0x400000
),因此主程式本身的可執行映像未對映的情況並不罕見。解除安裝這一操作可以通過 NtUnmapViewOfSection(IMAGE_BASE, SIZE_OF_IMAGE)
來完成。
主程式中的可執行映像 +--- +--------------------+ | | | | | | | | | | | | | | | NtUnmapViewOfSection + | | | | | | | | | | | | | | | | | | | | +--- +--------------------+複製程式碼
- Payload 注入
要將 Payload 注入,我們必須手動去解析 PE 檔案將其從磁碟格式轉換為映像格式。在使用 VirtualAllocEx
分配完虛擬記憶體後,PE 映像頭將直接被複制到基地址中。
主程式中的可執行映像 +--- +--------------------+ | | PE | | | Headers | +--- +--------------------+ | | | | | | WriteProcessMemory + | | | | | | | | | | | | | | +--------------------+複製程式碼
而如果要將 PE 檔案轉換成映像,所有的區塊(節)都必須從檔案偏移量裡逐個讀取,然後通過使用 WriteProcessMemory
將其放置到正確的虛擬偏移量中。在這篇 MSDN 文件中每個章節的 section header. 都有介紹。
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
// <
---- 虛擬偏移量 DWORD SizeOfRawData;
DWORD PointerToRawData;
// <
---- 檔案偏移量 DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
複製程式碼
主程式中的可執行映像 +--------------------+ | PE | | Headers | +--- +--------------------+ | | .text | +--- +--------------------+ WriteProcessMemory + | .data | +--- +--------------------+ | | ... | +---- +--------------------+ | | ... | +---- +--------------------+ | | ... | +---- +--------------------+複製程式碼
- 執行 Payload
最後一步就是將執行的首地址指向上面有提到過的(建立主程式)Payload 的 AddressOfEntryPoint
。由於程式的主執行緒已經被掛起,所以可以使用 GetThreadContext
方法來檢索相關資訊。其程式碼結構可以如以下宣告:
typedef struct _CONTEXT{
ULONG ContextFlags;
ULONG Dr0;
ULONG Dr1;
ULONG Dr2;
ULONG Dr3;
ULONG Dr6;
ULONG Dr7;
FLOATING_SAVE_AREA FloatSave;
ULONG SegGs;
ULONG SegFs;
ULONG SegEs;
ULONG SegDs;
ULONG Edi;
ULONG Esi;
ULONG Ebx;
ULONG Edx;
ULONG Ecx;
ULONG Eax;
// <
---- ULONG Ebp;
ULONG Eip;
ULONG SegCs;
ULONG EFlags;
ULONG Esp;
ULONG SegSs;
UCHAR ExtendedRegisters[512];
} CONTEXT, *PCONTEXT;
複製程式碼
如果要修改首地址,我們必須將上面的 Eax
資料成員更改為 Payload 的 AddressOfEntryPoint
的虛擬地址。簡單表示,context.Eax = ImageBase + AddressOfEntryPoint
。呼叫 SetThreadContext
方法,並傳入修改的 CONTEXT
結構,我們就可以更改應用到程式執行緒。之後現在我們只需呼叫 ResumeThread
,Payload 應該就可以開始執行了。
Atom Bombing 技術
Atom Bombing 是一種程式碼注入技術,它利用了 Windows 的全域性原子表來實現全域性資料儲存。全域性原子表中的資料可以跨所有程式進行訪問,這也正是我們能實現程式碼注入的原因。表中的資料是以空字元結尾的 C-string 型別,用 16-bit 的整數表示,我們稱之為原子(Atom),它類似於 map 資料結構。在 MSDN 中提供了 GlobalAddAtom 方法用於向其新增資料,如下宣告:
ATOM WINAPI GlobalAddAtom( _In_ LPCTSTR lpString);
複製程式碼
其中 lpString
是要儲存的資料,當方法呼叫成功時將會返回一個 16-bit 的整數原子。我們可以通過 GlobalGetAtomName 來檢索儲存在全域性原子表裡面的資料,如下宣告:
UINT WINAPI GlobalGetAtomName( _In_ ATOM nAtom, _Out_ LPTSTR lpBuffer, _In_ int nSize);
複製程式碼
通過 GlobalAddAtom
新增方法返回的標識原子將會被放入 lpBuffer
中並返回該字串的長度(不包含空終止符)。
Atom bombing 是通過強制讓目標程式載入並執行儲存在全域性原子表裡的程式碼,這依賴於另一個關鍵函式,NtQueueApcThread
,一個 QueueUserAPC
介面在使用者領域的呼叫方法。之所以使用 NtQueueApcThread
而不是 QueueUserAPC
其他方法的原因,正如前面所看到的,QueueUserAPC
的 APCProc 方法只能接收一個引數,而 GlobalGetAtomName
需要三個引數[3]。
VOID CALLBACK APCProc( UINT WINAPI GlobalGetAtomName( _In_ ATOM nAtom, _In_ ULONG_PTR dwParam ->
_Out_ LPTSTR lpBuffer, _In_ int nSize);
);
複製程式碼
然而在 NtQueueApcThread
的底層會允許我們可以傳入三個潛在的引數:
NTSTATUS NTAPI NtQueueApcThread( UINT WINAPI GlobalGetAtomName( _In_ HANDLE ThreadHandle, // target process's thread _In_ PIO_APC_ROUTINE ApcRoutine, // APCProc (GlobalGetAtomName) _In_opt_ PVOID ApcRoutineContext, ->
_In_ ATOM nAtom, _In_opt_ PIO_STATUS_BLOCK ApcStatusBlock, _Out_ LPTSTR lpBuffer, _In_opt_ ULONG ApcReserved _In_ int nSize);
);
複製程式碼
下面是我們用圖形模擬程式碼注入的過程:
Atom bombing code injection +--------------------+ | | +--------------------+ | lpBuffer | <
-+ | | | +--------------------+ | +---------+ | | | Calls | Atom | +--------------------+ | GlobalGetAtomName | Bombing | | Executable | | specifying | Process | | Image | | arbitrary +---------+ +--------------------+ | address space | | | | and loads shellcode | | | | | NtQueueApcThread +--------------------+ | +---------- GlobalGetAtomName ---->
| ntdll.dll | --+ +--------------------+ | | +--------------------+複製程式碼
這是 Atom bombing 的一種非常簡化的概述,但對於本文的其餘部分來說已經足夠了。如果餘姚瞭解更多關於 Atom bombing 的技術資訊,請參閱 enSilo 的 AtomBombing: Brand New Code Injection for Windows。
第二章:UnRunPE 工具
UnRunPE 是一個概念驗證(Proof of concept,簡稱 PoC)工具,是為了將 API 監控的理論概念應用到實際操作而編寫的。該工具的目的是將選定的可執行檔案作為程式建立並掛起,隨後將帶有鉤子函式的 DLL 通過傀儡程式技術(process hollowing)注入到程式中。
程式碼注入檢測
瞭解了相關的程式碼注入的基礎知識之後,可以通過下面的 WinAPI 函式呼叫鏈來實現傀儡程式技術的注入手段:
CreateProcess
NtUnmapViewOfSection
VirtualAllocEx
WriteProcessMemory
GetThreadContext
SetThreadContext
ResumeThread
其實當中有一些並不一定要按這樣的順序執行,例如,GetThreadContext
可以在 VirtualAllocEx
之前就呼叫。不過由於一些方法需要依賴前面呼叫的 API,例如 SetThreadContext
必須要在 GetThreadContext
或者 CreateProcess
呼叫之前呼叫,否則就無法將 Payload 注入到目標程式。該工具將假定上述的呼叫順序作為參考,嘗試檢測是否有潛在的傀儡程式。
遵循 API 監控的理論,我們最好是在函式呼叫等級最低的公共點進行掛鉤,但當被惡意軟體入侵時,我們最理想的應該是將其可訪問的可能性降到最低。假定在最壞的情況下,入侵者可能會嘗試繞過高層的 WinAPI 函式,而直接呼叫最低層的函式,這些函式通常在 ntdll.dll
模組中可以找到。下列是傀儡程式當中經常呼叫的達到上述要求的 WinAPI 函式:
NtCreateUserProcess
NtUnmapViewOfSection
NtAllocateVirtualMemory
NtWriteVirtualMemory
NtGetContextThread
NtSetContextThread
NtResumeThread
程式碼注入轉儲
一旦我們在需要的函式中掛鉤成功,目標程式就會被執行並且記錄每個掛鉤函式的引數,這樣我們就能跟蹤傀儡程式以及主程式的當前進度。最值得注意的是 NtWriteVirtualMemory
和 NtResumeThread
這兩個鉤子函式,因為前者參與應用了程式碼注入,而後者執行了它。除了記錄引數以外,UnRunPE 還會嘗試轉儲使用 NtWriteVirtualMemory
寫入的位元組並且當執行 NtResumeThread
時,它將嘗試轉儲整個被注入到主程式的 Payload。要做到這點,函式將需要利用通過 NtCreateUserProcess
記錄的程式和執行緒控制程式碼引數以及通過 NtUnmapViewOfSection
記錄的基地址及其大小。在這裡,如果使用 NtAllocateVirtualMemory
的引數可能會更合適,但實際應用中出於某些不明原因,對函式進行掛鉤的過程中會出現錯誤。當通過 NtResumeThread
將 Payload 成功轉儲後,它將終止目標程式及其宿主程式,同時也阻止了注入後的程式碼執行。
UnRunPE 示例
為了演示這點,我選擇了使用之前建立的二進位制木馬檔案來做實驗。檔案中包含了 PEview.exe
以及 PuTTY.exe
作為隱藏的可執行檔案。
第三章:Dreadnought 工具
Dreadnought 是基於 UnRunPE 構建的 PoC 工具,它提供了更多樣的程式碼注入檢測,也就是我們前面程式碼注入入門的全部內容。為了讓應用程式更全面的檢測程式碼注入,強化工具功能也在所必然。
檢測程式碼注入的方法
實現程式碼注入可以有很多種方法,所以我們必須要了解不同的技術之間的區別。第一種檢測程式碼注入的方法就是通過識別呼叫 API 的“觸發器”,也就是負責 Payload 遠端執行的 API 呼叫者。通過識別我們可以確定程式碼注入的完成過程以及某種程度上確定了程式碼注入的型別。其型別共分為以下四種:
- 區塊(節):將程式碼注入到區塊(節)中。
- 程式:將程式碼注入到程式中。
- 程式碼:通過程式碼注入或程式碼溢位(Shellcode)。
- DLL:程式碼掛載在 DLL 中載入。
由 Karsten Hahn 製作的程式碼注入圖形化過程[4]。
如上圖所示(圖片若載入失敗請前往 Github 倉庫檢視原文),每一個 API 觸發器都列在了 Execute 這一欄下,當其中任何一個觸發器被執行,Dreadnought 工具會立即將程式碼轉儲,之後將識別程式碼並匹配在此前假定的注入型別,這種方式和 UnRunPE 工具中處理傀儡程式的方式類似,但僅有這點是不夠的,因為每一個觸發 API 的行為都可能混淆了各種底層呼叫方法,最後仍舊可以實現上圖中箭頭所指向的功能。
啟發式邏輯檢測
啟發式的邏輯演算法將能夠使我們的 Dreadnought 工具更加精準地確定程式碼注入方法。因此在實際開發中,我們使用了一種非常簡單的啟發式邏輯。從我們的程式注入資訊圖表上看,每一次當任何一個 API 被掛鉤時,該演算法將會增加一個或者多個相關的程式碼注入型別的權重並儲存在一個 map 資料結構裡。在它跟蹤每個 API 的呼叫鏈時,它會嘗試偏向某一種注入型別。一旦 API 觸發器被觸發,它將會識別並把每一個有關聯的注入型別的權重對比之後採取適應的措施。
Dreadnought 示例
程式注入之傀儡程式(Process hollowing)
DLL 注入之 SetWindowsHookEx
DLL 注入之 QueueUserAPC
程式碼注入之 Atom Bombing
總結
本文旨在讓讀者對程式碼注入及其與 WinAPI 的互動具有一定程度的技術理解。此外,在使用者領域監控 API 呼叫的概念也曾被惡意地利用來繞過反病毒檢測。下面是本文中有關 Dreadnought 工具在實際的使用情況說明。
本文件在實際應用時的缺點
目前在理論上,Dreadnought 工具的這套檢測設計方式和啟發式演算法確實足夠讓我們向讀者演示並講述相關的原理知識,但在實際開發中卻不可能這麼理想。因為在我們作業系統的常規操作中,有非常大的可能性存在那些被用來掛鉤的 API 的替代品。而這些可以替代它們的行為或者呼叫,我們無法分辨其是否為惡意的,也就無法檢測到它們是否參與了程式碼注入。
由此看來,Dreadnought 工具以及它為使用者領域提供的相關操作,在對抗過於複雜的惡意程式時並不理想,特別是能直接侵入到系統核心並與其進行互動的又或者是具有能夠避開一般鉤子能力的惡意程式等等。
PoC 程式碼倉庫
參考文獻
- [1] www.blackhat.com/presentatio…
- [2] www.codeproject.com/Articles/79…
- [3] blog.ensilo.com/atombombing…
- [4] struppigel.blogspot.com.au/2017/07/pro…
- ReactOs
- NTAPI Undocumented Functions
- ntcoder
- GitHub – Process Hacker
- YouTube – MalwareAnalysisForHedgehogs
- YouTube – OALabs
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。