【系統篇】從int 3探索Windows應用程序調試原理


探索調試器下斷點的原理

  在Windows上做開發的程序猿們都知道,x86架構處理器有一條特殊的指令——int 3,也就是機器碼0xCC,用於調試所用,當程序執行到int 3的時候會中斷到調試器,如果程序不處於調試狀態則會彈出一個錯誤信息,之后程序就結束。使用VC開發程序時,在Debug版本的程序中,編譯器會向函數棧幀中填充大量的0xCC,用於調試使用。因此,經常我們的程序發生緩沖區溢出時,會看到大量的“燙燙燙…”,這是因為“燙”的編碼正是兩個0xCC。

  那么?為什么int 3可以讓程序中斷到調試器呢?沒有調試運行的時候,遇到int 3又怎么出現程序崩潰呢?使用VS調試時F9下的斷點是如何工作的?使用WinDbg的bp下的斷點是如何工作的?使用OllyDbg使用F2下的斷點呢?單步步入,單步步過怎么實現的呢?別着急,這篇文章將帶領你從一個簡單的int 3開始探索Windows系統至上而下的調試原理。Let’s go!

  其實,無論使用VC++中的F9下斷點也好,還是使用WinDbg中的bp下斷點也好,也包括OllyDbg使用F2下斷點,它們的工作原理都是一樣的:使用了int 3。具體怎么做的呢?我們以VC++為例,當我們將光標定位到源代碼的一行,按下F9后,VC++就會記下位置,隨即我們使用F5啟動調試程序后,VC++將會把下斷點位置的機器指定第一個字節先保存起來,然后改為0xCC,這樣,當程序執行到這里時,將觸發到調試器,調試器然后把這個地址處的值改回保存的值,這樣程序就可以往下執行,從而達到了下斷的目的而又不改變程序原來的指令。我們通過實驗來證實這個原理。

使用VC++新建控控制台程序,在main函數中鍵入如下代碼:

 1 int _tmain(int argc, _TCHAR* argv[])  2 {  3      unsigned char* pCode = NULL;  4  __asm{  5  push eax  6  mov eax, asm_addr  7  mov pCode, eax  8  pop eax  9  } 10      printf("0x%08X: %02X\n", pCode, *pCode); 11  __asm{ 12 asm_addr: 13  nop 14  nop 15  nop 16  nop 17  nop 18  } 19      return 0; 20 }

代碼很簡單,兩段內嵌匯編,其中第二段就是一段nop指令,也就是5個0x90。第一段是把標號asm_addr代表的地址,也就是第二段內嵌匯編的地址保存到局部變量pCode中,然后把這個地址的第一個字節打印出來。首先編譯直接Ctrl+F5非調試運行,得到如下的結果:

讀取到的內容是0x90,正是第一個nop指令。現在我們把光標定在第一個nop那一樣,按下F9,設置一個斷點。然后使用F5調試運行,輸出的內容如下:

可以看到,在調試狀態下讀取到的內容成了0xCC,就是一條int 3指令。這印證了前面的描述。需要注意的是,當你使用VC++調試的內存查看窗口查看到的內容仍然是0x90,這是因為VC在給調試者呈現的時候屏蔽了它設置斷點的操作,呈現的時候給你顯示原來的數據。

我們再看一看使用OllyDbg的F2,同樣的,啟動OD打開上面生成的那個程序,然后在任意一處按下F2,如下圖所示:

我選擇了在地址0x01041790處按下了F2,可以看到OD已經將這個地址標注為紅色,表示這里有一個斷點。那么此時,這個地址處的第一個字節代碼已經從圖中的0x8B改變成0xCC了。同樣和VC++有一樣的問題,當你直接通過OD的內存查看窗口查看0x01041790的內存時它也會給你呈現原來的數據,這樣就看不到變化了。為此,我們需要借助其他的工具。這里我選擇使用PCHunter的內存查看功能,指定地址將這段內存的內容dump出來,如下圖所示:

保存到文件打開如下所示:

對比OD中該地址處的指令代碼,可以發現,確實第一個字節已經變成了一條int 3中斷了。

對於WinDbg的bp命令使用的是同樣的手段實現的,大家可以去嘗試驗證一下。

對於單步步入和單步步過調試,相信到這里大家應該有自己的猜想了,可以去驗證一下,不再展開,進入今天的重點吧:int 3是如何讓程序中斷到調試器的呢?

 

Windows XP及其之后應用程序調試模型

仔細想想,在一次調試過程中,有哪些主要角色呢?至少有一個被調試進程,一個調試器吧。這是當然,那么除此之外呢?還需要操作系統層面的支持。下面看一張Windows下的應用程序調試簡單模型圖:

總體上有這么一個粗略的框架。下面就把這個結構一步步細化。

