unity游戏存在il2cpp打包方案,而其中的global-metadata.dat和assembly文件可经过il2cppdumper还原出详细类型和所有RVA,为逆向提供坚实基础,面对可以隐藏.dat的游戏,是时候得上核弹级大家伙了

前置准备

小端序

从最早的8086处理器开始,就把字节(Byte)定义为内存的最小寻址单位。每个地址对应一个字节。现在几乎所有通用计算机(x86、ARM等)都采用“字节寻址”(Byte-Addressable),即每个独立的地址编号,指向的就是那8个比特(0~255)的数据。

4 字节整数 0xFAB11BAF,在内存里怎么摆?

x86/x64 CPU 采用 小端序(Little-Endian)低位字节在前。所以 0xFAB11BAF 在内存里实际是:

地址+0  地址+1  地址+2  地址+3
  AF      1B      B1      FA

最低位字节 AF 放在最前面。所以你用十六进制编辑器看内存时,看到的是 AF 1B B1 FA,但作为 32 位整数读出来却是 0xFAB11BAF

所以 IL2CPP 元数据头的”魔术值”作为整数是 0xFAB11BAF,在内存里应该被搜的字节序列是 AF 1B B1 FA

地址也是数字

内存里每个字节都有一个编号,叫”地址”。32 位进程地址范围 0x00000000 ~ 0xFFFFFFFF(4GB),64 位进程理论范围 0x0000_0000_0000_0000 ~ 0x7FFF_FFFF_FFFF_FFFF(128TB)。

代码里你会看到这样的写法:

PVOID addr = (PVOID)0x10000;                 // 起始地址
PVOID maxAddr = (PVOID)0x00007FFFFFFFFFFFULL; // 用户态上限

这是从 64KB 扫到 128TB,覆盖整个用户态地址空间。


内核驱动

用户态 vs 内核态

Windows 把内存分成两层:

  • 用户态(User Mode,Ring 3):你平时跑的 .exe 都在这里。权限受限,读取其他进程的内存需要通过钩子,不能碰硬件。
  • 内核态(Kernel Mode,Ring 0):操作系统核心和所有驱动跑在这里。权限最高,能访问整个物理内存、所有进程的虚拟内存。

ida读取BrmsDriver.sys发现其导入表导入函数有: KeRegisterBugCheckReasonCallback KeRegisterBugCheckReasonCallback KeBugCheckEx IofCompleteRequest IoCreateDevice IoCreateSymbolicLink IoDeleteDevice IoDeleteSymbolicLink ExFreePoolWithTag ObfDereferenceObject IoQueryFileDeviceName PsReferenceProcessFilePointer RtlInitUnicodeString RtlCopyUnicodeString RtlAppendUnicodeStringToString KdDisableDebugger KdRefreshDebuggerNotPresent ExAllocatePool IoGetCurrentProcess ObReferenceObjectByHandle ZwCreateFile ZwOpenFile ZwQueryInformationFile ZwReadFile ZwClose MmIsAddressValid IoVolumeDeviceToDosName ZwOpenProcess PsLookupProcessByProcessId ZwQueryVirtualMemory MmCopyVirtualMemory ZwQueryInformationProcess __C_specific_handler IoFileObjectType PsSetLoadImageNotifyRoutine PsRemoveLoadImageNotifyRoutine KeInitializeGuardedMutex KeAcquireGuardedMutex KeReleaseGuardedMutex ObRegisterCallbacks PsGetCurrentProcessId PsGetProcessId PsGetThreadProcessId PsProcessType PsThreadType PsSetCreateProcessNotifyRoutineEx PsSetCreateThreadNotifyRoutine PsRemoveCreateThreadNotifyRoutine

普通程序想读别的进程内存,得调用 ReadProcessMemory,但这个 API 会被上图的ObRegisterCallbacks拦截。而内核驱动跑在 Ring 0,没有人拦得住它

额外的,函数KdDisableDebugger,KdRefreshDebuggerNotPresent还直接阻碍了xdbg的附着。我在分析驱动前,还手动编译了一份xdbg,改掉了所有特征,结果是依然附着不上去,非常难绷。

驱动

驱动就是一个 .sys 文件,本质是 PE 格式的 DLL,但入口函数叫 DriverEntry 而不是 main/DllMain。它不能双击运行,必须由操作系统加载(通常通过 SCM 服务管理器,或者用 KDU 这类手动映射工具)。

