avatar

Catalog
调试事件的处理

前言

前面两篇学习了调试器进程与被调试进程如何通过调试对象建立起联系,也了解了调试事件等概念,总结为下图:

本篇将通过程序模拟两种建立调试关系的方式,分析调试事件的处理过程及不同调试事件的结构细节。

建立调试关系(创建进程)

模拟程序代码

首先,我们要模拟一个调试关系建立的过程,参考如下代码:

c
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include "stdafx.h"
#include "windows.h"
#define dbgProcessName "C:\\Dbgview.exe"

int main()
{
BOOL nIsConinue = TRUE;
DEBUG_EVENT debugEvent = {0};

//1.创建调试进程
STARTUPINFO startupInfo = {0};
PROCESS_INFORMATION pInfo = {0};
GetStartupInfo(&startupInfo);

//以Create方式创建调试链接
BOOL bRet = CreateProcess(dbgProcessName, NULL, NULL, NULL, TRUE, DEBUG_PROCESS||DEBUG_ONLY_THIS_PROCESS, NULL, NULL, &startupInfo, &pInfo);
if (bRet == FALSE)
{
printf("CreateProcess error:%d\n", GetLastError());
getchar();
return 0;
}

//2.调试循环
while (nIsConinue)
{
//取DEBUG_EVENT
bRet = WaitForDebugEvent(&debugEvent, INFINITE);
if (!bRet)
{
printf("WaitForDebugEvent error:%d\n", GetLastError());
return 0;
}

switch (debugEvent.dwDebugEventCode)
{
case EXCEPTION_DEBUG_EVENT:
//printf("异常:发生异常的地址:%X \n",debugEvent.u.Exception.ExceptionRecord.ExceptionAddress);
printf("发生异常调试事件\n");
break;

case CREATE_THREAD_DEBUG_EVENT:
printf("创建线程调试事件\n");
break;

case CREATE_PROCESS_DEBUG_EVENT:
printf("创建进程调试事件\n");
break;

case EXIT_THREAD_DEBUG_EVENT:
printf("退出线程调试事件\n");
break;

case EXIT_PROCESS_DEBUG_EVENT:
printf("退出进程调试事件\n");
break;

case LOAD_DLL_DEBUG_EVENT:
printf("加载DLL调试事件\n");
break;

case UNLOAD_DLL_DEBUG_EVENT:
printf("卸载DLL调试事件\n");
break;

default:
break;
}

//DBG_CONTINUE 表示调试器已处理该异常
//DBG_EXCEPTION_NOT_HANDLED 表示调试器没有处理该异常,转回到用户态中执行,寻找可以处理该异常的异常处理器
//ContinueDebugEvent 告诉被调试程序让被调试程序继续执行
bRet = ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, DBG_CONTINUE);
}

return 0;
}

这个代码基本上可以看作是一个简单的调试器的逻辑代码了,这里面主要分为两个部分:一个是创建调试器与被调试进程的联系,另一个是调试器对调试事件的处理

创建调试器与被调试器进程的联系,这个原理在第一篇中已经介绍的比较清楚了,这里主要是通过CreateProcess的方式建立联系的,相当于在调试器中打开一个exe程序。这里需要说明的是CreateProcess中DEBUG_ONLY_THIS_PROCESS这个参数,它的作用是在调试过程中若被调试进程创建了新的进程,仍然只调试当前进程。

调试器对调试事件的处理这部分代码逻辑并不复杂,复杂的是不同调试事件的数据结构。在一个While循环中,调用WaitForDebugEvent不断从_DEBUG_OBJECT.EventList中获取调试事件,并根据调试事件类型的不同,进行相应的处理,这里仅将调试事件的类别打印出来。

调试事件结构

代码中通过调用WaitForDebugEvent从调试对象的EventList中获取调试事件,而调试事件是由DEBUG_EVENT定义的一个变量,用来接收从EventList中取到的调试事件。下面来看一下DEBUG_EVENT的结构:

c
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
typedef struct _DEBUG_EVENT {
DWORD dwDebugEventCode; //甚么类型的调试事件
DWORD dwProcessId; //触发调试事件的进程ID
DWORD dwThreadId; //触发调试事件的线程ID
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的取值
#define DRIVE_UNKNOWN 0
#define DRIVE_NO_ROOT_DIR 1
#define DRIVE_REMOVABLE 2
#define DRIVE_FIXED 3
#define DRIVE_REMOTE 4
#define DRIVE_CDROM 5
#define DRIVE_RAMDISK 6

前三个成员很好理解,调试事件的类型(取值已列出)、触发调试事件的进程ID、触发调试事件的线程ID;紧接着是一个共用体,该值由调试事件的类型决定,每一种类型的调试事件的取值都不同,对应的结构也不同,这也是为什么这些调试事件有这么多的采集函数,因为每种调试事件的结构体不同,所以调试事件采集函数也不同。各类调试事件对应的结构体如下:

c
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
//异常类型信息
typedef struct _EXCEPTION_DEBUG_INFO {
EXCEPTION_RECORD ExceptionRecord;
DWORD dwFirstChance;
} EXCEPTION_DEBUG_INFO, *LPEXCEPTION_DEBUG_INFO;

//线程创建类型信息
typedef struct _CREATE_THREAD_DEBUG_INFO {
HANDLE hThread;
LPVOID lpThreadLocalBase;
LPTHREAD_START_ROUTINE lpStartAddress;
} CREATE_THREAD_DEBUG_INFO, *LPCREATE_THREAD_DEBUG_INFO;

//进程创建类型信息
typedef struct _CREATE_PROCESS_DEBUG_INFO {
HANDLE hFile;
HANDLE hProcess;
HANDLE hThread;
LPVOID lpBaseOfImage;
DWORD dwDebugInfoFileOffset;
DWORD nDebugInfoSize;
LPVOID lpThreadLocalBase;
LPTHREAD_START_ROUTINE lpStartAddress;
LPVOID lpImageName;
WORD fUnicode;
} CREATE_PROCESS_DEBUG_INFO, *LPCREATE_PROCESS_DEBUG_INFO;

//线程退出类型信息
typedef struct _EXIT_THREAD_DEBUG_INFO {
DWORD dwExitCode;
} EXIT_THREAD_DEBUG_INFO, *LPEXIT_THREAD_DEBUG_INFO;

//进程退出类型信息
typedef struct _EXIT_PROCESS_DEBUG_INFO {
DWORD dwExitCode;
} EXIT_PROCESS_DEBUG_INFO, *LPEXIT_PROCESS_DEBUG_INFO;

//模块加载类型信息
typedef struct _LOAD_DLL_DEBUG_INFO {
HANDLE hFile;
LPVOID lpBaseOfDll;
DWORD dwDebugInfoFileOffset;
DWORD nDebugInfoSize;
LPVOID lpImageName;
WORD fUnicode;
} LOAD_DLL_DEBUG_INFO, *LPLOAD_DLL_DEBUG_INFO;

//模块卸载类型信息
typedef struct _UNLOAD_DLL_DEBUG_INFO {
LPVOID lpBaseOfDll;
} UNLOAD_DLL_DEBUG_INFO, *LPUNLOAD_DLL_DEBUG_INFO;

typedef struct _OUTPUT_DEBUG_STRING_INFO {
LPSTR lpDebugStringData;
WORD fUnicode;
WORD nDebugStringLength;
} OUTPUT_DEBUG_STRING_INFO, *LPOUTPUT_DEBUG_STRING_INFO;

typedef struct _RIP_INFO {
DWORD dwError;
DWORD dwType;
} RIP_INFO, *LPRIP_INFO;

额外的异常调试事件

前面介绍了代码的主体逻辑,以及不同调试事件对应的结构体,下面就来运行一下程序观察结果。

由图,一开始打开程序时会创建进程,接着加载了一系列程序需要用到的DLL,加载DLL期间会触发一次异常调试事件,最后创建主线程。这个逻辑还是很好理解的,就有一点比较奇怪,为什么会有一个看上去格格不入的异常调试事件?为了弄清这点,我们修改代码,打印异常发生时的地址,可以通过异常调试事件对应的结构体中获取。

发现异常发生的地址为0x7C92120E(不同环境下该值会不一样,本次实验的值为这个),记住这个值。然后,打开OD,以创建进程的方式调试Dbgview.exe,可以看到程序会断在0x413487的位置,进入调试选项,选择事件,会发现此时设置的是程序第一次加载会断在WinMain的位置。

将其改为系统断点,重新操作一次

情况就变得不一样了,可以看到程序断下的位置为0x7C92120F,在它前面一个字节0x7C92120E处刚好有一个INT3,刚好与发生异常的地址一样。现在我们知道,创建被调试进程时,发生的异常调试事件就是在这里的一个断点。但是,为什么会有这么一个突如其来的断点在这呢?这里,我们先来回顾一下进程的创建过程

Code
1
2
3
4
5
6
7
8
进程的创建过程:
1.映射PE文件
2.创建内核对象EPROCESS
3.映射系统DLL(ntdll.dll,这个DLL比较特殊,在初始化内核时就创建好了)
4.创建线程内核对象ETHREAD
5.系统启动线程
1)映射DLL(ntdll.LdrInitializeThunk)
2)线程开始启动