首先,對於一個調試器而言,它是作為調試會話的主動發起方。這通常有三種最常見的情景:

1、     打開調試器,文件——打開可執行文件——開始調試

2、     打開調試器,附加到一個正在運行的進程

3、     程序運行崩潰,選擇一個調試器調試,其實這和2屬於同一類。

 

無論怎樣,調試器都是作為調試會話的主動發起方,通過調用DebugActiveProcess() API開啟一次調試過程。對於一個調試器進程而言,它的核心工作就是進行一個調試信息獲取然后處理的循環。這有點像開發使用SDK開發Windows 應用程序使用的GetMessage,然后再處理循環。如下圖所示(這里使用一下張銀奎先生著作《軟件調試》第229頁的截圖):

調試器使用WaitForDebugEvent來捕獲調試消息,然后進行調試消息處理,處理完畢之后使用ContinueDebugEvent使被調試線程繼續運行等待下一個調試事件。非常簡單的邏輯,簡直和消息處理循環如出一轍啊。那么調試消息總共有哪些呢?查閱MSDN,在調試消息結構體DEBUG_EVENT中的第一個字段dwDebugEventCode 指出了所有的調試消息類型:

不多,就這幾種。遠比Windows消息少得多。

 

現在我們知道調試器核心調試線程是一個不斷獲取調試消息並處理的過程。調試器在獲取消息,那么誰在發送消息呢?不用猜也知道,被調試進程在發送消息。

暫且拋開調試不談,讓我們看看Windows中斷與異常處理機制。

 

Intel x86平台上Windows的異常處理流程

很多書上都曾講到,對於一個CPU,它內部有一個48bit的IDTR寄存器。在保護模式下,它指向了一個具有8*256項的一張表——IDT,中斷描述符表。表中指定了當每個中斷(或陷阱)出現時,CPU將要執行的處理函數——ISR,中斷服務例程。

對於 int 3而言,當CPU執行它時將自動從IDT中取出向量號為3的ISR來執行。在Windows平台上,操作系統在這個表的3號向量ISR填充為_KiTrap03()。該函數做一些簡單信息保存后調用nt!CommonDispatchException,該函數再調用nt!KiDispatchException()進行異常的分發過程。

nt!KiDispatchException()是Windows NT系列操作系統中Ring0級異常分發的核心樞紐。關於該函數的詳細信息,《軟件調試》一書中有詳細的講解。總體來說,nt!KiDispatchException()主要干兩件重要的事:

第一,先把異常消息發送給調試子系統,等待調試子系統的處理。如果當前進程沒有處於調試狀態,或者調試子系統沒有對該異常進行處理,那么將進行第二步。

第二,提交給Ring3的ntdll!KiUserExceptionDispatcher(),用於向量化異常處理和結構化異常處理。

對於一個處於調試狀態的進程來說,異常發生時,首先得到通知的是調試器,如果調試器未處理異常,那么將進入第二步,比如通過結構化異常處理進入你的__except處理分支。

 

Windows平台被調試進程投遞調試消息過程

這篇文章將重點關注第一點。在nt!KiDispatchException()中,調用nt!DbgkForwardException()向調試子系統報告異常信息。除了異常信息之外,Windows還定義了其他幾種調試信息。如下所示:

在WRK中定義了一個被調試進程將要發送的調試消息類型枚舉:

 1 typedef enum _DBGKM_APINUMBER  2 {  3  DbgKmExceptionApi,  4  DbgKmCreateThreadApi,  5  DbgKmCreateProcessApi,  6  DbgKmExitThreadApi,  7  DbgKmExitProcessApi,  8  DbgKmLoadDllApi,  9  DbgKmUnloadDllApi, 10  DbgKmMaxApiNumber 11 } DBGKM_APINUMBER;

在被調試進程Ring0級,和上面枚舉值對應的調試消息發送者為下列函數:

1 nt!DbgkForwardException()     //發送異常消息
2 nt!DbgkCreateThread()       //發送線程/進程創建消息
3 nt!DbgkExitThread()        //發送線程退出消息
4 nt!DbgkExitProcess()                  //發送進程退出消息
5 nt!DbgkMapViewOfSection()       //發送DLL加載消息
6 nt!DbgkUnmapViewOfSection()    //發送DLL卸載消息

仔細觀察這些函數以及發送的消息類型,和前面調試器獲取的消息類型基本吻合。調試器獲取到的哪些調試消息就是被調試進程通過這些函數發出的。

上面這些函數最終又會調用nt!DbgkpSendApiMessage()來發送調試信息。

