用Debug函数实现API函数的跟踪

 

如果我们能自己编写一个类似调试器的功能,这个调试器需要实现我们对于跟踪监视工具的要求,即自动记录输入输出参数,自动让目标进程继续运行。下面我们就来介绍在不知道函数原型的情况下也可以简单输出监视结果的方案——用Debug函数实现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;
}

在设置断点时你必须将原来的代码保存起来,这样在恢复断点时就可以将代码还原了。一般用法为:设置断点m_code = SetBreakPoint( pFunAdd, 0xCC); 恢复断点:SetBreakPoint( pFunAdd, m_code); 记住,每个函数入口地址的代码都可能不同,你应该为每个断点地址保存一个原来的代码,在恢复时就不会发生错误了。

好了,现在目标程序中已经设置好了断点,当目标程序调用设置了断点的函数时,将产生一个调试中断信息通知调试程序。我们就要在调试程序中编写我们的调试中断程序了。

 

编写调试中断处理程序

被调试程序产生中断时,将产生一个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<DWORD, HANDLE, less<DWORD> > THREAD_MAP;
typedef map<DWORD, void*, less<DWORD> > 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的内容一样的方式读取被调试程序的内存。总的来说,你必须意识到监视目标程序的所有内容都是对目标进程的内存读取操作,这些指针都是目标进程的内存地址,而不是调试进程的地址。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值