so-function-recovery

大型加固 SO 函数级还原流水线(2000+ 函数、无 .eh_frame、CFF 混淆、内嵌字节码 VM)。用台账三层筛选替代逐个读函数,用指针表交叉检查函数边界,用动态 trace 反推 VM 编码,最终产出完整逻辑闭环。当任务是"把某个 .so 的主要代码逻辑搞清楚"、要判断"分析到什么程度算完成"、或不想重复读同一批函数时使用。

SKILL.md

GitHub

so-function-recovery — 大型 SO 函数级还原流水线

目标不是读完每个函数,而是用最少的阅读量拿到完整逻辑闭环。

路径基准:项目根。工具在 tools/,Ghidra 脚本在 tools/ghidra/。 方法论正文见 references/so-function-recovery.md;本 skill 是可执行的流水线。

何时用 / 何时不用

用 不用
.so 有 500+ 函数,要搞清主要逻辑 只看一两个函数 → references/so-func-naming.md
脱壳产物无 .eh_frame、函数边界乱 要审漏洞 → references/native-audit.md
有 CFF / 字节码 VM 混淆 要复算单个算法 → skill so-emulate
要判断"分析到什么程度算完成" 只是查字符串/导入表 → tools/so.py 单条命令

前置检查(缺一项都会白干)

# 1) ELF 结构是否可用(脱壳产物常见 section header 被伪造)
python3 tools/so.py info <so>
#   判据:dynsym_defined > 0、relocations > 0、PT_LOAD 的 va_eq_off=true

# 2) Ghidra 是否已建库并完成分析
ghidra_get_current_program_info            # 确认 program 名与 image_base
ghidra_get_function_count                  # 记下基线

# 3) 清理会阻塞构建的残留脚本
#    C:\Users\Administrator\ghidra_scripts 内失败脚本会永久阻塞整批编译
#    删 McpInline_*.java + %APPDATA%\ghidra\ghidra_*\osgi\compiled-bundles\*

步骤

0. 先分诊 SO 类型(决定哪些步骤会空转)

本流水线默认目标是加固壳 SO。类型判错会让 1/5.5/5.6/5.7 全部白跑。

类型 判据 走哪些步骤
加固壳 SO DT_INIT_ARRAY 项数多;有 svc 自杀路径;有 CFF/VM 全流程
业务 native 库 DT_INIT_ARRAY < 5 项;大量 Java_* 导出;无 CFF 跳过 1/5.5/5.6/5.7,走 Java→native 反向映射
静态第三方库 _Z*/__cxa* 密集;外部进入边极少 直接排除
python3 tools/so.py info <so> --grep Java_   # 业务库会有大量 Java_* 导出
python3 tools/so.py svc <so>                 # 有 kill/exit 才像加固壳

业务库的最快路径:jadx 列 native 声明 → 对 Java_* 导出(静态注册)或 scan_register_natives.js(动态注册)→ 只读这批函数。判据详见 references/so-function-recovery.md ›「〇、Step 0:先分诊 SO 类型」。

1. 解析 .dynamic,定位真正的执行入口

# PT_DYNAMIC → DT_INIT(12) / DT_INIT_ARRAY(25) / DT_INIT_ARRAYSZ(27)
#             / DT_FINI_ARRAY(26) / DT_JNI 相关

关键:DT_INIT 可能指向函数内部(CFF 分发后的分支块)。

ghidra_get_function_by_address <DT_INIT>    # 看返回的 Entry 是否等于 DT_INIT
# 若不等,真身是返回的 Entry

判据:反编译若满是 unaff_x19/x20 且控制流不通,就是命中了内部块。

DT_INIT_ARRAY 每一项都是一个初始化器,按四类归档:

类型 特征 处理
字符串解密 全是 str[i] ^= const 可离线复算
表安装 单个 str 到固定全局偏移 读目标表内容
版本探测 dlsym + 条件赋值 读赋值常量
真实逻辑 有分支/循环/调用 需完整读

2. 导出函数台账

时序:边界重建必须在导台账之前。台账的 size/insns/形状哈希都基于当时的 函数边界;先导台账再重建边界,函数总数会变(实测 3200 → 3916),台账全部作废。 顺序:so_unpack_prep → so_boundary_rebuild → so_split_pointer_targets → so_inventory。

# 配置 $TMP/so_inventory.cfg:
#   out=<ledger.tsv>
#   shapes_out=<shapes.tsv>
ghidra_run_ghidra_script script_name=so_inventory.java

每行 15 列:地址 / 名 / 大小 / 指令数 / 参数 / 返回类型 / 调用者数 / 被调数 / 代码引用数 / 数据引用数 / 引用来源函数 / 字符串 / 被调名 / 形状哈希 / 父命名空间。

