软件调试

软件调试实际上涉及的内容并不多。如果你想开发一个调试器,掌握十几个API的使用就足够了。然而,如果你希望在调试与反调试的对抗中保持主动地位,对细节的了解就变得尤为重要。

软件调试的学习主要涉及到的文件有kernel32.dll、ntdll.dll和ntoskrnl.exe。对于一些未公开的信息,我们可以通过ReactOS(https://master.dl.sourceforge.net/project/reactos/ReactOS/0.3.15/ReactOS-0.3.15-REL-src.zip?viasf=1)来帮助我们对代码进行分析。

调试对象

调试器与被调试程序

调试器与被调试程序在用户层都是独立的进程,我们要想将两者之间相互关联,就需要通过一个媒介,即进程空间的高2G,也就是我们常说的内核层,每个进程的高2G都是共享的,因此调试器可以通过它来与被调试器进行通信。

images/download/attachments/3440657/image2023-10-11_17-0-26.png

通过调试器去调试一个程序有两个办法,第一种方法是通过CreateProcess函数打开一个未运行的程序,第二种方法是附加进程的方式通过DebugActiveProcess函数附加一个正在运行的进程

这两种方法其实在建立进程间联系的方式是一样的,唯一不同的是CreateProcess函数多了一个步骤,即创建进程,因此我们只需要来分析DebugActiveProcess函数就能知道建立联系的方法

DebugActiveProcess执行流程

关联调试对象与调试器

我们通过IDA来分析DebugActiveProcess函数的执行流程,首先,我们进入kernel32.dll中的DebugActiveProcess函数,在函数的最前面,我们可以注意到一个值得关注的函数,名为DbgUiConnectToDbg

当我们进入这个函数后,我们发现它调用了另一个同名函数,位于ntdll.dll模块中。

images/download/attachments/3440657/image2023-10-11_18-16-3.png

我们继续跟进就会发现ntdll.dll模块中的DbgUiConnectToDbg函数,我们可以看见它先获取FS:[0x18],也就是_TEB.NtTib.Self,其实就是_TEB的地址本身,然后通过它来获取FS:[0xF24],即_TEB.DbgSSReserved[1](该成员是一个指针数组,可以存储2个,它是一个保留成员,专门给调试器使用,调试器可以使用该字段来存储自己),用它作为参数给到了_ZwCreateDebugObject函数,最终该函数执行的返回结果就是EAX,也就表示将返回信息存储到了_TEB.DbgSSReserved[1]中。

images/download/attachments/3440657/image2023-10-11_18-37-12.png

_ZwCreateDebugObject函数其实什么也没干,就是通过系统调用号进入0环执行对应的0环函数来做具体的事情,根据函数的命名,我们可以推断,这个函数的作用是用于创建调试对象_DEBUG_OBJECT(以下简称为调试对象)调试对象充当了我们之前提到的媒介,目前调试器与调试对象已经关联起来了。

因此,这意味着该函数的返回结果即为调试对象,它不会直接返回调试对象在0环的地址。所以在3环_TEB.DbgSSReserved[1]中存储了该调试对象的句柄。这个句柄用于在3环中引用和操作调试对象。

images/download/attachments/3440657/image2023-10-11_18-42-25.png

关联调试对象与被调试进程

在以上流程都结束之后,再回到kernel32.dll,如下图所示,会比较返回结果,如果调试对象创建成功就进行跳转。

images/download/attachments/3440657/image2023-10-11_19-37-58.png

继续跟进代码,如下图所示我们可以看见首先通过_ProcessIdToHandle函数,根据进程ID获取被调试进程的句柄(返回结果EAX给到ESI),其次将被调试进程句柄作为参数带入到_DbgUiDebugActiveProcess函数。

images/download/attachments/3440657/image2023-10-11_19-44-58.png

_DbgUiDebugActiveProcess函数位于ntdll.dll模块中,所以需要切过去进行查看。在该函数内先获取当前线程的_TEB,然后获取_TEB的0xF24偏移位成员,即调试对象,将其与被调试进程句柄一并作为参数带入到_NtDebugActiveProcess函数。而这个函数仍然是一个系统调用,最终进入0环。

images/download/attachments/3440657/image2023-10-11_19-47-36.png

我们要继续跟进0环,也就是ntoskrnl.exe模块,找同名函数_NtDebugActiveProcess即可。如下图所示,我们可以看见最开始部分就是调用了_ObReferenceObjectByHandle函数,该函数的作用就是通过句柄来获取对象,它有6个参数,第5个参数就是用于存储函数获取的进程对象地址,在这里是通过被调试进程的句柄(参数1)获取_PsProcessType(参数3)类型的对象,也就是_EPROCESS。

images/download/attachments/3440657/image2023-10-11_21-53-29.png

往下继续看,这里就是获取当前进程结构体地址与被调试进程结构体地址进行对比,即判断是否存在自己调试自己的情况,如果存在则跳转走。以及判断了被调试进程是否是初始化系统进程,如果是的话也跳转走。这里其实也就说明了两种无法继续调试的条件。

images/download/attachments/3440657/image2023-10-11_21-54-57.png

然后又来到了通过句柄获取对象的环节,这次是通过调试对象的句柄来获取调试对象的地址,并且将调试对象的地址和被调试进程的_EPROCESS结构体地址作为参数带入_DbgkpSetProcessDebugObject函数执行。

images/download/attachments/3440657/image2023-10-11_22-6-26.png

_DbgkpSetProcessDebugObject函数的作用就是将调试对象与被调试进程关联,如下图所示,我们可以看见在获取了被调试进程结构体地址之后判断被调试进程是否已经属于被调试状态,即_EPROCESS的0xBC偏移位成员DebugPort是否非0,如果为0则表示没有被调试继续向下执行。接着就是最关键的,将调试对象放进被调试进程结构体的DebugPort成员当中,至此就将两者成功关联起来

images/download/attachments/3440657/image2023-10-11_22-13-26.png

_DEBUG_OBJECT结构体

通过上述的学习,我们了解到了调试器与被调试进程,通过调试对象结构体_DEBUG_OBJECT成功的关联起来:

images/download/attachments/3440657/image2023-10-11_22-20-7.png

以下为_DEBUG_OBJECT结构体的组成:

typedef struct _DEBUG_OBJECT {
KEVENT EventsPresent;
FAST_MUTEX Mutex;
LIST_ENTRY EventList;
ULONG Flags;
} DEBUG_OBJECT, *PDEBUG_OBJECT;

调试对抗

在了解了调试原理之后,我们可以总结出一些反调试的方法

  1. DebugPort检查与置0:创建一个线程,不断检查当前进程的DebugPort值,一旦有值就退出程序或将其置0,中断调试对象与被调试进程之间的联系。

  2. 遍历所有进程的TEB+0xF24处:检查该位置是否有值,如果有值,说明存在调试器,可以选择退出程序。

  3. Hook NtCreateDebugObject:阻止NtCreateDebugObject函数创建调试对象,从而防止调试器的附加。

反反调试的方法:

  1. 针对DebugPort检查与置0:不使用DebugPort,而是在进程结构体中另外选择一个空闲成员来存放_DEBUG_OBJECT的地址。

  2. 针对Hook NtCreateDebugObject:自行分配内存给_DEBUG_OBJECT结构体,并为其成员赋值,绕过原有的Hook。

  3. 重写DebugActiveProcess函数:重新实现DebugActiveProcess函数,以增加对反调试技术绕过能力。

调试事件的采集

调试事件

调试器与被调试进程之间通过_DEBUG_OBJECT(调试对象)来建立起联系,调试器通过调试对象的EventList成员来指导被调试进程到底发生了什么事情,该成员是一个链表,其中存储的是从被调试进程发送过来的各种类型的调试事件

typedef struct _DEBUG_OBJECT {
KEVENT EventsPresent; // 用于指示有调试事件发生的事件对象
FAST_MUTEX Mutex; // 用于同步的互斥对象
LIST_ENTRY EventList; // 保存调试事件的链表
ULONG Flags; // 标志位,调试消息是否已读取
} DEBUG_OBJECT, *PDEBUG_OBJECT;

images/download/attachments/3440657/image2023-10-11_22-20-7.png

种类

不是所有的进程行为都会产生调试事件的,调试事件也分种类,如下所示一共有8种,但有一个已经废弃,因此实际上只有7种调试事件

typedef enum _DBGKM_APINUMBER
{
DbgKmExceptionApi = 0, // 异常
DbgKmCreateThreadApi = 1, // 创建线程
DbgKmCreateProcessApi = 2, // 创建进程
DbgKmExitThreadApi = 3, // 线程退出
DbgKmExitProcessApi = 4, // 进程退出
DbgKmLoadDllApi = 5, // 加载DLL
DbgKmUnloadDllApi = 6, // 卸载DLL
DbgKmErrorReportApi = 7, // 已废弃
DbgKmMaxApiNumber = 8, // 最大值
} DBGKM_APINUMBER;

采集函数

在Windows系统中提供了一些以Dbgk开头的函数,可用于采集调试事件,并生成相应事件的结构体,我们称之为调试事件采集函数。如下图所示,我们会发现这些事件采集函数都是在API调用的必经之路上加入的。图中黑色字体的函数就是每个事件API最终都会经过的地方,紫色字体的函数用于采集调试事件,红色字体的函数用于发送(写入)调试事件.

images/download/attachments/3440657/image2023-11-15_21-43-53.png

采集流程

我们要想知道采集函数做了什么就需要跟进流程,来看它的代码逻辑。这里我们以创建进程、线程,和退出线程、进程为例来跟进分析一下。

创建进程、线程事件采集

创建进程的本质就是创建线程,其中第一次创建线程时即为创建进程。因此,无论是创建进程还是线程,底层调用的函数是相同的,都是PspUserThreadStartup函数。

在PspUserThreadStartup函数内,我们可以很快到的找到对应的调试事件采集函数_DbgkCreateThread。

images/download/attachments/3440657/image2023-11-15_22-19-7.png

继续跟进_DbgkCreateThread函数,我们会发现它判断了当前进程的DebugPort的值是否为空,这个判断是每个调试事件采集函数都会走的逻辑,如果判断值不为空则表示当前进程正在被调试就跳转进去。

images/download/attachments/3440657/image2023-11-15_22-23-10.png

跟进跳转的片段代码,我们可以看见它一开始就做了一件事情,即判断当前线程是否为第一个线程,以此来判断生成的调试事件是创建进程还是创建线程。

images/download/attachments/3440657/image2023-11-15_22-44-11.png

接着它做了第二件事情,将对应的调试事件打包成一个结构体,跳转到如下片段代码,最终会调用_DbgkpSendApiMessage函数。这个函数的第一个参数就是之前打包好的调试事件的结构体。

images/download/attachments/3440657/image2023-11-15_22-45-49.png

退出线程、进程事件采集

退出进程的本质也是退出线程,其中退出线程为最后一个时即为退出进程。因此,无论是退出进程还是线程,底层调用的函数是相同的,都是PspExitThread函数。

在PspExitThread函数内,我们可以看见它判断了DebugPort是否为0,如果不为0表示当前正在调试,就进行跳转。

images/download/attachments/3440657/image2023-11-15_23-0-58.png

跟进跳转的片段代码,这里判断了当前退出的线程是不是最后一个,也就表示判断是否是退出的进程,如果是则调用函数_DbgkExitProcess,反之如果不是则表示当前退出的是线程,则调用函数_DbgkExitThread。这里是根据退出事件选择对应的采集函数。

images/download/attachments/3440657/image2023-11-15_23-2-4.png

而我们每个都跟进去看,会发现最开始都在填充对应的调试事件结构体,最终调用_DbgkpSendApiMessage函数。

images/download/attachments/3440657/image2023-11-15_23-7-10.png

DbgkpSendApiMessage函数

执行流程

通过上面几个案例的分析,我们知道DbgkpSendApiMessage函数的作用是将已创建的调试事件发送到调试对象的事件链表中。

进入函数内,发现它调用了_DbgkpQueueMessage函数,该函数有两个参数,一个是Event,它是由调试事件采集函数创建的结构体,另一个是FastMutex,这是一个一个互斥体参数,与调试对象的第一个成员相同。

images/download/attachments/3440657/image2023-11-15_23-22-57.png

我们跟进_DbgkpQueueMessage函数,在该函数内部发现代码逻辑执行到一半时,它先从自身进程的_EPROCESS结构体中获取调试对象(调试对象不为空)。然后从调试对象中取出EventList成员的首地址(0x30偏移位),并将保存在EBX中的节点插入到EventList的第一个位置,也就表示这里的EBX为处理好的调试事件。至此,我们的调试事件就挂入了链表中,可以使得调试器进行处理了。

images/download/attachments/3440657/image2023-11-15_23-28-51.png

函数参数

该函数有两个参数,这里简要说明一下:

参数1:消息结构。总共有7种类型的消息结构,每种消息都有自己的消息结构,这些结构是由不同的调试事件采集函数创建的。
参数2:挂起线程标志。这个参数用于指示是否需要挂起除了当前进程之外的其他线程。有些调试事件需要挂起其他线程,比如针对int3断点的事件;而有些调试事件则不需要挂起线程,比如模块加载事件。

DbgkpSendApiMessage函数是整个调试事件采集的主要入口。如果在这个函数中设置了钩子,调试器将无法正常进行调试操作。

再看_DEBUG_OBJECT

我们现在再看一下调试对象的每个成员,应该能更好理解一些,在调用DbgkpQueueMessage函数将调试事件添加到链表后,_DEBUG_OBJECT.EventsPresent状态会被修改,调试器会判断这个状态,当状态改变后从EventList链表中提取调试事件。

为了避免并发修改EventList链表,需要使用一个Mutex进行互斥操作。这样,当调试器从EventList链表中提取调试事件时,同时DbgkpSendApiMessage函数向EventList链表写入数据时也会被正确地互斥。另外,Flags参数用于标识该事件是否已被调试器读取。

typedef struct _DEBUG_OBJECT {
KEVENT EventsPresent; // 用于指示有调试事件发生的事件对象
FAST_MUTEX Mutex; // 用于同步的互斥对象
LIST_ENTRY EventList; // 保存调试事件的链表
ULONG Flags; // 标志位,调试消息是否已读取
} DEBUG_OBJECT, *PDEBUG_OBJECT;

调试事件的处理

调试事件采集之后就需要对不同的事件进行处理,接下来我们通过程序模拟两种建立调试关系的方式(即创建进程、附加进程),分析调试事件的处理过程和不同调试事件的结构细节。

建立调试关系:创建进程

如下代码所示,我们创建了一个简易调试器,首先创建一个调试进程(CreateProcess函数第6个参数表示创建进程的标志,用于指定创建进程的行为和属性,这里的值为DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS,表示创建一个调试进程,并限制只有当前调试器可以附加到该进程进行调试,确保调试过程的独占性和安全性),接着进入一个调试循环。

在循环中,等待调试事件的发生,如异常、线程创建、进程创建等,然后根据具体的调试事件类型进行相应的处理。处理完事件后,告诉被调试程序继续执行。

#include "windows.h"
#include "stdio.h"
 
int main()
{
// 定义调试进程路径
const char* dbgProcessName = "C:\\notepad.exe";
// 创建调试进程
STARTUPINFO startupInfo = {0};
PROCESS_INFORMATION processInfo = {0};
GetStartupInfo(&startupInfo);
BOOL createProcessSuccess = CreateProcess(
dbgProcessName, NULL, NULL, NULL, TRUE,
DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS,
NULL, NULL, &startupInfo, &processInfo
);
if (!createProcessSuccess)
{
printf("CreateProcess error: %d\n", GetLastError());
getchar();
return 0;
}
 
// 调试循环
while (TRUE)
{
// 等待调试事件
DEBUG_EVENT debugEvent = {0};
BOOL waitForDebugEventSuccess = WaitForDebugEvent(&debugEvent, INFINITE);
if (!waitForDebugEventSuccess)
{
printf("WaitForDebugEvent error: %d\n", GetLastError());
return 0;
}
 
// 处理调试事件
switch (debugEvent.dwDebugEventCode)
{
case EXCEPTION_DEBUG_EVENT:
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;
}
 
// 告诉被调试程序让其继续执行
/*
第三个参数可以有两个值:
1. DBG_CONTINUE:表示调试器已处理该异常
2. DBG_EXCEPTION_NOT_HANDLED:表示调试器没有处理该异常,转回到用户态中执行,寻找可以处理该异常的异常处理器
*/
ContinueDebugEvent(debugEvent.dwProcessId, debugEvent.dwThreadId, DBG_CONTINUE);
}
 
return 0;
}

images/download/attachments/3440657/image2023-11-22_21-37-57.png

调试事件结构

在调试循环事件等待中,我们是通过结构体DEBUG_EVENT在WaitForDebugEvent函数中获取调试事件的,该结构体的定义如下。

前三个成员很好理解,最后一个成员是一个联合体,里面包含了各种结构体,这是因为调试事件类型的多样性,所以对应的结构也不同,通过这样的方式就可以根据不同类型的调试事件存放对应的结构体信息(这也是为什么不同的调试事件有不同的调试采集函数)。

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;

各类调试事件对应的结构体如下:

// 异常类型信息
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;

额外的异常调试事件

我们可以在上述的简易调试器调试循环判断异常类型的调试事件,将异常发生的地址打印,然后运行代码,就会发现一个被调试进程一切都正常运行,但是产生了一个异常调试事件。

printf("发生异常调试事件: %x\n", debugEvent.u.Exception.ExceptionRecord.ExceptionAddress);

images/download/attachments/3440657/image2023-11-22_22-49-45.png

想要知道这是为什么,我们可以通过OD来打开被调试进程,在这之前我们可以先设置一下打开时的暂停断点,由WinMain变成System breakpoint,这样就不会在进程一打开时就断下来:

images/download/attachments/3440657/image2023-11-22_22-6-29.png

然后我们再重新打开被调试进程,就会发现程序在我们之前编写的简易调试器所输出的发生异常调试事件地址加一的地址(0x7c921231)进行了断点。

images/download/attachments/3440657/image2023-11-22_22-7-55.png

简易调试器输出的地址处的指令刚好是INT 3,也证明了异常调试事件是来源于此。至于为什么会突然出现这个断点,我们需要回顾一下进程的创建过程:

images/download/attachments/3440657/image2023-11-22_22-12-0.png

在第5步,系统启动线程进行DLL的映射时会调用ntdll模块中的LdrInitializeThunk函数,我们跟进该函数看一下,在LdrInitializeThunk函数内部会调用_LdrpInitialize函数,在_LdrpInitialize函数内部会判断当前创建的是否为第一个线程,如果是就会调用_LdrpInitializeProcess函数

images/download/attachments/3440657/image2023-11-22_22-20-13.png

接着我们进入_LdrpInitializeProcess函数,这个函数的内容太多,我们只看关键的点。首先,通过将PEB的地址存储到EBX寄存器中。接下来检查PEB结构体中偏移为0x2的位置,即BeingDebugged的值是否为0。如果不等于0,将跳转并调用函数DbgBreakPoint。DbgBreakPoint函数就是执行了INT 3断点。

images/download/attachments/3440657/image2023-11-22_22-31-4.png

那么我们也就得出结论,在初始化进程时,会通过检查PEB中的BeingDebugged字段来判断当前进程是否正在被调试。如果当前进程正在被调试,就会为它添加一个INT 3断点。

建立调试关系:附加进程

我们可以通过修改建议调试器代码中的部分代码,即CreateProcess函数的逻辑修改为DebugActiveProcess函数的逻辑,这样就可以实现以附加进程的方式来建立调试关系。

DWORD PID;
scanf("%d", &PID);
if (!DebugActiveProcess(PID))
{
printf("AttachProcess error:%d\n", GetLastError());
getchar();
return 0;
}

在这边我们需要通过手动查看任务管理器的方式找到进程ID,然后在调试器中输入,以达到附加进程调试的目的。

这里我们仍然以notepad.exe为例进行附加调试,从简易调试器的输出结果来看,使用附加进程方式与创建进程方式并没有什么区别。

images/download/attachments/3440657/image2023-11-22_22-50-13.png

但是我们会发现使用创建进程调试时,触发DLL加载类型的调试事件。使用附加进程调试时,也同样的会出现DLL加载类型的调试事件

按照我们正常对于进程创建的理解,在创建进程后就已经完成了DLL加载,为什么在附加时又进行了DLL的加载呢?为了解决这个问题,我们需要来看一下ntoskrnl模块中的NtDebugActiveProcess函数。

虚构的假消息

当我们进入NtDebugActiveProcess函数时,我们可以观察到在将被调试进程与调试对象关联之前,会调用一个名为_DbgkpPostFakeProcessCreateMessages的函数。

从字面意思理解,这个函数的目的很明显,就是向调试器发送一些虚构的假消息。并且此时,被调试进程还未与调试对象关联,因此调试器无法接收来自被调试线程的任何消息。

进入_DbgkpPostFakeProcessCreateMessages函数后,我们可以发现与线程相关的虚假消息以及与模块相关的虚假消息都会被发送给调试器。显然,当我们通过附加进程的方式建立调试关系时,看到的DLL加载调试事件的情况实际上是由_NtDebugActiveProcess函数发送给调试器的虚构的假消息

images/download/attachments/3440657/image2023-11-22_23-3-42.png

调试器之所以能够观察到被调试进程加载的DLL模块,主要是因为在每个模块加载时,调试事件采集函数会先采集这些事件,并将其发送给调试器。

通过附加进程的方式建立调试关系时,由于无法观察到进程初始化时加载的DLL模块。因此,在执行NtDebugActiveProcess函数时,加入了这些虚假消息,希望为调试器提供必要的信息。

但很显然这些虚假消息并不可靠,因为它们主要依赖于PEB.Ldr中的三个链表(很容易被修改),这些链表以不同的顺序存储了该进程加载的所有模块。

images/download/attachments/3440657/image2023-11-22_23-9-3.png

异常的处理流程

在之前异常(用户异常的分发与内核异常的分发过程)章节的学习中,我们分析了KiDispatchException函数的执行流程。因此,我们知道在异常分发时,首先会检查调试器是否存。那么本文将在存在调试器和不存在调试器的情况下,验证异常分发的流程。

调试器下的异常分发

在异常章节的学习中,我们通过分析KiDispatchException函数知道在异常分发时会检查一下是否有内核调试器,但在当时我们并没有学习软件调试的,因此在这里我们可以加入调试来分析一下有无调试器时异常的分发流程。

如下代码所示我们构建一个除零异常,直接执行的话,正常流程就会进入_except分支执行代码:

#include "stdio.h"
 
int main()
{
int x = 100;
int y = 0;
 
_try
{
int a = x / y;
printf("Error");
}
_except(1)
{
printf("Except");
}
 
getchar();
 
return 0;
}

images/download/attachments/3440657/image2023-11-29_22-16-58.png

接着我们可以通过OD来调试该可执行文件,这里需要注意的是我们需要将OD设置中的忽略除零异常给取消勾选,如果勾选上,OD调试器就会在遇到除零异常时执行ContinueDebugEvent,使得被调试进程继续执行。

images/download/attachments/3440657/image2023-11-29_22-22-47.png

接着我们来调试,当调试器断点到除零异常时,就会发现无论我们怎么F9,断点依然停留在这里,这是因为调试器并没有处理这个异常。

images/download/attachments/3440657/image2023-11-29_22-27-33.png

那我们就需要在这里手动修改被除数0为1,然后再次F9,就会顺利的执行_try分支内的代码。

images/download/attachments/3440657/image2023-11-29_22-41-40.png

最后一道防线与二次分发

我们知道无论是进程的入口线程还是另起的线程,都会被添加的异常处理函数给处理掉,这是因为操作系统添加了一道最后的防线,其伪代码如下:

__try
{
}
__except(UnhandledExceptionFilter(GetExceptionInformation())
{
//终止线程
//终止进程
}

如果UnhandledExceptionFilter函数返回为0,即EXCEPTION_CONTINUE_SEARCH,那就真的是找不到对应的异常处理程序了,这种情况下只有在程序被调试时才会存在。

因此我可以将除零异常代码中的_except(1)修改为_except(0),表示没有对应的异常处理程序,也不去注册。我们再通过OD来调试会发现,即使我们忽略了除零异常,也仍然会在异常处断点。这是因为在第一次的异常分发时VEH、SEH、调试器都没有处理异常,最后一道防线中进入UnhandledExceptionFilter函数,在该函数内检测到此时存在调试器,就会进行第二次的异常分发,也就是给到调试器。

images/download/attachments/3440657/image2023-11-29_22-53-11.png

而如果没有调试器,正常打开该程序时,由于没有被调试,最后一道防线会查询当前是否有通过SetUnhandledExceptionFilter函数去注册异常处理函数,如果有就调用,没有的话Windows就会弹出窗口让用户选择终止程序还是启动即时调试器。

images/download/attachments/3440657/image2023-11-29_22-53-54.png

总结

一张图来总结包含了调试器的异常处理流程。

images/download/attachments/3440657/image2023-11-29_23-37-8.png

三大断点

所谓调试,其实就是在被调试进程中想法设法的去触发异常,当异常产生后,由调试器来接管异常。有三种触发异常的方法:软件断点、内存断点、硬件断点

软件断点

软件断点就是我们所熟悉的INT 3指令,软件断点的实现就是将下断点的地址处硬编码指令修改为0xCC。

这里我们可以先使用OD随便一处下断点,然后等程序停在对应位置,如下图所示指令并没有被修改为INT 3,这是因为OD为了更好的用户体验展示的时候保留了原指令。

images/download/attachments/3440657/image2023-11-30_20-6-44.png

实际上我们通过其他根据来看,例如下图所示的CE,对应地址处的指令确实变成了INT 3。

images/download/attachments/3440657/image2023-11-30_20-8-25.png

执行流程

触发软件断点的过程,实际上就是CPU异常分发的过程:CPU检测到异常(INT 3指令),查询中断描述符表,找到对应的中断处理函数(3号),中断处理函数内部会调用CommonDispatchException函数,在CommonDispatchException函数内部又会调用KiDispatchException函数。

这个流程我们在异常章节的学习中就已经了解了,那现在我们进入KiDispatchException函数内,之前在异常章节的学习时分析过这个函数,这里我们仅文字描述一下流程,不再重复的截图解释。

由于当前异常来自用户空间,所以我们直接看用户异常的处理流程,这里不管有没有内核调试器,或内核调试器函数返回结果为0,都会跳转到如下图所示的代码片段,然后调用_DbgkForwardException函数,将异常发送给3环调试器。

images/download/attachments/3440657/image2023-11-30_20-24-19.png

在_DbgkForwardException函数内,最终会调用_DbgkpSendApiMessage函数,这个函数我们在“调试事件的采集”篇的学习中已经了解,它的作用就是将已创建的调试事件发送到调试对象的事件链表中。

images/download/attachments/3440657/image2023-11-30_20-32-51.png

进入_DbgkpSendApiMessage函数,我们会发现它立刻就判断了传递过来的第二个参数是否为0,如果不为0,则会调用_DbgkpSuspendProcess函数将当前进程(被调试进程)内除当前线程外的其他线程挂起,当前例子中的INT 3引起的异常就会被挂起。

images/download/attachments/3440657/image2023-11-30_20-38-35.png

在挂起后,调试事件会被发送给调试对象,并在调试循环中被调试器提取。调试器将使用异常调试事件的结构体,列出相关信息,例如当前寄存器的值和内存情况。然后,调试器的用户可以对这些信息进行处理。

总体流程可以参考下图:

images/download/attachments/3440657/image2023-11-30_20-49-7.png

实验代码

刚刚我们是从一个被调试器角度了解软件断点的执行流程,那么从调试器角度又如何去实现软件断点,这值得我们去关注。首先在这里,为了实现软件断点功能,需要手动编写一个SetInt3BreakPoint函数,具体实现如下代码所示,我们可以在处理创建进程调试事件时,下该断点:

VOID SetInt3BreakPoint(PCHAR pAddress)
{
CHAR cInt3 = 0xCC;
// 1. 备份原始代码
ReadProcessMemory(hDebugeeProcess, pAddress, &OriginalCode, 1, NULL);
// 2. 设置软件断点
WriteProcessMemory(hDebugeeProcess, pAddress, &cInt3, 1, NULL);
}

接着我们需要一个INT 3软件断点的处理函数,下面仅列出相关代码,其每一步的意义如下:

  1. 由于软件断点会修改原指令,因此在重新执行之前需要恢复原硬编码指令。在这里判断了当前的INT 3指令是否为系统断点,如果是系统断点,则无需修复。IsSystemInt3函数需要自行实现。如果不是系统断点则通过调试事件来获取对应的异常地址,然后将原指令写入回去。

  2. 显示断点地址,便于使用者更清晰的知道当前行为所在位置。

  3. 获取线程的上下文环境,一旦获得了线程的上下文环境,就可以获取当前状态下各个寄存器的值。

  4. 修复EIP寄存器的值,是对于不同类型的断点,在断点触发后,EIP寄存器的位置会有所不同。对于软件断点IN T3来说,断点触发后,EIP位于原始地址的下一个字节位置,因此需要将EIP减去1来修复它。

  5. 显示反汇编代码,在常规调试器中,能够实时查看程序的反汇编代码至关重要。因此,在触发断点后,至少要显示断点周围的反汇编代码。用户知道的信息越多自然对调试就越有帮助。

  6. 等待用户命令,调试器的最主要的作用就是可以对代码进行调试,包括但不限于单步执行、逐行执行、继续执行等操作。在这里,通过一个循环来等待用户执行命令,如果用户没有执行命令,就一直等待下去。WaitForUserCommand函数需要自行实现,在后续单步学习时候会了解到。

BOOL Int3ExceptionProc(EXCEPTION_DEBUG_INFO* pExceptionInfo)
{
BOOL bRet = FALSE;
CONTEXT Context;
 
// 1. 如果是系统断点,不需要修复INT3
if (IsSystemInt3(pExceptionInfo))
{
return TRUE;
}
else
{
WriteProcessMemory(hDebugeeProcess, pExceptionInfo->ExceptionRecord.ExceptionAddress, &OriginalCode, 1, NULL);
}
// 2. 显示断点位置
printf("INT3断点地址: 0x%p \n", pExceptionInfo->ExceptionRecord.ExceptionAddress);
 
// 3. 获取线程上下文
Context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hDebugeeThread, &Context);
 
// 4. 修复EIP
Context.Eip--;
SetThreadContext(hDebugeeThread, &Context);
 
// 5. 显示反汇编
 
// 6. 等待用户命令
while (!bRet)
{
bRet = WaitForUserCommand();
}
 
return bRet;
}

内存断点

软件断点修改的是指令,内存断点修改的是物理页的属性。例如当某一地址,下了内存断点,当该地址被读取、写入时都会断下来。调试器进程通过调用VirtualProtectEx函数来跨进程对被调试进程物理页属性进行修改。

VirtualProtectEx函数的语法格式如下,其中需要关注的是flNewProtect参数,该参数取决定了修改的物理页的新属性:

BOOL VirtualProtectEx(
HANDLE hProcess, // 要修改内存的进程句柄
LPVOID lpAddress, // 要修改内存的起始地址
SIZE_T dwSize, // 页区域大小
DWORD flNewProtect, // 新物理页属性
PDWORD lpflOldProtect // 原物理页属性,用于保存改变前的属性
)

这边我们需要注意的是,由于VirtualProtectEx函数修改的并不是某一地址的属性,而是地址所在物理页的属性,因此在对应物理页内的地址都有可能出现异常,所以就需要调试器去判断接收到的调试事件中的对应地址是否是下内存断点的地址,如果不是的话就应该放行

执行流程

下完内存断点后,当被调试进程试图访问或写入被修改属性后的物理页时,就会触发页异常。然后就进行了异常的处理流程:

  1. CPU访问或写入访问或写入被修改属性后的物理页,触发页异常;

  2. 查IDT表找到对应的中断处理函数(这里页异常是0x0E号中断);

  3. 调用CommonDispatchException函数;

  4. 调用KiDispatchException函数;

  5. 进入DbgkForwardException函数,收集调试事件并发送给调试对象;

  6. 最后交给调试器来处理。

这个流程其实跟软件断点的流程差不多,因此就不必再重复的去跟进代码了。

实验代码

从调试器角度,我们第一步仍然是要下断点,在处理创建进程调试事件时,选择要下的断点类型,以下只是示例代码,具体的要根据不同的需求进行分支的判断和调整:

VOID SetMemBreakPoint(PCHAR pAddress)
{
DWORD dwOriginalProtect; // 保存原物理页属性
SIZE_T dwSize = 1;
// 设置访问断点
VirtualProtectEx(hDebugeeProcess, pAddress, dwSize, PAGE_NOACCESS, &dwOriginalProtect);
// 设置写入断点
VirtualProtectEx(hDebugeeProcess, pAddress, dwSize, PAGE_EXECUTE_READ, &dwOriginalProtect);
}

当异常触发之后,调试器会收到异常调试事件,因此就需要判断调试事件的类型,异常类型的调试事件结构体为_EXCEPTION_DEBUG_INFO(调试循环时即可获取:_DEBUG_EVENT.u.Exception),该结构体的第一个成员为ExceptionRecord,其也是一个结构体EXCEPTION_RECORD:

typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
UINT_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;

在该结构体中ExceptionCode成员表示异常的类型,类型有很多种,如下图所示,STATUS_ACCESS_VIOLATION(0xC0000005,表示访问违例)这就是内存断点引发的异常类型。

images/download/attachments/3440657/image2023-12-1_17-45-54.png

因此我们可以通过switch分支来判断异常类型,从而进一步的处理相关异常引起的断点。

swtich(pExceptionInfo->ExceptionRecord.ExceptionCode)
{
// 软件断点(异常断点)
case STATUS_BREAKPOINT:
bRet = Int3ExceptionProc(pExceptionInfo);
break;
// 内存断点(访问违例)
case STATUS_ACCESS_VIOLATION:
bRet = AccessExceptionProc(pExceptionInfo);
break;
}

接着我们就需要处理内存断点了,如下代码所示,这里的逻辑跟软件断点的处理逻辑差不多,唯一的区别就是第一步获取异常信息,在ExceptionRecord结构体中,有一个数组ExceptionInformation,根据MSDN Library的说明,该数组第一个成员用于表示异常的原因(值为0表示有线程试图读取,值为1表示有线程试图写入)。该数组第二个成员用于标识导致异常的虚拟地址。由于内存断点是针对整个物理页设置的,因此触发异常的位置可能不是我们设置断点的实际位置。因此,在此处通过第二个成员对地址进行检查,以确定是否为我们设置断点的位置。如果不是,则直接继续执行,如果是,则进行相应处理。

BOOL AccessExceptionProc(EXCEPTION_DEBUG_INFO* pExceptionInfo)
{
BOOL bRet = FALSE;
CONTEXT Context;
DWORD dwAccessFlag;
DWORD dwAccessAddr;
DWORD dwUnused;
 
// 1. 获取异常信息,修改内存属性
dwAccessFlag = pExceptionInfo->ExceptionRecord.ExceptionInformation[0];
dwAccessAddr = pExceptionInfo->ExceptionRecord.ExceptionInformation[1];
printf("内存断点 %x 0x%p\n", dwAccessFlag, dwAccessAddr);
VirtualProtectEx(hDebugeeProcess, (PVOID)dwAccessAddr, 1, dwOriginalProtect, &dwUnused);
// 2. 获取线程上下文
Context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hDebugeeThread, &Context);
 
// 3. 修复EIP,内存断点不需要修复EIP,软件断点需要
 
// 4. 显示反汇编
 
// 5. 等待用户命令
while (bRet == FALSE)
{
bRet = WaitForUserCommand();
}
 
return bRet;
}

