September 27, 2026
安全逆向工程IL2CPPAndroid

Unity IL2CPP 移动端运行时改造技术报告

由 LongCat-2.5-Preview with Deepseek-V4.1-Flash 生成。

测试环境:自有设备、自有账号、离线验证。全文隐去应用与厂商标识。所有偏移量来自单一构建,仅作方法示例。

0. 结论先行

目标实际改的层手法UI 即时生效
等级 → 推到上限查表调用点指令替换(跳转桩)是
特训档位运行时实体字段直接写 + 值域保护否
留影等级 + 上限运行时字段 + 缓存行走游戏 setter + 清缓存否
技能等级(演出/激奏)运行时字段 + 协议 DTO双写否
记忆满级LINQ 谓词谓词常量替换否

唯一能即时生效的是第一种,原因见第 9 节。

1. 执行层:热修闸门决定一切

目标集成了 IFix 热更新框架。每个托管方法编译后的原生代码都长这样:

; MemberCard.get_Level @0x605a54c
stp  x30, x19, [sp, #-0x10]!
mov  x19, x0
mov  w0, #0x1df4          ; 该方法的补丁编号
bl   IsPatched            ; 0x5f9cf50
tbz  w0, #0, original     ; 未热修 -> 执行原生体
mov  w0, #0x1df4
bl   GetPatch             ; 0x5f9ced4
cbz  x0, original
mov  x1, x19
mov  x2, xzr
ldp  x30, x19, [sp], #0x10
b    #0x5e86784           ; 已热修 -> 交给解释器
original:
mov  x0, x19
bl   get_CurrentSupportCardLevel
cbz  x0, ...
ldr  w0, [x0, #0x24]
ret

两个直接后果:

手法是否有效原因
方法体中部打补丁(换一条 bl)仅当该方法未被热修热修方法入口即跳解释器,原生体是死代码
入口内联钩(DobbyHook 替换函数地址)始终有效在 IsPatched 判定之前执行
钩属性 getter 改返回值对热修过的显示逻辑无效解释器执行 IL 时用 ldfld 直读字段,不经访问器

第三条是最大的认知转折:日志能证明 getter 返回了目标值,但界面纹丝不动,因为界面根本不调 getter。

判定一个方法是否被热修,读它入口第一个 mov w0, #imm 之后的 IsPatched 返回值即可。

2. 元数据获取:从加密文件到完整方法表

后续所有工作都依赖一张「类名 / 方法名 → RVA」的表。而应用随包的 global-metadata.dat 是加密的,标准工具直接解析失败。两条路:静态解出文件,或运行时从进程内存里捞解密后的副本。我们走后者,因为解密逻辑在 native 里且随版本变化。

2.1 结构判据扫描进程内存

IL2CPP metadata 头部布局是已知的:魔数 0xFAB11BAF、版本号,之后是成组的 (offset, size)。据此写判据:

static const uint32_t META_LIMIT = 40000000u;   // metadata ~31M,留余量
static const uint32_t MIN_SPAN   = 2000000u;    // 收紧版:排除短误报

static inline bool is_meta_header(const uint32_t* v) {
    if (v[0] != IL2CPP_MAGIC) return false;     // 0xFAB11BAF
    uint32_t ver = v[1];
    if (ver < 20 || ver > 40) return false;     // 已知版本区间
    if (v[2] != 0x100) return false;
    uint32_t max_off = 0;
    for (int i = 2; i <= 10; i += 2) {
        if (v[i]   >= META_LIMIT) return false; // 各表 offset
        if (v[i+1] >= META_LIMIT) return false; // 各表 size
        if (v[i] > max_off) max_off = v[i];
    }
    return max_off >= MIN_SPAN;                 // 真表 offset 跨度占文件 40%+
}

实测:4 个真实 metadata(ver 27/29/31)全部命中,而加密版本正确不命中。

扫描时的两个细节:

// mincore 逐页确认驻留,再读。否则 /proc/maps 与实际读之间有竞态,
// 映射被释放时直接 SIGBUS。
unsigned char* vec = malloc(npages);
if (mincore((void*)lo, (size_t)(hi - lo), vec) != 0) return;
for (size_t pg = 0; pg < npages; pg++) {
    if (!(vec[pg] & 1)) continue;               // 该页未驻留,跳过
    /* 逐 4 字节对齐位置试判据 */
}

另外不要跳过 [heap]:解密后的副本很可能就在堆上。

2.2 用体积定位解密副本

第二个判据更直接:解密副本是 malloc 出来的,长度与磁盘文件几乎一致。

if (perm[0] != 'r' || perm[1] != 'w') continue;   // 解密副本可写
size_t sz = (size_t)(hi - lo);
if (sz >= META_SIZE && sz < META_SIZE + (2u << 20)) {
    const uint8_t* b = (const uint8_t*)lo;
    /* 打印前 16 字节:明文/密文一眼可辨 */
}

2.3 运行时全局与镜像表

拿到头部之后,三个关键全局(同一构建,来自探针 dump):

用途地址
s_Imagebase + 0xCCE7288
Il2CppGlobalMetadataHeader*base + 0xCCE7290
类型注册表base + 0xCCE7278

镜像表用的是 init 阶段填好的静态槽位表 base + 0xCCE7448(最多 96 槽,实测 25 个非空),不是 domain 里的 vector(那个在运行时是空的):

// init 在 0x5438CFC 把 Il2CppImage* 填进 base+0xCCE7448
void** slots = (void**)(g_base + 0xCCE7448);
for (int i = 0; i < 96; i++) {
    void* im = untagp(slots[i]);
    if (!im || !ok(im, 0x48)) break;
    /* im + 0x00 = image, + 0x38 = codeGenModule */
}

2.4 直调引擎自己的元数据访问器

不自己解析元数据二进制格式(版本一改就废),而是直接调用引擎的两个解包函数:

函数地址作用
fp_td0x5415BD0解包 type definition 记录(0x54 字节)
fp_dec0x541592C解包 method definition 记录(0x24 字节)

ABI 坑(这次踩得最贵):这两个函数是「返回大结构体」的签名。按 AAPCS64,超过 16 字节的返回值走 X8(sret 约定):

struct TdOut { uint8_t b[0x54]; };
struct MdOut { uint8_t b[0x24]; };
// 正确:返回结构体,编译器自动把返回槽放进 X8
typedef TdOut (*fn_td)(const void* src, void* ctx);
typedef MdOut (*fn_dec)(const void* src, void* ctx);

我们一度声明成三参 (out, src, ctx),于是 out 进了 X0,而函数把数据写进 X8 里的垃圾指针:

SIGSEGV_ACCERR @0x5415bdc   (str d0,[x8])

方法地址的计算公式(来自对 0x54294F0 / 0x543D1D8 的反汇编):

mdAddr  = meta + hdr[0x44] + (hdr[0x48] / hdr[0x4C]) * (td.methodStart + j)
nameOff = meta + md.nameIndex + hdr[0x20]
codePtr = methodPointers[(md.token & 0xFFFFFF) - 1]

其中 methodPointers 的取法:klass+0x00 → image,image+0x38 → codeGenModule,module+0x10 → 方法指针数组。

还有一个魔改点:方法数不能读 klass+0x120,这个字段在该运行时里恒为 0,必须从 td 记录的 method_count 取。

2.5 产物

导出成 TSV:命名空间 / 类 / 方法名 / mdidx / token / RVA。本文后续出现的每一个地址,都是拿这张表反查出来的;反过来,任何一个名字也都能查到地址。

3. 定位基建

3.1 页面解密识别

目标对部分代码页做了单字节 XOR(密钥 0x98)。不识别的话反汇编出来一片乱码,容易误判成花指令。判据用 capstone 的解码率:

def get_code(rva, size):
    """返回 (code, mode):明文或 xor98 解密;用可解码率判定"""
    raw = so[rva - DELTA : rva - DELTA + size]
    def rate(b):
        return sum(1 for _ in md.disasm(b, rva))
    if rate(raw) >= size // 8:      return raw, "plain"
    dec = bytes(b ^ 0x98 for b in raw)
    if rate(dec) >= size // 8:      return dec, "xor98"
    return raw, "UNKNOWN"

3.2 桩编码助手

所有指令替换都靠手写 ARM64 编码。三个基础函数:

// 无条件跳转 B:imm26 是"相对当前指令"的指令数
static uint32_t b_enc(uintptr_t from, uintptr_t to) {
    int64_t d = (int64_t)to - (int64_t)from;
    return 0x14000000u | (uint32_t)((d >> 2) & 0x03FFFFFFu);
}
// 带链接跳转 BL:同一编码,opcode 换 0x94
static uint32_t bl_enc(uintptr_t from, uintptr_t to) {
    int64_t d = ((int64_t)to - (int64_t)from) >> 2;
    return 0x94000000u | (uint32_t)(d & 0x03FFFFFF);
}
// 改一条指令:临时给整页加写权限,写完立刻收回
static bool patch_word_rwx(uintptr_t rva, uint32_t w) {
    uint32_t* p = (uint32_t*)(g_base + rva);
    long psz = sysconf(_SC_PAGESIZE);
    uintptr_t page = (uintptr_t)p & ~((uintptr_t)psz - 1);
    if (mprotect((void*)page, psz, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) return false;
    memcpy(p, &w, 4);
    __builtin___clear_cache((char*)p, (char*)p + 4);
    mprotect((void*)page, psz, PROT_READ | PROT_EXEC);
    return true;
}

__builtin___clear_cache 不能省。ARM64 的 I-cache 和 D-cache 不保证一致,漏掉会「补丁写进去了但 CPU 跑的还是旧指令」。

3.3 方法信息捕获

手动调用托管方法时第二个参数必须是真实的 MethodInfo*,传 nullptr 会让游戏内部解引用崩掉(详见 7.3)。由于没有静态地址,用透传钩在真实调用时把它记下来:

static void hook_mi_upd(void* self, void* m) {
    if (m && !g_mc_upd_mi) g_mc_upd_mi = m;   // 首次命中即缓存
    g_orig_mi_upd(self, m);                    // 必须透传,不能只记日志
}

static void recalc_try_hooks(void) {
    struct { uintptr_t rva; void* repl; void** orig; bool* done; } H[4] = {
        { 0x5face00, (void*)hook_mi_upd,  (void**)&g_orig_mi_upd,  &g_mi_done[0] },  // UpdateLevel
        { 0x5fb05bc, (void*)hook_mi_chg,  (void**)&g_orig_mi_chg,  &g_mi_done[1] },  // OnChange
        { 0x605c720, (void*)hook_mi_schg, (void**)&g_orig_mi_schg, nullptr },        // SupportCard.OnChange
        { 0x6059570, (void*)hook_mi_supd, (void**)&g_orig_mi_supd, nullptr },        // SupportCard.UpdateLevel
    };
    for (int i = 0; i < 4; i++) {
        if (H[i].done && *H[i].done) continue;
        void* fn = (void*)(g_base + H[i].rva);
        if (!ok(fn, 4)) continue;                       // 页还没解密,等下一轮 tick
        if (DobbyHook(fn, H[i].repl, H[i].orig) != 0 || !*H[i].orig) continue;
        if (H[i].done) *H[i].done = true;
    }
}

ok() 是自写的地址校验:查 /proc/self/maps 确认该地址在已映射且可执行的区间内。所有解引用前都过一遍,避免在页未解密时踩内存。

4. 案例一:等级推到上限(唯一能热更新的那个)

4.1 链路

环节地址语义
get_Level0x605a54c读 [卡+0x48] 缓存的等级行,行 +0x24 = 等级
get_CurrentSupportCardLevel0x605a408缓存空时调 UpdateLevel
UpdateLevel0x6059570bl GetByGroupAndExp(等级组, 经验) ← 要替换的就是这条
get_AchievableSupportCardLevel0x605a664游戏自带:"不超过上限的最大成长行"
get_LimitLevel0x605a940当前档位行 +0x28

get_AchievableSupportCardLevel 内部就是一个 LINQ 谓词 行.Level <= get_LimitLevel,取最后一个满足的行。也就是说,它返回的就是上限行。

4.2 实现

被替换的那条 bl 位于 0x6059624,原始机器码 0x97e4817f。替换成一个三指令桩:

static const uintptr_t SN_SITES[1]      = { 0x6059624 };
static const uint32_t  SN_ORIG[1]       = { 0x97e4817f };   // bl GetByGroupAndExp
static const uintptr_t SN_ACHIEVABLE[1] = { 0x605a664 };    // bl get_AchievableSupportCardLevel

// 校验原始字节(页可能还没解密)
for (int i = 0; i < 1; i++) {
    uint32_t w = 0;
    if (!ok((const void*)(g_base + SN_SITES[i]), 4)) { LOGE("site%d 未映射", i); return; }
    memcpy(&w, (const void*)(g_base + SN_SITES[i]), 4);
    if (w != SN_ORIG[i]) { LOGE("site%d word %08x != %08x(页未解密)", i, w, SN_ORIG[i]); return; }
    g_sn_backup[i] = w;                       // 备份,用于还原
}

// 在站点附近申请一张可执行页
uintptr_t C = stub_alloc(g_base + SN_SITES[0], 0x40, &g_sn_slot);

// 写桩
uintptr_t c = C;
uint32_t code[3];
code[0] = 0xaa1303e0u;                             // mov x0, x19   ← this
code[1] = bl_enc(c + 4, g_base + SN_ACHIEVABLE[0]); // bl 可达行
code[2] = b_enc(c + 8, g_base + SN_SITES[0] + 4);   // b 回原位置
memcpy((void*)c, code, sizeof(code));
__builtin___clear_cache((char*)c, (char*)c + 12);

// 把原调用点改成跳到桩
patch_word_rwx(SN_SITES[0], b_enc(g_base + SN_SITES[0], c));

三件事必须同时成立,否则崩:

  1. x19 在当前点确实是 this。要回看被替换点的前几条指令确认(本例中 mov x19, x0 在函数序言,中间没被覆盖)。
  2. 跳回点是 site + 4,即原 bl 的下一条指令。
  3. x0/x30 在此处没有活跃值。两个调用点之后都不再读 x0(结果来自返回值),x30 退出时从栈恢复。

4.3 顺带撞到的坑

坑:改完不生效,因为缓存。 等级行被缓存在卡对象的 +0x48。桩只影响「重新查表」这条路径,缓存非空时根本不会查表。所以开启功能时还要清缓存:

static void cache_clear_snaps() {
    for (int i = 0; i < g_sn_probe_n && i < CARD_CAP; i++) {
        void* e = g_sn_probe[i];
        if (!e || !ok(e, 0x60)) continue;
        void* zero = nullptr;
        memcpy((char*)e + 0x48, &zero, sizeof(zero));   // 等级行缓存清零
    }
}

清零之后,下一次读 get_Level 时缓存为空 → 走 UpdateLevel → 桩生效 → 拿到上限行。全程由游戏自己在游戏线程上完成,我们只清了一个指针。

坑:桩页面必须离站点够近。 B 指令的 imm26 只有 ±128MB 范围。早期用固定阈值找空闲页,结果页面刚好超出范围,跳转变成乱飞。改成在站点附近按距离筛选,并把上限放宽到 ±119MB 再加余量。

实测(snread 直读游戏自身 getter):

snread[0] rank=3 max=5 lv=50 limit=50 exp=194250

5. 案例二:档位字段(值域保护)

5.1 字段布局

运行时对象的字段排布很紧凑,要找的字段前后都是同宽度的整数:

+0x18  经验
+0x1c  档位 A        ← 目标
+0x20  档位 B
+0x24  数值 C
+0x28  数值 D
+0x48  成长行缓存(指针)
+0x4c  另一个档位
+0x60  成长行缓存(指针)

四个整数都可能是「看起来合理的小整数」,写错一个就是另一个功能的静默损坏。

5.2 实现

权威偏移来源是叶子访问器的反汇编:get_X 就是 ldr w0,[x19,#0x1c],SetX 就是 str w19,[x20,#0x1c]。确认后扫写:

// 非 Custom 类:先确认这个位置确实是目标字段(用 getter 透传值配对)
uint32_t mc = g_orig_aw_mc ? g_orig_aw_mc(e, nullptr) : 0xFFFFFFFFu;
uint32_t a = 0;
memcpy(&a, (char*)e + 0x1c, 4);
if (g_tx_on && mc <= 15 && a == mc && a < tgt) {   // 只升不降
    memcpy((char*)e + 0x1c, &tgt, 4);
}

a == mc 这一句是必要的类判别:get_AwakeCount 的透传返回值必须等于 [+0x1c],才能确认这个元素是标准类而不是派生类(派生类同名字段在 +0x4c)。

5.3 坑:自证式校验恒真

我们一度写了这样一个「更严谨」的检查:

// 错误示范
uint32_t skv = 0;
memcpy(&skv, (char*)e + 0x24, 4);
if (g_orig_sk_get) {
    uint32_t passthru = g_orig_sk_get(e, nullptr);
    if (passthru != skv) continue;   // ← 恒真,等于没有检查
}

g_orig_sk_get 的实现体就是 ldr w0,[x19,#0x24],读的是同一块内存,两者必然相等。这个检查骗了我们几轮日志。改用值域合理性:

static const int SKR[2] = { 0x24, 0x28 };   // 演出技能 / 激奏技能
for (int j = 0; j < 2; j++) {
    uint32_t skv = 0;
    memcpy(&skv, (char*)e + SKR[j], 4);
    if (skv <= 15 && skv < g_sk_level) {    // 值域 + 单调
        memcpy((char*)e + SKR[j], &g_sk_level, 4);
    }
}

判据:任何「用同一来源验证同一来源」的检查都要重新审视。

5.4 坑:改错层导致美术资源异常

我们曾把一个字段当成运行时属性写入,结果卡面立绘白屏。事后确认那是主数据表行里的字段,而它同时是资源键的一部分。修复只能靠重启让主数据重新加载。

判据:写入前必须确认这一层就是界面读取的那一层。 判别方法:在运行时读出该对象的指针,确认它出现在界面刷新路径的参数里,而不是只在初始化时出现。

5.5 实测

扫写 27/31 张 (klass=0x0 base=0 tgt=2) 其中技能 sk=31 张 -> 3

6. 案例三:留影等级 + 上限(走游戏自己的 setter)

这个案例和案例一的区别:上限由另一个字段决定,而等级由经验查表决定,两者是独立的两条链。

6.1 链路与实现

环节地址语义
get_Rank0x605acb8[卡+0x1c]
get_MaxRank0x605ad54查表
SetRank0x605c6c0str w19,[x20,#0x1c],并触发 OnChange
get_Exp0x605a8f8[卡+0x18]
UpdateLevel0x6059570重算成长行

攻略是走游戏自己的 SetRank(而不是直接写字段),这样 OnChange 会被触发,UI 监听者能收到通知:

uint32_t rk = g_orig_sc_rank(e, nullptr);
uint32_t mx = g_orig_sc_maxrank(e, nullptr);
uint32_t want = g_sn_rank;
if (mx && mx < want) want = mx;      // 绝不越过表里的最大档
if (rk >= want) continue;            // 只升不降
g_orig_sc_setrank(e, want, nullptr); // 走游戏自己的 setter(含 OnChange)

// 关键一步:清成长行缓存并重算
void* zero = nullptr;
memcpy((char*)e + 0x48, &zero, sizeof(zero));
g_orig_sc_updlevel(e, nullptr);

// 把经验钳到该行的 exp,否则进度条显示 0
void* row = nullptr;
memcpy(&row, (char*)e + 0x48, sizeof(row));
if (row && ok(row, 0x2c)) {
    uint32_t row_exp = 0, cur_exp = 0;
    memcpy(&row_exp, (char*)row + 0x28, 4);
    memcpy(&cur_exp, (char*)e + 0x18, 4);
    if (row_exp > cur_exp) memcpy((char*)e + 0x18, &row_exp, 4);
}

6.2 坑:只改档不清缓存

第一次实现只做了 SetRank,日志显示:

snread[0] rank=3 max=5 lv=30 limit=50    ← 上限到 50 了,等级还停在 30

原因就是成长行缓存在 +0x48,非空时 get_CurrentSupportCardLevel 直接返回旧行。清掉之后:

snread[0] rank=3 max=5 lv=50 limit=50 exp=194250

7. 案例四:技能等级(协议层双写)

7.1 字段表

同一概念在三个类里有三套偏移,且演出技能与激奏技能是两个独立字段:

类演出技能激奏技能
运行时标准卡+0x24+0x28
运行时派生卡+0x54+0x58
协议 DTO+0x38+0x4c

对照组(很重要):队长技能没有存储字段。

; MemberCard.get_LeaderSkillLevel @0x5fac720
bl   get_CurrentMemberCardRank     ; 拿当前突破档位行
ldr  w0, [x0, #0x38]               ; 等级直接从这一行读出来

也就是说「突破档位抬升 → 队长技能自动跟着变」,不需要单独写。这一点纠正了我们早期的一个错误假设(曾以为某个档位字段是技能入口)。

7.2 双写实现

运行时层(扫写已有对象,按 klass 分流):

if (g_custom_klass && k == g_custom_klass) {
    static const int SKC[2] = { 0x54, 0x58 };       // 派生卡
    for (int j = 0; j < 2; j++) {
        uint32_t skv = 0;
        memcpy(&skv, (char*)e + SKC[j], 4);
        if (skv <= 15 && skv < g_sk_level) memcpy((char*)e + SKC[j], &g_sk_level, 4);
    }
    continue;
}
// 标准卡
static const int SKR[2] = { 0x24, 0x28 };
for (int j = 0; j < 2; j++) { /* 同上 */ }

协议层(在反序列化出口写,保证服务器重新下发时也是目标值):

static void mf_post(void* self, int which) {
    if (!g_sk_on || g_sk_level == 0) return;
    int off0 = (which < 2) ? 0x38 : 0x2c;   // 主 DTO / 卡组 DTO
    int off1 = (which < 2) ? 0x4c : 0x30;
    int offs[2] = { off0, off1 };
    for (int j = 0; j < 2; j++) {
        if (!ok((char*)self + offs[j], 4)) continue;
        uint32_t cur = 0;
        memcpy(&cur, (char*)self + offs[j], 4);
        if (cur <= 15 && cur < g_sk_level) memcpy((char*)self + offs[j], &g_sk_level, 4);
    }
}

挂载在反序列化出口(InternalMergeFrom),不是挂在 set_X 上:实测 setter 整场零命中,protobuf 解码是直接写字段的。

7.3 坑:MethodInfo 传空指针

第一版在命令线程里直接调游戏方法做重算:

// 崩溃写法
g_orig_sc_updlevel(e, nullptr);   // ← 第二个参数是 MethodInfo*

结果点一下开关立刻崩溃:

signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
x0 0  x19 0  x20 0x30
#00 pc 0x542a188  libil2cpp.so    ldr x8,[x0,w19,uxtw #3]

游戏内部会 ldr x8,[x0,#0xb8] 解引用这个指针。修法是 3.3 的捕获机制:抓到之前拒绝执行。

7.4 坑:诊断函数自己崩

写诊断命令时想直接调 get_LeaderSkillLevel 读值,结果崩在自己的函数里(崩溃点在该诊断函数 +240 字节处)。原因是该函数内部:

bl   get_CurrentMemberCardRank
ldr  w0, [x0, #0x38]     ; ← 对返回值没有空检查

档位行表未就绪时返回空,直接解引用。判据:反汇编时看到 bl 之后紧跟 ldr xN,[x0,...] 而没有 cbz,说明该函数依赖调用方保证前置条件,不能随意直调。 修法是诊断函数只读原始内存,不调这类函数。

7.5 坑:DTO 里没有对应字段

卡组 DTO 只有演出技能 +0x2c 和一个 PerformanceSkill +0x30,没有激奏字段。我无法确认 +0x30 的语义,所以没写它。少写一处不会出错,写错一处会静默损坏。

7.6 实测

sk: proto[1] LiveSkill   1 -> 3
sk: proto[1] GekisouSkill 1 -> 3
skread[0] 演出=3(getter 3) 激奏=3

8. 运行时控制台

8.1 架构

不 hook 游戏渲染。自己开一个 EGL 上下文画在独立 SurfaceView 上,命中判定在 native 完成,菜单外的触摸返回 false 还给游戏。

命令通道双路,都不需要 ptrace:

static void command_loop(uintptr_t base) {
    for (;;) {
        char prop[92] = {0};
        __system_property_get("debug.<pkg>.cmd", prop);     // 通道 1:系统属性
        FILE* f = fopen(CMD_FILE, "r");                       // 通道 2:命令文件
        if (f) {
            char cmd[300] = {0};
            if (fgets(cmd, sizeof(cmd), f)) {
                size_t n = strlen(cmd);
                while (n && (cmd[n-1]=='\n' || cmd[n-1]=='\r' || cmd[n-1]==' ')) cmd[--n] = 0;
                fclose(f);
                unlink(CMD_FILE);      // 先删再执行,避免重复触发
                /* ... 分发 ... */
            } else fclose(f);
        }
        usleep(400 * 1000);
    }
}

开关状态用 conf 文件持久化配置 + 自动武装轮询:进程启动时读 conf,之后每个 tick 检查是否该生效,页未解密时自动重试。

8.2 坑:跨图形上下文的资源失效

现象:应用切后台再回前台,悬浮球图标变成纯黑方块。

定位:日志显示生命周期是「销毁 → 重建」:

12:46:26  overlay render loop exited      ← 切后台,EGL 销毁
12:46:26  overlay stopped
12:46:34  overlay surface 2550x1024       ← 回前台,新建上下文

但「纹理已上传」标志是静态的,仍是 true,于是新上下文里从没上传过纹理,AddImage 拿旧上下文的纹理 ID 采样。采样失败返回不透明黑,而图标是方形贴图,就呈现为黑方块。

修法:把资源生命周期和上下文绑定。

static void render_loop() {
    if (eglMakeCurrent(g_disp, g_surf, g_surf, g_ctx) == EGL_FALSE) return;
    /* 每次进入渲染循环 = 一个新的 GL 上下文。
     * 旧上下文的纹理/缓冲全部作废,必须重置"已上传"标志重新上传。 */
    g_icon_tried = false;
    g_icon_tex = 0;
    ImGui::CreateContext();
    /* ... */
}

判据:凡跟随 GL 上下文的资源(纹理、VBO、shader、ImGui 字体图集),其"已创建"标志都必须在渲染循环入口重置。

8.3 坑:非游戏线程调用托管方法

在自己的命令线程里直接调游戏方法会崩(7.3)。最终改为纯内存操作 + 交回游戏线程:命令线程只置标志或写内存,需要引擎参与的部分(如 UpdateLevel、OnChange)挂在游戏自身的高频调用路径上执行。

// 角色卡:借 get_AwakeCount 这个高频入口
static uint32_t hook_aw_mc(void* self, void* method) {
    uint32_t v = g_orig_aw_mc(self, method);
    if (g_want_cp_recalc) { g_want_cp_recalc = false; card_recalc_now(); }
    /* ... */
    return v;
}

9. 数据源原则与它带来的差异

回到开头那张表。为什么只有案例一能热更新?

手法游戏持有的状态重启后UI 即时
替换查表路径无(每次重算)保持(代码补丁)是
写运行时字段旧缓存失效(服务器重下发)否
写协议 DTO无保持(每次解码都改)否

由此得到两条工程结论:

  1. 要即时生效,就必须改查表路径,不能只改数据。凡是能在链路上找到「输入 → 查表 → 结果」结构的,都应该优先用案例一的手法。
  2. 要跨重启保持,就必须写协议层(案例四),因为服务器每次下发的数据都会在反序列化出口被改写。

而字段级写入(案例二)两头不占:既不即时、也不持久。它只适合本来就由服务端权威、且不需要 UI 立刻刷新的场景。

10. 防御视角

服务端权威是唯一真边界。 上述所有改造都只影响本地显示与本地计算。任何服务端会重算的数值,客户端改完只是「看起来变了」。这直接解释了「为什么有些改造重启后失效、有些能持久」:取决于该字段是否会被服务端重新下发覆盖。

热更新框架会扩大攻击面。 它为了动态替换逻辑,在每个方法入口保留了统一的闸门与解释器通道。理解这一个机制,就能解释「为什么大段补丁是死代码」;反过来,防守方也应该意识到这等于给每个方法加了一个可被外部调整的判定点。

代码加密的成本远低于想象。 单字节 XOR 只让我们多花了半天写判据,解密之后分析路径与明文完全一致。真正的成本来自「分析工作量」。

完整性校验比加密有效。 我们的迭代是重打包→重签名→重安装,单轮一分钟以内。如果目标校验代码段哈希,单轮成本会显著上升。

日志是最被低估的泄露面。 本次分析中自己的注入库日志极大加速了定位。同理,应用若在生产环境输出详细方法名、字段名与调用信息,对分析者是极大助力。

11. 可复用判据清单

定位阶段:

  1. 先看方法入口有没有 IsPatched 闸门 → 有则原生体可能是死代码
  2. 界面数字的读取者可能不是 getter,而是字段直读(热修解释器用 ldfld)
  3. 叶子访问器的 ldr/str 偏移是权威答案,不要靠猜
  4. 区分三层副本:主数据表 / 运行时实体 / 协议 DTO
  5. bl 之后紧跟 ldr xN,[x0,...] 且无 cbz → 该函数不能随意直调

写入阶段:

  1. 优先替换查表调用,而不是改数据(唯一能即时生效的手法)
  2. 改完字段必须清缓存,否则界面读的是旧值
  3. 只升不降,避免把玩家已获得的进度改小
  4. 用值域合理性做保护;不要用同源自证式检查(恒真)
  5. 手动调托管方法必须带真实 MethodInfo*,且只能在游戏线程调用

图形阶段:

  1. 跟随 GL 上下文的资源,其「已创建」标志必须在渲染循环入口重置

未完成项:部分功能的界面重读时机(案例二、四的即时刷新)、开关状态持久化、客户端与服务端同步行为的系统性梳理。