从注入到调用:一个完整的Unity il2cpp运行时Hook实战指南(附C++代码)
从注入到调用:一个完整的Unity il2cpp运行时Hook实战指南(附C++代码)
在游戏开发与逆向工程领域,Unity引擎的il2cpp后端因其性能优势被广泛采用,但也带来了动态分析的独特挑战。本文将深入探讨如何通过运行时注入技术,实现对il2cpp编译后应用的精准控制——从定位内存结构到重定向关键函数调用,整个过程就像在黑暗森林中搭建一座可控制的桥梁。
1. 理解il2cpp运行时架构
il2cpp并非简单的C#到C++的转译器,而是一个完整的AOT(Ahead-Of-Time)编译系统。当选择il2cpp后端时,Unity会执行以下转换流程:
C#源码 → IL中间码 → C++代码 → 原生机器码关键组件构成:
- GameAssembly.dll/libil2cpp.so:核心运行时库,包含转换后的游戏逻辑
- global-metadata.dat:保存类型系统映射关系的元数据仓库
- 导出函数表:提供运行时反射能力的API接口
典型内存布局特征:
| 内存区域 | 内容描述 | 访问方式 |
|---|---|---|
| 代码段 | 编译后的机器指令 | 函数指针调用 |
| 元数据段 | 类型系统结构体 | API查询+指针解析 |
| 动态内存区 | 实例对象与运行时数据 | 指针追踪+偏移计算 |
注意:不同Unity版本中,il2cpp内部结构体可能存在字段偏移差异,实战中需结合具体版本验证。
2. 注入环境的精密准备
2.1 动态链接库注入方案
Windows平台推荐采用经典的DLL注入技术:
// 基础注入器示例(需管理员权限) HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, targetPID); LPVOID pMem = VirtualAllocEx(hProcess, NULL, dllPath.size(), MEM_COMMIT, PAGE_READWRITE); WriteProcessMemory(hProcess, pMem, dllPath.c_str(), dllPath.size(), NULL); HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle("kernel32"), "LoadLibraryA"), pMem, 0, NULL);Android平台则需要结合ptrace或LD_PRELOAD技术,这里展示一个dlopen的替代方案:
# 在注入器中执行 adb push injector /data/local/tmp adb push libhook.so /data/local/tmp adb shell chmod +x /data/local/tmp/injector adb shell /data/local/tmp/injector com.game.package2.2 运行时API定位策略
il2cpp的导出函数通常包含版本特征,可通过模式匹配动态定位:
// 动态获取API函数指针示例 auto il2cpp_resolve_export(const char* pattern) { HMODULE base = GetModuleHandle("GameAssembly"); IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)((BYTE*)base + ((IMAGE_DOS_HEADER*)base)->e_lfanew); IMAGE_EXPORT_DIRECTORY* exports = (IMAGE_EXPORT_DIRECTORY*)((BYTE*)base + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress); DWORD* names = (DWORD*)((BYTE*)base + exports->AddressOfNames); for(DWORD i = 0; i < exports->NumberOfNames; ++i) { const char* name = (const char*)base + names[i]; if(strstr(name, pattern)) { return (void*)((BYTE*)base + ((DWORD*)((BYTE*)base + exports->AddressOfFunctions))[i]); } } return nullptr; }3. 元数据导航与类型定位
3.1 程序集加载与类查询
通过三级跳转完成类方法定位:
// 典型查询路径示例 auto domain = il2cpp_domain_get(); auto assembly = il2cpp_domain_assembly_open(domain, "Assembly-CSharp"); auto image = il2cpp_assembly_get_image(assembly); auto targetClass = il2cpp_class_from_name(image, "", "PlayerController");关键结构体关系图:
Il2CppDomain → Il2CppAssembly → Il2CppImage → Il2CppClass → MethodInfo3.2 方法签名处理技巧
il2cpp方法调用存在隐式this指针参数,需要特殊处理:
// 方法Hook前后对比 Original: int Player_GetHealth(Player* this) { return this->health; } il2cpp转换后: int Player_GetHealth(Player* this, void* unused) { return this->health; } // 因此调用时需: typedef int (*Player_GetHealth_t)(Player*, void*); auto original = (Player_GetHealth_t)method->methodPointer; int health = original(playerInstance, nullptr);4. 实战Hook实现方案
4.1 虚函数表替换技术
对于虚方法可采用直接修改vtable的方案:
void hook_virtual_method(Il2CppClass* klass, const char* name, void* newFunc) { for(uint16_t i = 0; i < klass->vtable_count; ++i) { if(strcmp(klass->vtable[i].method->name, name) == 0) { DWORD oldProtect; VirtualProtect(&klass->vtable[i].methodPtr, sizeof(void*), PAGE_READWRITE, &oldProtect); klass->vtable[i].methodPtr = newFunc; VirtualProtect(&klass->vtable[i].methodPtr, sizeof(void*), oldProtect, &oldProtect); break; } } }4.2 机器码级Hook方案
更通用的指令跳转方案(x64平台示例):
#pragma pack(push, 1) struct JmpCode { uint8_t opcode = 0xE9; uint32_t offset; }; #pragma pack(pop) void install_detour(void* original, void* hook) { JmpCode jmp; jmp.offset = (uint32_t)((BYTE*)hook - (BYTE*)original - sizeof(JmpCode)); DWORD oldProtect; VirtualProtect(original, sizeof(JmpCode), PAGE_EXECUTE_READWRITE, &oldProtect); memcpy(original, &jmp, sizeof(JmpCode)); VirtualProtect(original, sizeof(JmpCode), oldProtect, &oldProtect); }4.3 上下文保存与恢复
安全的Hook实现应保存原始上下文:
// 典型Hook代理函数 __declspec(naked) void HookProxy() { __asm { pushad pushfd // 自定义处理逻辑 call [g_customHandler] popfd popad // 跳回原函数或替代逻辑 jmp [g_originalFunc] } }5. 高级调试技巧与异常处理
5.1 元数据校验绕过
当遇到元数据加密时,可采用动态重建策略:
Il2CppClass* safe_get_class(const char* name) { static std::unordered_map<std::string, Il2CppClass*> cache; if(auto it = cache.find(name); it != cache.end()) return it->second; auto klass = il2cpp_class_from_name(/*...*/); if(!klass) { // 尝试通过特征码扫描定位类实例 uintptr_t classPtr = scan_memory_for_class(name); if(classPtr) { klass = reinterpret_cast<Il2CppClass*>(classPtr); // 手动填充必要字段 klass->name = strdup(name); } } cache[name] = klass; return klass; }5.2 线程安全防护措施
多线程环境下的Hook操作需要同步机制:
std::recursive_mutex g_hookMutex; void thread_safe_hook() { std::lock_guard<std::recursive_mutex> lock(g_hookMutex); suspend_all_threads(); // 执行Hook操作 resume_all_threads(); }6. 实战案例:属性修改器实现
完整工作流程示例:
// 1. 注入初始化 void on_attach() { auto playerClass = il2cpp_class_from_name(/*...*/, "Player"); auto healthProp = il2cpp_class_get_property_from_name(playerClass, "Health"); // 2. 获取原始存取器 auto getter = il2cpp_property_get_get_method(healthProp); auto setter = il2cpp_property_get_set_method(healthProp); // 3. 安装Hook g_originalGet = getter->methodPointer; install_detour(g_originalGet, &hooked_getter); // 4. 持久化监控 create_mod_thread(); }典型问题排查表:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 注入后立即崩溃 | 版本不匹配 | 验证Unity版本和偏移量 |
| 调用时参数错乱 | this指针处理错误 | 检查调用约定和参数数量 |
| 部分功能失效 | 元数据混淆 | 采用动态特征码定位 |
| 多线程环境下崩溃 | 竞态条件 | 添加线程同步机制 |
在实际项目中,我发现最有效的调试方式是结合Cheat Engine的内存扫描与IL2CPP Dumper的输出交叉验证。例如当Hook一个玩家移动方法时,可以先用CE定位速度变量的内存地址,再通过反推访问路径来确定需要拦截的类方法。
