大話調試器(下)
本篇開始介紹一些調試技術。需要讀者很清楚地瞭解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篇,本意是拋磚引玉:當調試器不能完成某些特定工作時,能
夠自己利用調試技術解決。
文章裏提到的技術,大部分是在書上看到的,有些是自己發現或和同學一起探討出來的。
不過這些技術自己都經常使用,所以也不想再加以說明。
謝謝支持我的各位朋友。
最後送大家和自己一句話:
對程序世界的好奇心,“不在調試中爆發,就在調試中滅亡”。以此共勉。
一、本地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篇,本意是拋磚引玉:當調試器不能完成某些特定工作時,能
夠自己利用調試技術解決。
文章裏提到的技術,大部分是在書上看到的,有些是自己發現或和同學一起探討出來的。
不過這些技術自己都經常使用,所以也不想再加以說明。
謝謝支持我的各位朋友。
最後送大家和自己一句話:
對程序世界的好奇心,“不在調試中爆發,就在調試中滅亡”。以此共勉。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.