写Log记录分析
如果拥有目标程序的源代码,就可以在关键的API函数的入口点和出口点记录API的参数和运行结果。在除错程序中是经常可以看到这种方法的。该方法的缺点就是必须拥有源代码,每次修改Log时必须重新编译源代码。由于该方案和我们要讨论的目标不同,在此不作讨论。
将监视代码注入目标程序
该方案的原理是在目标程序运行时,将监视代码注入到目标程序的进程空间。监视代码通过前期的准备工作,修改目标程序的运行代码,使得目标程序调用指定的API函数时,运行指令会跳转到监视代码中,这样,监视代码就可以记录下API函数的运行参数,然后,监视代码在运行用来的API函数代码。在用来的API函数代码运行完后,再回到监视代码中,将来下运行结果,再返回目标程序。其运行原理图为:
在32位Windows平台上,各进程空间是独立的。要在目标程序中运行监视代码就必须将监视代码注入到目标进程。一种常用的方法就是将监视代码编译成一个DLL,再将该DLL注入到目标进程中。关于将DLL注入目标进程的文章有很多,比如在《Windows核心编程》上就详细介绍过CreateRemoteThread和SetWindowsHookEx的方法,关于DLL的注入不是本文的重点,在这里就不进行介绍,请读者参考其它相关的文章。
利用在目标程序中注入监视代码实现监视的方案,常见以下几种方式:
1.在目标函数写入跳转指令(jmp),跳转至监视代码实现监视
2.和第1种方式相同,在目标函数入口写入跳转指令,但在监视代码中实现监视的机制不同。
3.利用API Hook功能,修改EXE和DLL的导入表(Import Address Table),将监视代码中函数入口地址写入导入表中,当EXE或DLL调用其它DLL中API函数时,就可以跳转到监视代码中实现跟踪监视。
这里介绍的1~3种方案在很多资料上都介绍过,这里不再重复,他们都有一个最大的缺点:监视某个函数时必须知道函数的原型(即参数个数和调用方式——WINAPI调用还是其它?)。在写监视代码时必须确保监视代码函数的原型和被监视函数的原型一致。希望增加一个监视函数时必须增加一个监视代码。详细可以参考Detours和API Hook。
用代理DLL实现API函数的监视
用代理DLL实现API函数的监视就是将原来的DLL改名,用一个新的DLL代替原来的DLL。这个新的DLL的导出函数和用来DLL的导出函数相同,并且导出函数的顺序和原来的DLL一样。新的DLL名字和原来DLL的名字也一样。在新DLL中的每个函数实现代码中,就负责记录运行参数和运行结果,同时调用原来DLL的函数。其运行方式如图所示:
比如:A.exe调用B.DLL,希望监视B.DLL的导出函数时,就将B.DLL改名为C.DLL,生成一个新的监视模块B.DLL,其导出函数名和顺序和原来B.DLL完全相同,这样A.EXE调用B.DLL时就进入了监视模块,实现跟踪监视的目标。
这里新的B.DLL就是原来B.DLL的代理了,所有调用B.DLL的函数都会经过新的B.DLL得到监视。如果B.exe也调用了B.DLL,这样,不止A.exe的调用被监视了,B.exe的调用也被监视了。监视记录就多了一些无用的数据,对分析就增加了难度。对于这种方式,本文在此也不着更多的介绍。
利用调试函数实现跟踪监视
跟踪监视程序作为调试器对目标进程进行调试,在目标进程的API函数的入口设置断点。这样,当目标进程调用被监视的API函数时,目标程序将产生调试中断,系统将中断调试信息通知跟踪监视程序,同时被调试的目标进程挂起。这样,跟踪监视程序就可以访问目标进程的内存,得到API函数的参数。然后通知系统让挂起的目标进程继续运行。同样,在API函数的出口处再设置断点,就可以得到API函数的处理结果。
通过调试函数实现跟踪监视的方案实际上最基本的用法就是调试器。在调试器中可以轻松得到API函数的输入输出参数的,也可以得到变量的值。但利用调试器来作为跟踪监视程序的话,就非常的不方便了。产生断点调试信息时,首先必须自己记录下API函数的输入输出参数,其次让目标进程继续运行必须人工进行干预。由于过多的需要人工干预,在作为跟踪分析的工具上就无法有太大的作用。作为一个比较实用的跟踪监视工具,应该可以自动记录下输入输出参数,同时可以让目标进程自动进行运行而无需用户的干预。
那么,如果我们能自己编写一个类似调试器的功能,这个调试器只需要实现我们对于跟踪监视工具的要求,即自动记录输入输出参数,自动让目标进程继续运行。就可以达到跟踪监视工具的目的了。在下一篇文章中我们将对如何用调试函数达到这一要求进行详细说明。
参考资料:
1.《Windows核心编程》 , Jeffrey Richter,机械工业出版社
2.微软的MSDN
3.detours 可以在http://research.microsoft.com/sn/detours/ 上得到源代码。detours功能在WinNT和W2K下有效,对9X不支持。
用调试函数跟踪API
我们知道,当一个目标程序运行时,或多或少的都会调用一下API函数。当我们在调试该目标程序进行除错时,非常希望知道某些API的输入输出参数以及运行结果,对API的调用路径及参数的跟踪监视,在分析研究目标程序的内部调用机制时是非常有帮助的。在这里所指的API,不仅包括狭义上的Windows系统函数,还包括广义上第三方(及自身)提供DLL的输出函数。如果从跟踪监视的需求来讲,跟踪监视的API就不仅仅包括广义的API,更希望包括EXE和DLL中的内部未导出的函数。
对某一目标程序进行API函数的跟踪监视分析时,一般来讲是没有源代码和调试版本,更多的情况是只有EXE和DLL的发行版。跟踪的目标就是通过运行目标程序,得到调用API函数的参数和运行结果,而不希望改变目标程序的运行路径。
如果我们能自己编写一个类似调试器的功能,这个调试器需要实现我们对于跟踪监视工具的要求,即自动记录输入输出参数,自动让目标进程继续运行。下面我们就来介绍在不知道函数原型的情况下也可以简单输出监视结果的方案——用Debug函数实现API函数的监视。
大家知道,VC可以用来调试程序,除了调试Debug程序,当然也可以调试Release程序(调试Release程序时为汇编代码)。如果知道函数的入口地址,只需在函数入口上设置断点,当程序调用了设置断点的函数时,VC就会暂停目标程序的运行,你就可以得到目标程序内存的所有你希望得到的东西了。一般来说,只要你有足够的耐心和毅力,以及一些汇编知识,对于监视API函数的输入输出参数还是可以完成的。
不过,由于VC的调试器会在每次断点时暂停目标程序的运行,对目标程序的过多的暂停对于监视任务而言实在不能忍受。所以,不会有太多的人真的会用VC的调试器作为一个良好的API函数监视器的。
如果VC调试器能够在你设置好断点后,在运行时自动输出断点时的堆栈值(也就是函数的输入参数),在函数运行结束时也自动输出堆栈值(也就是函数的输出参数)和CPU寄存器的值(就是函数返回值),并且不会暂停目标程序。所有一切都是自动的无需我们干预。你会用它来作为监视器吗?我会的。
我不知道如何让VC这样作(或许VC真的可以这样,但我不知道。有人知道的话请通知我一声,谢谢),但我知道显然VC也是通过调用Windows API函数完成调试器的任务,而且,这些函数显然可以实现我的要求。我需要作的事情就是自己利用这些API函数,写一个简单的调试器,在目标程序断点发生时自动输出监视结果并且自动恢复目标程序的运行。
显然,用VC调试器作为监视器的话无需知道目标函数的原型就可以得到简单的输入输出参数和函数运行结果,而且,由于监视代码没有注入目标程序中,就不会出现监视目标函数和监视代码的冲突。VC调试器显然可以跟踪递归函数,也可以跟踪DLL模块调用DLL本身的函数,以及EXE内部调用自身的函数。只要你知道目标函数的入口地址,就可以跟踪了(监视Exe自身的函数可以通过生成Exe模块时选择输出Map文件,就可以参考Map文件得到Exe内部函数的地址)。没有听说VC不能调试多线程的,最多是说调试多线程比较麻烦----证明多线程是可以调试的。显然,VC也可以调试DllMain中的代码。这些,已经可以证明通过调试函数可以实现我们的目标了。
首先,让目标程序进入被调试状态:
对于一个已经启动的进程而言,利用DebugActiveProcess函数就可以捕获目标进程,将目标进程进入被调试状态。
BOOL DebugActiveProcess(DWORD dwProcessId); |
参数dwProcessId是目标进程的进程ID。如何通过ToolHelp系列函数或Psapi库函数获得一个运行程序的进程ID在很多文章中介绍过,这里就不再重复。对于服务器程序而言,由于没有权限无法捕获目标进程,可以通过提升监视程序的权限得到调试权限进行捕获目标进程(用户必须拥有调试权限)。
对于启动一个新的程序而言,通过CreateProcess函数,设置必要的参数就可以将目标程序进入被调试状态。
BOOL CreateProcess(LPCTSTR lpApplicationName, LPTSTR lpCommandLine, LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, BOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, LPCTSTR lpCurrentDirectory, LPSTARTUPINFO lpStartupInfo, LPPROCESS_INFORMATION lpProcessInformation ); |
该函数的具体说明请参考MSDN,在这里我仅介绍我们感兴趣的参数。这里和一般的用法不同,作为被调试程序dwCreationFlags必须设置为DEBUG_PROCESS或DEBUG_ONLY_THIS_PROCESS。这样启动的目标程序就会进入被调试状态。这里说明一下DEBUG_PROCESS和DEBUG_ONLY_THIS_PROCESS。DEBUG_ONLY_THIS_PROCESS就是只调试目标进程,而DEBUG_PROCESS参数则不仅调试目标进程,而且调试由目标进程启动的所有子进程。比如:在A.exe中启动B.exe,如果用DEBUG_ONLY_THIS_PROCESS启动,监视进程只调试A.exe不会调试B.exe,如果是DEBUG_PROCESS就会调试A.exe和B.exe。为简单起见,本文只讨论启动参数为DEBUG_ONLY_THIS_PROCESS的情况。
使用方法:
STARTUPINFO st = {0}; PROCESS_INFORMATION pro = {0}; st.cb = sizeof(st); CreateProcess(NULL, pszCmd, NULL, NULL, FALSE, DEBUG_ONLY_THIS_PROCESS, NULL, szPath, &st, &pro)); // 关闭句柄---这些句柄在调试程序中不再使用,所以可以关闭 CloseHandle(pro.hThread); CloseHandle(pro.hProcess); |
其次,对进入被调试状态的程序进行监视:
目标进程进入了被调试状态,调试程序(这里调试程序就是我们的监视程序,以后不再说明)就负责对被调试的程序进行调试操作的调度。调试程序通过WaitForDebugEvent函数获得来自被调试程序的调试消息,调试程序根据得到的调试消息进行处理,被调试进程将暂停操作,直到调试程序通过ContinueDebugEvent函数通知被调试程序继续运行。
BOOL WaitForDebugEvent( LPDEBUG_EVENT lpDebugEvent, // debug event information DWORD dwMilliseconds // time-out value ); |
在参数lpDebugEvent中可以获得调试消息,需要注意的是该函数必须和让目标程序进入调试状态的线程是同一线程。也就是说和通过DebugActiveProcess或CreateProcess调用的线程是一个线程。另外,我又喜欢将dwMilliseconds设置为-1(无限等待)。所以我通常都会将CreateProcess和WaitForDebugEvent函数在一个新的线程中使用。
typedef struct _DEBUG_EVENT { DWORD dwDebugEventCode; DWORD dwProcessId; DWORD dwThreadId; union { EXCEPTION_DEBUG_INFO Exception; CREATE_THREAD_DEBUG_INFO CreateThread; CREATE_PROCESS_DEBUG_INFO CreateProcessInfo; EXIT_THREAD_DEBUG_INFO ExitThread; EXIT_PROCESS_DEBUG_INFO ExitProcess; LOAD_DLL_DEBUG_INFO LoadDll; UNLOAD_DLL_DEBUG_INFO UnloadDll; OUTPUT_DEBUG_STRING_INFO DebugString; RIP_INFO RipInfo; } u; } DEBUG_EVENT, *LPDEBUG_EVENT; |
在这个调试消息结构体中,dwDebugEventCode记录了产生调试中断的消息代码。消息代码的详细说明可以参考MSDN。其中,我们感兴趣的消息代码为:
EXCEPTION_DEBUG_EVENT:产生调试例外 CRATE_THREAD_DEBUG_EVENT:新的线程产生 CREATE_PROCESS_DEBUG_EVENT:新的进程产生。注:在DEBUG_ONLY_THIS_PROCESS时只有一次, 在DEBUG_PROCESS时如果该程序启动了子进程就可能有多次。 EXIT_THREAD_DEBUG_EVENT:一个线程运行中止 EXIT_PROCESS_DEBUG_EVENT:一个进程中止。注:在DEBUG_ONLY_THIS_PROCESS时只有一次, 在DEBUG_PROCESS可能有多次。 LOAD_DLL_DEBUG_EVENT:一个DLL模块被载入。 UNLOAD_DLL_DEBUG_EVENT:一个DLL模块被卸载。 |
在得到目标程序的调试消息后,调试程序根据这些消息代码进行不同的处理,最后通知被调试程序继续运行。
BOOL ContinueDebugEvent( DWORD dwProcessId, // process to continue DWORD dwThreadId, // thread to continue DWORD dwContinueStatus // continuation status ); |
该函数通知被调试程序继续运行。
使用例:
DEBUG_EVENT dbe; BOOL rc; CreateProcess(NULL, pszCmd, NULL, NULL, FALSE, DEBUG_ONLY_THIS_PROCESS, NULL, szPath, &st, &pro)); while(WaitForDebugEvent(&dbe, INFINITE)) { // 如果是退出消息,调试监视结束 if(dbe. dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT) break; // 进入调试监视处理 rc = OnDebugEvent(&dbe); if(rc) ContinueDebugEvent(dbe.dwProcessId , dbe.dwThreadId , DBG_CONTINUE ); else ContinueDebugEvent(dbe.dwProcessId , dbe.dwThreadId , DBG_ DBG_EXCEPTION_NOT_HANDLED); } // 调试消息处理程序 BOOL WINAPI OnDebugEvent(DEBUG_EVENT* pEvent) { // 我们还没有对目标进程进行操作,所以,先返回TRUE。 return TRUE; } |
上面这些程序就是一个最简单的调试程序了。不过,它基本上没有什么用途。你还没有在目标进程中设置断点,你就不能完成对API函数监视的任务。
对目标进程设置断点:
我们的目标是监视API函数的输入输出,那么,首先应该知道DLL模块中提供了哪些API函以及这些API的入口地址。在前面将过,广义的API还包括未导出的内部函数。如果你有DLL模块的调试版本和调试连接文件(pdb文件),也可以根据调试信息得到内部函数的信息。
· 得到函数名及函数入口地址
通过程序得到函数的入口地址有很多种方法。对于用VC编译出来的DLL,如果是Debug版本,可以通过ImageHlp库函数得到调试信息,分析出函数的入口地址。如果没有Debug版本,也可以通过分析导出函数表得到函数的入口地址。
1.用Imagehlp库函数得到Debug版本的函数名和函数入口地址。
可以利用Imagehlp库函数分析Debug信息,关联的函数为SymInitialize、SymEnumerateSymbols和UnDecorateSymbolName。详细可以参考MSDN中关于这些函数的说明和用法。不过,用Imagehlp只能分析出用VC编译的程序,对C++Builder编译的程序不能用这种方法分析。
2.DLL的导出表得到函数导出函数名和函数的入口地址。
在大多数情况下,我们还是希望监视的是Release版本的输入输出参数,毕竟Debug版本不是我们最终提供给用户的产品。Debug和Release的编译条件不同导致产生的结果不同,在很多BBS中都讨论过。所以,我认为跟踪监视Release版本更加有实用价值。
通过分析DLL导出表得到导出函数名在MSDN上就有源代码。关于导出表的说明大家可以参考关于PE结构的文章。
3.通过OLE函数取得COM接口
你也可以通过OLE函数分析DLL提供的接口函数。接口函数不是通过DLL导出表导出的。你可以通过LoadTypeLib函数来分析COM接口,得到COM记录接口的入口地址,这样,你就可以监视COM接口的调用了。这是API HOOK没法实现的。在这里我不打算分析分析COM接口的方式了。在MSDN上通过搜索LoadTypeLib sample关键词你就可以找到相关的源代码进行修改实现你的目标。
这里是通过计算机自动分析目标模块得到DLL导出函数的方案,作为我们监视的目的而言,这些工作只是为了得到一系列的函数名和函数地址而已。函数名只是一个让我们容易识别函数的名称而已,该函数入口地址才是我们真正关心的目标。换句话说,如果你能够确保某一个地址一定是一个函数(包括内部函数)的入口地址,你就完全可以给这个函数定义自己的名称,将它加入你的函数管理表中,同样可以实现监视该函数的输入输出参数的功能。这也是实现Exe内部函数的监视功能的原因。如果你有Exe编译时生成的Map文件(你可以在编译时选择生成Map文件),你就可以通过分析Map文件,得到内部函数的入口地址,将内部函数加入到你的函数管理表中。(一个函数的名称对于监视功能来讲究竟是FunA还是FunB并没有什么意义,但名称是FunA还是FunB的名称对于监视者分析监视结果是有意义的,你完全可以将MessageBox的函数在输出监视结果是以FunA的名称输出,所以在监视一些内部无名称的函数时,你完全可以定义你自己的名字)。
· 在函数入口地址处设置断点
设置断点非常简单,只要将0xCC(int 3)写入指定的地址就可以了。这样程序运行到指定地址时,将产生调试中断信息通知调试程序。修改指定进程的内存数据可以通过WriteProcessMemory函数来完成。由于一般情况下作为程序代码段都被保护起来了,所以还有一个函数也会用到。VirtualProtectEx。在实际情况下,当调试断点发生时,调试程序还应该将原来的代码写回被调试程序。
unsigned char SetBreakPoint(DWORD pAdd, unsigned char code) { unsigned char b; BOOL rc; DWORD dwRead, dwOldFlg; // 0x80000000以上的地址为系统共有区域,不可以修改 if( pAdd >= 0x80000000 || pAdd == 0) return code; // 取得原来的代码 rc = ReadProcessMemory(_ghDebug, pAdd, &b, sizeof(BYTE), &dwRead); // 原来的代码和准备修改的代码相同,没有必要再修改 if(rc == 0 || b == code) return code; // 修改页码保护属性 VirtualProtectEx(_ghDebug, pAdd, sizeof(unsigned char), PAGE_READWRITE, &dwOldFlg); // 修改目标代码 WriteProcessMemory(_ghDebug, pAdd, &code, sizeof(unsigned char), &dwRead); // 恢复页码保护属性 VirtualProtectEx(_ghDebug, pAdd, sizeof(unsigned char), dwOldFlg, &dwOldFlg); return b; } |
被调试程序产生中断时,将产生一个EXCEPTION_DEBUG_EVENT信息通知调试程序进行处理。同时将填充EXCEPTION_DEBUG_INFO结构。
typedef struct _EXCEPTION_DEBUG_INFO { EXCEPTION_RECORD ExceptionRecord; DWORD dwFirstChance; } EXCEPTION_DEBUG_INFO, *LPEXCEPTION_DEBUG_INFO; typedef struct _EXCEPTION_RECORD { DWORD ExceptionCode; DWORD ExceptionFlags; struct _EXCEPTION_RECORD *ExceptionRecord; PVOID ExceptionAddress; DWORD NumberParameters; ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; } EXCEPTION_RECORD, *PEXCEPTION_RECORD; |
在该结构中,我们比较感兴趣的是产生中断的地址ExceptionAddress和产生中断的信息代码ExceptionCode。在信息代码中与我们任务相关的信息代码为:
EXCEPTION_BREAKPOINT:断点中断信息代码 EXCEPTION_SINGLE_STEP:单步中断信息代码 |
断点中断是由于我们在前面设置断点0xCC代码运行时产生的。由于产生中断后,我们必须将原来的代码写回被调试程序中继续运行。但是,代码一旦被写回目标程序,这样,当目标程序再次调用该函数时将不会产生中断,我们就只能实现一次监视了。所以,我们必须在将原代码写回被调试程序后,应该让被调试程序已单步的方式运行,再次产生一个单步中断的调试信息。在单步中断处理中,我们再次将0xCC代码写入函数的入口地址,这样就可以保证再次调用时产生中断。
首先,在进行中断处理前我们必须作些准备工作,管理起线程ID和线程句柄。为了管理单步中断处理,我们还必须维护一个基于线程的单步地址的管理,这样就可以允许被调试程序拥有多线程的功能。--我们不能保证单步运行时不被该进程的其他线程所打断。
// 我们利用一个map进行管理线程ID和线程句柄之间的关系 // 同时也用一个map管理函数地址和断点的关系 typedef map > THREAD_MAP; typedef map > THREAD_SINGLESTEP_MAP; THREAD_MAP _gthreads; FUN_BREAK_MAP _gFunBreaks; // 并且假设设置断点时采用了如下方案进行原来代码的管理 BYTE code = SetBreakPoint(pFunAdd, 0xCC); if(code != 0xCC) _gFunBreaks[pFunAdd] = code; … // 调试处理程序 BOOL WINAPI OnDebugEvent(DEBUG_EVENT* pEvent) { BOOL rc = TRUE; switch(pEvent->dwDebugEventCode) { case CREATE_PROCESS_DEBUG_EVENT: // 记录线程ID和线程句柄的关系 _gthreads[pEvent->dwThreadId] = pEvent->u.CreateProcessInfo.hThread; … break; case CREATE_THREAD_DEBUG_EVENT: // 记录线程ID和线程句柄的关系 _gthreads [pEvent->dwThreadId] = pEvent->u.CreateThread.hThread; … break; case EXIT_THREAD_DEBUG_EVENT: // 线程退出时清除线程ID _gthreads.erase (pEvent->dwThreadId); … break; case EXCEPTION_DEBUG_EVENT: // 中断处理程序 rc = OnDebugException(pEvent); break; … } return rc; } |
下面进行中断处理程序。同样,我们只考虑我们关心的中断信息代码。在发生中断时,我们通过GetThreadContext(&context)得到中断线程的上下文信息。此时,context.esp就是函数的返回地址,context.esp+4位置的值就是函数的第一个参数,context.esp+8就是第二个参数,依次类推可以得到你想要的任何参数。需要注意的是因为参数是在被调试进程中的内容,所以你必须通过ReadProcessMemory函数才能得到:
DWORD buf[4]; // 取4个参数 ReadProcessMemory(_ghDebug, (void*)(context.esp + 4), &buf, sizeof(buf), &dwRead); |
那么buf[0]就是第一个参数,buf[1]就是第二个参数。。。注意,在FunA(int a, char* p, OPENFILENAME* pof)函数调用时,buf[0] = a, buf[1] = p这里buf[1]是p的指针而不是p的内容,如果你希望访问p的内容,必须同样通过ReadProcessMemory函数再次取得p的内容。对于结构体指针也必须如此:
// 取得p的内容: char pBuf[256]; ReadProcessMemory(_ghDebug, (void*)(buf[1]), &pBuf, sizeof(pBuf), &dwRead); //取得pof的内容: OPENFILENAME of ReadProcessMemory(_ghDebug, (void*)(buf[2]), &of, sizeof(of), &dwRead); |
如果结构体中还有指针,要取得该指针的内容,也必须和取得p的内容一样的方式读取被调试程序的内存。总的来说,你必须意识到监视目标程序的所有内容都是对目标进程的内存读取操作,这些指针都是目标进程的内存地址,而不是调试进程的地址。
很明显,当被调试进程在函数入口产生中断调试信息时,调试程序只能得到函数的输入参数,而不能得到我们希望的输出参数及返回值!为了实现我们的目标,我们必须在函数调用结束时,再次产生中断,取得函数的输出参数和返回值。在处理函数入口中断时,就必须设置好函数的返回地址的断点。这样,在函数返回时,就可以得到函数的输出参数和返回值了。关于这里的实现说明请参考附录的源代码。
你完全可以参照附录的源代码写出你自己的简单的调试监视程序。当然,有几个问题因为比较复杂,我没有在这里进行说明。一个就是函数返回断点的处理,比如TRY、CATCH的处理,就必须重新设计好RETURN_FUN_STACK的结构,考虑一些除错处理还是可以解决这个问题的。另外一个问题就是函数的入口断点和返回断点没有任何关系。这个问题更好解决,只需重新设计RETURN_FUN,FUN_BREAK_MAP等结构体就可以将它们关联起来。由于我在这里只要是分析如何实现中断调试处理的过程,这些完善程序的工作就由读者自行跟踪改造了。
细心的读者在上面可以发现一个问题,那就是在SetBreakPoint函数中有一个限制,就是函数的入口地址不能大于0x80000000。确实如此,我们知道0x80000000以上的空间是系统共有的空间,我们一般不能修改这些空间的程序,否则将影响系统的工作。在NT环境下,所有的DLL都被加载在0x80000000下,修改0x80000000以下空间的代码不会对其它进程产生影响。所以在NT下可以用上面的方案监视所有的DLL函数。然而,在Win9X下,kernel32.dll,user32.dll,gdi32.dll等系统DLL都被加载到0x80000000以上的空间,修改这些空间的代码将破坏系统工作。那么,在9X下就不能监视这些DLL模块的函数吗?
的确,在Win9X平台下不能利用在函数入口处设置断点的方法实现监视。我们必须采用另外的方法实现该功能。在前面讨论中知道,通过API HOOK修改模块导入表的方法可以实现将API的入口修改为自己监视程序的入口,也可以实现监视功能。如果采用API HOOK的方法有限制,即必须知道函数原型,对每一个函数都必须编写相应的监视代码,灵活性受到限制。而我们的目标是不管有多少个DLL,不管DLL有多少个导出函数,在不修改我们的程序前提下都可以实现我们的监视功能。所以,API HOOK是不可以完成我们的目标,但我们可以利用修改导入表的方案实现目标。首先,修改导入表,将函数的调用地址指向我们的监视代码,在监视代码中,我们无需对函数编程,只是简单调用jmp XXXX就可以了。然后,设置断点时,不是设置在函数的入口点,而是设置在我们的监视代码上。这样,当我们的模块调用系统API函数时,就可以实现监视功能了。修改原理如图:
如图所示,假设我们的监视代码在目标进程的的0x20000000空间,我们在分析DLL导出表的同时,将导出表函数的地址经过计算,在监视代码中设置为jmp xxxx的代码。这样我们在修改EXE模块的导入表时写入的地址为监视代码的地址。当目标程序调用MessageBox函数是,程序将首先跳转到监视代码中执行jmp指令到user32.dll的MessageBox入口地址中。经过这样处理后,我们希望监视MessageBox函数的调用时,只需在监视代码的0x20000000处设置断点,就达到了监视的目的。限于篇幅原因,这里不再讨论。
你可以很轻松的在此基础上进行扩展你的监视跟踪功能。只需要修改一下记录输入输出函数结果的程序,就得到一个新的功能:
1.在记录输入输出参数的地方加入取得当前时刻的功能,就实现了监视函数调用性能的功能。(相当于Numega的TrueTime功能)由于采用了Debug技术,得到的时间将包括调试函数导致产生进程的切换时间。等到的时间只是一个参考价值,但对分析性能而言一般足够。
2.在记录输入输出参数的地方加入函数调用的计数器,就实现了Numega的TrueCoverage功能。
3.监视malloc, free, realloc函数的输入输出值,并进行统计,就实现了简单的内存泄漏检查功能。关键的是你可以通过Map文件得到Release版本的malloc等函数的地址,实现对Release版的跟踪。
4.在记录输入参数处理中加入StackWalk函数可以实现call stack功能,分析是由哪个函数调用了自己。在jmp方案中也可以实现这个功能,但是你必须确保StackWalk关联的函数没有调用被你监视的函数。在Hook API(IAT)的方案中到是不用保证,但得出的调用列表中有可能包含你的监视代码。
有一点需要注意的是,我们的目标是监视程序的运行路径,并不是改变参数和修改结果,所以,在jmp和Hook Api(IAT)中可以实现的修改参数和运行路径的做法在这里不能实现。
其他:
本文附录的代码TestDebug.zip就是实现了一个简单的调试监视器,自动输出监视函数的4个输入参数的地址内容和函数调用返回值。该代码只是表明通过监视函数可以实现对API的跟踪,所以没有实现9X下对系统DLL的监视。
DebugApi.zip是一个利用这个方案编写的应用程序DebugApiSpy.exe,它实现了这个方案中的最基本的跟踪监视函数的输入输出参数功能,也实现了9X下对系统DLL的监视支持。该程序支持Win9X/NT/W2K/XP上的运用。
源代码下载:TestDebug.zip,DebugApi.zip
参考资料:
1.《Windows核心编程》, Jeffrey Richter,机械工业出版社
2.微软的MSDN
3.detours 可以在http://research.microsoft.com/sn/detours/ 上得到源代码。detours功能在WinNT和W2K下有效,对9X不支持。