簡介
本文將討論如何把代碼注入不同的進程地址空間,然后在該進程的上下文中執行注入的代碼。 我們在網上可以查到一些窗口/密碼偵測的應用例子,網上的這些程序大多都依賴 Windows 鉤子技術來實現。本文將討論除了使用 Windows 鉤子技術以外的其它技術來實現這個功能。如圖一所示:

圖一 WinSpy 密碼偵測程序
為了找到解決問題的方法。首先讓我們簡單回顧一下問題背景。
要“讀取”某個控件的內容——無論這個控件是否屬于當前的應用程序——通常都是發送 WM_GETTEXT 消息來實現。這個技術也同樣應用到編輯控件,但是如果該編輯控件屬于另外一個進程并設置了 ES_PASSWORD 式樣,那么上面講的方法就行不通了。用 WM_GETTEXT 來獲取控件的內容只適用于進程“擁有”密碼控件的情況。所以我們的問題變成了如何在另外一個進程的地址空間執行:
::SendMessage( hPwdEdit, WM_GETTEXT, nMaxChars, psBuffer );
通常有三種可能性來解決這個問題。
- 將你的代碼放入某個 DLL,然后通過 Windows 鉤子映射該DLL到遠程進程;
- 將你的代碼放入某個 DLL,然后通過 CreateRemoteThread 和 LoadLibrary 技術映射該DLL到遠程進程;
- 如果不寫單獨的 DLL,可以直接將你的代碼拷貝到遠程進程——通過 WriteProcessMemory——并用 CreateRemoteThread 啟動它的執行。本文將在第三部分詳細描述該技術實現細節;
第一部分: Windows 鉤子
范例程序——參見HookSpy 和HookInjEx
Windows 鉤子主要作用是監控某些線程的消息流。通常我們將鉤子分為本地鉤子和遠程鉤子以及系統級鉤子,本地鉤子一般監控屬于本進程的線程的消息流,遠程鉤子是線程專用的,用于監控屬于另外進程的線程消息流。系統級鉤子監控運行在當前系統中的所有線程的消息流。
如果鉤子作用的線程屬于另外的進程,那么你的鉤子過程必須駐留在某個動態鏈接庫(DLL)中。然后系統映射包含鉤子過程的DLL到鉤子作用的線程的地址空間。Windows將映射整個 DLL,而不僅僅是鉤子過程。這就是為什么 Windows 鉤子能被用于將代碼注入到別的進程地址空間的原因。
本文我不打算涉及鉤子的具體細節(關于鉤子的細節請參見 MSDN 庫中的 SetWindowHookEx API),但我在此要給出兩個很有用心得,在相關文檔中你是找不到這些內容的:
- 在成功調用 SetWindowsHookEx 后,系統自動映射 DLL 到鉤子作用的線程地址空間,但不必立即發生映射,因為 Windows 鉤子都是消息,DLL 在消息事件發生前并沒有產生實際的映射。例如:
如果你安裝一個鉤子監控某些線程(WH_CALLWNDPROC)的非隊列消息,在消息被實際發送到(某些窗口的)鉤子作用的線程之前,該DLL 是不會被映射到遠程進程的。換句話說,如果 UnhookWindowsHookEx 在某個消息被發送到鉤子作用的線程之前被調用,DLL 根本不會被映射到遠程進程(即使 SetWindowsHookEx 本身調用成功)。為了強制進行映射,在調用 SetWindowsHookEx 之后馬上發送一個事件到相關的線程。
在UnhookWindowsHookEx了之后,對于沒有映射的DLL處理方法也一樣。只有在足夠的事件發生后,DLL才會有真正的映射。
- 當你安裝鉤子后,它們可能影響整個系統得性能(尤其是系統級鉤子),但是你可以很容易解決這個問題,如果你使用線程專用鉤子的DLL映射機制,并不截獲消息。考慮使用如下代碼:
BOOL APIENTRY DllMain( HANDLE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved )
{
if( ul_reason_for_call == DLL_PROCESS_ATTACH )
{
// Increase reference count via LoadLibrary
char lib_name[MAX_PATH];
::GetModuleFileName( hModule, lib_name, MAX_PATH );
::LoadLibrary( lib_name );
// Safely remove hook
::UnhookWindowsHookEx( g_hHook );
}
return TRUE;
}
那么會發生什么呢?首先我們通過Windows 鉤子將DLL映射到遠程進程。然后,在DLL被實際映射之后,我們解開鉤子。通常當第一個消息到達鉤子作用線程時,DLL此時也不會被映射。這里的處理技巧是調用LoadLibrary通過增加 DLLs的引用計數來防止映射不成功。
現在剩下的問題是如何卸載DLL,UnhookWindowsHookEx 是不會做這個事情的,因為鉤子已經不作用于線程了。你可以像下面這樣做:
- 就在你想要解除DLL映射前,安裝另一個鉤子;
- 發送一個“特殊”消息到遠程線程;
- 在鉤子過程中截獲這個消息,響應該消息時調用 FreeLibrary 和 UnhookWindowsHookEx;
目前只使用了鉤子來從處理遠程進程中DLL的映射和解除映射。在此“作用于線程的”鉤子對性能沒有影響。
下面我們將討論另外一種方法,這個方法與 LoadLibrary 技術的不同之處是DLL的映射機制不會干預目標進程。相對LoadLibrary 技術,這部分描述的方法適用于 WinNT和Win9x。
但是,什么時候使用這個技巧呢?答案是當DLL必須在遠程進程中駐留較長時間(即如果你子類化某個屬于另外一個進程的控件時)以及你想盡可能少的干涉目標進程時。我在 HookSpy 中沒有使用它,因為注入DLL 的時間并不長——注入時間只要足夠得到密碼即可。我提供了另外一個例子程序——HookInjEx——來示范。HookInjEx 將DLL映射到資源管理器“explorer.exe”,并從中/解除影射,它子類化“開始”按鈕,并交換鼠標左右鍵單擊“開始”按鈕的功能。
HookSpy 和 HookInjEx 的源代碼都可以從本文的下載源代碼中獲得。
第二部分:CreateRemoteThread 和 LoadLibrary 技術
范例程序——LibSpy
通常,任何進程都可以通過 LoadLibrary API 動態加載DLL。但是,如何強制一個外部進程調用這個函數呢?答案是:CreateRemoteThread。
首先,讓我們看一下 LoadLibrary 和FreeLibrary API 的聲明:
HINSTANCE LoadLibrary(
LPCTSTR lpLibFileName // 庫模塊文件名的地址
);
BOOL FreeLibrary(
HMODULE hLibModule // 要加載的庫模塊的句柄
);
現在將它們與傳遞到 CreateRemoteThread 的線程例程——ThreadProc 的聲明進行比較。
DWORD WINAPI ThreadProc(
LPVOID lpParameter // 線程數據
);
你可以看到,所有函數都使用相同的調用規范并都接受 32位參數,返回值的大小都相同。也就是說,我們可以傳遞一個指針到LoadLibrary/FreeLibrary 作為到 CreateRemoteThread 的線程例程。但這里有兩個問題,請看下面對CreateRemoteThread 的描述:
- CreateRemoteThread 的 lpStartAddress 參數必須表示遠程進程中線程例程的開始地址。
- 如果傳遞到 ThreadFunc 的參數lpParameter——被解釋為常規的 32位值(FreeLibrary將它解釋為一個 HMODULE),一切OK。但是,如果 lpParameter 被解釋為一個指針(LoadLibraryA將它解釋為一個串指針)。它必須指向遠程進程的某些數據。
第一個問題實際上是由它自己解決的。LoadLibrary 和 FreeLibray 兩個函數都在 kernel32.dll 中。因為必須保證kernel32存在并且在每個“常規”進程中的加載地址要相同,LoadLibrary/FreeLibray 的地址在每個進程中的地址要相同,這就保證了有效的指針被傳遞到遠程進程。
第二個問題也很容易解決。只要通過 WriteProcessMemory 將 DLL 模塊名(LoadLibrary需要的DLL模塊名)拷貝到遠程進程即可。
所以,為了使用CreateRemoteThread 和 LoadLibrary 技術,需要按照下列步驟來做:
- 獲取遠程進程(OpenProcess)的 HANDLE;
- 為遠程進程中的 DLL名分配內存(VirtualAllocEx);
- 將 DLL 名,包含全路徑名,寫入分配的內存(WriteProcessMemory);
- 用 CreateRemoteThread 和 LoadLibrary. 將你的DLL映射到遠程進程;
- 等待直到線程終止(WaitForSingleObject),也就是說直到 LoadLibrary 調用返回。另一種方法是,一旦 DllMain(用DLL_PROCESS_ATTACH調用)返回,線程就會終止;
- 獲取遠程線程的退出代碼(GetExitCodeThread)。注意這是一個 LoadLibrary 返回的值,因此是所映射 DLL 的基地址(HMODULE)。
在第二步中釋放分配的地址(VirtualFreeEx);
- 用 CreateRemoteThread 和 FreeLibrary從遠程進程中卸載 DLL。傳遞在第六步獲取的 HMODULE 句柄到 FreeLibrary(通過 CreateRemoteThread 的lpParameter參數);
- 注意:如果你注入的 DLL 產生任何新的線程,一定要在卸載DLL 之前將它們都終止掉;
- 等待直到線程終止(WaitForSingleObject);
此外,處理完成后不要忘了關閉所有句柄,包括在第四步和第八步創建的兩個線程以及在第一步獲取的遠程線程句柄。現在讓我們看一下 LibSpy 的部分代碼,為了簡單起見,上述步驟的實現細節中的錯誤處理以及 UNICODE 支持部分被略掉。
HANDLE hThread;
char szLibPath[_MAX_PATH]; // “LibSpy.dll”模塊的名稱 (包括全路徑);
void* pLibRemote; // 遠程進程中的地址,szLibPath 將被拷貝到此處;
DWORD hLibModule; // 要加載的模塊的基地址(HMODULE)
HMODULE hKernel32 = ::GetModuleHandle("Kernel32");
// 初始化szLibPath
//...
// 1. 在遠程進程中為szLibPath 分配內存
// 2. 將szLibPath 寫入分配的內存
pLibRemote = ::VirtualAllocEx( hProcess, NULL, sizeof(szLibPath),
MEM_COMMIT, PAGE_READWRITE );
::WriteProcessMemory( hProcess, pLibRemote, (void*)szLibPath,
sizeof(szLibPath), NULL );
// 將"LibSpy.dll" 加載到遠程進程(使用CreateRemoteThread 和 LoadLibrary)
hThread = ::CreateRemoteThread( hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32,
"LoadLibraryA" ),
pLibRemote, 0, NULL );
::WaitForSingleObject( hThread, INFINITE );
// 獲取所加載的模塊的句柄
::GetExitCodeThread( hThread, &hLibModule );
// 清除
::CloseHandle( hThread );
::VirtualFreeEx( hProcess, pLibRemote, sizeof(szLibPath), MEM_RELEASE );
假設我們實際想要注入的代碼——SendMessage ——被放在DllMain (DLL_PROCESS_ATTACH)中,現在它已經被執行。那么現在應該從目標進程中將DLL 卸載:
// 從目標進程中卸載"LibSpy.dll" (使用 CreateRemoteThread 和 FreeLibrary)
hThread = ::CreateRemoteThread( hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32,
"FreeLibrary" ),
(void*)hLibModule, 0, NULL );
::WaitForSingleObject( hThread, INFINITE );
// 清除
::CloseHandle( hThread );
進程間通信 到目前為止,我們只討論了關于如何將DLL 注入到遠程進程的內容,但是,在大多數情況下,注入的 DLL 都需要與原應用程序進行某種方式的通信(回想一下,我們的DLL是被映射到某個遠程進程的地址空間里了,不是在本地應用程序的地址空間中)。比如秘密偵測程序,DLL必須要知道實際包含密碼的控件句柄,顯然,編譯時無法將這個值進行硬編碼。同樣,一旦DLL獲得了秘密,它必須將它發送回原應用程序,以便能正確顯示出來。
幸運的是,有許多方法處理這個問題,文件映射,WM_COPYDATA,剪貼板以及很簡單的 #pragma data_seg 共享數據段等,本文我不打算使用這些技術,因為MSDN(“進程間通信”部分)以及其它渠道可以找到很多文檔參考。不過我在 LibSpy例子中還是使用了 #pragma data_seg。細節請參考 LibSpy 源代碼。
第三部分:CreateRemoteThread 和 WriteProcessMemory 技術
范例程序——WinSpy
另外一個將代碼拷貝到另一個進程地址空間并在該進程上下文中執行的方法是使用遠程線程和 WriteProcessMemory API。這種方法不用編寫單獨的DLL,而是用 WriteProcessMemory 直接將代碼拷貝到遠程進程——然后用 CreateRemoteThread 啟動它執行。先來看看 CreateRemoteThread 的聲明:
HANDLE CreateRemoteThread(
HANDLE hProcess, // 傳入創建新線程的進程句柄
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 安全屬性指針
DWORD dwStackSize, // 字節為單位的初始線程堆棧
LPTHREAD_START_ROUTINE lpStartAddress, // 指向線程函數的指針
LPVOID lpParameter, // 新線程使用的參數
DWORD dwCreationFlags, // 創建標志
LPDWORD lpThreadId // 指向返回的線程ID
);
如果你比較它與 CreateThread(MSDN)的聲明,你會注意到如下的差別:
- 在 CreateRemoteThread中,hProcess是額外的一個參數,一個進程句柄,新線程就是在這個進程中創建的;
- 在 CreateRemoteThread中,lpStartAddress 表示的是在遠程進程地址空間中的線程起始地址。線程函數必須要存在于遠程進程中,所以我們不能簡單地傳遞一個指針到本地的 ThreadFunc。必須得先拷貝代碼到遠程進程;
- 同樣,lpParameter 指向的數據也必須要存在于遠程進程,所以也得將它拷貝到那。
綜上所述,我們得按照如下的步驟來做:
- 獲取一個遠程進程的HANDLE (OpenProces) ;
- 在遠程進程地址空間中為注入的數據分配內存(VirtualAllocEx);
- 將初始的 INDATA 數據結構的一個拷貝寫入分配的內存中(WriteProcessMemory);
- 在遠程進程地址空間中為注入的代碼分配內存;
- 將 ThreadFunc 的一個拷貝寫入分配的內存;
- 用 CreateRemoteThread啟動遠程的 ThreadFunc 拷貝;
- 等待遠程線程終止(WaitForSingleObject);
- 獲取遠程來自遠程進程的結果(ReadProcessMemory 或 GetExitCodeThread);
- 釋放在第二步和第四步中分配的內存(VirtualFreeEx);
- 關閉在第六步和第一步獲取的句柄(CloseHandle);
ThreadFunc 必須要遵循的原則:
- 除了kernel32.dll 和user32.dll 中的函數之外,ThreadFunc 不要調用任何其它函數,只有 kernel32.dll 和user32.dll被保證在本地和目標進程中的加載地址相同(注意,user32.dll并不是被映射到每個 Win32 的進程)。如果你需要來自其它庫中的函數,將LoadLibrary 和 GetProcAddress 的地址傳給注入的代碼,然后放手讓它自己去做。如果映射到目標進程中的DLL有沖突,你也可以用 GetModuleHandle 來代替 LoadLibrary。
同樣,如果你想在 ThreadFunc 中調用自己的子例程,要單獨把每個例程的代碼拷貝到遠程進程并用 INJDATA為 ThreadFunc 提供代碼的地址。
- 不要使用靜態字符串,而要用 INJDATA 來傳遞所有字符串。之所以要這樣,是因為編譯器將靜態字符串放在可執行程序的“數據段”中,可是引用(指針)是保留在代碼中的。那么,遠程進程中ThreadFunc 的拷貝指向的內容在遠程進程的地址空間中是不存在的。
- 去掉 /GZ 編譯器開關,它在調試版本中是默認設置的。
- 將 ThreadFunc 和 AfterThreadFunc 聲明為靜態類型,或者不啟用增量鏈接。
- ThreadFunc 中的局部變量一定不能超過一頁(也就是 4KB)。
注意在調試版本中4KB的空間有大約10個字節是用于內部變量的。
- 如果你有一個開關語句塊大于3個case 語句,將它們像下面這樣拆分開:
switch( expression ) {
case constant1: statement1; goto END;
case constant2: statement2; goto END;
case constant3: statement2; goto END;
}
switch( expression ) {
case constant4: statement4; goto END;
case constant5: statement5; goto END;
case constant6: statement6; goto END;
}
END:
或者將它們修改成一個 if-else if 結構語句(參見附錄E)。
如果你沒有按照這些規則來做,目標進程很可能會崩潰。所以務必牢記。在目標進程中不要假設任何事情都會像在本地進程中那樣 (參見附錄F)。
GetWindowTextRemote(A/W)
要想從“遠程”編輯框獲得密碼,你需要做的就是將所有功能都封裝在GetWindowTextRemot(A/W):中。
int GetWindowTextRemoteA( HANDLE hProcess, HWND hWnd, LPSTR lpString );
int GetWindowTextRemoteW( HANDLE hProcess, HWND hWnd, LPWSTR lpString );
參數說明:
hProcess:編輯框控件所屬的進程句柄;
hWnd:包含密碼的編輯框控件句柄;
lpString:接收文本的緩沖指針;
返回值:返回值是拷貝的字符數;
下面讓我們看看它的部分代碼——尤其是注入數據的代碼——以便明白 GetWindowTextRemote 的工作原理。此處為簡單起見,略掉了 UNICODE 支持部分。
INJDATA
typedef LRESULT (WINAPI *SENDMESSAGE)(HWND,UINT,WPARAM,LPARAM);
typedef struct {
HWND hwnd; // 編輯框句柄
SENDMESSAGE fnSendMessage; // 指向user32.dll 中 SendMessageA 的指針
char psText[128]; // 接收密碼的緩沖
} INJDATA;
INJDATA 是一個被注入到遠程進程的數據結構。但在注入之前,結構中指向 SendMessageA 的指針是在本地應用程序中初始化的。因為對于每個使用user32.dll的進程來說,user32.dll總是被映射到相同的地址,因此,SendMessageA 的地址也肯定是相同的。這就保證了被傳遞到遠程進程的是一個有效的指針。
ThreadFunc函數
static DWORD WINAPI ThreadFunc (INJDATA *pData)
{
pData->fnSendMessage( pData->hwnd, WM_GETTEXT, // Get password
sizeof(pData->psText),
(LPARAM)pData->psText );
return 0;
}
// 該函數在ThreadFunc之后標記內存地址
// int cbCodeSize = (PBYTE) AfterThreadFunc - (PBYTE) ThreadFunc.
static void AfterThreadFunc (void)
{
}
ThradFunc 是被遠程線程執行的代碼。
- 注釋:注意AfterThreadFunc 是如何計算 ThreadFunc 大小的。通常這樣做并不是一個好辦法,因為鏈接器可以隨意更改函數的順序(也就是說ThreadFunc可能被放在 AfterThreadFunc之后)。這一點你可以在小項目中很好地保證函數的順序是預先設想好的,比如 WinSpy 程序。在必要的情況下,你還可以使用 /ORDER 鏈接器選項來解決函數鏈接順序問題。或者用反匯編確定 ThreadFunc 函數的大小。
如何使用該技術子類化遠程控件
范例程序——InjectEx
下面我們將討論一些更復雜的內容,如何子類化屬于另一個進程的控件。
首先,你得拷貝兩個函數到遠程進程來完成此任務
- ThreadFunc實際上是通過 SetWindowLong子類化遠程進程中的控件;
- NewProc是子類化控件的新窗口過程;
這里主要的問題是如何將數據傳到遠程窗口過程 NewProc,因為 NewProc 是一個回調函數,它必須遵循特定的規范和原則,我們不能簡單地在參數中傳遞 INJDATA指針。幸運的是我找到了有兩個方法來解決這個問題,只不過要借助匯編語言,所以不要忽略了匯編,關鍵時候它是很有用的!
方法一:
如下圖所示:

