溺的文档

libil2cpp.so:init_proc与libtprt.so加固流程分析文档

2026-07-14 · 阅读 73

文档状态:加固主链专项稿 / 加固结论唯一归口
整理日期:2026-07-14
目标进程:com.tencent.rmcn
目标架构:Android / AArch64
分析对象:libil2cpp.solibtprt.solibunity.so 的装载期加固协作
证据来源:IDA 静态逆向、ELF 元数据、MiniMem 只读快照、Frida 冷启动时序

本文只分析 libil2cpp.so:DT_INITlibtprt.so 之间的加固业务:Unity/EGL 预安装、受保护段恢复、私有重定位、导入/Hook 安装、运行时模块注册、隐藏初始化数组和失败路径。

以下 libtprt 完整业务不在本文展开:

  • JNI_OnLoadJNINativeMethod[7] 的完整注册流程;
  • AceApplication / GP6Service / GP7Service / GP7Worker 宿主链;
  • initialize / handleLoad* / ioctl / gp6ioctl / gp7ioctl 的本地业务;
  • unwind_xx_* / tp_syscall_imp 对外接口;
  • APK/zip/ApkAssets、sidecar 和完整环境检测业务。

上述内容统一归入:

文档归口规则:

文档 负责内容
本文 加固调用协议、精确时序、槽位语义、状态变化、失败极性、动态证据
libtprt_完整业务分析.md libtprt 完整 JNI/Java/native/注册表/对外接口/APK/环境业务
旧分节文档与 traces/ 分析过程、原始采集与可追溯证据,不作为最终结论归口

原分节材料中与加固有关的结论已归并到本文;后续加固修订继续维护本文,libtprt 非加固业务则只维护专项业务文档。


1. 分析范围与结论口径

这里分析的 init_proc 不是 Unity 标准的 il2cpp_init,而是 libil2cpp.so 通过 ELF DT_INIT 注册的装载期入口。它在 Android linker 完成标准 ELF relocation 后执行,职责属于 TPRT 加固层。

本文的 libtprt 范围止于加固链直接调用的函数表、原函数表、描述符和相关运行时状态;完整 JNI 初始化与其它宿主业务仅在 libtprt_完整业务分析.md 中维护。

本文将证据分为四级:

等级 含义
S:静态直接证据 ELF、汇编、反编译、交叉引用或数据结构可以直接证明
R:运行期快照证据 已初始化进程中的实际指针、内存、映射或符号解析可以直接证明
T:动态时序证据 Frida 冷启动 trace 直接记录了进入、返回、参数或状态变化
H:高置信合并推断 静态能力和动态状态完全吻合,但尚未直接命中敏感函数入口

本文使用“已确认”时,至少有 S、R、T 中一种直接证据;使用“高置信”时,表示证据链已经足以支撑业务流程判断,但仍保留精确函数归属或内部算法边界。

1.1 地址约定

  • libil2cpp.so+0xE6F6434libtprt.so+0x39CD8 均为模块 RVA;
  • 运行期绝对地址为 effective_load_bias + RVA
  • libil2cpp.so 会同时存在整文件只读映射和有效工作映像,最低同路径映射不一定是 load bias;
  • GOT、描述符、全局量和函数地址分别属于各自模块,跨模块比较必须同时记录模块名。

1.2 核心结论

完整加固链不是单一的 init_proc,而是两个相互衔接的阶段:

  1. init_proc 之前,TPRT 先利用 libunity.so 已加载窗口完成 Unity/EGL 指针预处理和 Hook 最终化;
  2. init_proc 内部再处理 libil2cpp.so 的受保护段、私有重定位、导入支撑表、运行时上下文和隐藏初始化数组。

最终稳定主链为:

libtprt 自身构造与运行时准备
  -> Unity/EGL 预安装路径(init_proc 前)
  -> libil2cpp DT_INIT
     -> slot 1
     -> slot 29
     -> slot 30
     -> slot 31
     -> slot 50
     -> slot 32
  -> initializedBase 置位

其中 slot 51 是由魔数控制的可选补丁路径,不是本样本稳定 trace 中的固定槽位。


2. 样本与 ELF 基线

2.1 文件摘要

文件 大小 MD5 SHA-1 SHA-256
libil2cpp.so 239,702,416 DE3B4816E05AC42B217DC05AFB588B27 EDAEBF044EAF9BCF9D2C1998B1BB9D1B8A54B551 147A1687B527E9CF53CF629CF199046D77171829776632D607EE668AF209C7FF
libtprt.so 1,692,296 4F93CAB8BC06FDE9E39F2E21D1EE131B 0FD90F4DD7A10BA6BEF9D00E44CD24E78C448A7F 0E7DF19BCD1ED6F1AE5EC38AF22FBA630C581F3B537DD9BF416A457CF16E9883

2.2 libil2cpp.so

项目
ELF 类型 ET_DYN
架构 AArch64
e_entry 0x0
SONAME libil2cpp.so
加固依赖 DT_NEEDED: libtprt.so
装载期入口 DT_INIT = 0xE6F6434
标准初始化数组 DT_INIT_ARRAY
绑定策略 BIND_NOWFLAGS_1: NOW
GNU Build ID e165cd211c5b6551

e_entry = 0 不影响共享库初始化。Android linker 会根据动态表调用 DT_INIT。本样本没有标准 DT_INIT_ARRAY,32 项隐藏初始化函数只由 TPRT 显式调度。

2.3 libtprt.so

符号/项目 RVA/值 作用
g_tprt_pfn_array 0x1A3210 53 项函数指针表,共 424 字节
g_tprt_ori_array 0x1A4B48 20 项原函数/回调/运行时状态表,共 160 字节
DT_INIT_ARRAY 0x165328 4 项标准构造器
GNU Build ID 22679299bec8ba3baf066d837c30ff0afce44b29 样本标识

TPRT 的 4 个 .init_array 入口已经由动态 trace 直接命中:

libtprt.so+0x269E0
libtprt.so+0x3BED8
libtprt.so+0xE3FD8
libtprt.so+0x136A78

它们属于真实构造链,但命中时 tprtSavedHooks[0/1] 仍为零,因此不是 Unity/EGL 最终 Hook 的直接落地点。


