首页
社区
课程
招聘
[原创]爱加密 v4 加固逆向:从 Frida 失效到完全离线解密脱壳
发表于: 15小时前 400

[原创]爱加密 v4 加固逆向:从 Frida 失效到完全离线解密脱壳

15小时前
400

由于太懒好久没写文章了,为了提高下语言能力,并且前一段时间刚好弄了一个用爱加密(ijiami)v4 商业加固的 APK,做了一次完整的静态分析。常规思路是先用 Frida 脱壳再反编译,结果在这个 App 上接连碰壁——Frida 注入 3 秒必死。

使用objection+frida-dexdump方案可以进行脱壳(实测可行),但是拿到的dex文件中的内容比较少,不过这个并不是重点,重点是想了解这里面到底发生了什么,带着猎奇的心理探索一下。

失败本身不是问题,问题是搞清楚为什么失败,以及如何绕过。这篇文章记录了从最初的 Frida 尝试,到最终实现完全不依赖动态执行的离线 DEX 提取的完整过程,包括踩过的坑和得出每个结论的依据。


环境:小米 Android 12(arm64-v8a),KernelSU,IDA Pro 9.0,Python 3.9(够用)。


解压 APK 后看到的文件结构:


classes.dex          13 KB   ← 异常小

assets/ijiami.dat    14 MB   ← 高熵载荷

assets/ijiami.ajm     6.2 MB  ← magic: "indl01"

lib/arm64-v8a/libexec.so      622 KB

lib/arm64-v8a/libexecmain.so  153 KB


classes.dex只有 13KB,反编译一看只有 4 个混淆类:


s/h/e/l/l/A   → Application 代理,attachBaseContext 入口

s/h/e/l/l/C   → ClassLoader 管理

s/h/e/l/l/N   → Native 加载 / 环境检测

s/h/e/l/l/S   → 签名校验


libexec.so 内有字符串 "ijiami SecLLVM compiler 1.7.4.20",爱加密 v4 无疑。业务代码全在加密的 "ijiami.dat" 里。



尝试一:frida-dexdump


frida-dexdump -n com.szzc -d


静默退出,无任何输出。原因:App 的进程名是应用名(中文),不是包名,"get_all_process()" 匹配失败。改成 spawn 模式同样无效,fork 前后 PID 不一致导致进程名仍然匹配不上。


尝试二:自定义 Frida 脚本 + spawn


写了含 TracerPID bypass 的注入脚本,spawn 启动 App。结果:"Java.perform"回调从未触发,进程在 resume 后约 3 秒内自杀


关键发现:"libexec.so" 在 "JNI_OnLoad" 极早期就执行 Frida 检测,此时 Frida JS runtime 都还没初始化完,注入完全来不及。


尝试三:重命名 frida-server


cp /data/local/tmp/frida-server /data/local/tmp/debugd

/data/local/tmp/debugd &


App 存活时间延长到了 30 秒以上(进程名检测绕过成功),但 attach 阶段仍然死亡。


抓 logcat 观察:死亡前有 "strstr" 调用扫描 "/proc/self/maps",找到 "frida-agent.so" 的路径字符串后触发 "kill(getpid(), SIGKILL)"


三次失败的共同根因:ijiami 的 Frida 检测在 JNI_OnLoad 内极早期触发,spawn 模式根本来不及注入任何 bypass

既然注入不进去,换个角度:不注入,让 App 自己正常运行完成解密,然后我们去读它的内存。


原理:ijiami 解密完成后,DEX 以明文驻留在 "[anon:dalvik-DEX data]"内存区段。有 root 权限就能直读 "/proc/pid/mem",完全不需要注入任何东西。


1. 正常启动 App

adb shell monkey -p com.szzc -c android.intent.category.LAUNCHER 1


2. 等 20 秒(ijiami 解密需要时间)


3. 找 dalvik-DEX data 区段

adb shell su -c "grep 'dalvik-DEX' /proc/$(pidof com.szzc)/maps"


输出示例:


7dc1e74000-7dc2250000 r--p 00000000 00:00 0  [anon:dalvik-DEX data]

7dc25a7000-7dc2c44000 r--p 00000000 00:00 0  [anon:dalvik-DEX data]

...(共 9 条)


对每个区段用 "dd" 读取内存,扫描 "dex\n" magic,得到 9 个 DEX 文件,共约 30MB。


这步成功了,但拿到的 DEX 含运行时页对齐填充,大小不精确。更重要的是——我想搞清楚 ijiami 的加密机制,实现仅凭 APK + 缓存文件就能离线还原,不依赖跑 App。


于是开始逆 "libexec.so"

"libexec.so"丢进 IDA,立刻发现 "DT_INIT"(偏移 "0x84BF8")不是常规业务代码,而是一个解压器。

解压参数表在 "0x84BE4",5 个 uint32:


[0x84BE4] = 0x00031E24   压缩 block header 基地址

[0x84BE8] = 0x00084BE8   自引用(运行时计算 ASLR slide)

[0x84BEC] = 0x000617B8   页对齐参数