在遠程進程中,INJDATA 被放在NewProc 之前,這樣 NewProc 在編譯時便知道 INJDATA 在遠程進程地址空間中的內存位置。更確切地說,它知道相對于其自身位置的 INJDATA 的地址,我們需要所有這些信息。下面是 NewProc 的代碼:
static LRESULT CALLBACK NewProc(
HWND hwnd, // 窗口句柄
UINT uMsg, // 消息標示符
WPARAM wParam, // 第一個消息參數
LPARAM lParam ) // 第二個消息參數
{
INJDATA* pData = (INJDATA*) NewProc; // pData 指向 NewProc
pData--; // 現在pData 指向INJDATA;
// 回想一下INJDATA 被置于遠程進程NewProc之前;
//-----------------------------
// 此處是子類化代碼
// ........
//-----------------------------
// 調用原窗口過程;
// fnOldProc (由SetWindowLong 返回) 被(遠程)ThreadFunc初始化
// 并被保存在(遠程)INJDATA;中
return pData->fnCallWindowProc( pData->fnOldProc,
hwnd,uMsg,wParam,lParam );
}
但這里還有一個問題,見第一行代碼:
INJDATA* pData = (INJDATA*) NewProc;
這種方式 pData得到的是硬編碼值(在我們的進程中是原 NewProc 的內存地址)。這不是我們十分想要的。在遠程進程中,NewProc “當前”拷貝的內存地址與它被移到的實際位置是無關的,換句話說,我們會需要某種類型的“this 指針”。
雖然用 C/C++ 無法解決這個問題,但借助內聯匯編可以解決,下面是對 NewProc的修改:
static LRESULT CALLBACK NewProc(
HWND hwnd, // 窗口句柄
UINT uMsg, // 消息標示符
WPARAM wParam, // 第一個消息參數
LPARAM lParam ) // 第二個消息參數
{
// 計算INJDATA 結構的位置
// 在遠程進程中記住這個INJDATA
// 被放在NewProc之前
INJDATA* pData;
_asm {
call dummy
dummy:
pop ecx // <- ECX 包含當前的EIP
sub ecx, 9 // <- ECX 包含NewProc的地址
mov pData, ecx
}
pData--;
//-----------------------------
// 此處是子類化代碼
// ........
//-----------------------------
// 調用原來的窗口過程
return pData->fnCallWindowProc( pData->fnOldProc,
hwnd,uMsg,wParam,lParam );
}
那么,接下來該怎么辦呢?事實上,每個進程都有一個特殊的寄存器,它指向下一條要執行的指令的內存位置。即所謂的指令指針,在32位 Intel 和 AMD 處理器上被表示為 EIP。因為 EIP是一個專用寄存器,你無法象操作一般常規存儲器(如:EAX,EBX等)那樣通過編程存取它。也就是說沒有操作代碼來尋址 EIP,以便直接讀取或修改其內容。但是,EIP 仍然還是可以通過間接方法修改的(并且隨時可以修改),通過JMP,CALL和RET這些指令實現。下面我們就通過例子來解釋通過 CALL/RET 子例程調用機制在32位 Intel 和 AMD 處理器上是如何工作的。
當你調用(通過 CALL)某個子例程時,子例程的地址被加載到 EIP,但即便是在 EIP杯修改之前,其舊的那個值被自動PUSH到堆棧(被用于后面作為指令指針返回)。在子例程執行完時,RET 指令自動將堆棧頂POP到 EIP。
現在你知道了如何通過 CALL 和 RET 實現 EIP 的修改,但如何獲取其當前的值呢?下面就來解決這個問題,前面講過,CALL PUSH EIP 到堆棧,所以,為了獲取其當前值,調用“啞函數”,然后再POP堆棧頂。讓我們用編譯后的 NewProc 來解釋這個竅門。
Address OpCode/Params Decoded instruction
--------------------------------------------------
:00401000 55 push ebp ; entry point of
; NewProc
:00401001 8BEC mov ebp, esp
:00401003 51 push ecx
:00401004 E800000000 call 00401009 ; *a* call dummy
:00401009 59 pop ecx ; *b*
:0040100A 83E909 sub ecx, 00000009 ; *c*
:0040100D 894DFC mov [ebp-04], ecx ; mov pData, ECX
:00401010 8B45FC mov eax, [ebp-04]
:00401013 83E814 sub eax, 00000014 ; pData--;
.....
.....
:0040102D 8BE5 mov esp, ebp
:0040102F 5D pop ebp
:00401030 C21000 ret 0010
- 啞函數調用;就是JUMP到下一個指令并PUSH EIP到堆棧;
- 然后將堆棧頂POP到 ECX,ECX再保存EIP;這也是 POP EIP指令的真正地址;
- 注意 NewProc 的入口點和 “POP ECX”之間的“距離”是9 個字節;因此為了計算 NewProc的地址,要從 ECX 減9。
這樣一來,不管 NewProc 被移到什么地方,它總能計算出其自己的地址。但是,NewProc 的入口點和 “POP ECX”之間的距離可能會隨著你對編譯/鏈接選項的改變而變化,由此造成 RELEASE和DEBUG版本之間也會有差別。但關鍵是你仍然確切地知道編譯時的值。
- 首先,編譯函數
- 用反匯編確定正確的距離
- 最后,用正確的距離值重新編譯
此即為 InjecEx 中使用的解決方案,類似于 HookInjEx,交換鼠標點擊“開始”左右鍵時的功能。
方法二:
對于我們的問題,在遠程進程地址空間中將 INJDATA 放在 NewProc 前面不是唯一的解決辦法。看下面 NewProc的變異版本:
static LRESULT CALLBACK NewProc(
HWND hwnd, // 窗口句柄
UINT uMsg, // 消息標示符
WPARAM wParam, // 第一個消息參數
LPARAM lParam ) // 第二個消息參數
{
INJDATA* pData = 0xA0B0C0D0; // 虛構值
//-----------------------------
// 子類化代碼
// ........
//-----------------------------
// 調用原來的窗口過程
return pData->fnCallWindowProc( pData->fnOldProc,
hwnd,uMsg,wParam,lParam );
}
此處 0xA0B0C0D0 只是遠程進程地址空間中真實(絕對)INJDATA地址的占位符。前面講過,你無法在編譯時知道該地址。但你可以在調用 VirtualAllocEx (為INJDATA)之后得到 INJDATA 在遠程進程中的位置。編譯我們的 NewProc 后,可以得到如下結果:
Address OpCode/Params Decoded instruction
--------------------------------------------------
:00401000 55 push ebp
:00401001 8BEC mov ebp, esp
:00401003 C745FCD0C0B0A0 mov [ebp-04], A0B0C0D0
:0040100A ...
....
:0040102D 8BE5 mov esp, ebp
:0040102F 5D pop ebp
:00401030 C21000 ret 0010
因此,其編譯的代碼(十六進制)將是:
558BECC745FCD0C0B0A0......8BE55DC21000.
現在你可以象下面這樣繼續:
- 將INJDATA,ThreadFunc和NewProc 拷貝到目標進程;
- 修改 NewProc 的代碼,以便 pData 中保存的是 INJDATA 的真實地址。
例如,假設 INJDATA 的地址(VirtualAllocEx返回的值)在目標進程中是 0x008a0000。然后象下面這樣修改NewProc的代碼:
558BECC745FCD0C0B0A0......8BE55DC21000 <- 原來的NewProc (注1)
558BECC745FC00008A00......8BE55DC21000 <- 修改后的NewProc,使用的是INJDATA的實際地址。
也就是說,你用真正的 INJDATA(注2) 地址替代了虛擬值 A0B0C0D0(注2)。
- 開始執行遠程的 ThreadFunc,它負責子類化遠程進程中的控件。
- 注1、有人可能會問,為什么地址 A0B0C0D0 和 008a0000 在編譯時順序是相反的。因為 Intel 和 AMD 處理器使用 little-endian 符號來表示(多字節)數據。換句話說,某個數字的低位字節被存儲在內存的最小地址處,而高位字節被存儲在最高位地址。
假設“UNIX”這個詞存儲用4個字節,在 big-endian 系統中,它被存為“UNIX”,在 little-endian 系統中,它將被存為“XINU”。
- 注2、某些破解(很糟)以類似的方式修改可執行代碼,但是一旦加載到內存,一個程序是無法修改自己的代碼的(代碼駐留在可執行程序的“.text” 區域,這個區域是寫保護的)。但仍可以修改遠程的 NewProc,因為它是先前以 PAGE_EXECUTE_READWRITE 許可方式被拷貝到某個內存塊中的。
何時使用 CreateRemoteThread 和 WriteProcessMemory 技術
與其它方法比較,使用 CreateRemoteThread 和 WriteProcessMemory 技術進行代碼注入更靈活,這種方法不需要額外的 dll,不幸的是,該方法更復雜并且風險更大,只要ThreadFunc出現哪怕一丁點錯誤,很容易就讓(并且最大可能地會)使遠程進程崩潰(參見附錄 F),因為調試遠程 ThreadFunc 將是一個可怕的夢魘,只有在注入的指令數很少時,你才應該考慮使用這種技術進行注入,對于大塊的代碼注入,最好用 I.和II 部分討論的方法。
WinSpy 以及 InjectEx 請從這里下載源代碼。
結束語
到目前為止,有幾個問題是我們未提及的,現總結如下:
解決方案 |
OS |
進程 |
I、Hooks |
Win9x 和 WinNT |
僅僅與 USER32.DLL (注3)鏈接的進程 |
II、CreateRemoteThread & LoadLibrary |
僅 WinNT(注4) |
所有進程(注5), 包括系統服務(注6) |
III、CreateRemoteThread & WriteProcessMemory |
僅 WinNT |
所有進程, 包括系統服務 |
- 注3:顯然,你無法hook一個沒有消息隊列的線程,此外,SetWindowsHookEx不能與系統服務一起工作,即使它們與 USER32.DLL 進行鏈接;
- 注4:Win9x 中沒有 CreateRemoteThread,也沒有 VirtualAllocEx (實際上,在Win9x 中可以仿真,但不是本文討論的問題了);
- 注5:所有進程 = 所有 Win32 進程 + csrss.exe
本地應用 (smss.exe, os2ss.exe, autochk.exe 等)不使用 Win32 API,所以也不會與 kernel32.dll 鏈接。唯一一個例外是 csrss.exe,Win32 子系統本身,它是本地應用程序,但其某些庫(~winsrv.dll)需要 Win32 DLLs,包括 kernel32.dll;
- 注6:如果你想要將代碼注入到系統服務中(lsass.exe, services.exe, winlogon.exe 等)或csrss.exe,在打開遠程句柄(OpenProcess)之前,將你的進程優先級置為 “SeDebugPrivilege”(AdjustTokenPrivileges)。
最后,有幾件事情一定要了然于心:你的注入代碼很容易摧毀目標進程,尤其是注入代碼本身出錯的時候,所以要記住:權力帶來責任!
因為本文中的許多例子是關于密碼的,你也許還讀過 Zhefu Zhang 寫的另外一篇文章“Super Password Spy++” ,在該文中,他解釋了如何獲取IE 密碼框中的內容,此外,他還示范了如何保護你的密碼控件免受類似的攻擊。
附錄A:
為什么 kernel32.dll 和user32.dll 總是被映射到相同的地址。
我的假定:因為Microsoft 的程序員認為這樣做有助于速度優化,為什么呢?我的解釋是——通常一個可執行程序是由幾個部分組成,其中包括“.reloc” 。當鏈接器創建 EXE 或者 DLL文件時,它對文件被映射到哪個內存地址做了一個假設。這就是所謂的首選加載/基地址。在映像文件中所有絕對地址都是基于鏈接器首選的加載地址,如果由于某種原因,映像文件沒有被加載到該地址,那么這時“.reloc”就起作用了,它包含映像文件中的所有地址的清單,這個清單中的地址反映了鏈接器首選加載地址和實際加載地址的差別(無論如何,要注意編譯器產生的大多數指令使用某種相對地址尋址,因此,并沒有你想象的那么多地址可供重新分配),另一方面,如果加載器能夠按照鏈接器首選地址加載映像文件,那么“.reloc”就被完全忽略掉了。
但kernel32.dll 和user32.dll 及其加載地址為何要以這種方式加載呢?因為每一個 Win32 程序都需要kernel32.dll,并且大多數Win32 程序也需要 user32.dll,那么總是將它們(kernel32.dll 和user32.dll)映射到首選地址可以改進所有可執行程序的加載時間。這樣一來,加載器絕不能修改kernel32.dll and user32.dll.中的任何(絕對)地址。我們用下面的例子來說明:
將某個應用程序 App.exe 的映像基地址設置成 KERNEL32的地址(/base:"0x77e80000")或 USER32的首選基地址(/base:"0x77e10000"),如果 App.exe 不是從 USER32 導入方式來使用 USER32,而是通過LoadLibrary 加載,那么編譯并運行App.exe 后,會報出錯誤信息("Illegal System DLL Relocation"——非法系統DLL地址重分配),App.exe 加載失敗。
為什么會這樣呢?當創建進程時,Win 2000、Win XP 和Win 2003系統的加載器要檢查 kernel32.dll 和user32.dll 是否被映射到首選基地址(實際上,它們的名字都被硬編碼進了加載器),如果沒有被加載到首選基地址,將發出錯誤。在 WinNT4中,也會檢查ole32.dll,在WinNT 3.51 和較低版本的Windows中,由于不會做這樣的檢查,所以kernel32.dll 和user32.dll可以被加載任何地方。只有ntdll.dll總是被加載到其基地址,加載器不進行檢查,一旦ntdll.dll沒有在其基地址,進程就無法創建。
總之,對于 WinNT 4 和較高的版本中
- 一定要被加載到基地址的DLLs 有:kernel32.dll、user32.dll 和ntdll.dll;
- 每個Win32 程序都要使用的 DLLs+ csrss.exe:kernel32.dll 和ntdll.dll;
- 每個進程都要使用的DLL只有一個,即使是本地應用:ntdll.dll;
附錄B:
/GZ 編譯器開關
在生成 Debug 版本時,/GZ 編譯器特性是默認打開的。你可以用它來捕獲某些錯誤(具體細節請參考相關文檔)。但對我們的可執行程序意味著什么呢?
當打開 /GZ 開關,編譯器會添加一些額外的代碼到可執行程序中每個函數所在的地方,包括一個函數調用(被加到每個函數的最后)——檢查已經被我們的函數修改的 ESP堆棧指針。什么!難道有一個函數調用被添加到 ThreadFunc 嗎?那將導致災難。ThreadFunc 的遠程拷貝將調用一個在遠程進程中不存在的函數(至少是在相同的地址空間中不存在)
附錄C:
靜態函數和增量鏈接
增量鏈接主要作用是在生成應用程序時縮短鏈接時間。常規鏈接和增量鏈接的可執行程序之間的差別是——增量鏈接時,每個函數調用經由一個額外的JMP指令,該指令由鏈接器發出(該規則的一個例外是函數聲明為靜態)。這些 JMP 指令允許鏈接器在內存中移動函數,這種移動無需修改引用函數的 CALL指令。但這些JMP指令也確實導致了一些問題:如 ThreadFunc 和 AfterThreadFunc 將指向JMP指令而不是實際的代碼。所以當計算ThreadFunc 的大小時:
const int cbCodeSize = ((LPBYTE) AfterThreadFunc - (LPBYTE) ThreadFunc)
你實際上計算的是指向 ThreadFunc 的JMPs 和AfterThreadFunc之間的“距離” (通常它們會緊挨著,不用考慮距離問題)。現在假設 ThreadFunc 的地址位于004014C0 而伴隨的 JMP指令位于 00401020。
:00401020 jmp 004014C0
...
:004014C0 push EBP ; ThreadFunc 的實際地址
:004014C1 mov EBP, ESP
...
那么
WriteProcessMemory( .., &ThreadFunc, cbCodeSize, ..);
將拷貝“JMP 004014C0”指令(以及隨后cbCodeSize范圍內的所有指令)到遠程進程——不是實際的 ThreadFunc。遠程進程要執行的第一件事情將是“JMP 004014C0” 。它將會在其最后幾條指令當中——遠程進程和所有進程均如此。但 JMP指令的這個“規則”也有例外。如果某個函數被聲明為靜態的,它將會被直接調用,即使增量鏈接也是如此。這就是為什么規則#4要將 ThreadFunc 和 AfterThreadFunc 聲明為靜態或禁用增量鏈接的緣故。(有關增量鏈接的其它信息參見 Matt Pietrek的文章“Remove Fatty Deposits from Your Applications Using Our 32-bit Liposuction Tools” )
附錄D:
為什么 ThreadFunc的局部變量只有 4k?
局部變量總是存儲在堆棧中,如果某個函數有256個字節的局部變量,當進入該函數時,堆棧指針就減少256個字節(更精確地說,在函數開始處)。例如,下面這個函數:
void Dummy(void) {
BYTE var[256];
var[0] = 0;
var[1] = 1;
var[255] = 255;
}
編譯后的匯編如下:
:00401000 push ebp
:00401001 mov ebp, esp
:00401003 sub esp, 00000100 ; change ESP as storage for
; local variables is needed
:00401006 mov byte ptr [esp], 00 ; var[0] = 0;
:0040100A mov byte ptr [esp+01], 01 ; var[1] = 1;
:0040100F mov byte ptr [esp+FF], FF ; var[255] = 255;
:00401017 mov esp, ebp ; restore stack pointer
:00401019 pop ebp
:0040101A ret
注意上述例子中,堆棧指針是如何被修改的?而如果某個函數需要4KB以上局部變量內存空間又會怎么樣呢?其實,堆棧指針并不是被直接修改,而是通過另一個函數調用來修改的。就是這個額外的函數調用使得我們的 ThreadFunc “被破壞”了,因為其遠程拷貝會調用一個不存在的東西。
我們看看文檔中對堆棧探測和 /Gs編譯器選項是怎么說的:
——“/GS是一個控制堆棧探測的高級特性,堆棧探測是一系列編譯器插入到每個函數調用的代碼。當函數被激活時,堆棧探測需要的內存空間來存儲相關函數的局部變量。
如果函數需要的空間大于為局部變量分配的堆棧空間,其堆棧探測被激活。默認的大小是一個頁面(在80x86處理器上4kb)。這個值允許在Win32 應用程序和Windows NT虛擬內存管理器之間進行謹慎調整以便增加運行時承諾給程序堆棧的內存。”
我確信有人會問:文檔中的“……堆棧探測到一塊需要的內存空間來存儲相關函數的局部變量……”那些編譯器選項(它們的描述)在你完全弄明白之前有時真的讓人氣憤。例如,如果某個函數需要12KB的局部變量存儲空間,堆棧內存將進行如下方式的分配(更精確地說是“承諾” )。
sub esp, 0x1000 ; "分配" 第一次 4 Kb
test [esp], eax ; 承諾一個新頁內存(如果還沒有承諾)
sub esp, 0x1000 ; "分配" 第二次4 Kb
test [esp], eax ; ...
sub esp, 0x1000
test [esp], eax
注意4KB堆棧指針是如何被修改的,更重要的是,每一步之后堆棧底是如何被“觸及”(要經過檢查)。這樣保證在“分配”(承諾)另一頁面之前,當前頁面承諾的范圍也包含堆棧底。
注意事項
“每一個線程到達其自己的堆棧空間,默認情況下,此空間由承諾的以及預留的內存組成,每個線程使用 1 MB預留的內存,以及一頁承諾的內存,系統將根據需要從預留的堆棧內存中承諾一頁內存區域” (參見 MSDN CreateThread > dwStackSize > Thread Stack Size)
還應該清楚為什么有關 /GS 的文檔說在堆棧探針在 Win32 應用程序和Windows NT虛擬內存管理器之間進行謹慎調整。
現在回到我們的ThreadFunc以及 4KB 限制
雖然你可以用 /Gs 防止調用堆棧探測例程,但在文檔對于這樣的做法給出了警告,此外,文件描述可以用 #pragma check_stack 指令關閉或打開堆棧探測。但是這個指令好像一點作用都沒有(要么這個文檔是垃圾,要么我疏忽了其它一些信息?)。總之,CreateRemoteThread 和 WriteProcessMemory 技術只能用于注入小塊代碼,所以你的局部變量應該盡量少耗費一些內存字節,最好不要超過 4KB限制。
附錄E:
為什么要將開關語句拆分成三個以上?
用下面這個例子很容易解釋這個問題,假設有如下這么一個函數:
int Dummy( int arg1 )
{
int ret =0;
switch( arg1 ) {
case 1: ret = 1; break;
case 2: ret = 2; break;
case 3: ret = 3; break;
case 4: ret = 0xA0B0; break;
}
return ret;
}
編譯后變成下面這個樣子:
地址 操作碼/參數 解釋后的指令
--------------------------------------------------
; arg1 -> ECX
:00401000 8B4C2404 mov ecx, dword ptr [esp+04]
:00401004 33C0 xor eax, eax ; EAX = 0
:00401006 49 dec ecx ; ECX --
:00401007 83F903 cmp ecx, 00000003
:0040100A 771E ja 0040102A
; JMP 到表***中的地址之一
; 注意 ECX 包含的偏移
:0040100C FF248D2C104000 jmp dword ptr [4*ecx+0040102C]
:00401013 B801000000 mov eax, 00000001 ; case 1: eax = 1;
:00401018 C3 ret
:00401019 B802000000 mov eax, 00000002 ; case 2: eax = 2;
:0040101E C3 ret
:0040101F B803000000 mov eax, 00000003 ; case 3: eax = 3;
:00401024 C3 ret
:00401025 B8B0A00000 mov eax, 0000A0B0 ; case 4: eax = 0xA0B0;
:0040102A C3 ret
:0040102B 90 nop
; 地址表***
:0040102C 13104000 DWORD 00401013 ; jump to case 1
:00401030 19104000 DWORD 00401019 ; jump to case 2
:00401034 1F104000 DWORD 0040101F ; jump to case 3
:00401038 25104000 DWORD 00401025 ; jump to case 4
注意如何實現這個開關語句?
與其單獨檢查每個CASE語句,不如創建一個地址表,然后通過簡單地計算地址表的偏移量而跳轉到正確的CASE語句。這實際上是一種改進。假設你有50個CASE語句。如果不使用上述的技巧,你得執行50次 CMP和JMP指令來達到最后一個CASE。相反,有了地址表后,你可以通過表查詢跳轉到任何CASE語句,從計算機算法角度和時間復雜度看,我們用O(5)代替了O(2n)算法。其中:
- O表示最壞的時間復雜度;
- 我們假設需要5條指令來進行表查詢計算偏移量,最終跳到相應的地址;
現在,你也許認為出現上述情況只是因為CASE常量被有意選擇為連續的(1,2,3,4)。幸運的是,它的這個方案可以應用于大多數現實例子中,只有偏移量的計算稍微有些復雜。但有兩個例外:
- 如果CASE語句少于等于三個;
- 如果CASE 常量完全互不相關(如:“"case 1” ,“case 13” ,“case 50” , 和“case 1000” );
顯然,單獨判斷每個的CASE常量的話,結果代碼繁瑣耗時,但使用CMP和JMP指令則使得結果代碼的執行就像普通的if-else 語句。
有趣的地方:如果你不明白CASE語句使用常量表達式的理由,那么現在應該弄明白了吧。為了創建地址表,顯然在編譯時就應該知道相關地址。
現在回到問題!
注意到地址 0040100C 處的JMP指令了嗎?我們來看看Intel關于十六進制操作碼 FF 的文檔是怎么說的:
操作碼 指令 描述
FF /4 JMP r/m32 Jump near, absolute indirect,
address given in r/m32
原來JMP 使用了一種絕對尋址方式,也就是說,它的操作數(CASE語句中的 0040102C)表示一個絕對地址。還用我說什么嗎?遠程 ThreadFunc 會盲目地認為地址表中開關地址是 0040102C,JMP到一個錯誤的地方,造成遠程進程崩潰。
附錄F:
為什么遠程進程會崩潰呢?
當遠程進程崩潰時,它總是會因為下面這些原因:
- 在ThreadFunc 中引用了一個不存在的串;
- 在在ThreadFunc 中 中一個或多個指令使用絕對尋址(參見附錄E);
- ThreadFunc 調用某個不存在的函數(該調用可能是編譯器或鏈接器添加的)。你在反匯編器中可以看到這樣的情形:
:004014C0 push EBP ; ThreadFunc 的入口點
:004014C1 mov EBP, ESP
...
:004014C5 call 0041550 ; 這里將使遠程進程崩潰
...
:00401502 ret
如果 CALL 是由編譯器添加的指令(因為某些“禁忌” 開關如/GZ是打開的),它將被定位在 ThreadFunc 的開始的某個地方或者結尾處。
不管哪種情況,你都要小心翼翼地使用 CreateRemoteThread 和 WriteProcessMemory 技術。尤其要注意你的編譯器/鏈接器選項,一不小心它們就會在 ThreadFunc 添加內容。