i加密加固某银行APP逆向还原

第一个完整逆向的安卓应用,记录一下。
因为应用的特殊性,未登录,只还原了外围一个方法,做POC验证,也没有做协议分析。

设备环境

  • 设备:Pixel 1
  • 系统镜像:sailfish-opm1.171019.011-factory-56d15350
  • Root工具:twrp-3.3.0-0、Magisk-v26.1

内核打补丁

目的是为了在运行时加载自己写的 .ko,用于观察壳行为。手上的机器是 Pixel 1(sailfish),AArch64,Android 8.1 系列实验镜像,运行内核 3.18.70-g1292056(Linux 3.18),没有 su、没有 Magisk。最后跑通了:uid=2000(shell)、上下文 u:r:shell:s0,多轮 unload → load 都成功,模块产出的采集数据有效。

driverctl:用 EL0 用户态发起模块加载

思路是不去提权,而是让加载动作由 EL0 的用户态程序 driverctl 发起:open(path, O_RDONLY|O_CLOEXEC),然后 syscall(__NR_finit_module, fd, params, 0),模块本身依旧在 EL1 跑 module_init。第一道坎其实在用户态——Toybox 的 insmod 直接回 toybox: Not root,那是 applet 在系统调用前自己的拒绝,跟内核无关;driverctl 绕的只是这一层。

driverctl 在 Windows 侧用 x:\xxx\ndk 的 build.cmd 出 libs/arm64-v8a/driverctl,推上去 chmod 755 后:

adb shell "cd /data/local/tmp && ./driverctl load ./xxxx_test.ko"
adb shell "cd /data/local/tmp && ./driverctl load ./xxxx_so_dump.ko trap_init_page=1 trap_rel_pages=0x95000,0x43000"
adb shell "grep '^xxxx_test ' /proc/modules"
adb shell "/data/local/tmp/driverctl unload xxxx_test"

卸载走 syscall(__NR_delete_module, name, O_NONBLOCK)。另外 driverctl 用的是 flags=0,没请求 MODULE_INIT_IGNORE_MODVERSIONS / IGNORE_VERMAGIC。

内核侧的检查点与五处补丁

真正的门在内核的 may_init_module():capable(CAP_SYS_MODULE) 与 modules_disabled 任一成立就 -EPERM。编译器把这个短函数分别内联进了两个 syscall 包装路径,所以 init_module 和 finit_module 各有一份拒绝分支,改一处不够。

从根目录 boot.img 解出未修改 kernel 作基线,逐字节对着打完补丁的 kernel 核,一共五处:

  • 0xB2470 check_version 入口 → MOV W0,#1,0xB2474 UBFIZ → RET:让版本检查恒通过;
  • init_module:0xB56C0、0xB56CC 两处条件分支 → NOP;
  • finit_module:0xB580C、0xB5818 两处 → NOP;
  • delete_module:0xB3600、0xB3610 两处 → NOP;
  • sel_write_enforce:0x285448 的 B.EQ → 无条件 B,跳过 capability 检查与赋值。

driverctl load 走的是 finit_module,所以 0xB580C/0xB5818 才是直接的门,只改 0xB56C0/0xB56CC 放行不了这个工具;卸载走 delete_module,那两处分别忽略 capable() 失败和 modules_disabled != 0,但引用计数、模块状态、名称查找和退出函数都没绕。

早期曾把 init/finit 误判成"共用一处内联检查"。实际情况是这两条路径被归进了以 0xB566C 开头的一个大函数,内部是两份独立控制流;原始 kernel 四个地址的条件分支字节各不相同,当前四处全是 NOP。这些是当前特定 raw kernel 的文件偏移,换版本需要重核构建版本、控制流和地址映射。

校验与签名

签名校验这块不用处理:设备 /proc/config.gz 里是 # CONFIG_MODULE_SIG is not set,二进制里也没有 ~Module signature appended~、sig_enforce、mod_verify_sig 这些特征,说明这个内核根本没开模块签名。

CRC 和 vermagic 是两回事。check_version() 固定返回 1,只关掉了 __versions 的符号 CRC 比较;vermagic 在 check_modinfo() 里独立比较,不一致就返回 -ENOEXEC。这两关最终都过了,xxxx_test.ko 能正常加载。

SELinux 与 enforcing 全局量

