大話調試器(下)

本篇開始介紹一些調試技術。需要讀者很清楚地瞭解Windows系統。



一、本地API鉤子原理

API鉤子是這樣的東西:它通過修改程序代碼,使得程序能夠在進行某一項系統調用前,
執行一段特定的代碼。

儘管鉤子常用於遠程(跨進程)操作,但本地API鉤子也有很重要的意義。

比如,分別在GetDC和ReleaseDC兩個API上設立鉤子,使得GetDC時使一個全局量count(
初始爲0)增1,ReleaseDC時使count減1。顯然,只有count==0才表示打開的DC都被關閉
了。只要count>0,就表明有資源泄漏。(當然,通過ReleaseDC關閉的不侷限於GetDC打
開的DC,還有GetWindowDC等等,故這個例子不是很合理,不過足夠來示意。)

先來看看程序是如何調用系統API的。

首先,系統API肯定不和程序自己在同一個模塊裏。系統API往往以動態庫方式“浮動”
在進程的地址空間裏。此處用“浮動”是指模塊在加載時的默認基地址有可能與別的模
塊發生衝突,因而需要操作系統進行重定位。儘管Win32子系統的三大模塊(KERNEL、USE
R、GDI)的地址是固定的,但考慮不同版本的Windows系統之間,三大模塊的基地址仍然
不同。所以還是得承認模塊的浮動性。當然,加載後模塊的地址將固定不變。

既然模塊是浮動的,那程序怎麼知道目標函數的位置呢?其實很簡單,讓操作系統來回
答。
任何一個可執行文件都有一個導入表(IAT,Import Address Table),上面記載與被調用
API有關的信息,比如此API在模塊中的序號或直接就是API的函數名,但沒有具體地址。
這個因環境不同而不同的地址由操作系統在完成模塊加載後填寫。

這下事情就清楚了。比如要調用SHELL32.dll裏的ShellAboutA函數,只需在代碼裏寫:
ShellAbout(NULL,"1","2",NULL);

按照上述討論,編譯器不能把代碼翻譯成:
mov esi,XXXXXXXXh(ShellAboutA的地址)
call esi

事實上,它也沒辦法這麼做:這個ShellAboutA的地址根本不知道。
於是,編譯器採用妥協的辦法。
call dword ptr [__imp__ShellAboutA@16]

這裏的__imp__ShellAboutA@16是一定值(硬編碼的數值,可以視爲宏),它是內存中一個
特定單元的地址。這條指令的意義就是間址__imp__ShellAboutA@16得到一32位整數,它
就是調用的目標。
而__imp__ShellAboutA@16指向的就是PE文件的導入表在內存中的映象。因爲在SHELL32.
dll被加載後,操作系統會在導入表裏填入具體的調用地址,所以通過編譯器與操作系統
兩方面配合,正確實現了系統調用。

整個流程可以這樣示意:

在靜態的文件中(還沒有被執行的程序),語義是這樣:

------- --------
| 代碼 | ----->| 值爲0 |
------- --------
__imp__ShellAboutA@16

程序被加載後:
------- ----------------- ------------
| 代碼 | ----->| ShellAboutA的地址 | ----->| ShellAboutA |
------- ----------------- ------------
__imp__ShellAboutA@16
(操作系統填寫)


過程搞清楚了之後,就可以編寫本地API鉤子了。
顯然,只要改寫__imp__ShellAboutA@16的值,讓它指向我們的一個函數F,那麼程序模
塊的所有ShellAboutA調用都將成爲對F的調用。
請注意:此處模塊只侷限於程序的模塊,不包括其它系統模塊;調用僅限於靜態(或稱隱
式)調用,不包含通過GetProcAddress完成的動態(或稱顯式)調用。

動手之前,需要說明一下:鉤子函數F的原型必須和被攔截的API一模一樣,這不僅包括
返回值類型,參數個數和類型,更重要的是調用的方式,即語言層面上的PASCAL調用和C
調用。調用方式如果不一致,將會導致棧的不平衡,進而使程序崩潰。
PASCAL調用和C調用的區別在於前者要求被調用方(通過ret指令)來恢復棧,後者要求 調
用方(通過修改sp)來恢復。後者的好處在於能處理具有可變數目的參數的函數。
正常聲明的C函數默認是C風格調用;加上修飾符__stdcall是PASCAL風格調用。

現在剩下的事情就是找出導出表的相應表項,加以修改就行了。完成這個目標,必須要
熟悉PE文件格式。這類文檔很多,請自行尋找(MS有一篇類似白皮書的文檔)。

