Windows软件调试初探-用户态调试模型
Windows软件调试初探-用户态调试模型
碎碎念
调试器进程通过调试API与NTDLL.DLL中调试支持函数和调试子系统交互。调试子系统分为3个部分:NTDL.DLL支持函数、内核文件支持函数、调试子系统服务器。
NTDLL.DLL中调试支持函数有3中:以DbgUi开头的供调试器用;以DbgSs开头的供调试子系统使用,已被废弃;其他以Dbg开头的用于实现调试API。
内核文件调试函数以Dbgk开头,负责采集和传递调试事件、控制被调试进程。
调试子系统服务器用于管理调试会话和调试事件,是调试消息(事件)的集散地,也是所有调试设施的核心,位于内核模式中。
Windows用户态调试通过调试事件驱动。调试器程序在与被调试进程建立调试对话后,调试器进程进入调试事件循环,等待调试事件发生,然后处理再等待,直到调试会话终止,如:
1 | while (WaitForDebugEvent(&DbgEvt, INIFINITE)) { //等待事件 |
WaitForDebugEvent
用于等待和接收调试事件,收到调试事件后,调试器根据事件类型(ID)来分发和处理,决定是否通知用户并进入交互式调试(命令模式)。此时被调试进程处于挂起状态,处理后用ContinueDebugEvent
将处理结果回复给调试子系统,让被调试程序继续运行。
调试信息采集
调试子系统公开给内核其他部件一些接口函数,称为Dbgk采集例程,函数以Dbgk开头但不是Dbgkp。Dbgk采集例程将所有调试事件(消息)分为:
1 |
|
进程管理由Windows执行体中一系列函数完成,即NTOSKRNL.EXE的上半部分,这些函数及其使用的数据结构称为进程管理器,函数大多以Ps或Psp开头。
进程管理器创建新用户态Windows进程时,为该进程建立必要的内核对象与数据结构,并分配栈空间,之后该线程处于挂起状态。之后进程管理器通知环境子系统,子系统做必要的设置和登记。最后进程管理器用PspUserThreadStartup
例程,该函数总用子系统内核函数DbgkCreateThread
让调试子系统得到处理机会。DbgkCreateThread
根据DebugPort是否为空检查新创建线程所在进程是否正被调试,不为空则检查该进程用户态运行时间是否为0来判断该线程是否为进程中第一个线程。为第一个线程则用DbgkpSendApiMessage
向DebugPort发送DbgKmCreateProcessApi消息,不是则发送DbgKmCreateThreadApi消息。调试器收到的进程创建CREATE_PROCESS_DEBUG_EVENT和线程创建CREATE_THREAD_DEBUG_EVENT源于这俩消息。
1 | NTSTATUS PspUserThreadStartup() { |
PspExitThread
负责线程退出和清除。若正在退出的线程不是进程中最后一个线程,则其用DbgkExitThread
通知指定线程退出;若是最后一个进程,则其用DbgkExitProcess
通知指定进程退出。
当DebugPort不为0则DbgkExitThread
将该进程挂起,并用DbgkpSendApiMessage
向DebugPort发送DbgKmExitThreadApi消息,发送函数返回后恢复进程运行。DbgkExitProcess
只需发送DbgKmExitProcessApi消息。调试器收到的EXIT_THREAD_DEBUG_EVENT和EXIT_PROCESS_DEBUG_EVENT源于这俩消息。
内核中内存管理器负责DLL的映射和反映射。内存管理器用Section对象表示一块可被多个进程共享的内存区域,有NtMapViewOfSection
用来映射模块,NtUnmapViewOfSection
用来反映射模块。NtMapViewOfSection
用MmMapViewOfSection
把一个模块映像(表示为Section对象)映射到指定进程空间,并用DbgkMapViewOfSection
通知调试子系统。进程初始化期间加载DLL的调用栈如下:
1 | nt!LpcRequestWaitReplyPort //用LPC发送并等待回复 |
DbgkMapViewOfSection
检查当前进程DebugPort不为空则用DbgkpSendApiMessage
发送DbgKmLoadDllApi消息。MmUnmapViewOfSection
调用DbgkUnMapViewOfSection
,其检测DebugPort不为空则发送DbgKmUnloadDllApi消息。调试器收到的LOAD_DLL_DEBUG_EVENT和UNLOAD_DLL_DEBUG_EVENT事件源于这俩消息。-
内核中KiDispatchException
为分发异常的枢纽,给每个异常安排最多两轮被处理的机会,每轮用DbgkForwardException
通知调试子系统。进程DebugPort字段不为空时,DbgkForwaredException
用DbgkpSendApiMessage
发送DbgKmExceptionApi消息。调试器收到的EXCEPTION_DEBUG_EVENT和OUTPUT_DEBUG_STRING_EVENT源于该消息。
调试信息发送
调试子系统内核函数用该结构描述和传递调试信息:
1 | typedef struct _DBGKM_APIMSG { |
调试消息采集函数填写上述结构,将其作为参数传给DbgkpSendApiMessage
函数,该函数用于将一条调试信息发送到调试子系统服务器:
1 | NTSTATUS DbgkpSendApiMessage( |
Port一般是EPROCESS结构中DebugPort字段,偶尔是进程异常端口ExceptionPort字段。SuspendProcess为真则用DbgkpSuspendProcess
挂起当前进程并发送消息,收到消息回复后用DbgkpResumeProcess
唤醒当前进程,这俩函数用于控制被调试进程。其用DbgkpQueueMessage
发送消息,一般需要用等待函数,等收到调试器回复后返回。
调试子系统向调试器发送调试事件前用DbgkpSuspendProcess
,其内部用KeFreezeAllThread
冻结被调试进程中非调试线程。调试线程会以阻塞的方式用DbgkpQueueMessage
发送消息并进入等待状态。由此当被调试进程被中断到调试器时,整个被调试程序无响应。
之后调试子系统服务器通知调试器读取调试消息,调试器处理后回复给调试子系统,调试子系统唤醒被调试进程等待线程。唤醒后执行DbgkpResumeProcess
,其内部用KeThawAllThreads
回复被调试进程中所有进程。
每个线程KTHREAD结构中,FreezeCount和SuspendCount字段与线程执行状态相关。可调度执行的线程这俩字段为0。当被调试进程中断到调试器时,KeFreezeAllThread
冻结非当前进程,即当前线程FreezeCount为0,其他线程为1。调试器收到调试事件后对被调试进程中所有线程依次用SuspendThread
,则所有线SuspendCount计数为1。
调试子系统服务器
DebugObject内核对象用于用户态调试:
1 | typedef struct _DEBUG_OBJECT { |
例如WaitForDebugEvent
对应的NtWaitForDebugEvent
等待的就是EventsPresent对象。Flags位1代表结束调试会话时是否终止被调试进程,如DebugSetProcessKillOnExit
就是设置这个标志位。
NtCreateDebugObject
用于创建调试对象。调试器与调试子系统建立连接时,调试子系统为其创建一个调试对象,将其保存在调试器当前线程TEB的DbgSsReserved字段。
对于在调试器中启动被调试程序,系统创建进程时将调试器线程TEB的DbgSsReserved字段中保存的调试对象句柄传给创建进程的内核服务,内核中进程创建函数将该句柄对应的对象指针赋给新创建进程的EPROCESS结构的DebugPort字段。对于把调试器附加到已运行进程中,系统用内核DbgkpSetProcessDebugObject
将创建好的调试对象附加到被调试进程,该函数除了将调试对象赋给EPROCESS的DebugPort字段,还用DbgkpMarkProcessPeb
设置PEB的BeingDebugged字段。
DbgkpQueueMessage
向一个调试对象的消息队列中追加调试事件。调试消息队列中每个节点为一个DBGKM_DEBUG_EVENT结构。
1 | typedef struct _DBGKM_DEBUG_EVENT{ |
当DbgkpQueueMessage
参数制定不需等待NOWAIT标志,则立刻通知调试器读取消息并返回。否则设置调试对象的EventPresent对象,通知调试器有消息读取,用KeWaitForSingleObject
等待DBGKM_DEBUG_EVENT中ContinueEvent对象,等待调试器回复。调试器处理后,用ContinueDebugEvent
调用nt!NtDebugContinue
,根据参数指定的CLIENT_ID结构遍历调试对象消息队列,找到匹配的调试事件后用DbgkpWakeTarget
设置要恢复的调试事件对象的ContinueEvent对象,使处于等待的被调试线程被唤醒而继续执行。栈帧如下,发生在被调试进程中。
1 | nt!KeSetEvent //设置事件 |
工作进程唤醒后读取调试对象中的消息队列。每读到一个调试事件时NtWaitForDebugEvent
用DbgkpConvertKernelToUserStateChange
将DBGKM_DEBUG_EVENT结构转为用户模式下的DBGUI_WAIT_STATE_CHANGE结构。栈帧如下:
1 | nt!DbgkpConvertKernelToUserStateChange //读取调试事件 |
读取一个调试事件后NtWaitForDebugEvent
在DBGKM_DEBUG_EVENT的Flags字段设置已读标志。
当将调试器附加到一个已运行的进程时,为了向调试器报告以前发生的目前仍有意义的调试事件,调试子系统捏造一些调试信息来模拟过去的调试事件,称为杜撰调试消息。
内核服务NtDebugActiveProcess
与已运行进程建立调试会话,其在调用DbgkpPostFakeProcessCreateMessages
后,用DbgkpSetProcessDebugObject
将调试对象设置到要调试的进程之前。DbgkpPostFakeProcessCreateMessages
用DbgkpPostFakeThreadMessages
遍历被调试进程的所有线程,再用DbgkpPostFakeModuleMessages
投放杜撰模块加载消息,这俩函数都用DbgkpQueueMessage
向消息队列添加调试消息。DbgkpSetProcessDebugObject
将调试对象设置到要调试的进程,并遍历事件队列中所有事件,设置调试对象EventsPresent字段。当NtDebugActiveProcess
服务返回后,调试器用NtWaitForDebugEvent
可立刻等待成功并读取事件队列中调试事件。
1 | __int64 __fastcall NtDebugActiveProcess(ULONG_PTR a1, void* a2) { |
栈帧如下,发生在调试器进程中。
1 | nt!DbgkpQueueMessage //放入消息队列 |
调试结束后要撤销调试会话时,系统用DbgkClearProcessDebugObject
将被调试进程DebugPort恢复为NULL。恢复时函数遍历调试对象的消息队列,将关于该进程的调试事件清除,但不破坏调试对象。
支持用户态调试的内核服务:
服务名 | 描述 |
---|---|
NtCreateDebugObject |
创建调试对象 |
NtRemoveProcessDebug |
分离调试对象 |
NtDebugActiveProcess |
与已运行进程建立调试会话 |
NtSetInformationDebugObject |
设置调试对象属性 |
NtDebugContinue |
回复调试事件,恢复被调试进程 |
NtWaitForDebugEvent |
等待调试事件 |
NtQueryDerbugFilterState |
查询调试信息输出的过滤级别 |
NtSetDebugFilterState |
设置调试信息输出的过滤级别 |
支持用户态调试的内核函数:
函数名 | 描述 |
---|---|
DbgkCreateThread |
采集线程创建事件 |
DbgkClearProcessDebugObject |
将调试对象从指定进程中分离 |
DbgkpConvertKernelToUserStateChange |
将DBGKM_DEBUG_EVENT转为DBGUI_WAIT_STATE_CHANGE |
DbgkDebugObjectType |
调用对象类型的全局指针 |
DbgkMarkProcessPeb |
建立和解除调试会话时修改被调试进程中PEB的BeginDebugged字段 |
DbgkpSetProcessDebugObject |
采集模块映射事件 |
DbgkMapViewOfSection |
采集进程退出事件 |
DbgkExitProcess |
访问指定进程DebugPort字段指定的调试对象 |
DbgkOpenProcessDebugPort |
访问指定进程DebugPort字段指定的调试对象 |
DbgkpWakeTarget |
设置ContinueEvent对象,唤醒等待调试器恢复的线程 |
DbgkpQueueMessage |
向调试事件队列中加入消息 |
DbgkpResumeProcess |
恢复执行被调试进程 |
DbgkpOpenHandles |
打开进程/线程对象,增加引用计数 |
DbgkInitialize |
系统启动早期初始化调试对象 |
DbgkpFreeDebugEvent |
释放调试事件 |
DbgkUnMapViewOfSection |
采集模块反映射事件 |
DbgkForwardException |
向调试子系统通报异常 |
DbgkpPostFakeProcessCreateMessages |
向调试子系统发送杜撰的进程创建消息 |
DbgkpPostFakeThreadMessages |
向调试子系统发送杜撰的线程创建消息 |
DbgkpSendApiMessageLpc |
向当前ExceptionPort字段的异常端口发送异常第二轮处理机会 |
DbgkpCloseObject |
关闭调试对象,枚举系统内所有进程,某进程DebugPort为要关闭的对象则将其置0 |
DbgkpPostFakeModuleMessages |
向调试子系统发送杜撰的模块信息 |
DbgkpSendApiMessage |
发送调试事件 |
DbgkExitThread |
采集线程退出事件 |
DbgkpProcessDebugPortMutex |
全局互斥量对象,保护EPROCESS的DebugPort字段的访问 |
DbgkCopyProcessDebugPort |
创建新进程时根据需要将父进程DebugPort复制到新进程中 |
DbgkpSectionToFileHandle |
取得Section对象对应的文件句柄 |
DbgkpSuspendProcess |
挂起被调试进程 |
调试子系统支持跨Windows登录对话进行调试,通过终端服务或快速用户切换功能。
NTDLL.DLL中调试支持例程
NTDLL.DLL中调试函数分为DbgUi、Dbg函数两类。
DbgUi函数有以下,是调试子系统向调试器提供的接口。
函数 | 说明 |
---|---|
DbgUiDebugActiveProcess |
上层为kernel32!DebugActiveProcess ,下层为nt!NtDebugActiveProcess |
DbgUiConnectToDbg |
连接调试子系统,调用ZwCreateDebugObject |
DbgUiConvertStateChangeStructure |
将DBGUI_WAIT_STATE_CHANGE转为调试器所需DEBUG_EVENT结构 |
DbgUiGetThreadDebugObject |
从调试器工作线程TEB读取调试对象 |
DbgUiSetThreadDebugObject |
将调试对象记录到TEB |
DbgUiIssueRemoteBreakin |
在被调试进程中创建远程线程以使其中断到调试器,上层为kernel32!DebugBreakProcess |
DbgUiContinue |
恢复被调试进程,用NtDebugContinue |
DbgUiWaitStateChange |
等待调试事件,用NtWaitForDebugEvent ,后者等待TEB中DbgSsReserved中DebugObject对象 |
DbgUiStopDebugging |
停止调试,用NtRemoveProcessDebug |
Dbg函数有:
函数 | 说明 |
---|---|
DbgBreakPoint |
INT 3 |
DbgUserBreakPoint |
断点指令 |
DbgPrint |
打印调试信息 |
DbgPrompt |
提示输入 |
DbgPrintReturnControlC |
|
DbgBreakPointWithStatus |
|
DbgSetDebugFilterState |
设置调试信息输出的过滤级别,内部用NtSetDebugFilterState |
DbgQueryDebugFilterState |
|
DbgPrintEx |
调试API
大多数都从KERNEL32.DLL中导出,有些在KERNEL32.DLL中实现,有些在NTDLL.DLL中实现。
函数 | 描述 | 实现 |
---|---|---|
CheckRemoteDebuggerPresent |
判断指定进程是否处于被调试状态 | 用NtQueryInformationProcess 查询PEB |
ContinueDebugEvent |
供调试器恢复被调试进程运行,回复调试事件 | 用ntdll!DbgUiContinue |
DebugActiveProcess |
供调试器附加到已运行的进程 | 用ntdll!DbgUiDebugActiveProcess |
DebugActiveProcessStop |
分离调试会话 | 将进程ID转为句柄后用ntdll!DbgUiStopDebugging |
DebugBreak |
在当前进程中产生断点异常 | 用ntdll!DbgBreakpoint |
DebugBreakProcess |
在指定进程中产生断点异常 | 用ntdll!DbgIssueRemoteBreakin |
DebugSetProcessKillOnExit |
指定调试器线程退出时是否终止被调试进程 | 用DbgUiGetThreadDebugObject 和NtSetInformationDebugObject |
FatalExit |
废弃 | 用ExitProcess |
FlushInstructionCache |
当调试器修改代码段时用该函数冲转缓存 | 用NtFlushInstructionCache |
GetThreadContext |
获取指定线程上下文结构 | 用NtGetContextThread |
GetThreadSelectorEntry |
从指定线程LDT中获取指定选择子所对应表项Entry | 用NtQueryInformationThread |
IsDebuggerPresent |
判断调用进程是否被调试 | 检查PEB的BeingDebugged字段 |
OutputDebugString |
供应用程序输出调试信息 | 通过产生异常实现RaiseException(DBG_PRINTEXCEPTION_C,0,2,...) |
ReadProcessMemory |
读取指定进程空间中指定内存区域 | 用NtReadVirtualMemory |
SetThreadContext |
设置指定线程上下文信息 | 用NtSetContextThread |
WaitForDebugEvent |
供调试器工作线程等待调试事件 | 用ntdll!DbgUiWaitStateChange |
WriteProcessMemory |
向指定进程空间指定内存区域写入数据 | 用NtWriteVirtualMemory |