Android APK 加固原理(五):SO `.text` 段加密、ELF 加载与运行时动态解密

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,通过 Hook dlopen / 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 .bitcode RC4 加密、ELF Key Symbol 写入等。
  • BusinessSoProtector.java:负责业务 SO .text 保护、ELF/relocation 检查、SO 策略与 Key 生成。
  • business_so.cpp:负责运行时 SO Key、.text 解密、dlopen Hook、android_dlopen_extso_plain、DT_NEEDED、Lazy/Eager 等机制。
  • XopProtector 中文 README:项目整体能力、SO .text RC4、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 如何防止攻击者在运行时把这些保护重新"打穿"。

相关推荐
lerhxx1 小时前
R3F 第一人称漫游与碰撞检测:Pointer Lock + 不穿墙的滑墙秘诀(中)
前端·javascript·three.js
LSCLikeApple2 小时前
入门promise
前端
爬楼的猪2 小时前
DeepSeek Harness 本地安装和对话过程的网络问题
前端
labixiong2 小时前
button按钮原生开关弹窗,零 JS 搞定80%交互场景
前端·javascript·html
悟空瞎说2 小时前
Vue3 应用实例 API
前端
隔岸观火烧连营2 小时前
如何用 WebCodecs 在浏览器里实现高清录屏 —— 无插件、无水印、直接导出 MP4
前端·javascript
杉氧2 小时前
页面栈与路由:React Navigation 与 Expo Router 深度实践
android·前端·react native
用户931456355662 小时前
别再满屏 try-catch 了:聊聊异常处理的正确姿势
前端
葡萄城技术团队2 小时前
ERP 内置 BI(上):为什么业务部门还在用 Excel 做分析?
前端