Windows XP 用户态调试(二)调试器进程
1 调试事件循环
在上一篇文章中,已经了解了调试器如何与被调试进程建立调试会话(本文后续还会补充说明建立调试会话的细节),也了解了调试进程发送杜撰调试消息、被调试进程发送调试消息的过程。
本文将会研究调试器如何接收调试消息,并处理调试消息(在 0 环时叫做调试消息,在 3 环叫做调试事件)。
当调试会话建立好之后,调试器将进入如下的接收调试事件–处理调试事件–回复调试事件的循环,直到被调试进程终止或者调试器终止调试会话。
1 | DEBUG_EVENT DbgEvt; |
2 等待调试事件
2.1 WaitForDebugEvent函数定义
函数 WaitForDebugEvent
:等待正在调试的进程中发生调试事件。
1 | BOOL WaitForDebugEvent( |
注意:
- 该函数第一个参数是
OUT
,不像WaitForSingleObject(IN HANDLE hHandle, IN DWORD dwMilliseconds)
来等待某个可等待对象。 - 不要向在处理
WaitForDebugEvent
函数的线程插入 APC,该函数虽然会阻塞线程,但不是用来进行线程调度的。
调用 WaitForDebugEvent
会导致所在线程阻塞,直到有调试事件发生,或等待时间已到或发生错误才返回。这也是大多数调试器使用多线程的原因,可使用其他线程处理 UI 更新和用户对话。
2.2 DEBUG_EVENT结构
在Windows XP 用户态调试(一)被调试进程 3.5.3已经提到,DbgkpSendApiMessage
发送的调试消息为 DBGKM_APIMSG
,而 DbgkpQueueMessage
会将 DBGKM_APIMSG
扩展成 DEBUG_EVENT
(为了区分我重命名为DBGKM_DEBUG_EVENT
)并挂到调试对象 DEBUG_OBJECT.EventList
消息队列中。
结构 DEBUG_EVENT
在 0 环和 3 环同名,但是并不是同一个结构,在 3 环的结构如下:
1 | typedef struct _DEBUG_EVENT { |
其中 dwDebugEventCode
用来标识调试事件的类型(取值范围是一个联合体),联合体 u
则是由9种不同的事件详细信息构成。根据 dwDebugEventCode
的不同,决定了 u
中包含的结构,具体对应关系如下:
事件类型(dwDebugEventCode) | 值 | 说明 | 详细信息所使用的结构(u) |
---|---|---|---|
EXCEPTION_DEBUG_EVENT | 1 | 异常 | EXCEPTION_DEBUG_INFO |
CREATE_THREAD_DEBUG_EVENT | 2 | 创建线程 | CREATE_THREAD_DEBUG_INFO |
CREATE_PROCESS_DEBUG_EVENT | 3 | 创建进程 | CREATE_PROCESS_DEBUG_INFO |
EXIT_THREAD_DEBUG_EVENT | 4 | 线程退出 | EXIT_THREAD_DEBUG_INFO |
EXIT_PROCESS_DEBUG_EVENT | 5 | 进程退出 | EXIT_PROCESS_DEBUG_INFO |
LOAD_DLL_DEBUG_EVENT | 6 | 映射DLL | LOAD_DLL_DEBUG_INFO |
UNLOAD_DLL_DEBUG_EVENT | 7 | 卸载DLL | UNLOAD_DLL_DEBUG_INFO |
OUTPUT_DEBUG_STRING_EVENT | 8 | 输出调试信息 | OUTPUT_DEBUG_STRING_INFO |
RIP_EVENT | 9 | 内部错误 | RIP_INFO |
在 0 环的 DEBUG_EVENT
结构为:
1 | // 0环使用, _DBGKM_DEBUG_EVENT |
2.3 WaitForDebugEvent源码分析
函数 WaitForDebugEvent
主要有三个功能:
- 将调试进程
TEB.DbgSsReserved[1]
的调试对象句柄传到 0 环,调用KeWaitForSingleObject
等待调试对象的同步事件DebugObject->EventsPresent
。 - 当调试对象的同步事件有信号时,会先从调试对象消息队列的队头取出结构为
DBGKM_DEBUG_EVENT
的DbgkmDebugEvent
节点。然后对该消息节点再判断一次,满足条件就继续往下执行。将DBGKM_DEBUG_EVENT
结构的消息转换成DBGUI_WAIT_STATE_CHANGE
结构的消息,最后将该消息返回 3 环。 - 在 3 环将
DBGUI_WAIT_STATE_CHANGE
结构的消息转换成DEBUG_EVENT
结构的消息,并且根据消息类型对TEB.DbgSsReserved[0]
链表上结构为TMPHANDLES
的节点进行维护(上一篇2.4有讲解),方便ContinueDebugEvent
回复消息时使用。注意,该结构的内存是在当前进程堆栈上进行申请的,每次新建进程/线程时都会将新的TMPHANDLES
节点插入链表头。
1 | typedef struct _TMPHANDLES { |
关于 DBGUI_WAIT_STATE_CHANGE
结构:
1 | typedef struct _DBGUI_WAIT_STATE_CHANGE |
关于该函数详细流程分析:《Windows调试内幕系列一之Windows用户模式调试内幕》、《Windows内核学习笔记之调试(下)》、《内核用户模式调试支持(DBGK)》。
关于调试器、被调试进程的同步问题:
发送消息:
1 | // DbgkpQueueMessage |
接收消息:
1 | // WaitForDebugEvent |
回复被调试进程:
1 | // ContinueDebugEvent |
3 初始断点
上一章分析了调试器如何接收调试消息。我们每次不管是以新建子进程方式还是以附加方式调试一个程序,被调试程序每次都会断下来。
如下以新建子进程方式启动一个被调试程序:
1
2
3
4
5
6
7(c4c.370): Break instruction exception - code 80000003 (first chance)
eax=00241eb4 ebx=7ffda000 ecx=00000001 edx=00000002 esi=00241f48 edi=00241eb4
eip=7c92120e esp=0012fb20 ebp=0012fc94 iopl=0 nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00000202
*** ERROR: Symbol file could not be found. Defaulted to export symbols for ntdll.dll -
ntdll!DbgBreakPoint:
7c92120e cc int 3可以看到程序自动断在
ntdll!DbgBreakPoint
函数,查看此时的调用堆栈如下图(因为调试符号的原因,我的机器调用堆栈显示不正确,下图必须在Windows XP下调用堆栈才是如此,其他版本OS下ntdll
的处理是不同的):当以附加形式调试一个程序时:
1
2
3
4
5
6
7
8
9(360.bd0): Break instruction exception - code 80000003 (first chance)
*** ERROR: Symbol file could not be found. Defaulted to export symbols for C:\Windows\SYSTEM32\ntdll.dll -
ntdll!DbgBreakPoint:
00000000`77c4b1d0 cc int 3
0:001> k
Child-SP RetAddr Call Site
00000000`0027fc78 00000000`77cd93c8 ntdll!DbgBreakPoint
00000000`0027fc80 00000000`77c33865 ntdll!DbgUiRemoteBreakin+0x38
00000000`0027fcb0 00000000`00000000 ntdll!RtlUserThreadStart+0x25被调试进程依然断在
ntdll!DbgBreakPoint
处。
当一打开/附加一个程序时,断下来的地方是系统为我们设置的一个断点,这个断点成为初始断点。
下面将分别介绍调试器两种方式建立调试会话,初始断点的来源。
3.1 从调试器启动进程
这种方式是以新建子进程方式启动被调试进程,如调用 CreateProcess
函数,参数 dwCreationFlags
包含 DEBUG_PROCESS
、DEBUG_ONLY_THIS_PROCESS
等标志位。
3.1.1 简述进程创建过程
进程创建过程涉及到内核许多方面知识,其创建过程中调用的 API 以及调用过程是非常繁琐的。这里就根据《Windows内核情景分析 5.6 P306》与《64位CreateProcess逆向系列文章》简要总结 32 位进程创建的基本框架( 64 位系统创建进程暂不研究)。
第一阶段:在 3 环
创建当前进程的子进程:
1
2//对 dwCreationFlags 等参数进行检查并处理
kernel32!CreateProcess() -> CreateProcessInternal() -> NtCreateProcess() -> 进入 0 环- 参数检查时看
dwCreationFlags
是否包含DEBUG_PROCESS
、DEBUG_ONLY_THIS_PROCESS
等标志位,如果包含则会调用DbgUiConnectToDbg
等函数建立调试会话。 - 然后将创建者进程
TEB.TEB.DbgSsReserved[1]
设置为调试对象的句柄,新建的被调试进程的EPROCESS.DebugPort
设置为调试对象地址,同时设置被调试进程PEB.BeingDebugged = TRUE
。
- 参数检查时看
创建当前进程、其他进程的子进程使用:
nt!NtCreateProcess
。
第二阶段:在 0 环,创建内核中的进程对象
- 创建
EPROCESS
结构、创建进程句柄表、初始化进程地址空间、初始化EPROCESS
。 - 将 PE 文件映射到进程用户空间。
- 将系统 DLL 映射到进程用户空间(
ntdll.dll
这个 DLL 比较特殊,在初始化内核的此时就映射了)。 - 创建
PEB
、将刚创建的子进程句柄插入到当前进程的句柄表。
第三阶段:在 0 环,创建并初始化第一个线程
- 创建并初始化
ETHREAD
。 - 在子进程用户空间创建并初始化
TEB
。 - 将线程在用户空间的起始地址设置成:
X86:kernel32!BaseProcessStartThunk
(主线程),随后线程BaseThreadStartupThunk
。
X64:ntdll!RtlUserThreadStart
(所有线程)。 - 设置第一个线程被调度执行开始的地方为
KiThreadStartup
函数。 - 调用通知回调函数。
第四阶段:在 0 环,通知 Windows 子系统
由子进程的创建者进程向 csrss.exe
子系统服务进程发通知。 在《Windows内核情景分析 5.6 P308》详细阐述了Windows子系统。
至此 CreateProcess
的操作己经完成,CreateProcess
的调用者从该函数返回,就回到了应用程序或更高层的 DLL 中。这四个阶段都是立足于创建者进程的用户空间。
第五阶段:在 0、3 环,启动第一个线程
当第一个线程(主线程)被调度时,首先会执行内核中的 KiThreadStartup
,该函数把目标线程的运行级别从 DPC 级降低到 APC 级,然后调用内核函数 PspUserThreadStartup
,该函数内部会调用 DbgkCreateThread
向调试子系统通知新线程创建事件,调试子系统会向调试事件队列中放入一个进程创建事件,并等待调试器来处理和回复。
最后 PspUserThreadStartup
将 ntdll!LdrInitializeThunk
作为 APC 函数挂入到 APC 队列,只要主线程一准备进入用户空间就会先执行该 APC 函数。当该 APC 函数执行完之后还是会再次回到用户空间,开始执行 kernel32!BaseProcessStartThunk
或 kernel32!BaseThreadStartupThunk
函数。对于进程中的第一个线程是前者,对于后来的线程则是后者。然后就是比较熟悉的接着调用 mainCRTStartup-main
了。
至于用户程序所提供的(线程)入口,则是作为参数提供给这两个函数的,这两个函数都会使用该指针调用由用户提供的入口函数。
第六阶段:在 3 环调用 ntdll!LdrInitializeThunk
,用户空间的初始化和 DLL 的连接
用户空间的初始化和 DLL 的动态连接是由 APC 函数 LdrInitializeThunk
在用户空间完成的。
在第二阶段 ntdll.dll
已经被映射到了用户空间,但是其他的 DLL 尚未装入,应用软件与 DLL 之间也尚未建立动态连接。函数 LdrInitializeThunk
在映像中的位置是预定的,所以在进入这个函数之前并不需要连接。
3.1.2 初始断点位置
如上第五阶段,当一个线程被调度时,将从 0 环 KiThreadStartup
函数开始执行,然后调用 PspUserThreadStartup
函数(该函数先调用DbgkCreateThread
发送调试消息),然后该函数将 ntdll!LdrInitializeThunk
作为一个 APC 函数插入到当前线程,使其只要一进入 3 环就执行该 APC 函数。
当线程一进入到 3 环,执行的是第一个 APC 函数ntdll!LdrInitializeThunk
,初始断点也就在这里面了。
函数
LdrInitializeThunk
只是简单调用了LdrpInitialize
。1
2
3
4
5
6
7
8
9
10
11
12.text:7C921166 // __stdcall LdrInitializeThunk(x, x, x, x)
.text:7C921166 public _LdrInitializeThunk@16
.text:7C921166 _LdrInitializeThunk@16 proc near // DATA XREF: .text:off_7C923428↓o
.text:7C921166
.text:7C921166 arg_0 = dword ptr 4
.text:7C921166 arg_C = byte ptr 10h
.text:7C921166
.text:7C921166 lea eax, [esp+arg_C]
.text:7C92116A mov [esp+arg_0], eax
.text:7C92116E xor ebp, ebp
.text:7C921170 jmp _LdrpInitialize@12 // LdrpInitialize(x,x,x)
.text:7C921170 _LdrInitializeThunk@16 endp // sp-analysis failed然后
LdrpInitialize
函数接着调用_LdrpInitialize
(下划线)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19.text:7C93B057 // =============== S U B R O U T I N E =======================================
.text:7C93B057
.text:7C93B057 // Attributes: bp-based frame
.text:7C93B057
.text:7C93B057 // __stdcall LdrpInitialize(x, x, x)
.text:7C93B057 _LdrpInitialize@12 proc near // CODE XREF: LdrInitializeThunk(x,x,x,x)+A↑j
.text:7C93B057
.text:7C93B057 // FUNCTION CHUNK AT .text:7C9426ED SIZE 0000000A BYTES
.text:7C93B057
.text:7C93B057 mov edi, edi
.text:7C93B059 push ebp
.text:7C93B05A mov ebp, esp
.text:7C93B05C cmp _SecurityCookieInitialized, 0
.text:7C93B063 jz loc_7C9426ED
.text:7C93B069
.text:7C93B069 loc_7C93B069: // CODE XREF: LdrpInitialize(x,x,x)+769B↓j
.text:7C93B069 pop ebp
.text:7C93B06A jmp __LdrpInitialize@12 // _LdrpInitialize(x,x,x)
.text:7C93B06A _LdrpInitialize@12 endp在函数
_LdrpInitialize
中会判断PEB.Ldr == NULL?
(Win10 21H2用常量LdrpProcessInitialized
来判断),用来判断当前线程是否是进程的第一个线程,如果是则此时是新建进程正在启动的过程(在前面还会检查(PEB.ProcessParameters)RTL_USER_PROCESS_PARAMETERS.Flags
并设置标志位,该Flags
还与调试状态/反调试有关),就会调用LdrpInitializeProcess
函数。1
2
3
4
5
6.text:7C93AFD5 cmp [esi+_PEB.Ldr], ebx
.text:7C93AFD8 jz loc_7C94106C // Peb->Ldr == NULL 则当前线程是第一个线程
...
.text:7C94106C loc_7C94106C:
...
.text:7C94108A call _LdrpInitializeProcess@20函数
LdrpInitializeProcess
功能非常复杂,我们暂时只关注与初始断点相关的地方。1
2
3
4
5
6
7
8
9
10
11.text:7C9410B2 loc_7C9410B2: // CODE XREF: LdrpInitializeProcess(x,x,x,x,x)+BCC↓j
.text:7C9410B2 cmp [ebx+_PEB.BeingDebugged], 0
.text:7C9410B6 jnz loc_7C95E60D
...
.text:7C95E60D loc_7C95E60D: // CODE XREF: LdrpInitializeProcess(x,x,x,x,x)-4BB↑j
.text:7C95E60D call _DbgBreakPoint@0 // DbgBreakPoint()
.text:7C95E612 mov eax, [ebx+68h]
.text:7C95E615 shr eax, 1
.text:7C95E617 and al, 1
.text:7C95E619 mov _ShowSnaps, al
.text:7C95E61E jmp loc_7C9410BC这里会判断当前进程
PEB.BeingDebugged == FALSE?
,如果不等于就会调用DbgBreakPoint
函数。函数
DbgBreakPoint
代码如下。1
2
3
4.text:7C92120E _DbgBreakPoint@0 proc near
.text:7C92120E CC int 3 // Trap to Debugger
.text:7C92120F C3 retn
.text:7C92120F _DbgBreakPoint@0 endp可以看到,该函数就是简单的
0xCC
断点,此时产生的断点异常(0x80000003
)第一轮分发时就会调用DbgkForwardException
将异常消息发给用户调试器的调试端口(就是调试对象,异常端口是Windows2000 LPC通信用的)。
使用调试器打开一个进程断下来的位置,就是上面 5 个步骤分析的位置了。
该断点是系统提供的,被调试进程运行时只有在判断当前被调试,才会执行该初始断点。
3.2 附加进程
将调试器进程附加到已处于运行的进程中,使用 kernel32!DebugActiveProcess
,需要注意两点:
- 在 NT内核中,当试图通过
DebugActiveProcess
函数将调试器捆绑到一个创建时带有安全描述符的进程上时,将被拒绝。《加密与解密第四版 P378》 - 在
kernel32!DebugActiveProcess
调用ProcessIdToHlandle
函数,获得指定 ID 的进程句柄,这个函数内部会调用OpenProcess
函数, 进而调用NtOpenProcess
内核服务。在执行这一步时需要调用进程与目标进程有同样或更高的权限,否则这一步便会失败,调用GetLastError
(返回的错误码通常是0x5
,意思是Access is denied
,即访问被拒绝。如果调试器进程具有SE_DEBUG_NAME 权限
,那么它通常有权限调试系统内的任何进程。《软件调试第二版 卷2 P206》
在函数
ntdll!DbgUiDebugActiveProcess
中调用NtDebugActiveProcess
将调试对象和被调试进程关联起来之后,紧接着会调用DbgUiIssueRemoteBreakin
函数,该函数会向被调试进程插入一个远程线程,线程处理回调函数为DbgUiRemoteBreakin
。观察
DbgUiRemoteBreakin
函数代码如下。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.text:7C96FFE3 // =============== S U B R O U T I N E =======================================
.text:7C96FFE3
.text:7C96FFE3 // Attributes: bp-based frame
.text:7C96FFE3
.text:7C96FFE3 // __stdcall DbgUiRemoteBreakin(x)
.text:7C96FFE3 public _DbgUiRemoteBreakin@4
.text:7C96FFE3 _DbgUiRemoteBreakin@4 proc near
.text:7C96FFE3
.text:7C96FFE3
.text:7C96FFE3 ms_exc = CPPEH_RECORD ptr -18h
.text:7C96FFE3 // __unwind { // __SEH_prolog
.text:7C96FFE3 push 8
.text:7C96FFE5 push offset stru_7C970030
.text:7C96FFEA call __SEH_prolog
.text:7C96FFEF mov eax, large fs:_TEB.NtTib.Self
.text:7C96FFF5 mov eax, [eax+_TEB.ProcessEnvironmentBlock]
.text:7C96FFF8 cmp [eax+_PEB.BeingDebugged], 0 // 判断 PEB.BeingDebugged
.text:7C96FFFC jnz short loc_7C970007
.text:7C96FFFE test byte ptr ds:7FFE02D4h, 2 // KUSER_SHARED_DATA.KdDebuggerEnabled
.text:7C970005 jz short loc_7C970027
.text:7C970007
.text:7C970007 loc_7C970007:
.text:7C970007 // __try { // __except at loc_7C970020
.text:7C970007 and [ebp+ms_exc.registration.TryLevel], 0
.text:7C97000B call _DbgBreakPoint@0 // DbgBreakPoint()
.text:7C970010 jmp short loc_7C970023
.text:7C970010 // } // starts at 7C970007
.text:7C970010 // } // starts at 7C96FFE3
.text:7C970010 _DbgUiRemoteBreakin@4 endp
...
.text:7C970023
.text:7C970023 loc_7C970023:
.text:7C970023 // __unwind { // __SEH_prolog
.text:7C970023 or [ebp+ms_exc.registration.TryLevel], 0FFFFFFFFh
.text:7C970027
.text:7C970027 loc_7C970027:
.text:7C970027 push 0
.text:7C970029 call _RtlExitUserThread@4 // RtlExitUserThread(x)
.text:7C97002E nop
.text:7C97002F nop当被调试进程中的被插入执行
DbgUiRemoteBreakin
时,会判断PEB.BeingDebugged == NULL && KdDebuggerEnabled != 2
时就会执行DbgBreakPoint
从而触发断点断到调试器中,此时就出现了本章一开始贴的代码:1
2
3
4
5
6
7
8
9(360.bd0): Break instruction exception - code 80000003 (first chance)
*** ERROR: Symbol file could not be found. Defaulted to export symbols for C:\Windows\SYSTEM32\ntdll.dll -
ntdll!DbgBreakPoint:
00000000`77c4b1d0 cc int 3
0:001> k
Child-SP RetAddr Call Site
00000000`0027fc78 00000000`77cd93c8 ntdll!DbgBreakPoint
00000000`0027fc80 00000000`77c33865 ntdll!DbgUiRemoteBreakin+0x38
00000000`0027fcb0 00000000`00000000 ntdll!RtlUserThreadStart+0x25
总结:在 NtDebugActiveProcess
成功返回后,DbgUiDebugActiveProccss
会调用 DbgUiIssueRemoteBreakin
,目的是在远程进程中创建远程中断线程,使被调试进程中断到调试器中。以上操作都成功后,DebugActiveProcess
会返回真,通知调用进程已经成功建立调试对话。
接下来调试器便进入调试事件循环,开始接收和处理调试事件了。它首先会接收到一系列杜撰的调试事件,包括进程创建、模块加载等事件。然后收到远程中断线程产生的断点事件。调试器收到这一事件后,通常会停下来报告给用户。
4 异常与调试
在Windows XP 异常处理(二)异常分发中详细分析过,KernelMode/UserMode
产生的异常在 KiDispatchException
中都会有两次异常分发的机会,本节只针对 UserMode
的异常在有用户调试器的情况下进行分析。
- 第一轮分发:在
KiDispatchException
中先判断KiDebugRoutine == NULL?
,内核的调试不进行处理或处理失败,会直接调用DbgkForwardException
向用户调试器的调试端口发送异常信息。DbgkForwardException
判断如果DebugPort == NULL
则直接返回(用户调试器不存在),否则调用DbgkpSendApiMessage
发送异常调试消息,然后使进程被挂起。 - 顶层异常处理(最后一道防线):当用户第一次异常分发到用户时,如果用户没有注册相应的
VEH/SEH
,则系统会调用顶层的异常处理。顶层异常处理的过滤器函数UnhandledExceptionFilter
,该函数会调用NtQueryInformationProcess
来查询程序是否正在被调试,如果是则将异常返回到 0 环进行第二轮分发,如果不在被调试则会判断kernel32!BaseCurrentTopLevelFilter
链上是否有SetUnhandledExceptionFilter
注册的顶层异常处理函数。如果有则直接调用,否则就会弹出对话框让用户选择终止程序/启动即时调试器。 - 第二轮分发:对于用户异常,如果没有调试器是不会进行第二轮分发的。
5.1 实验:第一轮分发
本节写一个程序并通过编译器注册 SEH,分别在有/无调试器的状态下进行对比。
1 |
|
在不被调试的状态下,程序输出”SEH异常处理代码!“。
在被调试的状态下,程序首先断在初始断点处(注意该实验要用原版OllyDbg),然后
F9
运行,程序将会断在如下位置,无论如何按F9,都无法继续执行。原因是KiDispatchException检测到了调试器的存在,因此会先交予调试器处理。:修改方法有很多,可以修改堆栈上局部变量的除数(此时修改ECX寄存器无效,因为这个不是异常结构里Context.Ecx)。修改除数为1:
然后
F7
单步运行如下(此时EIP已经指向下一行了):F9
运行后输出 ”无法执行的代码!”。如果要让 OllyDbg 忽略这些异常,可以设置:Option-Debugging Option-Exceptions,勾选上整数除0异常。
此时重新运行程序就不会被调试器断在除 0 异常这里了。
4.2 实验:顶层异常处理与第二轮分发
将上一节的 _except(1)
修改为 _except(0)
(本异常处理块不处理该该异常,寻找其他异常处理块(不是异常处理器)),让其去找顶层处理的 SEH
,同时 OllyDbg 设置“忽略整数除0异常”。修改完后,可以看到,在忽略除零异常的情况下,还是会断在 idiv
这里,这是因为在第一次分发时调试器没有处理,然后分发到用户层,用户注册的SEH让找其他SEH,最后找到顶层异常处理的SEH,顶层过滤器检测到此时存在调试器,于是又发送了一次异常调试事件给调试器,即第二轮分发。如果这次还不处理,那么便会终止进程。
4.3 反调试:使用顶层处理
方法一原理:在程序中使用 SetUnhandledExceptionFilter
向 kernel32!BaseCurrentTopLevelFilter
链表注册一个顶层异常处理函数,然后在程序中故意触发一个异常,如果异常分发的过程顶层的异常处理函数得不到执行的话,此时就认为程序正在被调试了。
1 |
|
方法二原理:除了上面这种利用顶层处理函数的机制进行反调试的手段,还有一种,也是比较常规的,就是进程不断的给调试器发送异常调试事件,调试器也无法区分哪个调试事件是有用的,会一并接收,从而达到反调试的目的。
方法三:可移植性好,基于行为的方式:线程相互心跳检测。就是每个线程定时向其它线程发心跳,超过多少次超时就认为有调试行为。借鉴《由一道CTF对10种反调试的探究-评论区》。
4.4 异常处理流程回顾
5 回复调试消息
当调试器对调试消息进行处理后,因为当前的被调试进程还一直处于挂起的等待状态,调试器需要对消息处理结果进行反馈,并且还会告知要不要继续读取调试对象消息队列队头的消息。
调试消息回复调用 ContinueDebugEvent
函数:
1 | BOOL __stdcall ContinueDebugEvent( |
关于参数 dwContinueStatus
可以取如下三个取值:
DBG_CONTINUE(0x00010002)
:对于EXCEPTION_DEBUG_EVENT
异常事件,表示当前异常已完成处理。DBG_EXCEPTION_NOT_HANDLED(0x80010001)
:对于EXCEPTION_DEBUG_EVENT
异常事件,如果是第一次异常分发则会让异常分发到用户态去,如果是第二次异常分发,则会将被调试进程。DBG_REPLY_LATER(0x40010001L)
:此标志在Windows 10版本1507或更高版本中支持,让被调试进程对该异常消息重发一次。
对于非异常的消息,返回 DBG_CONTINUE/DBG_EXCEPTION_NOT_HANDLED
没有差异,都会让被调试线程继续运行。
返回值:如果成功返回非零,失败则返回零。要获取扩展错误信息,请调用GetLastError。
5.1 ContinueDebugEvent调用流程
函数 ContinueDebugEvent
会调用 ntdll!DbgUiContinue-->ntdll!NtDebugContinue
进入 0 环。
在 0 环找到已经被处理的位于队列头的调试消息,然后将其摘除。
如下两次设置信号,分别让调试器读取下一条调试消息,让被调试进程获得信号,恢复进程。
1
2KeSetEvent(&DebugObject->EventsPresent, 0, FALSE);
KeSetEvent(&DebugEvent->ContinueEvent, 0, FALSE);现在整体来看一下,
DebugObject->EventsPresent
和DebugEvent->ContinueEvent
信号的设置情况。DbgkpQueueMessage
发送消息时:1
2KeSetEvent(&DebugObject->EventsPresent, 0, 0)
KeWaitForSingleObject(&DbgkmDebugEvent->ContinueEvent, Executive, KernelMode, FALSE, NULL);WaitForDebugEvent
调试器取消息时:1
KeWaitForSingleObject(&DebugObject->EventsPresent,x,x,x,x)
ContinueDebugEvent
回复消息时:1
2KeSetEvent(&DebugObject->EventsPresent, 0, FALSE);
KeSetEvent(&DebugEvent->ContinueEvent, 0, FALSE);
备注:不管是取消息然后去处理,还是回复消息,都是一次只能从消息队列上处理一条消息。不是批量处理的。
5.2 NtDebugContinue逆向分析
nt!NtDebugContinue
源代码逆向分析:
1 | PAGE:0056C0A8 // =============== S U B R O U T I N E ======================================= |
nt!DbgkpWakeTarget
源代码逆向分析:
1 | PAGE:0056BB16 // =============== S U B R O U T I N E ======================================= |