那么,對於int 3,它屬於哪種消息呢?對照上面幾種消息類型,只能是異常。我們重點關注int 3所屬的異常調試消息。異常類型的調試消息是通過函數nt!DbgkForwardException()發出的,上面說了,所有類型的消息都是通過nt!DbgkpSendApiMessage()發出的,而在發送消息之前,nt!DbgkpSendApiMessage()會執行一個操作:調用nt!DbgkpSuspendProcess()將當前進程中除自己所在線程外的所有其他線程全部凍結,也就是停止它們的執行。然后開始調用nt! DbgkpQueueMessage()真正開始投送消息了。

那么,消息如何發送?發送到哪里呢?一次調試會話中的兩個重要角色:調試器與被調試進程是通過什么連接在一起呢?在Windows XP及以后的系統上,是一個通過調試對象的內核對象實現的。在WRK中有關於這個內核對象的定義:

1 typedef struct _DEBUG_OBJECT { 2  KEVENT EventsPresent; 3  FAST_MUTEX Mutex; 4  LIST_ENTRY EventList; 5  ULONG Flags; 6 } DEBUG_OBJECT, *PDEBUG_OBJECT;

很簡單,就四個成員。其中EventList是最重要的,它作為頭將所有的調試消息構成了一個雙向鏈表。發送消息的時候向鏈表中插入一個節點,然后設置EventsPresent事件讓調試器來取消息。取消息的時候從鏈表中取得一個節點,然后使用nt! KeClearEvent()來關閉EventsPresent事件。調試消息處理完畢從鏈表中將這個節點刪除。同時為了調試器和被調試進程對這個鏈表的操作進行互斥,設置了一個Mutex。

消息鏈表中鏈接的節點是DEBUG_EVENT結構體,需要指出的是,調試器在Ring3調用kernel32!WaitForDebugEvent()獲取到的調試消息也是一個叫DEBUG_EVENT的結構體,不過這兩者並不相同。中間涉及到一些結構轉化,關於這一點稍后再表。在這個調試消息隊列中的節點,即Ring 0下的DEBUG_EVENT,結構在WRK中如下定義:

 1 typedef struct _DEBUG_EVENT {  2     LIST_ENTRY EventList;      // Queued to event object through this
 3  KEVENT ContinueEvent;  4  CLIENT_ID ClientId;  5     PEPROCESS Process;         // Waiting process
 6     PETHREAD Thread;           // Waiting thread
 7     NTSTATUS Status;           // Status of operation
 8  ULONG Flags;  9     PETHREAD BackoutThread;    // Backout key for faked messages
10     DBGKM_APIMSG ApiMsg;       // Message being sent
11 } DEBUG_EVENT, *PDEBUG_EVENT;

其中有一個成員叫ContinueEvent,顧名思義,”繼續事件”。對於一個新的調試信息,被調試進程將這個節點的這個ContinueEvent事件初始化為無信號狀態。將調試消息節點插入后,將開始來使用nt! KeWaitForSingleObject()來等待這個事件。由於之前已經使用nt!DbgkpSuspendProcess()將本進程其他線程都已經凍結了,這個等待將導致自己也停止運行。至此,被調試進程所有線程都將停止運行。所以在調試的時候中斷后,被調試進程出現“卡死”的現象,就是這樣實現的。

那么這個DEBUG_OBJECT放在哪里的呢?如何找到它?在被調試進程的EPROCESS結構中,有一個DebugPort成員,它就是作為一個指針,指向了自己進程所屬的DEBUG_OBJECT。

 

Windows平台調試器獲取調試消息及其處理過程

調試器又是如何取得這些消息的呢?

      調試器調用kernel32!WaitForDebugEvent()獲取調試消息,這個函數內部將使用ntdll!DbgUiWaitStateChange()進一步進入Ring0的nt!NtWaitForDebugEvent()進行調試消息的獲取。該進程將從前面的DEBUG_OBJECT中提取調試消息。提取之前將判斷EventsPresent是否為有信號狀態,前面說了,一旦被調試進程向鏈表中插入一個新的消息后,將會把這個事件置為有信號狀態。當獲取到一個新的調試消息后nt!NtWaitForDebugEvent()對消息結構體進行一個轉換,就依次返回到Ring3上的調用者。然后開始對這個獲取到的調試消息進行處理。

      和被調試進程一樣的問題,調試器又如何找到這個DEBUG_OBJECT呢?被調試進程是通過自己的EPROCESS中的DebugPort域找到的。對於調試器而言,它保存了DEBUG_OBJECT這個內核對象的句柄到調試器工作線程(DWT)的TEB的DbgSsReserved中。TEB的DbgSsReserved是一個含有兩個成員的數組。其中DbgSsReserved[0]是一個指針,指向了DBGSS_THREAD_DATA結構構成的一個單向鏈表,這個結構描述了被調試進程的所有線程信息。DbgSsReserved[1]便是DEBUG_OBJECT的一個HANDLE。調試器通過這個句柄獲取到DEBUG_OBJECT內核對象。而這個HANDLE通過ntdll!DbgUiWaitStateChange()進入Ring0時從TEB中獲取后傳遞給了nt!NtWaitForDebugEvent()。

  對於一個int 3斷點異常消息而言,調試器收到這個消息以后,判斷如果這個斷點是自己設置的(比如F9(VC++)或F2(OD)或bp(WinDbg)),就將原來寫在這個地方的指令改寫回去。然后讓程序繼續執行。

  調試器處理完一個調試消息后,使用kernel32!ContinueDebugEvent()讓被調試進程繼續運行。那它又是怎么做的呢?在kernel32!ContinueDebugEvent()內部調用了ntdll!DbgUiContinue(),最后調用了nt!NtDebugContinue(),該系統服務如前面所述將從消息鏈表中將處理的這個消息從鏈表中摘除。隨后,調用nt! DbgkpWakeTarget()將這個節點的ContinueEvent成員置為有信號狀態。如此一來,原來被調試進程中等待這個事件的線程將從等待狀態中“蘇醒”過來,繼續開始執行。

  被調試進程中等待這個事件的線程也就是原來投遞調試消息的這個線程“蘇醒”過來后就代表着這個消息已經被處理完畢。隨后負責消息投遞的nt! DbgkpQueueMessage()完成返回到nt!DbgkpSendApiMessage()后,隨即調用nt! DbgkpResumeProcess()將其他線程全部解凍。到這里,被調試進程的所有線程都已經全部恢復運行了。

 

