简介:这是一份面向Windows内核驱动初学者与系统安全研究者的Win7内存读写驱动实例,围绕Ring 0权限下的物理内存读写展开,可用于调试、性能优化及系统级任务的学习实践。资源包共37个文件,约27.67MB,以Visual Studio工程文件为主,包含Driver.c驱动实现源码、Types.h数据结构定义、Driver.sln与vcxproj工程配置,以及pdb调试符号、tlog编译日志、ipch预编译缓存等中间产物,便于直接编译与调试。内容涉及WDM驱动模型、IRP请求处理、MmAllocateContiguousMemory等内存管理API调用,以及内核调试与安全兼容性考量,对理解Windows内核操作与驱动编程具有参考价值。目前已有228人学习,适合具备一定C语言与系统基础、希望入门内核驱动开发的读者参考借鉴。
1. 从 Drv_headed3me 说起:Win7 内存读写驱动到底在解决什么问题
如果你在 Win7 上做过进程内存读取,大概率遇到过这个场景:用用户态ReadProcessMemory去读一个带保护的游戏进程,返回永远是ERROR_ACCESS_DENIED,换管理员权限、调SeDebugPrivilege都没用。这时候绕不开的方案就是写一个内核驱动,把读写操作下沉到 Ring0。Drv_headed3me 这个标题指向的正是这类东西——一个跑在 Win7 上的简单内存读写驱动,核心能力就两个:读目标进程内存、写目标进程内存。
它适合谁?适合需要在 Win7 环境做进程内存分析、调试辅助、逆向学习验证的工程师。不适合想直接拿去做商业外挂的人,那是另一条路。这篇笔记把驱动从编译、加载、通信到读写进程内存的完整链路拆开,参数怎么设、坑在哪,都落到可复现的步骤上。Win7 虽然老,但大量工控机、测试机、老游戏环境还跑着它,这套东西现在依然有实际用武之地。
2. 驱动读写的基本原理与 Win7 上的选型理由
2.1 为什么用户态读写会被拦,内核态为什么能过
用户态调OpenProcess拿进程句柄时,内核的ObOpenObjectByPointer会走一遍访问检查,目标进程如果设置了PsProtectedProcess或者被反作弊驱动挂了ObRegisterCallbacks回调,句柄请求直接被拒。就算拿到句柄,ReadProcessMemory最终走MmCopyVirtualMemory,中间还有一层ProbeForRead校验用户缓冲区。
内核驱动不一样。驱动运行在 Ring0,可以直接操作目标进程的EPROCESS结构,拿到DirectoryTableBase(也就是 CR3),然后手动做地址翻译,或者直接调MmCopyVirtualMemory但绕过句柄检查。关键点是:驱动里调KeStackAttachProcess挂到目标进程上下文后,用RtlCopyMemory就能直接读,因为此时当前进程的页表就是目标的页表。
提示:Win7 x64 有 PatchGuard,直接改内核结构(比如 SSDT)会触发蓝屏。读写内存本身不碰这些,相对安全,但别顺手去 hook 系统调用。
2.2 Win7 上驱动开发的工具链选择
Win7 驱动开发有两套工具链:WDK 7600(对应 Win7 原生)和 WDK 8.1/10 配合 Win7 目标平台。我一般用 Visual Studio 2013 + WDK 8.1,因为 VS2013 对 Win7 驱动项目的支持最稳,生成的驱动在 Win7 SP1 上直接能加载。用 VS2019+WDK10 也能编,但需要手动把目标平台设成 Win7,且要装 Win7 的调试符号。
编译配置上,DriverEntry的DriverObject->DriverUnload一定要设,否则驱动加载后卸载不掉,只能重启。INF文件里[Version]段的Signature用$WINDOWS NT$,Class用System,ClassGuid填{4d36e97d-e325-11ce-bfc1-08002be10318}。
2.3 通信方式:DeviceIoControl 还是共享内存
驱动和用户态通信常见三种:DeviceIoControl、共享内存映射、命名管道。做内存读写驱动,DeviceIoControl最直接,控制码传参清晰,同步性好。共享内存适合高频大数据量,但同步麻烦。命名管道在驱动里实现复杂,不推荐。
我一般用DeviceIoControl,定义两个控制码:一个读、一个写。用户态传结构体,包含 PID、目标地址、缓冲区指针、长度。驱动里用METHOD_BUFFERED方式,系统会自动把用户缓冲区拷到内核,省得自己做ProbeForWrite。
3. 从零编译加载一个 Win7 内存读写驱动
3.1 驱动入口与设备对象创建
先看DriverEntry里要做什么。创建控制设备对象、设置卸载例程、创建符号链接,这三步是标配。
#include <ntddk.h> #define DEVICE_NAME L"\\Device\\DrvHeaded3me" #define SYM_LINK L"\\DosDevices\\DrvHeaded3me" NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING pRegistryPath) { UNICODE_STRING devName, symLink; PDEVICE_OBJECT pDevObj = NULL; NTSTATUS status; RtlInitUnicodeString(&devName, DEVICE_NAME); // 创建设备对象,FILE_DEVICE_UNKNOWN 表示自定义设备 status = IoCreateDevice(pDriverObj, 0, &devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &pDevObj); if (!NT_SUCCESS(status)) return status; RtlInitUnicodeString(&symLink, SYM_LINK); // 符号链接让用户态能通过 CreateFile 打开设备 status = IoCreateSymbolicLink(&symLink, &devName); if (!NT_SUCCESS(status)) { IoDeleteDevice(pDevObj); return status; } pDriverObj->MajorFunction[IRP_MJ_CREATE] = DrvCreateClose; pDriverObj->MajorFunction[IRP_MJ_CLOSE] = DrvCreateClose; pDriverObj->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DrvDeviceControl; pDriverObj->DriverUnload = DrvUnload; return STATUS_SUCCESS; }IoCreateDevice的第四个参数DeviceType用FILE_DEVICE_UNKNOWN,因为不是标准设备。IoCreateSymbolicLink把\Device\DrvHeaded3me映射到\DosDevices\DrvHeaded3me,用户态才能用CreateFile打开。DriverUnload必须设,否则sc stop会失败。
3.2 读写内存的 IRP 处理
DrvDeviceControl里根据控制码分发。读操作的核心是KeStackAttachProcess挂到目标进程,然后直接RtlCopyMemory。
#define IOCTL_READ_MEM CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) #define IOCTL_WRITE_MEM CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS) typedef struct _MEM_REQUEST { ULONG Pid; PVOID Address; PVOID Buffer; ULONG Size; } MEM_REQUEST, *PMEM_REQUEST; NTSTATUS DrvDeviceControl(PDEVICE_OBJECT pDevObj, PIRP pIrp) { PIO_STACK_LOCATION pStack = IoGetCurrentIrpStackLocation(pIrp); ULONG code = pStack->Parameters.DeviceIoControl.IoControlCode; PMEM_REQUEST req = (PMEM_REQUEST)pIrp->AssociatedIrp.SystemBuffer; NTSTATUS status = STATUS_INVALID_PARAMETER; if (req && req->Pid && req->Address && req->Buffer && req->Size) { PEPROCESS pEprocess = NULL; if (NT_SUCCESS(PsLookupProcessByProcessId((HANDLE)(ULONG_PTR)req->Pid, &pEprocess))) { KAPC_STATE apcState; KeStackAttachProcess(pEprocess, &apcState); __try { if (code == IOCTL_READ_MEM) { RtlCopyMemory(req->Buffer, req->Address, req->Size); } else if (code == IOCTL_WRITE_MEM) { RtlCopyMemory(req->Address, req->Buffer, req->Size); } status = STATUS_SUCCESS; } __except (EXCEPTION_EXECUTE_HANDLER) { status = GetExceptionCode(); } KeUnstackDetachProcess(&apcState); ObDereferenceObject(pEprocess); } } pIrp->IoStatus.Status = status; pIrp->IoStatus.Information = NT_SUCCESS(status) ? req->Size : 0; IoCompleteRequest(pIrp, IO_NO_INCREMENT); return status; }PsLookupProcessByProcessId拿到EPROCESS指针,用完必须ObDereferenceObject,否则引用计数泄漏。KeStackAttachProcess挂到目标进程后,当前线程的页表切换成目标的,RtlCopyMemory就能直接访问目标地址。__try/__except必须加,目标地址无效时RtlCopyMemory会触发异常,不捕获直接蓝屏。
注意:
METHOD_BUFFERED下系统缓冲区大小取输入输出缓冲区中较小的那个。读写大块内存时,用户态缓冲区要够大,否则IoStatus.Information会小于预期。
3.3 用户态调用代码
用户态用CreateFile打开符号链接,然后DeviceIoControl传MEM_REQUEST。
#include <windows.h> #include <stdio.h> typedef struct _MEM_REQUEST { ULONG Pid; PVOID Address; PVOID Buffer; ULONG Size; } MEM_REQUEST; int main() { HANDLE hDev = CreateFileA("\\\\.\\DrvHeaded3me", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hDev == INVALID_HANDLE_VALUE) { printf("open device failed: %lu\n", GetLastError()); return 1; } DWORD pid = 1234; // 目标进程 PID PVOID addr = (PVOID)0x400000; // 目标地址 BYTE buf[64] = {0}; MEM_REQUEST req = { pid, addr, buf, sizeof(buf) }; DWORD ret = 0; BOOL ok = DeviceIoControl(hDev, IOCTL_READ_MEM, &req, sizeof(req), &req, sizeof(req), &ret, NULL); if (ok) { printf("read ok, first byte: %02X\n", buf[0]); } else { printf("ioctl failed: %lu\n", GetLastError()); } CloseHandle(hDev); return 0; }CreateFileA的路径是\\.\DrvHeaded3me,对应驱动里的符号链接。DeviceIoControl的输入输出缓冲区都用&req,因为METHOD_BUFFERED下系统缓冲区是同一块。IOCTL_READ_MEM和驱动里的定义必须一致,否则返回ERROR_INVALID_FUNCTION。
3.4 编译、签名与加载
Win7 x64 强制驱动签名,测试阶段有两个办法:一是开机按 F8 选「禁用驱动程序签名强制」,二是用测试签名模式。我一般用测试签名,因为不用每次重启。
# 管理员权限运行 bcdedit /set testsigning on # 重启后生效 # 生成测试证书 makecert -r -pe -ss PrivateCertStore -n "CN=DrvTest" DrvTest.cer # 签名驱动 signtool sign /v /s PrivateCertStore /n DrvTest /t http://timestamp.digicert.com DrvHeaded3me.sys # 注册并启动服务 sc create DrvHeaded3me type= kernel binPath= C:\path\DrvHeaded3me.sys sc start DrvHeaded3mebcdedit /set testsigning on后桌面右下角会显示「测试模式」,这是正常的。sc create的type= kernel和binPath=后面的空格不能省,这是sc命令的格式要求。启动失败用sc query DrvHeaded3me看状态,错误码 577 是签名问题,错误码 1275 是驱动被阻止加载。
4. 避坑:Win7 内存读写驱动最容易翻车的五个地方
4.1 加载时报错 577 或 1275
现象:sc start返回错误 577或错误 1275,驱动加载不上。
原因:577 是签名验证失败,1275 是驱动被系统策略阻止。Win7 x64 对未签名驱动零容忍,测试签名没开或者证书链不对都会报 577。
解决:先确认bcdedit /enum里testsigning是Yes。如果开了还报 577,检查signtool签名时用的证书是不是在PrivateCertStore里,/s参数要和makecert的-ss一致。1275 一般是驱动 INF 里的Class或ClassGuid不对,改成System和{4d36e97d-e325-11ce-bfc1-08002be10318}。
4.2 读写目标进程直接蓝屏
现象:DeviceIoControl一调用,系统立刻蓝屏,错误码0x00000050或0x0000001E。
原因:目标地址无效,RtlCopyMemory触发页错误,而__try/__except没包住,或者包了但异常发生在KeStackAttachProcess之前。
解决:确认__try块只包RtlCopyMemory,KeStackAttachProcess和PsLookupProcessByProcessId放在外面。另外检查req->Size是不是超过了用户态缓冲区实际大小,METHOD_BUFFERED下系统缓冲区大小取输入输出较小值,传大了会越界。
4.3 读到的数据全是 0 或者乱码
现象:DeviceIoControl返回成功,但buf里全是 0 或者随机值。
原因:KeStackAttachProcess挂载后,目标进程的页表生效,但如果目标地址在目标进程里本身就没映射(比如未初始化的堆),读出来就是 0。乱码一般是地址翻译到了错误的物理页。
解决:先用VirtualQueryEx在用户态确认目标地址在目标进程里是可读的。如果目标进程是 32 位而驱动是 64 位,地址要按 32 位截断。另外确认req->Pid是目标进程的真实 PID,不是线程 ID。
4.4 驱动卸载不掉,sc stop挂起
现象:sc stop DrvHeaded3me一直卡住,最后报错误 1053。
原因:DriverUnload没设,或者设了但里面没删设备对象和符号链接。IRP 处理里有未完成的请求也会导致卸载挂起。
解决:DriverUnload里必须IoDeleteSymbolicLink和IoDeleteDevice。如果有挂起的 IRP,用IoCancelIrp取消。另外DeviceIoControl是同步的,一般不会有挂起 IRP,但如果有异步操作要额外处理。
4.5 目标进程退出后驱动还持有 EPROCESS 引用
现象:目标进程退出后,系统变慢,或者驱动再次读写时蓝屏。
原因:PsLookupProcessByProcessId拿到的EPROCESS引用没释放,进程对象泄漏。
解决:每次PsLookupProcessByProcessId成功后,无论读写是否成功,都要ObDereferenceObject。用__try/__finally或者确保所有返回路径都走到ObDereferenceObject。我一般把ObDereferenceObject放在KeUnstackDetachProcess之后,不管status是什么都执行。
5. 进阶:用 MDL 映射做更稳的大块内存读写
RtlCopyMemory在KeStackAttachProcess下能用,但大块内存读写时效率一般,而且目标地址跨页时容易触发异常。更稳的做法是用 MDL(Memory Descriptor List)把目标地址映射到内核空间,然后直接访问映射后的虚拟地址。
NTSTATUS ReadMemoryByMdl(ULONG pid, PVOID addr, PVOID buf, ULONG size) { PEPROCESS pEprocess = NULL; if (!NT_SUCCESS(PsLookupProcessByProcessId((HANDLE)(ULONG_PTR)pid, &pEprocess))) return STATUS_NOT_FOUND; KAPC_STATE apcState; KeStackAttachProcess(pEprocess, &apcState); PMDL pMdl = IoAllocateMdl(addr, size, FALSE, FALSE, NULL); if (!pMdl) { KeUnstackDetachProcess(&apcState); ObDereferenceObject(pEprocess); return STATUS_INSUFFICIENT_RESOURCES; } __try { MmProbeAndLockPages(pMdl, KernelMode, IoReadAccess); } __except (EXCEPTION_EXECUTE_HANDLER) { IoFreeMdl(pMdl); KeUnstackDetachProcess(&apcState); ObDereferenceObject(pEprocess); return GetExceptionCode(); } PVOID pMapped = MmGetSystemAddressForMdlSafe(pMdl, NormalPagePriority); if (pMapped) { RtlCopyMemory(buf, pMapped, size); } MmUnlockPages(pMdl); IoFreeMdl(pMdl); KeUnstackDetachProcess(&apcState); ObDereferenceObject(pEprocess); return pMapped ? STATUS_SUCCESS : STATUS_UNSUCCESSFUL; }IoAllocateMdl创建 MDL,MmProbeAndLockPages锁定目标页面并校验可读性,MmGetSystemAddressForMdlSafe拿到内核映射地址。这样读的时候不依赖当前进程上下文,RtlCopyMemory从映射地址读,不会触发目标进程的页错误。MmUnlockPages和IoFreeMdl必须成对调用,否则内存泄漏。
写操作把IoReadAccess改成IoWriteAccess,RtlCopyMemory的参数顺序反过来。注意MmProbeAndLockPages在目标地址无效时会抛异常,必须用__try/__except包住。
| 对比项 | RtlCopyMemory 方案 | MDL 方案 |
|---|---|---|
| 实现复杂度 | 低 | 中 |
| 大块读写效率 | 一般 | 高 |
| 跨页处理 | 易触发异常 | 自动处理 |
| 适用场景 | 小数据量、快速验证 | 大数据量、稳定读写 |
验证驱动是否正常工作,我习惯用 WinDbg 双机调试。windbg -k com:pipe,port=\\.\pipe\com_1,resets=0连上目标机,在DrvDeviceControl下断点,看req->Pid和req->Address是不是预期值。如果断点没命中,说明 IRP 没走到这个分支,检查控制码是否匹配。
最后说个血泪教训:每次改完驱动代码,先在本机测试签名模式下加载,确认能sc start再sc stop,再去目标机跑。我早期有次直接往目标机扔,结果驱动卸载不掉,只能强制重启,浪费一下午。另外PsLookupProcessByProcessId的 PID 用ULONG传,别用HANDLE直接强转,32 位和 64 位下HANDLE宽度不一样,容易翻车。希望帮到你。
本文还有配套的精品资源,点击获取