这里注意到,在映射文件中引用的DLL时会调用ntdll.dll中的一个函数LdrInitializeThunk,下面打开IDA,跟进分析:

  1. LdrInitializeThunk内部会调用LdrpInitialize,LadpInitialize内部又会调用LdrpInitializeProcess(如果是第一个线程),这里的具体跳转就不细看,直接看能证明结论的图。

  2. LdrpInitializeProcess这个函数非常的复杂,这里只看关键部分逻辑。首先令ebx获取到PEB的地址,然后判断PEB+0x2处的值,即BeingDebugged的值是否为0,若不等于0,则跳转并调用函数DbgBreakPoint。进入DbgBreakPoint后发现,这个函数就是下一个INT3断点,只是封装成了函数的形式。

分析完LdrInitializeThunk内部的调用逻辑,这下就可以解释的通为什么会有一个异常调用事件出现。在初始化进程时会通过PEB.BeingDebugged判断当前进程是否正在被调试,若正在被调试,就会为它增加一个INT3断点

小结

这一部分,主要了解了以创建进程的方式建立调试关系时,触发的一些调试事件。并罗列出了各类调试事件对应的结构体。PEB.BeingDebugged作为3环查询是否被调试的字段,与0环的EPROCESS.DebugPort相对应,作为反调试一方要了解这些细节,而反反调试则要洞察这一切可能暴露自己的蛛丝马迹。

建立调试关系(附加进程)

与创建进程的不同

这部分,以附加进程的方式与被调试进程建立调试关系(相当于OD中attch进程的方式),代码没有太大变化,仅有一点小调整,将CreateProcess替换成DebugActiveProcess。由于GetProcessId这个函数已经用不了了,因此直接去任务管理器找PID的,然后从缓冲区读入的方式作为DebugActiveProcess的参数,具体如下:

c
1
2
3
4
5
6
7
8
//以Attach方式创建调试链接
scanf("%d", &PID);
if (!DebugActiveProcess(PID))
{
printf("AttachProcess error:%d\n", GetLastError());
getchar();
return 0;
}

运行结果如下

光从结果上来看,与CreateProcess的方式没有太大区别。但是,当我们通过CreateProcess时需要创建进程,所以才会有一系列的DLL加载的操作。然而,我们使用Attach方式式,却仍然可以看到DLL加载的操作,这到底是为什么呢?DLL不是应该早已加载好了吗?要解决这个问题,需要进入内核的NtDebugActiveProcess一探究竟。

杜撰的消息

进入NtDebugActiveProcess,可以看到,在将被调试进程与调试对象关联起来之前,调用了一个名为DbgkpPostFakeProcessCreateMessages,顾名思义,就是向调试器发一些杜撰出来的假消息。而且此时被调试进程还未和调试对象关联,因此调试器是接收不到任何来自被调试线程的消息的。

进入DbgkpPostFakeProcessCreateMessages后,发现有线程相关的假消息,以及模块相关的假消息,都会被发送给调试器。显然那些通过Attach方式与被调试进程建立调试关系时,看到那些DLL加载的调试事件,实际上是NtDebugActiveProcess发给调试器杜撰出来的假消息。

为什么NtDebugActiveProcess要给调试器发送假的消息呢?调试器可以看到被调试进程加载的DLL,主要是因为每个模块在加载的时候会被调试事件采集函数给截获,并将该类调试事件发送给调试器。而通过Attach的方式会无法看到进程初始化时所加载的DLL,因此该API在执行的过程中加上了这些假消息希望给调试器提供一些必要的信息。不过,这些假消息并不靠谱,这些模块主要依据PEB.Ldr中的三个链表得来的,这三个链表以不同的顺序存着该进程所有加载的模块。

显然,这个地方是很容易被修改的,因此通过Attach方式看到的加载模块往往并不靠谱。

总结

  1. 以创建进程的方式建立调试关系时,初始化函数为会进程增加一个INT3断点。
  2. 以附加进程的方式建立调试关系时,NtDebugActiveProcess会杜撰假消息发送给调试器。

参考资料

参考笔记:

参考教程:

  • 海哥逆向中级预习班
Author: cataLoc
Link: http://cataloc.gitee.io/blog/2020/09/14/%E8%B0%83%E8%AF%95%E4%BA%8B%E4%BB%B6%E7%9A%84%E5%A4%84%E7%90%86/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