int 3 斷點完整過程

  至此,對於一個int 3斷點中斷到調試器的完整過程簡化如下描述:

  Step 1: CPU執行 int 3時,將通過IDTR寄存器從其中斷描述符表中獲取中斷服務例程,也就是nt!_KiTrap03(),這一點是在CPU硬件級別完成的。

  Step 2: nt!_KiTrap03()按照nt!CommonExceptionDispatch()--->nt!KiDispatchException()--->nt!DbgkForwardException()的路線進入公共的調試消息發送例程:nt!DbgkpSendApiMessage()。

  Step 3: 公共調試消息發送例程nt!DbgkpSendApiMessage()首先將除自身外其他所有線程凍結。然后調用nt!DbgkpQueueMessage()開始消息投遞。投遞的時候然后通過自身EPROCESS找到DEBUG_OBJECT,在等待互斥體Mutex后,向消息隊列EventList插入一個新的調試消息。然后把DEBUG_OBJECT中的EventsPresent事件置為有信號狀態,以此來通知調試器:現在有新的調試消息產生,快來讀取吧。完成這個動作后,便開始等待消息中的ContinueEvent事件,從而整個進程停止運行。

  Step 4:當調試器得到通知后,也就是EventsPresent事件變有信號狀態后,便沿着kernel32!WaitForDebugEvent()--->ntdll!DbgUiWaitStateChange()--->nt!NtWaitForDebugEvent()進入Ring0,從DEBUG_OBJECT的消息鏈中提取出調試消息后原路返回到Ring3。

  回到Ring3后,調試器交互界面便開始等待我們的操作。這個時候我們的程序看到的現象就是中斷到了調試器。直到我們繼續運行程序(比如F5(VC++/WinDbg)或者F9(OllyDbg)),調試器才開始進行調用kernel32!ContinueDebugEvent()一路進入內核把調試消息消息的ContinueEvent置為有信號,從而“解放”被中斷的進程。

  總體來看,DEBUG_OBJECT是連接被調試進程和調試器的核心數據結構。當調試器使用kernel32!DebugActiveProcess()時將會產生一個DEBUG_OBJECT內核對象,將句柄保存在自己線程的DbgSsReserved[1]中,把地址保存到被調試進程的EPROCESS中。同時將被調試進程的PEB中的BeingDebugged字段標示為TRUE。

 

全景展現

下面看一張整個過程的全景圖,以加深對這個過程的認識和理解(可以右鍵在新標簽頁打開,查看高清大圖):

圖中,實心箭頭表示調用關於,虛線空心箭頭表示對對象進行操作,藍色虛線箭頭是調試器的調試循環。

 

如果當前進程沒有處於調試狀態,那么進程的EPROCESS中的DebugPort字段將為NULL,nt!DbgkForwardException()在發現其為空后將直接返回。不再繼續進行異常消息的傳遞。將回到nt!KiDispatchException(),而這個函數發現沒有調試將進一步進入進入應用層的異常處理模型。具體的調用ntdll!RtlDispatchException()開始SEH鏈表中尋找異常處理器。如果在int 3外使用了__try __exception進行捕獲則程序正常運行,否則將進入SEH的底端,最后彈出一個框宣告程序掛了。

最后,再附一張各個階段中調試消息的轉換過程圖:

 

參考資料:

張銀奎:《軟件調試》

WRK

ReactOS

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM