首页
社区
课程
招聘
[原创]HybridCLR拆解记录 (Part. I)
发表于: 4小时前 113

[原创]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.cppInterpreter::Execute函数负责寄存器IR指令的解释执行,同时也是Native/反射进入解释器的唯一入口;hybridclr/interpreter/Engine.hEnterFrameFromInterpreter函数负责解释器调用解释器(即解释器内部A方法调用内部B方法)时建立 frame,不复制参数;hybridclr/interpreter/Engine.hEnterFrameFromNative函数负责Native调用解释器时建立 frame,并把参数搬进解释器栈。上述亦参考了其他分析文章:UnityHybirdCLR Hook实现HybridCLR源码赏析

结论显而易见,同时hook EnterFrameFromNativeEnterFrameFromInterpreter函数,根据第一个参数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.h
union 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 函数进入监听

获得EnterFrameFromNativeEnterFrameFromInterpreter函数绝对地址的方法可参考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 test
void 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: 0x000242EC
public 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.hLeaveFrame函数,逐行解读:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// hybridclr/interpreter/Engine.h
InterpFrame* 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.cpp
case 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.h
inline 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.cpp
case 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* calleeLeaveFrame执行完毕后是否被其他值覆盖。结合MachineState定义,把LeaveFrame的Asm读一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// hybridclr/interpreter/Engine.h
MachineState() {
    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: 0x000B8885
public ObscuredFloat UpgradeReformSuccessRate {
    get {
        return 0.001f * (float)this.Level;
    }
}

这里需先在Github上找一份成品ObscuredFloat结构体定义并自行引用:传送门

可知ObscuredFloat结构体在内存中的真实布局为24字节:

1
2
3
slot0 (0x00~0x07): currentCryptoKey | hiddenValue
slot1 (0x08~0x0F): hiddenValueOldByte4 | inited + padding
slot2 (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 test
void 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.区分数据类型,提供更多示例


传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 3
支持
分享
最新回复 (1)
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
tql
1小时前
0
游客
登录 | 注册 方可回帖
返回