編寫import_entry函數返回導入表的表項的地址p。
改寫p的內容,把它改爲ShellAbout1---這是我們自己定義的函數。
簡單起見,僅僅在ShellAbout1裏調用MessageBox示範一下。
請注意,此時不能再直接調用ShellAbout,否則會形成遞歸調用,造成棧溢出。

代碼如下:
/* ex1.c */
#include
#include

void* import_entry(const char* mod,const char* fn)
{
   BYTE* b;
   IMAGE_DOS_HEADER* pidh;
   IMAGE_NT_HEADERS* pinh;
   IMAGE_DATA_DIRECTORY* piat;
   IMAGE_IMPORT_DESCRIPTOR* p;
   IMAGE_THUNK_DATA* p1;
   DWORD* p2;
   IMAGE_IMPORT_BY_NAME* p3;
   char* s1;

   b=(BYTE*)GetModuleHandle(NULL); /* get base address */
   pidh=(IMAGE_DOS_HEADER*)b;
   pinh=(IMAGE_NT_HEADERS*)(b+pidh->e_lfanew);
   piat=&pinh->OptionalHeader.DataDirectory[1];
   p=(IMAGE_IMPORT_DESCRIPTOR*)(b+piat->VirtualAddress);
   while (p->Characteristics)
   {
     s1=(char*)(b+p->Name);
     if (strcmpi(s1,mod)==0) /* find module */
       break;
     p++;
   }
   if (!p->Characteristics)
     return 0;

   p1=(IMAGE_THUNK_DATA*)(b+p->OriginalFirstThunk);
   p2=(DWORD*)(b+p->FirstThunk);
   while (p1->u1.AddressOfData)
   {
     p3=(IMAGE_IMPORT_BY_NAME*)(b+p1->u1.Function);
     if ((p1->u1.AddressOfData & 0x80000000)==0 && /* by name */
       strcmp((char*)p3->Name,fn)==0) /* compare name */
       break;

     p1++;
     p2++;
   }
   return p2;
}

int PASCAL ShellAbout1(HWND hWnd,LPSTR s1,LPSTR s2,HICON h)
{
   MessageBox(NULL,s1,s2,0); /* call MessageBoxA instead */
   return TRUE;
}

int main()
{
   *(DWORD*)import_entry("shell32.dll","ShellAboutA")=
     (DWORD)ShellAbout1; /* overwrite */
   ShellAbout(NULL,"Hello","Author",0);   /* test */
   return 0;
}

前面已經說了,本地API鉤子對檢測內存泄漏是很有效的。Numega Boundschecker就是根
據這個原理設計的:它通過對幾乎所有的API掛接鉤子來記錄每一次的分配或釋放動作,
最後在程序結束前作統計。



二、遠程鉤子

本質上沒什麼太大的區別,只要把自己的代碼(往往用動態庫的形式)注入目標進程,那
麼上面的一切都可以照搬。注入動態庫的方法太多了。最漂亮的辦法是藉助NT對遠程線
程的支持。上有介紹。不過這種辦法僅限於NT平臺。9x上的注入通常
依靠掛接消息鉤子(系統直接支持的一種鉤子)來實現。



三、標準API鉤子

在(一)裏已經說了,修改IAT的辦法只能實現對靜態調用(即在代碼裏隱式調用)起作用。
如果一個程序通過LoadLibrary/GetProcAddress來直接獲得API地址完成調用,(一)裏方
法是行不通的。這時可以採取經典的API鉤子技術。

很多書上都有介紹。該種方法通過直接修改目標函數開頭的代碼來使CPU轉跳到鉤子函數
。這種方法有一定的弊病(比如多線程問題,對不規則函數無效等等)。在此不再贅述。

其實如果要求不高,可以變相使用(一)中的辦法來解決該問題。

既然程序通過GetProcAddress來直接獲得API地址,那麼可以通過對GetProcAddress掛接
鉤子來修改GetProcAddress的返回值,因爲GetProcAddress是隱式調用的。

實際上,因爲程序都必須依靠系統模塊才能運行,絕大部分程序都需要隱式調用GetProc
Address和LoadLibrary,特別是在代碼中進行顯式調用的程序。不過病毒不需要,病毒
是通過暴力搜索,或嘗試來獲得系統模塊的位置。

