一、背景 & 场景
1.1 环境
样本:某汽车 App(包名 com.xiaopeng.mycarinfo),网易易盾(libnesec.so + com.netease.nis.wrapper)加固,Android arm64
工具链:IDA Pro 9.2、调试器(linker / art 下断)、JADX
1.2 加固的本质,与不同视角
聊脱壳之前,先说点务虚的——加固到底在保护什么。
代码安全只是表面,核心是帮客户满足”业务不被阻断、关键数据资产不被窃取”的安全需求。加固自身不创造价值,它的价值必须挂在业务上间接体现:通过安全体系为业务保驾护航,增加收益、降低资损率。
换个立场看 App 加固,每一面看到的东西都不一样:
- 用户视角:只关注功能和交互,对 C 端用户而言加固几乎无感;
- 产品视角:关注需求、方案、价值;
- 技术视角:关注架构、实现、可扩展性与安全性,能感知加固的重要性,但更多从技术实现出发;
- 业务视角:保障业务正常运营,看到的是成本和收益。
视角不同,决策不同,安全程度也不同。有了这层铺垫,下面这套”看起来有点绕”的加固方案,你大概就不会觉得奇怪了——它是计划的一部分。
本文以一个汽车 App 的易盾壳为样本,完整走一遍脱壳:壳的 Java 层 → SO 静态分析(入口怎么藏的)→ SO 动态分析(自定义 linker、SMC、抹 ELF 头)→ DEX 解密与脱壳点 → dump 二次打包。文中涉及的包名、密钥等均为逆向实测结果,仅供安全研究学习。
本文仅限学习交流与防御研究,请勿用于非法及商业用途。
二、加固整体架构
整套壳是经典的 dex 整体加固 + Native 自定义 linker 保护 结构:

分三层看:
- Java 层:替换 App 入口、读取壳 SO、判断目标平台、加载壳 SO;
- Native 层(
libnesec.so):壳入口(init_array/ 解密代码 / 自定义 linker 加载 SO)、初始化(JNI_OnLoad/ SMC 执行代码 / 注册 native)、反调试(IDA 端口 / 脱壳工具 / 进程状态 / root·模拟器); - Load Dex:解密 dex、加载 dex、还原原 App 入口。
解壳过程
壳跑起来后,干的就是”解密原始 dex + 启动加载原 App Application”这一串活:
- 解密原 apk 的 dex 集合——用加密时对应的算法逐个解密 dex;
- 把解密后的 dex 加进
dexElements数组——通过反射塞进去; - 动态加载原 apk 的 Application——加密时原 Application 被替换成了壳的 Application,加载过程中要把这步还原回去。
三、壳 Java 层分析
3.1 attachBaseContext
SO 的释放与加载发生在进程创建、加载 Application 之前:在 attachBaseContext 里解压释放 SO 到 lib 目录,System.loadLibrary("nesec") 加载进内存,并在 MyJni 类里注册了几个 native 方法:
public static native void cp();
public static native void d(String arg0);
public static native void e(String arg0);
public static native boolean load(Application arg0, String arg1);
public static native boolean load2(Application arg0, String arg1);
public static native boolean run(Context arg0, Application arg1);
3.2 native load 加载 dex
加载完 SO 后调用 load:解密 dex 并加载到内存——dex 文件解密 + 把解密后的 dex 集合塞进 dexElements 数组。
3.3 还原原 Application
dex 加载好后,要从原 App 的 Application 跑起来。壳里存了原 Application 的类名,反射创建并执行它:
static {
MyApplication.strAppName = "com.netease.nis.wrapper.MyApplication";
...
}
private static Application a(Context ctx) {
ClassLoader cl = ctx.getClassLoader();
Class c = cl.loadClass(MyApplication.strAppName); // 加载原 Application
MyApplication.newApp = (Application) c.newInstance(); // 创建实例
return MyApplication.newApp;
}
具体流程:
Class.newInstance()创建一个 Application 实例;- 反射拿到
Application.attach(Context)并invoke执行:
Application app = MyApplication.a(ctx);
Method attach = Application.class.getDeclaredMethod("attach", Context.class);
attach.setAccessible(true);
attach.invoke(app, ctx); // 让原 App 真正"接管"
到这里壳就把控制权交还给了原 App,对上层完全透明。
四、壳 SO 静态分析
按惯例,定位到壳 SO 后先用 IDA 静态收集信息(字符串、入口、导出方法)。但这个壳一上来就给你三连击:
① 加载直接报错——节信息被处理过,专门用来反静态反编译:

② 导出函数一堆乱码——导出表被加密了:

③ 找不到 init_array 节——连入口节都被抹了:

静态这条路基本被堵死。结论很简单:静态不好定位就动态定位——内存里再怎么藏,也跳不出系统的加载机制。
五、壳 SO 动态分析
5.1 壳入口定位
按 linker 加载 SO 的流程,有两个点可以当壳入口:init 和 init_array 是 SO 代码能执行的最早时机,之后才轮到 JNI_OnLoad。只要在 linker 执行 init 的地方下断,就能逮到壳入口:

定位到的 init_array 里有几个关键点,其中 init_array[3] 负责解密 JNI_OnLoad 的代码:

解密例程是个标准的 RC4 风格 KSA/PRGA 变体(256 字节 S 盒交换 + 取流 + 一点魔改):
dec_sub:
loop:
ADD W6, W6, #1 ; AND W6, W6, #0xFF ; i = (i+1) & 0xFF
LDRB W3, [X0,X4] ; S[i]
ADD W5, W3, W5 ; AND W5, W5, #0xFF ; j = (j+S[i]) & 0xFF
LDRB W8, [X0,X7] ; S[j]
STRB W8, [X0,X4] ; STRB W3, [X0,X7] ; swap S[i],S[j]
ADD W4, W3, W4 ; UXTB W4, W4 ; t = (S[i]+S[j]) & 0xFF
LDRB W3, [X0,X4] ; k = S[t]
LSR W4, W3, #2 ; ORR W3, W4, W3,LSL#6 ; 魔改:循环右移
ADD W3, W3, #0x3A ; +0x3A
EOR W3, W3, W7 ; 与明文异或
STRB W3, [X1],#1
CMP X1, X2 ; B.NE loop
5.2 运行时 SMC:执行时解密、执行后加密
壳大量使用 SMC(自修改代码):一段代码只在即将执行时才解密,执行完立刻加密回去。这样无论你静态分析还是内存 dump,看到的都是密文。解密用 NEON 一次处理 16 字节,单字节 key 异或:
decCode:
...
DUP V1.16B, W23 ; 用 W23 这个 key 字节铺满 16 字节
loop16:
LDR Q0, [X3]
EOR V0.16B, V0.16B, V1.16B ; 16 字节一把 XOR 解密
STR Q0, [X3],#0x10
...
tail: ; 不足 16 字节的尾部逐字节
LDRB W1, [X19,X2]
EOR W1, W23, W1
STRB W1, [X19,X2]
...
SMC 是这个壳最硬的一块:你在 IDA 里看到的永远是加密态,必须动态跑到那条指令解密的瞬间才能看清,且看完它马上又加密回去。
5.3 自定义 linker:解密 + 填充 + 抹 ELF 头
壳没用系统 linker,而是自己实现了一套 SO 加载,全程在 init_array 里完成。三步走:
① 解密字符串表(简单异或,带递增 key):