硬件断点

对于软件断点,可以使用CRC校验来进行检测;对于内存断点,可以创建一个线程来不断刷新PTE的属性,以防止其被修改。而本篇要介绍的硬件断点比较难以防范,因为其不依赖于修改被调试进程的数据,所以也值得我们深入学习。

硬件断点的实现需要使用调试寄存器DR0到DR7,它们的结构如下:

Dr0-3用于设置硬件断点,也就是存储对应的线性地址,由于只有4个断点寄存器,所以最多只能设置4个硬件调试断点。Dr4-5是保留的,我们可以不用看。

Dr7是最重要的寄存器,它控制着断点的各类属性,有很多个位,每位的意思如下(0-3都是与Dr0-3为对应关系,如Dr0对应L0、G0、LEN0、R/W0):

  1. L0/G0 - L3/G3:控制Dr0-3寄存器是否有效,确定是局部(Lx)还是全局(Gx)断点。每次异常触发后,Lx都被清零,Gx不清零。当你向Dr0-3存储线性地址后,Lx或Gx必须有一个为1,才会触发。

  2. 断点长度(LENx):00(1字节),01(2字节),11(4字节)。

  3. 断点类型(R/Wx):00(执行断点),01(写入断点),11(访问断点)。

images/download/attachments/3440657/image2023-12-1_18-9-58.png