3. 运行期映射与有效基址

MiniMem 在 2026-07-10 的只读快照中发现 libil2cpp.so 有两类同路径映射:

  • 最低映射 0x6DF1B66000 是整文件只读映射;
  • 有效工作映像 load bias 是 0x6F10091000

有效工作映像与 Program Header 对齐:

PT_LOAD RVA 运行期映射 权限 内容
0x0 0x6F10091000 r--p ELF Header/首段
0x51A000 0x6F15231000 r-xp 主代码段
0xE6F4000 0x6F1E785000 r-xp .init_proc 与保护主函数
0xE6F8000 0x6F1E789000 rw-p 加固数据段
0xE6FC000 0x6F1E78D000 rw-p 加固数据段
0xE704000 0x6F1E795000 rw-p BBMG... 描述符和隐藏数组

.init_proc 使用的自定位算法也独立得到相同基址:

runtime_address("111TIUYUPOIder0") - linked_address(0xE6FFFD8)
    = effective_load_bias

因此任何 Frida、MiniMem 或离线 dump 脚本都不能直接把“最低模块映射”当作 libil2cpp.so 工作基址。TPRT 本身没有观察到相同歧义,可按正常模块基址换算。


4. TPRT 加固调用协议

本章只保留 libil2cpp.init_proc 和 Unity/EGL 预安装链直接依赖的 TPRT 协议面。JNI_OnLoad、宿主 native、外部查询/控制接口与 APK 业务不参与本轮槽位调用,统一见 libtprt_完整业务分析.md

4.1 函数表

运行期完整读取 g_tprt_pfn_array 得到 53 个有效指针。当前加固链使用的槽位如下:

槽位 表偏移 TPRT RVA 静态函数 统一语义
1 0x08 0x32800 sub_32800 处理受保护段
29 0xE8 0x323E8 sub_323E8 私有重定位
30 0xF0 0x342C0 sub_342C0 导入描述符处理/通用安装链入口
31 0xF8 0x38B74 sub_38B74 保存原指针并安装 thunk 的 Hook 族
32 0x100 0x39CD8 sub_39CD8 隐藏初始化数组调度
50 0x190 0x1361BC sub_1361BC 运行时命令解析与模块基址注册
51 0x198 0x337E8 sub_337E8 可选内嵌补丁应用

MiniMem 快照中,上述 7 项运行时地址减去 TPRT 基址后全部与表中 RVA 精确相等。

4.2 原函数/回调表

g_tprt_ori_array 静态初始值全零。初始化完成后,20 项中稳定出现 11 个非零项:

4, 6, 7, 8, 9, 10, 14, 15, 16, 17, 19

动态时序进一步确认:

  • 16/17init_proc 前已经指向 libunity.so
  • slot 29 新增索引 19
  • slot 30 新增索引 4/6/7/8/9/10/14/15

所以 g_tprt_ori_array 不是单一槽位一次性填完,而是跨“Unity 预处理、私有重定位、导入安装”三个阶段逐步建立。

最终 11 项的模块归属为:

索引 最终归属
4 libil2cpp.so+0xE6F68BC
6 libil2cpp.so+0xE6FFEC8
7 libil2cpp.so+0xE6F6938
8 libil2cpp.so+0xE6F69B4
9 libil2cpp.so+0xE6F6A30
10 libil2cpp.so+0xE6F6AAC
14 libil2cpp.so+0xE6F6BF4
15 libil2cpp.so+0xE6F6C68
16 libunity.so+0x1B62E84
17 libunity.so+0x1B62EF8
19 libil2cpp.so+0x5B8F198

4.3 描述符族

描述符 libil2cpp RVA 作用
BBMG311DIS12IMP 0xE704000 重点导入项和符号/重定位信息
BBMG311OOKbaIMP 0xE7040E4 Hook 顶层描述块
BBMG311OOKbaHSI 0xE704138 5 个可重映射支撑指针单元
BBMG311ENC12IAI 0xE704268 32 项隐藏初始化数组

这些结构带 BBMG... 开始魔数和 EEMG... 结束魔数。运行期快照显示头部、数量和目标 RVA 与文件静态值一致,状态并不统一回写到描述符主体;真实状态分散在 TPRT 全局区、目标指针单元和私有记录中。

4.4 文档边界

libtprt.so 还有 JNI_OnLoadunwind_xx_*tp_syscall_imp 等导出,但当前 IDA 调用图、ELF 导入关系和动态时序都没有把它们放进 libil2cpp.init_proc 主链。本文只记录这一边界,不展开这些接口的内部业务。


5. init_proc 前的 Unity/EGL 预安装链

这是后续动态分析对早期静态结论最重要的修正。

5.1 初始状态

在早期 constructor 状态快照中:

tprtSavedHooks[0] = 0
tprtSavedHooks[1] = 0
unity+0x1A35328  = libEGL!eglGetCurrentSurface
unity+0x1A35330  = libEGL!eglGetCurrentContext

说明 libunity.so 已完成标准 linker 绑定,但 TPRT 最终 Hook 尚未安装。

5.2 sub_358A0 的过渡改写

Frida 稳定捕获到 libtprt.so+0x358A0init_proc 前执行:

arg0 = libunity.so base
return address = libtprt.so+0x34B3C

调用返回时的直接变化为:

单元 调用前 调用后
Unity slot 0 libEGL+0x22DA0 libEGL+0x22D80
Unity slot 1 libEGL+0x22D80 libc+0xAD490 (fprintf)
qword_1A4A08 0 0
qword_1A4A10 0 0

因此 0x358A0 确实是通用槽位安装 helper,但它只完成了这次 Unity 路径中的一部分。

静态唯一调用链为:

sub_342C0 -> sub_347C0 -> sub_358A0

这证明 slot30-family 逻辑并不只在 init_proc 内使用,它在之前已经以 libunity 为模块基址执行过一轮。

一次硬件写监视实验还定位到 0x358A0 内的核心 store:

libtprt+0x35D68  ADD X4, X3, X0
libtprt+0x35D6C  LDR X3, [X4]
libtprt+0x35D70  LDR X5, [X8]
libtprt+0x35D78  STR X3, [X5]

