程式碼注入的三種方法

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

圖一 WinSpy 密碼偵測程式

為了找到解決問題的方法。首先讓我們簡單回顧一下問題背景。

要“讀取”某個控制元件的內容――無論這個控制元件是否屬於當前的應用程式――通常都是傳送 WM_GETTEXT 訊息來實現。這個技術也同樣應用到編輯控制元件,但是如果該編輯控制元件屬於另外一個程式並設定了 ES_PASSWORD 式樣,那麼上面講的方法就行不通了。用 WM_GETTEXT 來獲取控制元件的內容只適用於程式“擁有”密碼控制元件的情況。所以我們的問題變成了如何在另外一個程式的地址空間執行:

1.::SendMessage( hPwdEdit, WM_GETTEXT, nMaxChars, psBuffer );

通常有三種可能性來解決這個問題。

1.將你的程式碼放入某個 DLL,然後通過 Windows 鉤子對映該DLL到遠端程式;

2.將你的程式碼放入某個 DLL,然後通過 CreateRemoteThread 和 LoadLibrary 技術對映該DLL到遠端程式;

3.如果不寫單獨的 DLL,可以直接將你的程式碼拷貝到遠端程式――通過 WriteProcessMemory――並用 CreateRemoteThread 啟動它的執行。本文將在第三部分詳細描述該技術實現細節;

第一部分: Windows 鉤子

範例程式――參見HookSpy 和HookInjEx

Windows 鉤子主要作用是監控某些執行緒的訊息流。通常我們將鉤子分為本地鉤子和遠端鉤子以及系統級鉤子,本地鉤子一般監控屬於本程式的執行緒的訊息流,遠端鉤子是執行緒專用的,用於監控屬於另外程式的執行緒訊息流。系統級鉤子監控執行在當前系統中的所有執行緒的訊息流。

如果鉤子作用的執行緒屬於另外的程式,那麼你的鉤子過程必須駐留在某個動態連結庫(DLL)中。然後系統對映包含鉤子過程的DLL到鉤子作用的執行緒的地址空間。Windows將對映整個 DLL,而不僅僅是鉤子過程。這就是為什麼 Windows 鉤子能被用於將程式碼注入到別的程式地址空間的原因。

本文我不打算涉及鉤子的具體細節(關於鉤子的細節請參見 MSDN 庫中的 SetWindowHookEx API),但我在此要給出兩個很有用心得,在相關文件中你是找不到這些內容的:

1.在成功呼叫 SetWindowsHookEx 後,系統自動對映 DLL 到鉤子作用的執行緒地址空間,但不必立即發生對映,因為 Windows 鉤子都是訊息,DLL 在訊息事件發生前並沒有產生實際的對映。例如:

如果你安裝一個鉤子監控某些執行緒(WH_CALLWNDPROC)的非佇列訊息,在訊息被實際傳送到(某些視窗的)鉤子作用的執行緒之前,該DLL 是不會被對映到遠端程式的。換句話說,如果 UnhookWindowsHookEx 在某個訊息被髮送到鉤子作用的執行緒之前被呼叫,DLL 根本不會被對映到遠端程式(即使 SetWindowsHookEx 本身呼叫成功)。為了強制進行對映,在呼叫 SetWindowsHookEx 之後馬上傳送一個事件到相關的執行緒。

在UnhookWindowsHookEx了之後,對於沒有對映的DLL處理方法也一樣。只有在足夠的事件發生後,DLL才會有真正的對映。

2.當你安裝鉤子後,它們可能影響整個系統得效能(尤其是系統級鉤子),但是你可以很容易解決這個問題,如果你使用執行緒專用鉤子的DLL對映機制,並不截獲訊息。考慮使用如下程式碼:

01.BOOL APIENTRY DllMain( HANDLE hModule,
02.                       DWORD  ul_reason_for_call,
03.                       LPVOID lpReserved )
04.{
05.    if( ul_reason_for_call == DLL_PROCESS_ATTACH )
06.    {
07.        // Increase reference count via LoadLibrary
08.        char lib_name[MAX_PATH];
09.        ::GetModuleFileName( hModule, lib_name, MAX_PATH );
10.        ::LoadLibrary( lib_name );
11.
12.        // Safely remove hook
13.        ::UnhookWindowsHookEx( g_hHook );
14.    }   
15.    return TRUE;
16.}          