硬件调试断点产生的异常是STATUS_SINGLE_STEP(单步异常)。除了硬件断点外,当Eflags的TF标志位置为1时,也会产生单步异常。Dr6寄存器的作用是确定产生的是哪种类型的单步异常。当B0-3中有值时,可以确定是某个硬件断点触发了单步异常。如果B0-3的值都为空,那么说明是由Eflags的TF标志位为1引起的单步异常。

在设置断点时,我们需要将要断点的线性地址写入Dr0-3中的任意一个寄存器。当CPU执行到该线性地址时,如果发现与调试寄存器中的值相同,就会触发断点异常,进而暂停执行。需要注意的是,设置断点是修改当前线程Context中记录的调试寄存器的值,不同线程之间是相互隔离的,因此设置硬件断点不会影响其他线程的执行。

执行流程

下完硬件断点后,当CPU检测调试寄存器(Dr0-3)到执行到对应的线性地址,就会触发单步异常。然后就进行了异常的处理流程:

  1. CPU检测调试寄存器(Dr0-3)到执行到对应的线性地址,就会触发单步异常;

  2. 查IDT表找到对应的中断处理函数(这里是0x01号中断);

  3. 调用CommonDispatchException函数;

  4. 调用KiDispatchException函数;

  5. 进入DbgkForwardException函数,收集调试事件并发送给调试对象;

  6. 最后交给调试器来处理。