SELinux 这边本来会拦 finit_module:策略要求 system:module_load 权限,而实测只报 avc: denied 并带 permissive=1,也就是当前是宽容模式——记一笔,但不真拦。把 0x285448 改成无条件跳转(前面五处补丁里的最后一处)之后,setenforce 会直接走到成功返回,selinux_enforcing 留在初始值 0,SELinux 就一直保持宽容。前提是启动参数里没有 enforcing=1。另外,bootloader 解锁、Verified Boot 显示 orange,只说明能启动自定义镜像,跟能不能加载模块是两件事。

模块编译与部署

模块在 Ubuntu 20.04 上编,只编外部模块:

export KDIR=$HOME/pixel1/kernel/out
export CROSS_COMPILE=$HOME/pixel1/gcc49/bin/aarch64-linux-android-
make -C "$KDIR" M="$PWD" ARCH=arm64 CROSS_COMPILE=$CROSS_COMPILE modules

要求 KDIR 已配置并跑过 modules_prepare,.config、生成头和 Module.symvers 尽量与设备一致。

风险与回刷

两条踩过的坑:从 /data/local/tmp 加载成功,不等于模块里的 kworker 能往 /data 写文件——加载是 shell 进程打开的 .ko,kworker 写 FBE 加密目录可能因没有 fscrypt key 返回 -ENOKEY;拿 /dev 的 tmpfs 绕,但重启即失、容量和清理权限也受限。另外,这些补丁把 capability、modules_disabled 单向锁、版本校验和 SELinux 阻断一起削弱了,shell 或被它控制的进程能把任意内核代码装进 EL1,ABI 不匹配可以直接 panic、写坏文件系统或静默破坏内存。实验完按槽回刷:

fastboot getvar current-slot
fastboot flash boot_a boot.img
fastboot reboot

壳观察和对抗工具

样本跑在 Pixel 1(sailfish、ARM64、Linux 3.18),目标包 com.xxbank.xbank。壳在 Root、调试状态、Frida 注入三条线上都有检查,还带自毁分支,普通运行时 hook 撑不过载荷加载的头几秒。改用项目里的 kernel_tools,主组合根 osb_hook_driver/thread_phase2_8_1.c,加载形态是:

driverctl load ./xxxx_thread_phase2_8_1.ko \
  target_package=com.xxbank.xbank \
  target_so_names=libexec.so,libexecmain.so ...

后面跟一串 name=value。它不做全局隐身,只做两件事:改变目标读到的结果(对抗面),和记录目标触达了什么(观察面)。

参数分层

参数分五层:

  • 绑定层:决定对哪个进程实例生效;
  • 处理点层(xxxx_p28_mask、xxxx_anchor_mask):决定处理点是否安装;
  • 对抗层(xxxx_p28_hide_*、xxxx_p28_forge_tracerpid、xxxx_p28_block_exit):决定命中后是否改目标可见结果;
  • 证据层(observe_mask、xxxx_p28_mem_mask、xxxx_d0_mask):只增加证据,不改行为;
  • VM 层(xxxx_vm_*):独立的 SO 函数入口观察器。

目标绑定

驱动不做全局隐身,只对指定进程实例生效:target_package 必填,打开 auto_bind 后会等目标成功加载 target_so_names 里的 SO,再用 tgid + leader_start_ns 把实例固定下来,避免同名进程或重启后错绑;正式绑定前还有一道 current->comm 子串兜底。时序上有硬约束:先 driverctl load 进 PREBIND、再启动 App,已经在跑的 App 要先结束进程,否则绑不上。

观察面

观察面分几条路,都不改目标行为。

一、tracepoint 回调:用 for_each_kernel_tracepoint 按名字解析到 tracepoint,再 tracepoint_probe_register 挂回调。

tracepoint 回调 记录什么
sched_process_fork probe_sched_process_fork 进程 fork
sched_process_exit probe_sched_process_exit 进程 exit
sched_switch probe_sched_switch 线程切换
sys_enter / sys_exit probe_sys_enter / probe_sys_exit 系统调用进出
signal_generate / signal_deliver 按位单独开 信号产生与投递
binder_ioctl / binder_command / binder_return probe_binder_* Binder 事务方向

二、观察锚点(inline hook): arm64_inline_hook 挂内核函数,只记录、不改返回值,由 xxxx_anchor_mask 按位开关:

锚点 内核函数 位置 看什么
PRCTL SyS_prctl kernel/sys.c:2178 prctl 调用(子类型有 GET_DUMPABLE、SET_VMA)
STATUS proc_pid_status fs/proc/array.c:353 /proc/<pid>/status 的输出(子类型 TRACERPID)
WCHAN proc_pid_wchan fs/proc/base.c:234 等待通道
MAP_VMA show_map_vma fs/proc/task_mmu.c:318 /proc/<pid>/maps 的每行
PID_MAP show_pid_map fs/proc/task_mmu.c:421 同一条调用链的上游
PID_SMAP show_pid_smap fs/proc/task_mmu.c:751 /proc/<pid>/smaps 的每行

