第8章 进程和线程通知
注册进程、线程与映像加载通知回调,把内核事件经队列搬运到用户模式,并掌握回调短小、队列有界与卸载注销的完整纪律。
学习目标
- 能注册进程、线程、映像加载三种通知回调,并解释各自回调参数与运行上下文
- 能改出“回调只登记、队列搬运、用户模式取走”的通知管道,并加入丢弃计数
- 能回答:回调里直接调用 IoCreateFile 打开日志文件会发生什么?为什么通知回调必须短小?
为什么大楼要装“入住和搬离”的通知系统
想象你管理一栋大楼的安保。谁搬进来了、谁搬走了、谁带什么大件行李进门,这些信息你得第一时间知道——但你不能派保安站在每个房间门口盯着,那会堵死走廊。正确做法:物业系统在入住/搬离时自动发通知给保安室,保安只记一笔,攒一批再上报给监控中心。
没有这套通知系统会怎样?你想统计“今晚楼里住了多少人”都得挨个敲门。而内核里,进程创建、线程开闭、程序加载都是高频事件——每秒钟几十上百次,没有一个高效的通知机制,安全软件、监控工具全都无从做起。
这一章装这套系统:注册三种自动通知(谁住进来、谁开新房间、谁搬大件)、在保安室只做快速登记(回调短小)、攒成队列再上报(用户模式通道),最后学会安全撤岗(卸载注销)。
进程通知回调:入住与搬离的自动登记
↡内核在进程创建或退出时调用的回调,通过 PsSetCreateProcessNotifyRoutineEx 注册,可附带创建标志 是监控进程生死的入口。注册后,每次任何进程创建或退出,内核都会调用你的回调,参数包括新进程 ID、父进程 ID 和创建标志(是否成功创建)。
回调的运行上下文接近 APC_LEVEL(第 6 章的 APC 机制),这意味着:不能等待、不能访问分页内存、动作必须极短。回调里唯一该做的是“填一条事件记录进队列”——把它当成保安室门口的登记簿,写一笔就完事。写文件、发网络请求这些重活,等搬进队列再说。
线程通知回调:更密集的房客变动
↡内核在线程创建或退出时调用的回调,通过 PsSetCreateThreadNotifyRoutine 注册,参数含进程 ID 与线程 ID 与进程通知同类,但事件密度高一个量级:一个进程可能开几十个线程,线程的创建退出是系统里最频繁的内核事件之一。
它的参数只有进程 ID 和线程 ID——没有父线程信息,也没有创建标志。实现上与进程通知共用同一套“回调登记 + 队列搬运”管道,只是回调体必须更短、队列的丢弃策略必须更激进。想关联“哪个线程属于哪个进程”,用进程 ID 去查你自己维护的进程表。
映像加载通知:谁搬进了什么大件
↡内核在进程加载可执行映像(EXE/DLL)时调用的回调,通过 PsSetLoadImageNotifyRoutine 注册,参数含映像路径与基址 监控的是“程序把什么模块装进了自己的地址空间”。每次加载 EXE 或 DLL,回调收到完整映像路径(已带 \SystemRoot 前缀)和映像基址。
这个回调的价值在关联分析:结合进程通知,可以还原“哪个进程、什么时候、加载了什么模块”的完整时间线——这正是安全软件监控可疑 DLL 注入的标准手段。注意路径是内核格式,发给用户模式前要转成用户能读的形式;回调同样必须短小。
事件队列:登记簿要排队上报
回调不能阻塞、用户模式读取不能等,中间的缓冲就是↡回调与用户模式之间的有界环形缓冲区,回调写入事件、后台线程批量取走,满时丢弃并计数。它是典型的“生产者—消费者”:回调(生产者)只往队列尾写一条记录(自旋锁保护,第 6 章);取走的线程(消费者)把一批记录打包交给用户模式。
队列必须有界:事件爆发(如批量启动 100 个进程)时队列会满。满了怎么办?丢弃并计数——让客户端能从计数感知“中间丢了 N 条”。丢掉不可察觉是事故,丢掉可统计是设计。队列的容量、丢弃策略和序号字段,是协议的一部分,两端都要遵守。
用户模式通道:监控中心的大屏幕
队列里的数据最终要交给用户模式程序,通道是↡用户程序通过设备 IOCTL 从驱动批量读取事件记录的协议,包含序号与丢弃计数:客户端打开控制设备(第 4 章的设备对象),用 IOCTL 一次取走一批事件,循环轮询或等待“有新数据”事件。
协议设计要点:每条记录带序号(发现空洞 = 有丢弃),响应里带丢弃计数(精确知道丢了多少),批次大小由客户端指定(控制单次拷贝量)。两侧只通过协议结构体交互,回调侧永远不直接触碰用户模式的内存。
动手:走一遍通知管道
下面的可视化把整条通知管道画成图:事件源(三种通知类型)→ 内核回调 → 事件队列 → 用户模式客户 → 卸载注销。点事件源按钮观察回调与队列变化;打开“队列满”开关,再点事件源——观察丢弃计数增长。
猜一猜:打开“队列满”后连续点击事件源按钮——新事件被丢弃时计数会变吗?用“丢弃可统计”的设计思路解释这个计数为什么必须存在。
⚡ 最新事件:[proc] PID=1234 PPID=888 创建 (CreationStatus=0)
用 PsSetCreateProcessNotifyRoutineEx 注册:每次进程创建或退出,内核调用你的回调,传入进程 ID、父进程 ID 和创建标志。回调运行在 APC_LEVEL 附近,必须短小——只记录事件、填队列,绝不阻塞或做重活。
代码对照:通知驱动的最小实现
这一节按原书结构拆解通知驱动的核心代码:DriverEntry 注册、进程回调、队列与用户模式读取、Unload 注销。四个段覆盖驱动侧全部关键代码,完整工程见章末来源链接。
段一:DriverEntry —— 注册三类回调
入口做三件事:创建设备对象(用户模式取数据的门)、初始化队列和锁、注册三个通知回调。注册返回 NTSTATUS,失败要回滚已注册的回调——顺序注册、逆序回滚。
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING)
{
UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\Notify");
PDEVICE_OBJECT dev;
NTSTATUS st = IoCreateDevice(DriverObject, sizeof(CTX),
&devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &dev);
if (!NT_SUCCESS(st)) return st;
InitQueue(&((CTX*)dev->DeviceExtension)->queue);
// 注册三个通知回调(逆序回滚)
st = PsSetCreateProcessNotifyRoutineEx(OnProcessNotify, FALSE);
if (!NT_SUCCESS(st)) { IoDeleteDevice(dev); return st; }
st = PsSetCreateThreadNotifyRoutine(OnThreadNotify);
if (!NT_SUCCESS(st)) {
PsRemoveCreateProcessNotifyRoutine(OnProcessNotify);
IoDeleteDevice(dev); return st;
}
st = PsSetLoadImageNotifyRoutine(OnImageNotify);
if (!NT_SUCCESS(st)) { /* 同样逆序回滚 */ }
DriverObject->DriverUnload = OnUnload;
return STATUS_SUCCESS;
}DriverEntry
│
├─→ IoCreateDevice(用户模式的取数门)
├─→ InitQueue:队列 + 自旋锁 + 序号
├─→ PsSetCreateProcessNotifyRoutineEx(OnProcessNotify)
├─→ PsSetCreateThreadNotifyRoutine(OnThreadNotify)
├─→ PsSetLoadImageNotifyRoutine(OnImageNotify)
│ 注册失败 → 逆序回滚已注册的回调
└─→ DriverUnload = OnUnload段二:进程回调 —— 只登记,不干活
回调体是整章纪律的浓缩:取参数 → 填一条记录 → 入队(可能被丢弃)→ 返回。绝不在这里做文件操作、内存分配或等待。CreateInfo 非空表示进程创建(含是否成功),为空表示退出——注意区分。
VOID OnProcessNotify(PEPROCESS Process, HANDLE Pid,
PPS_CREATE_NOTIFY_INFO CreateInfo)
{
UNREFERENCED_PARAMETER(Process);
CTX* ctx = GetCtx(); // 全局上下文
EVENT_REC rec = { 0 };
rec.kind = CreateInfo ? EV_PROC_CREATE : EV_PROC_EXIT;
rec.pid = (ULONG)(ULONG_PTR)Pid;
if (CreateInfo) {
rec.ppid = (ULONG)(ULONG_PTR)CreateInfo->ParentProcessId;
// 创建标志:CreateInfo->CreationStatus 非 0 表示创建失败
}
// 入队:满则丢弃并递增丢弃计数(生产者的唯一动作)
QueuePush(&ctx->queue, &rec);
}任何进程创建/退出
│
▼
OnProcessNotify(APC_LEVEL)
│
├─→ 组装 EVENT_REC(kind/pid/ppid)
├─→ QueuePush(自旋锁保护)
│ ├─→ 队列有空位 → 写入,序号 +1
│ └─→ 队列已满 → 丢弃,丢弃计数 +1
│
└─→ 返回(全程无等待、无分页访问)段三:线程与映像回调 —— 同一管道的两种记录
线程和映像回调复用同一队列。线程记录只有 pid/tid;映像记录带路径——路径是内核格式(\SystemRoot\...),存进队列前要复制到非分页缓冲(回调上下文不能碰分页内存)。
VOID OnThreadNotify(HANDLE Pid, HANDLE Tid, BOOLEAN Create)
{
CTX* ctx = GetCtx();
EVENT_REC rec = { 0 };
rec.kind = Create ? EV_THREAD_CREATE : EV_THREAD_EXIT;
rec.pid = (ULONG)(ULONG_PTR)Pid;
rec.tid = (ULONG)(ULONG_PTR)Tid;
QueuePush(&ctx->queue, &rec);
}
VOID OnImageNotify(PUNICODE_STRING FullImageName,
HANDLE Pid, PIMAGE_INFO ImageInfo)
{
UNREFERENCED_PARAMETER(ImageInfo);
CTX* ctx = GetCtx();
EVENT_REC rec = { 0 };
rec.kind = EV_IMAGE_LOAD;
rec.pid = (ULONG)(ULONG_PTR)Pid;
if (FullImageName && FullImageName->Length < MAX_PATH * 2) {
// 路径复制到非分页记录缓冲(回调里不能访问分页内存)
RtlCopyMemory(rec.imagePath,
FullImageName->Buffer, FullImageName->Length);
rec.imageLen = FullImageName->Length;
}
QueuePush(&ctx->queue, &rec);
}线程创建/退出(高频!)
│ → OnThreadNotify:pid/tid 一条,入队
│
进程加载 EXE/DLL
│ → OnImageNotify:路径 + 基址 + pid
│ · 路径复制进非分页记录缓冲
│ · 入队(满则丢弃计数)
│
共同点:回调极短,全部动作 = 组装 + 入队段四:用户模式读取与卸载注销
用户模式通过 IOCTL 批量取走事件:驱动从队列头拷贝一批到输出缓冲,响应里带上丢弃计数。卸载时逆序注销三个回调——注销返回后内核保证不再调用,才能释放队列。
NTSTATUS GetEvents(PIRP Irp, PIO_STACK_LOCATION stack)
{
CTX* ctx = GetCtx();
ULONG outLen = stack->Parameters.DeviceIoControl.OutputBufferLength;
auto* out = (GET_EVENTS_OUT*)Irp->SystemBuffer;
if (outLen < sizeof(GET_EVENTS_OUT)) {
Irp->IoStatus.Status = STATUS_BUFFER_TOO_SMALL;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_BUFFER_TOO_SMALL;
}
ULONG n = QueueDrain(&ctx->queue, out->records,
(outLen - sizeof(ULONG)) / sizeof(EVENT_REC));
out->dropped = ctx->queue.dropped; // 丢弃计数随响应返回
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information =
sizeof(ULONG) + n * sizeof(EVENT_REC);
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}卸载部分逆序注销三个回调,注销返回后再排空队列、释放资源、删设备。
VOID OnUnload(PDRIVER_OBJECT DriverObject)
{
// 1. 先注销:内核保证不再调用回调
PsRemoveCreateProcessNotifyRoutine(OnProcessNotify);
PsRemoveCreateThreadNotifyRoutine(OnThreadNotify);
PsRemoveLoadImageNotifyRoutine(OnImageNotify);
// 2. 再排空队列、释放资源、删设备
QueueFree(&GetCtx()->queue);
IoDeleteDevice(DriverObject->DeviceObject);
}// 轮询取事件(或等"有新事件"信号再取)
for (;;) {
GET_EVENTS_OUT out = { 0 };
DWORD returned = 0;
DeviceIoControl(h, IOCTL_GET_EVENTS,
nullptr, 0, &out, sizeof(out), &returned, nullptr);
if (out.dropped > lastDropped) {
// 丢弃计数变了 → 中间丢了 (out.dropped - lastDropped) 条
printf("丢失 %u 条事件\n", out.dropped - lastDropped);
}
lastDropped = out.dropped;
for (ULONG i = 0; i < returned / sizeof(EVENT_REC); i++) {
// 处理 out.records[i](kind/pid/tid/路径)
}
Sleep(100);
}容易踩的坑
小结
- 三类通知:进程(含创建标志)、线程(高频)、映像(带路径基址)
- 回调运行在 APC_LEVEL 附近:只登记入队,绝不等待与分页访问
- 事件队列有界:满则丢弃并计数,客户端可感知丢失量
- 用户模式经 IOCTL 批量取事件,协议含序号与丢弃计数
- 卸载逆序注销回调,返回后再排空队列释放资源
练习
问题 1: 在 Demo 中打开“队列满”开关后连续点击事件源,观察丢弃计数。为什么“丢弃可统计”比“不丢事件”更符合内核回调的设计?
问题 2: 你的进程通知回调需要把事件写到文件。按本章纪律给出正确的实现方案(回调里怎么做、谁来做文件写入、怎么通知写入者)。
问题 3(独立实现题): 实现线程通知回调与队列读取的协议部分:1)写出 OnThreadNotify 回调(组装 pid/tid 记录并入队);2)写出 GetEvents 分发例程(校验输出长度、批量取走、返回丢弃计数);3)写出用户模式解析响应的片段(感知丢弃并打印记录)。要求包含序号与丢弃计数两个协议字段。
知识点对照
8.2 实现进程通知
实现进程通知要注册 PsSetCreateProcessNotifyRoutine 回调并保存通知句柄。
8.2.1 DriverEntry例程
DriverEntry例程中注册进程与线程通知回调,保存通知句柄,卸载时注销并释放相关资源。
8.2.2 处理进程退出通知
处理进程退出通知时读取退出状态,把事件封装后交给用户模式客户处理,同时清空关联队列并释放条目。
8.2.3 处理进程创建通知
处理进程创建通知时检查进程属性与父进程信息,据此决定是否把创建事件上报给用户模式客户程序。
8.3 将数据提供给用户模式
将数据提供给用户模式用事件配合共享缓冲区完成,或者通过设备 I/O 请求让客户程序主动读取数据。
8.7 总结
本章总结了进程、线程与映像加载三类通知的注册、处理与数据上报的完整方法、流程和注意事项,这也是本章的核心要点。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 进程通知回调
内核在进程创建或退出时调用的函数,用 PsSetCreateProcessNotifyRoutineEx 注册。回调拿到进程 ID、父进程 ID 和创建标志。相当于大楼物业在住户入住/搬离时自动通知保安室。
- 线程通知回调
内核在线程创建或退出时调用的函数,用 PsSetCreateThreadNotifyRoutine 注册,参数只有进程 ID 和线程 ID。事件频率比进程通知高一个量级,回调必须更短。
- 映像加载通知
内核在进程加载 EXE/DLL 时调用的函数,用 PsSetLoadImageNotifyRoutine 注册,参数含映像路径和基址。常用于还原“哪个进程什么时候加载了什么模块”的监控时间线。
- 事件队列
回调与用户模式之间的有界环形缓冲区。回调(生产者)只往队尾写记录,取走方(消费者)批量读取;满时丢弃并计数。相当于保安室的登记簿:先快速记一笔,攒够了再统一上报。
- 用户模式通道
用户程序通过设备 IOCTL 从驱动批量读取事件记录的协议,包含序号与丢弃计数字段。回调侧只写队列,用户侧只读队列,两侧互不阻塞。相当于监控中心的大屏幕:数据从登记簿批量同步过来。