思路還是比較直觀的:程序通過GetProcAddress獲得A的地址,但GetProcAddress已經被
掛接,所以會經過鉤子函數;鉤子函數再完成正常的GetProcAddress調用(仍然注意:此
時不能直接調用,必須事先把覆蓋的GetProcAddress地址保存起來),最後返回對此API
掛接的鉤子函數的地址。

可以用示意圖表示:

設程序想得到B(一個API)的地址,並且用於掛接該API的函數是C:

沒遭到“毒手”時的執行情形:

----------- ---------
| 程序 | ---GetProcAddress---> | B的地址 |
----------- ---------

按照上述的掛接方法後:

-----------                                         ---------
|     程序    | ---GetProcAddress->-     ---返回----->| C的地址 |
-----------          (已掛接)      |   |               ---------
                                   |   |
                  ----<------------    |
                 |                     |                  
           ----------------            |         -------------
          | 用於掛接        |           |        | 用於掛接B的 |
          | GetProcAddress |           |        | 鉤子函數C    |
          | 的鉤子函數      |           |         -------------
           ----------------            |
                 |                      --------------
                 |                       ---------     |
                  ----GetProcAddress-->| B的地址 |---
                        (原始地址)       ---------


本篇敘述這一系列文章裏的最後一件事情:編寫一個極小化的調試器。
第一篇已經介紹了斷點的原理,所以現在把注意力集中在實現技術上。

爲了使問題具體一點,設想有這樣的目標:編寫程序監視Shell的工作,每當Shell啓動
一個新的程序(由用戶雙擊目標引起),程序都能在第一時間內獲得所啓動程序的路徑名


實現此目標的方法很多。這裏利用調試器來實現。
思路很簡單:Shell是通過調用KERNEL32.dll模塊的CreateProcessW來啓動程序,只要在
CreateProcessW被Shell調用時中斷即可。

本篇涉及的程序不能在9x系統上運行,否則整個系統崩潰必定。
(參考中Copy-On-Write機制的描述)

本篇的完整代碼放在第7篇裏。



一、調試器的前提

完成調試工作,調試器必須能夠:
讀寫目標內存、讀寫目標上下文(寄存器)、接管相關中斷、改變目標(各個線程的)狀態


所幸的是Windows把這些細節都隱藏起來了,提供了諸多系統接口,用於調試器對目標的
控制。這使得調試器的編寫大爲簡化。不但總體上有統一的異常處理框架,細節上也照
顧的很周到。

例如:
ReadProcessMemory、WriteProcessMemory - 讀寫進程的內存;
GetThreadContext、SetThreadContext - 讀寫進程上下文;
SuspendThread、ResumeThread - 改變進程狀態;

請注意,其間很多系統調用需要有足夠的權限。
這是題外話,以後默認個個都是管理員:)



二、Windows下調試器工作流程

調試器的工作流程很簡單:調試器運行於一個獨立的線程,不停地接受來自被調試程序
的調試事件,通過對這些事件進行進一步的邏輯處理,再根據結果繼續被調試程序的運
行。

目標程序的所有異常或事件都以調試事件(DEBUG_EVENT結構)的格式統一報告給調試器。
調試器用一個開關語句完成諸多事件的邏輯處理。形式上像窗口函數一樣。


調試器通過調用WaitForDebugEvent來等待調試事件的到來:
if (!WaitForDebugEvent(&d,INFINITE))
   assert(0);

有調試事件出現時,該過程返回。
並將事件的有關信息保存在DEBUG_EVENT(即上述調用的第一個參數)裏。

調試事件有:
CREATE_PROCESS_DEBUG_EVENT - 目標進程被建立;
EXIT_PROCESS_DEBUG_EVENT - 目標進程中止;
CREATE_THREAD_DEBUG_EVENT - 目標程序啓動了新的線程;
EXIT_THREAD_DEBUG_EVENT - 目標程序有一個線程中止了;
LOAD_DLL_DEBUG_EVENT - 目標程序裝載了新的模塊;
UNLOAD_DLL_DEBUG_EVENT - 目標程序卸載了新的模塊;
OUTPUT_DEBUG_STRING_EVENT
- 目標程序通過調用OutputDebugString向調試器輸出字符串(詳見第4篇);
EXCEPTION_DEBUG_EVENT - 目標程序觸發了某個異常;

每個事件發生時,系統都會附上必要的信息。
比如建立進程時,系統會爲調試器打開目標進程的句柄,提供PID等等。進程退出時,系
統會提供進程的返回值。

