-
-
加固分析
-
发表于: 1天前 470
-
由于时间和水平有限,本文会存在诸多不足,希望得到您的及时反馈与指正,多谢!
某加固的so分析,是32位的,so见附件。
整个流程可以理解为:.init_proc 解密自身 → JNI_OnLoad 搭环境 → 检测调试/注入 → 解密并注入 Dex → 装 IO Hook 做运行时透明解密。
这篇文章按执行顺序,从 .init_proc 开始,把整个壳从头到尾拆一遍。
先看全局
从 so 被加载到 JNI_OnLoad 返回,整个执行链大概是这样的:
| 阶段 | 地址范围 | 目的 |
|---|---|---|
.init_proc(ELF 构造函数) |
linker 自动调用 | 解密 so 内部加密数据段,还原 JNI_OnLoad 的真实代码和配置 |
| JNI_OnLoad 前置初始化 | 0x28ACC → 0x2A35A |
8 步初始化:校验 JNI、注册方法、解码数据、分配上下文、探测版本、解析路径 |
检测链 sub_2A790 |
在 0x2A35A 处被调用 |
反调试检测 + Dex 注入,不通过就直接杀进程 |
防护引擎 sub_32AC0 |
被 sub_2A790 拉起来 |
IO Hook 全家桶:路径处理、完整性校验、装 Hook、Asset 重定向 |
| 收尾 | 0x2A35E → 0x2A712 |
ARM 装 syscall Hook / x86 修 ELF 头,返回 JNI_VERSION_1_4 |
接下来按执行顺序,从 .init_proc 开始往下拆。
.init_proc:一切的起点
当 System.loadLibrary("DexHelper") 触发 so 加载时,linker 会先执行 ELF 的 .init_proc(也就是 DT_INIT 指定的构造函数),这一步发生在 JNI_OnLoad 之前。.init_proc 干的核心事情就是解密 so 自身的数据。如果你直接在 IDA 里静态看这个 so,会发现很多数据段和代码段的内容是乱的——那是因为它们在磁盘上是加密状态,只有 .init_proc 跑完之后才会被还原成真正的代码和数据。
这里用的是frida_dump——在运行时通过 Frida 把 .init_proc 解密后的 so 从内存里 dump 出来,再拿去 IDA 分析。这时候看到的 JNI_OnLoad 才是真正的逻辑。
JNI_OnLoad 的 8 步初始化
Step 1:入口判断
上来先 vm->GetEnv 拿 JNIEnv,拿不到就返回 -1,bye。然后有意思的是,它会读 ELF 的 e_machine 字段——如果发现是 x86,只注册一个简化方法 sl 就直接返回了。也就是说模拟器上这个壳基本不干活,只有 ARM/ARM64 才走完整防护流程。
Step 2:注册 JNI 方法
往 com/secneo/apkwrapper/H 这个类上绑了 6 个 native 方法,方法名全是混淆的:gha、ghc、gah、sha、he、gv。然后用了个比较骚的方式——通过 vtable 偏移间接调 GetJavaVM,把 JavaVM 指针缓存到全局变量 off_9F008。后面跨线程操作要靠这个。
Step 3:数据段写权限
读 /proc/self/maps 找目标数据段,没写权限就 mprotect 开一下。
Step 4:XOR 0xAC 解码
so 内部塞了一段加密数据,解码方式简单粗暴:每字节 XOR 0xAC。解出来按 token 分发——类型 5 是数值,6/7/8 是字符串。结果填到各种全局槽位里:函数指针、字符串指针、配置值。
另外它还会探测 ART 有没有 JNIException::~JNIException 这个符号,有的话就能拿到 ArtMethod::data_offset
Step 5:全局上下文
malloc 一个 1096 字节的结构体,魔数 0x6D6D21。里面塞了混淆类名、包名、SDK 版本号。
字符串去混淆:先取偶数位字节,然后按 [1,3,2] 循环步长逐个减。SDK Preview 版本号会多 1,专门做了修正。
Step 6:ART 版本探测(Android 12+)SDK ≥ 31 才走这段。先不走 Java API,直接尝试打开 APEX 包读 AndroidManifest.xml 的 versionCode。拿不到才退回反射调 getPackageInfo。拿版本号是为了算 ArtMethod、Thread、ClassLinker 等内部结构的字段偏移,后面 Dex 注入要用。
Step 7:VM 类型 + 路径解析
判断 Dalvik 还是 ART :判断加载了 libdvm.so 还是 libart.so。三星 API25 和 YunOS 有专门的分支处理。
路径解析:从 ApplicationInfo 读 sourceDir,但它不完全信这个值——还会遍历 fd 5 到 127,用 readlink 去 /proc/pid/fd/N 找真正在用的 APK 路径。找到后建 .cache 工作目录,再校验 /proc/self/cmdline 里的进程名。
Step 8:注册桥接方法
最后给 AW 类注册 hn(配置注入)和 pn(收尾),给 H 类注册 d(运行时字符串解密入口)。到这里 Java ↔ Native 的桥就搭好了。
检测链:sub_2A790
这是整个壳检测部分的部分,如果检测没有通过,直接杀死进程
检测全景
| 检测 | 方式 | 错误码 |
|---|---|---|
| libc 是否被 patch | sub_38FC0 读 pthread_create 函数头 16 字节比对 |
0xB6A001DD |
| BlackDex | 直接看 /data/data/top.niunaijun.blackdexa/ 存不存在 |
0xB6A002DD |
| Frida | 遍历 /proc/self/task/*/status,找 gum-js-loop、gmain、gdbus 线程名 |
0xB6A080FF |
| linjector 注入 | 遍历 /proc/self/fd,readlink 看有没有指向 linjector 的 |
0xB6A080FF |
| APK 路径 | sub_3FBB4 路径白名单校验 |
0xB6A0822D |
| 签名 | SigBlock 快速校验 + PackageInfo.signatures |
0xB6A0822F |
| Dex 注入后签名 | 二次交叉校验 | 0xB6A0822E |
Frida 检测:
- 线程名扫描:打开
/proc/self/task目录,枚举所有 TID,逐个读status文件里的Name:字段。Frida 注入后会多出gum-js-loop(JS 运行时主循环)、gmain(GLib 事件循环)、gdbus(D-Bus 通信)这些线程。 - fd 扫描:遍历
/proc/self/fd,对每个 fd 做readlink,看链接目标有没有linjector这样的注入工具特征。 - 字符串混淆:检测关键词不是明文存在 so 里的。它有个专门的解码函数
sub_3913C,算法是buf[i] += ~(i % 3),也就是按(-1, -2, -3)循环偏移。比如"hwp.lv.nrpr"解码后才是"gum-js-loop"。
绕过代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | const KEYWORD_PATCHES = [ { kw: 'gum-js-loop', rep: 'no-frida-thr' }, { kw: 'gmain', rep: 'dummy' }, { kw: 'gdbus', rep: 'dummy' }, { kw: 'linjector', rep: 'kworker00' }, { kw: 'frida', rep: 'xxxxx' },];Interceptor.attach(sub_3913C_addr, { onEnter(args) { this.buf = args[0]; this.len = args[1].toInt32(); }, onLeave(_ret) { if (!this.buf || this.len <= 0) return; try { const decoded = this.buf.readCString(); if (!decoded) return; for (const { kw, rep } of KEYWORD_PATCHES) { const idx = decoded.indexOf(kw); if (idx >= 0) { const padded = rep.padEnd(kw.length, '\0').substring(0, kw.length); Memory.writeUtf8String(this.buf.add(idx), padded); } } } catch (_) {} }}); |
替换字符串必须等长,因为解码是原地操作,写多了会导致栈溢出。sub_2A790 内部用 sub_24060(path, 0x80000) 打开 status 文件时带了 O_CLOEXEC,可能走的直接 svc syscall 绕过了 libc,所以还得在 read 层面做盲过滤:不管读的是什么文件,只要内容里出现 Frida 线程名就替换掉。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | Interceptor.attach(Module.findExportByName('libc.so', 'read'), { onEnter(args) { this.buf = args[1]; this.size = args[2].toInt32(); }, onLeave(ret) { const n = ret.toInt32(); if (n <= 0 || n > 4096) return; try { const content = this.buf.readCString(n); if (!content) return; let modified = content; let dirty = false; for (const sig of FRIDA_THREAD_SIGNATURES) { if (modified.includes(sig)) { modified = modified.split(sig).join('kworker'); dirty = true; } } if (dirty) { const encoded = Memory.allocUtf8String(modified); Memory.copy(this.buf, encoded, modified.length); ret.replace(ptr(modified.length)); } } catch (_) {} }}); |
libc 完整性检测
sub_38FC0 做的事情很精确:通过 dlsym 拿到 pthread_create 的地址,读函数开头 16 字节,和预期的原始指令逐字节比对。如果你用 Frida 做了 inline hook,函数头必然被改成了跳板指令,这一步就会挂掉。
逆一下调用方的汇编可以确认返回值语义:R0 == 1 时触发 kill(检测到异常),R0 != 1 放行。所以绕过方式就是强制让它返回 0:
1 2 3 4 5 | Interceptor.attach(sub_38FC0, { onLeave(ret) { if (ret.toInt32() !== 0) ret.replace(ptr(0)); }}); |
杀进程
不是调 exit() 或者 kill(),而是把 sp 和 lr 清零,然后跳到 0x97c。
逆出来它的签名是 void loc_27FA0(int flag, int errorCode, int mask),比如线程名检测命中时调的是 loc_27FA0(256, 0xB6A080FF, 4095)。
因为它内部会主动破坏栈帧,用 Interceptor.attach 的 onEnter 返回后原始代码照跑,还是会崩。所以必须用 Interceptor.replace 完全接管:
1 2 3 4 5 6 | Interceptor.replace(loc_27FA0, new NativeCallback( function (flag, errorCode, mask) { // 直接 return,不执行 sp=0 的 kill 逻辑 }, 'void', ['int', 'int', 'int'])); |
策略位控制
检测链不是无脑全开的,配置里有几个 bit 控制:
- bit12 & bit4 → 启动异步 worker
- bit3 & bit2 → 装 Android log hook
- bit3 → 深度反调试:ptrace attach/cont/detach 监控链 + 双 pipe fork 守护进程
ptrace :壳自己 fork 出子进程然后PTRACE_ATTACH占住位置,其他 debugger 就没法再 attach 了。
Dex 注入:
检测全过了以后,就到了核心功能——把加密的 Dex 解出来注入虚拟机。
解密流程
- 找到 dexdata0:依次尝试 DexCache → mCookie → 反射 → 缓存文件 → 最后从 so 符号表里扒。兜底链路做得很全。
- 校验 dex 头尾,确认拿到的是对的。
- 分块解密:16 字节一块做变换,key 是动态派生的,还搞了元数据回溯和索引挂链。不是简单的 XOR 能搞定的。
注入虚拟机
这里分两条路线:
Dalvik(老设备):
- 调
loadDex加载解密后的 Dex - 回填 mCookie
- 扩容
dexElements数组
ART(主流): - 直接给
VerifyClass和IsVerificationEnabled打 patch,把类验证关掉 - 这样注入的 Dex 即使签名不对也能跑
挂到 ClassLoader
- SDK ≤ 25:简单粗暴,直接替换
pathList.dexElements - SDK > 25:扩展
base.apk的 mCookie,用sub_43204做修正
可选地还会 hookOpenDexFilesFromOat、OpenMemory、Open来拦截 ART 的 Dex 加载流程。
Dex 注入完之后,还要再做一次签名校验(错误码0xB6A0822E),防止有人在注入过程中做了手脚。
sub_32AC0:
这个函数负责运行时的 IO 透明解密。
路径和完整性
先拼出 .cache 目录和 classes.dve 缓存路径。对目标文件算 MD5(16 字节),和缓存里的 24 字节校验块比较。不一致就清缓存重建。
资源规则解密
从 APK 的 assets/RES_RULE 读出规则文件,用 11 字节周期的 XOR key 解密。解出来的规则决定了哪些文件需要运行时解密。
RC4 密钥准备
密钥生成分两步:
- 前 16 字节:
dword_90E18⊕dword_9F0B8 - 后 16 字节:经过
dword_912CC置换表变换
最终凑成 32 字节的 RC4 KSA key,后面 IO Hook 解密用这个。
IO Hook
对 libc 做 inline hook,实现运行时透明解密:
| 被 Hook 的函数 | 替换函数地址 | 目的 |
|---|---|---|
__open / __openat |
0x37FB0 / 0x38044 |
拦截 trace_marker 打开;把 fd 注册到跟踪表 |
mmap64 / mmap |
0x380D8 / 0x38428 |
映射命中加密区间就 RC4 解密;大块(>0x20000)降级为 XOR 0xAA |
close |
0x38728 |
从跟踪表删 fd,调原始 close |
read |
对应 hook | 被跟踪 fd 的数据做缓冲拷贝 + 变换 |
write / pread64 / pwrite64 |
对应 hook | 被跟踪 fd 做变换;trace_marker 放过 |
打开文件时记录 fd,读写时自动解密,关闭时清理。应用层完全感知不到文件是加密的。
mmap 的处理:小块用 RC4,但超过 0x20000(128KB)的大块为了性能退化成简单的 XOR 0xAA。
厂商适配
- 三星(标记 1)和小米 SDK>33(标记 2):额外注册
libbinder、libutils、libcutils、libartbase的规则 - Pixelbook / 展锐平台:通过扫 ELF 符号表定位
__close来做 inline hook - SDK 19~23 华为/荣耀:有个 SharedPreferences 热修复,反射拿
ContextImpl.sSharedPrefs强制重新加载 - Perfetto HPROF(SDK ≥ 30):hook 掉
libperfetto_hprof.so把 signal pipe 关了
Asset 重定向
从 APK 抽出 assets/baoef 放到 .cache/assets,然后按 SDK 版本 hook 不同入口:
- SDK ≥ 30 →
ZipAssetsProvider::OpenInternal - SDK 28~29 →
ApkAssets::Open - SDK < 28 →
AssetManager::open
创建内存容器对象,命中时把读请求重定向过去。
JNI_OnLoad 收尾
ARM
- 在 libc 的代码段里扫描 ARM 指令特征(
0xE1A0C007这种),从 r7 赋值指令里提取 syscall 编号 - 定位到
open、read、mmap2的 syscall 桩地址 - 对这三个 syscall 装拦截 Hook,原函数指针存下来做蹦床
这一步直接 hook 的是 syscall 入口。
x86
找到 libDexHelper.so 的映射位置,mprotect 开写权限,往头部写 3 个 dword 把故意破坏的 ELF 头修回来。防止直接 readelf 或者加载分析。
最后一步
不管 ARM 还是 x86,最终都收到 loc_2A64A,返回 JNI_VERSION_1_4(0x10004)。JNI_OnLoad 执行完毕,壳正式生效。
脚本这边需要处理 so 加载时序——libDexHelper.so 通过 android_dlopen_ext 加载,内部函数级 hook 必须等 dlopen 返回后才能装:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | let dexHelperInternalsHooked = false;function hookDexHelperInternals(modOverride) { if (dexHelperInternalsHooked) return true; const mod = modOverride || findModule('DexHelper'); if (!mod) return false; const sub_38FC0 = resolveThumbAddress(mod, 0x38FC0, 'sub_38FC0'); const sub_3913C_addr = resolveThumbAddress(mod, 0x3913C, 'sub_3913C'); const sub_3FBB4 = resolveThumbAddress(mod, 0x3FBB4, 'sub_3FBB4'); const loc_27FA0 = resolveThumbAddress(mod, 0x27FA0, 'loc_27FA0'); if (!sub_38FC0 || !sub_3913C_addr || !sub_3FBB4 || !loc_27FA0) return false; // 在这里装上面提到的各个 Hook ... dexHelperInternalsHooked = true; return true;}if (!hookDexHelperInternals()) { Interceptor.attach( Module.findExportByName(null, 'android_dlopen_ext'), { onEnter(args) { try { this.soName = args[0].readUtf8String(); } catch (_) { this.soName = ''; } }, onLeave(ret) { if (!ret.isNull() && this.soName && this.soName.includes('DexHelper')) { hookDexHelperInternals( findLoadedModuleFromPath(this.soName, 'DexHelper') ); } } } );} |
注意所有偏移都是 ARM Thumb 指令集的,resolveThumbAddress 做了 +1 处理(Interwork 标记 bit0=1)。
关键全局变量
| 符号 | 用途 |
|---|---|
off_A80F8 |
全局上下文,1096 字节结构体 |
off_9F008 |
JavaVM 指针缓存 |
off_9F018 |
SDK 版本号 |
off_9F024 |
配置字节数组,控制防护开关 |
off_9EFD4 |
防护配置对象(C++ vtable) |
off_A824C / A8250 / A8254 |
原始 open / read / mmap2 函数指针 |
dword_A8348 |
32 字节 RC4 key 材料 |
off_9F0B4 |
APK meta-data 条目区间数组 |
dword_A80F0 / A80F4 / A81B8 |
内存 AssetProvider 容器指针 |
总结
整个壳分四层防护,从外到内:
反调试 — 查 libc 完整性、扫 Frida 线程名和 fd、检测 BlackDex 安装路径、ptrace 占位 + fork 守护进程
签名校验 — APK SigBlock 快速校验 + PackageInfo.signatures 兜底,Dex 注入完还要交叉验一次
Dex 保护 — 数据段 XOR 0xAC 解码 → 16B 分块变换 + 动态 key → Dalvik 走 loadDex / ART 关验证 → 挂到 dexElements
IO 透明解密 — Hook open/read/mmap2 syscall;RC4 或 XOR 0xAA 运行时解密;Asset 读取内存容器重定向
厂商适配覆盖了三星、小米、华为、展锐、Pixelbook;SDK 版本从 19 覆盖到 33+;Dalvik 和 ART 都有对应路径。反调试不是玩具级别的字符串匹配,而是深入到 /proc 文件系统和 syscall 层面。
参考文章
https://bbs.kanxue.com/thread-273614-1.htm
https://bbs.kanxue.com/thread-280302-1.htm