这里的 X0 与模块基址参数吻合,函数按照描述信息解析源指针,再将其写入目标单元。

5.3 modules-ready 前的最终化

在 trace tprt-init-20260713-154948.jsonlmodules-ready initialState 中,init-proc-enter 尚未发生,但状态已经变为:

qword_1A4A08 = libEGL!eglGetCurrentSurface
qword_1A4A10 = libEGL!eglGetCurrentContext
unity+0x1A35328 = libtprt.so+0x3827C
unity+0x1A35330 = libc.so+0xAD490

TPRT 两个转发 thunk 的静态关系为:

sub_38110 -> qword_1A4A08
sub_3827C -> qword_1A4A10

sub_38B74 静态反编译又明确包含以下模式:

qword_1A4A08 = *target;
*target = sub_38110;

qword_1A4A10 = *target;
*target = sub_3827C;

由此得到的当前结论是:

  • savedHooks 填充和 Unity thunk 最终切换均在 init_proc 前完成,这是 T 级直接证据;
  • 收尾动作最符合 slot31-family/sub_38B74 的静态行为,这是 H 级高置信归属;
  • 后续 init_proc 内的 slot 31 调用没有产生首次安装变化,只表现为复用、校验或幂等执行。

需要保留一个地址语义边界:Unity 原始 relocation 名称、TPRT 保存槽顺序和最终 thunk 目标并不是简单一一对应。特别是 Unity +0x1A35328 最终指向 sub_3827C,而该 thunk 静态转发到 qword_1A4A10;在没有写入前后完整调用语义的情况下,不能只按原始 eglGetCurrentSurface/Context 名称强制配对。

5.4 constructor 次数不作为业务语义

较早 trace 把最终化描述为“第 3 次 call_constructors(libtprt)”,较新的早期 trace 中相同语义阶段对应计数 2。差异的精确原因尚未完全还原,至少说明该计数会受采集起点和可见调用窗口影响,不能作为稳定协议字段。

统一文档只保留以下稳定状态序列:

原始 EGL 绑定
  -> 0x358A0(libunity) 过渡改写
  -> modules-ready 前 savedHooks + thunk 最终态
  -> init_proc 内复用/幂等调用

5.5 入口探针敏感性

直接在 libtprt+0x342C0libtprt+0x38B74 安装早期 Frida inline hook 时,多次出现目标在真正命中前 detach/早退。该现象可以确认这些入口不适合作为常开探针,但当前证据不能单独区分:

  • TPRT 主动完整性检测;
  • 极早装载窗口的时序扰动;
  • Frida trampoline 与目标代码布局冲突。

因此后续应优先采用 0x358A0 间接命中、轻量状态快照或单次写点观测。


6. libil2cpp.so:.init_proc

6.1 入口属性

项目
RVA 0xE6F6434
大小 0x38(56 字节)
来源 DT_INIT
主调用 sub_E6F6094
末端动作 尾调用 g_tprt_pfn_array[32]

6.2 汇编