这个流程其实跟软件断点的流程差不多,因此就不必再重复的去跟进代码了。

实验代码

硬件断点与软件断点或内存断点有所不同。软件断点和内存断点可以在OEP(原始执行点)处设置断点,但是硬件断点不能在OEP处设置断点,因为此时主线程还未创建出来(参考使用CreateProcess函数创建调试关系时所产生的调试事件)。

硬件断点是基于线程的,没有线程的情况下无法触发硬件断点。因此,可以采用另一种方法,在OEP处设置一个软件断点。当软件断点被触发时,将进入软件断点处理函数,这样就会创建一个线程,因此我们可以在软件断点处理函数中来设置硬件断点(可以在OEP+1处设置),这样就可以触发硬件断点。硬件断点的实现如下所示(省略了软件断点处的代码):

VOID SetHardBreakPoint(PVOID pAddress)
{
CONTEXT Context;
// 获取线程上下文
Context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hDebugeeThread, &Context);
// 设置断点位置(使用Dr0作为断点寄存器)
Context.Dr0 = (DWORD)pAddress;
Context.Dr7 |= 1;
// 设置断点长度
Context.Dr7 &= 0xfff0ffff;
// 设置线程上下文
SetThreadContext(hDebugeeThread, &Context);
}

由于单步异常存在两种情况,因此在处理函数内部需要进行判断,以确定是否是由硬件断点引起的异常。

