由于时间和水平有限,本文会存在诸多不足,希望得到您的及时反馈与指正,多谢!
某加固的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) {
},
'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 做修正
可选地还会 hook OpenDexFilesFromOat、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;
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