命中后统一走 xxxx_emit_anchor(...) 压成动作记录入队。默认 mask 去掉 show_map_vma:它和 show_pid_map、show_pid_smap 在同一条调用链上,都装会让每个 VMA 被记两次。

三、按位打开的观察项:observe_mask 另有几类,默认只开前三类 —— 目录项读取(DIRENT)、信号(SIGNAL)、futex(FUTEX)、Binder(BINDER)、系统属性读取(PROPERTY)、linker 锁(LINKER_LOCK)、sysinfo(SYSINFO)。

四、撤掉的锚点:futex 的 inline hook 会让主线程自死锁(FUTEX_WAIT 把 replacement 留在 original() 里睡眠),do_exit 则已被 sched_process_exit tracepoint 覆盖,都不再单独挂。

以上回调与锚点只做记录,写进事件池后由持久化线程落盘,不碰任何返回值。

对抗面

root 与 mount 隐藏

壳会查几处常见的 root 痕迹和挂载项,判断设备有没有被动过。所以这里把文件存在性与权限查询(newfstatat、faccessat、access)的命中结果改成"不存在",符号链接读取(readlinkat)同理;挂载信息输出(show_vfsmnt、show_mountinfo)里带 magisk、.magisk、worker 的行则直接过滤掉。目标再看这些点,就看不到 root 痕迹和异常挂载项了。

要注意源码里 hide_root、hide_mounts 默认就开着,所以"什么都不传"并不是纯观察基线。

反调试:TracerPid 改写

壳会读 /proc/<pid>/status 里的 TracerPid,跟 getppid() 比对,发现被 ptrace 就走自毁分支;对应样本函数是 f_1F0F4。所以这里动的是这个文件的输出 —— 把 TracerPid: 后面的数字等长改写为 0,长度不变,读起来就像没被跟踪,进程也就不会自毁了。

对照实验只切这一个开关:关掉时连续三轮都加载失败,打开后原 PID 保持、Frida 心跳与 Interceptor 都命中;Native Interceptor 打 libc getpid 4 次全中,Java bridge 三轮 ping 通过、枚举约 1.06 万类。另有一轮约 3 秒后异常终止、PID 被替换,原因没定位到。就本样本而言,这个字段是加载成败的关键。

Frida 痕迹与 block_exit

Frida 痕迹这条线:壳会扫 /proc/<pid>/maps 和 /proc/net/unix 找注入痕迹,所以这两处输出里匹配 memfd、frida、gum、linject、substrate 的行会被丢掉,readlinkat 命中时也返回"不存在" —— 进程里就看不到 Frida 的暴露点了。注意它和观察锚点里控制映射展示的那一项会抢同一个函数,默认配置避开了这个组合。

block_exit 是挡自杀:壳会给自己发信号结束进程,所以这里把 kill、tgkill、tkill、rt_tgsigqueueinfo 四个投递入口都拦下。只拦前两个是不够的 —— 实测还有 rt_tgsigqueueinfo 带着 SIGTRAP 打进来。退出被挡住之后进程能继续留在观察窗口里,代价是拦 exit 会卡住正常收尾,长时间保持有 watchdog 风险,只适合短窗定位。

自编译 frida

官方预编译的 frida 是标准件,但这次要的不是标准件:名称、监听端口、构建标记都得自己控住,所以改成自编译。只在获授权的设备、应用和测试环境里用。

固定配置

配置都固化在 output/build-info.json:版本 17.16.4、架构 android-arm64、名称 hwcmapper、端口 19144、NDK r29、strict_wx=true,构建时间 2026-09-16T22:01:48Z。

保留目录是 /home/xxx/work/phantom-frida-build(WSL Ubuntu 24.04),output/ 放历史产物和校验清单;日常重构建用 ~/phantom-frida 工作树,不动已验证的那份副本。

环境前置

原始构建 VM:Ubuntu 24.04.5、12 vCPU、20 GB RAM、约 193 GB 可用磁盘。组件按版本表核对:

组件 版本 / 路径
Temurin JDK 17.0.20.1($HOME/opt/jdk17)
Node v24.13.1($HOME/opt/node24)
Android SDK platform 34 / build-tools 34.0.0($HOME/opt/android-sdk)
NDK r29(构建树 build/android-ndk-r29)
Python 3.12,带 venv / pip
其它 build-essential git curl unzip zip rsync file xxd、Ninja、Frida releng/meson

