最近在读软件调试这本书 , 得到了不少知识.
所以把书中的一些知识点扣了出起来.
调试子系统采集调试事件的方法和过程:
能够采集到的调试事件(消息)
typedef enum _DBGKM_APINUMBER
{
DbgKmExceptionApi=0, // 异常
DbgKmCreateThreadApi,//创建线程
DbgKmCreateProcessApi,//创建进程
DbgKmExitThreadApi,// 线程退出
DbgKmExitProcessApi,// 进程退出
DbgKmLoadDllApi, // 加载Dll
DbgKmUnLoadDllApi, // 卸载Dll
DbgKmErrorReportApi,// 内部错误
DbgKmMaxApiNumber, // 这组常量的最大值
}
PS:DbgKmErrorReportApi 是用来报告调试子系统内部的错误 ,目前已经不再使用
- 进程创建和线程创建事件的采集过程:
创建新的进程和线程 ,进程的通信,终止进程和线程,
资源分配回收这些任务通常称为进程管理,
完成这些功能的是ntoskrnl.exe中有Ps或Psp开头的系列
函数. 这写系列函数被泛称为进程管理器.
进程管理器创建新的用户态Windows线程时,有如下工作
- 为该线程建立必要的内核对象和数据结构
- 分配栈空间
- 挂起该线程
- 通知环境子系统(子系统会作必要的设置和登记)
- 调用 PspUserThreadStartup,准备启动线程(函数总是会
调用 调试子系统的内核函数 DbgkCreateThread.)
- 调试子系统的内核函数 DbgkCreateThread()函数会检查新
创建线程所在的进程是否正在被调试(根据 DebugPort 是否为NULL),
如果为NULL,便立即返回(返回到 PspUserThreadStartup()函数),
如果不是NULL,则会继续检查该进程的用户态时间(UserTime)是否为0,
目的是判断该线程是否是进程中的第一个线程 ,如果是第一个线程,则
通过DbgkpQueueMessage()函数向调试端口(DebugPort)发送
DbgKmCreateProcessApi消息. 如果不是第一个线程,则发送
DbgkmCreateTheadApi消息.
具体流程如下:
建立内核对象和数据结构
||
\/
分配栈空间
||
\/
挂起线程
||
\/
通知环境子系统
|| |--> 必要的设置和登记
\/
----->>>PspUserThreadStartup()
|| |--> DbgkCreateThread()/*在函数内部调用*/
|| ||
返回到上层函数 \/
||<--是<<- [DebugPort==NULL]
||
不是
||
\/ --------------------------------
[UserTime==0]--是-->>|通过DbgkpQueueMessage()函数 |
|| |发送DbgKmCreateProcessApi消息 |
不是 --------------------------------
||
\/
-------------------------------
| 通过DbgkpQueueMessage()函数 |
| 发送DbgkmCreateTheadApi消息 |
-------------------------------
- 进程和线程退出事件的采集过程
进程管理器的PspExitThread函数负责线程的退出和清除.
在函数销毁线程的结构和资源之前 , 该函数会调用调试
子系统的函数让调试器(如果有)得到处理机会.
如果退出的是一个进程中的最后一个线程,PspExitThread
会调用DbgkExitProcess函数 , 否则调用DbgkExitThread
函数.
DbgExitThread函数被调用后,会检查进程的DebugPort是否
位0,如果不为0,则会先将该进程挂起,然后通过 DbgkpQueueMessage
函数向DebugPort发送DbgKmExitThreadApi消息.并且会
等待DbgKmExitThreadApi函数返回才将挂起的线程恢复运行.
DbgExitProcess函数执行过程和DbgExitThread函数非常类似,
只不过发送的是DbgKmExitProcessApi消息.且没必要执行挂起
和恢复动作,因为进程管理器已经对该线程做了删除标记(??)
- 模块映射和反映射事件的采集过程
当系统要dll时,会首先判断该dll是否被加载过(判断条件是什么?)
如果是,则不会重复加载,只将该dll对应的内存页面映射到目标进程
的内存空间(如何得知要已加载的dll在内存中的位置和大小),并把该
dll的引用次数加1. 当一个进程退出或调用FreeLibrary函数卸载一个
Dll时 , 系统会从该进程的虚拟内存空间中把该Dll的映射删除(如何
得知映射到了进程的虚拟内存空间的哪个地址) , 并递减该Dll的引用
次数, 如果引用次数为0,那么该Dll会被彻底移出内存(从哪移出)
系统内核中的内存管理器(Memory Manager)便是负责DLl的映射和发映
射. 内存管理器使用Section对象来表示一块可被多个进程共享的内存
区域. 并设计了一系列的内核服务和函数来实现各种映射和反映射任务
NtMapViewOfSection函数就是用来映射模块的内核服务,
NtUnmapViewOfSection是用来反映射的.
当NtMapViewOfSection在把一个模块映像成功映射到指定进程空间中时,
(主要是使用MmMapViewOfSection映射),NtMapViewOfSection函数会调用
调试子系统的DbgkMapViewOfSection函数通知调试子系统.
模块映射过程如下:
- 递归遍历模块的输入表(LdrpWalkImportDescriptor)
- 加载输入表依赖的模块(LdrpLoadImportModule)
- 将模块映射到进程的虚拟空间(用户态函数:ZwMapViewOfSection)
- 将模块映射到进程的虚拟空间(内核态函数:NtMapViewOfSection)
- 通知调试子系统(DbgkMapViewOfSection)
- 检查DebugPort字段是否为空
- 如果为空则发送调试信息到调试端口(DbgkpQueueMessage)
MnUnmapViewOfSection函数的执行过程也类似 , 该函数会调用调试子系统
的DbgkUnmapViewOfSection函数,
DbgkUnmapViewOfSection函数内部会检测DebugPort不为空后,会发送
DbgKmUnLoadDllAPi消息
- 异常事件的采集
KiDispatchException函数:异常分发的枢纽,它会给每个异常安排最多两轮被处
理的机会对于每一轮处理机会, 它都会调用调试子系统的DbgkForwardException
函数来通知调试子系统.
,也可以向调试端口发
消息,具体决定给哪一个发消息 , 是由KiDispatchException函数在调用它时通
过传递一个布尔类型的形参给这个函数传参决定的.
如果DbgkForwardException决定了给异常端口发消息, 那么
DbgkForwardException函数会判断进程的DebugPort字段是否为空,如果不为空,
则通过DbgkpQueueMessage函数发送DbgKmExceptionApi消息.
调试事件的发送流程
-1 调试子系统服务器将消息发给调试器的过程
- 1.1 调试子系统接收到异常事件消息(在采集的时候)
- 1.2 调试子系统控制被调试进程(冻结除被调试进程的
当前线程之外的全部线程), 阻塞被调试进程的调
用线程进入等待状态
- 1.3 通知调试器来读取调试信息.并等待调试器回复
- 1.4 等到回复后,唤醒被调试进程的当前线程,恢复之前挂起
的线程.
- 1.1.1 调试子系统在内核函数用于描述和传递调试消息的结构:
{
typedef struct _DBGKM_APIMSG
{
PORT_MESSAGE h; // LPC端口消息结构,XP之前使用
DBGKM_APINUMBER ApiNumber; // 消息类型
ULONG ReturnedStatus; // 调试器的回复状态
union // 具体描述消息详情的联合结构
{
DBGKM_EXCEPTION Exception; // 异常
DBGKM_CREATE_THREAD CreateThread; // 创建线程
DBGKM_CREATE_PROCESS CreateProcess; // 创建进程
DBGKM_EXIT_THREAD ExitThread; // 线程退出
DBGKM_EXIT_PROCESS ExitProcess; // 进程退出
DBGKM_LOAD_DLL LoadDll; // 映射DLl
DBGKM_UNLOAD_DLL UnloadDll; // 反映射Dll
};
} DBGKM_MSG, *PDBGKM_MSG;
PS: DBGKM_APINUMBER ApiNumber;// 消息类型
就是"调试子系统采集调试事件的方法和过程"提到的调试器能够
采集到的调试事件(消息)
- 调试信息采集函数确认需要向调试子系统报告消息后(确认DebugPort),
会填写DBGMK_APIMSG结构,然后将其作为参数传给 DbgkpSendApiMessage
函数.
- DbgkpSendApiMessage 函数是用来将一个调试消息发送到调试子系统的.
函数原型:
NTSTATUS DbgkpSendApiMessage(PDBGKM_APIMSG ApiMsg,
PVOID Port,
BOOLEAN SuspendProcess
);
形参1 : 用来描述消息的详细信息
形参2 : 用来指定要发往的端口,大多数时候,就是EPROCESS结构中的
debugPort字段的值,偶尔是进程中的异常端口.即Exception
字段
形参3 : 如果该形参为真,那么该函数会先调用 DbgkpSuspendProcess
挂起当前进程. 然后发送消息.等收到消息回复后再调用
DbgkpResumeProcess 函数唤醒当前进程.
发消息时, 如果系统的版本是NT或win 2000,由于这两个系统的
调试子系统服务器位于用户态,因此在这些系统上可以使用
DbgkpSendApiMessage 函数. DbgkpSendApiMessage函数会通过
LPC机制来发送调试信息,这时,Port 参数指定的是一个LPC端口.
这个端口的监听者通常是windows环境子系统的服务进程. 即:
CSRSS, CSRSS 收到消息后会再转发给位于会话管理进程中的调试
子系统服务器(CSRSS 相当于转发). 调试子系统再通知等候调试事件的
调试器.
流程如下:
DbgkpSendApiMessage
||
\/
DbgkpSuspendProcess() 挂起当前进程
||
\/
DbgkpSendApiMessage()
|--调用 LpcRequestWaitReplyPort 函数完成
| 具体的LPC收发任务,该函数是阻塞的,只有
| 收到回复,该函数才会返回.
|
| LPC机制
|--> LpcRequestWaitReplyPort ---------------> CRSS
Port指定端口
-----------------------------------------------
CSRSS -->> 调试子系统 -->> 调试器
PS :
DbgkpSendApiMessage 函数只能在NT和win 2000 版本系统中会被调
用
- DbgkpQueueMessage 函数
如果系统的版本是XP或者是以上版本的系统, 那么将不会再继续使用函数
DbgkpSendApiMessage, 而是改为 DbgkpQueueMessage 函数.
函数原型:
NTSTATUS DbgkpQueueMessage(IN PEPROCESS Process,
IN PETHREAD Thread,
IN_OUT PDBGKM_APIMSG ApiMsg,
IN ULONG Flags,
IN PDEBUG_OBJECT TargetDebugObject
);
}
- {1.2,1.3,1.4}
调试子系统控制被调试进程详细过程
{
在调试子系统向调试器发送调试事件之前, 通常会先调用
DbgkpSuspendProcess()函数, 这个函数内部会调用 KeFreezeAllThreads()
冻结被调试进程中 除 调用线程之外 的所有线程. 接下来才执行实际的消息
发送函数, 也就是 DbgkpQueueMessage().
流程如下:
DbgkpSuspendProcess()
|
| 冻结被调试进程中所有线程(除被调试进程的当前线程)
|--> KeFreezeAllThreads()
|
| 发送消息到调试器
|---> DbgkpQueueMessage()
| ||
| \/
|-- 阻塞等待调试器回复
| ||
| \/
|-- 唤醒被调试进程的等待线程,
| ||
| \/
| 恢复之前挂起的线程.
|-- DbgkpResumeProcess()
|
| 恢复被调试进程中的所有线程
|--> KeThawAllThreads()
}
-2 调试子系统和调试器之间用于描述和传递调试消息的结构:
typedef struct _DEBUG_OBJECT
{
KEVENT EventsPresent; // 用于指示有调试事件发生的事件对象
FAST_MUTEX Mutex; // 用于同步的互斥对象
LIST_ENTRY StateEventListEntry; // 保存调试事件的链表
ULONG Flags; // 标志
}DEBUG_OBJECT,*P_DEBUG_OBJECT;
-2.1 StateEventListEntry:
- 用来存储调试事件的链表
-2.2 EventPresent
- 用来同步调试器进程和被调试进程,调试子系统服务器通过设置此事件来
通知调试器读取消息队列中的调试信息.
调试器进程通过 WaitFOrDebugEvent()函数来等待调试事件,这个函数对
应的 NtWaitFOrDebugEvent内核服务内部实际上等待的就是这个事件对象
-2.3 Mutex
- 用来锁定对这个数据结构的访问, 以防止多个线程同时读写造成数据错误
-2.4 Flags
- 该字段包含多个标志位, 比如 1 代表结束调试会话时是否终止被调试进
程,DebugSetProcessKillOnExit() 设置的就是这个标志位
-3 调试事件的产生和传递
-3.1 创建调试对象
- 当调试器与调试子系统建立连接时,调试子系统会调用内核API
NtCreateDebugObject()创建一个调试对象.
- 并将这个内核对象保存在调试器当前线程的线程环境块的
DbgSsReserved[1]字段.
- 一个线程的线程环境块的DbgSsReserved[1]字段保存的调试对象是这个调
试线程区别于其他普通线程的重要标志.
-3.2 设置调试对象
- 当调试器建立应用程序调试会话时, 会有两种情况:
- 被调试进程是在调试器中打开的
- 系统在创建被调试的进程时, 会把调试器线程TEB结构的
DbgSsReserved[1]字段中保存的调试对象句柄传递给创建进程的内核
服务.内核中的进程创建函数会将这个句柄所对应的调试对象指针赋
给新创建进程的 EPROCESS结构中的DebugPort字段.
- 调试器进程附加到被调试进程
- 系统会调用内核中的 DbgkpSetProcessDebugObject() 函数来将一个
创建好的调试对象附加到其参数所指定的进程中(被调试进程)
DbgkpSetProcessDebugObject() 函数内部除了将调试对象赋给
EPROCESS结构的DebugPort字段外, 还会调用
DbgkpMarkProcessPeb() 函数设置进程环境块的 BeingDebugged字段
-3.3 传递调试对象
- DbgkpQueueMessage() 函数用于向一个调试对象的消息队列追加调试事件.
- 指定 DbgkpQueueMessage()函数的调试对象的方法有两个:
- 直接在参数中指定调试对象
- 指定 EPROCESS 结构, DbgkpQueueMessage 函数会使用这个结
构中的 DebugPort 字段代替调试对象
- 调试对象的消息队列的每一个节点的结构: DEBUG_EVENT, 这个结构与调
试API的 DEBUG_EVENT 同名,但是内容不相同,为了避免混淆, 这里将内核
中的 DEBUG_EVENT 结构称为 DBGKM_DEBUG_EVENT,其结构定义如下:
typedef struct _DBGKM_DEBUG_EVENT
{
LIST_ENTRY EventList; // 与兄弟节点相互链接的节点结构
KEVENT ContinueEvent;// 用于等待调试器回复的事件对象
CLIENT_ID ClientId; // 调试事件所在线程的线程ID和进程ID
PEPROCESS Process; // 被调试进程的EPROCESS结构地址
PETHREAD Thread; // 被调试进程中触发调试事件的线程
// ETHREAD地址
NTSTATUS Statuc; // 对调试事件的处理结果
ULONG Flags; // 标志
PETHREAD BackoutThread;//产生假信息的线程
DBGKM_MSG ApiMsg; // 调试事件的详细信息
}DBGKM_DEBUG_EVENT,*P_DBGKM_DEBUG_EVENT
CLIENT_ID是一个包含两个DWORD字段的结构体,这两个DWORD字段分别表
示:
- 进程ID
- 线程ID
在把 DBGKM_DEBUG_EVENT 结构赋值之后, DbgkpQueueMessage() 函数会
把它插入到调试子系统的调试对象(DEBUG_OBJECT)中的消息链表中
(StateEventListEntry).
之后 DbgkpQueueMessage() 函数会根据参数Flag是否有NOWAIT标记,来选
择是否通知调试器来读取调试消息.
当Flag设置了NOWAIT标记,函数会返回.
如果没有设置, 函数会设置形参TargetDebugObject(调试对象)的
EventPresent字段(KEVENT),通知调试器来读取调试信息().
然后调试器会将 ContinueEvent(插入到调试对象链表的调试对象结构体
中的)传入 KeWaitForSingleObject()函数,等待调试器的回复.
调试器方面:
调试器中的一个线程使用了函数WaitforDebugEvent()函数,这个函数最终
会转到内核API: NtWaitForDebugEvent()。
当调试子系统设置了EventPresent字段(KEVENT), NtWaitForDebugEvent()
函数就会被唤醒, 然后就去读取一个调试事件(使用CLIENT_ID遍历匹配调
试事件链表的调试对象),读取到调试事件之后,先是在这个事件
DBGKM_DEBUG_EVENT结构的Flags字段中设置一个已读标志, 再调用函数
DbgkpConvertKernelToUserStateChange()将DBGKM_DEBUG_EVENT结构转换
成用户态使用的DBGUI_WAIT_STATE_CHANGE结构.
最后会通过 ContinueDebugEvent() 函数间接或直接调用
nt!NtDebugContinue 内核API. 而 NtDebugContinue()会根据参数中指定
的 CLIENT_ID结构找到要恢复的调试事件结构(可能是遍历调试事件链表),
找到之后, 设置它的 ContinueEvent事件对象, 使处于等待的被调试器的
等待线程唤醒而继续执行.
-3.4 清除调试对象
- 系统会调用 DbgkCLearProcessDebugObject()将被调试进程的DebugPort字段
恢复为NULL
- 遍历调试对象的消息队列(??),将关于这个进程的调试事件清除,但不破坏调试
对象.
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)