调试器原理

本篇算是做逆向工程相关学习的开篇吧,调试则不仅仅是逆向工程的内容,更是一个开发程序猿需要掌握的内容。

用户态调试器 与 内核态调试器

首先我们需要了解调试器到底是如何轻而易举的观察和控制被调试的过程呢。其实简单来说,就是利用了调试的API,而调试又分为用户态调试和内核态调试。

所谓的用户态调试器就是在用户态下进行调试,利用了操作系统提供的一系列公开的API 来进行调试。

Windows操作系统提供了一组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 他有一个很长的异常列表:

这么多的异常,虽然是我们平时看到的,但是列在一起也是非常让人老眼昏花,不过其实我们重点关注的是EXCEPTION_BREAKPOINT 断点异常,代码调试遇到该指令,立即中断运行,然后交给调试器进行处理。

所以,说了这些,利用调试的方法逆向的重点就是在这,利用断点的方法,找到代码中我们想要插入的部分,设置断点,将该字节改成0xCC 即可。使用完之后,将该字节回复原值,然后将控制权返回给被调试者,就可以了。

说了那么多,WaitForDebugEvent 的基本结构是这样的:

1
BOOL WaitForDebugEvent(LPDEBUG_ENENT lpDebugEvent, DWORD dwMilliseconds)

如果函数成功,则返回非零值;如果失败,则返回零。在调试器调用WaitForDebugEvent返回后,得到事件通知,然后解析DEBUG_EVENT结构,并对事件进行响应,处理完成后调试器将会调用ContinueDebugEvent,并根据参数来通知调试目标执行相应操作。

另一个重点的函数就是让被调试者恢复运行了:

ContinueDebugEvent函数,此函数允许调试器恢复先前由于调试事件而挂起的线程。

1
BOOL ContinueDebugEvent(DWORD dwProcessId,DWORD dwThreadId, DWORD dwContinueStatus )

如果函数成功,则返回非零值;如果失败,则返回零。

下面是一个简单的示例:

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>