0xE6F6434  STP  X29, X30, [SP,#-0x10]!
0xE6F6438  MOV  X29, SP
0xE6F643C  BL   sub_E6F6094
0xE6F6440  NOP
0xE6F6444  ADR  X8, a111tiuyupoider
0xE6F6448  ADRP X10, #off_E6FB5E0@PAGE
0xE6F644C  LDR  W9, [X8,#0x10]
0xE6F6450  LDR  X10, [X10,#off_E6FB5E0@PAGEOFF]
0xE6F6454  LDR  X2, [X10,#0x100]
0xE6F6458  SUB  X0, X8, X9
0xE6F645C  NOP
0xE6F6460  ADR  X1, aBbmg311enc12ia
0xE6F6464  LDP  X29, X30, [SP],#0x10
0xE6F6468  BR   X2

BR X2 不改写 LR。槽位 32 返回时会沿原 LR 回到 Android linker,因此这是可返回的尾调用,而不是控制流丢失。

6.3 等价逻辑

void libil2cpp_tprt_dt_init(void)
{
    tprt_libil2cpp_init_once();

    uintptr_t base = runtime_marker - linked_marker;

    // g_tprt_pfn_array[32],尾调用
    tprt_call_hidden_init_array(base, BBMG311ENC12IAI);
}

7. 一次性保护主函数 sub_E6F6094

7.1 一次性状态

项目
RVA 0xE6F6094
大小 0x304
状态字节 byte_E700478

运行期快照中该字节为 1。它在主流程较早位置写入,但没有观察到原子或互斥操作,所以准确语义是“已经开始,阻止重入”,不等同于线程安全的 pthread_once

弱符号 xxxxXXXXxxxx 的导入槽在已验证进程中为 NULL,因此可选前置桥没有执行。它是兼容分支,不是当前必经路径。

7.2 ELF 权限准备与引擎识别

sub_E6F7078 会解析 ELF Header 和 Program Header:

  • 读取 e_phoffe_phnum 并遍历 PT_LOAD
  • 根据 p_vaddr 推导 load bias;
  • 将 ELF p_flags 转换为 mprotect 权限;
  • 为后续原地恢复、重定位和 Hook 临时准备目标页;
  • PT_GNU_RELRO 恢复只读保护。

保护代码还内置五类引擎模块匹配:

类型 运行时/引擎 目标库
1 Mono libmono.solibmonobdwgc-2.0.so
2 IL2CPP libil2cpp.so
3 Unreal libUE4.solibUnreal.so
4 Unity Native libunity.so
5 Cocos Lua libcocos2dlua.so

本样本在 libil2cpp.so 主链中命中类型 2;早期 libunity 描述符处理则体现了同一 TPRT 框架的跨模块复用能力。

7.3 稳定动态顺序

完整成功 trace 直接记录到:

槽位 返回值 主要状态变化
1 0x1 受保护段处理;快照面无新增表项
29 0x1 g_tprt_ori_array[19] 置位
30 0x0 填充 8 项 ori;重写 5 项 HSI;复用 0x358A0
31 0x0 已跟踪 Unity/TPRT 状态无变化
50 非稳定寄存器快照 解析 ui:/mb: 命令并写入运行时注册表
32 非语义化寄存器值 调度隐藏初始化数组

因此稳定槽位序列为:

1 -> 29 -> 30 -> 31 -> 50 -> 32

slot 1/29 的成功极性为非零,slot 30/31 的成功极性为零。任何已知关键校验失败都可能进入 exit_group(25)


8. 槽位 1:受保护段恢复

slot 1 = libtprt.so+0x32800,调用参数包含段描述表、模块基址、描述符数量、模块名和摘要样文本:

tprt_process_protected_sections(
    section_descriptors,
    module_base,
    descriptor_count,
    "libil2cpp.so",
    "iIyggaioqKmoqKipW7ztqVu87ag=");

静态确认的动作:

  1. 遍历步长约 48 字节的段描述符;
  2. 计算页对齐地址、长度和原权限;
  3. 临时增加写权限;
  4. 通过 sub_12021C 处理大段内存;
  5. 调用 sub_15E618 刷新 ARM64 数据/指令缓存;
  6. 恢复页面权限;
  7. 对大范围按 0x10000 步长执行 madvise(addr, 0xC000, MADV_DONTNEED)
  8. 记录模块、段和处理状态。

内部模式函数以 0x800 字节块处理前置区域,再进入主体处理。调用方向、页面写权限、缓存刷新和后续可执行性共同支持“解密或恢复受保护内容”的高置信语义,但具体密码算法和密钥派生尚未完全还原。

TPRT 内部状态格式包含:

SOBASE_%s
module_base=%llu|section_enctype=%u|vaddr=%u|memsz=%u|md5_crc32=%u|begin=%u|end=%u
pf_%s
func_state

字段名只能证明产品内部记录这些属性,不能单独证明当前分支一定执行 MD5 或 CRC32。


9. 槽位 29:TPRT 私有重定位

slot 29 = libtprt.so+0x323E8。Android linker 的标准 relocation 在 DT_INIT 前已经完成;此处处理的是 TPRT 额外维护的编码重定位,两者不能混淆。

load_bias = runtime_base - linked_base;
page = align_down(runtime_base + descriptor->target_vaddr);
length = align_up(descriptor->target_size);

mprotect(page, length, old_prot | PROT_WRITE);
tprt_decode_relocation_records(...); // sub_120278
flush_arm64_code_cache(...);          // sub_15E618
mprotect(page, length, old_prot);

sub_120278 遍历 24 字节记录,包含位置、编码后的 64 位值和控制字段,并使用 NEON 位运算重排。动态 trace 显示该阶段至少使 g_tprt_ori_array[19] 从零变为一个 libil2cpp.so 工作映像地址。

当前仍未逐条还原哪些记录对应函数、数据或隐藏符号。


10. 槽位 30:导入完整性与通用安装链

10.1 DIS12IMP

描述符头部字段:

偏移 解释
+0x10 0x130 描述大小或子表偏移
+0x14 0xE704470 动态符号相关表
+0x18 0x350 重定位/辅助表偏移
+0x1C 0x13E 记录数量或索引范围
+0x20 5 重点导入数量

五个重点 GOT:

GOT RVA 原始符号
0xDDAFF78 connect
0xDDAFFB8 recvfrom
0xDDAFFC0 send
0xDDAFFD8 sendto
0xDDB0430 dlopen

最终运行期快照中五项仍精确指向各自系统库导出,未指向 TPRT 或未知跳板。结合调用方“返回非零即退出”,槽位 30 至少承担导入解析、合法性检查和安装准备;“完整性/反 Hook”是高置信目的,精确允许规则尚未还原。

10.2 0x358A0 的复用

init_procslot 30 内,0x358A0 再次被命中:

arg0 = libil2cpp.so base
tracked Unity/EGL changes = none

这与它在 init_proc 前以 libunity 为基址执行形成对照,证明它是按“模块基址 + 描述信息”工作的通用 helper,而不是 EGL 专用 Hook 函数。

10.3 g_tprt_ori_array 与 HSI

slot 30 返回时新增 ori 索引:

4, 6, 7, 8, 9, 10, 14, 15

同时 5 个 OOKbaHSI 单元全部重写。原始 ELF 身份与最终函数为:

单元 RVA 原始 JUMP_SLOT 初始化后函数
0xDB92720 dup mmap/mmap64
0xDB92728 mmap munmap
0xDB92730 madvise fread
0xDB92738 basename fwrite
0xDB92740 usleep fclose

因此 HSI 是一组被 TPRT 消费和重映射的支撑函数槽,不能按原始重定位符号直接解释为最终 Hook 目标。


11. 槽位 31:Hook thunk 安装族

slot 31 = libtprt.so+0x38B74。静态代码明确具有以下能力:

  1. 解析最多 8 组目标偏移;
  2. 将目标页临时设为可写;
  3. 保存原指针到 qword_1A4A08 ... qword_1A4A40
  4. 写入 sub_38110sub_3827C 等 TPRT 包装器;
  5. 恢复页面权限。

但必须区分两件事:

  • sub_38B74 具备安装 Hook 的静态能力”:S 级确认;
  • init_proc 内这一次 slot 31 首次安装 Unity/EGL Hook”:已被 T 级证据否定。

modules-ready 前,两个保存槽和 Unity 最终状态已经到位;init_procslot 31 返回前后没有可见变化。最合理解释是相关安装族早期已经运行,当前调用用于幂等确认、其它目标处理或私有状态注册。

尚未直接证明的是:早期最终化入口能否在不触发目标早退的情况下精确命中 sub_38B74


12. 槽位 50:运行时命令解析与模块基址注册

12.1 ui:/mb: 分发路径

slot 50 = libtprt.so+0x1361BC 可解析:

ui:is_tplk2=1
mb:libil2cpp.so:<load_base>

mb: 路径会构造 SOBASE_%s 键,用于注册模块名和基址,供地址换算、补丁定位和多模块协同使用。

静态反编译和尾部汇编表明 sub_1361BC 是副作用型运行时命令分发器:

  • ui: 路径调用 sub_12CDD8 -> sub_12D5F4,保存原始命令字符串;
  • mb: 路径对命令做 strtok_r(':') 分段,提取模块名与基址;
  • 随后构造 SOBASE_%s 键,并通过 sub_C1638 -> sub_D17F0 写入运行时注册表。

动态 trace 在槽位离开点看到的 x0 只是 post-exit 寄存器快照。函数尾部会在清理临时 token 容器前重写 x0,因此不能把它定义为稳定的上下文句柄返回值。

12.2 加固链使用的注册表语义

sub_C1638 通过 pthread_once 建立 0xD8 字节全局单例;sub_D17F0(reg, key, value) 加锁后在 reg + 0x90 一带的树/映射中更新或插入字符串键值。

对加固主链而言,可确认的必要结论是:

  1. slot 50SOBASE_<module> 写入共享注册表,而不是写一个临时局部变量;
  2. 模块基址注册是副作用,槽位离开时的 x0 不构成稳定 ABI;
  3. 注册表的其它消费者、缓存字段和对外查询/控制接口属于 libtprt 完整业务,见独立业务文档。

13. 槽位 51:可选补丁恢复

只有描述符满足以下魔数时才调用:

if (*(uint32_t *)(descriptor + 16) == 0xE2031FAA)
    g_tprt_pfn_array[51](descriptor, module_base);

sub_337E8 会临时增加目标页写权限,执行 memcpy 和一次 16 位写入,刷新指令缓存后恢复权限。这是明确的运行期补丁应用器,可用于恢复被抹除指令、修正跳板或写回短片段。

稳定 Frida 主链未命中该槽位,因此它是条件路径,不能列为每次启动必经阶段。


14. 槽位 32:隐藏初始化数组

14.1 描述符与数组

BBMG311ENC12IAI

字段
数组 RVA 0xE7149D0
数量 0x20(32)
数组范围 [0xE7149D0, 0xE714AD0)

32 项均带 R_AARCH64_RELATIVE,目标 RVA 为:

0x5C792BC, 0x5C79600, 0x5C2D480, 0x5839068,
0x585FACC, 0x5B2F0F4, 0x5B32CDC, 0x5B41D84,
0x5B44B80, 0x5B5D6FC, 0x5B704D0, 0x5B74BA4,
0x5B75558, 0x5B7AF44, 0x5B84B08, 0x5B86D04,
0x5B91948, 0x5B95970, 0x5B97844, 0x5BB2AC4,
0x5BB3A28, 0x5BC8B58, 0x5BCA15C, 0x5BD148C,
0x5BD360C, 0x5BD568C, 0x5BDBD30, 0x5BDFF28,
0x5BE7E90, 0x5BF78D4, 0x5C1194C, 0x5C12018

MiniMem 最终快照对 32 个绝对指针逐项减去有效 load bias,得到 32/32 精确匹配,且全部落在 libil2cpp.so 主可执行工作映像。

14.2 调度逻辑

sub_39CD8 可还原为:

void tprt_call_hidden_init_array(uintptr_t base, const Desc *d)
{
    void (**array)(void) = (void *)(base + d->array_offset);

    for (uint32_t i = 0; i < d->count; ++i) {
        void (*fn)(void) = array[i];
        if (fn != NULL && fn != (void *)-1)
            fn();
    }
}

14.3 动态负载

完整 trace 中:

  • slot 32 持续约 489–510 ms,明显长于其它槽位;
  • 记录到 180 次 mprotect,其中 90 次切到可写、90 次恢复;
  • 隐藏函数探针命中索引 3..31,共 29 项;
  • 索引 0/1/2 未命中探针。

“未命中”不自动等于数组项为空:最终快照证明 32 项全部有效。进一步地,sub_39CD8 汇编已确认循环从 i=0 顺序迭代到 count-1,不存在内建“从 3 开始”或“主动跳过前三项”的逻辑。

因此当时更合理的解释变为:

  • 索引 0/1/2 的 Frida inline hook 没有真正生效;
  • 或 hook 安装后目标首指令又被后续写链覆盖。

14.4 低负担 focused probe:补齐索引 0/1/2

为避免全量主 trace 的早期副作用,本轮新增最小化脚本 tprt-hidden-focus.js,只保留:

  • linker call_constructors 命中;
  • libil2cpp.init_proc 命中;
  • slot 32 命中;
  • 隐藏数组索引 0/1/2 的 hook 安装、入参、返回值和最终字节快照;
  • slot 32 期间针对这些页的 mprotect 触达诊断。

Trace tprt-init-20260713-170726.jsonl 直接确认:

  • 索引 0 命中一次:358 ms,返回 0x0
  • 索引 1 命中一次:367 ms,返回 0x40000000efffffff
  • 索引 2 命中一次:386–387 ms,返回 0x0
  • 三项都成功安装 inline hook,installError = null,最终都保留 Frida stub;
  • slot 32 期间没有任何 mprotect 范围覆盖这三项所在页。

这意味着:

  1. 业务执行层面,索引 0/1/2 并未被当前样本跳过;
  2. “29/32” 是全量主 trace 的观测盲区,不是隐藏初始化真实执行数;
  3. 至少在 focused probe 中,不存在“hook 装上后又被页权限恢复链覆盖”的现象。

进一步地,focused probe 还暴露出静动态字节差异:

  • 索引 20x5C2D480)运行时首字节与静态文件一致;
  • 索引 0/10x5C792BC / 0x5C79600)静态文件字节不是可直接反汇编的函数体,但在运行到 slot 32 入口前已变成合法 AArch64 代码。

这说明前三项中的至少前两项依赖装载期恢复/重建链,但仅凭 170726 / 171837 两条 trace,还不能把恢复边界压缩到单一槽位。

14.5 恢复发生边界:位于 call_constructors-enter(libil2cpp) 之后、slot 32 之前

tprt-init-20260713-172747.jsonl 中,tprt-hidden-focus.js 进一步在 call_constructors-enter(libil2cpp) 时加入了 focus-target-snapshot。把这组字节与 slot32-enter 时 focused hook 安装前的 beforeBytes 对比,可得到:

索引 call_constructors-enter(libil2cpp) slot32-enterbeforeBytes 结论
0 不等于恢复态 等于恢复态 恢复发生在两者之间
1 不等于恢复态 等于恢复态 恢复发生在两者之间
2 已等于恢复态 已等于恢复态 该项无需同类恢复

更具体地说:

  • 索引 0focus-target-snapshot.bytes 前 16 字节为 2b3717c1...,而 beforeBytes 前 16 字节为 3f2303d5...
  • 索引 1focus-target-snapshot.bytes 前 16 字节为 4b3017c1...,而 beforeBytes 前 16 字节为 5f2403d5...
  • 索引 2call_constructors-enter(libil2cpp)slot32-enter 始终保持 3f2303d5fd7bbea9... 开头,与静态函数体一致;
  • 三项都满足 beforeBytes == afterAttachBytes,说明 focused hook 安装前目标代码已经稳定,Frida 本身没有先把它们改写成另一种“恢复态”。

因此目前最强的动态边界是:

  1. 索引 0/1 在进入 libil2cpp.so constructor 链时,仍不是最终恢复后的函数体;
  2. slot 32 入口时,它们已经变成可直接反汇编的合法 AArch64 代码;
  3. 也就是说,恢复动作发生在 init_proc 内、且早于 slot 32,候选范围被压缩为 slot 1 / 51 / 29 / 30 / 31 / 50
  4. 结合静态职责划分,slot 1 仍是最强候选,但这一点还没有达到“单点命中证明”。

14.6 索引 0/1/2 的运行时业务语义

tprt-init-20260713-171837.jsonl 中,把 focused probe 的字节抓取长度提升到 128 后,可以直接反汇编出前三项恢复后的函数体前半段。

索引 0libil2cpp+0x5C792BC)表现为一段“属性读取 + 布尔标志回写”逻辑:

  • 读取属性键 ro.arch
  • 对结果做 10 字节比较;
  • 将比较结果写入 libil2cpp+0xE6F1A60
  • 如果前置辅助调用失败,则把该标志清零后直接返回。

索引 1libil2cpp+0x5C79600)与索引 0 构成配对门槛:

  • 先检查 libil2cpp+0xE6F1A68,若非零则直接返回;
  • 否则同样读取 ro.arch
  • 对结果做 10 字节比较;
  • 在一条回退路径上,再以常量 0x100x1A 调用另一组 remapped thunk,并把结果转交给 libil2cpp+0x5C79354
  • 本样本实测返回值为 0x40000000efffffff

