初探Windows用户态调试机制
我们在感叹Onlydbg强大与便利的同时,是否考虑过它实现的原理呢?
作为一个技术人员知其然必知其所以然,这才是我们追求的本心。
最近在学习张银奎老师的《软件调试》,获益良多。熟悉Windows调试机制,对我们深入理解操作系统以及游戏保护的原理有着莫大好处。
0X01
初探调试原理
调试系统的实现思路如图所示:
调试器与被调试程序建立联系,程序像调试器发送调试信息,调试器暂停程序处理完调试信息后再恢复程序运行,如此周而复始。
下面我们看看如何用操作系统提供的API去实现一个简单的调试器。
//启动要调试的进程或挂接调试器到已运行的进程上 CreateProcess(..., DEBUG_PROCESS, ...) or DebugActiveProcess(dwProcessId) DEBUG_EVENT de; BOOL bContinue = TRUE; DWORD dwContinueStatus; while(bContinue) { bContinue = WaitForDebugEvent(&de, INFINITE); switch(de.dwDebugEventCode) { ... default: { dwContinueStatus = DBG_CONTINUE; break; } } ContinueDebugEvent(de.dwProcessId, de.dwThreadId, dwContinueStatus); }
在调试器开始调试的时候,会启动被调试程序的新进程或者挂接(attach)到一个已运行进程上,此时Win32系统会启动调试接口的服务器端;然后调试器调用WaitForDebugEvent函数等待调试服务器端的调试事件被引发;调试器根据调试事件进行相应的处理;最后调用ContinueDebugEvent函数请求调试服务器继续执行被调试进程,以等待并处理下一个调试事件。
0X02
抽茧剥丝看调试机制
要想深入了解Windows调试机制,对着三个函数的深入分析是必不可少的。
1.DebugActiveProcess
BOOL WINAPI DebugActiveProcessSelf( _In_ DWORD dwProcessId ) { NTSTATUS status; HANDLE TargProcessHandle; status = DbgUiConnectToDbg(); //DebugObject if (!NT_SUCCESS(status)) { BaseSetLastNTError(status); return false; } TargProcessHandle = GetTargProcessHandle(dwProcessId); if (TargProcessHandle == 0) { return false; } //调试目标进程 status = DbgUiDebugActiveProcess(TargProcessHandle); //不管调试是否成功都关闭目标进程句柄 ZwClose(TargProcessHandle); if (!NT_SUCCESS(status)) { BaseSetLastNTError(status); return false; } return true; }
DbgUiConnectToDbg函数内部主要调用ZwCreateDebugObject创建一个调试对象,并将调试对象句柄保存在调试器当前线程的TEB结构的DbgSsReserved[1]中。
其中TEB可以通过FS:[0x18]获得,DbgSsReserved字段在不同操作系统版本中也不相同,在Win732位中处于TEB结构的0xF20中。那么我们可以通过一下汇编得到DbgSsReserved。
__asm{ push eax mov eax,FS:[0x18] lea eax,[eax+0xF20] mov DbgSsReserved,eax pop eax }
那么到底什么是调试对象呢?
调试任务的顺利进行在于调试器与调试程序两者间的事件交互,一开始的图里已经很好的表示了。既然是两个进程间的交互,那么必定涉及进程间通信的问题,我在Windows进程通信中已经总结的很明白了,进程间通信靠的是所有进程共享高2G内核空间中的内核对象,
比如事件对象,管道对象等。由此可以推断出调试对象就是调试器与被调试程序间通讯的桥梁! 调试对象保存在调试器TEB线程环境变量块的DbgSsReserved[1]中,保存在被调试进程的DebugPort字段中。(这点下文做详细分析)所以判断一个进程是否被调试可
以看这个进程的DebugPort字段。游戏保护其中的一种保护手段就是通过不断抹除DebugPort,从而达到反调试的目的,所以我们发现用OD无法附加游戏,当然我们可以通过端口移位的方法绕过这种保护方法,这里暂且不做讨论。
GetTargProcessHandle函数主要就是运用ZwOpenProcess函数获得了下进程句柄,在此不作分析,我们下面主要看看最后这个DbgUiDebugActiveProcess函数。
NTSTATUS DbgUiDebugActiveProcess(HANDLE hTargProcess) { NTSTATUS status; HANDLE hDebugObject; hDebugObject = (GetThreadDbgSsReserved())[1]; status = ZwDebugActiveProcess(hTargProcess,hDebugObject); if (!NT_SUCCESS(status)) { return status; } status = DbgUiIssueRemoteBreakin(hTargProcess); //创建远程线程 设置远程断点 if (!NT_SUCCESS(status)) { DbgUiStopDebugging(hTargProcess); } return status; }
我们先来看看DbgUiIssueRemoteBreakin函数
这个函数比较简单的主要作用是创建远程线程下远程断点,如果没有断点进行拦截,那还怎么调试。
到此DebugActiveProcess函数在Ring3下分析的就差不多了,剩下我们可以看见把被调试程序和调试对象作为参数调用系统函数ZwDebugActiveProcess
我结合上面所说的是不是很清晰这个系统调用在内核做了些什么事情呢? 显然在内核把调试对象放到被调试进程的Debugport字段中去了!
但是ZwDebugActiveProcess在内核中所做的事情可不止这么一点哦,这个函数主要做三件事:
(1)取得被调试进程EPROCESS和调试对象的指针。
(2)向调试对象发送杜撰的调试事件。(当调试器附加到一个已经运行的进程时,为了向调试器报告以前发生的但目前仍有意义的调试事件,调试子系统会“捏造”一些调试事件来模拟过去的调试事件,这样的调试消息被称为杜撰的调试消息)。
(3)调用DbgSetprocessDebugObject将调试对象设置到被调试进程的Debug字段,并调用DbgkpmarkprocessPeb设置PEB中的BeingDebugged字段。
我觉得学习新知识就应该从大体入手,千万不能太抠细节,在有了清晰的框架后再逐渐了解细节的实现问题。看到这里肯定有了很多疑问,比如调试事件结构是什么,它又是如何获得的,又是怎么通过调试对象进行传递的?下面我们再来一探究竟。
调试事件的采取
首先我们应该明白什么算调试事件:被调试进程创建了一个进程、创建了一个线程、加载了一个模块......这些都是调试事件,那么调试器又是如何知道的呢?
在操作系统中有一组Dbgk开头的一组函数它们就是采集例程。以创建线程为例,我们看一下调试消息传递过程。
当我们调用CreateThread函数时,函数建立了线程必要的内核对象和数据结构,做了必要的登记后,最终会调用PspUserthreadStartup函数,准备启动该线 程。为了支持调试,PspUserThreadStartup函数总是会调用DbgkCreateThread,以便采集调试事件。DbgkCreateThread函数会检查自己的DebugPort字段是否为空来判断自己是否被调试,如果被调试,则采集调试信息调用DbgkpSendApiMessage函数向DebugPort发送消息。同理可得LoadLibrary会调用系统函数NtMapViewOfSection然后会调用采集函数DbgkMapViewOfMapSection,最后判断自己是否被调试决定是否采集调试事件来调用DbgkpSendApiMessage。
我们看到采集调试事件中最后都是调用DbgkpSendApiMessage,那么这个函数到底做了些什么呢?
我们先来看看这个函数的定义
NTSTATUS DbgkpSendApiMessage(
IN OUT PDBGKM_APIMSG ApiMsg,
IN PVOID Port,
IN BOOLEAN SuspendProcess)
其中ApiMsg用来描述消息的,Port用来指定要发送的端口,大多数时候就是EPROCESS结构的DebugPort字段的值,偶尔是进程中的异常端口,即ExceptionPort字段。
//消息结构 typedef struct _DBGKM_APIMSG { PORT_MESSAGE h; //+0x0 DBGKM_APINUMBER ApiNumber; //+0x18 NTSTATUS ReturnedStatus; //+0x1c union { DBGKM_EXCEPTION Exception; //异常 DBGKM_CREATE_THREAD CreateThread; //创建线程 DBGKM_CREATE_PROCESS CreateProcessInfo; //创建进程 DBGKM_EXIT_THREAD ExitThread; //线程退出 DBGKM_EXIT_PROCESS ExitProcess; //进程退出 DBGKM_LOAD_DLL LoadDll; //映射DLL DBGKM_UNLOAD_DLL UnloadDll; //反映射DLL } u; //0x20 } DBGKM_APIMSG, *PDBGKM_APIMSG;
其中DBGKM_APINUMBER是个枚举常量。
//枚举类型,指定是哪种事件 typedef enum _DBGKM_APINUMBER { DbgKmExceptionApi, DbgKmCreateThreadApi, DbgKmCreateProcessApi, DbgKmExitThreadApi, DbgKmExitProcessApi, DbgKmLoadDllApi, DbgKmUnloadDllApi, DbgKmMaxApiNumber } DBGKM_APINUMBER;
上面说道DbgkpSendApiMessage把调试消息发送给调试对象,那么调试对象又是如何管理这些调试消息的呢?
//调试对象 typedef struct _DEBUG_OBJECT { KEVENT EventsPresent; FAST_MUTEX Mutex; LIST_ENTRY EventList; union { ULONG Flags; struct { UCHAR DebuggerInactive:1; UCHAR KillProcessOnExit:1; }; }; } DEBUG_OBJECT, *PDEBUG_OBJECT;
这个就是调试对象的数据结构,里面可以清晰的看见有个LIST_ENTRY的双向链表。
到这里可能已经有点迷糊了,我们需要个图来整理整理。
这里需要注意的是有个KEVENT的内核事件对象,我们回忆下应用层有个WaitForDebugEvent函数在阻塞着,这个事件就是通知调试器有调试事件到达。
2.WaitForDebugEvent
BOOL WINAPI WaitForDebugEvent(
_Out_ LPDEBUG_EVENT lpDebugEvent,
_In_ DWORD dwMilliseconds
)
WaitForDebugEvent用于等待和接收调试事件,收到调试事件后,调试器便根据事件的类型(事件ID)来分发和处理,并根据情况决定是否要通知用户并进入交互式调试。在处理调试事件的过程中,被调试进程时处于挂起状态的。处理调试事件后,调试器调用ContinueDebugEvent将处理结果回复给调试子系统。到这里细心的似乎已经发现这个调试事件和内核中的调试事件的结构不一样。
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;
在内核中调试事件使用DBGKM_APIMSG的结构来描述。在发送调试器时,调试API使用的是DEBUG_EVENT结构。所以之间必定有一个转换过程。简单的说,DBGKM_APIMSG转换成DBGUI_WAIT_STATE_CHANGE然后在转换成DEBUG_EVENT。
我们再来画张图整理一下
0X03 总结