當調試器處理完畢後,它會讓目標繼續運行。這通過調用ContinueDebugEvent實現。
調試器有兩種方式繼續程序運行:一是DBG_CONTINUE,二是DBG_EXCEPTION_NOT_HANDLED

這兩種方式在第3篇裏已經闡述過了:前者會讓程序繼續執行,後者會展開程序自己的異
常處理。

一般說來,調試器可以選擇總是讓程序以DBG_EXCEPTION_NOT_HANDLED(這也是事實^^)的
方式來對付異常。但只有一點例外:就是目標程序在被加載到內存並即將運行時,系統
會自動觸發一個INT3。完全是假的中斷,主要是方便那些需要在程序剛要開始運行就中
斷的調試任務。所以對於此斷點,一定要返回DBG_CONTINUE。

最後說明如何開始一個調試,通過CreateProcess即可開始調試。在CreateProcess的第6
個參數裏指定調試標記DEBUG_PROCESS或DEBUG_ONLY_THIS_PROCESS即可。兩者的區別在
於是否需要連目標進程的子進程也要調試。

還有一種方法是調用DebugActiveProcess對一個已經運行的程序進行調試。
因爲Shell一開始就是運行的,所以本篇採取這種方法。

總結一下上面的經過。可以編寫一個極小化(什麼也不做)的調試器。

/* mini.c */

#include
#include
#include

int main()
{
     DEBUG_EVENT d;
     DWORD mark;
     BOOL initBP,r;
     DWORD pid;

     initBP=0;
     printf("set pid=? ");
     scanf("%d",&pid);
     r=DebugActiveProcess(pid); /* 開始調試 */
     assert(r);

     printf("debugging...");

     for (;;)
     {
         if (!WaitForDebugEvent(&d,INFINITE)) /* 等待調試事件 */
             assert(0);
         switch (d.dwDebugEventCode)
         {
         case CREATE_PROCESS_DEBUG_EVENT:
         case EXIT_PROCESS_DEBUG_EVENT:
         case CREATE_THREAD_DEBUG_EVENT:
         case EXIT_THREAD_DEBUG_EVENT:
         case LOAD_DLL_DEBUG_EVENT:
         case UNLOAD_DLL_DEBUG_EVENT:
         case OUTPUT_DEBUG_STRING_EVENT:
         case RIP_EVENT:
             mark=DBG_CONTINUE; /* 這些事件都不需要處理,讓目標直接運行 */
             break;
         case EXCEPTION_DEBUG_EVENT:
             if (initBP)
             {
                 mark=DBG_EXCEPTION_NOT_HANDLED; /* 沒處理就是沒處理*/
             }
             else
             {
                 initBP=1;
                 mark=DBG_CONTINUE; /* 初始斷點要特別留意 */
             }
             break;
         }
         if (!ContinueDebugEvent(
             d.dwProcessId,
             d.dwThreadId,mark)) /* 繼續執行目標程序 */
             assert(0);
         if (d.dwDebugEventCode==EXIT_PROCESS_DEBUG_EVENT)
             break;
     }
     printf("stopped/n");

     return 0;
}


這裏有一點值得強調,細細查看DEBUG_EVENT結構就能夠知道:很多調試事件到來時,都
會攜帶一些系統主動打開的句柄。這些句柄對應的內核對象是屬於調試器進程的,所以
一定要在適當的時候主動關閉,不然會造成資源泄漏。以上程序就存在泄漏。



三、相關數據的保存

敬業的調試器至少要保存進程、線程和模塊的信息。調試器以後需要很頻繁的使用它們
,不可能在使用時再去查詢。



四、斷點的實現

第1篇裏一開頭已經說明了斷點的原理。斷點的觸發是以異常事件報告給調試器的。
通過在開關語句中討論EXCEPTION_DEBUG_EVENT來完成邏輯上的處理。

以下是對異常事件的處理流程(就是對第1篇原理部分的最自然的實現):