索引 2libil2cpp+0x5C2D480)则保持为稳定静态函数:

sub_D4D37F0(&unk_E6F0640);
__cxa_finalize(sub_5C2C764);

它更像一个 finalizer / 清理 shim,而不是纯粹的业务初始化器。

这里还有一个重要修正:恢复后 0/1 的调用签名证明,静态数据库里的导入名在这些路径上已经不再可信。

  • 静态名为 .getauxval 的 thunk,在运行时被当作 char * 风格接口调用;
  • 静态名为 .strcmp 的 thunk,在运行时被以三参数、长度 0xA 的方式调用;
  • 静态名为 .closelog 的 thunk,在运行时被以常量 0x10 / 0x1A 查询式调用。

这与前文 slot 30 / HSI 重映射的结论一致:对隐藏初始化前置路径,运行时语义必须优先依据调用签名和副作用判断,不能机械沿用 ELF 原始导入名。

initializedBase 在此前始终为零,只有 slot 32 完成后的快照才变为当前 libil2cpp.so 工作基址。因此隐藏初始化数组是加固层放行真实业务初始化的最终门槛。


15. 完整加固流程

15.1 时序图

Android linker
  |
  +-- load libtprt.so
  |    |
  |    +-- 4 个标准 .init_array constructor
  |    +-- 建立 TPRT 基础运行时状态
  |
  +-- libunity.so 标准 EGL 绑定可见
  |    |
  |    +-- slot30-family 预安装路径
  |    |    sub_342C0 -> sub_347C0 -> sub_358A0(libunity)
  |    |    -> Unity 两槽进入过渡态
  |    |
  |    +-- 早期 Hook 收尾(高置信 slot31-family)
  |         -> 保存两个原始 EGL 指针
  |         -> Unity slot0 切到 libtprt+0x3827C
  |         -> 在 modules-ready 前完成
  |
  +-- call libil2cpp.so DT_INIT (0xE6F6434)
       |
       +-- sub_E6F6094
       |    |
       |    +-- 计算有效 load bias、恢复/准备 ELF 页权限
       |    +-- slot 1  受保护段恢复
       |    +-- slot 51 条件补丁(本次未命中)
       |    +-- slot 29 私有重定位,ori[19]
       |    +-- slot 30 导入检查/安装
       |    |    -> 0x358A0(libil2cpp) 复用
       |    |    -> ori[4,6,7,8,9,10,14,15]
       |    |    -> 5 项 HSI 重映射
       |    +-- slot 31 Hook 族复用/幂等
       |    +-- slot 50 解析 runtime command 并注册 module-base
       |    +-- 到 slot 32 入口前,hidden[0/1] 已恢复为合法函数体(具体落点待定)
       |
       +-- 尾调用 slot 32
            -> 调度 32 项隐藏初始化数组
            -> 大量页面权限切换与业务初始化
            -> initializedBase = libil2cpp 工作基址
            -> 返回 Android linker