那麼會發生什麼呢?首先我們通過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 的宣告:

1.HINSTANCE LoadLibrary(
2.LPCTSTR lpLibFileName // 庫模組檔名的地址
3.);
4.
5.BOOL FreeLibrary(
6.HMODULE hLibModule // 要載入的庫模組的控制程式碼
7.);

現在將它們與傳遞到 CreateRemoteThread 的執行緒例程――ThreadProc 的宣告進行比較。

1.DWORD WINAPI ThreadProc(
2.LPVOID lpParameter // 執行緒資料
3.);

你可以看到,所有函式都使用相同的呼叫規範並都接受 32位引數,返回值的大小都相同。也就是說,我們可以傳遞一個指標到LoadLibrary/FreeLibrary 作為到 CreateRemoteThread 的執行緒例程。但這裡有兩個問題,請看下面對CreateRemoteThread 的描述:

1.CreateRemoteThread 的 lpStartAddress 引數必須表示遠端程式中執行緒例程的開始地址。

2.如果傳遞到 ThreadFunc 的引數lpParameter――被解釋為常規的 32位值(FreeLibrary將它解釋為一個 HMODULE),一切OK。但是,如果 lpParameter 被解釋為一個指標(LoadLibraryA將它解釋為一個串指標)。它必須指向遠端程式的某些資料。

第一個問題實際上是由它自己解決的。LoadLibrary 和 FreeLibray 兩個函式都在 kernel32.dll 中。因為必須保證kernel32存在並且在每個“常規”程式中的載入地址要相同,LoadLibrary/FreeLibray 的地址在每個程式中的地址要相同,這就保證了有效的指標被傳遞到遠端程式。

第二個問題也很容易解決。只要通過 WriteProcessMemory 將 DLL 模組名(LoadLibrary需要的DLL模組名)拷貝到遠端程式即可。

所以,為了使用CreateRemoteThread 和 LoadLibrary 技術,需要按照下列步驟來做:

1.獲取遠端程式(OpenProcess)的 HANDLE;

2.為遠端程式中的 DLL名分配記憶體(VirtualAllocEx);

3.將 DLL 名,包含全路徑名,寫入分配的記憶體(WriteProcessMemory);

4.用 CreateRemoteThread 和 LoadLibrary. 將你的DLL對映到遠端程式;

5.等待直到執行緒終止(WaitForSingleObject),也就是說直到 LoadLibrary 呼叫返回。另一種方法是,一旦 DllMain(用DLL_PROCESS_ATTACH呼叫)返回,執行緒就會終止;

6.獲取遠端執行緒的退出程式碼(GetExitCodeThread)。注意這是一個 LoadLibrary 返回的值,因此是所對映 DLL 的基地址(HMODULE)。
在第二步中釋放分配的地址(VirtualFreeEx);

7.用 CreateRemoteThread 和 FreeLibrary從遠端程式中解除安裝 DLL。傳遞在第六步獲取的 HMODULE 控制程式碼到 FreeLibrary(通過 CreateRemoteThread 的lpParameter引數);

8.注意:如果你注入的 DLL 產生任何新的執行緒,一定要在解除安裝DLL 之前將它們都終止掉;

9.等待直到執行緒終止(WaitForSingleObject);

此外,處理完成後不要忘了關閉所有控制程式碼,包括在第四步和第八步建立的兩個執行緒以及在第一步獲取的遠端執行緒控制程式碼。現在讓我們看一下 LibSpy 的部分程式碼,為了簡單起見,上述步驟的實現細節中的錯誤處理以及 UNICODE 支援部分被略掉。

