调试器原理
本篇算是做逆向工程相关学习的开篇吧,调试则不仅仅是逆向工程的内容,更是一个开发程序猿需要掌握的内容。
用户态调试器 与 内核态调试器
首先我们需要了解调试器到底是如何轻而易举的观察和控制被调试的过程呢。其实简单来说,就是利用了调试的API,而调试又分为用户态调试和内核态调试。
所谓的用户态调试器就是在用户态下进行调试,利用了操作系统提供的一系列公开的API 来进行调试。
Windows操作系统提供了一组API来支持调试器。这些API可以分为三类:
- 创建调试目标的API;
- 在调试循环中处理调试事件的API。
- 查看和修改调试目标的API。
下面这种图是张银奎老师《软件调试》一书中画的Windows 下用户态调试的模型,各个角色包括了调试器进程,被调试进程,调试子系统,调试API,NTDLL和内核中支持的一些函数。书中讲解的非常详细,但本次总结的重点不在于此,所以就不再展开,下面简单的围绕用户态调试这些API 做以介绍。
创建调试目标
调试器工作之前,首先做的是创建调试目标。用户态调试器创建目标的方法不外乎创建一个新进程,或者是直接附加到一个运行的进程,这和逆向做注入是一样的。采用方法之后,进程就成了调试目标,操作系统将调试器与调试目标关联起来。
调试器创建目标通过调用CreateProcess 同时传入DEBUG_PROCESS 标志。
1 2 3 4 5
| STARTUPINFO si={0}; si.cb=sizeof(si); PROCESS_INFORMATION pi={0}; bool ret=CreateProcesss(NULL,argv[1],NULL,NULL,false, DEBUG_PROCESS,NULL,NULL,&si,&pi);
|
而调试器附加到一个运行进程则是通过调用 DebugActiveProcess 来实现的。此函数允许将调试器绑定到一个正在运行的进程。
1
| bool DebugActiveProcess(DWORD dwProcessId )
|
dwProcessId 就是绑定金城的标示id,如果绑定成功,返回非零,否则返回零。
调试循环
接下来,就类似于Windows 的消息循环,调试循环也是在调试部结束的时候,会一直等待操作系统发送调试事件。
在调试目标被调试时,进程执行的一些操作会以事件的方式通知调试器。例如动态库的加载与卸载、新线程的创建和销毁以及代码或处理器抛出的异常都会通知调试器。当有事件需要通知调试器时,操作系统会首先挂起调试目标的所有线程,然后把事件通知调试器。并且等待调试器通知其继续执行。
首先,调试器会利用WaitForDebugEvent 来等待事件通知的到来,当事件到来,该函数会返回事件信息,而事件信息会包装在一个叫DEBUG_EVENT 结构中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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; ;
|
我们看一下union 中的各种调试事件:
- EXCEPTION_DEBUG_EVENT 调试中出现异常,生成调试事件。
- CREATE_THREAD_DEBUG_EVENT 调试过程中创建新进程或调试器开始调试已经激活的进程时,就会生成调试事件。要注意,当调试器主线程被创建时不会收到该通知。
- CREATE_PROCESS_DEBUG_EVENT 进程被创建。
- EXIT_THREAD_DEBUG_EVENT 线程退出事件。
- EXIT_PROCESS_DEBUG_EVENT 进程退出事件。
- LOAD_DLL_DEBUG_EVENT 装载DLL 文件时,生成事件。PE 装载器第一次解析出与DLL 有关的链接时,将收到这一事件。调试进程使用了 LoadLibrary 也会发生。
- UNLOAD_DLL_DEBUG_EVENT 使用FreeLibrary 函数卸载DLL 文件时,会生成调试事件。注意,只有在DLL 使用次数为0的时候,才会生成这个事件。
- OUTPUT_DEBUG_STRING_EVENT 调用DebugOutputString 函数向程序发送消息字符串发生该事件。
- RIP_EVENT 只有Windows 98 检查过的构件才会生成该调试事件,报告错误信息。
而调试相关的重点则是第一个 EXCEPTION_DEBUG_EVENT 他有一个很长的异常列表:
- EXCEPTION_ACCESS_VIOLATION 0xC0000005 程序企图读写一个不可访问的地址时引发的异常。例如企图读取0地址处的内存。
- EXCEPTION_ARRAY_BOUNDS_EXCEEDED 0xC000008C 数组访问越界时引发的异常。
- EXCEPTION_BREAKPOINT 0x80000003 触发断点时引发的异常。
- EXCEPTION_DATATYPE_MISALIGNMENT 0x80000002 程序读取一个未经对齐的数据时引发的异常。
- EXCEPTION_FLT_DENORMAL_OPERAND 0xC000008D 如果浮点数操作的操作数是非正常的,则引发该异常。所谓非正常,即它的值太小以至于不能用标准格式表示出来。
- EXCEPTION_FLT_DIVIDE_BY_ZERO 0xC000008E 浮点数除法的除数是0时引发该异常。
- EXCEPTION_FLT_INEXACT_RESULT 0xC000008F 浮点数操作的结果不能精确表示成小数时引发该异常。
- EXCEPTION_FLT_INVALID_OPERATION 0xC0000090 该异常表示不包括在这个表内的其它浮点数异常。
- EXCEPTION_FLT_OVERFLOW 0xC0000091 浮点数的指数超过所能表示的最大值时引发该异常。
- EXCEPTION_FLT_STACK_CHECK 0xC0000092 进行浮点数运算时栈发生溢出或下溢时引发该异常。
- EXCEPTION_FLT_UNDERFLOW 0xC0000093 浮点数的指数小于所能表示的最小值时引发该异常。
- EXCEPTION_ILLEGAL_INSTRUCTION 0xC000001D 程序企图执行一个无效的指令时引发该异常。
- EXCEPTION_IN_PAGE_ERROR 0xC0000006 程序要访问的内存页不在物理内存中时引发的异常。
- EXCEPTION_INT_DIVIDE_BY_ZERO 0xC0000094 整数除法的除数是0时引发该异常。
- EXCEPTION_INT_OVERFLOW 0xC0000095 整数操作的结果溢出时引发该异常。
- EXCEPTION_INVALID_DISPOSITION 0xC0000026 异常处理器返回一个无效的处理的时引发该异常。
- EXCEPTION_NONCONTINUABLE_EXCEPTION 0xC0000025 发生一个不可继续执行的异常时,如果程序继续执行,则会引发该异常。
- EXCEPTION_PRIV_INSTRUCTION 0xC0000096 程序企图执行一条当前CPU模式不允许的指令时引发该异常。
- EXCEPTION_SINGLE_STEP 0x80000004 标志寄存器的TF位为1时,每执行一条指令就会引发该异常。主要用于单步调试。
- EXCEPTION_STACK_OVERFLOW 0xC00000FD 栈溢出时引发该异常。
这么多的异常,虽然是我们平时看到的,但是列在一起也是非常让人老眼昏花,不过其实我们重点关注的是EXCEPTION_BREAKPOINT 断点异常,代码调试遇到该指令,立即中断运行,然后交给调试器进行处理。
所以,说了这些,利用调试的方法逆向的重点就是在这,利用断点的方法,找到代码中我们想要插入的部分,设置断点,将该字节改成0xCC 即可。使用完之后,将该字节回复原值,然后将控制权返回给被调试者,就可以了。
说了那么多,WaitForDebugEvent 的基本结构是这样的:
1
| BOOL WaitForDebugEvent(LPDEBUG_ENENT lpDebugEvent, DWORD dwMilliseconds)
|
- lpDebugEvent :指向接收调试事件信息的DEBUG_ ENENT结构的指针
- dwMilliseconds:指定用来等待调试事件发生的毫秒数,如果 这段时间内没有调试事件发生,函数将返回调用者;如果将该参数指定为INFINITE,函数将一直等待直到调试事件发生
如果函数成功,则返回非零值;如果失败,则返回零。在调试器调用WaitForDebugEvent返回后,得到事件通知,然后解析DEBUG_EVENT结构,并对事件进行响应,处理完成后调试器将会调用ContinueDebugEvent,并根据参数来通知调试目标执行相应操作。
另一个重点的函数就是让被调试者恢复运行了:
ContinueDebugEvent函数,此函数允许调试器恢复先前由于调试事件而挂起的线程。
1
| BOOL ContinueDebugEvent(DWORD dwProcessId,DWORD dwThreadId, DWORD dwContinueStatus )
|
- dwProcessId 为被调试进程的进程标识符
- dwThreadId 为欲恢复线程的线程标识符
- dwContinueStatus指定了该线程将以何种方式继续,包含两个定义值DBG_CONTINUE和DBG_EXCEPTION_NOT_HANDLED
如果函数成功,则返回非零值;如果失败,则返回零。
下面是一个简单的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| DWORD Condition=DBG_CONTINUE; while(Condition) { DEBUG_EVENT DebugEvent={0}; WaitForDebugEvent(&DebugEvent,INFINITE); ProcessEvenet(DebugEvent) ContinueDebugEvent(DebugEvent.dwProcessId,DebugEvent.dwThreadId,Condition); }
|
而具体的处理事件可以是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| DWORD ProcessEvent(DEBUG_EVENT de) { switch(de.dwDebugEvent.Code) { case EXCEPTION_DEBUG_EVENT: { } break; case CREATE_THREAD_DEBUG_EVENT: { } break; case CREATE_PROCESS_DEBUG_EVENT: { } break; case EXIT_THREAD_DEBUG_EVENT: { } break; case EXIT_PROCESS_DEBUG_EVENT: { } break; case LOAD_DLL_DEBUG_EVENT: { } break; case OUTPUT_DEBUG_STRING_EVENT: { } break; ...... } return DBG_CONTINUE; }
|
调试事件到来的顺序
当我们启动调试目标时,调试器接收到的第一个事件是CREATE_THREAD_DEBUG_EVENT。接下来是加载dll的事件。每加载一个,都会产生一个这样的事件。
当所有模块都被加载到进程地址空间后,调试目标就准备好运行了,调试器此时也做好了接收通知的准备。此时是设置断点的最佳时机。
在调试目标退出之前调试器会收到 EXIT_DEBUG_PROCESS_EVENT通知。此后调试器不能收到加载到进程地址空间的dll从进程卸载的UNLOAD_DLL_DEBUG_EVENT通知。
Windows操作系统使用结构化异常处理(SEH)机制将处理器引发的异常传递给内核及用户态程序。每个SEH异常都有一个无符号整形的异常码来唯一标识。这个异常码是由系统在异常发生时指定的。这些异常码使用了操作系统开发人员定义的公开异常码。
内核态调试
简单来说,内核调试就是分析和调试内和空间的代码和数据,主要有操作系统的内核,执行体 和各种驱动程序。可以把驱动程序看作是对操作系统内核的拓展和补充,因此可以把内核调试简单的理解为调试操作系统的广义内核。内核调试的问题在于他讲调试目标中断到调试器意味着操作系统内核的中断,而内核负责整个系统的调度和执行,内核一旦停止,整个系统也就停止了。所以如果想做内核级别的调试,要么需要硬件级别的调试器,通过特定的借口与CPU 建立连接,要么是在系统内核加入调试支持。
内核态调试则是提供了内核级别的调试。除了前边提到的功能,借助内核调试,我们还可以直接下载到系统文件符号,获取到系统内部的结构体,有一些是尚未公开的,以及一些API 的相关信息。还可以用来读取,分析Windows OS 的转储文件,帮助分析发生系统崩溃的原因。
目前比较流行的OllyDBG, IDA pro, 属于用户态调试器,而微软自己出品的WinDBG既可以用户态调试,也可以内核调试,Linux 下的调试器对应为GDB。
script>