环境统一走 ~/.frida-builder-env(导出 JAVA_HOME、ANDROID_SDK_ROOT、ANDROID_HOME、PATH),用之前先 source 一下。

构建

源码树是一个带 submodule 的仓库:

git clone https://github.com/TheQmaks/phantom-frida.git phantom-frida
cd ~/phantom-frida
git submodule update --init --recursive

确认落在固定的那个提交上、且工作树干净,然后按历史配置构建:

python3 build.py \
  --version 17.16.4 --name hwcmapper --arch android-arm64 \
  --port 19144 --extended --strict-wx --verify

三个开关分别是:

  • --extended:扩大名称变换范围,只作为构建配置保留;
  • --strict-wx:避免持久 RWX 代码池,兼容性失败时 fail-closed;
  • --verify:只扫描构建器定义的禁止二进制标记,过了只说明这一次扫描过了。

验收与部署

adb push "output/hwcmapper-server-17.16.4-android-arm64" /data/local/tmp/hwcmapper
adb shell "su -c 'chmod 755 /data/local/tmp/hwcmapper && /data/local/tmp/hwcmapper &'"
adb forward tcp:19144 tcp:19144
python -m pip install "frida==17.16.4" frida-tools
frida-ps -H 127.0.0.1:19144

Server 与主机 Python 包都用 17.16.4,通过 add_remote_device('127.0.0.1:19144')(或等价的 -H)连,不走 USB 那条路径。产物复验保留的两项裸二进制都 PASS。

边界与说明

Java bridge 不是 Server/Gadget 自编译产物的一部分:它是独立的主机项目,锁 frida-java-bridge@7.0.13,入口脚本要放在该 npm 项目的 project_root 内,再用 frida.Compiler().build(..., project_root=<该目录>) 打包。

这套结论只覆盖保留证据和本次校验;设备部署、完整 Android smoke test、全新 VM 重建、应用侧运行观察都还没做。

反反调试对抗

注入 Frida 载荷后,进程总是反复终止。内核驱动先只拦 kill / tgkill,随后补上 tkill 与 rt_tgsigqueueinfo,实测拿到:

!!BLOCK kill pid=19735 sig=9
!!BLOCK rt_tgsigqueueinfo tgid=19735 pid=19749 sig=5

软件发的 SIGKILL 和软件调的 SIGTRAP 都被拦下了,应用照旧重启。crash 记录给出:

pc = 0x7a9c6f1e7c   <anonymous:0x7a9c6d4000>
backtrace:
  #00 pc 0x1de7c
  #01 pc 0x1a5c

ANON+0x1DE7C 是 brk #1。这条指令由 arm64 异常路径直接产生 SIGTRAP,不调用 kill、tkill、tgkill、rt_tgsigqueueinfo——继续扩 syscall hook 覆盖不到它。这是放弃纯内核侧拦截、转向进程内代码页 patch 的决定依据。同一阶段还定死了驱动参数 xxxx_p28_mask=0x31f 而非 0x37f:后者含 exit_group / exit hook,与 xxxx_p28_block_exit=1 组合会伪造成功却不真正退出,启动阶段反复阻塞 exit。

自杀器长什么样

匿名内存 .lex_anon dump 里的关键片段(省略了中间指令,不是完整反汇编):

