大话调试器(下)

本篇开始介绍一些调试技术。需要读者很清楚地了解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篇,本意是抛砖引玉:当调试器不能完成某些特定工作时,能
够自己利用调试技术解决。

文章里提到的技术,大部分是在书上看到的,有些是自己发现或和同学一起探讨出来的。
不过这些技术自己都经常使用,所以也不想再加以说明。

谢谢支持我的各位朋友。
最后送大家和自己一句话:
对程序世界的好奇心,“不在调试中爆发,就在调试中灭亡”。以此共勉。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值