加载驱动

主流两种方式:

  1. SCM 加载:调用 OpenSCManager / CreateService / StartService,需要驱动有数字签名(或在测试模式系统上)。微软官方文档有完整示例。但是我们作为低端捡垃圾人员,当然是没有财力购置证书的。而自签证书模式需要开启windows的测试模式,但是游戏在测试模式下无法启动。而windows11一样会拦截过期证书的载入,于是我放弃了这条路。
  2. 手动映射(KDU):利用一个已签名的含漏洞驱动(vulnerable driver)来加载未签名的 .sys,绕过签名校验。本项目用的就是 KDU(见仓库 KDU-1.4.9/ 目录)。

KDU 是 hfiref0x 开源的工具,仓库地址见 参考链接


驱动骨架

DriverEntry

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject,
                     PUNICODE_STRING RegistryPath)
{
    UNREFERENCED_PARAMETER(RegistryPath);
    DbgPrint(DMMDZZ_DBG_TAG " DriverEntry\n");
    return dmmdzz_Init(DriverObject);
}
  • NTSTATUS 是驱动的返回值类型,STATUS_SUCCESS (= 0) 表示成功。
  • PDRIVER_OBJECT 是操作系统传的”驱动对象”指针,你后面要往它的 MajorFunction[] 函数表里填回调。
  • PUNICODE_STRING RegistryPath:注册表路径,形如 \Registry\Machine\System\CurrentControlSet\Services\<驱动名>,是 SCM 加载驱动时由系统填入的配置位置(KDU 手动映射时此值可能为空串)。本项目用不到,故用 UNREFERENCED_PARAMETER 静默掉 C4100”未使用形参”的编译警告——该宏 WDK 中展开就是 (void)(RegistryPath)
  • DbgPrint 类似 printf,输出到内核调试器(用 DebugView 勾选 Capture Kernel 可看)。
  • DMMDZZ_DBG_TAGdriver.h 里定义的调试前缀字符串 "[dmmdzz]",拼到日志前头方便在 DebugView 海量输出里一眼过滤本驱动的消息。
  • dmmdzz_Init 是项目自实现的初始化函数,下文那些 IoCreateDevice / IoCreateSymbolicLink / 注册 IRP 全在它内部完成。

Device Object

驱动要和用户态通信,必须先创建一个”设备”。设备是内核里的一个对象,用户态程序通过它的”名字”来访问。

RtlInitUnicodeString(&devName, DEVICE_NAME);   // L"\\Device\\dmmdzz_injector"

status = IoCreateDevice(
    DriverObject,
    0,                       // DeviceExtensionSize
    &devName,
    FILE_DEVICE_UNKNOWN,
    FILE_DEVICE_SECURE_OPEN,
    FALSE,                   // 不独占
    &devObj);

设备名是 \Device\dmmdzz_injector 这种”内核命名空间”的路径,用户态直接看不到。

IoCreateDevice 七个参数逐个说明:

  • DriverObject:调用方驱动对象指针,新设备会挂到它的设备链表上。
  • 0(DeviceExtensionSize):设备扩展区大小。设备扩展是挂在 DEVICE_OBJECT 末尾的”私有存储”,用来存自定义上下文(如进程上下文、锁、计数器)。本项目每条 IOCTL 都从 Irp->SystemBuffer 直接取参数,无需挂额外状态,故填 0。
  • &devName:设备名 UNICODE_STRING 指针,必须以 \Device\ 开头,否则 IoCreateDevice 返回 STATUS_OBJECT_NAME_INVALID
  • FILE_DEVICE_UNKNOWN:设备类型码。微软已分配一堆(FILE_DEVICE_DISKFILE_DEVICE_NETWORK 等),第三方驱动没拿到专属类型时一律用 0x22FILE_DEVICE_UNKNOWN),表示”通用未分类”。
  • FILE_DEVICE_SECURE_OPEN:设备特征位。它强制 I/O 网络栈在 CreateFile 时按 SD(安全描述符)做权限检查,防止低权限用户态进程随便拿到设备句柄。
  • FALSE(Exclusive):是否独占。TRUE 表示同一时刻只允许一个句柄打开此设备,本项目用户态 CLI 与驱动可能并发通信,故填 FALSE
  • &devObj:输出参数,函数成功后这里返回创建好的 PDEVICE_OBJECT 指针,后续 devObj->Flags 等都要用它。

