写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;
}
在设置断点时你必须将原来的代码保存起来,这样在恢复断点时就可以将代码还原了。一般用法为:设置断点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
本文地址:http://com.8s8s.com/it/it410.htm