Android APK 加固原理(五):SO .text 段加密、ELF 加载与运行时动态解密
本文是 Android APK 加固原理系列第五篇,基于开源项目 XopProtector 的实际源码进行分析。
系列文章:
- 第一篇:《Android APK 加固原理(一):Native Shell 如何隐藏和恢复 DEX》
- 第二篇:《Android APK 加固原理(二):从 DEX 解密到 ART 加载,如何缩短代码明文暴露窗口》
- 第三篇:《Android APK 加固原理(三):方法级代码抽取------PVM1 虚拟化打包到底是什么》
- 第四篇:《Android APK 加固原理(四):真正的代码虚拟化------PVM2 Native Interpreter 技术解析》
- 第五篇:《Android APK 加固原理(五):SO
.text段加密、ELF 加载与运行时动态解密》- 第六篇:《Android APK 加固原理(六):RASP 运行时安全防护------如何检测 Frida、Hook 与运行时攻击》
一、为什么 Android APK 加固不能只保护 DEX?
前面几篇文章主要讨论的是 Android Java/Kotlin 代码,也就是 DEX 层。
但是在真实的 Android 商业应用中,大量核心能力实际上并不一定存在于 DEX:
- 算法;
- 音视频处理;
- 图形渲染;
- 游戏引擎;
- JNI 接口;
- 加密算法;
- 授权校验;
- 核心业务逻辑;
- C/C++ 实现的核心模块。
这些代码最终通常会进入 ELF 格式的 Native .so 文件。
例如:
text
lib/arm64-v8a/libnative.so
lib/arm64-v8a/libcrypto.so
lib/arm64-v8a/libbusiness.so
传统 APK 加固如果只保护 DEX,那么攻击者仍然可以直接对:
text
lib/arm64-v8a/*.so
进行静态分析。
使用 IDA、Ghidra、Binary Ninja 等工具,就可以直接看到 Native 层机器指令。
因此,从加固体系来看:
text
APK
│
┌──────┴──────┐
│ │
DEX SO
│ │
Java/Kotlin C/C++
│ │
DEX 加固 Native 加固
如果只解决左边的问题,而右边的 .so 仍然保持完整明文,那么整个保护体系依然存在明显短板。
XopProtector 因此在 DEX 保护之外,又增加了一层 Business SO Protection。
项目 README 对这一能力的描述非常明确:M3 阶段加入了业务 SO .text 的 RC4 保护,并且默认开启。
二、XopProtector 的 SO 加固到底保护什么?
这里首先要纠正一个非常容易产生误解的概念。
它并不是简单地:
"把整个
.so文件 AES 加密,然后运行时自己解析 ELF 并重新加载。"
从源码来看,实际保护粒度主要是:
text
ELF
│
├── ELF Header
├── Program Header
├── Section Header
├── .text ← 重点保护
├── .rodata
├── .data
├── .dynamic
├── .dynsym
└── ...
XopProtector 的构建期 SO 保护器首先定位 ELF 的:
text
.text
然后只对 .text 内容进行 RC4 加密。
源码中的 SoSectionEncryptor.java 明确寻找:
java
private static final String BITCODE = ".bitcode";
并通过 ELF Section Header 定位 .bitcode。
而业务 SO 的 BusinessSoProtector.java 则专门对:
text
.text
进行分析和保护。
也就是说,这套体系实际上存在两类 Native 代码保护:
text
Native Shell 自身
│
└── .bitcode RC4 保护
业务 SO
│
└── .text RC4 保护
这是理解 XopProtector 源码非常重要的一点。
SoSectionEncryptor 本身的注释也明确说明,它负责对 Shell 的 .bitcode 使用 RC4 做 size-preserving 加密,同时把多个 AES Key 写入指定 ELF Symbol。
而业务 SO 保护器则负责真正的:
text
业务 .so
↓
解析 ELF
↓
定位 .text
↓
RC4 加密 .text
三、为什么只加密 .text?
这是整个设计里非常关键的一点。
一个 ELF .so 文件并不是只有机器代码。
例如:
text
ELF Header
Program Header
Section Header
.text
.rodata
.data
.bss
.dynsym
.dynstr
.dynamic
.rela.dyn
.rela.plt
...
如果把整个 .so 文件全部加密,那么 Android 系统动态链接器就无法正常识别:
- ELF Header;
- Program Header;
- Dynamic Section;
- Dynamic Symbol;
- Relocation;
- DT_NEEDED;
- 初始化函数;
- JNI_OnLoad。
换句话说:
Android 的 linker 仍然需要看到一个合法 ELF。
因此更加实际的做法是:
text
保留 ELF 外壳
+
只隐藏最有价值的机器代码
最终形成:
text
原始 SO:
ELF Header
Program Header
.text
.rodata
.data
.dynamic
...
加固后:
ELF Header
Program Header
加密后的 .text
.rodata
.data
.dynamic
...
这样:
- ELF 结构仍然存在;
- 动态链接器仍然能够识别文件;
.dynsym等必要结构仍然存在;- 但是静态分析工具看到的
.text已经不再是正常 ARM64 指令流。
这就是所谓的:
局部代码段加密,而不是整个 ELF 文件加密。
四、构建期第一步:解析 ELF 找到 .text
XopProtector 并没有简单按照固定偏移去加密。
它会真正解析 ELF。
BusinessSoProtector.java 中首先读取 .text 的大小:
java
if (".text".equals(sh.getName())) {
return sh.getSize();
}
同时,它还会扫描 ELF 的重定位信息。
源码专门存在:
java
hasUnsafeTextRelocs(...)
这一检查。
其核心思想是:
如果某个 SO 存在重定位项直接落在
.text范围内,那么这个.text不能简单进行原地加密。
原因很好理解。
假设:
text
.text
↓
某个机器指令
↓
里面存在需要运行时 relocation 修正的数据
如果直接对整个 .text 做加密:
text
原始指令
↓
RC4
↓
密文
那么动态链接器在加载阶段看到的就已经不是正常机器指令。
因此源码采用:
text
ELF 分析
↓
检查 .text
↓
检查 relocation
↓
安全 → 加密
不安全 → 跳过
这是一个非常重要的工程化设计。
它说明这个 SO 加固器不是简单的:
text
read()
↓
XOR
↓
write()
而是在考虑:
Native ELF 的加载约束。
源码明确将 .text 中存在危险 relocation 的 SO 判定为不能直接做原地 .text 加密。
五、构建期第二步:为每个 SO 生成独立密钥
在确定某个 SO 可以保护以后,XopProtector 会为 SO 生成随机密钥。
源码使用:
java
SecureRandom rng = new SecureRandom();
然后:
java
byte[] key = new byte[16];
rng.nextBytes(key);
也就是:
text
16 Bytes
= 128 bit
并且非常重要的一点是:
同一个 basename 的多 ABI SO 使用同一个 Key。
例如:
text
lib/arm64-v8a/libbusiness.so
lib/armeabi-v7a/libbusiness.so
它们都属于:
text
libbusiness.so
这一逻辑在源码中进行了明确处理。
原因是运行时的 key 表是以:
text
SO basename
为索引。
因此:
text
libbusiness.so
↓
Key
↙ ↘
arm64 armv7
这样可以保证不同 ABI 下的同名 SO 都能正确恢复。
六、构建期第三步:RC4 加密 .text
确定保护目标和 Key 后,真正的加密动作非常直接:
text
读取 .text
↓
RC4(key, .text)
↓
写回原位置
源码最终调用:
java
encryptText(c.so, key);
而 SoSectionEncryptor 中对 Native Shell .bitcode 的实现更加直观:
java
byte[] plain = readAt(soFile, offset, size);
byte[] enc = CryptoUtils.rc4Crypt(aesKey, plain);
writeAt(soFile, offset, enc);
这是一种典型的:
size-preserving encryption
即:
text
加密前大小 = 加密后大小
因此:
text
.text offset
.text size
都不需要改变。
这也是 RC4 在这种场景下非常方便的原因。
不需要:
text
重新分配 ELF segment
重新计算 section offset
扩大文件
修改 Program Header
重新布局 ELF
只需要:
text
读取
→ 加密
→ 原位置覆盖
即可。
七、为什么使用 RC4,而不是 AES?
从密码学角度来看,RC4 已经属于过时的流密码算法,不适合现代通用安全通信。
但是在"代码段隐藏"这个场景里,它有一个非常实际的工程优势:
输入输出长度完全一致。
例如:
text
.text = 1 MB
加密以后仍然:
text
1 MB
因此可以:
text
offset 不变
size 不变
section 不变
ELF 布局不变
而且解密过程就是:
text
ciphertext XOR keystream
不需要额外 padding。
所以这里真正的安全目标并不是:
用 RC4 作为长期保密通信算法。
而是:
让 APK 中的 Native 机器代码无法直接被静态反汇编,同时让运行时能够快速恢复。
这属于典型的:
Anti-Static-Analysis Encryption
即反静态分析型代码加密。
八、加密后的 .text 会发生什么?
假设原始 .text 开头是 ARM64 指令:
text
FD 7B BF A9
FD 03 00 91
F4 4F 01 A9
...
这些字节对应真实 ARM64 指令。
经过 RC4:
text
7A 31 E9 44
C2 8F 1A 03
91 D7 62 BE
...
这时候:
text
IDA
Ghidra
objdump
虽然仍然可以识别:
text
ELF
也可以看到:
text
.text
但 .text 内部已经不是正常指令流。
因此静态分析得到的结果可能变成:
text
无法正常反汇编
大量非法指令
随机跳转
无意义数据
也就是说:
text
传统 Native SO
↓
直接静态反汇编
↓
真实机器码
XopProtector SO
↓
直接静态反汇编
↓
密文
↓
无法得到完整真实代码
这就是 .text 加密最直接的价值。
九、真正困难的问题:Android 怎么加载一个被加密的 SO?
到这里问题就来了。
Android 正常加载 SO 的流程大致是:
text
System.loadLibrary()
↓
ClassLoader
↓
libnativeloader / linker
↓
dlopen()
↓
解析 ELF
↓
建立内存映射
↓
处理 relocation
↓
执行 .init_array
↓
JNI_OnLoad
↓
SO 开始工作
但是现在:
text
.text
已经被加密。
如果直接:
text
dlopen(encrypted.so)
那么 linker 最终映射到内存里的 .text 也是密文。
CPU 执行:
text
密文
自然会产生:
text
SIGILL
或者直接崩溃。
因此真正的难点并不是:
"怎么加密?"
而是:
"怎么让 Android linker 最终执行解密后的代码?"
这才是 SO 加固真正有技术含量的地方。
十、XopProtector 没有完全替换 Android ELF Loader
这里是分析 XopProtector 源码时最值得强调的一点。
它并没有自己重新实现一个完整的:
text
ELF Loader
而是:
继续利用 Android 原生动态链接器,只是在
dlopen周围增加了一层 Native Shell 控制逻辑。
源码中直接使用:
cpp
dlopen()
以及 Android 特有的:
cpp
android_dlopen_ext()
同时使用 ByteHook 对这些加载入口进行 Hook。
也就是说:
text
Android linker
↑
│
ByteHook
↑
│
XopProtector Native Shell
↑
│
加密 SO
它的定位更准确地说应该是:
Native Loader Interposition + Runtime Decryption
而不是完整替代系统 ELF Loader。
十一、核心机制:Hook dlopen
在:
text
native/src/main/cpp/so/business_so.cpp
中,可以看到:
cpp
bytehook_hook_all(
nullptr,
"dlopen",
reinterpret_cast<void*>(fake_dlopen),
nullptr,
nullptr);
同时还 Hook:
text
android_dlopen_ext
__loader_android_dlopen_ext
dladdr
源码对应的 Hook 逻辑非常明确。
也就是说:
text
应用调用 dlopen()
↓
被 ByteHook 截获
↓
fake_dlopen()
↓
判断是不是受保护 SO
↓
如果是
↓
准备明文 SO
↓
交给真正的 linker
这样就形成:
text
App
│
│ dlopen("libbusiness.so")
▼
fake_dlopen()
│
├── 非保护 SO → 正常 dlopen
│
└── 保护 SO
↓
找到 Key
↓
准备明文
↓
Android linker
↓
正常加载
源码中 fake_dlopen() 和 fake_android_dlopen_ext() 都采用了这一模式。
十二、关键问题:Key 放在哪里?
如果:
text
.text
被 RC4 加密,那么运行时必须拿到:
text
RC4 Key
否则无法解密。
XopProtector 没有简单把所有 SO Key 直接明文写进 APK。
它设计了:
text
sokeys.bin
这一层。
运行时首先读取:
text
sokeys.bin
然后使用:
text
AES-128-GCM
进行解密。
源码:
cpp
if (!crypto::aes128_gcm_decrypt(
dex_aes_key,
enc,
enc_len,
plain.data(),
plain_len)) {
...
}
解密后的数据结构大致可以理解为:
text
SO Key Table
count
├── libA.so + 16 bytes key
├── libB.so + 16 bytes key
├── libC.so + 16 bytes key
└── ...
因此形成:
text
sokeys.bin
↓
AES-GCM
↓
SO Key Table
↓
libbusiness.so → Key
libcrypto.so → Key
...
源码还会在使用完后主动:
cpp
memset(plain.data(), 0, plain.size());
memset(buf.data(), 0, buf.size());
尽量缩短 Key 明文驻留时间。
十三、那么 AES Key 又在哪里?
这里 XopProtector 又增加了一层。
SoSectionEncryptor.java 中定义了多个 ELF Symbol:
text
PROTECTOR_UNKNOWN_DATA
PROTECTOR_INSN_KEY
PROTECTOR_DEX_KEY
PROTECTOR_HMAC_KEY
PROTECTOR_ASSETS_KEY
其中:
text
PROTECTOR_DEX_KEY
等关键数据并不是直接把明文 Key 放进去。
而是:
text
真实 Key
XOR
固定 Pad
↓
写入 ELF Symbol
例如:
text
StoredKey = RealKey XOR Pad
运行时再:
text
StoredKey XOR Pad
↓
RealKey
这并不是严格意义上的密码学保护,而更接近:
静态 Key 混淆。
目的主要是避免:
text
strings
readelf
nm
IDA
直接从 ELF Symbol / 数据区中看到完整明文 Key。
SoSectionEncryptor 的源码明确实现了这一逻辑。
所以整个 Key 链条可以画成:
text
┌────────────────────┐
│ Native Shell SO │
│ │
│ XOR-Padded Key │
└─────────┬──────────┘
│
▼
还原 DEX Key
│
▼
AES-GCM
│
▼
sokeys.bin
│
▼
SO Key Table
│
┌────────────┼────────────┐
▼ ▼ ▼
libA.so libB.so libC.so
│ │ │
Key A Key B Key C
│ │ │
└────────────┴────────────┘
│
▼
RC4 解密 .text
这已经不是简单的:
text
SO + RC4
而是:
多层密钥封装 + SO 独立 Key + Native Runtime 解密。
十四、运行时如何找到 .text?
拿到:
text
libbusiness.so
和:
text
RC4 Key
之后,还不能直接解密。
还需要知道:
text
.text 在 ELF 文件里的什么位置?
.text 在进程内存里的什么地址?
这就涉及 ELF 的两个不同概念:
text
File Offset
Virtual Address
例如:
text
ELF 文件:
0x00000000 ELF Header
0x00001000 Program Header
0x00002000 .text
但是加载到进程以后:
text
load_bias + .text.sh_addr
才是:
text
.text 的实际内存地址
XopProtector 的 Native 代码会首先通过:
cpp
find_so_load_bias()
获取 SO 的:
text
load bias
然后读取 ELF Section Header:
cpp
get_elf_section(&shdr, path.c_str(), ".text");
最终计算:
cpp
auto* text =
reinterpret_cast<uint8_t*>(bias + shdr.sh_addr);
也就是:
text
.text runtime address
=
load_bias
+
.text sh_addr
然后:
text
.text size
=
shdr.sh_size
这就是运行时定位 .text 的核心。
十五、已经加载到内存怎么办?
如果 SO 已经被 Android linker 映射:
text
/data/app/.../libbusiness.so
那么:
text
.text
现在可能已经进入:
text
RX
也就是:
text
Read + Execute
但 RC4 解密需要修改里面的数据。
因此源码会临时修改页面权限:
cpp
mprotect(
text,
shdr.sh_size,
PROT_READ | PROT_WRITE | PROT_EXEC
);
也就是:
text
RX
↓
RWX
↓
解密
↓
RX
然后:
cpp
rc4_init(...)
rc4_crypt(...)
memcpy(text, tmp.data(), shdr.sh_size);
完成解密。
最后重新设置:
cpp
PROT_READ | PROT_EXEC
恢复:
text
RX
同时调用:
cpp
__builtin___clear_cache(...)
确保 CPU 指令缓存与刚刚修改后的代码同步。
完整流程就是:
text
加密 .text
↓
SO 被加载
↓
.text 映射到内存
↓
mprotect(RWX)
↓
RC4 解密
↓
覆盖原内存
↓
clear cache
↓
mprotect(RX)
↓
CPU 执行真实机器码
源码中的 decrypt_loaded_text() 就实现了这一过程。
十六、为什么不能简单地"加载以后再解密"?
这是 SO 加固最容易踩坑的地方。
因为 ELF 中可能存在:
text
.init
.init_array
JNI_OnLoad
constructor
这些代码可能在:
text
dlopen()
期间就会执行。
也就是说:
text
dlopen()
↓
ELF 映射
↓
relocation
↓
.init_array
↓
JNI_OnLoad
如果 .text 还是密文:
text
.init_array
↓
执行密文
↓
SIGILL
所以:
解密时机必须早于真正的 Native 初始化代码执行。
XopProtector 的源码注释对此有非常明确的说明:仅在 dlopen 完成后再做内存解密可能已经太晚,因此它同时设计了磁盘侧预解密路径。
十七、这就引出了 so_plain
XopProtector 为了解决:
text
Android linker
必须先看到正常 ELF
以及:
text
.init_array / JNI_OnLoad
必须执行明文
之间的矛盾,引入了一个非常重要的概念:
text
so_plain
可以理解为:
运行时生成的 Native SO 明文镜像目录。
例如:
text
保护 APK
nativeLibDir/
libbusiness.so ← 密文 .text
protector/
so_cipher/
libbusiness.so ← 原始密文备份
so_plain/
libbusiness.so ← 运行时解密后的明文
这样:
text
APK 中
↓
保存加密 SO
运行时
↓
复制到可写目录
↓
解密
↓
so_plain/libbusiness.so
↓
Android linker
这是一种非常典型的:
Runtime Materialization
也就是:
运行时物化。
十八、为什么还要保留 so_cipher?
源码中有:
text
so_cipher
这一目录。
它的作用非常关键。
假设:
text
nativeLibDir/libbusiness.so
一开始是:
text
密文
运行时把它解密后,如果直接覆盖:
text
nativeLibDir/libbusiness.so
那么:
text
原始密文
就丢失了。
下一次重新启动时:
text
没有密文源
也就无法重新恢复。
所以 XopProtector 会先保存:
text
so_cipher/libbusiness.so
作为:
稳定的密文源。
然后:
text
so_cipher/libbusiness.so
↓
RC4
↓
so_plain/libbusiness.so
这样每次启动都可以重新构造明文。
源码中的 keyed_packaged_src() 就优先从:
text
so_cipher/
寻找原始密文。
十九、L1 / L2 / L3 三种 SO 加载路径
这是 XopProtector 当前 SO 保护实现中非常有意思的一部分。
源码定义了:
text
L1
L2
L3
三种加载策略。
1. L1:直接生成明文到 Native Library Directory
如果 Native Library Directory:
text
nld
可写,那么可以:
text
encrypted SO
↓
备份到 so_cipher
↓
复制
↓
nld/libxxx.so
↓
解密
这样 Android linker 仍然认为自己加载的是:
text
正常路径下的 libxxx.so
但文件内容实际上已经变成明文。
源码:
cpp
copy_file_bytes(plain_path, extract, true)
之后让:
text
linker_name = extract
content_path = extract
这种方式就是:
路径不变,内容变成明文。
二十、L2:android_dlopen_ext + LIBRARY_FD
如果不能直接修改 Native Library Directory,就使用第二条路径。
源码通过:
cpp
android_dlopen_ext()
并设置:
text
ANDROID_DLEXT_USE_LIBRARY_FD
让 Android linker:
text
文件名
↓
仍然保持期望的路径
但:
text
真正读取的数据
↓
来自 so_plain 的 FD
可以理解成:
text
linker 认为:
/data/app/.../libbusiness.so
实际上读取:
protector/so_plain/libbusiness.so
这就是:
Path 与 Content 分离。
源码在 dlopen_keyed_plan() 中专门实现了这一机制。
二十一、L3:直接从 so_plain 加载
如果:
text
L1 不可用
L2 不可用
则退化到:
text
dlopen(so_plain/libxxx.so)
也就是:
text
L3
所以整体结构可以理解成:
text
Protected SO
│
▼
是否可以直接写?
/ \
Yes No
│ │
▼ ▼
L1 L2
extract 明文 android_dlopen_ext
│
▼
失败
│
▼
L3
so_plain 直接加载
这说明它不是单一方案,而是:
多级加载降级策略。
二十二、为什么还要 Hook dladdr()?
这是一个非常容易被忽略的细节。
当使用:
text
ANDROID_DLEXT_USE_LIBRARY_FD
时,实际映射的文件可能来自:
text
so_plain
那么:
cpp
dladdr()
返回的:
text
dli_fname
可能变成:
text
.../so_plain/libbusiness.so
但是某些 Native 程序会依赖:
text
dladdr()
得到原始路径。
尤其是:
text
大型 Native SDK
图形引擎
OSG
Unity
Cocos
UE
可能存在:
text
路径判断
资源定位
模块判断
插件发现
等行为。
因此 XopProtector 又 Hook:
text
dladdr()
把:
text
so_plain/libbusiness.so
重新映射回:
text
原始 extract/libbusiness.so
这就是源码里的:
cpp
fake_dladdr()
以及:
text
g_dladdr_extract
映射表。
所以它不仅考虑:
"SO 能不能加载。"
还考虑:
"加载之后,Native 世界看到的 SO 路径是否仍然符合原应用预期。"
这属于非常典型的:
兼容性工程。
二十三、DT_NEEDED 依赖链怎么处理?
Native SO 并不是孤立的。
例如:
text
libbusiness.so
│
├── libcrypto.so
├── libc++_shared.so
└── libfoo.so
ELF 中通过:
text
DT_NEEDED
记录依赖。
问题来了:
如果:
text
libbusiness.so
是明文,但:
text
libfoo.so
仍然是密文。
那么 linker 在内部解析:
text
DT_NEEDED
时,可能不会经过你 Hook 的:
text
dlopen()
入口。
因此:
text
libbusiness.so
↓
DT_NEEDED
↓
libfoo.so
↓
直接被 linker 找到
↓
密文
↓
SIGILL
这就是 Native SO 加固非常难处理的一点。
二十四、XopProtector 如何解决 DT_NEEDED?
源码专门实现:
cpp
read_dt_needed()
解析 ELF 的:
text
PT_DYNAMIC
DT_STRTAB
DT_NEEDED
然后建立依赖关系。
最终形成:
text
libA.so
│
├── libB.so
│ └── libC.so
│
└── libD.so
运行时会做:
text
Depth-First Load
也就是:
text
A
↓
B
↓
C
然后
D
最后
A
源码中的:
cpp
preload_one()
明确使用:
text
visiting
loaded
两个集合避免:
text
循环依赖
重复加载
然后对受保护 SO 使用:
text
KeyedOpenPlan
进行加载。
这就是 XopProtector 的:
DT_NEEDED Closure Handling
即:
Native 依赖闭包处理。
二十五、为什么还要创建依赖 SO 的软链接?
如果:
text
so_plain/
里只放:
text
libbusiness.so
但:
text
libbusiness.so
依赖:
text
libc++_shared.so
libfoo.so
那么 linker 仍然可能找不到。
所以源码中的:
cpp
copy_plain_deps()
会分析:
text
DT_NEEDED
然后将非受保护依赖建立软链接。
例如:
text
so_plain/
libbusiness.so
libfoo.so -> /nativeLibDir/libfoo.so
这样可以避免:
text
重复复制
同时保证:
text
依赖关系
可以正常解析。
源码还专门避免将一些系统级 SO 放进 so_plain,防止:
text
GLES
Conscrypt
系统 libc++
等发生冲突。
二十六、Eager 模式:启动阶段提前准备全部 SO
XopProtector 支持:
text
eager
模式。
可以理解为:
text
App 启动
↓
读取 SO Key
↓
扫描受保护 SO
↓
全部生成 so_plain
↓
处理 DT_NEEDED
↓
预加载
↓
业务运行
这样做最大的优点是:
可靠性高。
因为等真正业务代码调用:
text
System.loadLibrary()
之前,明文 SO 已经准备好了。
但是缺点也非常明显:
text
启动耗时 ↑
磁盘 IO ↑
内存压力 ↑
CPU 解密压力 ↑
因此:
text
SO 越多
SO 越大
Eager 越容易影响启动速度。
二十七、Lazy 模式:真正按需解密
因此 XopProtector 又实现:
text
lazy
模式。
核心思想:
启动时不把所有 SO 全部解密。
而是:
text
App 启动
↓
初始化 Native Shell
↓
读取 Key
↓
安装 dlopen Hook
↓
等待真正加载某个 SO
↓
发现目标 SO
↓
只解密它
↓
加载
例如:
text
App 启动
libcrypto.so
libnetwork.so
libmap.so
libcamera.so
lib3d.so
真正启动阶段只使用:
text
libcrypto.so
那么:
text
只准备 libcrypto.so
其余:
text
libnetwork.so
libmap.so
libcamera.so
lib3d.so
可以暂时保持密文。
这就是:
On-Demand Native Decryption
二十八、Lazy 模式还有一个重要问题
如果:
text
dlopen Hook
没有成功。
那么:
text
ClassLoader
↓
Android linker
↓
直接加载加密 SO
↓
SIGILL
因此源码明确做了 fallback:
text
Lazy
↓
Hook 成功?
↓
Yes → 按需解密
No → 强制全量 materialize
也就是说:
Lazy 不是无条件 Lazy。
它建立在:
text
Native Loader Hook
正常工作的基础上。
否则就自动退化成:
text
Eager
这种设计体现的是:
安全策略优先于性能策略。
源码对此有非常明确的处理。
二十九、冷启动优化:so_plain_ready
如果每次启动都:
text
重新读取密文
↓
重新 RC4
↓
重新生成 so_plain
显然浪费性能。
因此源码增加:
text
so_plain_ready
标记。
当:
text
so_plain/
里面已经存在完整的:
text
明文 SO
并且大小与源文件一致时,就可以认为:
text
Warm Reuse
已经可用。
于是:
text
第一次启动
↓
生成 so_plain
↓
so_plain_ready
第二次启动
↓
检查 ready
↓
直接复用
这样可以明显降低:
text
Warm Start
的成本。
三十、后台异步填充:把性能成本移出启动关键路径
Lazy 模式还有一个非常有意思的优化。
假设:
text
App 当前只需要 A.so
那么启动阶段:
text
只解密 A.so
但是后台可以继续:
text
线程
↓
B.so
C.so
D.so
E.so
逐步生成:
text
so_plain
源码中:
cpp
fill_so_plain_async()
专门做这个工作。
而且它会:
text
降低线程优先级
同时限制:
text
worker 数量
避免后台解密把 App 主线程资源全部抢走。
源码还会在后台完成后:
text
so_plain_ready
标记。
因此整个过程变成:
text
冷启动
│
▼
只准备必须 SO
│
▼
App 尽快启动
│
▼
用户开始使用
│
▼
后台异步生成其他 SO
│
▼
so_plain_ready
│
▼
Warm Start
源码中 fill_so_plain_async() 明确采用后台线程,并对剩余 SO 做异步 materialize。
三十一、已经加载的 SO 怎么办?
现实情况还有一种:
text
SO 在 Hook 安装之前已经加载。
例如:
text
某个 SDK
↓
非常早地 dlopen()
↓
XopProtector Native Shell
↓
稍后才完成 Key 初始化
那么:
text
SO 已经进入内存
这时候不能只依赖:
text
dlopen Hook
因此源码还有:
cpp
decrypt_already_loaded()
以及:
cpp
decrypt_already_loaded_async()
逻辑就是:
text
枚举受保护 SO
↓
判断是否已经 Loaded
↓
获取 load bias
↓
找到 .text
↓
mprotect RWX
↓
RC4 解密
↓
恢复 RX
也就是说,XopProtector 同时覆盖:
text
未来加载 SO
+
已经加载 SO
两条路径。
这也是它比单纯 Hook dlopen() 更完整的地方。
三十二、整个 SO 加固流程可以总结成一张图
把前面的所有源码逻辑串起来:
text
┌──────────────────────┐
│ 原始 SO │
└──────────┬───────────┘
│
▼
解析 ELF
│
▼
定位 .text
│
▼
检查 relocation
│
┌──────────┴──────────┐
│ │
安全 不安全
│ │
▼ ▼
生成随机 Key 跳过保护
│
▼
RC4 加密 .text
│
▼
输出加密后的 SO
│
▼
APK 打包 / 发布
运行时:
text
Protected APK
│
▼
Native Shell 启动
│
▼
获取 DEX Key
│
▼
AES-GCM 解密
│
▼
sokeys.bin
│
▼
SO Key Table
│
▼
安装 dlopen Hooks
│
┌─────────┴─────────┐
│ │
Eager Lazy
│ │
▼ ▼
准备全部 SO 等待 SO 加载
│ │
└─────────┬─────────┘
▼
生成 so_plain
│
▼
获取 ELF
│
▼
找到 .text
│
▼
RC4 Runtime Decrypt
│
▼
明文 .text
│
▼
Android Linker
│
▼
JNI_OnLoad
│
▼
正常执行
三十三、它真正解决的是哪一个问题?
理解到这里,就可以看出:
SO 加固真正要解决的并不是:
"如何让 SO 无法被读取。"
因为只要程序需要执行:
text
CPU
最终一定需要看到:
text
机器码
所以真正能够实现的目标是:
让攻击者无法仅通过 APK 静态文件直接获得完整的 Native 机器码,同时把机器码明文暴露推迟到运行时。
因此:
text
磁盘:
.text = 密文
运行时:
text
内存:
.text = 明文
这就是典型的:
At-Rest Encryption + Runtime Decryption
即:
静态存储加密 + 运行时解密。
三十四、它和传统 SO 加固有什么区别?
可以简单比较:
| 方案 | APK 中 .text |
运行时 | 静态分析难度 |
|---|---|---|---|
| 普通 SO | 明文 | 明文 | 低 |
| Strip | 明文 | 明文 | 中低 |
| OLLVM | 混淆代码 | 混淆代码 | 中 |
| 整 SO 加密 | 密文 | 自定义 Loader | 高 |
| XopProtector | .text 密文 |
Native Runtime 解密 | 高 |
| 更高级 VMP | VM Bytecode | Native Interpreter | 更高 |
XopProtector 的特点不是单纯依赖:
text
代码混淆
而是:
text
静态文件阶段:
代码直接消失
运行时:
需要时恢复
执行:
继续使用正常 ARM64 指令
所以它和第四篇介绍的:
text
PVM2
实际上属于两种不同思路。
三十五、SO .text 加密和 PVM2 到底有什么区别?
这是整个系列非常值得说明的一个问题。
PVM2 的思路是:
text
Native Machine Code
↓
转换
↓
VM Bytecode
↓
Native Interpreter
↓
解释执行
CPU 不再直接执行:
text
原始机器码
而是:
text
VM Interpreter
↓
解释
↓
VM Instruction
而 SO .text 加密则是:
text
Native Machine Code
↓
RC4
↓
Encrypted Machine Code
↓
Runtime RC4
↓
Original Machine Code
↓
CPU 正常执行
所以:
text
SO .text Encryption
核心解决:
静态代码隐藏。
而:
text
PVM2
解决:
机器指令语义隐藏。
二者可以叠加:
text
APK
│
├── DEX Encryption
│
├── PVM1
│
├── PVM2
│
├── SO .text Encryption
│
└── RASP
这才形成真正意义上的:
多层 Native + Java/Kotlin 综合加固体系。
三十六、XopProtector 的 SO 保护还有一个非常重要的工程特征
从源码来看,它并不是"所有 SO 无脑加密"。
它实现了非常多的策略判断:
text
系统 SO
↓
跳过
特定行业 SDK
↓
部分模式跳过
大型图形 / 游戏引擎 SO
↓
Path Sensitive 检查
.text relocation
↓
存在风险 → 跳过
SO 太大
↓
Size Budget
↓
可能跳过
例如源码专门检查:
text
libGLESv3.so
libGLESv2.so
libEGL.so
Unity
libil2cpp
UE4
Cocos
OSG
等相关特征。
原因很现实:
Native SO 加固不是"加密越多越好"。
真正商业级的加固需要在:
text
安全性
兼容性
启动速度
内存
磁盘空间
稳定性
之间寻找平衡。
源码中的 SAFE / AGGRESSIVE 等策略以及 size budget 就是在做这种工程化权衡。
三十七、为什么这是一个"多层防御",而不是一个 RC4 算法?
如果只看:
text
RC4
那么这个方案其实并不复杂。
真正复杂的是周围的整个运行时体系:
text
┌──────────────────┐
│ ELF Analysis │
└────────┬─────────┘
│
.text / reloc
│
▼
┌──────────────────┐
│ RC4 Code Crypto │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ SO Key Table │
└────────┬─────────┘
│
AES-GCM
│
▼
┌──────────────────┐
│ Native Shell Key │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ dlopen Hook │
│ android_dlopen │
│ __loader_* │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ so_plain │
│ materialize │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ DT_NEEDED │
│ Dependency Graph │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Android Linker │
└────────┬─────────┘
│
▼
正常执行
所以:
真正的保护能力来自"加密算法 + ELF 解析 + Loader Hook + Runtime Materialization + Dependency Handling + 生命周期管理"的组合。
三十八、从逆向角度看,它提高了什么成本?
传统 SO:
text
APK
↓
解压
↓
libbusiness.so
↓
IDA
↓
直接分析
XopProtector:
text
APK
↓
解压
↓
libbusiness.so
↓
发现 .text 是密文
↓
无法直接得到完整指令
↓
继续分析 Shell
↓
寻找 Key
↓
分析 sokeys
↓
分析 AES-GCM
↓
分析 SO Key
↓
分析 RC4
↓
分析 dlopen Hook
↓
定位运行时解密点
↓
获取解密后的内存
↓
恢复 Native Code
因此它并没有做到:
"绝对不可逆。"
但它改变了攻击者的工作模式:
text
静态分析
被迫转向:
text
静态 + 动态联合分析
攻击者不仅需要分析:
text
ELF
还需要分析:
text
Native Shell
Key 管理
运行时加载
Hook
解密流程
内存状态
这就是商业加固最核心的价值:
提高逆向成本,而不是追求理论上的绝对不可破解。
XopProtector 自己的 README 也明确强调,加固的目标是提高逆向成本,而不是让应用变成绝对不可破解。
三十九、一个值得注意的安全边界:运行时最终还是存在明文
这是理解所有 Native 加密方案时必须保持清醒的地方。
如果:
text
CPU
需要执行:
text
.text
那么最终某个时刻必须出现:
text
明文机器码
所以:
text
磁盘:
密文
↓
运行时:
明文
攻击者如果拥有:
text
Root
Frida
ptrace
Debugger
Inline Hook
Memory Dump
等能力,理论上依然可能在:
text
RC4 解密之后
获取:
text
明文 `.text`
因此:
.text加密解决的是"静态暴露",不是"运行时绝对隐藏"。
这也正是下一篇文章:
《Android APK 加固原理(六):RASP 运行时安全防护------如何检测 Frida、Hook 与运行时攻击》
需要解决的问题。
四十、第五篇总结:XopProtector 的 SO 加固到底是什么?
如果用一句话概括:
XopProtector 的 SO 加固,本质上是对业务 ELF 的
.text段进行 RC4、等长加密,在 APK 静态文件中隐藏 Native 机器码;运行时由 Native Shell 获取受保护 SO 的独立 Key,通过 Hookdlopen/android_dlopen_ext等加载路径,在真正执行 Native 初始化代码之前将 SO 物化为明文,并最终交给 Android 原生 linker 正常完成 ELF 加载。
整个技术链可以压缩成:
text
构建期
ELF
↓
解析 Section
↓
定位 .text
↓
检查 relocation
↓
生成 SO 独立 Key
↓
RC4
↓
覆盖 .text
↓
APK
运行时
Native Shell
↓
获取 DEX Key
↓
AES-GCM 解密 sokeys
↓
获得 SO Keys
↓
Hook dlopen
↓
发现目标 SO
↓
so_plain materialize
↓
RC4 解密 .text
↓
Android linker
↓
JNI_OnLoad
↓
正常执行
而进一步考虑:
text
Eager / Lazy
L1 / L2 / L3
DT_NEEDED
dladdr
so_cipher
so_plain
so_plain_ready
异步 materialize
已加载 SO 修复
就可以发现:
真正的技术难点并不是"RC4 加密",而是如何把一个已经被加密的 Native ELF 平滑地重新接入 Android 的动态加载体系。
这也是 XopProtector SO 加固方案最值得研究的地方。
四十一、从整个系列来看,SO 加固处于什么位置?
到第五篇为止,XopProtector 的保护体系已经逐渐完整:
text
XopProtector
│
┌──────────────┼──────────────┐
│ │ │
DEX PVM Native
│ │ │
▼ ▼ ▼
DEX 加密 PVM1/PVM2 SO .text 加密
│ │ │
└──────────────┼──────────────┘
│
▼
RASP
│
▼
运行时攻击防护
前五篇解决的核心问题分别是:
text
第一篇:
如何让 DEX 从 APK 中消失?
第二篇:
如何缩短 DEX 明文暴露窗口?
第三篇:
如何把方法代码抽取出来?
第四篇:
如何让代码不再直接以原始指令执行?
第五篇:
如何隐藏 Native SO 的机器码?
第六篇:
如何防止攻击者在运行时把这些保护重新打穿?
因此,第六篇的 RASP 就不是一个独立功能,而是整个体系最后的一道防线:
text
静态文件保护
↓
运行时恢复
↓
运行时执行
↓
攻击者尝试 Hook / Dump / Frida
↓
RASP 检测
↓
风险处理
四十二、结语
Android Native 加固真正困难的地方,从来不是:
text
"选 AES 还是 RC4?"
而是:
text
如何让 ELF 保持可加载?
如何让 linker 正常工作?
如何处理 DT_NEEDED?
如何处理 JNI_OnLoad?
如何处理 .init_array?
如何解决不同 Android 版本?
如何解决不同 ABI?
如何降低启动耗时?
如何避免明文长期驻留磁盘?
如何控制运行时明文内存?
XopProtector 的 .text 加固方案给出的答案,是一种比较典型的工程化路线:
text
ELF 局部代码加密
+
SO 独立密钥
+
AES-GCM 密钥封装
+
Native Loader Hook
+
运行时 Materialize
+
Android linker 复用
+
DT_NEEDED 依赖处理
+
Eager / Lazy
+
异步预热
+
运行时内存解密
最终形成:
"磁盘上隐藏代码,运行时按需恢复,尽量缩短 Native 明文暴露时间,同时继续复用 Android 原生 ELF 加载体系。"
这比单纯的"SO 加密"四个字要复杂得多。
而从整个 Android APK 加固体系来看,真正成熟的方案通常也不是依赖某一个算法,而是:
text
DEX 加密
+
代码抽取
+
代码虚拟化
+
Native SO 加密
+
运行时动态恢复
+
RASP
通过多层保护,把攻击者从:
text
打开 APK → 直接看到代码
逐步推向:
text
分析 ELF
→ 分析 Shell
→ 分析 Key
→ 分析 Loader
→ 分析 Runtime
→ 处理 Hook
→ 处理反调试
→ 处理动态 Dump
最终达到的目标不是"不可破解",而是:
让逆向工程从一次静态分析,变成一个需要持续理解运行时环境、代码恢复机制和安全防护体系的复杂工程。
这正是现代 Android APK 加固体系的核心思想。
源码依据:
- XopProtector 项目主页:XopProtector GitHub Repository
SoSectionEncryptor.java:负责 Native Shell.bitcodeRC4 加密、ELF Key Symbol 写入等。BusinessSoProtector.java:负责业务 SO.text保护、ELF/relocation 检查、SO 策略与 Key 生成。business_so.cpp:负责运行时 SO Key、.text解密、dlopenHook、android_dlopen_ext、so_plain、DT_NEEDED、Lazy/Eager 等机制。- XopProtector 中文 README:项目整体能力、SO
.textRC4、Eager/Lazy 等说明。
下一篇:
《Android APK 加固原理(六):RASP 运行时安全防护------如何检测 Frida、Hook 与运行时攻击》
下一篇将继续沿着同一套源码分析,重点研究:
text
Frida 检测
Hook 检测
Maps 扫描
端口 / 进程检测
TracerPid
Inline Hook
ByteHook 自保护
Native Shell 自守护
风险评分
Threat Report
运行时阻断
也就是:
当 DEX、PVM1、PVM2、SO
.text都已经做好保护之后,XopProtector 如何防止攻击者在运行时把这些保护重新"打穿"。