很多朋友都夢想有自己的Debugger程式,今天我們就來自己製作一個。作為一個Debugger程式,其最基本的功能架構其實就是完成2件事情:
啟動目標程式。
即時監控目標程式的運行,並做出相應的應對。
我們要打造自己的Debugger程式,實際上也只需要完成這兩個功能就可以了。當然,要完成這兩個特定的功能,我們不可能從頭開始造輪子,要首先看看作業系統給我們提供了什麼樣的基礎設施:
由於我們是在Windows平台上工作,自然離不開微軟公司提供的文檔大全——MSDN。翻開MSDN,定位到“Debugging and Error Handling”,一些最基本的Windows Debug資訊都在這裡面。不過與其他欄目相比,這個欄目的資訊明顯顯得單薄許多——也許越是底層、強大的技術,微軟越不想公開吧。
初步瀏覽之後,我們可以確定,對於我們的Debugger而言,最重要的Debug API有如下幾個:
CreateProcess —— 用於建立被調試進程
WaitForDebugEvent —— Debug Loop(調試迴圈)的主要構成函數
ContinueDebugEvent —— 用於構成Debug Loop
GetThreadContext —— 得到被調試進程的寄存器資訊
SetThreadContext —— 設定被調試進程的寄存器資訊
ReadProcessMemory —— 得到被調試進程的記憶體內容
WriteProcessMemory —— 設定被調試進程的記憶體內容
最重要的資料結構有如下幾個:
CONTEXT —— 寄存器結構
STARTUPINFO —— Start資訊
PROCESS_INFORMATION —— 進程相關資訊
DEBUG_EVENT —— Debug Event(調試事件)結構
可以說,我們的Debugger程式就是利用這幾個API函數結合下面的幾個資料結構,完成我們指定的功能。那麼下面就讓我們先來看看這幾個API和資料結構的具體含義:
Debug API解析
在這裡,我們將對上面所述的幾個Debug調試API做一個檢閱式的考察,大概介紹一下每個API的應用領域。而將這些API應用到具體實踐中去,將會在下一部分“執行個體解析”中,給出詳細的說明。
1.CreateProcess。
函數原型:BOOL CreateProcess(
LPCTSTR lpApplicationName, // 要建立的進程模組名
LPTSTR lpCommandLine, // 命令列字串
LPSECURITY_ATTRIBUTES lpProcessAttributes, // 進程安全屬性
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 安全執行緒屬性
BOOL bInheritHandles, // 控制代碼繼承選項
DWORD dwCreationFlags, // 進程建立選項
LPVOID lpEnvironment, // 進程環境塊資料指標
LPCTSTR lpCurrentDirectory, // 目前的目錄名
LPSTARTUPINFO lpStartupInfo, // 啟動資訊
LPPROCESS_INFORMATION lpProcessInformation // 進程資訊
);
函數解析:該函數是Windows平台提供的最基本的建立進程的函數。每當我們雙擊一個EXE可執行檔,Windows核心就會自動調用該函數建立我們雙擊的檔案所對應的進程。該函數中,最重要的參數有三個:一個是進程模組名,指明了要建立哪個進程;一個是進程建立選項,指明了要如何建立目標進程;對於Debugger程式而言,最常用的建立選項就是:DEBUG_PROCESS|DEBUG_ONLY_THIS_PROCESS。最後還有一個就是進程資訊,我們調用CreateProcess建立了進程以後,Windows會將新建立的進程的相關資訊全部放到ProcessInfo資訊塊中,我們在Debug Loop調試迴圈中使用進程資訊塊中的資料與目標進程互動,監視和控制目標進程的動作。
2.WaitForDebugEvent。
函數原型:BOOL WaitForDebugEvent(
LPDEBUG_EVENT lpDebugEvent, // Debug Event(調試事件指標)
DWORD dwMilliseconds // 逾時設定
);
函數解析:該函數構成了Debug Loop調試迴圈的主體,一個Debugger程式在建立出目標進程後,一般都會緊接著迴圈調用該函數等待目標進程的各種調試資訊,這個迴圈調用WaitForDebugEvent的過程,我們就稱之為Debug Loop調試迴圈。調試迴圈是所有Debugger程式的主體部分,Debugger幾乎所有的監視、控制、調整的功能都是在調試迴圈內完成的。一般來說,此處的逾時設定都設定為-1也就是無窮等待下去,該函數是非阻塞函數,在沒有Debug Event發生,處於等待的過程中,僅消耗極其微小的系統資源。
3.ContinueDebugEvent。
函數原型:BOOL ContinueDebugEvent(
DWORD dwProcessId, // 目標進程ID
DWORD dwThreadId, // 目標線程ID
DWORD dwContinueStatus // 線程繼續的標誌
);
函數解析:該函數主要用於Debugger在Debug Loop調試迴圈中,處理完Debug Event,通知目標進/線程繼續運作。通常情況下,目標進程ID和目標線程ID這兩個參數,都是CreateProcess調用後,ProcessInfo結構中所包含的資訊。該函數通過目標進程/線程ID來唯一標識目標進/線程,並且通過設定不同的ContinueStatus來通知目標進/線程繼續啟動並執行動作。最主要的ContinueStatus有兩種選擇:一個是DBG_CONTINUE,表明調試事件已經被Debugger處理完畢,目標進/線程可以照常繼續運行;另一個是DBG_EXCEPTION_NOT_HANDLED,表明Debugger並未處理該調試事件,目標進程收到該標誌位後,將會將調試事件沿著Windows異常調用鏈繼續往下發送。直至該調試事件被處理完為止——當然,如果目標進程發出的Debug Event沒有任何調試器能夠處理,那最後Windows只有祭出自己的殺手鐧:應用程式XXX異常,即將被關閉。
3.GetThreadContext & SetThreadContext。
函數原型:BOOL GetThreadContext(
HANDLE hThread, // 目標線程控制代碼
LPCONTEXT lpContext // CONTEXT結構
);
BOOL SetThreadContext(
HANDLE hThread, // 目標線程控制代碼
CONST CONTEXT *lpContext // CONTEXT結構
);
函數解析:這兩個函數分別用來得到和設定目標線程的寄存器內容。請注意,在Windows作業系統中,作業系統調度的最小單位粒度是線程而不是進程,所以,籠統地說:設定某進程的寄存器內容是錯誤的,因為一個進程可能對應多個線程。因此在和寄存器打交道的時候,一定要指明是哪個線程所對應的寄存器。該函數參數中,由hThread參數也就是線程控制代碼參數指定目標線程,該參數的來源也通常是CreateProcess調用後所得到的ProcessInfomation中的hThread成員。而CONTEXT結構則是根據所在機器硬體平台的不同而有不同的定義。Windows作業系統在Intel、MIPS、Alpha和PowerPC平台上,各有不同的CONTEXT定義,每種定義都忠實而完整地反映出了目標CPU的寄存器分布情況。不過雖然CONTEXT結構在不同的CPU平台上有不同的表現形式,但是最基本的Intel X86架構在各個CPU上面的表現都是相同的,因此,只要Debugger代碼未牽涉到各個CPU非常Specifc的細節,還是可以跨CPU平台使用的。
4.ReadProcessMemory & WriteProcessMemory。
函數原型:BOOL ReadProcessMemory(
HANDLE hProcess, // 進程控制代碼
LPCVOID lpBaseAddress, // 欲讀取記憶體基地址
LPVOID lpBuffer, // 資料緩衝區指標
SIZE_T nSize, // 欲讀取的記憶體內容長度
SIZE_T * lpNumberOfBytesRead // 實際讀取的記憶體內容長度
);
BOOL WriteProcessMemory(
HANDLE hProcess, // 進程控制代碼
LPVOID lpBaseAddress, // 欲寫入記憶體基地址
LPCVOID lpBuffer, // 資料緩衝區指標
SIZE_T nSize, // 欲寫入的記憶體內容長度
SIZE_T * lpNumberOfBytesWritten // 實際寫入的記憶體內容長度
);
函數解析:這兩個函數分別用於“讀取”和“寫入”目標進程的記憶體位址空間。與寄存器操作不同,Windows對記憶體的分配粒度是以進程為單位的。由於自Intel 386以後,所有的Intel x86系列CPU都採用的保護模式,因此在保護模式下,Windows為每一個應用程式——也就是每一個進程都虛擬出一個擁有4GB大小記憶體的“虛擬機器”, 隸屬於該進程的所有線程都共用這4GB的地址空間。因此,與上面寄存器操作不同,讀取、寫入記憶體操作時,我們需要的是進程的控制代碼——當然,該控制代碼也來源於CreateProcess後得到的ProcessInfomation結構。有了進程控制代碼以後,我們還需要一個基地址和一個長度參數來確定我們的Debugger程式所需要讀取的記憶體範圍。當然,此處的基地址數值上應該對應於Windows虛擬出來的那4GB的平坦地址空間內的地址——也就是經過了段選擇和頁選擇過程後的地址數值。
DEBUG_EVENT全面剖析
在整個調試迴圈中,Debugger和目標進程之間調試資訊的互動完全是通過調用WaitForDebugEvent時,傳遞的DEBUG_EVENT結構參數。該結構的定義初看起來似乎很簡單:
typedef struct _DEBUG_EVENT {
DWORD dwDebugEventCode;
DWORD dwProcessId;
DWORD dwThreadId;
union {
EXCEPTION_DEBUG_INFO Exception;
CREATE_THREAD_DEBUG_INFO CreateThread;
CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
EXIT_THREAD_DEBUG_INFO ExitThread;
EXIT_PROCESS_DEBUG_INFO ExitProcess;
LOAD_DLL_DEBUG_INFO LoadDll;
UNLOAD_DLL_DEBUG_INFO UnloadDll;
OUTPUT_DEBUG_STRING_INFO DebugString;
RIP_INFO RipInfo;
} u;
} DEBUG_EVENT, *LPDEBUG_EVENT;
不過就是一個union聯合域加上三個普通的資訊資料。可細細推敲之下,這個結構所實現的功能卻絕對不簡單——讓我們做一個最簡單的思考,作為Debugger,至少應該能夠收到:
目標進程啟動
目標進程發生異常
目標進程退出
這三個最基本的調試資訊。而且每個資訊所對應的資訊類型應該都不太一樣,比如:
目標進程啟動時,我們需要的是目標進程的模組名字,使用權限設定等啟動資訊
目標進程異常時,我們應該能夠知道異常的地址,異常的原因(這裡面分類又很多),異常的嚴重程度
目標進程退出時,我們應該能夠知道進程的退出值,用來確定進程是否屬於正常退出
上面所列出的,還只是一些最基本的要素而已,如果要構成整個Windows Debug API的資料互動層,那情況還要複雜得多。將這麼多錯綜複雜的資訊全部用一個結構表示,其難度可想而知。
微軟公司在這裡選擇的方法是通過dwDebugEventCode標識最基本資料,然後通過union聯合域將各種資訊全部包羅進去的方法。這種做法的優點是在WaitForDebugEvent調用時,只需要傳遞一個統一的結構參數即可;缺點就是Debug_Event結構內部資訊的異常複雜,給程式設計帶來不小的麻煩。
我們在使用Debug_Event結構時,首先要得到dwDebugEventCode的值,從而確定union聯合域內是什麼內容。兩者之間的對應關係如下表所示:
dwDebugEventCode的值
Union聯合域類型 調試資訊
EXCEPTION_DEBUG_EVENT EXCEPTION_DEBUG_INFO
應用程式發生異常
CREATE_THREAD_DEBUG_EVENT CREATE_THREAD_DEBUG_INFO
線程建立
CREATE_PROCESS_DEBUG_EVENT CREATE_PROCESS_DEBUG_INFO
進程建立
EXIT_THREAD_DEBUG_EVENT EXIT_THREAD_DEBUG_INFO
線程退出
EXIT_PROCESS_DEBUG_EVENT EXIT_PROCESS_DEBUG_INFO
進程退出
LOAD_DLL_DEBUG_EVENT LOAD_DLL_DEBUG_INFO
Dll載入
UNLOAD_DLL_DEBUG_EVENT UNLOAD_DLL_DEBUG_INFO
Dll卸載
OUTPUT_DEBUG_STRING_EVENT OUTPUT_DEBUG_STRING_INFO
輸出Debug字串
RIP_EVENT RIP_INFO 系統調試錯誤
上表僅僅是Debug_Event結構資料解析的第一層,當union聯合域取得各個不同的值時,這些第二層的資料結構並不比這一層簡單多少——不過幸虧大部分的時間我們解析Debug_Event結構只需要解析到第二層就可以了。
一般情況下,我們最感興趣的DebugEventCode就是EXCEPTION_DEBUG_EVENT,而不巧的是,EXCEPTION_DEBUG_EVENT所對應的EXCEPTION_DEBUG_INFO結構也是所有union聯合域結構中最複雜的一個,因此,有必要在此再對EXCEPTION_DEBUG_INFO這個二級資料結構作進一步的詳細說明:
typedef struct _EXCEPTION_DEBUG_INFO {
EXCEPTION_RECORD ExceptionRecord;
DWORD dwFirstChance;
} EXCEPTION_DEBUG_INFO, *LPEXCEPTION_DEBUG_INFO;
dwFirstChange:如果為0,表示是該異常從前未被處理過,也就是當前我們的Debugger程式處於Windows異常處理鏈條的頭部。
ExceptionRecord:該結構內部的資訊是EXCEPTION_DEBUG_INFO結構的真實儲存地點。因此,繼續剖析該結構。
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;
首先:要注意到,該結構內部有一個類型為EXCEPTION_RECORD*的資料成員,這是C語言裡經典的串聯資料的方式:用一個指標域串連各個資料成員,可以構成經典的“鏈表”資料結構(英文裡管這種做法叫做Chain)。
其次:ExceptionCode成員標識了該結構所代表的EXCEPTION_RECORD的類型,Windows內部定義了如下20種異常行為:
值 含義
EXCEPTION_ACCESS_VIOLATION 存取越界
EXCEPTION_ARRAY_BOUNDS_EXCEEDED 由硬體監測到的數組訪問越界
EXCEPTION_BREAKPOINT 觸發斷點
EXCEPTION_DATATYPE_MISALIGNMENT 資料未對齊
EXCEPTION_FLT_DENORMAL_OPERAND 浮點運算元範圍越界(太小或太大)
EXCEPTION_FLT_DIVIDE_BY_ZERO 浮點操作除數為0
EXCEPTION_FLT_INEXACT_RESULT 浮點運算結果不能用小數正常表示.
EXCEPTION_FLT_INVALID_OPERATION 其他未知的浮點數錯誤
EXCEPTION_FLT_OVERFLOW 浮點操作太大溢出
EXCEPTION_FLT_STACK_CHECK 浮點棧溢出.
EXCEPTION_FLT_UNDERFLOW 浮點操作太小溢出
EXCEPTION_ILLEGAL_INSTRUCTION 執行非法指令
EXCEPTION_IN_PAGE_ERROR 存取未存在的記憶體頁
EXCEPTION_INT_DIVIDE_BY_ZERO 除數為0
EXCEPTION_INT_OVERFLOW 整數操作,最高位溢出
EXCEPTION_INVALID_DISPOSITION 錯誤的例外處理常式地址
EXCEPTION_NONCONTINUABLE_EXCEPTION 遇上不能繼續執行的異常
EXCEPTION_PRIV_INSTRUCTION 當前模式下,不能執行該指令
EXCEPTION_SINGLE_STEP 單步跟蹤斷點觸發
EXCEPTION_STACK_OVERFLOW 線程的棧空間溢出
一般而言,我們的Debugger程式處理得最多的異常情況就是EXCEPTION_BREAKPOINT和EXCEPTION_SINGLE_STEP。例如:我們需要在目標進程運行到0x00400000地址的時候對目標進程進行一些操作,那麼我們只要設法讓目標程式運行到0x00400000的時候,向我們的Debugger程式發出異常訊號,那麼我們的Debugger程式就能收到該訊號,並進而通過前面介紹的Set/GetThreadContext和Read/WriteProcessMemory函數對目標進程進行控制操作。
一個最初步的Debugger架構
既然上面所述的內容現在已經足夠我們寫出一個最基本的Debugger了,那麼我們就來“實戰演習”一番,先寫出一個最基本的Debugger程式,該程式完成的功能異常簡單:利用CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS作為標誌,建立一個進程,並讓目標進程能夠照常地運作起來。
首先跳入我們腦海的代碼大概如下所示:
::CreateProcess (_T("Msg.exe"), NULL, NULL, NULL, NULL, DEBUG_PROCESS|DEBUG_ONLY_THIS_PROCESS,
NULL, NULL, &sif, &pi) ;
//這個下面就是大名鼎鼎的Debug架構!
do
{
::WaitForDebugEvent (&DBEvent, INFINITE) ;
dwState = DBG_EXCEPTION_NOT_HANDLED ;
case EXIT_PROCESS_DEBUG_EVENT :
{
STOP = TRUE ;
break ;
}
if (!STOP)
{
::ContinueDebugEvent(pi.dwProcessId, pi.dwThreadId, dwState) ;
}
} while (!STOP) ;
編譯,雙擊,很不幸!Windows給了我們
為什麼會出現上面這種錯誤情況?微軟的文檔裡面並沒有對Debugger具體應該如何工作做出詳細的說明。不過通過跟蹤上面的程式,我們發現每次Msg.exe目標應用程式啟動前,都會向我們的Debugger程式發送一個EXCEPTION_BREAKPOINT的斷點訊號,而我們的Debug Loop並沒有對該訊號進行處理。現在我們加入對該訊號的處理過程,使我們的Debugger不返回DBG_EXCEPTION_NOT_HANDLED而返回DBG_CONTINUE,具體到代碼中,就是在:
case EXIT_PROCESS_DEBUG_EVENT :
這一句之前,加入:
case EXCEPTION_DEBUG_EVENT:
{
switch (DBEvent.u.Exception.ExceptionRecord.ExceptionCode)
{
case EXCEPTION_BREAKPOINT:
{
dwState = DBG_CONTINUE ;
break ;
}
}
break ;
}
編譯、連結,試運行,一切OK。Msg.exe很正常地彈出了MessageBox,
從C到C++
從上面那個最簡單的調試架構代碼不難看出,每當我們要增加一個處理判斷的異常類型,都要在日益龐大複雜的switch – case代碼中增加新的選擇路線,程式的代碼很容易就變得臃腫不堪且難以維護。而實際上整個代碼的工作骨架並沒有發生實質性的改變,變化的僅僅是針對各種不同的異常情況我們所需要的不同處理子過程——這不禁讓我們想起了Template設計模式——我們可以將在Debug Loop中分解Debug_Event的代碼放進架構代碼中,然後在架構代碼中調用不同的hook虛函數,這樣,我們要擴充自己的Debugger功能時,只需要從已有的debug_base基類繼承,重寫hook虛方法即可。
隨本文附帶的Debug_Base.h中有debug_base類,該類就實現了如上所述的Debugger Template模板。該類最簡單的使用如下所示:
Debugger::debug_base debugger ;
debugger.run_debug_loop(std::tstring(TEXT("Msg.exe"))) ;
以上這兩段代碼,就實現了上一節所提到的那個最簡單的調試器所完成的功能——載入被偵錯工具並且照常運行。
Debug_base提供有如下幾個hook虛函數:
hook函數名 函數作用
handle_first_exception Windows核心首次發送EXCEPTION_BREAKPOINT時的處理子過程
handle_exception_breakpoint 調試過程中EXCEPTION_BREAKPOINT的處理子過程
handle_single_step 調試過程中EXCEPTION_SINGLE_STEP的處理子過程
handle_process_create 進程建立處理子過程
handle_process_exit 進程退出處理子過程
handle_thread_create 線程建立處理子過程
handle_thread_exit 線程推出處理子過程
handle_dll_load Dll載入時得到調用
handle_dll_unload Dll卸載的時候得到調用
handle_debug_wstring 被偵錯工具調用OutputDebugString得到調用
實際的編程過程中,我們只需要從debug_base派生出自己的新類,並重寫其中需要的虛函數即可編譯得到自己的Debugger!
例如:我們想讓我們的Debugger具有如下功能:
在程式進入點處中斷,提示已經到達進入點
截獲被偵錯工具的Debug字串,並輸出
那麼我們可以這樣設計我們的新類:
class MyDebugger : public Debugger::debug_base
{
virtual void handle_first_exception(const PROCESS_INFORMATION& pi)
{
::MessageBox(NULL, TEXT("首次中斷"), TEXT("Debugger Worked"), NULL) ;
return ;
}
virtual void handle_debug_wstring(std::wstring& debug_wstring)
{
::MessageBox(NULL, ATL::CW2T(debug_wstring.c_str()), TEXT("Debug String"), NULL) ;
return ;
}
} ;
而主程式並不需要做任何改變,該Debugger運行後,可以接受被偵錯工具的調試資訊,並輸出:
結束語
Windows藉助於Debug API,為我們提供了一個功能全面的Ring 3層級的調試平台,但是由於文檔資料、範例程式碼的缺乏,迄今為止,Debug API的應用面還很窄。其實如果能真正把握住動態調試的精髓,利用Debug API打造一個自己的專有調試器調試一些很特殊的程式是能收到非常好的效果的。可以說,任何一款脫殼機或者是記憶體註冊機都只不過是Debug API的一個具體應用。
當然,由於篇幅所限,本文也不可能更加詳盡深入地探索Windows Debugger的世界,我們的debug_base類還很初步——甚至還不具備在程式運行時增加調試斷點的功能,不過只要理解了Debugger的工作原理,結合MicroSoft和Intel的一些底層的編程資料,各種功能都可以慢慢添加、完善。希望讀者朋友在學習逆向工程的過程中,注意基礎知識的積累、整理、消化,不斷地提升自己對電腦系統的理解水平,最終才能修成一代宗師,打造出made in china的Soft-ice和OllyDbg!