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 核,一共五处:
0xB2470check_version入口 →MOV W0,#1,0xB2474UBFIZ→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,驱动和进程按顺序收尾。
是个记录器而不是分析器:跟踪只管把证据记全、能复算,还原都是后面离线做的。
设备端执行顺序
- 卸载残留驱动、停止 App、清旧状态;
- 加载带对抗参数的驱动(不开
block_exit,用 patch 封闭自毁); - 冷启动 App,2–3 轮读
RegisterNatives,按名称+签名精确匹配取到fnPtr; - 算
ENTRY=fnPtr、EXIT=ENTRY+fixed_offset,校验页内偏移与匿名可执行映射; - patch 工具在一次 attach 中校验 code base、写入并回读
spin/keepg/brknop; - 后台起 tracer 等 ENTRY,触发调用;
- 命中 EXIT 写上下文并安全 detach;
- 先卸载驱动,再停 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;
}
运行时记录没有把两个对象调用解析到精确的方法名,也没有拿到最终布尔谓词的类、签名和参数顺序。因此列表字段、比较函数、异常行为以及空值分支都保持未知,这段代码只表示已经确认的控制结构,不是等价源码,只能推断语义相同。

浙公网安备 33010602011771号