; 自杀器入口 ANON + 0x1DD8C,原始首字 0xA9BD7BFD
0x1dd8c: stp  x29, x30, [sp, #-0x30]!
0x1de58: mov  x0, x19                 ; x19 = getpid()
0x1de5c: mov  w1, #9                  ; SIGKILL
0x1de60: mov  x8, #129                ; arm64 __NR_kill
0x1de64: svc  #0
0x1de68: cmn  x0, #0xfff
0x1de7c: brk  #1                      ; 指令级 SIGTRAP 出口
0x1a5c:  bl   0x1dd8c                 ; wrapper 调用自杀器

内核同轮记下的 PC / LR 与这段静态反汇编对得上,确认走的就是 kill(getpid(), SIGKILL)。还原出的伪代码里 self_kill(code) 是 noreturn,共四条出口:br x0 跳向 0xDEAD0100 | (code<<2)、kill(pid, SIGABRT)、kill(pid, SIGKILL)、brk #1。两个全局字段参与路径选择(一个决定是否先 sleep_ms(15000),一个决定是否转 SIGKILL),但它们的业务语义从偏移本身推不出来。

下面是这条控制流还原出来的伪代码(Obj、g、sleep_ms 是分析时的命名,0xB27C8 是那一轮记录里的全局槽口径):

/* self_kill(int code),实际路径不返回 */
void self_kill(int code)
{
    long pid = syscall(SYS_getpid);              // 0x1DDA0: mov x8,#172; svc
    if (pid < 0) { errno = -pid; pid = -1; }     // 0x1DDA8..0x1DDBC

    Obj *g = *(Obj **)0xB27C8;                   // 0x1DDC0/0x1DDC4:全局槽

    if (code == 5 && g && ((unsigned char *)g)[0x1CB] != 0) {
        sleep_ms(15000);                         // 0x1DDE0: w0=0x3A98; bl 0x1228
        g = *(Obj **)0xB27C8;
        if (!g)
            goto kill_sigkill;
    }
    if (!g || ((unsigned char *)g)[0x1D6] == 0)
        goto kill_sigkill;

    uintptr_t bad = ((uint32_t)code << 2) | 0xDEAD0100;
    *(uintptr_t *)bad = 0;                       // 0x1DE24
    asm volatile("mov x29, xzr; mov x30, xzr");  // 清 FP/LR,破坏常规回溯
    goto *(void *)bad;                           // 0x1DE18: br x0,出口1

kill_abort:                                      // 0x1DE1C:独立可进入标签
    sleep_ms(10);
    syscall(SYS_kill, pid, SIGABRT);             // 0x1DE2C..0x1DE34,出口2
    sleep_ms(1000);

kill_sigkill:                                    // 0x1DE54
    /* 对应 str xzr,[x21]:清的是经 x21 指向的全局指针槽。 */
    *(Obj **)0xB27C8 = NULL;
    syscall(SYS_kill, pid, SIGKILL);             // 0x1DE5C..0x1DE64,出口3
    asm volatile("brk #1");                      // 0x1DE7C,出口④(实测命中)
}

code == 5 会在 g+0x1CB 非零时先延迟 15 秒,g+0x1D6 决定是否转到 SIGKILL 路径;现有证据能确认这两个字段参与路径选择,但从偏移本身推不出它们的业务语义。bad 那条路先写 0xDEAD.... 派生地址,再清 FP/LR 跳过去,属于故意制造不可恢复故障的出口;另外三条分别覆盖信号终止和指令异常终止。

还有一处要区分:self_kill 里的 str xzr,[x21] 清的是全局对象指针槽,所以写成 *(Obj **)0xB27C8 = NULL;另一处改动碰的 +0x1E524 是 VM 路径上的另一次清零。两者可能共同造成后续 *g == 0,但不是同一个 patch 点。

b .

三次尝试改的都是函数入口 code_base + 0x1DD8C 那一条指令,只是换成不同内容(AArch64 小端,下面按内存里低地址在前的顺序写):

code_base+0x1DD8C:  a9bd7bfd  stp x29, x30, [sp, #-0x30]!
code_base+0x1DD8C:  d65f03c0  ret      ; pc = x30,返回调用方
code_base+0x1DD8C:  14000000  b .      ; pc 停在入口,永不返回

把入口写成 ret 时,回读是对的,但进程仍旧死在 +0x1DE7C 的 brk 上 —— 说明改完之后还是进了自杀器内部。原因是 self_kill 是 noreturn:调用方按"不返回"准备栈和寄存器,改成 ret 等于让调用方带着这套状态继续跑,现场出现过 pc 直接跳进数据段。换成 b .(自旋)后保持了"不返回"的形态,那个函数的 kill / brk 主体再没执行过,代价是触发路径上的线程被永久占住。

ret 本身不是错指令:当 x30 已被证明是可用的返回地址、而且调用方允许返回时,它就是受控返回手段。b . 适合把 noreturn 的自杀线程隔离、保住其他线程和现场,所以选它 Patch。

结果

修改 回读 结果
+0x1DD8C → 0x14000000(自旋) 正确 不再死 brk,改崩 +0x1EE74,fault 0x300
+0x1DD8C → 0xD65F03C0(ret),加 +0x1E524 → 0xD503201F 均正确 仍在 +0x1DE7C 的 brk 死
+0x1DD8C → 0x14000000,加 +0x1E524 → 0xD503201F 均正确 brk 与 +0x1EE74 消失,改崩 +0x79360,fault 0x2f8

自旋封住了 self_kill,崩点却一路后移到新的空指针站点,逐出口封堵不收敛。于是回到检测输入和上游 VM 分支:VM 段里有 327 处 ldr [x22, wIdx, uxtw #3] / add xN, x28 / br xN 形态的计算跳转(归一化常量 0x511C4EDD),错误码在 +0x1E364 写入 err = 5,再由 +0x10A4C 读出、判零后进 self_kill。索引表和处理器基址都在运行时寄存器 x22 / x28 上,没有命中时的寄存器快照就还原不出每个 br 的实际目标,所以靠静态地址给 switch 下断点不成立,可重复的观察点只能落在语义稳定的写入与判定处。

反模式清单:入口写 ret;不做 register write pc;用历史绝对地址或按 VMA 长度猜基址;长时间保持 block_exit=1(约 54 秒后触发 NON_SECURE_WDT);把 spin_p 当稳定成功方案。最后把根因指向 /proc/self/status 的 TracerPid 与 VM 自毁分支,通过对抗面处理后成功隐藏。

APK 重建

为什么要重建

com.xxbank.xbank 是抽取式 DEX 壳,已验证行为是:方法体被零字填充,但 code_off、insns_size 保持不变;真实指令要等类加载时才在运行时 DEX 里原地恢复。原包只有一个壳 stub classes.dex,直接丢给 jadx 等于空的,所以先把真实 DEX 从内存捞出来,再按 multidex 规则塞回去。

提取真实 DEX 与修 header

设备侧先起驱动、App 和 Frida server,再 attach 上去。

用 Frida 注入脚本扫描内存:遍历 r-- 权限段匹配 dex\n035\0 魔数定位 DEX,从 header 偏移 +0x20 读取 file_size,按该长度把整块内存 dump 出来。实测扫描 1064MB、命中 16 处、dump 15 个,其中 13 个是有效业务 DEX。

内存中的 DEX 已是运行时形态,但 signature 与 checksum 可能仍是壳写入的旧值,需按固定顺序重算:先写 signature = SHA-1(bytes[32:]),再写 checksum = adler32(bytes[12:])(后者覆盖包含 signature 的区域)。顺序不可颠倒,否则算出的校验值仍是错的。

重建初始 APK

重建不使用 jadx 或 apktool,而是自写的重建流程:删掉原 APK 全部 .dex 条目(原包只有壳 stub classes.dex),保留 manifest、resources、assets、lib,跳过 0 类 / 0 方法的占位 DEX,把非空 DEX 按体积降序写成 classes.dex 到 classes13.dex。初始基线判据三条:ZIP 条目 3556 → 3568、DEX 数 1 → 13、输出约 160 MB;达不到就停下。

apktool 3.0.3 与 jadx 1.5.6 在这里都不负责还原方法体。apktool 把重建 APK 的全部 DEX 输出成 smali(--no-res -f,历史 49,354 个),作为接近无损的指令与类覆盖基准,Invalid debug offset 只是调试信息告警。jadx 严格校验 DEX header,只能在前一步自检通过后跑,历史输出 24,132 个 Java、15,695 个类,它的 EXIT=3 和混淆告警不能单独判失败。交叉校验顶层类差集:全局 smali 有/JADX 无 160 个(0.69%,多为第三方或混淆类),目标包 com/xxbank 为 908 vs 908、差集 0。

待恢复方法 map 与批量回填

先扫描已修好 header 的磁盘 DEX,列出疑似被抽空的方法:

得到 5,285 项、1,885 类。nop_ratio 只是数 0x0000 的启发式,用于定位整片塌缩候选,不是最终「真 nop」数量。

批量恢复由 Frida 脚本驱动,每轮依次取三个时间点的快照:起步时的 t0、自然等待后的 t_nat、解析完该批类之后的 t_resolve。只有 t_resolve 那份能用于回填,缺失就重试,t0 和 t_nat 不能顶替。计划按 (dex_file, code_off) 合并成 plan_merged.json,再按 Lcom/xxbank 过滤出 plan_xxbank_only.json。回填工具为 fail-closed 实现:只接受新指令数不超过原 insns_size 的方法,遇到指令变长的直接拒绝、不做截断写入。写完会重新解析 DEX 校验一遍(每个 code_off 能定位、方法名与 insns_size 对得上、方法集合没变、改动的集合与计划一致),最后按正确顺序重算 signature 和 checksum。最后再跑一次重建脚本,得到 xxbank_restored.apk:13 个 DEX、3,568 条目、未签名,仅供静态分析。

验收与后续

回填后的可复算结果是:覆盖 5,285/5,285,方法集合 49,611 不变,code_off 集合不变,insns_size 零变化,计划范围内真 nop 从 211,229 降到 14,odex 与未定义 opcode 均为 0。

ptrace 工具与 Trace 采集

ptrace 工具

用 ptrace 把一个 native 函数从入口到出口的每条指令全记下来 —— 每条指令执行前的寄存器、栈和帧窗口一起存 —— 顺手把 AVM 状态、JNI 调用这些事件单独存一条流。出来就是一个原始证据包,跟踪本身不管语义。

为什么非做到指令这一层:壳把关键逻辑塞进自己的 OLLVM,静态反编译看到的是一堆查表跳转,看不出真实路径。只有运行时跟一遍,才知道它到底执行了什么。

实现上大概这么几层:

  • 挂上去用 PTRACE_SEIZE 加 TRACECLONE / TRACEFORK / TRACEVFORK,跟住所有线程。
  • 入口和出口各埋一条软件断点(首字节换成 brk),命中后由断点管理器恢复原指令、判断这是哪个角色;
  • 命中入口开始单步,目标区域里每条指令取一次寄存器落一条记录;走出区域就不再逐条记,只留 GAP 的起止和返回点;
  • AVM dispatch、JNI 调用这类模式记在另一条事件流里,存的是原始寄存器和必要窗口,语义留给离线解码;
  • 命中出口写上下文,然后安全 detach,驱动和进程按顺序收尾。

是个记录器而不是分析器:跟踪只管把证据记全、能复算,还原都是后面离线做的。

设备端执行顺序

  1. 卸载残留驱动、停止 App、清旧状态;
  2. 加载带对抗参数的驱动(不开 block_exit,用 patch 封闭自毁);
  3. 冷启动 App,2–3 轮读 RegisterNatives,按名称+签名精确匹配取到 fnPtr;
  4. 算 ENTRY=fnPtr、EXIT=ENTRY+fixed_offset,校验页内偏移与匿名可执行映射;
  5. patch 工具在一次 attach 中校验 code base、写入并回读 spin / keepg / brknop;
  6. 后台起 tracer 等 ENTRY,触发调用;
  7. 命中 EXIT 写上下文并安全 detach;
  8. 先卸载驱动,再停 App。

这一轮是否成功,看 collect.log 里有没有 tracer 退出码=0、[exit] reason=exit_reached,以及 entry_tid 与 exit_tid 是不是同一个 tid。

产物结构

产物落在 evidence/r115-<时间戳>-<标签>/。原始证据是三件:r115.bin(xxxxTRC3 指令流,带执行前寄存器与栈/帧窗口)、r115.bin.events.bin(xxxxEVT1 事件序列)、r115.bin.blob.bin(事件引用的大块内存)。另外有 maps/modules 快照、身份文件,以及 manifest.complete 这个"写入已 drain/fsync"的标志(它不等于整体 PASS)。

上下文、JNI 与 restoration 档位的产物按需出现;JSONL 都是派生视图,后续分析引用原始 .bin。

校验

采集完要交叉核对三件事:终止原因是不是 exit_reached(max_steps / timeout / signal 都不算函数跑完)、身份与原始格式对不对(独立 raw reader 与共享 reader 的记录数、seq、关键字段要一致,用同一个实现得出一致不算数)、以及复算出来的 coverage 与 restoration 准备度(restoration 档位要求十项准备度检查全 PASS,但那只说明材料备齐,不代表语义已还原)。最后落到唯一顶层判词 release.verdict.json。

动态路径代码还原

Java层被native化,并且被 OLLVM 保护起来的函数,且 OLLVM 保护的还是壳自定义解释器,被保护函数被翻译为自定义解释器的 opcode 执行。静态分析会非常困难,并且要完整拿到dump要跟壳完整的对抗,所以转为获取动态路径trace。

第一步:整理Trace数据

采集包里有三类原始材料:每一步执行前的机器指令和寄存器、AVM/JNI/缺口等运行时事件,以及用于确认文件完整性和进程身份的校验信息。第一步把它们按执行顺序合成一条时间线,并建立“某条指令对应哪些事件”“某个地址执行过几次”“一次 VM 调度从哪里开始、在哪里结束”等索引。

只有前后连续、且中间没有采集缺口的两条指令才会形成控制流关系;缺失区间单独记账,不根据事件线程反推指令线程。最终 76,314 条指令、3,913 个唯一指令地址、622 个 VM 调度窗口和 608 个缺口全部能够对上,没有无法解释的记录。这里确认的是数据完整性,还没有解释任何虚拟指令。

第二步:Native CFG 重建

执行时间线提供实际走过的指令地址和相邻跳转,运行时内存快照提供这些地址处的真实代码字节。两者逐字节核对后,用 AArch64 反汇编器识别顺序执行、条件跳转、函数调用、返回和间接跳转,再按真实执行关系切分基本块。

实际走过的边与“指令编码能够推出、但本轮没有执行”的候选边分开保存,采集缺口两侧也不会强行连接。3,913 个唯一地址全部成功反汇编,最后得到 480 个实际执行块、525 条实际执行边和 191 条静态候选边,动态记录与静态指令之间没有控制流冲突。产物是一张本轮真实执行的 native 路径图,而不是整个函数的所有可能路径。

第三步:划分 VM Handler

每次 VM 调度都会记录当前虚拟操作码、对应的 native 处理入口,以及这次处理覆盖的执行区间。把 622 个区间按操作码聚合后,可以看到不同处理器末尾反复经过同一组 native 指令;反汇编表明,这组指令负责读取下一条编码、解码、查处理器表并跳向下一个处理器,属于公共调度尾部。

公共调度尾部从各个处理器中剥离后,剩余部分就是虚拟指令自己的动态实现。622 个窗口都能落回实际指令范围,25 种本轮出现的操作码都有稳定入口。离开解释器进入普通 native 或 JNI 的区间单独保留,不会被误算成处理器本体。

第四步:重建 VM 控制流

每次调度被转换成一个 VM 节点,节点保存当前字节码位置、操作码、处理器和操作数;相邻两次调度形成一条实际执行边。这里的“字节码位置”是 VM 在数据区中的读取游标,不是 native 指令地址,操作数需要从同轮内存快照中按相对位置取出。

公共调度尾部还包含下一操作码的解码运算。把对应的 AArch64 位运算翻译成普通表达式后,再用实际相邻节点反向验证:预测 608 条普通边;剩余 12 条进入外部辅助函数,1 条结束解释器,均有明确分类。最终得到 622 个节点、621 条边组成的已执行 VM 路径,未执行分支仍然保留为未知。

第五步:识别虚拟指令语义

每种虚拟指令的输入来自处理器反汇编、执行前后的虚拟栈、寄存器和内存快照。处理器先被翻译成“从哪里取值、按多大宽度计算、写回哪里、怎样改变栈顶和下一字节码位置”,再用下一次调度看到的状态复算结果。比较条件、符号扩展和数据宽度都以真实 AArch64 指令为准,不按操作码数字猜名字。

例如,“比较两个 64 位值后按无符号小于写入布尔结果”,同时得到比较指令、条件码、栈变化和下一状态四方面验证;相对跳转则同时满足立即数符号扩展和实际目标地址。最终 622 次调度的操作码、处理入口和字节码位置全部一致,608 次栈变化、151 个可直接计算的结果和 72 次外部内存取值全部复算成功,本轮出现的 25 种操作码均得到确定语义。

第六步:整理为 C 解释器

确认后的操作码表被整理成一个普通的 C 状态机:状态中保留字节码游标、虚拟栈、虚拟寄存器和几组运行时内存基址,每种操作码对应一个 switch 分支。32/64 位宽度、16/24 位符号扩展、栈顶移动和跳转公式全部直接来自上一层已经复算通过的语义;访问进程内存和调用外部辅助函数则通过独立接口表示。

每个分支的输入、输出和副作用都能回到对应的 native 处理器和下一 VM 状态,25 个分支与本轮 25 种操作码一一对应。当前还缺完整的初始内存镜像,以及部分间接调用最终落到的业务函数,因此能够准确表达已执行指令的含义,但还不能把 622 步从头到尾完全离线重放,C 结果为 PARTIAL。

第七步:还原普通 Java 结构

DEX 中的 native 方法声明和运行时注册记录,可以把 isAntiUrl(String): boolean 精确绑定到这条 trace。JNI 事件又显示,这条路径读取了 URLConstants 的一个静态对象,并与 List、Iterator 发生对象调用;VM 控制流则给出了循环、条件判断和布尔返回的结构。

这些材料可以还原出下面的 Java 骨架:

private static final boolean isAntiUrl(String url) {
    for (Object item : unresolvedUrlConstantsList()) {
        if (unresolvedPredicate(url, item)) return true;
    }
    return false;
}

运行时记录没有把两个对象调用解析到精确的方法名,也没有拿到最终布尔谓词的类、签名和参数顺序。因此列表字段、比较函数、异常行为以及空值分支都保持未知,这段代码只表示已经确认的控制结构,不是等价源码,只能推断语义相同。

posted @ 2026-09-23 20:40  ylc0x01  阅读(8)  评论(0)    收藏  举报