R0进程保护驱动实战:从内存读写到ObRegisterCallbacks拦截
简介面向Windows内核驱动开发与安全研究方向的R0进程保护驱动源码包聚焦Ring 0层级的进程保护与内存读写机制适用于游戏反作弊、调试工具、底层安全分析等场景适合系统学习驱动开发或准备开展内核安全项目的读者。压缩包约36.67MB已有1003人学习参考。内容围绕进程保护驱动的核心实现展开源码展示了如何在最高权限层级拦截或规避对目标进程的非法访问并完成对游戏进程内存的读写操作有助于理解Windows内核对象、回调机制、句柄操作与硬件交互的底层原理。压缩包内附带Windows内核开发项目工程与示例代码可作为内核编程起步资料也可配合调试工具验证驱动加载与通信流程帮助开发者掌握高效、安全驱动的编写方法并迁移到实际反作弊场景中。1. 为什么 R0 进程保护驱动从一句“结束进程”开始讲把“保护进程”做烂的驱动大多败在同一句话上把目标进程的 DebugPort 清空就宣称反调试把任务管理器里的窗口标题隐藏就宣称保护。真正到了对抗场景对方只需要一行OpenProcess(PROCESS_TERMINATE)加TerminateProcess就能让前面所有伪装全部失效。R0 进程保护驱动要做的是在句柄创建路径上做裁决谁有权限触碰目标进程、以什么权限触碰、触碰时内核回调是否放行。读写驱动是这套源码的地基它决定你能不能在内核态安全地访问任意进程内存保护进程是上层策略它决定 Endpoint 和反调试哪个先死。适合已经能写出最小 WDM 驱动、想往进程对抗方向深入的人按“先读写着陆、再保护、再验证”的顺序推进避免一上来研究回调还没跑起来就蓝屏。2. R0 读写驱动的源码地基设备对象、IOCTL 与跨进程内存2.1 DriverEntry 里必须出现的四样东西R0 读写驱动源码第一眼看上去都是同一个骨架DriverEntry、DriverUnload、Create/Close 分发、DeviceControl 分发。四个函数里前两个决定驱动能否被sc start起来后两个决定用户态进程能不能拿到设备句柄并把读写请求送进内核。DriverEntry 里承担的注册工作固定是这几条IoCreateDevice 创建设备对象、IoCreateSymbolicLink 暴露给用户态的访问入口、IoRegisterDriverReinitialization 一般用不到但 DriverObject-DriverUnload 必须赋值。下面是最小可编译的入口段NTSTATUS DriverEntry(PDRIVER_OBJECT pDriver, PUNICODE_STRING regPath) { PDEVICE_OBJECT pDev NULL; NTSTATUS status IoCreateDevice( pDriver, 0, NULL, // 设备名先不写后面通过符号链接暴露 FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, pDev); if (!NT_SUCCESS(status)) return status; pDev-Flags | DO_BUFFERED_IO; // 统一走缓冲 I/O避免 METHOD_NEITHER 内核态地址校验 pDriver-MajorFunction[IRP_MJ_CREATE] DispatchCreateClose; pDriver-MajorFunction[IRP_MJ_CLOSE] DispatchCreateClose; pDriver-MajorFunction[IRP_MJ_DEVICE_CONTROL] DispatchDeviceControl; pDriver-DriverUnload DriverUnload; return STATUS_SUCCESS; }IoCreateDevice 的第四个参数 FILE_DEVICE_UNKNOWN 只是设备类型分类第二个参数 DeviceExtensionSize 在这里传 0实际驱动一般会用 DeviceExtension 保存回调句柄、保护进程列表和读写缓冲区锁建议在源码层面预留一个结构体而不是传 0。第五个参数 FILE_DEVICE_SECURE_OPEN 会让打开设备时检查设备对象所在目录的安全描述符普通用户态工具默认没权限打开这对保护型驱动是合理的默认行为想给低权限进程开放时再单独 SetSecurityObject。注意 IoCreateDevice 创建的是设备对象不是用户态能看见的文件路径。要让人能 OpenFile 到它必须再建立符号链接。2.2 符号链接与打开设备时的权限检查R0 驱动要暴露读写接口常见做法是创建设备名\Device\ProcGuard再建立\??\ProcGuard符号链接。用户态代码用CreateFileW(L\\\\.\\ProcGuard, ...)就能打开。这里有一个隐含知识点\??\目录是 Win32 命名空间里对驱动器号、DOS 设备名的映射用户态看到的\\.\ProcGuard最终指向的就是\??\ProcGuard。符号链接创建的代码通常长这样UNICODE_STRING devName RTL_CONSTANT_STRING(L\\Device\\ProcGuard); UNICODE_STRING linkName RTL_CONSTANT_STRING(L\\??\\ProcGuard); status IoCreateSymbolicLink(linkName, devName); if (!NT_SUCCESS(status)) { IoDeleteDevice(pDev); return status; }把这段放进 DriverEntry 的返回值判断里如果符号链接失败设备对象要立刻删掉否则卸载时 IoDeleteDevice 找不到设备会留下孤儿对象。符号链接的名字最好不要用太通用的名字生产环境里一般带版本或厂商前缀避免被别的驱动抢先占名系统里已经有一堆\??\链接时重名不会报错而是静默覆盖这也是一个容易踩的坑。打开设备后用户态发的不是 ReadFile/WriteFile而是 DeviceIoControl。把控制码和请求结构体定义在同一个头文件里R0 和用户态共用避免结构体对齐不一致。2.3 用 MmCopyVirtualMemory 实现 R0 读写驱动的跨进程访问R0 读写驱动的核心诉求是给定目标 PID 和一个内存地址在当前进程上下文里读写目标进程内存。早期驱动常用 KeStackAttachProcess 切换进程上下文后直接解引用指针现在的习惯是用 MmCopyVirtualMemory它会处理页表衔接和异常代码量也小得多。这里给一个最小实现typedef struct _RW_REQUEST { HANDLE TargetPid; // 0 表示当前进程 PVOID TargetAddr; PVOID Buffer; SIZE_T Length; BOOLEAN Write; // TRUE写目标内存FALSE读目标内存 } RW_REQUEST; NTSTATUS RwCopyMemory(PRW_REQUEST req) { PEPROCESS target PsInitialSystemProcess; PEPROCESS current IoGetCurrentProcess(); SIZE_T done 0; if (req-TargetPid ! 0) { NTSTATUS st PsLookupProcessByProcessId(req-TargetPid, target); if (!NT_SUCCESS(st)) return st; } // 以 KernelMode 执行避免 PreviousMode 检查拦截缓冲地址 return MmCopyVirtualMemory( req-Write ? current : target, // 源进程 req-Write ? (PVOID)req-Buffer : req-TargetAddr, req-Write ? target : current, // 目标进程 req-Write ? req-TargetAddr : (PVOID)req-Buffer, req-Length, KernelMode, done); }这段代码的关键不是“把 Buffer 换成 Process 和地址”而是四个参数的方向性。写目标内存时源进程是当前进程、源地址是 Buffer读目标内存时源进程是目标进程、源地址是 TargetAddr。方向搞反的结果是内存越界读蓝屏概率极高。PsLookupProcessByProcessId 返回的 EPROCESS 引用需要用 ObDereferenceObject 释放驱动源码里如果漏了这一步长时间运行会看到对象引用泄漏进程无法正常退出。提示IoGetCurrentProcess 返回的 EPROCESS 不需要释放PsInitialSystemProcess 也同理只有 PsLookupProcessByProcessId 这种返回引用计数的 API 才需要配对。这个区分在 R0 驱动读写源码里是排错时最常见的点之一。2.4 三种缓冲方法与 DeviceIoControl 的参数约定用户态 DeviceIoControl 传下来的缓冲区内核态用什么方式接收由控制码里的 Method 位决定。常见的是 METHOD_BUFFERED系统会分配一块与 InputBufferLength/OutputBufferLength 较大值相同的缓冲区内核从 SystemBuffer 里读输入、写输出对驱动源码来说代码最安全但大块数据会有一次复制开销METHOD_IN_DIRECT 和 METHOD_OUT_DIRECT 适合大吞吐的读写场景后者配合 MDL驱动源码复杂度和出错的概率同时上升。这里给一张参数对照表Method 值内核访问方式典型 IOCTL 场景METHOD_BUFFERED输入输出共用 SystemBuffer由 I/O 管理器复制小型控制命令、注册表类数据METHOD_IN_DIRECT输出用 SystemBuffer输入用 Irp-MdlAddress批量写入目标进程内存METHOD_OUT_DIRECT输入用 SystemBuffer输出用 Irp-MdlAddress批量读取目标进程内存METHOD_NEITHER使用用户态原始地址需要自行 ProbeForRead/Write追求零拷贝的高速驱动方法值写错最典型的症状是 DeviceIoControl 返回 ERROR_INVALID_PARAMETER 而不是请求失败。因为 I/O 管理器在分发前先按 Method 解释控制码缓冲区模型不匹配就直接拒绝根本进不到你自己的 DispatchDeviceControl。控制码本身用CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_READ_ACCESS | FILE_WRITE_ACCESS)生成。0x801 是功能号选 0x800 以上是为了避开系统保留区Access 位是用户态打开设备句柄时要求的权限生成动态库时两边用同一个宏展开这样最不容易出现一边 Buffer 一边 DIRECT 的错位。3. 保护进程的“免死金牌”用回调在句柄创建路径拦截 TerminateProcess3.1 为什么保护进程不能靠 Hook 系统调用早期驱动保护进程的普遍做法是 inline hook NtTerminateProcess 或 NtOpenProcess在 R0 层判断目标 PID 后直接返回 STATUS_ACCESS_DENIED。这个方案在 x86 时代还能勉强站住到 x64 上变成两条死路一是 PatchGuard 会周期检测关键内核函数的指令是否被修改inline hook 大概率触发 0x109 蓝屏二是 EDR 和安全软件也在挂同一批系统调用两个驱动改同一段指令改挂顺序不同结果就是其中一方被覆盖。进程保护要做的是“在 Windows 自己提供的过滤点上做裁决”而不是跟系统调用实现较劲。微软官方给出的过滤点就是 ObRegisterCallbacks它截获的是进程对象句柄的创建和复制操作恰好覆盖了 OpenProcess 到 TerminateProcess 之间的整条路径。3.2 注册 ObRegisterCallbacks 的最小驱动源码实现ObRegisterCallbacks 需要在 DriverEntry 或受控初始化路径里注册用户态传过来一个控制码来动态启用保护时也能注册但卸载时必须在 DriverUnload 里配对 ObUnRegisterCallbacks。注册结构体分两层外层是 OB_CALLBACK_REGISTRATION内层是 OB_OPERATION_REGISTRATION 数组。注意 Version 字段必须写 OB_FLT_REGISTRATION_VERSIONAltitude 必须是一个唯一的字符串写错任意一个都会返回 STATUS_INVALID_PARAMETER。static OB_CALLBACK_REGISTRATION_HANDLE g_hCb NULL; NTSTATUS RegisterProcessGuard() { OB_CALLBACK_REGISTRATION cbReg { 0 }; OB_OPERATION_REGISTRATION opReg { 0 }; cbReg.Version OB_FLT_REGISTRATION_VERSION; cbReg.OperationRegistrationCount 1; cbReg.Altitude L321345; cbReg.RegistrationContext NULL; opReg.ObjectType PsProcessType; opReg.Operations OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE; opReg.PreOperation PreProcessGuard; opReg.PostOperation NULL; cbReg.OperationRegistration opReg; return ObRegisterCallbacks(cbReg, g_hCb); }Altitude 字符串不能明显的“321345”和其他驱动重复正式发布前需要到微软申请一个合法高度自用和调试阶段找一个 32xxxx 段落的数值即可。海拔字符串中间的段位决定了回调的先后次序进程保护驱动一般希望越早看到越安全选低海拔段会更早介入但海拔越低受其它过滤器的干扰也越大并没有绝对的优劣。3.3 PreOperation 回调里如何拦住 PROCESS_TERMINATE回调函数原型是 OB_PREOP_CALLBACK_STATUS它拿到的 OB_PRE_OPERATION_INFORMATION 里有三个关键字段Object 是被创建或复制的进程 EPROCESSOperation 是 CREATE 还是 DUPLICATECreateOrDuplicate.OriginalDesiredAccess 是请求的访问权限。拦截 TerminateProcess 的本质是掐掉其中的 PROCESS_TERMINATE 权限位。OB_PREOP_CALLBACK_STATUS PreProcessGuard( PVOID regCtx, POB_PRE_OPERATION_INFORMATION info) { if (info-ObjectType ! PsProcessType) return OB_PREOP_SUCCESS; PEPROCESS proc (PEPROCESS)info-Object; HANDLE pid PsGetProcessId(proc); if (pid IsProtectedProcess(pid)) { ACCESS_MASK access info-Parameters-CreateOrDuplicate.OriginalDesiredAccess; if (access PROCESS_TERMINATE) { // 策略一去掉终止权限但仍允许打开句柄 info-Parameters-CreateOrDuplicate.OriginalDesiredAccess ~PROCESS_TERMINATE; // 策略二直接拒绝整个 OpenProcess连读取权限都不给 // info-Status STATUS_ACCESS_DENIED; } } return OB_PREOP_SUCCESS; }直接拒绝打开和去掉终止权限是两种完全不同的保护语义。去掉权限后其他进程可以 OpenProcess(PROCESS_QUERY_INFORMATION) 查看进程信息但不能结束它直接拒绝后目标进程对任何进程都不可见也不可触碰连 ReadProcessMemory 都断掉适合游戏反作弊这类强隔离场景。策略二会把正常调试器的附加也一并拒绝调试自己写的驱动时经常因此怀疑保护没生效调试时建议临时把 IsProtectedProcess 关掉。注意不要同时设置 Status 和保留权限位。设置 Status 为非零值表示整个打开操作失败OriginalDesiredAccess 改不改已经没有意义只清权限位则句柄能创建但句柄上的权限集合里不再有 PROCESS_TERMINATETerminateProcess 调用时会返回 STATUS_ACCESS_DENIED。两种做法选一个混着写反而让行为不可预期。IsProtectedProcess 里一般用 PsGetProcessId 拿 PID 和一个进程名列表做匹配也可以直接比对进程创建时记录的 EPROCESS 值。比对 EPROCESS 指针更直接但进程退出后 EPROCESS 会被回收保存的指针变成悬垂指针需要配合 PostOperation 或进程退出通知来清理否则后续每次 OpenProcess 都在解引用一块已释放的内存。3.4 回调方案的三个边界边界一ObRegisterCallbacks 只拦句柄创建和复制已经握有句柄的进程不经过这个回调。外部进程如果在保护开启前就 OpenProcess 拿到 PROCESS_TERMINATE 句柄之后直接 TerminateProcess回调根本不会触发。所以驱动在保护一个进程时最好等待进程刚创建就立即注册保护或者对系统里的其它进程做一次句柄过滤后者已经超出这个标题的最小范围。边界二回调运行在任意线程上下文里不能在里面调用需要睡眠、分页的 API。查进程名用 PsGetProcessImageFileName 这类非分页路径的查询不要做 IoGetDeviceObjectPointer、ZwQuerySystemInformation 这类重量级调用。判断保护名单的哈希表如果放在分页池访问时可能触发缺页异常蓝屏保护进程列表建议用非分页池或锁保护全内存结构。边界三注册回调后驱动的 DriverUnload 路径必须调用 ObUnRegisterCallbacks否则驱动卸载后回调地址变成一个悬垂指针下一次有人 OpenProcess 就直接蓝屏。卸载顺序通常在回调注销之后还要主动保护自己不被卸载IoIncrementIoCount 增加驱动对象引用可以让试图强制卸载的请求在引用计数归零前被拒绝。拦截策略OpenProcess(PROCESS_TERMINATE) 结果TerminateProcess 结果适用场景清除权限位成功句柄可创建返回 ERROR_ACCESS_DENIED最小干扰保留读取权限设置 Status返回 ERROR_ACCESS_DENIED拿不到句柄自然失败强隔离反调试/反作弊4. 让 R0 驱动在 x64 上真正跑起来签名、服务注册与 PatchGuard 约束4.1 自签与测试签名先把驱动加载门槛踩过去Windows 10/11 对内核驱动的加载有一个隐性的默认策略必须由受信任的证书链签名否则拒绝加载。驱动源码写得再漂亮过不了签名这关连sc start都发不出来。开发阶段的常见做法是开启测试签名模式bcdedit /set testsigning on shutdown /r /t 0重启后通过bcdedit /query能看到测试模式标记为 Yes命令行里的 winver 界面右下角也会出现“测试模式”水印。测试模式关掉的命令是bcdedit /set testsigning off不需要重启就能改但下次启动才生效。注意 testsigning 只降低签名要求不绕过“驱动必须包含嵌入签名”的原则开发时给驱动做测试签名还需要一个自签证书或者用 Visual Studio 的驱动模板自动生成证书。Win11 上如果开启了内核隔离和内存完整性VBS/HVCI测试模式的驱动也会被 Code Integrity 拦截表现为加载成功但服务状态一直停在 START_PENDING随后事件日志里出现系统错误。需要先在“设置-隐私和安全性-Windows 安全中心-设备安全性-内核隔离”里关掉内存完整性再重启。对一台专门做驱动开发的机器建议直接关闭 VBS因为 PatchGuard 和 HVCI 叠加后很多正常的回调调试操作也会被误拦。4.2 用 sc 命令完成驱动的装载、启动与卸载加载一个内核驱动源码编译出的 .sys 文件不需要写 INF直接把它复制到 C:\Windows\System32\drivers 下然后用 sc 创建内核服务sc create ProcGuard type kernel binPath C:\Windows\System32\drivers\ProcGuard.sys sc start ProcGuard sc query ProcGuard sc stop ProcGuard sc delete ProcGuardbinPath前面的空格和type后面的空格都是 sc 命令的语法要求不写会提示参数号错误。sc query主要看 SERVICE_STATE 是否为 RUNNING驱动自己打印的 DbgPrint 不会出现在这里。加载失败时优先看事件查看器里的“系统”日志关键字搜“ProcGuard”或“CodeIntegrity”通常能看到失败的具体状态码。sc 命令返回错误常见含义处理方向2找不到文件.sys 没拷到 drivers 目录或文件名打错检查路径和文件是否真实存在3找不到路径binPath 路径不存在或包含空格未转义确认路径写法空格用十六进制转义577无效映像哈希签名校验失败测试签名未开启或证书无效开启 testsigning确认 sys 是否有嵌入签名1275驱动已被阻止驱动被安全策略拦截HVCI 或 VBS 过滤关闭内存完整性检查驱动是否满足签名策略31连接设备未工作DriverEntry 返回失败或资源不足用 WinDbg 看 DriverEntry 返回的 NTSTATUS4.3 PatchGuard 的约束边界哪些能用哪些一碰就蓝Windows x64 内核配置了 Kernel Patch Protection也就是常说的 PatchGuard。它会对 SSDT、IDT、GDT 以及关键内核模块的代码块做周期校验任何非微软代码对这些位置的修改都会触发 0x109 蓝屏。进程保护驱动完全不需要碰这些数据ObRegisterCallbacks 是系统自己的过滤机制不在 PatchGuard 的校验范围。但要小心两件事一是不要用自定义的 inline hook 去“补强”回调的拦截能力二是在回调里访问的全局数据必须放在非分页内存。另一个边界是回调会对整个进程对象生效不只是你要保护的那一个。PreProcessGuard 每次 OpenProcess 都会被调用如果 IsProtectedProcess 的实现效率差所有进程的打开操作都会变慢表现为任务管理器响应迟钝。一般用哈希表或数组按 PID 做 O(1) 查找不要遍历进程链表判断。驱动源码本身也要遵守 IRQL 约束ObRegisterCallbacks 的回调运行在 PASSIVE_LEVEL 到 APC_LEVEL 之间不能调用 KeWaitForSingleObject 这样的同步原语。调试时如果看到 0x1E 或 0x7E 蓝屏多半是回调里做了不允许的操作。注意进程保护驱动里最常见的“合法强化”是配合 PsSetCreateProcessNotifyRoutineEx 监听新进程创建。这个通知回调同样在系统线程上下文里运行也不能做阻塞操作建议只做 PID 记录把实际拦截全部交给 ObRegisterCallbacks。5. 在 WinDbg 里验证 R0 进程保护驱动的回调是否生效并安全收尾5.1 挂上内核调试器后验证驱动的三条命令驱动加载成功不等于保护逻辑正确。最常见的验证手段是 WinDbg 双机内核调试或者本机用虚拟机。驱动装好后先用下面三组命令确认驱动对象和设备对象都在!object \Driver\ProcGuard !object \Device\ProcGuard !process 0 0第一组看驱动对象是否存在第二组看设备对象和符号链接是否注册成功第三组顺手拿到目标进程的 EPROCESS 地址。EPROCESS 地址后面调试回调时经常要拿来比对。5.2 用户态自测代码OpenProcess 拒绝即证明拦截生效在另一个用户态进程里写一个最小自测工具HANDLE h OpenProcess(PROCESS_TERMINATE, FALSE, targetPid); if (h NULL) { DWORD err GetLastError(); if (err ERROR_ACCESS_DENIED) return 0; // 拦截生效 } else { CloseHandle(h); // 权限还在回调没有被触发 }如果选择“去掉 PROCESS_TERMINATE 权限”的策略OpenProcess 会成功但 TerminateProcess(h, 1) 会返回 FALSEGetLastError 同样是 ERROR_ACCESS_DENIED。两种自测结果对应 3.3 节里两种策略的区别测试时把两个场景都过一遍。5.3 一个能省半天的调试技巧让回调打印被拒绝的 PID在 PreProcessGuard 的回调里加一行 DbgPrintDbgPrint([ProcGuard] block open pid%lu\n, (ULONG)(ULONG_PTR)PsGetProcessId(proc));然后在 WinDbg 的 kd 命令行输入ed nt!KdDebuggerEnabled 1或者直接 F5 继续执行回到被保护进程的机器上再去调用 OpenProcess。内核调试器会打出这行日志。如果日志没有输出先排查回调本身是否注册成功再从 IRQL 和 Altitude 角度检查而不是反复猜测用户态代码写错。最后还有一个容易忽略的卸载顺序DriverUnload 里先调 ObUnRegisterCallbacks(g_hCb)再 IoDeleteSymbolicLink最后 IoDeleteDevice。回调的卸载要放在删除设备之前因为回调函数内部如果引用了设备对象的指针设备先删会导致回调访问悬垂指针蓝屏。我一般会在卸载函数开头先置一个全局布尔卸载标志回调里见标志直接返回 OB_PREOP_SUCCESS等所有处理器核都退出回调后再真正撤销注册这样重复 sc start/sc stop 不会因为回调残留出现随机蓝屏。本文还有配套的精品资源点击获取