libil2cpp.so:init_proc与libtprt.so加固流程分析文档
文档状态:加固主链专项稿 / 加固结论唯一归口
整理日期:2026-07-14
目标进程:com.tencent.rmcn
目标架构:Android / AArch64
分析对象:libil2cpp.so、libtprt.so、libunity.so的装载期加固协作
证据来源:IDA 静态逆向、ELF 元数据、MiniMem 只读快照、Frida 冷启动时序
本文只分析 libil2cpp.so:DT_INIT 与 libtprt.so 之间的加固业务:Unity/EGL 预安装、受保护段恢复、私有重定位、导入/Hook 安装、运行时模块注册、隐藏初始化数组和失败路径。
以下 libtprt 完整业务不在本文展开:
JNI_OnLoad与JNINativeMethod[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+0xE6F6434、libtprt.so+0x39CD8均为模块 RVA;- 运行期绝对地址为
effective_load_bias + RVA; libil2cpp.so会同时存在整文件只读映射和有效工作映像,最低同路径映射不一定是 load bias;- GOT、描述符、全局量和函数地址分别属于各自模块,跨模块比较必须同时记录模块名。
1.2 核心结论
完整加固链不是单一的 init_proc,而是两个相互衔接的阶段:
init_proc之前,TPRT 先利用libunity.so已加载窗口完成 Unity/EGL 指针预处理和 Hook 最终化;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_NOW、FLAGS_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/17在init_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_OnLoad、unwind_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+0x358A0 在 init_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.jsonl 的 modules-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+0x342C0 或 libtprt+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_phoff、e_phnum并遍历PT_LOAD; - 根据
p_vaddr推导 load bias; - 将 ELF
p_flags转换为mprotect权限; - 为后续原地恢复、重定位和 Hook 临时准备目标页;
- 对
PT_GNU_RELRO恢复只读保护。
保护代码还内置五类引擎模块匹配:
| 类型 | 运行时/引擎 | 目标库 |
|---|---|---|
| 1 | Mono | libmono.so、libmonobdwgc-2.0.so |
| 2 | IL2CPP | libil2cpp.so |
| 3 | Unreal | libUE4.so、libUnreal.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=");
静态确认的动作:
- 遍历步长约 48 字节的段描述符;
- 计算页对齐地址、长度和原权限;
- 临时增加写权限;
- 通过
sub_12021C处理大段内存; - 调用
sub_15E618刷新 ARM64 数据/指令缓存; - 恢复页面权限;
- 对大范围按
0x10000步长执行madvise(addr, 0xC000, MADV_DONTNEED); - 记录模块、段和处理状态。
内部模式函数以 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_proc 的 slot 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。静态代码明确具有以下能力:
- 解析最多 8 组目标偏移;
- 将目标页临时设为可写;
- 保存原指针到
qword_1A4A08 ... qword_1A4A40; - 写入
sub_38110、sub_3827C等 TPRT 包装器; - 恢复页面权限。
但必须区分两件事:
- “
sub_38B74具备安装 Hook 的静态能力”:S 级确认; - “
init_proc内这一次 slot 31 首次安装 Unity/EGL Hook”:已被 T 级证据否定。
在 modules-ready 前,两个保存槽和 Unity 最终状态已经到位;init_proc 内 slot 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 一带的树/映射中更新或插入字符串键值。
对加固主链而言,可确认的必要结论是:
slot 50把SOBASE_<module>写入共享注册表,而不是写一个临时局部变量;- 模块基址注册是副作用,槽位离开时的
x0不构成稳定 ABI; - 注册表的其它消费者、缓存字段和对外查询/控制接口属于
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范围覆盖这三项所在页。
这意味着:
- 业务执行层面,索引
0/1/2并未被当前样本跳过; - “29/32” 是全量主 trace 的观测盲区,不是隐藏初始化真实执行数;
- 至少在 focused probe 中,不存在“hook 装上后又被页权限恢复链覆盖”的现象。
进一步地,focused probe 还暴露出静动态字节差异:
- 索引
2(0x5C2D480)运行时首字节与静态文件一致; - 索引
0/1(0x5C792BC/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-enter 时 beforeBytes |
结论 |
|---|---|---|---|
0 |
不等于恢复态 | 等于恢复态 | 恢复发生在两者之间 |
1 |
不等于恢复态 | 等于恢复态 | 恢复发生在两者之间 |
2 |
已等于恢复态 | 已等于恢复态 | 该项无需同类恢复 |
更具体地说:
- 索引
0的focus-target-snapshot.bytes前 16 字节为2b3717c1...,而beforeBytes前 16 字节为3f2303d5...; - 索引
1的focus-target-snapshot.bytes前 16 字节为4b3017c1...,而beforeBytes前 16 字节为5f2403d5...; - 索引
2从call_constructors-enter(libil2cpp)到slot32-enter始终保持3f2303d5fd7bbea9...开头,与静态函数体一致; - 三项都满足
beforeBytes == afterAttachBytes,说明 focused hook 安装前目标代码已经稳定,Frida 本身没有先把它们改写成另一种“恢复态”。
因此目前最强的动态边界是:
- 索引
0/1在进入libil2cpp.soconstructor 链时,仍不是最终恢复后的函数体; - 到
slot 32入口时,它们已经变成可直接反汇编的合法 AArch64 代码; - 也就是说,恢复动作发生在
init_proc内、且早于slot 32,候选范围被压缩为slot 1 / 51 / 29 / 30 / 31 / 50; - 结合静态职责划分,
slot 1仍是最强候选,但这一点还没有达到“单点命中证明”。
14.6 索引 0/1/2 的运行时业务语义
在 tprt-init-20260713-171837.jsonl 中,把 focused probe 的字节抓取长度提升到 128 后,可以直接反汇编出前三项恢复后的函数体前半段。
索引 0(libil2cpp+0x5C792BC)表现为一段“属性读取 + 布尔标志回写”逻辑:
- 读取属性键
ro.arch; - 对结果做
10字节比较; - 将比较结果写入
libil2cpp+0xE6F1A60; - 如果前置辅助调用失败,则把该标志清零后直接返回。
索引 1(libil2cpp+0x5C79600)与索引 0 构成配对门槛:
- 先检查
libil2cpp+0xE6F1A68,若非零则直接返回; - 否则同样读取
ro.arch; - 对结果做
10字节比较; - 在一条回退路径上,再以常量
0x10和0x1A调用另一组 remapped thunk,并把结果转交给libil2cpp+0x5C79354; - 本样本实测返回值为
0x40000000efffffff。
索引 2(libil2cpp+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:
am force-stop com.tencent.rmcn;- root 侧监控 PID,出现后立即
SIGSTOP; - Frida attach 并安装 linker
call_constructors探针; - resume 目标;
- 等模块出现后安装
.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 |
两次 0x358A0:libunity 与 libil2cpp 复用 |
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_proc 是 libil2cpp.so 的 DT_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 | 槽位前后状态差分 |
0x358A0 以 libunity 和 libil2cpp 两种基址复用 |
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/1 在 call_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. 未闭合边界与后续验证优先级
sub_38B74早期执行能否通过非侵入手段直接命中;0x358A0返回后到savedHooks和 thunk 最终化之间的精确子调用;- slot 1 的具体加密算法、密钥派生和调用前后字节差异;
- slot 29 每条 24 字节私有重定位记录的准确语义;
- slot 30 判断合法跳板和异常 Hook 的精确规则;
- slot 31 最多 8 个保存槽与包装器的完整一一对应;
- Unity
+0x1A35330 -> fprintf的真实调用用途; - slot 50 中注册表单例的必要字段布局,以及
ui:/mb:两条路径的共享状态; - 全量主 trace 稳定漏掉
0/1/2与探针负载/inline patch 时机的关系; - hidden
0/1中ro.arch比较常量和辅助查询的精确业务含义; - 加固相关环境检测分支的实际可达性和失败条件;
- hidden
0/1的恢复具体落在slot 1/51/29/30/31/50的哪一个边界。
建议按以下顺序继续补证:
- 一次性记录首次执行
libtprt+0x3827C / +0x38110; - 对
qword_1A4A08、qword_1A4A10和 Unity+0x1A35328做低负担写点观测; - 解码 slot 50 的
ui:/mb:路径和注册表单例必要字段; - 在
slot 1/51/29/30/31/50各边界做最小字节快照,把 pre-slot32 恢复窗口压缩到单一槽位; - 局部减载全量主 trace,定位 hidden
0/1/2的观测盲区。
GP6/GP7、handleLoad*、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_342C0、sub_358A0、sub_38B74 的名称刻意保留“描述符/槽位/thunk”含义,避免把通用安装框架限定为单一 Unity 或单一 API 专用函数。
21. 最终结论
TPRT 将真正的 IL2CPP 初始化置于一条分层加固链之后:
TPRT 自身就绪
-> 预处理 Unity/EGL 导入并建立转发 Hook
-> 恢复 libil2cpp 受保护内容
-> 应用私有重定位
-> 检查并重映射关键导入
-> 注册运行时模块和上下文
-> 执行隐藏初始化数组
-> 标记 libil2cpp 初始化完成
这条链同时实现内容保护、装载期地址恢复、导入完整性控制、跨模块 Hook、隐藏构造器调度和失败即终止。
最重要的时序修正是:Unity/EGL Hook 首次落地早于 libil2cpp.init_proc;init_proc 内的 slot 30/31 是同一通用安装框架针对 libil2cpp 的复用,不是 Unity Hook 首装点。focused boundary trace 进一步证明 hidden 0/1 在 call_constructors-enter(libil2cpp) 时尚未恢复,但到 slot 32 入口前已经成为合法函数体,因此恢复边界已压缩到 init_proc 内的 pre-slot32 窗口。
本文结论到此止于加固链。libtprt 的完整 JNI 初始化、宿主 native、GP6/GP7、共享对外接口、APK/sidecar 与环境业务统一见 libtprt_完整业务分析.md,不再在本文重复展开。
评论
- 还没有评论,来说点什么吧。