首先,\.\ 是一个NT 命名空间中的特殊硬链接(Hardlink),它指向 \Device 目录。

  • 在 Windows 内部,\.\ 被解析为 \DosDevices\ 或 ??\(根据版本不同,但本质相同)。
  • 这个 ??\ 目录下存放着所有用户态可以通过 CreateFile 访问的设备符号链接。
  • 微软为了方便,将 \.\ 定义为“当前计算机的本地设备命名空间”的入口。

为了让用户态用 \\.\xxx 这种路径打开设备,需要建一个”符号链接”指向它:

RtlInitUnicodeString(&dosName, DOS_DEVICE_NAME);  // L"\\??\\dmmdzz_injector"
status = IoCreateSymbolicLink(&dosName, &devName);

IoCreateSymbolicLink 两个参数:

  • &dosName(SymbolicLinkName):符号链接名,必须落在 \??\\DosDevices\ 命名空间下,否则用户态通过 \\.\ 看不见。
  • &devName(DeviceName):上一节 IoCreateDevice 创建设备时填的那个 \Device\dmmdzz_injector,符号链接就指向它。

返回 STATUS_SUCCESS 后,\\.\dmmdzz_injector 这个 Win32 路径就和 \Device\dmmdzz_injector 绑定上了。卸载时记得对称地调用 IoDeleteSymbolicLink + IoDeleteDevice,否则下次加载驱动会因为名字占用而 STATUS_OBJECT_NAME_COLLISION

建好之后,用户态就能这样打开:

HANDLE h = CreateFileW(L"\\\\.\\dmmdzz_injector", ...);

\\ 转义后是一个反斜杠,所以 "\\\\.\\" 实际是 \\.\

注册 IRP 分发函数

用户态每次调用 CreateFile / ReadFile / DeviceIoControl / CloseHandle,操作系统都会生成一个 IRP(I/O Request Packet,I/O 请求包) 投递给驱动。驱动需要提前告诉系统”这些 IRP 我用哪个函数处理”:

DriverObject->MajorFunction[IRP_MJ_CREATE]         = dmmdzz_CreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE]          = dmmdzz_CreateClose;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = dmmdzz_DeviceControl;
DriverObject->DriverUnload                          = dmmdzz_Unload;

devObj->Flags |= DO_BUFFERED_IO;   // 关键:声明用缓冲 I/O
devObj->Flags &= ~DO_DEVICE_INITIALIZING;
  • IRP_MJ_CREATE / IRP_MJ_CLOSE:用户态 CreateFile / CloseHandle 时触发。
  • IRP_MJ_DEVICE_CONTROL:用户态 DeviceIoControl 时触发,这是用户态↔内核态通信的核心通道
  • DO_BUFFERED_IO:声明这个设备用”缓冲 I/O”方式(见下一节)。
  • DO_DEVICE_INITIALIZING:设备刚创建时 I/O 管理器自动置上的标志位,期间别人无法 CreateFile 打开。DriverEntry 返回前必须用 &= ~ 清掉它,否则用户态会拿到 STATUS_DEVICE_DOES_NOT_EXIST;如果是在 AddDevice 例程里创建设备(WDF 框架),则框架会自动清,不用手动操心。

用户态与内核态的通信

IOCTL

IOCTL(I/O Control Code,I/O 控制码) 是一个 32 位整数,用户态用它告诉驱动”我要做哪件事”。每个 IOCTL 还配套一个”输入缓冲区”和”输出缓冲区”,用来传参数和收结果。

IOCTL 的位布局(由 CTL_CODE 宏打包):

 31        16 15      14 13         2 1        0
+------------+----------+------------+----------+
| DeviceType |  Access  |  Function  |  Method  |
+------------+----------+------------+----------+
   16 bit       2 bit      12 bit       2 bit
#define DMMDZZ_DEVICE_TYPE      0x8000          // 用户自定义设备类型
#define DMMDZZ_IOCTL_FUNC_BASE  0x800           // 功能码基址