01.HANDLE hThread;
02.char    szLibPath[_MAX_PATH];  // “LibSpy.dll”模組的名稱 (包括全路徑);
03.void*   pLibRemote;   // 遠端程式中的地址,szLibPath 將被拷貝到此處;
04.DWORD   hLibModule;   // 要載入的模組的基地址(HMODULE)
05.HMODULE hKernel32 = ::GetModuleHandle("Kernel32");
06.
07.// 初始化szLibPath
08.//...
09.// 1. 在遠端程式中為szLibPath 分配記憶體
10.// 2. 將szLibPath 寫入分配的記憶體
11.pLibRemote = ::VirtualAllocEx( hProcess, NULL, sizeof(szLibPath),
12.                               MEM_COMMIT, PAGE_READWRITE );
13.::WriteProcessMemory( hProcess, pLibRemote, (void*)szLibPath,
14.                      sizeof(szLibPath), NULL );
15.
16.// 將"LibSpy.dll" 載入到遠端程式(使用CreateRemoteThread 和 LoadLibrary)
17.hThread = ::CreateRemoteThread( hProcess, NULL, 0,
18.            (LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32,
19.                                       "LoadLibraryA" ),
20.             pLibRemote, 0, NULL );
21.::WaitForSingleObject( hThread, INFINITE );
22.
23.// 獲取所載入的模組的控制程式碼
24.::GetExitCodeThread( hThread, &hLibModule );
25.
26.// 清除
27.::CloseHandle( hThread );
28.::VirtualFreeEx( hProcess, pLibRemote, sizeof(szLibPath), MEM_RELEASE );       

假設我們實際想要注入的程式碼――SendMessage ――被放在DllMain (DLL_PROCESS_ATTACH)中,現在它已經被執行。那麼現在應該從目標程式中將DLL 解除安裝:

01.// 從目標程式中解除安裝"LibSpy.dll"  (使用 CreateRemoteThread 和 FreeLibrary)
02.hThread = ::CreateRemoteThread( hProcess, NULL, 0,
03.            (LPTHREAD_START_ROUTINE) ::GetProcAddress( hKernel32,
04.                                       "FreeLibrary" ),
05.            (void*)hLibModule, 0, NULL );
06.::WaitForSingleObject( hThread, INFINITE );
07.
08.// 清除
09.::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 的宣告:

01.HANDLE CreateRemoteThread(
02.  HANDLE hProcess,        // 傳入建立新執行緒的程式控制程式碼
03.  LPSECURITY_ATTRIBUTES lpThreadAttributes,  // 安全屬性指標
04.  DWORD dwStackSize,      // 位元組為單位的初始執行緒堆疊
05.  LPTHREAD_START_ROUTINE lpStartAddress,     // 指向執行緒函式的指標
06.  LPVOID lpParameter,     // 新執行緒使用的引數
07.  DWORD dwCreationFlags,  // 建立標誌
08.  LPDWORD lpThreadId      // 指向返回的執行緒ID
09.);             

如果你比較它與 CreateThread(MSDN)的宣告,你會注意到如下的差別:

在 CreateRemoteThread中,hProcess是額外的一個引數,一個程式控制程式碼,新執行緒就是在這個程式中建立的;

在 CreateRemoteThread中,lpStartAddress 表示的是在遠端程式地址空間中的執行緒起始地址。執行緒函式必須要存在於遠端程式中,所以我們不能簡單地傳遞一個指標到本地的 ThreadFunc。必須得先拷貝程式碼到遠端程式;

同樣,lpParameter 指向的資料也必須要存在於遠端程式,所以也得將它拷貝到那。

綜上所述,我們得按照如下的步驟來做:

1.獲取一個遠端程式的HANDLE (OpenProces) ;

2.在遠端程式地址空間中為注入的資料分配記憶體(VirtualAllocEx);

3.將初始的 INDATA 資料結構的一個拷貝寫入分配的記憶體中(WriteProcessMemory);

4.在遠端程式地址空間中為注入的程式碼分配記憶體;

5.將 ThreadFunc 的一個拷貝寫入分配的記憶體;

6.用 CreateRemoteThread啟動遠端的 ThreadFunc 拷貝;

7.等待遠端執行緒終止(WaitForSingleObject);

8.獲取遠端來自遠端程式的結果(ReadProcessMemory 或 GetExitCodeThread);

9.釋放在第二步和第四步中分配的記憶體(VirtualFreeEx);

