本篇开始介绍一些调试技术。需要读者很清楚地了解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 <windows.h>
#include <stdio.h>
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对远程线
程的支持。<Windows核心编程>上有介绍。不过这种办法仅限于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系统上运行,否则整个系统崩溃必定。
(参考<Windows核心编程>中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 <windows.h>
#include <stdio.h>
#include <assert.h>
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篇,本意是抛砖引玉:当调试器不能完成某些特定工作时,能
够自己利用调试技术解决。
文章里提到的技术,大部分是在书上看到的,有些是自己发现或和同学一起探讨出来的。
不过这些技术自己都经常使用,所以也不想再加以说明。
谢谢支持我的各位朋友。
最后送大家和自己一句话:
对程序世界的好奇心,“不在调试中爆发,就在调试中灭亡”。以此共勉。