#define IOCTL_DMMDZZ_READ_MEMORY  CTL_CODE(DMMDZZ_DEVICE_TYPE, \
        DMMDZZ_IOCTL_FUNC_BASE + 3, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_DMMDZZ_SCAN_MEMORY  CTL_CODE(DMMDZZ_DEVICE_TYPE, \
        DMMDZZ_IOCTL_FUNC_BASE + 6, METHOD_BUFFERED, FILE_ANY_ACCESS)
  • 0x8000 ~ 0xFFFF 是微软留给第三方驱动的设备类型段。
  • METHOD_BUFFERED 是最简单安全的缓冲方式,下一节解释。

METHOD_BUFFERED:缓冲 I/O

用户态调 DeviceIoControl 时传两个缓冲区(输入、输出)。在 METHOD_BUFFERED 模式下,I/O 管理器会自动分配一块内核内存,把用户输入拷进来;调用结束时,再按 OutputBufferLength 把这块内存拷回用户输出缓冲区

驱动里只需要读写一个指针:

PVOID sysBuf = Irp->AssociatedIrp.SystemBuffer;

这样驱动完全不用碰用户态指针,避免了 ProbeForRead / ProbeForWrite 一堆繁琐校验。

代价是:多一次内存拷贝,且单次 IOCTL 缓冲区不宜过大(一般 < 1MB,否则分配失败)。所以后面 dump 大文件时,我们要分块 1MB 1MB 地读。

用户态发 IOCTL

第一步:打开设备

hDevice_ = CreateFileW(
    L"\\\\.\\dmmdzz_injector",
    GENERIC_READ | GENERIC_WRITE, 0, nullptr,
    OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);

第二步:组装缓冲区

每个 IOCTL 都有一个对应的请求结构体。例如读内存的 DMMDZZ_MEM_OP

typedef struct _DMMDZZ_MEM_OP {
    HANDLE    ProcessId;       // 目标进程 PID
    ULONG_PTR Address;         // 目标进程里的虚拟地址
    SIZE_T    Size;            // 要读多少字节
    ULONG_PTR BufferOffset;    // 数据载荷在缓冲区里的偏移
    DMMDZZ_STATUS Hdr;         // 返回状态
    SIZE_T    BytesTransferred;
} DMMDZZ_MEM_OP;

缓冲区布局是 头部 + 载荷 一整块连续内存:

[ DMMDZZ_MEM_OP 头 | payload 字节 ... ]
  ^                  ^
  buf                buf + BufferOffset

用户态代码这样拼:

std::vector<uint8_t> buf(sizeof(DMMDZZ_MEM_OP) + size, 0);
auto* hdr = reinterpret_cast<DMMDZZ_MEM_OP*>(buf.data());
hdr->ProcessId    = (HANDLE)(ULONG_PTR)pid;
hdr->Address      = remoteVA;
hdr->Size         = size;
hdr->BufferOffset = sizeof(DMMDZZ_MEM_OP);  // payload 紧跟在头后面

第三步:调用 DeviceIoControl

SendIoctl(IOCTL_DMMDZZ_READ_MEMORY,
          buf.data(), (DWORD)buf.size(),   // 输入
          buf.data(), (DWORD)buf.size(),   // 输出(复用同一块)
          &ret);

注意输入输出用的是同一块 buffer。这是因为 METHOD_BUFFERED 下,I/O 管理器在调用结束时会把 OutputBufferLength 字节拷回输出缓冲区,我们让它和输入缓冲区相同,就能一次拿到驱动填好的数据。

内核侧分发

内核侧 driver/ioctl.cdmmdzz_HandleIoctl 就是一个大 switch,根据 IOCTL 码分发给不同的处理函数:

case IOCTL_DMMDZZ_READ_MEMORY:
case IOCTL_DMMDZZ_WRITE_MEMORY: {
    PDMMDZZ_MEM_OP p = (PDMMDZZ_MEM_OP)sysBuf;
    if (inLen < sizeof(*p)) { status = STATUS_BUFFER_TOO_SMALL; break; }
    if (p->BufferOffset + p->Size > (SIZE_T)inLen) {
        status = STATUS_INVALID_PARAMETER; break;
    }
    PVOID payload = (PVOID)((ULONG_PTR)sysBuf + p->BufferOffset);
    if (code == IOCTL_DMMDZZ_READ_MEMORY) {
        status = dmmdzz_ReadMemory(p, payload, inLen);
        retBytes = (ULONG)(p->BufferOffset + p->BytesTransferred);
    }
    break;
}