形状哈希 = 指令归一化成 mnemonic + 操作数形状(立即数/地址/符号替换为 #) 后 MD5。同形状 = 同模板生成,读一个代表即可。

3. 三层筛选,生成工作队列

python3 tools/so_ledger.py <ledger.tsv> --shapes <shapes.tsv> --so <so> \
    --report LEDGER.md --queue queue.tsv --rename rename.tsv

# 只看还剩多少
python3 tools/so_ledger.py ... --stats

三层含义与证据等级判据见 references/so-function-recovery.md ›「一、先建立函数台账,再动手」。

验收(两道闸门,缺一不可):

闸门 判据 不通过说明
镜像可信 empty 形状占比 < 5% --so 不是脱壳镜像(.text 仍是密文)→ 函数全被判成桩
分区有效 T4 + X1 + X2 合计 ≥ 45% 分区表或形状判据有问题

先看第一道。empty 占比高时后面所有统计都不可信 —— 这是最容易踩的坑: 拿未脱壳的 SO 跑,机器形状会变成 empty 80%+,并产出上千条错误的 FUN_xxx → empty_xxx 重命名建议。

T1 = 0 是正常的:字符串锚点这一轮做完后,所有有串函数都已命名,T1 队列自然空。 看到 T1=0 说明串锚点已完成,不是判据失效。

4. 用指针表交叉检查函数边界(若在步骤 2 之前做,需重导台账)

自动分析按序言识别起点,会漏掉同族函数合并产生的第二入口。

# 列出所有"被指针表引用但落在函数内部"的地址
# 判据:首指令是 ldr xN,[x0] / stp x30 / str x30,[sp,#-N]! / bti
#       反例 ldrb w9,[x0] 是读数据,不是入口
ghidra_run_ghidra_script script_name=so_split_pointer_targets.java

不要先删原函数。createFunction 会重新界定 body,原函数自动截断。

验收:拆分后函数总数增加量 = 成功拆分数;重新导出台账,新函数应被分类器 识别为预期形状(如 ffi_bridge)。

5. 批量应用机械命名

# 配置 $TMP/so_apply_rename.cfg: in=<rename.tsv> apply=1
ghidra_run_ghidra_script script_name=so_apply_rename.java

脚本只改「生成名」开头的函数(FUN_/leaf_/frag_/tail_/cff_/dl_/ thunk_/vm_/sprintf_inline_/mprot_/ptr_),已有语义名一律跳过。 重复执行安全。

5.5 扫内联系统调用(对抗类壳必做)

python3 tools/so.py svc <so>
# svc @0xADDR  nr=N (name)

加固壳常绕过 libc 直接 svc #0,使 hook kill/hook exit 失效。

nr 名称 含义
129 kill 自杀点
172 getpid 取自身 pid
101 nanosleep 延时
93 exit 退出
48 faccessat 探测文件

先数 nr=129 再读代码:检测逻辑可分散在几十个函数里,但自杀点集中且必然存在。 把 svc 地址映射到函数,得到的清单就是"检测路径的终点"。

约束:含 kill 的函数不返回。不能 hook 返回值,只能在 svc 指令处下断点 或在 syscall 层拦截。

5.6 识别 hook 引擎与 JNI 表替换(加固壳必查)

三个可静态识别的特征:

特征 判据 含义
hook 三件套 同一函数调 memcmp + pthread_mutex_lock/unlock 哈希表定位 + 字节校验 + 打补丁
lookup3 哈希 常量 0x9e3779b9 + 0xfeedbeef,移位 13/8/13/12/16/5/3/10/15 Bob Jenkins lookup3
JNI 表克隆 memcpy(dst, src, 0x1bf0) + 重定位 ToReflectedField/Throw/ExceptionClear 影子 JNINativeInterface

影响动态分析:壳换掉了 env->functions,之后所有 JNI 调用走副本。 hook libart.so 的导出函数无效,必须 hook 表里的槽位。

5.7 判定为 VM 壳时(JNI_OnLoad 只启动解释器)

业务方法不在 .text 里、而是被编译成自定义字节码时,不要在本流水线里硬啃 —— 走专项流程:

做什么 去哪
8 步执行清单(确认 → 找表 → 取字段 → 写 profile → trace → verify → disasm → 命名) skill vmp-analysis
换壳工作流的命令序列 references/so-function-recovery.md ›「换壳工作流(0 → 1)」
识别特征与编码推导方法论 references/native-analysis.md ›「3.5.2 字节码 VM 逆向(VMP 型壳)」
工具 tools/vmp.py(profile list 先看有没有现成模型)

已有 profile 可直接复用:tools/vmp_profiles/ 下每个 JSON 是一套壳的编码模型。 命中就不用从零建模,直接 trace → verify → disasm。

6. 按队列读 T1 → T2 → T3

排序依据:证据等级 → 同形状成员数(读一个覆盖多个)→ 体积。

命名证据分级(只写有证据的名字):

强度 证据 例
强 字符串直接说明用途 读 ro.build.version.sdk → prop_ro_build_version_sdk
强 机器特征唯一确定 FFI 桩 → ffi_pop7w_<addr>
中 外部调用面收敛 只调两次 munmap → vm_frame_free
中 反编译确认语义 *(a+0x10) = b → field_write_0x10
弱 只凭名字像 不要用

7. 重新导出,验证命名生效

# 配置 $TMP/so_dump_pseudo.cfg: out=<path> min_size=64 include_vm=1
ghidra_run_ghidra_script script_name=so_dump_pseudocode.java

验收:新命名的函数应出现在伪代码里;机械名带地址后缀便于回溯。

8. 写逻辑闭环文档

不是函数清单,是"它怎么工作":

必须覆盖 内容
启动序列 所有初始化入口及归类
主流程 从入口到最终目的的完整调用链
外部依赖 dlopen/dlsym 目标、被调库函数清单
数据结构 关键全局结构、栈帧、VM 帧布局
混淆手法 CFF / VM / 字符串加密的算法与范围
对抗手段 检测项清单 + 每项实现位置

完成判据

函数命名率不是判据。 命名率低但逻辑闭环完整,比 100% 命名但讲不清主流程更有价值。 判据全文见 references/so-function-recovery.md ›「十二、完成判据」。

判据 验证方式
执行入口清楚 DT_INIT / DT_INIT_ARRAY / JNI_OnLoad 全部定位到真身
主流程闭环 从入口到最终目的有完整调用链
外部依赖明确 所有 dlopen/dlsym 目标 + 被调库函数清单
数据结构清楚 关键全局结构、栈帧结构、VM 帧布局
混淆手法识别 算法与作用范围
对抗手段清楚 检测项清单 + 实现位置

卡壳树

现象 处理
反编译满是 unaff_、控制流不通 命中了 CFF 内部块 → 查所属函数真身(步骤 1)
指令覆盖率 < 99% 跑 so_boundary_rebuild.java,再跑步骤 4
同形状函数上千个 分类器判据太松 → 检查是否多条件同时满足
机械命名覆盖了语义名 保护名单缺前缀 → 补 GENERATED 列表
拆分边界后函数数没变 地址基准错(ELF VA vs Ghidra VA)
hook 无效(kill 拦不住 / libart 导出无效) 见 references/anti-detection.md 与 references/so-function-recovery.md ›「七之三、裸系统调用」
台账形状哈希全是 err 指令遍历越界 → 检查 body 边界

工具清单

通用工具(tools/,任何 SO 可用)

工具 作用
tools/ghidra/so_inventory.java 导出函数台账 + 形状指纹
tools/so_ledger.py 三层筛选、证据分级、机械命名建议
tools/ghidra/so_split_pointer_targets.java 按指针表补建漏掉的函数边界
tools/ghidra/so_apply_rename.java 批量重命名(带语义名保护)
tools/ghidra/so_boundary_rebuild.java 函数边界重建(无 .eh_frame 时)
tools/ghidra/so_dump_pseudocode.java 批量导出伪代码
tools/so.py ELF 侦察、字符串、反汇编、交叉引用、SVC、JNI 判型
tools/ptr_table.py 指针表枚举与分类、表槽位反查
tools/probe_layout.py 动态结构体布局探测(--at/--hook/--multi-hook/--setup)
tools/check_registry.py 登记漂移自检

样本专用工具(<包名>/tools/,换样本需重写或改 profile)

工具 作用
<包名>/tools/setup_*.py 给 probe_layout.py / emu_run.py / vmp.py trace 准备样本环境

分界线:能按 ELF 结构/通用算法判定的 → tools/; 需要样本的地址、字符串、VM 编码常量的 → <包名>/tools/。

VM 专项工具(vmp.py / vmp_profiles/ / 样本的 vm_*.py)归 skill vmp-analysis 管, 本节不重复列 —— 步骤 5.7 会转交过去。

参考

  • 方法论与判据全文:references/so-function-recovery.md
  • VM 编码反推方法论:references/native-analysis.md ›「3.5.2 字节码 VM 逆向(VMP 型壳)」
  • 单函数仿真:references/so-emulation.md、skill so-emulate
  • 单函数命名:references/so-func-naming.md、skill func-naming