15.2 统一伪代码

void tprt_pre_libil2cpp_phase(void)
{
    run_tprt_init_array();

    if (libunity_is_ready()) {
        process_import_descriptors(unity_base);       // 0x342C0 family
        resolve_and_write_slots(unity_base);          // 0x358A0
        finalize_saved_hooks_and_thunks(unity_base);  // 高置信 0x38B74 family
    }
}

void libil2cpp_dt_init(void)
{
    if (!g_protection_started) {
        g_protection_started = true;

        uintptr_t base = calculate_effective_load_bias();
        prepare_elf_mappings(base);

        if (!process_protected_sections(base))
            exit_group(25);

        if (embedded_patch.magic == 0xE2031FAA)
            apply_embedded_patch(base);

        if (!apply_private_relocations(base))
            exit_group(25);

        if (process_import_descriptor(base, BBMG311DIS12IMP) != 0)
            exit_group(25);

        if (process_hook_descriptor(base, BBMG311OOKbaIMP) != 0)
            exit_group(25);

        parse_and_register_runtime_command(
            format_module_base_command(base));        // "mb:libil2cpp.so:0x..."
    }

    // BR 尾调用
    call_hidden_init_array(base, BBMG311ENC12IAI);
}

16. 失败路径与保护能力

16.1 返回值极性