retBytes(写到 Irp->IoStatus.Information)告诉 I/O 管理器”实际要回传多少字节给用户态”。


跨进程读内存

为什么要”附加”进程

每个进程都有自己独立的虚拟地址空间。0x12345678 在 A 进程和 B 进程里指向完全不同的物理内存。

驱动要读 A 进程的 0x12345678,必须先切换 CR3 寄存器(页表基址),让当前 CPU 临时”住进”A 进程的地址空间。这就是 KeStackAttachProcess 做的事:

PEPROCESS target;
KAPC_STATE apc;

PsLookupProcessByProcessId(p->ProcessId, &target);  // 根据 PID 找 EPROCESS
KeStackAttachProcess(target, &apc);                 // 切到目标进程地址空间

// ... 在这里访问的虚拟地址,都是目标进程的 ...

KeUnstackDetachProcess(&apc);                       // 切回原进程
ObDereferenceObject(target);                        // 释放引用
  • PEPROCESS 是内核里”进程对象”的不透明指针。
  • KAPC_STATE 用来保存切换前的 APC 状态,恢复时用。
  • 这一组 API 必须在 PASSIVE_LEVEL IRQL 调用,不能在中断里用。

安全拷贝

直接 RtlCopyMemory(dst, userAddr, size) 看起来很简单,但会蓝屏。原因:

  1. 用户态那页可能被换出到 pagefile,访问触发缺页。
  2. 用户态那页可能是 PAGE_NOACCESS,访问触发访问违例。
  3. 内核态默认没法捕获这些异常(__try/__except 在 KDU 手动映射的驱动里不工作,因为 SEH 表没注册)。

所以本项目用一个间接方法:调用 ZwReadVirtualMemory。它是 NtReadVirtualMemory(即 ReadProcessMemory 的底层)的内核态版本,内部用 ntoskrnl 自己注册好的 SEH 表处理缺页,不会把异常抛给我们的驱动:

BOOLEAN dmmdzz_SafeCopyUser(PVOID dst, PVOID src, SIZE_T size)
{
    if (!dmmdzz_IsRangeValid(src, size))   // 快速过滤明显无效的地址
        return FALSE;
    NTSTATUS s = g_ZwReadVirtualMemory((HANDLE)-1, src, dst, size, &bytesRead);
    return NT_SUCCESS(s) && bytesRead == size;
}

(HANDLE)-1NtCurrentProcess() 的简写——因为我们已经 KeStackAttachProcess 到目标进程了,“当前进程”就是目标进程。

dmmdzz_IsRangeValidMmIsAddressValid 检查每个页是否驻留物理内存,作为快速预筛。注意它不是绝对保证(TOCTOU 竞争),但能挡掉绝大多数明显无效的地址,省一次系统调用。

完整的 ReadMemory 流程

NTSTATUS dmmdzz_ReadMemory(PDMMDZZ_MEM_OP p, PVOID buf, ULONG bufLen)
{
    PEPROCESS target = NULL;
    KAPC_STATE apc;

    status = PsLookupProcessByProcessId(p->ProcessId, &target);
    if (!NT_SUCCESS(status)) { /* ... */ }

    KeStackAttachProcess(target, &apc);

    if (dmmdzz_SafeCopyUser(buf, (PVOID)p->Address, p->Size)) {
        p->BytesTransferred = p->Size;
    } else {
        status = STATUS_ACCESS_VIOLATION;
        p->BytesTransferred = 0;
    }

    KeUnstackDetachProcess(&apc);
    ObDereferenceObject(target);
    return status;
}

buf 就是 IOCTL 系统缓冲区里 payload 的起始位置,读到的字节直接写到这里,I/O 管理器会负责把它拷回用户态。


内核扫描内存

枚举所有”可读已提交”的内存区域

光能读一段内存还不够。我们要”扫”——在几个 GB 的进程地址空间里,找出某个特定字节序列(比如魔术头)出现的所有位置。 进程地址空间不是连续的有效内存,而是一块一块的”区域”(region)。用 ZwQueryVirtualMemory 配合 MemoryBasicInformation 可以枚举它们:

PVOID addr    = (PVOID)0x10000;
PVOID maxAddr = (PVOID)0x00007FFFFFFFFFFFULL;