10.關閉在第六步和第一步獲取的控制程式碼(CloseHandle);

ThreadFunc 必須要遵循的原則:

1.除了kernel32.dll 和user32.dll 中的函式之外,ThreadFunc 不要呼叫任何其它函式,只有 kernel32.dll 和user32.dll被保證在本地和目標程式中的載入地址相同(注意,user32.dll並不是被對映到每個 Win32 的程式)。如果你需要來自其它庫中的函式,將LoadLibrary 和 GetProcAddress 的地址傳給注入的程式碼,然後放手讓它自己去做。如果對映到目標程式中的DLL有衝突,你也可以用 GetModuleHandle 來代替 LoadLibrary。

同樣,如果你想在 ThreadFunc 中呼叫自己的子例程,要單獨把每個例程的程式碼拷貝到遠端程式並用 INJDATA為 ThreadFunc 提供程式碼的地址。

2.不要使用靜態字串,而要用 INJDATA 來傳遞所有字串。之所以要這樣,是因為編譯器將靜態字串放在可執行程式的“資料段”中,可是引用(指標)是保留在程式碼中的。那麼,遠端程式中ThreadFunc 的拷貝指向的內容在遠端程式的地址空間中是不存在的。

3.去掉 /GZ 編譯器開關,它在除錯版本中是預設設定的。

4.將 ThreadFunc 和 AfterThreadFunc 宣告為靜態型別,或者不啟用增量連結。

5.ThreadFunc 中的區域性變數一定不能超過一頁(也就是 4KB)。

注意在除錯版本中4KB的空間有大約10個位元組是用於內部變數的。

1.如果你有一個開關語句塊大於3個case 語句,將它們像下面這樣拆分開:

01.switch( expression ) {
02.    case constant1: statement1; goto END;
03.    case constant2: statement2; goto END;
04.    case constant3: statement2; goto END;
05.}
06.switch( expression ) {
07.    case constant4: statement4; goto END;
08.    case constant5: statement5; goto END;
09.    case constant6: statement6; goto END;
10.}
11.END:               

或者將它們修改成一個 if-else if 結構語句(參見附錄E)。

如果你沒有按照這些規則來做,目標程式很可能會崩潰。所以務必牢記。在目標程式中不要假設任何事情都會像在本地程式中那樣 (參見附錄F)。

GetWindowTextRemote(A/W)

要想從“遠端”編輯框獲得密碼,你需要做的就是將所有功能都封裝在GetWindowTextRemot(A/W):中。

1.int GetWindowTextRemoteA( HANDLE hProcess, HWND hWnd, LPSTR lpString );
2.int GetWindowTextRemoteW( HANDLE hProcess, HWND hWnd, LPWSTR lpString );
3.
4.引數說明:
5.hProcess:編輯框控制元件所屬的程式控制程式碼;
6.hWnd:包含密碼的編輯框控制元件控制程式碼;
7.lpString:接收文字的緩衝指標;
8.返回值:返回值是拷貝的字元數;

下面讓我們看看它的部分程式碼――尤其是注入資料的程式碼――以便明白 GetWindowTextRemote 的工作原理。此處為簡單起見,略掉了 UNICODE 支援部分。

01.INJDATA
02.typedef LRESULT (WINAPI *SENDMESSAGE)(HWND,UINT,WPARAM,LPARAM);
03.
04.typedef struct {
05.HWND hwnd; // 編輯框控制程式碼
06.SENDMESSAGE fnSendMessage; // 指向user32.dll 中 SendMessageA 的指標
07.
08.char psText[128]; // 接收密碼的緩衝
09.} INJDATA;

INJDATA 是一個被注入到遠端程式的資料結構。但在注入之前,結構中指向 SendMessageA 的指標是在本地應用程式中初始化的。因為對於每個使用user32.dll的程式來說,user32.dll總是被對映到相同的地址,因此,SendMessageA 的地址也肯定是相同的。這就保證了被傳遞到遠端程式的是一個有效的指標。

ThreadFunc函式

