原版位址:http://www.codeproject.com/threads/winspy.asp?df=100&forumid=16291&select=1025152&msg=1025152
下載下傳整個壓縮包
下載下傳WinSpy
作者:Robert Kuster
翻譯:袁曉輝([email protected])
摘要:如何向其他線程的位址空間中注入代碼并在這個線程的上下文中執行之。
目錄:
●導言
●Windows 鈎子(Hooks)
●CreateRemoteThread 和LoadLibrary 技術
○程序間通訊
●CreateRemoteThread 和 WriteProcessmemory 技術
○如何使用該技術子類(SubClass)其他程序中的控件
○什麼情況下适合使用該技術
●寫在最後的話
●附錄
●參考
●文章曆史
導言:
我們在Code project(www.codeproject.com)上可以找到許多密碼間諜程式(譯者注:那些可以看到别的程式中密碼框内容的軟體),他們都依賴于Windows鈎子技術。要實作這個還有其他的方法嗎?有!但是,首先,讓我們簡單回顧一下我們要實作的目标,以便你能弄清楚我在說什麼。
要讀取一個控件的内容,不管它是否屬于你自己的程式,一般來說需要發送 WM_GETTEXT 消息到那個控件。這對edit控件也有效,但是有一種情況例外。如果這個edit控件屬于其他程序并且具有 ES_PASSWORD 風格的話,這種方法就不會成功。隻有“擁有(OWNS)”這個密碼控件的程序才可以用 WM_GETTEXT 取得它的内容。是以,我們的問題就是:如何讓下面這句代碼在其他程序的位址空間中運作起來:
::SendMessage( hPwdEdit, WM_GETTEXT, nMaxChars, psBuffer );
一般來說,這個問題有三種可能的解決方案:
1. 把你的代碼放到一個DLL中;然後用 windows 鈎子把它映射到遠端程序。
2. 把你的代碼放到一個DLL中;然後用 CreateRemoteThread 和 LoadLibrary 把它映射到遠端程序。
3. 不用DLL,直接複制你的代碼到遠端程序(使用WriteProcessMemory)并且用CreateRemoteThread執行之。在這裡有詳細的說明:
Ⅰ. Windows 鈎子
示例程式:HookSpy 和 HookInjEx
Windows鈎子的主要作用就是監視某個線程的消息流動。一般可分為:
1. 局部鈎子,隻監視你自己程序中某個線程的消息流動。
2. 遠端鈎子,又可以分為:
a. 特定線程的,監視别的程序中某個線程的消息;
b. 系統級的,監視整個系統中正在運作的所有線程的消息。
如果被挂鈎(監視)的線程屬于别的程序(情況2a和2b),你的鈎子過程(hook procedure)必須放在一個動态連接配接庫(DLL)中。系統把這包含了鈎子過程的DLL映射到被挂鈎的線程的位址空間。Windows會映射整個DLL而不僅僅是你的鈎子過程。這就是為什麼windows鈎子可以用來向其他線程的位址空間注入代碼的原因了。
在這裡我不想深入讨論鈎子的問題(請看MSDN中對SetWindowsHookEx的說明),讓我再告訴你兩個文檔中找不到的訣竅,可能會有用:
1. 當SetWindowHookEx調用成功後,系統會自動映射這個DLL到被挂鈎的線程,但并不是立即映射。因為所有的Windows鈎子都是基于消息的,直到一個适當的事件發生後這個DLL才被映射。比如:
如果你安裝了一個監視所有未排隊的(nonqueued)的消息的鈎子(WH_CALLWNDPROC),隻有一個消息發送到被挂鈎線程(的某個視窗)後這個DLL才被映射。也就是說,如果在消息發送到被挂鈎線程之前調用了UnhookWindowsHookEx那麼這個DLL就永遠不會被映射到該線程(雖然SetWindowsHookEx調用成功了)。為了強制映射,可以在調用SetWindowsHookEx後立即發送一個适當的消息到那個線程。
同理,調用UnhookWindowsHookEx之後,隻有特定的事件發生後DLL才真正地從被挂鈎線程解除安裝。
2. 當你安裝了鈎子後,系統的性能會受到影響(特别是系統級的鈎子)。然而如果你隻是使用的特定線程的鈎子來映射DLL而且不截獲如何消息的話,這個缺陷也可以輕易地避免。看一下下面的代碼片段:
BOOL APIENTRY DllMain( HANDLE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved )
{
if( ul_reason_for_call == DLL_PROCESS_ATTACH )
{
//用 LoadLibrary增加引用次數
char lib_name[MAX_PATH];
::GetModuleFileName( hModule, lib_name, MAX_PATH );
::LoadLibrary( lib_name );
// 安全解除安裝鈎子
::UnhookWindowsHookEx( g_hHook );
}
return TRUE;
}
我們來看一下。首先,我們用鈎子映射這個DLL到遠端線程,然後,在DLL被真正映射進去後,我們立即解除安裝挂鈎(unhook)。一般來說當第一個消息到達被挂鈎線程後,這DLL會被解除安裝,然而我們通過LoadLibrary來增加這個DLL的引用次數,避免了DLL被解除安裝。
剩下的問題是:使用完畢後如何解除安裝這個DLL?UnhookWindowsHookEx不行了,因為我們已經對那個線程取消挂鈎(unhook)了。你可以這麼做:
○在你想要解除安裝這個DLL之前再安裝一個鈎子;
○發送一個“特殊”的消息到遠端線程;
○在你的新鈎子的鈎子過程(hook procedure)中截獲該消息,調用FreeLibrary 和 (譯者注:對新鈎子調用)UnhookwindowsHookEx。
現在,鈎子隻在映射DLL到遠端程序和從遠端程序解除安裝DLL時使用,對被挂鈎線程的性能沒有影響。也就是說,我們找到了一種(相比第二部分讨論的LoadLibrary技術)WinNT和Win9x下都可以使用的,不影響目的程序性能的DLL映射機制。
但是,我們應該在何種情況下使用該技巧呢?通常是在DLL需要在遠端程序中駐留較長時間(比如你要子類[subclass]另一個程序中的控件)并且你不想過于幹涉目的程序時比較适合使用這種技巧。我在HookSpy中并沒有使用它,因為那個DLL隻是短暫地注入一段時間――隻要能取得密碼就足夠了。我在另一個例子HookInjEx中示範了這種方法。HookInjEx把一個DLL映射進“explorer.exe”(當然,最後又從其中解除安裝),子類了其中的開始按鈕,更确切地說我是把開始按鈕的滑鼠左右鍵點選事件颠倒了一下。
你可以在本文章的開頭部分找到HookSpy和HookInjEx及其源代碼的下載下傳包連結。
Ⅱ. CreateRemoteThread 和 LoadLibrary 技術
示例程式:LibSpy
通常,任何程序都可以通過LoadLibrary動态地加載DLL,但是我們如何強制一個外部程序調用該函數呢?答案是CreateRemoteThread。
讓我們先來看看LoadLibrary和FreeLibrary的函數聲明:
HINSTANCE LoadLibrary(
HINSTANCE LoadLibrary(
HINSTANCE LoadLibrary(
HINSTANCE LoadLibrary(
LPCTSTR lpLibFileName // address of filename of library module
);
BOOL FreeLibrary(
HMODULE hLibModule // handle to loaded library module
);
再和CreateRemoteThread的線程過程(thread procedure)ThreadProc比較一下:
DWORD WINAPI ThreadProc(
LPVOID lpParameter // thread data
);
你會發現所有的函數都有同樣的調用約定(calling convention)、都接受一個32位的參數并且傳回值類型的大小也一樣。也就是說,我們可以把LoadLibrary/FreeLibrary的指針作為參數傳遞給CrateRemoteThread。
然而,還有兩個問題(參考下面對CreateRemoteThread的說明)
1. 傳遞給ThreadProc的lpStartAddress 參數必須為遠端程序中的線程過程的起始位址。
2. 如果把ThreadProc的lpParameter參數當做一個普通的32位整數(FreeLibrary把它當做HMODULE)那麼沒有如何問題,但是如果把它當做一個指針(LoadLibrary把它當做一個char*),它就必須指向遠端程序中的記憶體資料。
第一個問題其實已經迎刃而解了,因為LoadLibrary和FreeLibrary都是存在于kernel32.dll中的函數,而kernel32可以保證任何“正常”程序中都存在,且其加載位址都是一樣的。(參看附錄A)于是LoadLibrary/FreeLibrary在任何程序中的位址都是一樣的,這就保證了傳遞給遠端程序的指針是個有效的指針。
第二個問題也很簡單:把DLL的檔案名(LodLibrary的參數)用WriteProcessMemory複制到遠端程序。
是以,使用CreateRemoteThread和LoadLibrary技術的步驟如下:
1. 得到遠端程序的HANDLE(使用OpenProcess)。
2. 在遠端程序中為DLL檔案名配置設定記憶體(VirtualAllocEx)。
3. 把DLL的檔案名(全路徑)寫到配置設定的記憶體中(WriteProcessMemory)
4. 使用CreateRemoteThread和LoadLibrary把你的DLL映射近遠端程序。
5. 等待遠端線程結束(WaitForSingleObject),即等待LoadLibrary傳回。也就是說當我們的DllMain(是以DLL_PROCESS_ATTACH為參數調用的)傳回時遠端線程也就立即結束了。
6. 取回遠端線程的結束碼(GetExitCodeThtread),即LoadLibrary的傳回值――我們DLL加載後的基位址(HMODULE)。
7. 釋放第2步配置設定的記憶體(VirtualFreeEx)。
8. 用CreateRemoteThread和FreeLibrary把DLL從遠端程序中解除安裝。調用時傳遞第6步取得的HMODULE給FreeLibrary(通過CreateRemoteThread的lpParameter參數)。
9. 等待線程的結束(WaitSingleObject)。
同時,别忘了在最後關閉所有的句柄:第4、8步得到的線程句柄,第1步得到的遠端程序句柄。
現在我們看看LibSpy的部分代碼,分析一下以上的步驟是任何實作的。為了簡單起見,沒有包含錯誤處理和支援Unicode的代碼。
HANDLE hThread;
char szLibPath[_MAX_PATH]; // "LibSpy.dll"的檔案名
// (包含全路徑!);
void* pLibRemote; // szLibPath 将要複制到位址
DWORD hLibModule; //已加載的DLL的基位址(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 );
//取得DLL的基位址
::GetExitCodeThread( hThread, &hLibModule );
//掃尾工作
::CloseHandle( hThread );
::VirtualFreeEx( hProcess, pLibRemote, sizeof(szLibPath), MEM_RELEASE );
我們放在DllMain中的真正要注入的代碼(比如為SendMessage)現在已經被執行了(由于DLL_PROCESS_ATTACH),是以現在可以把DLL從目的程序中解除安裝了。
// 從目标程序解除安裝LibSpu.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需要知道包含了密碼的的控件的句柄。很明顯,這個句柄是不能在編譯期間寫死(hardcoded)進去的。同樣,當DLL得到密碼後,它也需要把密碼發回我們的程式。
幸運的是,這個問題有很多種解決方案:檔案映射(Mapping),WM_COPYDATA,剪貼闆等。還有一種非常便利的方法#pragma data_seg。這裡我不想深入讨論因為它們在MSDN(看一下Interprocess Communications部分)或其他資料中都有很好的說明。我在LibSpy中使用的是#pragma data_seg。
你可以在本文章的開頭找到LibSpy及源代碼的下載下傳連結。
Ⅲ.CreateRemoteThread和WriteProcessMemory技術
示例程式:WinSpy
另一種注入代碼到其他程序位址空間的方法是使用WriteProcessMemory API。這次你不用編寫一個獨立的DLL而是直接複制你的代碼到遠端程序(WriteProcessMemory)并用CreateRemoteThread執行之。
讓我們看一下CreateRemoteThread的聲明:
HANDLE CreateRemoteThread(
HANDLE hProcess, // handle to process to create thread in
LPSECURITY_ATTRIBUTES lpThreadAttributes, // pointer to security
// attributes
DWORD dwStackSize, // initial thread stack size, in bytes
LPTHREAD_START_ROUTINE lpStartAddress, // pointer to thread
// function
LPVOID lpParameter, // argument for new thread
DWORD dwCreationFlags, // creation flags
LPDWORD lpThreadId // pointer to returned thread identifier
);
和CreateThread相比,有一下不同:
●增加了hProcess參數。這是要在其中建立線程的程序的句柄。
●CreateRemoteThread的lpStartAddress參數必須指向遠端程序的位址空間中的函數。這個函數必須存在于遠端程序中,是以我們不能簡單地傳遞一個本地ThreadFucn的位址,我們必須把代碼複制到遠端程序。
●同樣,lpParameter參數指向的資料也必須存在于遠端程序中,我們也必須複制它。
現在,我們總結一下使用該技術的步驟:
1. 得到遠端程序的HANDLE(OpenProcess)。
2. 在遠端程序中為要注入的資料配置設定記憶體(VirtualAllocEx)、
3. 把初始化後的INJDATA結構複制到配置設定的記憶體中(WriteProcessMemory)。
4. 在遠端程序中為要注入的資料配置設定記憶體(VirtualAllocEx)。
5. 把ThreadFunc複制到配置設定的記憶體中(WriteProcessMemory)。
6. 用CreateRemoteThread啟動遠端的ThreadFunc。
7. 等待遠端線程的結束(WaitForSingleObject)。
8. 從遠端程序取回指執行結果(ReadProcessMemory 或 GetExitCodeThread)。
9. 釋放第2、4步配置設定的記憶體(VirtualFreeEx)。
10. 關閉第6、1步打開打開的句柄。
另外,編寫ThreadFunc時必須遵守以下規則:
1. ThreadFunc不能調用除kernel32.dll和user32.dll之外動态庫中的API函數。隻有kernel32.dll和user32.dll(如果被加載)可以保證在本地和目的程序中的加載位址是一樣的。(注意:user32并不一定被所有的Win32程序加載!)參考附錄A。如果你需要調用其他庫中的函數,在注入的代碼中使用LoadLibrary和GetProcessAddress強制加載。如果由于某種原因,你需要的動态庫已經被映射進了目的程序,你也可以使用GetMoudleHandle代替LoadLibrary。同樣,如果你想在ThreadFunc中調用你自己的函數,那麼就分别複制這些函數到遠端程序并通過INJDATA把位址提供給ThreadFunc。
2. 不要使用static字元串。把所有的字元串提供INJDATA傳遞。為什麼?編譯器會把所有的靜态字元串放在可執行檔案的“.data”段,而僅僅在代碼中保留它們的引用(即指針)。這樣,遠端程序中的ThreadFunc就會執行不存在的記憶體資料(至少沒有在它自己的記憶體空間中)。
3. 去掉編譯器的/GZ編譯選項。這個選項是預設的(看附錄B)。
4. 要麼把ThreadFunc和AfterThreadFunc聲明為static,要麼關閉編譯器的“增量連接配接(incremental linking)”(看附錄C)。
5. ThreadFunc中的局部變量總大小必須小于4k位元組(看附錄D)。注意,當degug編譯時,這4k中大約有10個位元組會被事先占用。
6. 如果有多于3個switch分支的case語句,必須像下面這樣分割開,或用if-else if代替.
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:
(參考附錄E)
如果你不按照這些遊戲規則玩的話,你注定會使目的程序挂掉!記住,不要妄想遠端程序中的任何資料會和你本地程序中的資料存放在相同記憶體位址!(參看附錄F)
(原話如此:You will almost certainly crash the target process if you don\'t play by those rules. Just remember: Don\'t assume anything in the target process is at the same address as it is in your process.)
GetWindowTextRemote(A/W)
所有取得遠端edit中文本的工作都被封裝進這個函數:GetWindowTextRemote(A/W):
int GetWindowTextRemoteA( HANDLE hProcess, HWND hWnd, LPSTR lpString );
int GetWindowTextRemoteW( HANDLE hProcess, HWND hWnd, LPWSTR lpString );
參數:
hProcess
目的edit所在的程序句柄
hWnd
目的edit的句柄
lpString
接收字元串的緩沖
傳回值:
成功複制的字元數。
讓我們看以下它的部分代碼,特别是注入的資料和代碼。為了簡單起見,沒有包含支援Unicode的代碼。
INJDATA
typedef LRESULT (WINAPI *SENDMESSAGE)(HWND,UINT,WPARAM,LPARAM);
typedef struct {
HWND hwnd; // handle to edit control
SENDMESSAGE fnSendMessage; // pointer to user32!SendMessageA
char psText[128]; // buffer that is to receive the password
} INJDATA;
INJDATA是要注入遠端程序的資料。在把它的位址傳遞給SendMessageA之前,我們要先對它進行初始化。幸運的是unse32.dll在所有的程序中(如果被映射)總是被映射到相同的位址,是以SendMessageA的位址也總是相同的,這也保證了傳遞給遠端程序的位址是有效的。
ThreadFunc
static DWORD WINAPI ThreadFunc (INJDATA *pData)
{
pData->fnSendMessage( pData->hwnd, WM_GETTEXT, // 得到密碼
sizeof(pData->psText),
(LPARAM)pData->psText );
return 0;
}
// This function marks the memory address after ThreadFunc.
// int cbCodeSize = (PBYTE) AfterThreadFunc - (PBYTE) ThreadFunc.
static void AfterThreadFunc (void)
{
}
ThreadFunc是遠端線程實際執行的代碼。
●注意AfterThreadFunc是如何計算ThreadFunc的代碼大小的。一般地,這不是最好的辦法,因為編譯器會改變你的函數中代碼的順序(比如它會把ThreadFunc放在AfterThreadFunc之後)。然而,你至少可以确定在同一個工程中,比如在我們的WinSpy工程中,你函數的順序是固定的。如果有必要,你可以使用/ORDER連接配接選項,或者,用反彙編工具确定ThreadFunc的大小,這個也許會更好。
如何用該技術子類(subclass)一個遠端控件
示例程式:InjectEx
讓我們來讨論一個更複雜的問題:如何子類屬于其他程序的一個控件?
首先,要完成這個任務,你必須複制兩個函數到遠端程序:
1. ThreadFunc,這個函數通過調用SetWindowLong API來子類遠端程序中的控件,
2. NewProc, 那個控件的新視窗過程(Window Procedure)。
然而,最主要的問題是如何傳遞資料到遠端的NewProc。因為NewProc是一個回調(callback)函數,它必須符合特定的要求(譯者注:這裡指的主要是參數個數和類型),我們不能再簡單地傳遞一個INJDATA的指針作為它的參數。幸運的我已經找到解決這個問題的方法,而且是兩個,但是都要借助于彙編語言。我一直都努力避免使用彙編,但是這一次,我們逃不掉了,沒有彙編不行的。
解決方案1
看下面的圖檔:
不知道你是否注意到了,INJDATA緊挨着NewProc放在NewProc的前面?這樣的話在編譯期間NewProc就可以知道INJDATA的記憶體位址。更精确地說,它知道INJDATA相對于它自身位址的相對偏移,但是這并不是我們真正想要的。現在,NewProc看起來是這個樣子:
static LRESULT CALLBACK NewProc(
HWND hwnd, // handle to window
UINT uMsg, // message identifier
WPARAM wParam, // first message parameter
LPARAM lParam ) // second message parameter
{
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會被複制到遠端程序,那樣的話,這個位址就錯了。
用C/C++沒有辦法解決這個問題,可以用内聯的彙編來解決。看修改後的NewProc:
static LRESULT CALLBACK NewProc(
HWND hwnd, // handle to window
UINT uMsg, // message identifier
WPARAM wParam, // first message parameter
LPARAM lParam ) // second message parameter
{
// 計算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并且對它進行讀寫的操作碼(OpCode)。然而,EIP同樣可以被JMP,CALL,RET等指令隐含地改變(事實上它一直都在改變)。讓我們舉例說明32位的Intel和AMD處理器上CALL/RET是如何工作的吧:
當我們用CALL調用一個子程式時,這個子程式的位址被加載進EIP。同時,在EIP被改變之前,它以前的值會被自動壓棧(在後來被用作傳回指令指針[return instruction-pointer])。在子程式的最後RET指令自動把這個值從棧中彈出到EIP。
現在我們知道了如何通過CALL和RET來修改EIP的值了,但是如何得到他的目前值?
還記得CALL把EIP的值壓棧了嗎?是以為了得到EIP的值我們調用了一個“假(dummy)函數”然後彈出棧頂值。看一下編譯過的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
a. 一個假的函數調用;僅僅跳到下一條指令并且(譯者注:更重要的是)把EIP壓棧。
b. 彈出棧頂值到ECX。ECX就儲存的EIP的值;這也就是那條“pop ECX”指令的位址。
c. 注意從NewProc的入口點到“pop ECX”指令的“距離”為9位元組;是以把ECX減去9就得到的NewProc的位址了。
這樣一來,不管被複制到什麼地方,NewProc總能正确計算自身的位址了!然而,要注意從NewProc的入口點到“pop ECX”的距離可能會因為你的編譯器/連結選項的不同而不同,而且在Release和Degub版本中也是不一樣的。但是,不管怎樣,你仍然可以在編譯期知道這個距離的具體值。
1. 首先,編譯你的函數。
2. 在反彙編器(disassembler)中查出正确的距離值。
3. 最後,使用正确的距離值重新編譯你的程式。
這也是InjectEx中使用的解決方案。InjectEx和HookInjEx類似,交換開始按鈕上的滑鼠左右鍵點選事件。
解決方案2
在遠端程序中把INJDATA放在NewProc的前面并不是唯一的解決方案。看一下下面的NewProc:
static LRESULT CALLBACK NewProc(
HWND hwnd, // handle to window
UINT uMsg, // message identifier
WPARAM wParam, // first message parameter
LPARAM lParam ) // second message parameter
{
INJDATA* pData = 0xA0B0C0D0; // 一個假設
//-----------------------------
// 子類代碼
// ........
//-----------------------------
// 調用以前的視窗過程
return pData->fnCallWindowProc( pData->fnOldProc,
hwnd,uMsg,wParam,lParam );
}
這裡,0XA0B0C0D0僅僅是INJDATA在遠端程序中的位址的占位符(placeholder)。你無法在編譯期得到這個值,然而你在調用VirtualAllocEx(為INJDATA配置設定記憶體時)後确實知道INJDATA的位址!(譯者注:就是VirtualAllocEx的傳回值)
我們的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。
現在,你這麼做:
1. 把INJDATA,ThreadFunc和NewFunc複制到目的程序。
2. 改變NewPoc的機器碼,讓pData指向INJDATA的真實位址。
比如,假設INJDATA的的真實位址(VirtualAllocEx的傳回值)為0x008a0000,你把NewProc的機器碼改為:
558BECC745FCD0C0B0A0......8BE55DC21000
<- 修改前的 NewProc 1
558BECC745FC00008A00......8BE55DC21000
<- 修改後的 NewProc
也就是說,你把假值 A0B0C0D0改為INJDATA的真實位址2
3. 開始指向遠端的ThreadFunc,它子類了遠端程序中的控件。
¹ 你可能會問,為什麼A0B0C0D0和008a0000在編譯後的機器碼中為逆序的。這時因為Intel和AMD處理器使用littl-endian标記法(little-endian notation)來表示它們的(多位元組)資料。換句話說:一個數的低位元組(low-order byte)在記憶體中被存放在最低位,高位元組(high-order byte)存放在最高位。
想像一下,存放在四個位元組中的單詞“UNIX”,在big-endia系統中被存儲為“UNIX”,在little-endian系統中被存儲為“XINU”。
² 一些蹩腳的破解者用類似的方法來修改可執行檔案的機器碼,但是一個程式一旦載入記憶體,就不能再更改自身的機器碼(一個可執行檔案的.text段是寫保護的)。我們能修改遠端程序中的NewProc是因為它所處的那塊記憶體在配置設定時給予了PAGE_EXECUTE_READWRITE屬性。
何時使用CreateRemoteThread和WriteProcessMemory技術
通過CreateRemoteThread和WriteProcessMemory來注入代碼的技術,和其他兩種方法相比,不需要一個額外的DLL檔案,是以更靈活,但也更複雜更危險。一旦你的ThreadFunc中有錯誤,遠端線程會立即崩潰(看附錄F)。調試一個遠端的ThreadFunc也是場惡夢,是以你應該在僅僅注入若幹條指令時才使用這個方法。要注入大量的代碼還是使用另外兩種方法吧。
再說一次,你可以在文章的開頭部分下載下傳到WinSpy,InjectEx和它們的源代碼。
寫在最後的話
最後,我們總結一些目前還沒有提到的東西:
方法
适用的作業系統
可操作的程序程序
I. Windows鈎子
Win9x 和WinNT
連接配接了USER32.DLL的程序1
II. CreateRemoteThread & LoadLibrary
僅WinNT2
所有程序3,包括系統服務4
III. CreateRemoteThread & WriteProcessMemory
僅WinNT
所有程序,包括系統服務
1. 很明顯,你不能給一個沒有消息隊列的線程挂鈎。同樣SetWindowsHookEx也對系統服務不起作用(就算它們連接配接了USER32)。
2. 在Win9x下沒有CreateRemoteThread和VirtualAllocEx(事實上可以在9x上模拟它們,但是到目前為止還隻是個神話)
3. 所有程序 = 所有的Win32程序 + csrss.exe
本地程式(native application)比如smss.exe, os2ss.exe, autochk.exe,不使用Win32 APIs,也沒有連接配接到kernel32.dll。唯一的例外是csrss.exe,win32子系統自身。它是一個本地程式,但是它的一些庫(比如winsrv.dll)需要Win32 DLL包括kernel32.dll.
4.如果你向注入代碼到系統服務或csrss.exe,在打開遠端程序的句柄(OpenProcess)之前把你的程序的優先級調整為“SeDebugprovilege”(AdjustTokenPrivileges)。
大概就這些了吧。還有一點你需要牢記在心:你注入的代碼(特别是存在錯誤時)很容易就會把目的程序拖垮。記住:責任随權利而來(Power comes with responsibility)!
這篇文章中的很多例子都和密碼有關,看過這篇文章後你可能也會對Zhefu Zhang(譯者注:大概是一位中國人,張哲夫??)寫的Supper Password Spy++感興趣。他講解了如何從IE的密碼框中得到密碼,也說了如何保護你的密碼不被這種攻擊。
最後一點:讀者的回報是文章作者的唯一報酬,是以如果你認為這篇文章有作用,請留下你的評論或給它投票。更重要的是,如果你發現有錯誤或bug;或你認為什麼地方做得還不夠好,有需要改進的地方;或有不清楚的地方也都請告訴我。
感謝
首先,我要感謝我在CodeGuru(這篇文章最早是在那兒發表的)的讀者,正是由于你們的鼓勵和支援這篇文章才得以從最初的1200單詞發展到今天這樣6000單詞的“龐然大物”。如果說有一個人我要特别感謝的話,他就是Rado Picha。這篇文章的一部分很大程度上得益于他對我的建議和幫助。最後,但也不能算是最後,感謝Susan Moore,他幫助我跨越了那個叫做“英語”的雷區,讓這篇文章更加通順達意。
――――――――――――――――――――――――――――――――――――
附錄
A) 為什麼kernel32.dll和user32.dll中是被映射到相同的記憶體位址?
我的假定:以為微軟的程式員認為這麼做可以優化速度。讓我們來解釋一下這是為什麼。
一般來說,一個可執行檔案包含幾個段,其中一個為“.reloc”段。
當連結器生成EXE或DLL時,它假定這個檔案會被加載到一個特定的位址,也就是所謂的假定/首選加載/基位址(assumed/preferred load/base address)。記憶體映像(image)中的所有絕對位址都時基于該“連結器假定加載位址”的。如果由于某些原因,映像沒有加載到這個位址,那麼PE加載器(PE loader)就不得不修正該映像中的所有絕對位址。這就是“.reloc”段存在的原因:它包含了一個該映像中所有的“連結器假定位址”與真正加載到的位址之間的差異的清單(注意:編譯器産生的大部分指令都使用一種相對尋址模式,是以,真正需要重定位[relocation]的地方并沒有你想像的那麼多)。如果,從另一方面說,加載器可以把映像加載到連結器首選位址,那麼“.reloc”段就會被徹底忽略。
但是,因為每一個Win32程式都需要kernel32.dll,大部分需要user32.dll,是以如果總是把它們兩個映射到其首選位址,那麼加載器就不用修正kernel32.dll和user32.dll中的任何(絕對)位址,加載時間就可以縮短。
讓我們用下面的例子來結束這個讨論:
把一個APP.exe的加載位址改為kernel32的(/base:"0x77e80000")或user32的(/base:"0x77e10000")首選位址。如果App.exe沒有引入UESE32,就強制LoadLibrary。然後編譯App.exe,并運作它。你會得到一個錯誤框(“非法的系統DLL重定位”),App.exe無法被加載。
為什麼?當一個程序被建立時,Win2000和WinXP的加載器會檢查kernel32.dll和user32.dll是否被映射到它們的首選位址(它們的名稱是被寫死進加載器的),如果沒有,就會報錯。在WinNT4 中ole32.dll也會被檢查。在WinNT3.51或更低版本中,則不會有任何檢查,kernel32.dll和user32.dll可以被加載到任何地方。唯一一個總是被加載到首選位址的子產品是ntdll.dll,加載器并不檢查它,但是如果它不在它的首選位址,程序根本無法建立。
總結一下:在WinNT4或更高版本的作業系統中:
●總被加載到它們的首選位址的DLL有:kernel32.dll,user32.dll和ntdll.dll。
●Win32程式(連同csrss.exe)中一定存在的DLL:kernel32.dll和ntdll.dll。
●所有程序中都存在的dll:ntdll.dll。
B) /GZ編譯開關
在Debug時,/GZ開關預設是打開的。它可以幫你捕捉一些錯誤(詳細内容參考文檔)。但是它對我們的可執行檔案有什麼影響呢?
當/GZ被使用時,編譯器會在每個函數,包含函數調用中添加額外的代碼(添加到每個函數的最後面)來檢查ESP棧指針是否被我們的函數更改過。但是,等等,ThreadFunc中被添加了一個函數調用?這就是通往災難的道路。因為,被複制到遠端程序中的ThreadFunc将調用一個在遠端程序中不存在的函數。
C) static函數和增量連接配接(Incremental linking)
增量連接配接可以縮短連接配接的時間,在增量編譯時,每個函數調用都是通過一個額外的JMP指令來實作的(一個例外就是被聲明為static的函數!)這些JMP允許連接配接器移動函數在記憶體中的位置而不用更新調用該函數的CALL。但是就是這個JMP給我們帶來了麻煩:現在ThreadFunc和AfterThreadFunc将指向JMP指令而不是它們的真實代碼。是以,當計算ThreadFunc的大小時:
const int cbCodeSize = ((LPBYTE) AfterThreadFunc - (LPBYTE) ThreadFunc);
實際得到的将是指向ThreadFunc和AfterThreadFunc的JMP指令之間的“距離”。現在假設我們的ThreadFunc在004014C0,和其對應的JMP指令在00401020
:00401020 jmp 004014C0
...
:004014C0 push EBP ; ThreadFunc的真實位址
:004014C1 mov EBP, ESP
...
然後,
WriteProcessMemory( .., &ThreadFunc, cbCodeSize, ..);
将把“JMP 004014C0”和其後的cbCodeSize範圍内的代碼而不是ThreadFunc複制到遠端程序。遠端線程首先會執行“JMP 004010C0”,然後一直執行到這個程序代碼的最後一條指令(譯者注:這當然不是我們想要的結果)。
然而,如果一個函數被聲明為static,就算使用增量連接配接,也不會被替換為JMP指令。這就是為什麼我在規則#4中說把ThreadFunc和AfterThreadFunc聲明為static或禁止增量連接配接的原因了。(關于增量連接配接的其他方面請參看Matt Pietrek寫的“Remove Fatty Deposits from Your Applications Using Our 32-bit Liposuction Tools”)
D) 為什麼ThreadFunc隻能有4K的局部變量?
局部變量總是儲存在棧上的。假設一個函數有256位元組的局部變量,當進入該函數時(更确切地說是在functions prologue中),棧指針會被減去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
請注意在上面的例子中ESP(棧指針)是如何被改變的。但是如果一個函數有多于4K的局部變量該怎麼辦?這種情況下,棧指針不會被直接改變,而是通過一個函數調用來正确實作ESP的改變。但是就是這個“函數調用”導緻了ThreadFunc的崩潰,因為它在遠端程序中的拷貝将會調用一個不存在的函數。
讓我們來看看文檔關于棧探針(stack probes)和/Gs編譯選項的說明:
“/Gssize選項是一個允許你控制棧探針的進階特性。棧探針是編譯器插入到每個函數調用中的一系列代碼。當被激活時,棧探針将溫和地按照存儲函數局部變量所需要的空間大小來移動。
如果一個函數需要大于size指定的局部變量空間,它的棧探針将被激活。預設的size為一個頁的大小(在80x86上為4k)。這個值可以使一個Win32程式和Windows NT的虛拟記憶體管理程式和諧地互動,在運作期間向程式棧增加已送出的記憶體總數。
我能确定你們對上面的叙述(“棧探針将溫和地按照存儲函數局部變量所需要的空間大小來移動”)感到奇怪。這些編譯選項(他們的描述!)有時候真的讓人很惱火,特别是當你想真的了解它們是怎麼工作的時候。打個比方,如果一個函數需要12kb的空間來存放局部變量,棧上的記憶體是這樣“配置設定”的
sub esp, 0x1000 ; 先“配置設定”4 Kb
test [esp], eax ; touches memory in order to commit a
; new page (if not already committed)
sub esp, 0x1000 ; “配置設定”第二個 4 Kb
test [esp], eax ; ...
sub esp, 0x1000
test [esp], eax
注意棧指針是如何以4Kb為機關移動的,更重要的是每移動一步後使用test對棧底的處理(more importantly, how the bottom of the stack is "touched" after each step)。這可以確定了在“配置設定”下一個頁之前,包含棧底的頁已經被送出。
繼續閱讀文檔的說明:
“每一個新的線程會擁有(receives)自己的棧空間,這包括已經送出的記憶體和保留的記憶體。預設情況下每個線程使用1MB的保留記憶體和一個頁大小的以送出記憶體。如果有必要,系統将從保留記憶體中送出一個頁。”(看MSDN中GreateThread > dwStackSize > “Thread Stack Size”)
..現在為什麼文檔中說“這個值可以使一個Win32程式和Windows NT的虛拟記憶體管理程式和諧地互動”也很清楚了。
E) 為什麼我要把多于3個case分支的swith分割開來呢?
同樣,用例子來說明會簡單些:
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;
}
将會被編譯為類似下面的代碼:
Address OpCode/Params Decoded instruction
--------------------------------------------------
; 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 to one of the addresses in table <B>***</B>
; note that ECX contains the offset
: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
看到switch-case是如何實作的了嗎?
它沒有去測試每個case分支,而是建立了一個位址表(address table)。我們簡單地計算出在位址表中偏移就可以跳到正确的case分支。想想吧,這真是一個進步,假設你有一個50個分支的switch語句,假如沒有這個技巧,你不的不執行50次CMP和JMP才能到達最後一個case,而使用位址表,你可以通過一次查表即跳到正确的case。使用算法的時間複雜度來衡量:我們把O(2n)的算法替換成了O(5)的算法,其中:
1. O代表最壞情況下的時間複雜度。
2. 我們假設計算偏移(即查表)并跳到正确的位址需要5個指令。
現在,你可能認為上面的情況僅僅是因為case常量選擇得比較好,(1,2,3,4,5)。幸運的是,現實生活中的大多數例子都可以應用這個方案,隻是偏移的計算複雜了一點而已。但是,有兩個例外:
●如果少于3個case分支,或
●如果case常量是完全互相無關的。(比如 1, 13, 50, 1000)。
最終的結果和你使用普通的if-else if是一樣的。
有趣的地方:如果你曾經為case後面隻能跟常量而迷惑的話,現在你應該知道為什麼了吧。這個值必須在編譯期間就确定下來,這樣才能建立位址表。
回到我們的問題!
注意到0040100C處的JMP指令了嗎?我們來看看Intel的文檔對十六進制操作碼FF的說明:
Opcode Instruction Description
FF /4 JMP r/m32 Jump near, absolute indirect,
address given in r/m32
JMP使用了絕對位址!也就是說,它的其中一個操作數(在這裡是0040102C)代表一個絕對位址。還用多說嗎?現在遠端的ThreadFunc會盲目第在位址表中004101C然後跳到這個錯誤的地方,馬上使遠端程序挂掉了。
F) 到底是什麼原因使遠端程序崩潰了?
如果你的遠端程序崩潰了,原因可能為下列之一:
1. 你引用了ThreadFunc中一個不存在的字元串。
2. ThreadFunc中一個或多個指令使用了絕對尋址(看附錄E中的例子)
3. ThreadFunc調用了一個不存在的函數(這個函數調用可能是編譯器或連接配接器添加的)。這時候你需要在反彙編器中尋找類似下面的代碼:
:004014C0 push EBP ; entry point of ThreadFunc
:004014C1 mov EBP, ESP
...
:004014C5 call 0041550 ; 在這裡崩潰了
; remote process
...
:00401502 ret
如果這個有争議的CALL是編譯器添加的(因為一些不該打開的編譯開關比如/GZ打開了),它要麼在ThreadFunc的開頭要麼在ThreadFunc接近結尾的地方
不管在什麼情況下,你使用CreateRemoteThread & WriteProcessMemory技術時必須萬分的小心,特别是編譯器/連接配接器的設定,它們很可能會給你的ThreadFunc添加一些帶來麻煩的東西。
參考(省略)
文章曆史(省略)
<結束>