while (addr < maxAddr) {
    MEMORY_BASIC_INFORMATION mbi;
    ZwQueryVirtualMemory((HANDLE)-1, addr, MemoryBasicInformation,
                         &mbi, sizeof(mbi), &returnLength);

    PVOID nextAddr = (PUCHAR)mbi.BaseAddress + mbi.RegionSize;

    if (mbi.State == MEM_COMMIT &&
        !(mbi.Protect & PAGE_NOACCESS) &&
        !(mbi.Protect & PAGE_GUARD) &&
        (mbi.Protect & (PAGE_READONLY | PAGE_READWRITE |
                        PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE |
                        PAGE_WRITECOPY | PAGE_EXECUTE_WRITECOPY))) {
        // 这一区域可扫
    }

    addr = nextAddr;   // 跳到下一个区域
}

MEMORY_BASIC_INFORMATION 关键字段:

  • StateMEM_COMMIT(已分配物理内存)、MEM_RESERVE(仅保留地址)、MEM_FREE(完全空闲)。只扫 MEM_COMMIT
  • Protect:页保护属性。跳过 PAGE_NOACCESSPAGE_GUARD,否则会蓝屏。
  • RegionSize:连续同属性区域的字节数,可能几 KB 也可能几百 MB。

分块拷贝到内核缓冲再比对

直接对用户态地址调用 RtlCompareMemory 不安全(缺页会蓝屏)。所以先把一大块用户内存安全拷贝到内核缓冲,再在内核缓冲里搜

#define SCAN_CHUNK_SIZE  0x10000   // 64KB

PUCHAR chunkBuf = ExAllocatePoolWithTag(NonPagedPool,
        SCAN_CHUNK_SIZE + valueSize - 1, 'sZmm');

while (off < regionSize) {
    SIZE_T toCopy = min(remaining, SCAN_CHUNK_SIZE + valueSize - 1);
    if (!dmmdzz_SafeCopyUser(chunkBuf, regionBase + off, toCopy)) {
        off += toCopy;   // 这一块坏了,跳过
        continue;
    }

    SIZE_T scanLen = toCopy - valueSize + 1;
    for (SIZE_T i = 0; i < scanLen; i++) {
        if (RtlCompareMemory(chunkBuf + i, valuePtr, valueSize) == valueSize) {
            results[resultCount++] = (ULONG_PTR)(regionBase + off + i);
        }
    }
    off += scanLen;
}

几个关键设计:

  1. 缓冲多分配 valueSize - 1 字节:处理”目标序列横跨两个 chunk 边界”的情况。
  2. off += scanLen 而非 += toCopy:避免对边界处那几个字节重复扫描,又不会漏。
  3. 失败 chunk 填零跳过:不中断整个扫描,最多丢一段数据。

缓冲区布局

整个扫描 IOCTL 的系统缓冲区长这样:

+--------------------------------------+
|  DMMDZZ_SCAN_REQUEST 头              |  <- 用户填 PID、ValueSize 等
+--------------------------------------+
|  value 字节 (要搜的模式)              |  <- 用户填,比如 AF 1B B1 FA
+--------------------------------------+
|  results: ULONG_PTR[]                |  <- 驱动填命中地址
+--------------------------------------+

驱动处理完后,retBytes = ResultsOffset + ResultsCount * sizeof(ULONG_PTR),告诉 I/O 管理器回传多少字节。

const ULONG headerSize    = sizeof(DMMDZZ_SCAN_REQUEST);
const ULONG valueOffset   = headerSize;
const ULONG resultsOffset = (valueOffset + valueSize + 7) & ~7u;  // 8 字节对齐
const ULONG totalSize     = resultsOffset + maxResults * sizeof(uintptr_t);

std::vector<uint8_t> buf(totalSize, 0);
auto* hdr = reinterpret_cast<DMMDZZ_SCAN_REQUEST*>(buf.data());
hdr->ProcessId     = (HANDLE)(ULONG_PTR)pid;
hdr->ValueSize     = valueSize;
hdr->ValueOffset   = valueOffset;
hdr->MaxResults    = (ULONG)maxResults;
hdr->ResultsOffset = resultsOffset;

std::memcpy(buf.data() + valueOffset, value, valueSize);