阶段 成功表现 失败动作
slot 1 返回最低位非零 exit_group(25)
slot 29 返回最低位非零 exit_group(25)
slot 30 返回 0 非零时 exit_group(25)
slot 31 返回 0 非零时 exit_group(25)
slot 51 条件调用,不检查普通返回值 继续
slot 32 尾调用,逐项忽略普通返回值 返回 linker

exit_group(25) 终止整个进程。其上游原因可能是完整性异常,也可能是描述符解析、权限修改、私有重定位或安装失败,不能把所有退出统一命名为“反篡改失败”。

16.2 环境检测边界

TPRT 确实具备模块枚举、异常映射识别、反调试和模拟器/兼容层识别能力,但当前证据尚未证明这些完整环境检测业务在 init_proc 同步路径中逐项执行或直接导致本节的 exit_group(25)

因此本文只保留这一可达性边界,不把库级能力并入已确认的加固时序。检测项、已解密路径和其它宿主交互能力统一见 libtprt_完整业务分析.md


17. Frida 采集方案与稳定性边界

17.1 稳定附加流程

当前可靠方案是 root-stop-monitor

  1. am force-stop com.tencent.rmcn
  2. root 侧监控 PID,出现后立即 SIGSTOP
  3. Frida attach 并安装 linker call_constructors 探针;
  4. resume 目标;
  5. 等模块出现后安装 .init_proc、槽位和状态快照探针。

spawn-gating 在当前设备/启动方式下暴露不稳定,不作为默认方案。

17.2 当前脚本

文件 作用
frida-il2cpp-bridge/example/tprt-init-trace.js 主时序、槽位、状态、隐藏数组、0x358A0 观测和隐藏索引 0/1/2 诊断
frida-il2cpp-bridge/example/tprt-hidden-focus.js 低负担 slot32 focused probe,只验证隐藏索引 0/1/2
frida-il2cpp-bridge/example/trace-tprt-init.py 冷启动、附加、trace 落盘;支持 frida-spawn 备用启动模式
frida-il2cpp-bridge/example/summarize-tprt-trace.py JSONL 汇总、slot 50 命令串解码、未完成 trace 容错和状态差分

JNI、字符串与 APK 宿主分析脚本列在 libtprt_完整业务分析.md,不再混入本节。

稳定配置应保持:

traceHookInstaller = true
traceEarlyBusinessHooks = false

.init_array 细粒度 trace、硬件写监视点和 0x342C0/0x38B74 早期入口 hook 都会显著增加 detach/早退概率,只应在单目标实验中临时开启。

17.3 关键 trace

Trace 主要价值
20260713-031454 完整槽位时序、29/32 隐藏命中、180 次 mprotect
20260713-150240 constructor 状态演化、slot 50 命令串与离开点 x0 快照
20260713-152613 两次 0x358A0libunitylibil2cpp 复用
20260713-154742 精简的早期 Unity 0x358A0 命中
20260713-154948 modules-ready 前最终 Hook 状态闭环
20260713-170726 低负担 probe,补齐隐藏索引 0/1/2 的直接命中
20260713-171837 128 字节 focused probe,补齐隐藏索引 0/1/2 的运行时语义
20260713-172747 call_constructors-enter(libil2cpp) 快照 vs slot32-enter 字节,对 hidden 0/1 的恢复边界压缩到 init_proc 的 pre-slot32 窗口

未完成 trace 仍可用于其停止点之前的直接事件,但不能用“缺少后续事件”证明后续逻辑不存在。

17.4 分析注意事项

  • 两个样本存在明显控制流平坦化,Hex-Rays 状态变量不能逐字解释;
  • BBMG... 魔数后是二进制结构,不是普通 C 字符串;
  • libil2cpp.so 尾部存在文件偏移与虚拟地址非平凡映射,换算必须依据 Program Header;
  • 函数表间接调用容易被 IDA 误判参数数量,应以 AArch64 调用约定和调用点寄存器为准;
  • TPRT 字符串按需解密,普通字符串搜索不能覆盖全部检测项;
  • ASLR 绝对地址只对单次进程有效,跨 trace 对比应使用模块 RVA 和符号归属;
  • Frida 探针会改变极早装载窗口的时序,只有重复稳定且状态一致的事件才应提升为时序结论。

18. 加固证据矩阵

结论 等级 主要证据
.init_proclibil2cpp.soDT_INIT 入口 S/R ELF 动态表、运行期代码
TPRT 函数表含 53 项,7 个加固相关槽位地址正确 S/R 导出大小、运行期逐项读取
稳定槽位顺序为 1→29→30→31→50→32 T 完整冷启动 trace
slot 51 是可选路径 S/T 魔数分支、稳定 trace 未命中
slot 29 写入 ori[19] T 槽位前后状态差分
slot 30 填 8 项 ori 并改 5 项 HSI T 槽位前后状态差分
0x358A0libunitylibil2cpp 两种基址复用 S/T 参数与两次动态命中
Unity/EGL 最终 Hook 在 init_proc 前完成 T modules-ready initialState
早期最终化最可能属于 sub_38B74 family H 静态 store 模式与最终状态吻合
init_proc 内 slot 31 不是首次 Unity Hook T 调用前已是最终态,调用后无变化
slot 50 解析 ui:/mb: 并写入 SOBASE_<module> 注册表项 S/T 命令入参、静态分发、sub_C1638 -> sub_D17F0
slot 50 离开时的 x0 只是寄存器快照 S/T 函数尾部汇编、动态快照
隐藏数组有 32 个有效函数指针 S/R 描述符、relocation、32/32 运行期匹配
隐藏数组循环静态上从索引 0 开始 S sub_39CD8 汇编
组合 trace 已覆盖 32/32 隐藏初始化项 T 主 trace 与 focused probe 合并
全量主 trace 漏掉 0/1/2 属于观测盲区 T/H focused probe 命中且目标页未被 mprotect 覆盖
hidden 0/1 是恢复后的环境/架构门槛逻辑 T/H ro.arch、运行时字节、比较和辅助查询
hidden 2 是 finalizer / 清理 shim S/T sub_5C2D480 静态反编译与 focused probe
hidden 0/1call_constructors-enter(libil2cpp) 后、slot32-enter 前恢复 T 20260713-172747 边界快照
initializedBase 在 slot 32 后置位 T 阶段快照差分
slot 1 是受保护段解密/恢复的最强候选入口 H 权限切换、内存处理、cache flush、pre-slot32 恢复边界
slot 30 具有导入完整性/反 Hook 目的 H 重点 GOT、失败极性、解析逻辑和最终合法绑定

