Windows XP 用户态调试(一)被调试进程
1 调试用到的 API
软件调试系列主要用到 kernel32.dll
、ntdll.dll
、ntoskrnl.exe
这几个文件。kernel32.dll
:主要是用在调试器在调试 被调试程序 前的一些建立过程上。ntdll.dll
中的调试支持函数主要分为 3 类:
- 以
DbgUi
开头的,供调试器使用; - 以
DbgSs
开头的,这一部分在 Windows 2000 之后被移除; - 以
Dbg
开头(非前两种)的,用于实现调试API,如DbgBreakpoint
是DebugBreak
API的实现。
ntoskrnl.exe
:该文件中的调试支持函数负责采集和传递调试事件,以及控制被调试进程。这些内核函数都是以 Dbgk
开头的。
调试子系统主要由 3 个部分:位于ntdll.dll
中的支持函数、位于内核文件中的 Dbgk
支持函数,以及在内核的调试子系统服务器组成(实际上子系统是一类服务函数的集合,所以这里指的服务器就是为调试提供服务的函数)。
2 调试器与被调试程序
2.1 调试对象
调试器是一个进程,被调试程序是一个进程,如何才能将两个进程联系到一起呢?就需要一个桥梁,在 Windows 中一般都是用一个对象来管理和连接许多事务,而调试用到的就是调试对象_DEBUG_OBJECT
(进程间是相互隔离的,但是高2G往往又是相同的,因此这个桥梁可以利用内核层来实现)。
1 | typedef struct _DEBUG_OBJECT { |
偏移 | 名称 | 作用 |
---|---|---|
0x00 | EventsPresent | 用于指示调试事件发生的事件对象,用来同步调试器进程和被调试进程,调试子系统服务器通过设置此事件来通知调试器读取消息队列中的调试信息。调试器通过 WaitForDebugEvent 来等待此对象。 |
0x10 | Mutex | 用于同步的互斥对象,用来锁定对 StateEventListEntry 的访问,以防止向链表写数据时调试器正在取数据造成数据读写错误。 |
0x30 | StateEventListEntry | 保存调试事件的链表,被称为调试消息队列。 |
0x38 | Flags | 包含多个标志位,比如,位1代表结束调试会话时是否终止被调试进程(KillProcessOnExit ),位0代表调试对象是否被正在被删除。 DebugSetProcessKillOnExit 实际上设置的就是这个标志位。具体见3.5.2。 |
调试对象通常是在调试器进程中创建的,将调试器进程与被调试进程建立连接的过程为:
- 调试器进程调用函数创建调试对象,并将调试对象句柄保存到调试器当前线程
TEB.DbgSsReserved[1]
字段中。 - 然后进入 0 环,将调试对象地址保存在被调试进程
EPROCESS.DebugPort
。
2.2 调试关系建立方式
打开调试器,有两种与被调试程序建立联系的方式:
- 在调试器中打开未运行的可执行文件:通过
kernel32!CreateProcess
建立联系。 - 将一个正在运行的程序附加到调试器中:通过
kernel32!DebugActiveProcess
建立联系。
一、kernel32!CreateProcess
1 | BOOL WINAPI CreateProcessA( |
其中 dwCreationFlags
参数用于指定创建新进程的选项,可以是一系列标志位的组合。以下两个标志位是专门用于调试的。
1 |
系统在创建进程时,会检查创建标志中是否包含以上标志。如果包含,那么系统会把调用进程当作调试器(debugger)进程,把新创建的进程当作被调试(debuggee)进程,为二者建立起调试关系。
二者主要区别:
DEBUG_PROCESS
:调试器会收到被调试进程及由被调试进程创建的所有子进程中发生的所有调试事件的信息,但一般来说没有必要这样做。DEBUG_ONLY_THIS_PROCESS
和DEBUG_PROCESS
组合标志来禁止它。DEBUG_ONLY_THIS_PROCESS
:调试器将只会收到被调试进程的调试事件,而对其子进程的调试事件不予理睬。
因为操作系统将调试对象标记为在特殊模式下运行,所以可以使用 IsDebuggerPresent
函数查看进程是否在调试器下运行。《加密与解密第四版 9.3 P377》。
这种方式创建具体细节请看《软件调试第二版卷2 10.3 P201》、《64位CreateProcess逆向》、《基于Win11的CreateProcess逆向分析-3环用户层逆向分析(一)》。
两种建立联系的方式本质上区别并不大,只是第一种有一个创建进程的过程,多出一步,所以仅分析第二种通过 kernel32!DebugActiveProcess
建立联系的方式即可。
2.3 DebugActiveProcess
函数 kernel32!DebugActiveProcess
:该函数允许调试器附加一个处于活动状态的进程。
1 | BOOL __stdcall DebugActiveProcess( |
- 该函数调用
ntdll!DbgUiConnectToDbg
,无需进入 0 环。先判断调试器当前线程 TEB+0xF24 即DbgSsReserved[1] == NULL
,如果不为空则直接使用该调试对象即可,如果为空会创建一个调试对象。最后将调试对象句柄值存入调试器当前线程TEB+0xF24
,即可关联起来。 - 然后调用
ntdll!DbgUiDebugActiveProcess
带着被调试进程句柄、调试对象句柄进入 0 环后调用DbgkpSetProcessDebugObject
,然后将调试对象地址存入被调试进程 EPROCESS.DebugPort即可。
由于TEB是用户层的数据结构,所以此时 DbgSsReserved[1]
中保存的其实是调试对象的句柄,而不是调试对象的地址。
ntdll!DbgUiConnectToDbg
:关联调试器-调试对象。ntdll!DbgUiDebugActiveProcess --> DbgkpSetProcessDebugObject
:关联被调试进程-调试对象。
当调试器与被调试进程的调试会话建立起来后,调试器进程就进入了调试事件循环,等待调试事件的发生,然后处理,然后等待,直到调试会话结束,调试器的调试事件循环如下:
1 | while(WaitForDebugEvent(&DbgEvt, INFINITE))// 等待事件 |
DbgkpSetProcessDebugObject
函数内部除了将调试对象赋给 EPROCESS
结构的 DebugPort
字段,还会调用 DbgkpMarkProcessPeb
函数设置进程环境块 PEB.BeingDebugged 字段(用于在用户态判断进程是否正在被调试):
1 | VOID DbgkpMarkProcessPeb(PEPROCESS Process) |
将调试器进程附加到已处于运行的进程中,使用 kernel32!DebugActiveProcess
,需要注意两点:
- 在 NT内核中,当试图通过
DebugActiveProcess
函数将调试器捆绑到一个创建时带有安全描述符的进程上时,将被拒绝。《加密与解密第四版 P378》 - 在
kernel32!DebugActiveProcess
调用ProcessIdToHlandle
函数,获得指定 ID 的进程句柄,这个函数内部会调用OpenProcess
函数, 进而调用NtOpenProcess
内核服务。在执行这一步时需要调用进程与目标进程有同样或更高的权限,否则这一步便会失败,调用GetLastError
(返回的错误码通常是0x5
,意思是Access is denied
,即访问被拒绝。如果调试器进程具有SE_DEBUG_NAME 权限
,那么它通常有权限调试系统内的任何进程。《软件调试第二版 卷2 P206》
2.4 调试器TEB.DbgSsReserved
TEB
结构的 DbgSsReservedr[2]
数组就是专门用来记录调试器工作线程与调试子系统之间通信用的同步对象和通信对象的。
DbgSsReservear[0]
:是一个链表头,指向所有被调试线程。这个链表的每个节点是一个DBGSS_THREAD_DATA
结构或TMPHANDLES
结构(XP SP3使用),每个节点用来描述被调试进程中的一个线程。DbgSsReservear[1]
:存放调试对象句柄。
关于 DBGSS_THREAD_DATA
:
1 | typedef struct _DBGSS_THREAD_DATA |
调试时间处理的 WaitForDebugEvent
和 ContinueDebugEvent
函数会维护这个链表。
2.5 被调试进程PEB.BeingDebugged
DbgkpSetProcessDebugObject
函数内部除了将调试对象赋给被调试进程 EPROCESS
结构的 DebugPort
字段,还会调用 DbgkpMarkProcessPeb
函数设置被调试进程环境块PEB.BeingDebugged字段(用于在用户态判断进程是否正在被调试):
1 | VOID DbgkpMarkProcessPeb(PEPROCESS Process) |
如果一个进程不在被调试状态,那么其 PEB 结构的 Being Debugged
字段为 0;否则,为 1。ISDebuggerPresent
API 就是通过判断 BeingDebugged
字段实现的。
2.6 反调试
在掌握了调试原理后,自然也就可以总结出一些反调试的手段:
- 清零DebugPort,只要起一个线程不断的检查当前进程的DebugPort,一旦有值就退出程序或者将其清零,这样可以中断调试对象与被调试进程的联系,以达到反调试的目的。
- 遍历所有进程TEB+0xF24处,看有没有值,若有值,一定就是调试器,则退出程序。
- Hook NtCreateDebugObject,不让它创建调试对象。
2.7 反反调试
有反调试,自然就有反反调试,正所谓道高一尺魔高一丈,针对各类反调试手段,也会衍生出各类的反反调试,攻防领域永远都在交替上升:
- 针对Hook NtCreateDebugObject的反调试方式,可以自己分配一个内存给_DEBUG_OBJECT,并为它的成员赋值。
- 针对清零DebugPort的反调试方式,可以不使用DebugPort的位置,在进程中另找一个区域存放_DEBUG_OBJECT的地址。把原先+0xbc的值都选为新的偏移处。
- 重写整个DebugActiveProcess函数。
3 调试消息的采集-0环
3.1 调试消息是什么
调试消息(事件):调试器进程如何才能知道被调试进程发生甚么事了?于是就有了调试消息这么一个概念,用来描述被调试进程的某些行为,当被调试进程做出了一些行为后,如果属于调试事件中的一类,就会借助调试对象告知调试器。参考下图:
调试事件的采集是在内核进行的,使用以 Dbgk
开头的函数来进行采集。
创建/退出进程、线程时,进行消息采集的线程由被调试进程来提供。
在内核中,调试事件有时也称为调试消息,并使用一个名为 DBGKM_APIMSG
的结构来描述。实际上在 0 环侧一般叫调试消息。
调试事件是在 3 环的叫法,调试消息是在 0 环的叫法。
对于在3 环的调试器,调试 API 使用的是一个名为 DEBUG_EVENT
的结构。因为这两个结构是不同的,所以需要一个转化过程,这个工作是由调试子系统服务器和 ntdll.dll
中的用户态函数来完成的。简单来说,子系统服务器会将自己使用的结构转化为 ntdll.dll
使用的DBGULWAIT_STATE_CHANGE
, ntdll.dll
再将这个结构转化为调试器使用的 DEBUG_EVENT
结构。
1 | typedef struct _DEBUG_OBJECT { |
3.2 调试消息种类
不能说执行了什么代码(例如打印了某个字符,申请了一块内存),都产生一个调试事件(消息)发送给 _DEBUG_OBJECT
,那样链表也就过于复杂了,所以调试事件设定了以下7种类型(对应的常量在 3 环使用):
1 | typedef enum _DBGKM_APINUMBER |
当被调试进程做出任何一种上述类型的行为时,都会产生调试消息,并发送给 _DEBUG_OBJECT.EventList
。
消息类型 | 采集方式 |
---|---|
进程和线程创建消息 | 创建进程的时候,都需要创建主线程。而创建用户模式线程的时候,会执行PspUserThreadStartup例程,在该函数中会通过调用DbgkCreateThread来采集创建消息 |
进程和线程退出消息 | 进程的退出其实就是将其所有线程都退出,而线程退出的最终函数是PspExitThread。该函数会判断退出的线程是否是最后一个线程,如果不是则会调用DbgkExitThread来采集线程退出消除;如果是最后一个线程,就会通过调用DbgkExitProcess来采集进程退出消息 |
DLL映射消息 | Windows内核使用NtMapViewOfSection来将一个模块对象映射到指定的进程空间中时,NtMapViewOfSection会调用DbgkMapViewOfSection来采集模块映射的消息 |
DLL卸载消息 | NtUnMapViewOfSection则会在卸载模块的时候,调用DbgkUnMapViewOfSection来采集模块卸载消息 |
异常消息 | 当出现异常的时候,KiDispatchException会完成对异常的分发,而该函数会判断是否具有调试器,如果有调试器,则会通过调用DbgkForwardException函数来采集异常消息 |
3.3 调试消息结构
为了让调试进程得知被调试进程的状态,内核会将被调试进程所有的调试消息都收集起来发送给调试子系统。
不同的 Dbgk
采集例程会根据当前进程的 DebugPort
字段来判断是否处于被调试状态。如果不是,便会忽略这次调用;如果处于调试状态,便会产生一个0环的 DBGKM_APIMSG
消息结构,该结构的定义如下:
1 | typedef struct _DBGKM_APIMSG { |
其中 ApiNumber 的种类枚举值取自 DBGKM_APINUMBER
,不同类型的消息将会封装成 u
中不同的结构。
1 | typedef struct _DBGKM_CREATE_THREAD { |
3.4 调试消息的采集
对应 3.2 中的调试消息种类,Windows系统中提供了一些调试消息的采集函数,它们以 Dbgk
开头,用于生成不同调试事件对应的结构体,具体如下:
针对不同类型的调试事件,均有对应的调试消息采集函数。图中:
- 黑色字体的为导致调试消息产生的函数;
- 紫色字体的为生成调试消息的_采集函数_;
- 红色字体的则为调试消息_写入函数_。
并且调试消息采集函数均在导致调试事件发生的函数执行的必经之路上,从而捕获到被调试进程的行为。下面以前4类调试事件为例,分析调试事件采集函数的执行过程。
3.4.1 创建进程、线程
从线程角度来说,创建进程就是创建第一个线程,可以参考《64位CreateProcess逆向》、《基于Win11的CreateProcess逆向分析-3环用户层逆向分析(一)》。
首先来看创建进程、线程的事件采集函数,创建进程的本质就是创建线程,其中第一次创建线程时为创建进程。因此底层调用的函数一样,均为PspUserThreadStartup,下面来分析它的执行流程:
在
PspUserThreadStartup
中如果满足!DeadThread && !HideFromDebugger
就会调用DbgkCreateThread
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20/*VOID __stdcall PspUserThreadStartup(
IN PKSTART_ROUTINE StartRoutine, //忽略,并没有使用到(反汇编的代码直接将其忽略)
IN PVOID StartContext //线程开始执行的地址
)
*/
...
PAGE:004F8E52 loc_4F8E52: // CODE XREF: PspUserThreadStartup(x,x)+44↑j
PAGE:004F8E52 xor cl, cl // NewIrql
PAGE:004F8E54 call ds:__imp_@KfLowerIrql@4 // KfLowerIrql(x)
PAGE:004F8E5A test byte ptr [esi+_ETHREAD.CrossThreadFlags], 6 // DeadThread:2(创建失败)|
//HideFromDebugger:4(该线程对于调试器不可见)
PAGE:004F8E61 jnz short loc_4F8E6B
PAGE:004F8E63 push [ebp+arg_4]
PAGE:004F8E66 call _DbgkCreateThread@4 // DbgkCreateThread(x)
PAGE:004F8E6B loc_4F8E6B: // CODE XREF: PspUserThreadStartup(x,x)+93↑j
PAGE:004F8E6B cmp [ebp+var_19], 0
PAGE:004F8E6F jz short loc_4F8E7E
PAGE:004F8E71 push 0C000004Bh // ExitStatus
PAGE:004F8E76 push esi // Object
PAGE:004F8E77 call _PspTerminateThreadByPointer@8从代码分析来看,只要不是创建失败且不对调试器隐藏的线程,就一定会调用函数
DbgkCreateThread
。如果一个线程不走DbgkCreateThread
这条路线是不被 CPU 跑起来的,所以是必经之路。进入
DbgkCreateThread
,进来后,可以看到有一个判断,判断当前进程的 DebugPort 的值是否为空(ebx先前会被清零),这是每个 Dbgk 系列的函数都会做的判断;如果 DebugPort 的值不为空,说明当前进程正在被调试。1
2
3
4
5
6
7
8
9
10
11
12
13/* VOID DbgkCreateThread(
PVOID StartAddress //Supplies the start address for the thread that is starting.
)
*/
PAGE:0056C5A9 loc_56C5A9: // CODE XREF: DbgkCreateThread(x)+26↑j
PAGE:0056C5A9 // DbgkCreateThread(x)+2F↑j
PAGE:0056C5A9 cmp [esi+_EPROCESS.DebugPort], ebx
PAGE:0056C5AF jz loc_56C815
...
PAGE:0056C815 loc_56C815: // CODE XREF: DbgkCreateThread(x)+12B↑j
PAGE:0056C815 // DbgkCreateThread(x)+343↑j ...
PAGE:0056C815 call __SEH_epilog
PAGE:0056C81A retn 4在判断
DebugPort == 0?
前线程已经调用PsCallImageNotifyRoutines
。如果正在被调试则做两件事:第一件事,通过判断创建的线程是不是第一个线程来判断此时属于创建进程还是创建线程(DbgKmCreateThreadApi/DbgKmCreateProcessApi
),第二件事,针对该调试事件将其打包成一个结构体。最终调用DbgkpSendApiMessage
,它的第一个参数,就是刚刚打包的调试事件结构体。可参考《软件调试第二版卷2 9.2.2》
小结:一个线程、进程被创建时,执行到 PspUserThreadStartup
,只要线程创建成功,且该线程不对调试设置隐藏就一定会调用 DbgkCreateThread
,以便让调试子系统得到处理机会。在 DbgkCreateThread
中只要 DebugPort != 0
就一定会调用函数 DbgkpSendApiMessage
。
3.4.2 退出进程、线程
在《Terminate/Suspend/ResumeThread函数分析》中已经分析过结束一个线程的执行路径了,线程自杀,也就是退出进程时会调用 PspExitThread
。他杀是通过插入APC实现的。
如下,当
EPROCESS.Flags = 0x8(ProcessDelete)
即退出进程时,会设置变量var_19 = 1
, 这里的变量最后用来指示是退出线程还是退出进程(是否是最后一个线程)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// VOID PspExitThread( IN NTSTATUS ExitStatus )
...
PAGE:004FB180 loc_4FB180: // CODE XREF: PspExitThread(x)+F1↑j
PAGE:004FB180 dec [edi+_EPROCESS.ActiveThreads]
PAGE:004FB186 jnz loc_4FB29E
PAGE:004FB18C push 8 //如果已经是最后一个线程
PAGE:004FB18E pop eax
PAGE:004FB18F lea ecx, [edi+_EPROCESS.Flags]
PAGE:004FB195 lock or [ecx], eax // Flags.ProcessDelete = 1
PAGE:004FB195 // +0x248 Flags : Uint4B
PAGE:004FB195 // +0x248 CreateReported : Pos 0, 1 Bit
PAGE:004FB195 // +0x248 NoDebugInherit : Pos 1, 1 Bit
PAGE:004FB195 // +0x248 ProcessExiting : Pos 2, 1 Bit
PAGE:004FB195 // +0x248 ProcessDelete : Pos 3, 1 Bit
PAGE:004FB195 // +0x248 Wow64SplitPages : Pos 4, 1 Bit
PAGE:004FB198 mov [ebp+var_19], 1 // if(Flags.ProcessDelete == 1) var_19 = 1;如下图,只要对应的条件满足,一定会给内核调试器和用户调试器机会对即将退出的线程进行调试(这里就用到变量
var_19
)。参考下面代码,无论是
DbgkExitThread
还是DbgkExitProcess
,内部都会调用DbgkpSendApiMessage
,当然在调用之前,这两个函数都会先生成一个该函数对应的调试事件结构体。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// VOID DbgkExitProcess( NTSTATUS ExitStatus )
PAGE:0056C8ED push 0 //因为进程马上就要退出了,无需再进程挂起和恢复
//因为进程管理器已经对该线程做了删除标记
PAGE:0056C8EF lea eax, [ebp+var_78]
PAGE:0056C8F2 push eax
PAGE:0056C8F3 mov [ebp+var_78], 78000Ch
PAGE:0056C8FA mov [ebp+var_74], 8
PAGE:0056C901 mov [ebp+var_60], 4
PAGE:0056C908 call _DbgkpSendApiMessage@8 // DbgkpSendApiMessage(x,x)
// VOID DbgkExitThread( NTSTATUS ExitStatus )
PAGE:0056C877 call _DbgkpSuspendProcess@0 // 先将进程挂起
PAGE:0056C87C mov bl, al
PAGE:0056C87E push 0
PAGE:0056C880 lea eax, [ebp+var_78]
PAGE:0056C883 push eax
PAGE:0056C884 call _DbgkpSendApiMessage@8 // DbgkpSendApiMessage(x,x)
小结:在退出线程或进程的必经之路上会调用 DbgkExitThread
或者 DbgkExitProcess
生成相应的调试事件结构体,并最终调用 DbgkpSendApiMessage
。其余的调试事件采集函数不再作分析,结论相同,可自行分析。
3.4.3 模块的加载、卸载
3.4.4 异常消息
3.5 调试消息的发送
在 3.4 中讲解了调试消息的采集,产生调试消息的被调试进程的线程调用相关 Dbgk
函数,将相应的调试消息封装成 DBGKM_APIMSG
结构,然后调用 DbgkpSendApiMessage
来传送该调试消息给调试器。
3.5.1 DbgkpSendApiMessage
函数 DbgkpSendApiMessage
:该函数将一条调试消息发送到调试子系统服务器。
1 | NTSTATUS DbgkpSendApiMessage( |
参数 | 含义 |
---|---|
ApiMsg | 消息结构,每种消息都有自己的消息结构,由不同的调试事件采集函数创建,共有7种类型。 |
SuspendProcess | 如果 SuspendProcess 为 TRUE ,那么这个函数会先调用 DbgkpSuspendProcess 函数挂起本进程内除了自己之外的其他所有线程,然后发送消息,消息发送后将当前线程也挂起。等收到消息回复后先唤醒消息处理的线程,然后再调用 DbgkpResumeProcess 函数唤醒当前进程的其他所有线程。 |
KeFreezeAllThreads
:当被调试进程中断到调试器中时,它当前线程的 FreezeCount
通常为0,其他线程的 FreezeCount
通常为1。因为 KeFreezeAllThreads
不会冻结当前线程,包括 WinDBG 在内的调试器在收到调试事件后,会对被调试进程中的所有线程依次调用 SuspendThread
,这样所有线程的 SuspendCount
计数通常都为 1
。
关于 KTHREAD.FrezeeCount
和 KTHREAD.SuspendCount
:
KTHREAD.FrezeeCount
:该字段由KeFreezeAllThreads
函数和KeThawAllThreads
操作。当一个线程的进程被中断到调试器之后,当前线程FreezeCount == 0
,其余线程FreezeCount == 1
。即该字段为1
时表示这个线程处于冻结状态。《软件调试第二版卷2 9.3.3 P175》KTHREAD.SuspendCount
:当前线程被挂起的次数,次数小于1则会恢复执行线程。线程活动时该值为0。该字段加减次数由SuspendThread
和ResumeThread
(对应于NtSuspendThread
内核服务KeSuspendThread
)操作。
1 | PAGE:0056C1F0 // =============== S U B R O U T I N E ======================================= |
3.5.2 DbgkpQueueMessage
函数 DbgkpQueueMessage
:将扩展的调试消息 Queue
到调试对象 DEBUG_OBJECT
的消息队列 EventList
,以便用户模式调试器获取消息。
1 | NTSTATUS __stdcall DbgkpQueueMessage ( |
3.5.3 _DBGKM_DEBUG_EVENT
注意:调试对象的消息队列 DEBUG_OBJECT.EventList
串着的都是 DEBUG_EVENT
结构,该结构是对 DBGKM_APIMSG
的拓展,但是 0 环和 3 环该结构的定义不一样。
为了区分使用,所以本文借鉴《软件调试》中的命名方式,将 0 环使用的结构名称换为_DBGKM_DEBUG_EVENT。
1 | // 0环使用, _DBGKM_DEBUG_EVENT |
需要说明的是:
在 0 环:
DebugObject->EventsPresent
:该成员在 0 环将其信号设置为 1,通知 3 环的调试器来取调试消息。DbgkmDebugEvent.ContinueEvent
:该成员在 0 环,用来等待调试器传来的信号。并且这里使用KeWaitForSingleObject
将被调试进程的最后一个活动线程也挂起,当这个线程获得信号后返回到DbgkpSendApiMessage
中调用DbgkpResumeProcess
唤醒其他所有线程。在 0 环的调用结构是这样:
1
2KeSetEvent(&DebugObject->EventsPresent, 0, 0)
KeWaitForSingleObject(&DbgkmDebugEvent->ContinueEvent, Executive, KernelMode, FALSE, NULL);
我猜在 3 环的调用结构应该是这样:
1
2KeWaitForSingleObject(&DebugObject->EventsPresent, Executive, UserMode, FALSE, NULL);
KeSetEvent(&DbgkmDebugEvent->ContinueEvent, 0, 0)
参考内容:《Windows调试流程分析-Win10 1511 Build:10586》、《科锐三阶段项目-跟踪调试框架》、《读书笔记|Windows 调试原理学习|持续更新》、《win7 x64内核调试函数逆向还原C代码,自建调试体系》。
源代码分析如下:
1 | PAGE:0056AFDB // --------------------------------------------------------------------------- |
4 杜撰的调试消息
在 2.3 中讲了如何将调试器附加到一个已经处于运行状态的被调试进程,但是还有两个细节没有说(没有逆向分析)。本节是对 2.3 的补充,同时本节用来和 3.4.1 作对比。
当使用附加的方式来调试一个进程时,调试子系统会“假装”此时正在创建一个新进程,模仿像创建进程那样给调试器发送杜撰的消息(faked debug message)。但是有一点区别:
- 在被调试进程
DbgkCreateThread
中,当判断是创建进程时,会调用两次DbgkpSendApiMessage
来分别发送DbgKmCreateProcessApi-DbgKmLoadDllApi
或DbgKmCreateThreadApi
消息。发送调试信息的线程属于被调试进程。可以挂起当前进程。消息发送是同步的。 - 在发送杜撰消息时,调试器的线程直接使用
DbgkpQueueMessage
来发送上面这三类消息。发送调试信息的线程属于调试器进程。不会挂起被调试进程,且消息发送是异步的(指定了NO_WAIT
)。
在 nt!NtDebugActiveProcess
中函数的主要调用顺序如下(由于该函数调用的子函数太多了,所以本节主要是参考源码和软件调试第二版卷2 9.4.5 来精简总结):
1 | NTSTATUS NtDebugActiveProcess(IN HANDLE ProcessHandle, IN HADNLE DebugObjectHandle) |
函数 DbgkpSetProcessDebugObject
用来将被调试进程和调试对象进行关联,但是在关联之前会调用 DbgkpPostFakeProcessCreateMessages
来发送上面提到的杜撰的消息。
1 | NTSTATUS DbgkpPostFakeProcessCreateMessages ( |
DbgkpPostFakeProcessCreateMessages
会先调用 DbgkpPostFakeThreadMessages
,后者会遍历被调试进程的所有线程,以向调试对象的消息队列中投放杜撰的进程和线程来创建消息。而后 DbgkpPostPakeProcessCreateMessages
会调用DbgkpPostFakeModuleMessages
来投放杜撰的模块加载消息。
DbgkpPostFakeThreadMessages
和 DbgkpPostFakeModuleMessages
都是调用 DbgkpQueueMessage
来向消息队列添加调试消息的。
因为在参数中指定了不需等待的标志(NOWAIT
,异步),所以 DbgkpQueueMessage
将事件放人队列后便会返回,不会设置 EventsPresent
对象以避免它通知调试器来读取。
DbgkpSetProcessDebugObject
函数内部除了将调试对象赋给被调试进程 EPROCESS
结构的 DebugPort
字段,还会调用 DbgkpMarkProcessPeb
函数设置被调试进程环境块PEB.BeingDebugged字段(用于在用户态判断进程是否正在被调试):
1 | VOID DbgkpMarkProcessPeb(PEPROCESS Process) |
如果一个进程不在被调试状态,那么其 PEB 结构的 Being Debugged
字段为 0;否则,为 1。ISDebuggerPresent
API 就是通过判断 BeingDebugged
字段实现的。
5 清除调试对象
当调试结束后需要撤销调试会话时,系统会调用 DbgkClearProcessDebugObject
将被调试进程的 DebugPort
字段恢复为 NULL
。恢复时,这个函数会遍历调试对象的消息队列,将关于这个进程的调试事件清除。这个函数并不破坏调试对象,因为一个调试器可以同时调试多个被调试进程,这个调试对象可能还在被其他被调试进程所使用。
以前的 TP 保护就是起一个线程不断地扫描要保护的程序的 DebugPort
字段,来检测是否被调试,然后将 DebugPort
不断清零。后来逐渐演变,有的调试器在建立调试会话时,会将 DebugPort
移位到其他一些不常用的偏移处或保留位置(如 ExitTime
)。也有的是将 EPROCESS
结构进行扩展,然后将相关调试结构移位到扩展的位置。
还有的检测手段就是,起一个线程,不断地遍历每一个线程 TEB.DbgSsReserved
出来判断其是否是调试器。
可以参考:
梦织未来论坛的调试与异常和过驱动保护(win7/win8/win10 64 过保护 驱动教程–郁金香外挂教程)