SendIoctl(IOCTL_DMMDZZ_SCAN_MEMORY,
          buf.data(), totalSize,
          buf.data(), totalSize, &ret);

魔术头找.dat

到这里我们已经能”扫内存”。但实际目标是:找到 IL2CPP 游戏的 global-metadata.dat 并 dump 成文件

失踪的global-metadata.dat

Unity 引擎原本用 Mono 把 C# 编译成 .NET IL 字节码再 JIT 执行。IL2CPP 是 Unity 的另一套后端:把 IL 转成 C++ 代码,再编译成原生机器码(GameAssembly.dll)。游戏逻辑都变成了原生代码,反编译难度大增。

但元数据(类名、方法名、字段偏移、字符串字面量)还得有个地方存——这就是 global-metadata.dat。众多采用il2cpp的厂商使用五花八门的招数隐藏global-metadata.dat,增加逆向难度。它由游戏在启动时从 APK/资源包里解密,加载到进程内存里。

global-metadata.dat 的文件头长这样:

偏移类型字段
+0int32sanity(魔术值)0xFAB11BAF
+4int32version例如 16、21、22、24、27
+8(int32, int32) × N各 section 的 (offset, size)

为什么是 AF 1B B1 FA

回顾上文字节序提到的,整数 0xFAB11BAF 在小端序机器的内存里,按字节排列就是 AF 1B B1 FA

代码里直接这么写:

// IL2CPP 元数据头的 magic
const uint8_t MAGIC[4] = { 0xAF, 0x1B, 0xB1, 0xFA };
std::vector<uint8_t> pattern(MAGIC, MAGIC + 4);

把这 4 字节传给驱动的 ScanMemory,驱动就扫遍整个进程内存,返回所有命中的地址。

魔术头的意义

进程内存动辄几个 GB,怎么知道哪段是 global-metadata.dat?靠的就是这个”指纹”。

魔术头(magic number) 是文件格式设计者故意放在文件开头的独特字节序列,用来:

  1. 快速识别:扫一遍内存就知道哪里是这个文件。
  2. 校验合法性:加载器一看开头不对就拒绝。

常见魔术头举例:

文件类型魔术头(hex)ASCII
PE(exe/dll)4D 5AMZ
ZIP / APK / JAR50 4B 03 04PK..
PNG89 50 4E 47 0D 0A 1A 0A.PNG…
ELF(Linux 可执行)7F 45 4C 46.ELF
IL2CPP metadataAF 1B B1 FA(非可打印)

字节过滤

光靠 4 字节 magic 可能误命中。可以再拼上 version 字节做更精确的匹配。global-metadata.dat 第 5 字节是 version 的最低字节,比如 version=16 对应 0x10

// 找 version=16 的 metadata
pattern.push_back(0x10);   // 现在搜 5 字节:AF 1B B1 FA 10

如果版本跨字节,还可以拼 4 个字节:

AF 1B B1 FA 10 00 00 00   // version = 0x00000010 = 16

命中后验货

ScanMemory 返回一堆地址。每个地址都可能是:

  1. 真正加载到内存的 global-metadata.dat
  2. 误命中(恰巧这 4 字节是这个值)。
  3. 文件的多个副本(比如游戏在多个地方缓存)。

确认方法是读出前 32 字节看看usermode/ce_cli.cpp 第 985~990 行):

uint8_t preview[32] = {};
drv.ReadMemory(pid, addr, preview, sizeof(preview));

打印出来:

AF 1B B1 FA 10 00 00 00 80 02 00 00 00 A0 23 00 ...

看到 AF 1B B1 FA 开头,后面跟着合理的 version 和 section offset,基本就稳了。


提取内存数据

怎么知道 dump 多大

找到地址只是第一步。下一步是把这块内存完整保存成磁盘上的 .dat 文件,方便用 Il2CppDumper 等工具分析。

global-metadata.dat 文件大小不固定,可能 10MB 也可能 50MB。直接 dump 整个内存区域会带上大量垃圾尾巴。

本项目用ai想了一个聪明的办法:解析文件头里的 section 表,算出精确大小

原理:metadata 文件由多个 section 拼成,每个 section 在头部有一对 (offset, size)文件总大小 = 所有 section 的 offset + size 的最大值