[0x84BF0] = 0x000D7FF0   解压总输出大小(= 0xA8000 字节)

[0x84BF4] = 0x00031E0C   压缩数据起始偏移


ASLR base 计算:"slide = runtime_addr(0x84BE8) - 0x84BE8"


解压流程

1. "sub_84E0C":读参数表,准备 src/dst/len

2. "sub_84EE0":调用 "sub_852CC"(NRV2B 核心)解压两个 block

3. "sub_85220":修正解压后代码内的 BL 指令偏移

4. "mmap(base+0x30000, 0xA8000, PROT_RW, MAP_ANON|MAP_FIXED)":用匿名映射覆盖 "0x30000" 处的原始文件映射

5. "blr x4":跳转到内层代码新的 DT_INIT


这是标准的"外层 stub 解压内层代码并覆盖自身"做法,NRV2B 的 get_bit 核心:


-->asm<--

get_bit:

adds w4, w4, w4    ; 左移,MSB → carry(提取的位)

cbz  w4, reload

ret

reload:

ldr  w4, [x0], #4  ; 加载 32-bit LE 字,src += 4

adcs w4, w4, w4    ; 左移 + carry_in;新 carry = 字的 MSB

ret


初始状态 "w4 = 0x80000000"(哨兵,首次调用立即 reload),经典 UCL NRV2B 实现。


-->gap code 问题<--

内层代码占 vaddr "0x30000""0xD8000"(0xA8000 字节),但 IDA 静态分析只能看到 "0x00000""0x852DC",从 "0x852DC" 往后的 0x52D24 字节("gap code")在原始文件里是压缩态,IDA 看不到。包括关键的内层解密函数都在这个区域。


获取 gap code 的方法:App 运行时从 "/proc/pid/mem" 读取 "libexec.so" 运行时 base + "0x852DC" 处的数据,保存为 "gap_code.bin",再在 IDA 用"load additional binary"加载到对应 vaddr。

在开始写解密脚本之前,最重要的一步是把调用链搞清楚。这也是整个分析里最容易出错的地方

通过 IDA 逐函数追踪,最终确认的管道:


ijiami.dat(14 MB,压缩态)

 |  sub_68AE8:

├─ skip 40 字节 header

├─ deflate 解压(14MB → 55MB)

├─ sub_6A52C:外层 XOR 去混淆

└─ 结果写入设备:/data/data/<pkg>/files/ia<hash>(55MB 缓存文件)

 | sub_68634(外层 DEX 加载):

├─ 读取缓存文件 [40:](55,099,928 字节)

├─ sub_6AC24:内层 S-box 解密

└─ 读尾部索引表 → 按条目提取各 DEX


关键陷阱:我最初以为 "sub_6A52C" 在 "sub_68634" 里调用,反复对 "dec_szzc.bin" 前后各再做一遍外层 XOR 都拿不到正确结果,白耗了不少时间。IDA 反编译确认后才发现它在 "sub_68AE8" 内部、解压之后调用。这个顺序搞错,整个管道就错了。

"sub_6A52C" 的反编译相对干净,算法是纯 XOR,密钥由包名推导:


-->python<--

import hashlib


EAEC0 = bytes([

   0x37,0x1f,0x01,0x75,0xf1,0xbf,0x01,0x80,

   0x54,0x58,0x66,0x9a,0x4d,0x39,0xda,0xcf,

   0x8b,0xb0,0x43,0xf4,0x06,0xa0,0x66,0x7f,

   0xc4,0xe8,0xbf,0xf9,0x11,0x07,

])


v4  = hashlib.md5(b"com.szzc_IJM_2019").hexdigest().encode()

v40 = bytearray(v4[i+3] ^ EAEC0[i+1] for i in range(27))


作用域只有首尾各约 1024 字节,中间的 ~14.9MB 完全不动:


-->python<--

xpos = [0, 2, 4, 6, 8, 9, 11, 13, 15]

xk   = [v40[1],v40[3],v40[5],v40[7],v40[9],v40[10],v40[13],v40[15],v40[17]]


# 前 1039 字节

for i in range(15):

   buf[i] ^= v40[i+1]

for i in range(64):

   base = 15 + i*16

   for pi, xi in zip(xpos, xk):

       buf[base+pi] ^= xi


# 后 1024 字节

base_last = len(buf) - 1024

for i in range(64):

   base = base_last + i*16

   for pi, xi in zip(xpos, xk):

       buf[base+pi] ^= xi



[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 15小时前 被奋斗的小趴菜编辑 ,原因:
收藏
免费 24
支持
分享
最新回复 (8)
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
666666
13小时前
0
雪    币: 4136
活跃值: (6767)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
3
感谢分享
12小时前
0
雪    币: 3626
活跃值: (4729)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
过来瞧瞧
12小时前
0
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5
tql
11小时前
0
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
6
赞美大佬
11小时前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7
666
10小时前
0
雪    币: 67
活跃值: (115)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
感谢分享
9小时前
0
雪    币: 158
活跃值: (2211)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9
感谢分享
9小时前
0
游客
登录 | 注册 方可回帖
返回