第8章 进程和线程通知

注册进程、线程与映像加载通知回调,把内核事件经队列搬运到用户模式,并掌握回调短小、队列有界与卸载注销的完整纪律。

学习目标

  • 能注册进程、线程、映像加载三种通知回调,并解释各自回调参数与运行上下文
  • 能改出“回调只登记、队列搬运、用户模式取走”的通知管道,并加入丢弃计数
  • 能回答:回调里直接调用 IoCreateFile 打开日志文件会发生什么?为什么通知回调必须短小?

为什么大楼要装“入住和搬离”的通知系统

想象你管理一栋大楼的安保。谁搬进来了、谁搬走了、谁带什么大件行李进门,这些信息你得第一时间知道——但你不能派保安站在每个房间门口盯着,那会堵死走廊。正确做法:物业系统在入住/搬离时自动发通知给保安室,保安只记一笔,攒一批再上报给监控中心。

没有这套通知系统会怎样?你想统计“今晚楼里住了多少人”都得挨个敲门。而内核里,进程创建、线程开闭、程序加载都是高频事件——每秒钟几十上百次,没有一个高效的通知机制,安全软件、监控工具全都无从做起。

这一章装这套系统:注册三种自动通知(谁住进来、谁开新房间、谁搬大件)、在保安室只做快速登记(回调短小)、攒成队列再上报(用户模式通道),最后学会安全撤岗(卸载注销)。

进程通知回调:入住与搬离的自动登记

是监控进程生死的入口。注册后,每次任何进程创建或退出,内核都会调用你的回调,参数包括新进程 ID、父进程 ID 和创建标志(是否成功创建)。

回调的运行上下文接近 APC_LEVEL(第 6 章的 APC 机制),这意味着:不能等待、不能访问分页内存、动作必须极短。回调里唯一该做的是“填一条事件记录进队列”——把它当成保安室门口的登记簿,写一笔就完事。写文件、发网络请求这些重活,等搬进队列再说。

线程通知回调:更密集的房客变动

与进程通知同类,但事件密度高一个量级:一个进程可能开几十个线程,线程的创建退出是系统里最频繁的内核事件之一。

它的参数只有进程 ID 和线程 ID——没有父线程信息,也没有创建标志。实现上与进程通知共用同一套“回调登记 + 队列搬运”管道,只是回调体必须更短、队列的丢弃策略必须更激进。想关联“哪个线程属于哪个进程”,用进程 ID 去查你自己维护的进程表。

映像加载通知:谁搬进了什么大件

监控的是“程序把什么模块装进了自己的地址空间”。每次加载 EXE 或 DLL,回调收到完整映像路径(已带 \SystemRoot 前缀)和映像基址。

这个回调的价值在关联分析:结合进程通知,可以还原“哪个进程、什么时候、加载了什么模块”的完整时间线——这正是安全软件监控可疑 DLL 注入的标准手段。注意路径是内核格式,发给用户模式前要转成用户能读的形式;回调同样必须短小。

事件队列:登记簿要排队上报

回调不能阻塞、用户模式读取不能等,中间的缓冲就是。它是典型的“生产者—消费者”:回调(生产者)只往队列尾写一条记录(自旋锁保护,第 6 章);取走的线程(消费者)把一批记录打包交给用户模式。

队列必须有界:事件爆发(如批量启动 100 个进程)时队列会满。满了怎么办?丢弃并计数——让客户端能从计数感知“中间丢了 N 条”。丢掉不可察觉是事故,丢掉可统计是设计。队列的容量、丢弃策略和序号字段,是协议的一部分,两端都要遵守。

用户模式通道:监控中心的大屏幕

队列里的数据最终要交给用户模式程序,通道是:客户端打开控制设备(第 4 章的设备对象),用 IOCTL 一次取走一批事件,循环轮询或等待“有新数据”事件。

协议设计要点:每条记录带序号(发现空洞 = 有丢弃),响应里带丢弃计数(精确知道丢了多少),批次大小由客户端指定(控制单次拷贝量)。两侧只通过协议结构体交互,回调侧永远不直接触碰用户模式的内存。

动手:走一遍通知管道

下面的可视化把整条通知管道画成图:事件源(三种通知类型)→ 内核回调 → 事件队列 → 用户模式客户 → 卸载注销。点事件源按钮观察回调与队列变化;打开“队列满”开关,再点事件源——观察丢弃计数增长。

猜一猜:打开“队列满”后连续点击事件源按钮——新事件被丢弃时计数会变吗?用“丢弃可统计”的设计思路解释这个计数为什么必须存在。

⚡ 进程 / 线程 / 映像通知管道
通知管道:内核事件 → 回调 → 队列 → 用户模式事件源(选择一种通知类型)进程创建/退出线程创建/退出映像加载内核回调(注册:PsSetCreateProcessNotifyRoutineEx 等)回调运行:APC_LEVEL,禁止等待回调只做登记:填一条事件记录,绝不做重活、绝不等待事件队列(环形缓冲,自旋锁保护)[proc] PID=1234 PPID=888 创建[proc] PID=1234 退出[thrd] PID=1234 TID=5678 创建[img ] PID=1234 \SystemRoot\System32\notepad.exe[thrd] PID=1234 TID=5679 创建[proc] PID=1240 PPID=888 创建丢弃计数:0 条(客户端可通过协议感知丢失)队列有空位:事件正常入队用户模式客户(IOCTL 批量取走)DeviceIoControl(IOCTL_GET_EVENTS) → 一次取回一批事件记录协议含序号与丢弃计数:客户端能发现"中间少了 N 条"卸载:先注销回调(PsRemove*NotifyRoutine)→ 再排空队列 → 最后释放缓冲区注销返回后内核保证不再调用回调,此时才能安全释放资源

⚡ 最新事件:[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;
}

段二:进程回调 —— 只登记,不干活

回调体是整章纪律的浓缩:取参数 → 填一条记录 → 入队(可能被丢弃)→ 返回。绝不在这里做文件操作、内存分配或等待。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);
}

段三:线程与映像回调 —— 同一管道的两种记录

线程和映像回调复用同一队列。线程记录只有 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);
}

段四:用户模式读取与卸载注销

用户模式通过 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);
}

容易踩的坑

小结

  • 三类通知:进程(含创建标志)、线程(高频)、映像(带路径基址)
  • 回调运行在 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 从驱动批量读取事件记录的协议,包含序号与丢弃计数字段。回调侧只写队列,用户侧只读队列,两侧互不阻塞。相当于监控中心的大屏幕:数据从登记簿批量同步过来。

讨论

评论区加载中…