科技行者

行者学院 转型私董会 科技行者专题报道 网红大战科技行者

知识库

知识库 安全导航

至顶网软件频道用Debug函数实现API函数的跟踪

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

  • 扫一扫
    分享文章到微信

  • 扫一扫
    关注官方公众号
    至顶头条

下面我们就来介绍在不知道函数原型的情况下也可以简单输出监视结果的方案——用Debug函数实现API函数的监视。

作者:韩小明 来源:CSDN 2007年10月10日

关键字:

  • 评论
  • 分享微博
  • 分享邮件

在本页阅读全文(共2页)

对目标进程设置断点:

我们的目标是监视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的内容一样的方式读取被调试程序的内存。总的来说,你必须意识到监视目标程序的所有内容都是对目标进程的内存读取操作,这些指针都是目标进程的内存地址,而不是调试进程的地址。

很明显,当被调试进程在函数入口产生中断调试信息时,调试程序只能得到函数的输入参数,而不 能得到我们希望的输出参数及返回值!为了实现我们的目标,我们必须在函数调用结束时,再次产生中断,取得函数的输出参数和返回值。在处理函数入口中断时, 就必须设置好函数的返回地址的断点。这样,在函数返回时,就可以得到函数的输出参数和返回值了。关于这里的实现说明请参考附录的源代码。

你完全可以参照附录的源代码写出你自己的简单的调试监视程序。当然,有几个问题因 为比较复杂,我没有在这里进行说明。一个就是函数返回断点的处理,比如TRY、CATCH的处理,就必须重新设计好RETURN_FUN_STACK的结 构,考虑一些除错处理还是可以解决这个问题的。另外一个问题就是函数的入口断点和返回断点没有任何关系。这个问题更好解决,只需重新设计 RETURN_FUN,FUN_BREAK_MAP等结构体就可以将它们关联起来。由于我在这里只要是分析如何实现中断调试处理的过程,这些完善程序的工 作就由读者自行跟踪改造了。

关于Win9X系统

细心的读者在上面可以发现一个问题,那就是在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.zipDebugApi.zip

参考资料:

1.《Windows核心编程》, Jeffrey Richter,机械工业出版社

2.微软的MSDN

3.detours 可以在http://research.microsoft.com/sn/detours/ 上得到源代码。detours功能在WinNT和W2K下有效,对9X不支持。



Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=1675793

    • 评论
    • 分享微博
    • 分享邮件
    邮件订阅

    如果您非常迫切的想了解IT领域最新产品与技术信息,那么订阅至顶网技术邮件将是您的最佳途径之一。

    重磅专题
    往期文章
    最新文章