size_t  maxSize = 0;
for (size_t off = 8; off + 8 <= headerLen; off += 8) {
    int32_t sectionOffset = *(const int32_t*)(header + off);
    int32_t sectionSize   = *(const int32_t*)(header + off + 4);

    if (sectionOffset == 0 && sectionSize == 0) continue;

    // section offset 是单调递增的;遇到下降说明已经离开 header
    if (sectionOffset > 0 && prevOffset >= 0 && sectionOffset < prevOffset)
        break;

    if (sectionOffset > 0) prevOffset = sectionOffset;

    if (sectionOffset > 0 && sectionSize > 0) {
        size_t end = (size_t)sectionOffset + (size_t)sectionSize;
        if (end > maxSize) maxSize = end;
    }
}
return maxSize;

用”offset 单调递增”作为离开 header 的判据。header 后面紧接着的是 string heap / method table 等数据,把这些 4 字节当 offset 解读会乱跳,刚好可以用来识别”header 结束”。

单次 IOCTL 有限制

METHOD_BUFFERED 下,I/O 管理器要分配一块等同于缓冲区大小的内核内存。一次申请 50MB 大概率失败。

所以 dump 必须分块,每块 1MB:

const size_t CHUNK = 0x100000;  // 1 MB per IOCTL

std::ofstream f(path, std::ios::binary);
while (total < size) {
    size_t toRead = std::min(CHUNK, size - total);
    buf.resize(toRead);

    try {
        drv.ReadMemory(pid, addr + total, buf.data(), toRead);
        ok = true;
    } catch (...) {
        std::memset(buf.data(), 0, toRead);  // 失败的块填零
        failedChunks++;
    }

    f.write((const char*)buf.data(), toRead);
    total += toRead;
}

失败的块填零而非中断,保证输出文件大小始终是 size,方便后续工具按偏移解析。

完整 dump 命令

最终的成品:

> findmeta
[*] Pattern: AF 1B B1 FA  (4 bytes)
[+] Found 2 match(es)

  #    Address              AllocBase            AllocSize     First 32 bytes
  1    0x000000000CACC020   0x000000000CACC000   0x0239D000    AF 1B B1 FA 10 00 00 00 ...
  2    ...

  To dump metadata precisely (no trailing zeros), use:
    dump 0x000000000CACC020 auto metadata.dat

> dump 0x0CACC020 auto metadata.dat
[*] Auto: IL2CPP metadata v16
[*] Auto: precise size = 0x239D000 (35.62 MB)
[*] Dumping 0x0CACC020 size=0x239D000 (35.62 MB) -> metadata.dat
  [Progress] 35/35 MB (100%)
[+] Dumped 37220352 bytes (0x239D000) from 0x0CACC020 to metadata.dat

auto 关键字触发 computeIl2cppMetadataSize 自动算大小,避免拖泥带水。


踩坑提醒

实战中有很多跟头栽过的跟头,在此特意记录!

  1. KDU 模式下 __try/__except 不工作:手动映射的驱动 SEH 表没注册,任何未捕获异常直接 KMODE_EXCEPTION_NOT_HANDLED 蓝屏。所以读用户态内存必须用 ZwReadVirtualMemory 这种”ntoskrnl 自带 SEH”的 API,不能直接 RtlCopyMemory

  2. MmIsAddressValid 不是绝对保证:它只检查”当前时刻页是否驻留物理内存”,存在 TOCTOU 竞争。所以它只能用作快速预筛,真正的拷贝还得靠 ZwReadVirtualMemory

  3. 单次 IOCTL 缓冲区别太大METHOD_BUFFERED 要在内核分配等同大小的非分页内存,>1MB 容易失败。dump 大文件必须分块。

  4. 扫描时 chunk 边界要 overlap:搜的 pattern 可能横跨两个 chunk。缓冲多分配 valueSize - 1 字节、off += scanLen 而非 += toCopy,能既不漏又不重。

  5. 魔术头扫出来一堆:内存里可能存在多份 metadata 副本(游戏缓存、解密前后),通常选最小地址那个最稳(往往是原始加载位置)。

  6. 小端序坑:搜 0xFAB11BAF 别输成 FA B1 1B AF——那是大端序写法。x86/x64 永远是小端,低位字节在前。


参考链接

微软官方文档(驱动语法核心)

IL2CPP 与 metadata

驱动加载方式