-
-
[原创]HybridCLR拆解记录 (Part. I)
-
发表于: 4小时前 113
-
第一次发帖,被迫造轮子但必须得手撕Asm的感觉不亚于屎里找饭,这种自虐的感觉真让人欲罢不能。
原本在上一篇文章0x06的尝试过后,我已经打定主意不再碰HybridCLR了,一是因为网络上可参考的内容确实不多,native hook方案非开源,抄都没地方抄;二是既然已经可以直接replace dll了,为啥还要自找麻烦呢?但实在是不甘心只能用Frida脚本反复试错,于是艰苦的旅程又双叒叕开始了。下文依旧围绕coderustle2展开。
APP: com.wingjoy.coderustle2 (ver 1.8.16)
OS: Redmi K40 with HyperOS 1.0.6.0 (Android 13) / Xiaomi 14 with HyperOS 3.0.6.0 (Android 16)
Tools: Android Studio / NDK r27 / SDK 36.0
函数进入监听及入参修改
0x00 原理分析
额外说明:不同版本的HybridCLR,相关重要函数参数及结构体定义不尽相同。以下内容均基于hybridclr-4.0.0。
不赘述,根据官方文档桥接函数、HybridCLR源码结构及调试及源码,可知hybridclr/interpreter/interpreter_Execute.cpp的Interpreter::Execute函数负责寄存器IR指令的解释执行,同时也是Native/反射进入解释器的唯一入口;hybridclr/interpreter/Engine.h的EnterFrameFromInterpreter函数负责解释器调用解释器(即解释器内部A方法调用内部B方法)时建立 frame,不复制参数;hybridclr/interpreter/Engine.h的EnterFrameFromNative函数负责Native调用解释器时建立 frame,并把参数搬进解释器栈。上述亦参考了其他分析文章:UnityHybirdCLR Hook实现、HybridCLR源码赏析
结论显而易见,同时hook EnterFrameFromNative和EnterFrameFromInterpreter函数,根据第一个参数InterpMethodInfo* imi结构体定义,通过解析可获得MethodInfo* method,进而拿到所有进入解释器的方法信息。InterpMethodInfo定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | // hybridclr/interpreter/InterpreterDefs.h#include <vector>struct InterpMethodInfo { const MethodInfo* method; MethodArgDesc* args; uint32_t argCount; uint32_t argStackObjectSize; byte* codes; uint32_t codeLength; uint32_t maxStackSize; // args + locals + evalstack size uint32_t localVarBaseOffset; uint32_t evalStackBaseOffset; uint32_t localStackSize; // args + locals StackObject size std::vector<uint64_t> resolveDatas; std::vector<InterpExceptionClause*> exClauses; bool initLocals;}; |
第二个参数StackObject* argBase中的StackObject本身并不是数据类型,而是作为解释器栈槽(stack slot),同时声明解释器的最小栈单位是8字节。每个参数、局部变量、临时值都可能占用 1个或多个StackObject:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // hybridclr/interpreter/InterpreterDefs.hunion StackObject { uint64_t __u64; void* ptr; bool b; int8_t i8; uint8_t u8; int16_t i16; uint16_t u16; int32_t i32; uint32_t u32; int64_t i64; uint64_t u64; float f4; double f8; Il2CppObject* obj; Il2CppString* str; Il2CppObject** ptrObj;};static_assert(sizeof(StackObject) == 8, "require 8 bytes"); |
其中bool/int8/int16/int32/float类型实际只用slot低位,高位清零,但仍占用8字节槽;int64/double/指针类型正好占满8字节;Struct类型将按大小拆成多个StackObject,如:12字节 -> 8字节+4字节 -> 2个slot。对于简单的入参类型,如int A,可以直接按上述定义读取值,例:int valueA = argBase[i].i32;写回值时要注意不要混用成员,同理:argBase[i].i32 = newValue;针对值传递结构体,取值时应按结构体大小,把连续的StackObject视同一块原始内存来读:
1 2 3 | auto* value = reinterpret_cast<Struct*>(&argBase[start]);Struct v = *value;float f = value->someField; // 也可只读字段 |
写回时直接按内存拷贝:
1 2 | auto* dst = reinterpret_cast<Struct*>(&argBase[start]);*dst = newValue; |
针对ref/out(引用传递/输出引用)结构体,栈上只放了1个指向真实Struct地址的指针,读写方式需要对应修改:
1 2 3 | auto* p = reinterpret_cast<Struct*>(argBase[i].ptr); // 读p->field = value; // 写*p = newValue; // 写 |
0x01 函数进入监听
获得EnterFrameFromNative和EnterFrameFromInterpreter函数绝对地址的方法可参考HybridCLR-Hook,此处不赘述。基于Dobby的hook写法示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | //0xF5D7C4 InterpFrame* EnterFrameFromNative(const InterpMethodInfo* imi, StackObject* argBase)//0xF5D8E0 InterpFrame* EnterFrameFromInterpreter(const InterpMethodInfo* imi, StackObject* argBase)HOOK_DEF(InterpFrame*, EnterFrameFromInterpreter, InterpFrame* self, const InterpMethodInfo* imi, StackObject* argBase) { InterpFrame* newFrame = orig_EnterFrameFromInterpreter(self, imi, argBase); const MethodInfo* method = imi->method; const char* name = method->name; LOGW("[Interp] %s called", name); return newFrame;}// Use absolute address for testvoid doHook() { //HOOK_FUNC((il2cpp_base + 0xF5D7C4), EnterFrameFromNative); HOOK_FUNC((il2cpp_base + 0xF5D8E0), EnterFrameFromInterpreter);} |
0x02 入参修改
函数原型:
1 2 3 4 | // Token: 0x06000694 RID: 1684 RVA: 0x000260EC File Offset: 0x000242ECpublic void AddDropGoldCoins(int count) { this.m_TotalGoldCoins += count;} |
hook示例如下:
1 2 3 4 5 6 7 8 9 10 | HOOK_DEF(InterpFrame*, EnterFrameFromInterpreter, InterpFrame* self, const InterpMethodInfo* imi, StackObject* argBase) { const MethodInfo* method = imi->method; const char* name = method->name; if (strcmp(name, "AddDropGoldCoins") == 0) { int count = argBase[1].i32; LOGI("[Interp] orig count: %d", count); argBase[1].i32 = count * 50; } return orig_EnterFrameFromInterpreter(self, imi, argBase);} |
函数返回值读取及修改
0x03 原理分析
首先查看hybridclr/interpreter/Engine.h的LeaveFrame函数,逐行解读:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | // hybridclr/interpreter/Engine.hInterpFrame* LeaveFrame() { IL2CPP_ASSERT(_machineState.GetFrameTopIdx() > _frameBaseIdx); // 断言当前至少存在一个可弹出的解释器帧,防止栈下溢 POP_STACK_FRAME(); // 通知调试器/profiler当前方法即将退出 InterpFrame* frame = _machineState.GetTopFrame(); // 取得当前正在执行的callee帧#if IL2CPP_ENABLE_PROFILER il2cpp_codegen_profiler_method_exit(frame->method->method);#endif if (frame->exFlowBase) { // 如果该方法内部使用过异常处理(try/catch) _machineState.SetExceptionFlowTop(frame->exFlowBase); // 恢复异常流栈到进入该方法之前的状态,防止异常信息泄漏到caller帧 } _machineState.PopFrame(); // 将当前callee Frame从frame栈中移除 _machineState.SetStackTop(frame->oldStackTop); // 恢复解释器操作数栈到进入该方法之前的高度 _machineState.SetLocalPoolBottomIdx(frame->oldLocalPoolBottomIdx); return _machineState.GetFrameTopIdx() > _frameBaseIdx ? _machineState.GetTopFrame() : nullptr; // 如果还有上层帧,则返回caller的InterpFrame*;如果已经回到解释器入口,则返回nullptr,解释器执行结束} |
进一步查找LeaveFrame函数于何处被使用,进入到hybridclr/interpreter/interpreter_Execute.cpp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // hybridclr/interpreter/interpreter_Execute.cpp#define LEAVE_FRAME() { \ frame = interpFrameGroup.LeaveFrame(); \ // 弹出当前解释器帧返回caller帧,或nullptr if (frame) \ // 如果为解释器内部调用返回(interp -> interp) { \ LOAD_PREV_FRAME(); \ // 恢复caller帧的执行上下文,继续解释执行 }\ else \ // 最外层返回(interp -> native) { \ goto ExitEvalLoop; \ // 跳出解释器主循环 } \}#define SET_RET_AND_LEAVE_FRAME(nativeSize, interpSize) { \ void* _curRet = frame->ret; \ // 保存当前帧的返回值地址 frame = interpFrameGroup.LeaveFrame(); \ // 弹出当前解释器帧并返回caller帧,或nullptr if (frame) \ // 如果为解释器内部调用返回(interp -> interp) { \ Copy##interpSize(_curRet, (void*)(localVarBase + __ret)); \ // 使用解释器格式(StackObject)拷贝callee帧的返回值到_curRet,供caller帧读取 LOAD_PREV_FRAME(); \ // 恢复caller帧的执行上下文,继续解释执行 }\ else \ // 最外层返回(interp -> native),如:Runtime::Invoke { \ Copy##nativeSize(_curRet, (void*)(localVarBase + __ret)); \ // 使用native ABI约定拷贝返回值 goto ExitEvalLoop; \ // 跳出解释器主循环 } \} |
可见此处定义了2个预处理宏,其中LEAVE_FRAME()用于在执行循环中弹出当前解释器帧,恢复上一帧上下文或在没有帧可返回时跳出解释器主循环,执行完毕后销毁当前帧;SET_RET_AND_LEAVE_FRAME(nativeSize, interpSize)用于处理有返回值的方法,这里我们结合Execute函数中的特定case以及Copy##函数源码重新阅读一遍,以case RetVar_ret_1为例:
1 2 3 4 5 6 | // hybridclr/interpreter/interpreter_Execute.cppcase HiOpcodeEnum::RetVar_ret_1: { // 若返回值为1字节 uint16_t __ret = *(uint16_t*)(ip + 2); // 从当前解释器指令后2个字节中取uint16_t作为__ret,作为返回值的局部变量索引 SET_RET_AND_LEAVE_FRAME(1, 8); // 将返回值从localVarBase的ret slot拷贝到caller帧的ret slot,供caller读取 continue;} |
1 2 3 4 5 6 7 | // hybridclr/interpreter/MemoryUtil.hinline void Copy1(void* dst, void* src) { // 如果最外层返回至native,遵循ABI按真实类型大小进行拷贝,即Copy1 *(uint8_t*)dst = *(uint8_t*)src;}inline void Copy8(void* dst, void* src) { // 如果解释器内部调用返回,执行Copy8,最终返回1个StackObject(8字节) *(uint64_t*)dst = *(uint64_t*)src;} |
那么,如果想要读取或者修改函数返回值,最稳妥的载点应该是Copy函数执行之前,对localVarBase + __ret指向的内存区域进行读写。但还有1个关键问题待确认:Copy执行之前,LeaveFrame已经执行完毕,在这中间阶段寄存器中是否还留存有InterpFrame* callee?相关信息是否已经被LeaveFrame销毁?这关乎到我们能否在此处获得callee帧method信息,以及进行特定method hook。
阅读源码case RetVar_ret_n,通过memove交叉引用定位到LeaveFrame():
1 2 3 4 5 6 7 8 | // hybridclr/interpreter/interpreter_Execute.cppcase HiOpcodeEnum::RetVar_ret_n: { uint16_t __ret = *(uint16_t*)(ip + 2); uint32_t __size = *(uint32_t*)(ip + 4); std::memmove(frame->ret, (void*)(localVarBase + __ret), __size); LEAVE_FRAME(); continue;} |
对应:
loc_F52F24:
LDRH W8, [X22,#2] ; jumptable 0000000000F4AAC0 case 294
LDR W2, [X22,#4] ; size_t
LDR X0, [X25,#0x18] ; void *
ADD X1, X23, X8,LSL#3 ; void *
BL .memmove
ADD X0, SP, #0x1460+var_BF0
BL LeaveFrame ; sub_F5D954
B loc_F5445C
Execute共计调用LeaveFrame16次,与IDA所查看的交叉引用次数相符。此处画一个流程图便于理解:

顺带根据Copy的系列定义,对比Asm语义逐一标注出SET_RET_AND_LEAVE_FRAME(1, 8)至SET_RET_AND_LEAVE_FRAME(32, 32)入口:

以case RetVar_ret_24为例(ObscuredFloat就是24字节Struct,后面接着细说),先解读入口的几句Asm:
loc_F4B24C: ; jumptable 0000000000F4AAC0 case 291 RetVar_ret_24
LDRH W20, [X22,#2] ; X22 = ip,W20 = __ret = ip + 2
LDR X19, [X25,#0x18] ; X25 = InterpFrame* callee,X19 = frame->ret = dst
ADD X0, SP, #0x1460+var_BF0 ; X0 = &interpFrameGroup(this)
BL LeaveFrame
ADD X8, X23, X20,LSL#3 ; X23 = localVarBase,X20 = __ret,X8 = src,等价于:void* src = localVarBase + __ret
CMP X19, X8
B.HI loc_F50DCC
LDR X9, [X8] ; 拷贝slot0
STR X9, [X19] ; 拷贝slot0
LDR X9, [X8,#8] ; 拷贝slot1
ADD X8, X8, #0x10 ; X8 -> slot3
STR X9, [X19,#8] ; 拷贝slot1
ADD X19, X19, #0x10 ; X19 -> slot3
B loc_F54454 ; slot3后续拷贝
因此,需要重点关注寄存器X25所保存的InterpFrame* callee在LeaveFrame执行完毕后是否被其他值覆盖。结合MachineState定义,把LeaveFrame的Asm读一遍:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // hybridclr/interpreter/Engine.hMachineState() { Config& hc = Config::GetIns(); _stackSize = -1; _stackBase = nullptr; _stackTopIdx = 0; _localPoolBottomIdx = -1; _frameBase = nullptr; _frameCount = -1; _frameTopIdx = 0; _exceptionFlowBase = nullptr; _exceptionFlowCount = -1; _exceptionFlowTopIdx = 0;} |
LeaveFrame:
STP X20, X19, [SP,#-0x10]! ; 保存被调用者寄存器 X19/X20
STP X29, X30, [SP,#0x10] ; 保存 FP/LR
ADD X29, SP, #0x10 ; 建立栈帧
LDR X8, [X0] ; X8 = this->_machineState
MOV X19, X0 ; X19 = this (InterpFrameGroup*)
LDRSW X9, [X8,#0x20] ; X9 = _frameTopIdx
CMP W9, #1
B.LT loc_F5D984 ; 若 frameTopIdx < 1,则没有 frame
LDR X8, [X8,#0x18] ; X8 = _frameBase
ADD X8, X8, X9, LSL #6 ; X8 = _frameBase + frameTopIdx * sizeof(InterpFrame)
SUB X20, X8, #0x40 ; X20 = GetTopFrame() = base + idx - 1
B loc_F5D988
loc_F5D984:
MOV X20, XZR ; frame = nullptr
loc_F5D988:
LDR X8, [X20] ; X8 = frame->method
LDR X0, [X8] ; X0 = frame->method->method
BL sub_C4E428 ; profiler_method_exit(method)
LDR X8, [X20,#0x28] ; X8 = frame->exFlowBase
CBZ X8, loc_F5D9BC ; 若无异常流,跳过
LDR X9, [X19] ; X9 = _machineState
LDR X10,[X9,#0x28] ; X10 = _exceptionFlowBase
SUB X8, X8, X10 ; X8 = exFlowBase - exceptionFlowBase (字节差)
LSR X8, X8, #3 ; /8
MOV W10,#0xAAAB
MOVK W10,#0xAAAA,LSL#16 ; W10 = 0xAAAAAAAB
MUL W8, W8, W10 ; /3 → 等价于 /24
STR W8, [X9,#0x30] ; _exceptionFlowTopIdx = (top - base)
loc_F5D9BC:
LDR X8, [X19] ; X8 = _machineState
MOV X0, XZR ; 默认返回 nullptr
LDR W9, [X8,#0x20] ; W9 = _frameTopIdx
SUB W9, W9, #1
STR W9, [X8,#0x20] ; PopFrame(): --_frameTopIdx
LDR X8, [X19]
LDR W9, [X20,#0x10] ; frame->oldStackTop
STR W9, [X8,#0x0C] ; _stackTopIdx = oldStackTop
LDR X8, [X19]
LDR W9, [X20,#0x38] ; frame->oldLocalPoolBottomIdx
STR W9, [X8,#0x10] ; _localPoolBottomIdx = oldLocalPoolBottomIdx
LDR X8, [X19]
LDR W9, [X8,#0x20] ; W9 = _frameTopIdx
CMP W9, #1
B.LT loc_F5DA14 ; 若无 frame,返回 nullptr
LDR W10,[X19,#0x0C] ; W10 = _frameBaseIdx
CMP W9, W10
B.LS loc_F5DA14 ; 若 <= baseIdx,返回 nullptr
LDR X8, [X8,#0x18] ; X8 = _frameBase
SXTW X9, W9
ADD X8, X8, X9, LSL #6
SUB X0, X8, #0x40 ; 返回新的 GetTopFrame(),即caller Frame
loc_F5DA14:
LDP X29, X30, [SP,#0x10] ; 恢复 FP/LR
LDP X20, X19, [SP],#0x20 ; 恢复寄存器并回收栈
RET
可见X25 在整个LeaveFrame执行期间保持不变,LeaveFrame最终返回X0 = caller Frame。现在可以确认:在CMP X19, X8语句处观测X25寄存器,即可获得当前method;读写X8寄存器指向的内存,即可读写函数返回值。
0x04 函数返回值读取
函数原型:
1 2 3 4 5 6 7 | // Token: 0x170005D7 RID: 1495// (get) Token: 0x06002918 RID: 10520 RVA: 0x000BA685 File Offset: 0x000B8885public ObscuredFloat UpgradeReformSuccessRate { get { return 0.001f * (float)this.Level; }} |
这里需先在Github上找一份成品ObscuredFloat结构体定义并自行引用:传送门
可知ObscuredFloat结构体在内存中的真实布局为24字节:
1 2 3 | slot0 (0x00~0x07): currentCryptoKey | hiddenValueslot1 (0x08~0x0F): hiddenValueOldByte4 | inited + paddingslot2 (0x10~0x17): fakeValue | fakeValueActive + padding |
示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | //0xF5D8E0 InterpFrame* EnterFrameFromInterpreter(const InterpMethodInfo* imi, StackObject* argBase)HOOK_DEF(InterpFrame*, EnterFrameFromInterpreter, InterpFrame* self, const InterpMethodInfo* imi, StackObject* argBase) { InterpFrame* newFrame = orig_EnterFrameFromInterpreter(self, imi, argBase); const MethodInfo* method = imi->method; const char* name = method->name; if (strcmp(name, "get_UpgradeReformSuccessRate") == 0) { LOGW("[Interp] get_UpgradeReformSuccessRate called frame=%p method=%p", newFrame, method); } return newFrame;}static void on_ret24_cmp(void *address, DobbyRegisterContext *ctx) { uint64_t x8 = ctx->general.regs.x8; InterpFrame* frame = (InterpFrame*)ctx->general.regs.x25; const MethodInfo* method = frame->method->method; const char* name = frame->method->method->name; if (strcmp(name, "get_UpgradeReformSuccessRate") == 0) { LOGI("F4B260 hit:"); LOGI(" X8 (ret src) = %p", (void*)x8); LOGI("InterpFrame:"); LOGW(" self = %p", frame); LOGI(" imiMethod = %p", frame->method); LOGW(" method = %p", frame->method->method); LOGI(" stackBasePtr = %p", frame->stackBasePtr); LOGI(" oldStackTop = %d", frame->oldStackTop); LOGI(" ret = %p", frame->ret); LOGI(" ip = %p", frame->ip); // dump return slot memory uint8_t dump[24]; memcpy(dump, (void*)x8, sizeof(dump)); for (int i = 0; i < 24; i += 8) { LOGI(" ret +%02X: %02X %02X %02X %02X %02X %02X %02X %02X", i, dump[i+0], dump[i+1], dump[i+2], dump[i+3], dump[i+4], dump[i+5], dump[i+6], dump[i+7]); } auto* of = (ObscuredFloat*)x8; LOGI("Original ObscuredFloat:"); LOGW(" key=%d hidden=0x%08X inited=%d fake=%f fakeActive=%d", of->currentCryptoKey, (uint32_t)of->hiddenValue, of->inited, of->fakeValue, of->fakeValueActive); }}// Use absolute address for testvoid doHook() { HOOK_FUNC((il2cpp_base + 0xF5D8E0), EnterFrameFromInterpreter); // 双向观测 DobbyInstrument((void*)(il2cpp_base + 0xF4B260), on_ret24_cmp);} |
运行结果:

0x05 函数返回值修改
示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | static void on_ret24_cmp(void *address, DobbyRegisterContext *ctx) { uint64_t x8 = ctx->general.regs.x8; InterpFrame* frame = (InterpFrame*)ctx->general.regs.x25; const MethodInfo* method = frame->method->method; const char* name = frame->method->method->name; if (strcmp(name, "get_UpgradeReformSuccessRate") == 0) { LOGI("Original ObscuredFloat:"); LOGW(" key=%d hidden=0x%08X inited=%d fake=%f fakeActive=%d", of->currentCryptoKey, (uint32_t)of->hiddenValue, of->inited, of->fakeValue, of->fakeValueActive); of->fakeValueActive = true; of->Encrypt(0.25f); of->inited = true; LOGI("New ObscuredFloat:"); LOGE(" key=%d hidden=0x%08X inited=%d fake=%f fakeActive=%d", of->currentCryptoKey, (uint32_t)of->hiddenValue, of->inited, of->fakeValue, of->fakeValueActive); }} |
运行结果:

下一步待实现
画大饼时间到
1.自动识别 EnterFrameFromNative、FromInterpreter 并hook
2.自动识别所有 Ret case 并插桩
3.高版本HybridCLR支持
4.区分数据类型,提供更多示例