HybridCLR拆解记录 (Part. I)
被迫造轮子但必须得手撕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 | |
第二个参数StackObject* argBase中的StackObject本身并不是数据类型,而是作为解释器栈槽(stack slot),同时声明解释器的最小栈单位是8字节。每个参数、局部变量、临时值都可能占用 1个或多个StackObject:
1 | |
其中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 | |
写回时直接按内存拷贝:
1 | |
针对ref/out(引用传递/输出引用)结构体,栈上只放了1个指向真实Struct地址的指针,读写方式需要对应修改:
1 | |
0x01 函数进入监听
获得EnterFrameFromNative和EnterFrameFromInterpreter函数绝对地址的方法可参考HybridCLR-Hook,此处不赘述。基于Dobby的hook写法示例如下:
1 | |
0x02 入参修改
函数原型:
1 | |
hook示例如下:
1 | |
函数返回值读取及修改
0x03 原理分析
首先查看hybridclr/interpreter/Engine.h的LeaveFrame函数,逐行解读:
1 | |
进一步查找LeaveFrame函数于何处被使用,进入到hybridclr/interpreter/interpreter_Execute.cpp:
1 | |
可见此处定义了2个预处理宏,其中LEAVE_FRAME()用于在执行循环中弹出当前解释器帧,恢复上一帧上下文或在没有帧可返回时跳出解释器主循环,执行完毕后销毁当前帧;SET_RET_AND_LEAVE_FRAME(nativeSize, interpSize)用于处理有返回值的方法,这里我们结合Execute函数中的特定case以及Copy##函数源码重新阅读一遍,以case RetVar_ret_1为例:
1 | |
1 | |
那么,如果想要读取或者修改函数返回值,最稳妥的载点应该是Copy函数执行之前,对localVarBase + __ret指向的内存区域进行读写。但还有1个关键问题待确认:Copy执行之前,LeaveFrame已经执行完毕,在这中间阶段寄存器中是否还留存有InterpFrame* callee?相关信息是否已经被LeaveFrame销毁?这关乎到我们能否在此处获得callee帧method信息,以及进行特定method hook。
阅读源码case RetVar_ret_n,通过memove交叉引用定位到LeaveFrame():
1 | |
对应:
1 | |
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:
1 | |
因此,需要重点关注寄存器X25所保存的InterpFrame* callee在LeaveFrame执行完毕后是否被其他值覆盖。结合MachineState定义,把LeaveFrame的Asm读一遍:
1 | |
1 | |
可见X25 在整个LeaveFrame执行期间保持不变,LeaveFrame最终返回X0 = caller Frame。现在可以确认:在CMP X19, X8语句处观测X25寄存器,即可获得当前method;读写X8寄存器指向的内存,即可读写函数返回值。
0x04 函数返回值读取
函数原型:
1 | |
这里需先在Github上找一份成品ObscuredFloat结构体定义并自行引用:传送门
可知ObscuredFloat结构体在内存中的真实布局为24字节:
1 | |
示例如下:
1 | |
运行结果:

0x05 函数返回值修改
示例如下:
1 | |
运行结果:

下一步待实现
画大饼时间到