BOOL SingleStepExceptionProc(EXCEPTION_DEBUG_INFO* pExceptionInfo)
{
CONTEXT Context;
BOOL bRet = FALSE;
// 1. 获取线程上下文
Context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hDebugeeThread, &Context);
// 2. 判断是否是硬件断点导致的异常
if (Context.Dr6 & 0xF) // B0-3不为空
{
// 显示硬件断点信息
printf("硬件断点 %d 0x%p\n", Context.Dr7 & 0x00030000, Context.Dr0);
// 移除断点
Context.Dr0 = 0;
Context.Dr7 &= 0xfffffffe;
}
else
{
// 显示单步异常信息
printf("单步异常 0x%p\n", Context.Eip);
// 清除单步标志
Context.EFlags &= 0xfffffeff;
}
// 3. 等待用户命令
while (!bRet)
{
bRet = WaitForUserCommand();
}
return bRet;
}

单步步入和步过

所谓单步步入和步过,实际上就是OD调试器的快捷键F7和F8,单步步入是可以每一行指令进行单步执行,单步步过也是如此,不同的是单步步过不会进入调用函数的内部(CALL指令)

步入

想要实现单步步入有很多种方式,例如我们可以将每一行要执行的指令下一地址的指令中设为INT 3,然后单步执行后就恢复再以此类推去设置。但是这样的方式很笨拙,因此Intel在设计CPU时考虑到了调试程序的必要性,就在EFlags寄存器中设置了一个TF位。TF位我们可以称之为单步标志,当TF位被置1时,每执行完一行指令就会产生一个异常

images/download/attachments/3440657/image2023-12-2_14-54-15.png

单步异常的处理流程与硬件断点一致,都是CPU检测到异常后通过IDT表找到0x01号中断函数进行处理等等,因此此处就不再赘述。

实验代码

单步步入(异常)的断点实现也很简单,就是获取线程上下文,就得到了相关的寄存器,接着将EFlags寄存器的TF位置1,然后再将上下文设置回去。

VOID SetSingleStep()
{
CONTEXT Context;
Context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
GetThreadContext(hDebugeeThread, &Context);
// 将EFlags寄存器的TF位置1
Context.EFlags |= 0x100;
SetThreadContext(hDebugeeThread, &Context);
}

由于单步步入与硬件断点触发的异常都属于单步异常,因此这两种异常可以使用同一个处理函数,只需对Dr6的值进行判断即可区分,这部分代码与硬件断点的实现代码一致,所以也不再赘述。

步过

想要实现单步步过,可以通过两种方式:硬件断点、软件断点。实现的原理是在遇到CALL指令(包括多种类型)后,计算当前指令的长度,并在当前EIP+当前指令长度的位置设置断点,即CALL指令的下一行,然后继续执行,从而实现单步步过的效果。