DecSecString:
MOV W7, #0x2342 ; MOVK W7, #0x5631,LSL#16 ; key = 0x56312342
loop:
LDR W6, [X3,X5]
EOR W6, W6, W7 ; 异或解密
ADD W7, W7, W5 ; key 随偏移递增
STR W6, [X3,X5]
ADD X5, X5, #4 ; CMP W4,W5 ; B.HI loop
② 解密指令(前面 5.1 那个 RC4 变体)。
③ 填充指令 + 抹 ELF 头:解析 ELF 头拿到各 LOAD 段,mmap 映射进内存,mprotect 改读写权限,最后用 0xBB 把 ELF 头整个 memset 掉:
mmap_so_memcpy_code:
...
BLR X8 ; mprotect(对齐后的段, RW)
MOV W1, #0xBB
BL memset_0 ; 用 0xBB 填充/抹掉 ELF 头
...
BLR X4 ; mprotect(段, 按 p_flags 设置最终权限)
抹 ELF 头这一手专治”从内存直接反 ELF / dump SO”——你 dump 出来的是一坨没有头、没有节表的残体,想完整还原 SO,得按 ELF 格式手工重组一个出来。这是这个壳防 dump 的核心价值所在。
至此 init_array 阶段结束,接着才执行 JNI_OnLoad。
5.4 JNI_OnLoad 定位
代码虽然解密、填充好了,但导出函数还是看不到,所以仍要借系统机制定位 JNI_OnLoad。跟 LoadNativeLibrary 流程:
art::JavaVMExt::LoadNativeLibrary(...)
└─ art::SharedLibrary::FindSymbolWithoutNativeBridge("JNI_OnLoad")
→ 直接返回 JNI_OnLoad 地址
在 FindSymbolWithoutNativeBridge 返回处即可拿到真实 JNI_OnLoad 地址。
5.5 反调试
JNI_OnLoad 里铺了几道反调试,常规手法:
- 扫调试器:
opendir("/proc/")+readdir遍历进程,查是否有android_server(IDA 远程调试服务)在跑; fork子进程反调试:fork出子进程,pthread_create起反调试线程(典型的ptrace互锁 / 自附加思路)。
5.6 RegisterNatives 与 DEX 解密
JNI_OnLoad 里 RegisterNatives 动态注册 native 方法(X2 寄存器即 JNINativeMethod 数组):
typedef struct {
const char* name; // Java 层 native 方法名
const char* signature; // 函数签名
void* fnPtr; // native 实现地址
} JNINativeMethod;
解密 dex 前先查脱壳器——access 探测一票已知脱壳工具 / 模拟器特征:
/data/dexname
/data/local/tmp/unpacker.config
/data/fart
/sdcard/fart
/data/local/tmp/libFupk3.so
libFupk3.so
libblackdex.so
libhoudini.so ; 模拟器(houdini = x86 上跑 ARM)
然后从壳 dex 里拷出一段配置密文解密,里面是包名、原 Application、各种 key 和开关:
c : 5FD6EB80B07CB412704C5A46D899C63B
p : com.xiaopeng.mycarinfo
a : com.xiaopeng.mycarinfo.application.tinker.CarApplication // 原 App 入口
r : 047AEA142BC032C4
d : 4A7651EA24600BAA // 解密 dex 的 key
m : 1 z : 0 u : 1 ...(一长串行为开关)
拿到 dex key 后,解析壳 dex 格式取出加密存放的 DEX,循环解密 6 个 DEX 还原出明文:
DecDex_sub:
...
BL memcpy_2 ; 拷贝 DEX 密文
BL DecKEY_sub ; 解密 key(4A7651EA24600BAA)
BL initkey_sub ; 初始化解密器
BL Dec_DexData_sub ; 解出明文 DEX
...
脱壳点 1:这里解密完,内存里就是原始明文 DEX,直接 dump 即可。
5.7 加载 DEX
Android 10 是个分水岭:10 之前 DexClassLoader 加载的 dex 默认会走 dex2oat 优化;10 之后系统不再对 DexClassLoader 加载的 dex 做 dex2oat,运行时只接受系统生成的 OAT。所以壳直接调 art 内部接口加载明文 DEX:
std::unique_ptr<DexFile> DexFile::OpenCommon(
const uint8_t* base, size_t size,
const std::string& location, uint32_t location_checksum, ...);
脱壳点 2:
DexFile::OpenCommon是个非常舒服的脱壳点——hook 它,base/size就是完整 DEX,直接拿。之后壳构造PathDexList,用其成员Element[] dexElements指向这些 DEX,DEX 就被挂进了 ClassLoader。
六、脱壳二次打包
经过上面的分析,dump 点其实很多:内存里解密完那一刻、DexFile::OpenCommon 加载时,都能拿到明文 DEX。
把 dump 出来的 6 个 DEX 重新打包进 apk,入口类改成上面解出来的原 App 入口 com.xiaopeng.mycarinfo.application.tinker.CarApplication,加载完所有 DEX 后反射调用原始入口,App 就能正常跑起来:

七、总结
亮点:Native 层的 SO 保护比”整体压缩加密”那种壳上了一个台阶——SMC(运行时自修改)+ 自定义 linker 加载 SO + 抹掉 ELF 头/节信息,能很好地防止从内存直接 dump ELF。想完整脱出 SO,得按 ELF 格式重新拼一个完整文件出来,防脱壳的安全度确实是有的。
不足:Native 层堆了那么多反调试、反脱壳,但最终 DEX 还是要在内存里以完整明文出现,这一步绕不过去——只要 DEX 落地成明文,各种脱壳机就很难真正防住。这也是 dex 整体加固的通病:壳保护得再狠,被保护的 DEX 总有”现明文”的一刻。
最后用一张表对比一下这个壳”防得住”和”防不住”的:
| 防护手段 | 目的 | 实际效果 |
|---|---|---|
| 节信息处理 / 导出加密 / 抹 init_array | 反静态分析 | ✅ 静态基本看不了 |
| SMC 运行时加解密 | 反内存 dump 代码 | ✅ 看到的永远是密文 |
| 自定义 linker + 抹 ELF 头 | 防 dump 完整 SO | ✅ dump 出来是残体,要手工重组 |
| 多重反调试(android_server / fork ptrace) | 拦调试器 | ⚠️ 常规手法,可对抗 |
| 脱壳器特征检测(fart/blackdex/Fupk3) | 拦已知脱壳机 | ⚠️ 黑名单式,能绕 |
| DEX 整体加密 | 保护业务代码 | ❌ 内存必现明文 → 可 dump |
一句话收尾:这个壳真正的功夫全在 SO 的自我保护上,DEX 本身反而是软肋。对 dex 壳来说,SO 加固做得再厚,也挡不住”内存里那份明文 DEX”——找准 DexFile::OpenCommon 这类系统加载点,脱壳就是水到渠成的事。
借这次分析,也顺带把高版本系统的 DEX 加载流程和自定义 linker 的玩法摸了一遍。
本文涉及的逆向均用于安全研究与防御对抗学习,请勿用于非法用途。
文档信息
- 本文作者:我是小三
- 本文链接:https://xiaosan-re.github.io/2023/02/02/yidun-shell-unpack/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)