01.static DWORD WINAPI ThreadFunc (INJDATA *pData)
02.{
03.    pData->fnSendMessage( pData->hwnd, WM_GETTEXT, // Get password
04.                sizeof(pData->psText),
05.                (LPARAM)pData->psText );
06.    return 0;
07.}
08.
09.// 該函式在ThreadFunc之後標記記憶體地址
10.// int cbCodeSize = (PBYTE) AfterThreadFunc - (PBYTE) ThreadFunc.
11.static void AfterThreadFunc (void)
12.{
13.}

ThradFunc 是被遠端執行緒執行的程式碼。

註釋:注意AfterThreadFunc 是如何計算 ThreadFunc 大小的。通常這樣做並不是一個好辦法,因為連結器可以隨意更改函式的順序(也就是說ThreadFunc可能被放在 AfterThreadFunc之後)。這一點你可以在小專案中很好地保證函式的順序是預先設想好的,比如 WinSpy 程式。在必要的情況下,你還可以使用 /ORDER 連結器選項來解決函式連結順序問題。或者用反彙編確定 ThreadFunc 函式的大小。

如何使用該技術子類化遠端控制元件

範例程式――InjectEx

下面我們將討論一些更復雜的內容,如何子類化屬於另一個程式的控制元件。

首先,你得拷貝兩個函式到遠端程式來完成此任務

1.ThreadFunc實際上是通過 SetWindowLong子類化遠端程式中的控制元件;

2.NewProc是子類化控制元件的新視窗過程;

這裡主要的問題是如何將資料傳到遠端視窗過程 NewProc,因為 NewProc 是一個回撥函式,它必須遵循特定的規範和原則,我們不能簡單地在引數中傳遞 INJDATA指標。幸運的是我找到了有兩個方法來解決這個問題,只不過要藉助組合語言,所以不要忽略了彙編,關鍵時候它是很有用的!

方法一:

如下圖所示:

在遠端程式中,INJDATA 被放在NewProc 之前,這樣 NewProc 在編譯時便知道 INJDATA 在遠端程式地址空間中的記憶體位置。更確切地說,它知道相對於其自身位置的 INJDATA 的地址,我們需要所有這些資訊。下面是 NewProc 的程式碼:

01.static LRESULT CALLBACK NewProc(
02.  HWND hwnd,       // 視窗控制程式碼
03.  UINT uMsg,       // 訊息標示符
04.  WPARAM wParam,   // 第一個訊息引數
05.  LPARAM lParam )  // 第二個訊息引數
06.{
07.    INJDATA* pData = (INJDATA*) NewProc;  // pData 指向 NewProc
08.    pData--;              // 現在pData 指向INJDATA;
09.                          // 回想一下INJDATA 被置於遠端程式NewProc之前;
10.
11.    //-----------------------------
12.    // 此處是子類化程式碼
13.    // ........
14.    //-----------------------------
15.
16.    // 呼叫原視窗過程;
17.    // fnOldProc (由SetWindowLong 返回) 被(遠端)ThreadFunc初始化
18.    // 並被儲存在(遠端)INJDATA;中
19.    return pData->fnCallWindowProc( pData->fnOldProc,
20.                                    hwnd,uMsg,wParam,lParam );
21.}

但這裡還有一個問題,見第一行程式碼:

1.INJDATA* pData = (INJDATA*) NewProc;

這種方式 pData得到的是硬編碼值(在我們的程式中是原 NewProc 的記憶體地址)。這不是我們十分想要的。在遠端程式中,NewProc “當前”拷貝的記憶體地址與它被移到的實際位置是無關的,換句話說,我們會需要某種型別的“this 指標”。

雖然用 C/C++ 無法解決這個問題,但藉助內聯彙編可以解決,下面是對 NewProc的修改:

01.static LRESULT CALLBACK NewProc(
02.  HWND hwnd,       // 視窗控制程式碼
03.  UINT uMsg,       // 訊息標示符
04.  WPARAM wParam,   // 第一個訊息引數
05.  LPARAM lParam )  // 第二個訊息引數
06.{
07.    // 計算INJDATA 結構的位置
08.    // 在遠端程式中記住這個INJDATA
09.    // 被放在NewProc之前
10.    INJDATA* pData;
11.    _asm {
12.        call    dummy
13.dummy:
14.        pop     ecx         // <- ECX 包含當前的EIP
15.        sub     ecx, 9      // <- ECX 包含NewProc的地址
16.        mov     pData, ecx
17.    }
18.    pData--;
19.
20.
21.    //-----------------------------
22.    // 此處是子類化程式碼
23.    // ........
24.    //-----------------------------
25.
26.    // 呼叫原來的視窗過程
27.    return pData->fnCallWindowProc( pData->fnOldProc,
28.                                    hwnd,uMsg,wParam,lParam );
29.}

那麼,接下來該怎麼辦呢?事實上,每個程式都有一個特殊的暫存器,它指向下一條要執行的指令的記憶體位置。即所謂的指令指標,在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 來解釋這個竅門。

01.Address   OpCode/Params   Decoded instruction
02.--------------------------------------------------
03.:00401000  55              push ebp            ; entry point of
04.                                               ; NewProc
05.:00401001  8BEC            mov ebp, esp
06.:00401003  51              push ecx
07.:00401004  E800000000      call 00401009       ; *a*    call dummy
08.:00401009  59              pop ecx             ; *b*
09.:0040100A  83E909          sub ecx, 00000009   ; *c*
10.:0040100D  894DFC          mov [ebp-04], ecx   ; mov pData, ECX
11.:00401010  8B45FC          mov eax, [ebp-04]
12.:00401013  83E814          sub eax, 00000014   ; pData--;
13......
14......
15.:0040102D  8BE5            mov esp, ebp
16.:0040102F  5D              pop ebp
17.: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版本之間也會有差別。但關鍵是你仍然確切地知道編譯時的值。

1.首先,編譯函式

2.用反彙編確定正確的距離

3.最後,用正確的距離值重新編譯

此即為 InjecEx 中使用的解決方案,類似於 HookInjEx,交換滑鼠點選“開始”左右鍵時的功能。

方法二:

對於我們的問題,在遠端程式地址空間中將 INJDATA 放在 NewProc 前面不是唯一的解決辦法。看下面 NewProc的變異版本:

01.static LRESULT CALLBACK NewProc(
02.  HWND hwnd,      // 視窗控制程式碼
03.  UINT uMsg,      // 訊息標示符
04.  WPARAM wParam,  // 第一個訊息引數
05.  LPARAM lParam ) // 第二個訊息引數
06.{
07.    INJDATA* pData = 0xA0B0C0D0;    // 虛構值
08.
09.    //-----------------------------
10.    // 子類化程式碼
11.    // ........
12.    //-----------------------------
13.
14.    // 呼叫原來的視窗過程
15.    return pData->fnCallWindowProc( pData->fnOldProc,
16.                                    hwnd,uMsg,wParam,lParam );
17.}

此處 0xA0B0C0D0 只是遠端程式地址空間中真實(絕對)INJDATA地址的佔位符。前面講過,你無法在編譯時知道該地址。但你可以在呼叫 VirtualAllocEx (為INJDATA)之後得到 INJDATA 在遠端程式中的位置。編譯我們的 NewProc 後,可以得到如下結果:

01.Address   OpCode/Params     Decoded instruction
02.--------------------------------------------------
03.:00401000  55                push ebp
04.:00401001  8BEC              mov ebp, esp
05.:00401003  C745FCD0C0B0A0    mov [ebp-04], A0B0C0D0
06.:0040100A  ...
07.....
08.:0040102D  8BE5              mov esp, ebp
09.:0040102F  5D                pop ebp
10.:00401030  C21000            ret 0010

因此,其編譯的程式碼(十六進位制)將是:

1.558BECC745FCD0C0B0A0......8BE55DC21000.

現在你可以象下面這樣繼續:

1.將INJDATA,ThreadFunc和NewProc 拷貝到目標程式;

2.修改 NewProc 的程式碼,以便 pData 中儲存的是 INJDATA 的真實地址。

例如,假設 INJDATA 的地址(VirtualAllocEx返回的值)在目標程式中是 0x008a0000。然後象下面這樣修改NewProc的程式碼:

1.558BECC745FCD0C0B0A0......8BE55DC21000 <- 原來的NewProc (注1)
2.558BECC745FC00008A00......8BE55DC21000 <- 修改後的NewProc,使用的是INJDA他的實際地址。

也就是說,你用真正的 INJDATA(注2) 地址替代了虛擬值 A0B0C0D0(注2)。

1.開始執行遠端的 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 的大小時:

1.const int cbCodeSize = ((LPBYTE) AfterThreadFunc - (LPBYTE) ThreadFunc)

你實際上計算的是指向 ThreadFunc 的JMPs 和AfterThreadFunc之間的“距離” (通常它們會緊挨著,不用考慮距離問題)。現在假設 ThreadFunc 的地址位於004014C0 而伴隨的 JMP指令位於 00401020。

1.:00401020   jmp  004014C0
2. ...
3.:004014C0   push EBP          ; ThreadFunc 的實際地址
4.:004014C1   mov  EBP, ESP
5. ...

那麼

1.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個位元組(更精確地說,在函式開始處)。例如,下面這個函式:

1.void Dummy(void) {
2.    BYTE var[256];
3.    var[0] = 0;
4.    var[1] = 1;
5.    var[255] = 255;
6.}

編譯後的彙編如下:

01.:00401000   push ebp
02.:00401001   mov  ebp, esp
03.:00401003   sub  esp, 00000100           ; change ESP as storage for
04.                                         ; local variables is needed
05.:00401006   mov  byte ptr [esp], 00      ; var[0] = 0;
06.:0040100A   mov  byte ptr [esp+01], 01   ; var[1] = 1;
07.:0040100F   mov  byte ptr [esp+FF], FF   ; var[255] = 255;
08.:00401017   mov  esp, ebp                ; restore stack pointer
09.:00401019   pop  ebp
10.:0040101A   ret

注意上述例子中,堆疊指標是如何被修改的?而如果某個函式需要4KB以上區域性變數記憶體空間又會怎麼樣呢?其實,堆疊指標並不是被直接修改,而是通過另一個函式呼叫來修改的。就是這個額外的函式呼叫使得我們的 ThreadFunc “被破壞”了,因為其遠端拷貝會呼叫一個不存在的東西。

我們看看文件中對堆疊探測和 /Gs編譯器選項是怎麼說的:

――“/GS是一個控制堆疊探測的高階特性,堆疊探測是一系列編譯器插入到每個函式呼叫的程式碼。當函式被啟用時,堆疊探測需要的記憶體空間來儲存相關函式的區域性變數。

如果函式需要的空間大於為區域性變數分配的堆疊空間,其堆疊探測被啟用。預設的大小是一個頁面(在80x86處理器上4kb)。這個值允許在Win32 應用程式和Windows NT虛擬記憶體管理器之間進行謹慎調整以便增加執行時承諾給程式堆疊的記憶體。”

我確信有人會問:文件中的“……堆疊探測到一塊需要的記憶體空間來儲存相關函式的區域性變數……”那些編譯器選項(它們的描述)在你完全弄明白之前有時真的讓人氣憤。例如,如果某個函式需要12KB的區域性變數儲存空間,堆疊記憶體將進行如下方式的分配(更精確地說是“承諾” )。

1.sub    esp, 0x1000    ; "分配" 第一次 4 Kb
2.test  [esp], eax      ; 承諾一個新頁記憶體(如果還沒有承諾)
3.sub    esp, 0x1000    ; "分配" 第二次4 Kb
4.test  [esp], eax      ; ...
5.sub    esp, 0x1000
6.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:

為什麼要將開關語句拆分成三個以上?

用下面這個例子很容易解釋這個問題,假設有如下這麼一個函式:

01.int Dummy( int arg1 )
02.{
03.    int ret =0;
04.
05.    switch( arg1 ) {
06.    case 1: ret = 1; break;
07.    case 2: ret = 2; break;
08.    case 3: ret = 3; break;
09.    case 4: ret = 0xA0B0; break;
10.    }
11.    return ret;
12.}

編譯後變成下面這個樣子:

01.地址      操作碼/引數       解釋後的指令
02.--------------------------------------------------
03.                                             ; arg1 -> ECX
04.:00401000  8B4C2404         mov ecx, dword ptr [esp+04]
05.:00401004  33C0             xor eax, eax     ; EAX = 0
06.:00401006  49               dec ecx          ; ECX --
07.:00401007  83F903           cmp ecx, 00000003
08.:0040100A  771E             ja 0040102A
09.
10.; JMP 到表***中的地址之一
11.; 注意 ECX 包含的偏移
12.:0040100C  FF248D2C104000   jmp dword ptr [4*ecx+0040102C]
13.
14.:00401013  B801000000       mov eax, 00000001   ; case 1: eax = 1;
15.:00401018  C3               ret
16.:00401019  B802000000       mov eax, 00000002   ; case 2: eax = 2;
17.:0040101E  C3               ret
18.:0040101F  B803000000       mov eax, 00000003   ; case 3: eax = 3;
19.:00401024  C3               ret
20.:00401025  B8B0A00000       mov eax, 0000A0B0   ; case 4: eax = 0xA0B0;
21.:0040102A  C3               ret
22.:0040102B  90               nop
23.
24.; 地址表***
25.:0040102C  13104000         DWORD 00401013   ; jump to case 1
26.:00401030  19104000         DWORD 00401019   ; jump to case 2
27.:00401034  1F104000         DWORD 0040101F   ; jump to case 3
28.:00401038  25104000         DWORD 00401025   ; jump to case 4

注意如何實現這個開關語句?

與其單獨檢查每個CASE語句,不如建立一個地址表,然後通過簡單地計算地址表的偏移量而跳轉到正確的CASE語句。這實際上是一種改進。假設你有50個CASE語句。如果不使用上述的技巧,你得執行50次 CMP和JMP指令來達到最後一個CASE。相反,有了地址表後,你可以通過表查詢跳轉到任何CASE語句,從計算機演算法角度和時間複雜度看,我們用O(5)代替了O(2n)演算法。其中:

1.O表示最壞的時間複雜度;

2.我們假設需要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 的文件是怎麼說的:

1.操作碼 指令     描述
2.FF /4  JMP r/m32  Jump near, absolute indirect,
3.           address given in r/m32

原來JMP 使用了一種絕對定址方式,也就是說,它的運算元(CASE語句中的 0040102C)表示一個絕對地址。還用我說什麼嗎?遠端 ThreadFunc 會盲目地認為地址表中開關地址是 0040102C,JMP到一個錯誤的地方,造成遠端程式崩潰。

附錄F:

為什麼遠端程式會崩潰呢?

當遠端程式崩潰時,它總是會因為下面這些原因:

1.在ThreadFunc 中引用了一個不存在的串;

2.在在ThreadFunc 中 中一個或多個指令使用絕對定址(參見附錄E);

3.ThreadFunc 呼叫某個不存在的函式(該呼叫可能是編譯器或連結器新增的)。你在反彙編器中可以看到這樣的情形:

1.:004014C0    push EBP         ; ThreadFunc 的入口點
2.:004014C1    mov EBP, ESP
3. ...
4.:004014C5    call 0041550     ;  這裡將使遠端程式崩潰
5. ...
6.:00401502    ret

如果 CALL 是由編譯器新增的指令(因為某些“禁忌” 開關如/GZ是開啟的),它將被定位在 ThreadFunc 的開始的某個地方或者結尾處。

不管哪種情況,你都要小心翼翼地使用 CreateRemoteThread 和 WriteProcessMemory 技術。尤其要注意你的編譯器/連結器選項,一不小心它們就會在 ThreadFunc 新增內容。

參考資料:

使用 INJLIB載入32-位 DLL到另一個程式地址空間――Jeffrey Richter. MSJ May, 1994

HOWTO: 在Windows 95中子類化窗――微軟知識庫文章125680

Tutorial 24: Windows 鉤子――Iczelion

CreateRemoteThread ――Felix Kasza

API 鉤子揭祕――Ivo Ivanov

PE 內幕――Win32 PE 檔案格式探祕――Matt Pietrek, March 1994

Intel 體系架構――軟體開發人員手冊, Volume 2: 指令集參考

相關文章