libtprt 的 JNI、宿主 native、对外接口和 APK/sidecar 证据矩阵统一维护在独立业务文档中。


19. 未闭合边界与后续验证优先级

  1. sub_38B74 早期执行能否通过非侵入手段直接命中;
  2. 0x358A0 返回后到 savedHooks 和 thunk 最终化之间的精确子调用;
  3. slot 1 的具体加密算法、密钥派生和调用前后字节差异;
  4. slot 29 每条 24 字节私有重定位记录的准确语义;
  5. slot 30 判断合法跳板和异常 Hook 的精确规则;
  6. slot 31 最多 8 个保存槽与包装器的完整一一对应;
  7. Unity +0x1A35330 -> fprintf 的真实调用用途;
  8. slot 50 中注册表单例的必要字段布局,以及 ui:/mb: 两条路径的共享状态;
  9. 全量主 trace 稳定漏掉 0/1/2 与探针负载/inline patch 时机的关系;
  10. hidden 0/1ro.arch 比较常量和辅助查询的精确业务含义;
  11. 加固相关环境检测分支的实际可达性和失败条件;
  12. hidden 0/1 的恢复具体落在 slot 1/51/29/30/31/50 的哪一个边界。

建议按以下顺序继续补证:

  • 一次性记录首次执行 libtprt+0x3827C / +0x38110
  • qword_1A4A08qword_1A4A10 和 Unity +0x1A35328 做低负担写点观测;
  • 解码 slot 50 的 ui:/mb: 路径和注册表单例必要字段;
  • slot 1/51/29/30/31/50 各边界做最小字节快照,把 pre-slot32 恢复窗口压缩到单一槽位;
  • 局部减载全量主 trace,定位 hidden 0/1/2 的观测盲区。

GP6/GP7handleLoad*unwind_xx_*、APK/sidecar 等非加固待验证项统一在 libtprt_完整业务分析.md 维护。


20. 建议的 IDA 重命名

20.1 libil2cpp.so

原名 建议名称
.init_proc libil2cpp_tprt_dt_init
sub_E6F6094 tprt_libil2cpp_init_once
sub_E6F6398 tprt_format_module_base_command
sub_E6F6B28 relocate_tprt_internal_ptrs
sub_E6F6BA4 fill_tprt_ori_array_group_2a
sub_E6F6CDC fill_tprt_ori_array_group_2b
sub_E6F6E58 fill_tprt_ori_array_group_3
sub_E6F6F6C fill_tprt_ori_array_group_4
sub_E6F7000 fill_tprt_ori_array_group_5
sub_E6F7078 prepare_elf_load_permissions
sub_E6F7220 module_name_matches_engine_type

20.2 libtprt.so

原名 建议名称
sub_32800 tprt_process_protected_sections
sub_323E8 tprt_apply_encoded_relocations
sub_342C0 tprt_process_import_descriptor
sub_347C0 tprt_resolve_import_records
sub_358A0 tprt_write_resolved_import_slots
sub_38B74 tprt_install_saved_hook_thunks
sub_39CD8 tprt_call_hidden_init_array
sub_1361BC tprt_parse_runtime_command
sub_337E8 tprt_apply_embedded_patch
sub_120278 tprt_decode_relocation_records
sub_12021C tprt_process_encrypted_region
sub_C1638 get_tprt_shared_registry
sub_D17F0 tprt_registry_set
sub_15E618 flush_arm64_code_cache
sub_11D258 elf_flags_to_mprotect

这里只保留加固主链会用到的名称。JNI、对外接口和 APK/环境业务的完整命名表位于 libtprt_完整业务分析.md

名称用于表达当前分析语义,不代表厂商原始符号。对 sub_342C0sub_358A0sub_38B74 的名称刻意保留“描述符/槽位/thunk”含义,避免把通用安装框架限定为单一 Unity 或单一 API 专用函数。


21. 最终结论

TPRT 将真正的 IL2CPP 初始化置于一条分层加固链之后:

TPRT 自身就绪
  -> 预处理 Unity/EGL 导入并建立转发 Hook
  -> 恢复 libil2cpp 受保护内容
  -> 应用私有重定位
  -> 检查并重映射关键导入
  -> 注册运行时模块和上下文
  -> 执行隐藏初始化数组
  -> 标记 libil2cpp 初始化完成

这条链同时实现内容保护、装载期地址恢复、导入完整性控制、跨模块 Hook、隐藏构造器调度和失败即终止。

最重要的时序修正是:Unity/EGL Hook 首次落地早于 libil2cpp.init_procinit_proc 内的 slot 30/31 是同一通用安装框架针对 libil2cpp 的复用,不是 Unity Hook 首装点。focused boundary trace 进一步证明 hidden 0/1call_constructors-enter(libil2cpp) 时尚未恢复,但到 slot 32 入口前已经成为合法函数体,因此恢复边界已压缩到 init_proc 内的 pre-slot32 窗口。

本文结论到此止于加固链。libtprt 的完整 JNI 初始化、宿主 native、GP6/GP7、共享对外接口、APK/sidecar 与环境业务统一见 libtprt_完整业务分析.md,不再在本文重复展开。

评论

  • 还没有评论,来说点什么吧。

无需注册或登录,填个昵称即可评论。