p=find(ps,MAX_PROC,d.dwProcessId);   /* 找到發生異常的進程 */
if (!p->init_bp)   /* 對初始斷點特別關注 */
{
   mark=DBG_CONTINUE;
   p->init_bp=TRUE;
   my_init(p->id);
}
else
{
   mark=DBG_EXCEPTION_NOT_HANDLED;

   r=&d.u.Exception.ExceptionRecord;
   if (d.u.Exception.dwFirstChance) /* 異常是第一次觸發嗎?(詳見第2篇) */
   {
     if (r->ExceptionCode==EXCEPTION_BREAKPOINT) /* 觸發了斷點中斷 */
     {
       /* 根據中斷地點查找斷點記錄 */
       bp=find(ps->bps,MAX_BP,(DWORD)r->ExceptionAddress);

       if (bp) /* 是調試器安置的斷點 */
       {
         t=find(ps->ts,MAX_THRD,d.dwThreadId); /* 取線程上下文 */
         memset(&ctx,0,sizeof(ctx));
         ctx.ContextFlags=CONTEXT_FULL;
         f=GetThreadContext(t->h,&ctx);
         assert(f);
         ctx.Eip--;   /* 將指令指針值減1 */
         ctx.EFlags|=TF_BIT; /* 打開CPU單步標記 */
         f=SetThreadContext(t->h,&ctx); /* 向目標寫入修改過的上下文 */
         assert(f);
       
         ps->write_back=bp; /* 標記該進程在單步結束後準備寫回的斷點 */
         safe_write(ps->h,bp->addr,bp->c); /* 寫入被斷點覆蓋的代碼 */

         on_bp(d.dwProcessId,d.dwThreadId,bp->addr); /* 用戶處理 */
       
         suspend_except(ps->ts,d.dwThreadId); /* 掛起除異常線程外的所有線程 */
         mark=DBG_CONTINUE;
       }
     }
     else if (r->ExceptionCode==EXCEPTION_SINGLE_STEP) /* 發生了單步中斷 */
     {
       bp=ps->write_back; /* 獲得準備寫回的斷點記錄 */
       if (bp) /* 如果bp==0則表示此單步中斷不是調試器引起的 */
       {
         ps->write_back=NULL;
         safe_write(ps->h,bp->addr,INT3); /* 寫回斷點 */
         resume_except(ps->ts,d.dwThreadId); /* 恢復其它線程的運行 */
         mark=DBG_CONTINUE;
       }
     }
   }
   else
   {
     assert(0); /* 如果運行到這裏,表示目標程序出錯了,咱不考慮…… */
   }
}



五、用戶操作

(四)裏面已經做好了斷點處理,並留下on_bp一個缺口作用戶處理。
下面就很簡單了。

當Shell調用CreateProcessW時,調試器接到斷點中斷,並調用on_bp。
此時只需要將目標的中斷線程的棧情況獲得就可以了。
參考CreateProcessW的原型,並注意到參數是從右向左壓棧,所以棧頂元素是返回地址
,而在向下的一個元素(32位整數)就是第一個參數(從左到右),然後在向下是第二個參
數……

於是要獲得第二個參數,只需先取出EBP,然後間址EBP+8即得待執行的文件的路徑名了(
注意:這個字符串在目標進程的地址空間裏,所以要用ReadProcessMemory讀取)。



六、結束調試

很遺憾,Windows並沒有哪個API能夠讓調試器從目標程序上脫離。也就是一旦調試,目
標就會一直被調試,直到調試器結束或目標自行結束(調試器的結束會立刻導致目標結束
,而反之不然)。

不過情況在XP下有所改變,XP提供接口讓調試器脫離目標。MSVC7也能通過運行服務的方
法實現從調試目標上脫離。

所以,關閉調試後,Shell也會被中止。不過沒關係,Windows後自動重新加載Shell。如
果Windows沒有重新加載,請在任務管理器裏的文件菜單裏通過“新任務”重啓Shell。

終於把這些東西寫完了!

雖然起初有一定的計劃,但實際上卻是想到什麼寫什麼。但願不至於太亂----呵呵,自己
倒是沒什麼感覺。定位變了,本來只是想給低年級的學弟學妹留點參考,但後來發現如果
真是這樣,可能3篇就夠了。後來突然想到以前有個同學告訴我有關Delphi調試器的斷點
處理功能,真是比MSVC厲害得多。當時我就希望能夠讓VC也能支持。結果到現在VC都達不
到Delphi的境界。MS的軟件就是容易使用,但高端功能不足。

想到這裏,才準備寫6篇和7篇,本意是拋磚引玉:當調試器不能完成某些特定工作時,能
夠自己利用調試技術解決。

文章裏提到的技術,大部分是在書上看到的,有些是自己發現或和同學一起探討出來的。
不過這些技術自己都經常使用,所以也不想再加以說明。

謝謝支持我的各位朋友。
最後送大家和自己一句話:
對程序世界的好奇心,“不在調試中爆發,就在調試中滅亡”。以此共勉。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章