目录
  1. 1. 一、为什么要对 SO 加固
    1. 1.1. 一.1 SO 在 Android 安全中的角色
    2. 1.2. 一.2 SO 攻击面分析
  2. 2. 二、编译期保护手段
    1. 2.1. 二.1 符号剥离(Strip)
    2. 2.2. 二.2 代码混淆(OLLVM)
      1. 2.2.1. 控制流平坦化(-fla)
      2. 2.2.2. 指令替换(-sub)
      3. 2.2.3. 虚假控制流(-bcf)
    3. 2.3. 二.3 字符串加密
  3. 3. 三、运行时保护手段
    1. 3.1. 三.1 代码段加密
    2. 3.2. 三.2 反调试(Anti-Debugging)
    3. 3.3. 三.3 反 Hook
    4. 3.4. 三.4 完整性校验
  4. 4. 四、AArch64 架构下的特殊考虑
    1. 4.1. 四.1 AArch64 与 ARM32 的关键差异
    2. 4.2. 四.2 AArch64 下的代码段加密注意点
    3. 4.3. 四.3 AArch64 下的反调试:断点指令检测
  5. 5. 五、SO 加固的防御纵深配置
    1. 5.1. 五.1 推荐的 SO 保护组合
    2. 5.2. 五.2 配置示例
    3. 5.3. 五.3 version_script.map(符号导出控制)
  6. 6. 六、AOSP 相关源码导读
    1. 6.1. 关键源码片段:linker 中 .init_array 的执行
  7. 7. 面试常考问题
【逆向安全技术-防护篇】so加固原理

一、为什么要对 SO 加固

在 Android 逆向攻防中,即便是做了一层 DEX 加固,攻击者仍可从 lib 目录下的 SO 文件下手——SO 中通常包含了核心算法、签名校验逻辑、加解密密钥等敏感信息。对 SO 的加固就是要在编译期和运行期对 Native 代码施加各类保护手段,使其难以被静态分析和动态调试。

一.1 SO 在 Android 安全中的角色

APK 中的 Native 代码(SO 文件)的安全角色:

┌─────────────────────────────────────────────────────┐
│ lib/armeabi-v7a/ 和 lib/arm64-v8a/ │
│ │
│ ├── 核心算法实现 │
│ │ - 加密/解密 (AES, RSA, ECC) │
│ │ - 签名生成与校验 │
│ │ - 图像/音视频编解码 │
│ │ - 游戏物理引擎/渲染引擎 │
│ │ │
│ ├── 安全敏感逻辑 │
│ │ - License 验证 │
│ │ - Root / Hook 检测 │
│ │ - 反调试逻辑 │
│ │ - 完整性校验 (CRC/Hash) │
│ │ - DEX 加解密 (加固中的壳 so) │
│ │ │
│ └── 隐藏数据 │
│ - 加密密钥 (分散在 .rodata 中) │
│ - 网络通信协议私密参数 │
│ - 许可证/API Token │
│ - 混淆后的配置数据 │
└─────────────────────────────────────────────────────┘

一.2 SO 攻击面分析

攻击者可以如何利用 SO 文件:

1. 静态分析
├── IDA Pro / Ghidra 反汇编 → 理解核心算法
├── strings 命令 → 提取硬编码的密钥/URL
├── objdump/nm → 获取导出函数列表
└── readelf → 分析 ELF 结构和依赖

2. 动态分析
├── Frida Interceptor.attach → Hook Native 函数
├── GDB/LLDB → 动态调试 so
├── /proc/pid/maps + /proc/pid/mem → 内存 dump
└── Frida Stalker → 指令级 trace

3. 修改/重打包
├── Patch 关键跳转 → 绕过 License 检查
├── 替换 so → 注入恶意代码
└── Hook JNI_OnLoad → 在初始化阶段注入

二、编译期保护手段

二.1 符号剥离(Strip)

编译时使用 -s 参数或调用 strip 工具删除符号表,使 IDA Pro 打开后无法看到函数名,只能看到 sub_1234 这样的无意义符号。

# NDK 编译时自动 strip(通常默认开启)
# Android.mk:
LOCAL_CFLAGS := -fvisibility=hidden

# 手动 strip
$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android-strip \
--strip-all libmylib.so

# 检查 strip 效果
readelf -s libmylib.so # 查看符号表(strip 后只剩 .dynsym)
nm -D libmylib.so # 查看动态符号表(导出函数)
objdump -T libmylib.so # 查看动态符号

# 使用版本脚本控制导出符号
# version_script.map:
# {
# global:
# JNI_OnLoad;
# Java_*; # 仅导出 JNI 函数
# local:
# *; # 隐藏其余所有符号
# };
# ldflags: -Wl,--version-script=version_script.map

Strip 前后对比:

Strip 前(.symtab 中有完整符号):
IDA Pro: 显示 "aes_encrypt", "verify_signature", "decrypt_payload"
strings: 可能泄露函数名常量

Strip 后:
IDA Pro: 显示 "sub_12A4C", "sub_138B0", "sub_14C20"
strings: 不包含函数名
攻击者需要从汇编逻辑推断函数用途,工作量倍增

二.2 代码混淆(OLLVM)

LLVM 编译套件提供 -mllvm -fla(控制流平坦化)、-mllvm -sub(指令替换)、-mllvm -bcf(虚假控制流)等混淆选项,将原本清晰的逻辑分支打散成由分发器控制的 switch-case 结构,使静态分析的控制流图变得极为复杂。

控制流平坦化(-fla)

原始控制流:
[入口] → [A] → [B] → [C] → [返回]
↘ [D] ↗

OLLVM 平坦化后:
[入口]

[分发器: switch(state)]
├── case 0: → [A]; state = 1; 跳回分发器
├── case 1: → [B]; state = 2 或 3; 跳回分发器
├── case 2: → [C]; state = 4; 跳回分发器
├── case 3: → [D]; state = 2; 跳回分发器
└── case 4: → [返回]

效果:
- 控制流从"可读的线性逻辑"变成"不可读的循环分发器"
- IDA Pro 的 Graph View 变得几乎无法理解
- 所有基本块都经过同一个分发器,掩盖了执行顺序

对应 C 代码的变化:

// 原始代码
bool verify_license(const char* key) {
if (key == NULL) return false;
if (strlen(key) != 32) return false;
if (!check_format(key)) return false;
return check_crypto(key);
}

// OLLVM -fla 混淆后的等效代码(伪代码示意)
bool verify_license(const char* key) {
int state = 0;
bool result;

while (true) {
switch (state) {
case 0:
if (key == NULL) { state = 1; } else { state = 2; }
break;
case 1:
result = false;
state = 8; // → 返回
break;
case 2:
if (strlen(key) != 32) { state = 3; }
else { state = 4; }
break;
case 3:
result = false;
state = 8;
break;
case 4:
if (!check_format(key)) { state = 5; }
else { state = 6; }
break;
case 5:
result = false;
state = 8;
break;
case 6:
result = check_crypto(key);
state = 8;
break;
case 8:
return result;
}
}
}

指令替换(-sub)

将简单的算术/逻辑操作替换为等效但更复杂的指令序列:

原始指令:
a = b + c

替换后(示例):
a = b - (-c)

a = (b ^ c) + 2 * (b & c)

t1 = b | c; t2 = b & c; a = t1 + t2

原始指令:
if (a == 0)

替换后:
if ((a ^ 0) == 0) // XOR with 0 (no-op but obscures)

if (!a && !(-a)) // 用更复杂的条件等价替换

虚假控制流(-bcf)

在真实的基本块之间插入永远不执行的分支(opaque predicates):

// 原始:
int result = compute(x);
return result;

// -bcf 混淆后(插入永不执行的假分支):
int result;
int opaque = (x * 0x3D79) % 0x5B5F; // opaque predicate

if (opaque == -1) { // 永远为 false
// 这里的代码永远不会执行
// 但会出现在反编译工具的输出中,混淆攻击者
result = fake_computation_1();
} else if (opaque == -2) { // 永远为 false
result = fake_computation_2();
} else {
result = compute(x);
}

// 更复杂的 opaque predicate:
// 利用数学恒等式构造"永远 true/false"的条件
int p = x * (x + 1) % 2; // 两个连续整数的乘积总是偶数,所以 p 永远为 0
if (p == 1) { /* 永不执行 */ }

二.3 字符串加密

自定义 LLVM Pass 在编译时将字符串常量加密存储,运行时调用统一的解密函数还原,防止通过 strings 命令或 IDA 的字符串窗口直接定位到敏感信息。

# 编译前(源码中的字符串)
"https://api.example.com/v1/auth"
"AES/CBC/PKCS5Padding"
"license_check_failed"

# 编译后(so 中的 .rodata section)
# 这些字符串被替换为加密后的版本
# 通过 strings 命令看到的是乱码

# LLVM Pass 的编译集成:
# cmake -DCMAKE_CXX_FLAGS="-Xclang -load -Xclang ./libStringObfuscator.so"

字符串加密 LLVM Pass 的关键逻辑(简化版):

// StringObfuscatorPass.cpp (LLVM Pass, 简化)
// 这是一个 LLVM Function Pass,在编译期对字符串常量进行变换

struct StringObfuscator : public FunctionPass {
virtual bool runOnFunction(Function &F) {
for (auto &BB : F) {
for (auto &I : BB) {
// 寻找全局字符串引用
if (auto *GEP = dyn_cast<GetElementPtrInst>(&I)) {
if (auto *GV = dyn_cast<GlobalVariable>(
GEP->getPointerOperand())) {
if (GV->hasInitializer()) {
if (auto *CDA = dyn_cast<ConstantDataArray>(
GV->getInitializer())) {
if (CDA->isString()) {
StringRef str = CDA->getAsString();
// 1. 生成随机密钥
// 2. XOR/AES 加密字符串
// 3. 替换 GV 的初始值为加密后的数据
// 4. 在函数入口插入解密调用
encryptAndReplace(F, I, str);
}
}
}
}
}
}
}
return true;
}

void encryptAndReplace(Function &F, Instruction &I, StringRef str) {
// 1. 生成一个随机单字节 XOR 密钥
uint8_t key = rand() % 256;

// 2. 加密字符串
std::string encrypted = str;
for (char &c : encrypted) c ^= key;

// 3. 创建加密后的全局常量
// 4. 在 I 之前插入解密 IR 指令(XOR key)
// decrypt = encrypted[i] ^ key;
}
};

运行时解密函数:

// 运行时字符串解密(在 .init_array 或首次调用时执行)
static const uint8_t xor_key = 0x4F; // 编译期随机生成

// 在 so 加载时解密所有字符串
static char* encrypted_strings[] = {
(char*)(enc_str_1), // "https://..."
(char*)(enc_str_2), // "AES/CBC..."
// ...
};

__attribute__((constructor))
void decrypt_strings() {
for (int i = 0; i < sizeof(encrypted_strings) / sizeof(char*); i++) {
char* p = encrypted_strings[i];
while (*p) {
*p ^= xor_key;
p++;
}
}
// 更安全的做法:不要在 constructor 中全部解密
// 而是在每个字符串被使用前才解密,用完立即重新加密
}

三、运行时保护手段

三.1 代码段加密

使用链接脚本或编译后处理工具,将 .text 段中的函数加密存放,在 .init_array(动态链接器最先执行的初始化回调)中解密恢复后清空解密密钥内存区域。

编译期处理的完整流程:

1. 正常编译 so
clang -shared *.cpp -o libprotect.so

2. 链接脚本标记加密 section
将关键函数放入 .encrypted_text section

3. 编译后工具 encrypt_section.py
readelf 解析 libprotect.so
找到 .encrypted_text section (sh_offset, sh_size)
AES 加密该 section 的数据
生成的密钥分成 3-5 个片段
将密钥片段嵌入到其他 section 的间隙中
修改 .init_array,添加解密函数的调用

4. 输出:加固后 libprotect.so
// .init_array 中的解密函数
__attribute__((constructor))
void decrypt_text_section() {
// Step 1: 从 /proc/self/maps 获取 so 加载基址
void* base = get_module_base("libprotect.so");
if (!base) goto cleanup;

// Step 2: 定位 .encrypted_text section
Elf64_Ehdr* ehdr = (Elf64_Ehdr*)base;
Elf64_Shdr* shdr = find_section(ehdr, ".encrypted_text");
if (!shdr) goto cleanup;

void* text_start = (char*)base + shdr->sh_offset;
size_t text_size = shdr->sh_size;

// Step 3: 收集密钥片段(分散存储在不同的全局变量中)
uint8_t key[32];
collect_key_fragments(key);

// Step 4: 修改内存页权限为可写
void* page_start = (void*)((uintptr_t)text_start & ~(PAGE_SIZE - 1));
size_t page_size = ((uintptr_t)text_start + text_size) - (uintptr_t)page_start;
mprotect(page_start, page_size, PROT_READ | PROT_WRITE);

// Step 5: AES 解密
AES_CTX ctx;
AES_init_ctx(&ctx, key);
AES_CTR_xcrypt(&ctx, (uint8_t*)text_start, text_size);

// Step 6: 刷新指令缓存(关键!)
__builtin___clear_cache((char*)text_start,
(char*)text_start + text_size);

// Step 7: 恢复执行权限(移除写权限)
mprotect(page_start, page_size, PROT_READ | PROT_EXEC);

cleanup:
// Step 8: 清零栈上的密钥(防止被内存 dump 获取)
memset(key, 0, sizeof(key));
}

// 密钥片段分散存储
__attribute__((section(".key_fragment_1")))
static const uint8_t key_part1[] = {0xDE, 0xAD, 0xBE, 0xEF, ...};

__attribute__((section(".key_fragment_2")))
static const uint8_t key_part2[] = {0xCA, 0xFE, 0xBA, 0xBE, ...};

void collect_key_fragments(uint8_t* out_key) {
// 从多个分散的位置组合出真正的密钥
// 组合方式:XOR ⊕, 交织, 或自定义算法
for (int i = 0; i < 16; i++) {
out_key[i] = key_part1[i] ^ key_part2[i] ^ 0x55;
}
}

三.2 反调试(Anti-Debugging)

最常见的做法是通过 ptrace(PTRACE_TRACEME, 0, 0, 0) 自附加——每个进程最多被一个调试器附加,一旦自占成功,攻击者就无法再用 gdb/lldb 附加。同时循环读取 /proc/self/status 中的 TracerPid 字段,若不为 0 则立即终止。检查 /proc/self/wchan 内容也可以检测 ptrace 是否挂起。

// SO 加固常用的反调试检测
void anti_debug() {
// 方式1: ptrace 自占
if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
__android_log_print(ANDROID_LOG_ERROR, "protect", "ptrace failed, exit");
_exit(0);
}
// 方式2: 循环检测 TracerPid
while (1) {
FILE *fp = fopen("/proc/self/status", "r");
char buf[1024];
while (fgets(buf, sizeof(buf), fp)) {
if (strncmp(buf, "TracerPid:", 10) == 0) {
int pid = atoi(buf + 10);
if (pid != 0) _exit(0);
}
}
fclose(fp);
sleep(1);
}
}

更全面的 Native 反调试检测:

// 检测 1: /proc/self/status TracerPid
bool check_tracer_pid() {
char buf[1024];
FILE* fp = fopen("/proc/self/status", "r");
if (!fp) return false;

while (fgets(buf, sizeof(buf), fp)) {
if (strncmp(buf, "TracerPid:", 10) == 0) {
int pid = atoi(buf + 10);
fclose(fp);
return pid != 0;
}
}
fclose(fp);
return false;
}

// 检测 2: /proc/self/wchan 内容
bool check_wchan() {
char buf[256];
int fd = open("/proc/self/wchan", O_RDONLY);
if (fd < 0) return false;

int n = read(fd, buf, sizeof(buf) - 1);
close(fd);

if (n > 0) {
buf[n] = '';
// 被 ptrace 挂起时,wchan 通常为 "ptrace_stop" 或 "signal"
if (strstr(buf, "ptrace_stop") || strstr(buf, "signal")) {
return true;
}
}
return false;
}

// 检测 3: 检测 Frida 线程
bool detect_frida_threads() {
DIR* dir = opendir("/proc/self/task");
if (!dir) return false;

struct dirent* entry;
while ((entry = readdir(dir)) != NULL) {
if (entry->d_type == DT_DIR && isdigit(entry->d_name[0])) {
char path[256];
snprintf(path, sizeof(path),
"/proc/self/task/%s/status", entry->d_name);

FILE* fp = fopen(path, "r");
if (fp) {
char line[512];
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, "frida") ||
strstr(line, "gum-js-loop")) {
fclose(fp);
closedir(dir);
return true;
}
}
fclose(fp);
}
}
}
closedir(dir);
return false;
}

// 检测 4: 检测 Frida 的内存映射
bool detect_frida_maps() {
char line[512];
FILE* fp = fopen("/proc/self/maps", "r");
if (!fp) return false;

while (fgets(line, sizeof(line), fp)) {
if (strstr(line, "frida") ||
strstr(line, "gum-js-loop") ||
strstr(line, "linjector") ||
strstr(line, "frida-agent") ||
strstr(line, "gadget")) {
fclose(fp);
return true;
}
}
fclose(fp);
return false;
}

// 检测 5: 检测 Frida 的命名管道(named pipe)
bool detect_frida_pipe() {
char buf[512];
FILE* fp = fopen("/proc/self/net/tcp", "r");
if (!fp) return false;

while (fgets(buf, sizeof(buf), fp)) {
// frida-server 默认监听 27042 端口
if (strstr(buf, "69A2")) { // 27042 的 hex
fclose(fp);
return true;
}
}
fclose(fp);
return false;
}

// 综合反调试
void* anti_debug_monitor(void* arg) {
while (1) {
if (check_tracer_pid() || check_wchan()
|| detect_frida_maps() || detect_frida_pipe()) {

// 不要在检测到后立即退出(给攻击者精准的时间反馈)
// 而是延迟 3-15 秒随机退出
int delay = 3 + (rand() % 12);
sleep(delay);
_exit(0);
}
sleep(5); // 每 5 秒检查一次
}
return NULL;
}

三.3 反 Hook

检测 Xposed 框架可以通过检查 /proc/self/maps 中是否加载了 xposed 相关模块;检测 Frida 可以通过扫描自身进程内存中的 frida-agent 特征字符串或使用 open 调用检测 /proc/self/task/<tid>/maps 中是否存在 frida 线程段。此外还可以通过 dlopen/dlsym 方式直接获取原始系统调用(如原始 ptrace),避免被上层 hook 拦截。

// 反 Hook 检测集合

// 检测 Xposed
bool detect_xposed() {
// 检查 maps 中是否有 Xposed 模块
FILE* fp = fopen("/proc/self/maps", "r");
char line[512];
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, "xposed") || strstr(line, "XposedBridge")) {
fclose(fp);
return true;
}
}
fclose(fp);

// 检查系统属性
// ro.dalvik.vm.native.bridge 在 Xposed 环境下被修改
char prop[PROP_VALUE_MAX];
__system_property_get("ro.dalvik.vm.native.bridge", prop);
if (prop[0] != '') {
return true;
}

return false;
}

// 绕过 Hook:直接使用 syscall 而非 libc 包装
#include <sys/syscall.h>
#include <unistd.h>

// 直接通过 syscall 打开文件(绕过对 fopen/open 的 Hook)
int my_open_direct(const char* path, int flags) {
#ifdef __aarch64__
// AArch64 的 openat syscall number = 56
register long x8 asm("x8") = __NR_openat;
register long x0 asm("x0") = AT_FDCWD;
register long x1 asm("x1") = (long)path;
register long x2 asm("x2") = flags;
register long x3 asm("x3") = 0; // mode

asm volatile("svc #0"
: "=r"(x0)
: "r"(x8), "r"(x0), "r"(x1), "r"(x2), "r"(x3)
: "memory", "cc");
return (int)x0;
#else
// ARM32 使用 open
register long r7 asm("r7") = __NR_open;
register long r0 asm("r0") = (long)path;
register long r1 asm("r1") = flags;

asm volatile("svc #0"
: "=r"(r0)
: "r"(r7), "r"(r0), "r"(r1)
: "memory", "cc");
return (int)r0;
#endif
}

// 直接通过 syscall 进行 ptrace(绕过对 ptrace 函数的 Hook)
long my_ptrace_direct(int request, pid_t pid, void* addr, void* data) {
#ifdef __aarch64__
register long x8 asm("x8") = __NR_ptrace;
register long x0 asm("x0") = request;
register long x1 asm("x1") = pid;
register long x2 asm("x2") = (long)addr;
register long x3 asm("x3") = (long)data;

asm volatile("svc #0"
: "=r"(x0)
: "r"(x8), "r"(x0), "r"(x1), "r"(x2), "r"(x3)
: "memory", "cc");
return x0;
#else
register long r7 asm("r7") = __NR_ptrace;
register long r0 asm("r0") = request;
register long r1 asm("r1") = pid;
register long r2 asm("r2") = (long)addr;
register long r3 asm("r3") = (long)data;

asm volatile("svc #0"
: "=r"(r0)
: "r"(r7), "r"(r0), "r"(r1), "r"(r2), "r"(r3)
: "memory", "cc");
return r0;
#endif
}

三.4 完整性校验

.init_array 中计算 .text 段的 CRC32 或 MD5 哈希值,与编译期预埋的期望值比较。不一致则说明 SO 被静态修改或打了补丁,立即退出进程。

// 完整性校验增强版

// 编译期预埋的校验值(分散存储以避免直接搜索)
static const uint32_t CRC_PART_1 = 0xABCD1234;
static const uint32_t CRC_PART_2 = 0x567890EF;

// 期望的完整 CRC = XOR of parts
#define EXPECTED_CRC (CRC_PART_1 ^ CRC_PART_2)

// 检测 .text 和 .rodata 段的完整性
bool verify_integrity() {
void* base = get_module_base(NULL); // 获取自身 so 基址
if (!base) return false;

Elf64_Ehdr* ehdr = (Elf64_Ehdr*)base;

// 验证 ELF magic
if (memcmp(ehdr->e_ident, ELFMAG, SELFMAG) != 0) return false;

Elf64_Shdr* shdr = (Elf64_Shdr*)((char*)base + ehdr->e_shoff);
const char* shstrtab = (char*)base +
((Elf64_Shdr*)((char*)base + ehdr->e_shoff)
+ ehdr->e_shstrndx)->sh_offset;

bool text_ok = false, rodata_ok = false;

for (int i = 0; i < ehdr->e_shnum; i++) {
const char* name = shstrtab + shdr[i].sh_name;

if (strcmp(name, ".text") == 0) {
uint32_t crc = crc32(0L, Z_NULL, 0);
crc = crc32(crc, (uint8_t*)base + shdr[i].sh_offset,
shdr[i].sh_size);
text_ok = (crc == EXPECTED_CRC);
}

if (strcmp(name, ".rodata") == 0) {
uint32_t crc = crc32(0L, Z_NULL, 0);
crc = crc32(crc, (uint8_t*)base + shdr[i].sh_offset,
shdr[i].sh_size);
rodata_ok = (crc == EXPECTED_RODATA_CRC);
}
}

return text_ok && rodata_ok;
}

// 对 ELF header 的非破坏性修改检测
// 特别检测攻击者在 ELF header 注入 shellcode 的行为
bool check_elf_header_integrity() {
// 检查 Program Header 数量是否被篡改
// 检查 Section Header 偏移是否被篡改
// 检查 .dynamic section 中的字符串是否被替换
void* base = get_module_base(NULL);
Elf64_Ehdr* ehdr = (Elf64_Ehdr*)base;

// 攻击者有时在 SO 头部插入 shellcode 并修改入口点
// 检查 e_entry 是否指向合法的 .text 范围
if (ehdr->e_entry < 0x1000) { // 入口点不应在 ELF header 区域内
return false;
}

return true;
}

四、AArch64 架构下的特殊考虑

四.1 AArch64 与 ARM32 的关键差异

ARM32 (ARMv7-A):
├── 指令:32-bit ARM 或 16+32-bit Thumb-2
├── PC 作为通用寄存器 R15(可读可写)
├── 条件执行:几乎所有指令都支持条件后缀 (EQ, NE, GT, ...)
├── IT 块:Thumb-2 的 if-then 预测执行
├── BLX 用于 ARM ↔ Thumb 模式切换
└── inline hook:LDR PC, [PC, #-4] 跳转

AArch64 (ARMv8-A):
├── 指令:固定 32-bit
├── PC 不是通用寄存器(不能直接访问)
├── 条件执行:仅少数指令 (CSEL, CSET, ...)
├── 更多寄存器:31 个通用寄存器 (X0-X30)
├── 链接寄存器 LR (X30)
├── 帧指针 FP (X29)
├── inline hook:LDR X16, #8; BR X16
└── 需要 DSB + ISB 来同步指令缓存

四.2 AArch64 下的代码段加密注意点

// AArch64 下的代码段加密/解密关键差异
void aarch64_decrypt_text() {
// ... 获取 base 和 section 信息 ...

// Step 1: 修改内存页权限
// AArch64 的页大小可能为 4KB 或 64KB
// 使用 sysconf(_SC_PAGESIZE) 获取实际页大小
long page_size = sysconf(_SC_PAGESIZE);
void* page_start = (void*)((uintptr_t)text_start & ~(page_size - 1));
size_t page_len = ((uintptr_t)text_start + text_size) - (uintptr_t)page_start;

mprotect(page_start, page_len, PROT_READ | PROT_WRITE);

// Step 2: 解密
aes_ctr_decrypt(text_start, text_size, key);

// Step 3: AArch64 缓存刷新
// 使用 __builtin___clear_cache(编译器自动生成正确的序列)
__builtin___clear_cache((char*)text_start, (char*)text_start + text_size);

// AArch64 下的缓存刷新等价于以下指令序列:
// DC CVAU, Xn ; 数据缓存清理到统一点
// DSB ISH ; 数据同步屏障
// IC IVAU, Xn ; 指令缓存无效化到统一点
// DSB ISH ; 再次数据同步屏障
// ISB ; 指令同步屏障(确保后续指令从缓存读取)

// Step 4: 恢复权限
mprotect(page_start, page_len, PROT_READ | PROT_EXEC);
}

四.3 AArch64 下的反调试:断点指令检测

// AArch64:检测软件断点(BRK 指令)
// 调试器通过插入 BRK #0 来实现断点
// A64 BRK 指令编码:0xD4200000 | (imm16 << 5)
// BRK #0 = 0xD4200000

bool detect_breakpoints(void* func_start, size_t func_size) {
uint32_t* code = (uint32_t*)func_start;
size_t count = func_size / 4;

for (size_t i = 0; i < count; i++) {
// 检测 BRK 指令(opcode 0xD4200000)
if ((code[i] & 0xFFE0001F) == 0xD4200000) {
// 发现断点指令
return true;
}
}
return false;
}

// 检测硬件断点(Watchpoint / Hardware Breakpoint)
// ARM64 有最多 6 个硬件断点寄存器
bool detect_hardware_breakpoints() {
uint32_t dbgbcr[6]; // Breakpoint Control Registers
uint32_t dbgwcr[4]; // Watchpoint Control Registers

// 读取硬件断点控制寄存器
// 如果使能位为 1,说明有硬件断点被设置
// 需要访问 CP14 调试寄存器(特权模式)
// 通常在 EL0(用户态)无法直接访问,需要特殊处理
// ...
return false;
}

五、SO 加固的防御纵深配置

五.1 推荐的 SO 保护组合

安全需求等级与 SO 保护组合:

Level 1 - 基础保护(大多数应用)
├── Strip symbols (-fvisibility=hidden)
├── 基础字符串加密 (LLVM Pass)
└── ProGuard/R8 obfuscation (Java层)

Level 2 - 中等保护(有重要 IP 的应用)
├── Level 1 所有措施
├── OLLVM -fla (控制流平坦化)
├── 完整性校验 (CRC32 of .text section)
├── 基础反调试 (ptrace + TracerPid)
└── 反 Hook 检测 (Frida/Xposed)

Level 3 - 高保护(金融/支付/安全应用)
├── Level 2 所有措施
├── OLLVM -sub + -bcf (指令替换 + 虚假控制流)
├── 代码段加密 (.encrypted_text + .init_array 解密)
├── 高级反调试 (多线程检测 + syscall 直调)
├── 反 Frida (端口检测 + 内存扫描)
├── 签名校验 (Java + Native 双重)
└── 环境检测 (Root / Emulator / Debug)

Level 4 - 极高级保护(银行/加密货币/DMR)
├── Level 3 所有措施
├── VMP 保护核心算法
├── 硬件安全模块 (TEE / SE)
├── 定制 OLLVM Pass (自研混淆策略)
├── 服务端完整性验证
└── 动态保护(AI 驱动的运行时异常检测)

五.2 配置示例

# Android.mk - SO 加固编译配置示例
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)

LOCAL_MODULE := myprotectedlib

LOCAL_SRC_FILES := \
main.cpp \
crypto.cpp \
integrity_check.cpp \
anti_debug.cpp \
anti_hook.cpp \
str_decrypt.cpp

# OLLVM 混淆选项
LOCAL_CFLAGS += -mllvm -fla # 控制流平坦化(核心函数)
LOCAL_CFLAGS += -mllvm -sub # 指令替换
LOCAL_CFLAGS += -mllvm -bcf # 虚假控制流
LOCAL_CFLAGS += -fvisibility=hidden # 符号隐藏

# 安全编译选项
LOCAL_CFLAGS += -fstack-protector-all # 栈保护(所有函数)
LOCAL_CFLAGS += -fPIE -fPIC # 位置无关代码
LOCAL_CFLAGS += -Wformat -Wformat-security
LOCAL_CFLAGS += -D_FORTIFY_SOURCE=2 # Fortify 源码加强
LOCAL_CFLAGS += -O2 -s # 优化 + strip

# 链接选项
LOCAL_LDFLAGS += -Wl,-z,relro # RELRO(只读重定位)
LOCAL_LDFLAGS += -Wl,-z,now # 立即绑定(Full RELRO)
LOCAL_LDFLAGS += -Wl,--hash-style=gnu # GNU hash(加快符号查找)
LOCAL_LDFLAGS += -Wl,--version-script=version_script.map # 符号版本控制
LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL

LOCAL_LDLIBS := -llog -lz

include $(BUILD_SHARED_LIBRARY)

五.3 version_script.map(符号导出控制)

# 仅导出 JNI 必需的函数,隐藏其余所有内部符号
{
global:
JNI_OnLoad;
JNI_OnUnload;
Java_com_example_*; # 仅导出 JNI 方法

local:
*; # 其余全部隐藏
};

六、AOSP 相关源码导读

理解 so 加载和动态链接机制是深入掌握 SO 加固原理的基础:

模块 源码路径 关键内容
Dynamic Linker /bionic/linker/linker.cpp so 加载、重定位、.init_array 执行
linker_soinfo /bionic/linker/linker_soinfo.cpp soinfo 结构体管理
linker_phdr /bionic/linker/linker_phdr.cpp Program Header 处理
linker_mapped_file_fragment /bionic/linker/ so 内存映射
ELF 定义 /bionic/libc/kernel/uapi/linux/elf.h ELF 结构体定义
libc syscall /bionic/libc/bionic/ syscall 函数实现(ptrace, open, mmap 等)
stdio /bionic/libc/stdio/ fopen, fgets 等文件操作
AArch64 syscall /bionic/libc/arch-arm64/syscalls/ ARM64 syscall 表

关键源码片段:linker 中 .init_array 的执行

// bionic/linker/linker.cpp (AOSP,简化)
// 这是 .init_array 构造函数被调用的地方(加固代码执行的第一个位置)

void soinfo::call_constructors() {
// ... 递归调用依赖 so 的 constructors ...

// 遍历 .dynamic section
for (ElfW(Dyn)* d = dynamic; d->d_tag != DT_NULL; ++d) {
switch (d->d_tag) {
case DT_INIT:
// 调用 DT_INIT 函数(如果存在)
// 这是加固代码最早执行的入口之一
break;

case DT_INIT_ARRAY:
// 获取 init_array 的数组指针
ElfW(Addr)* array = reinterpret_cast<ElfW(Addr)*>(
load_bias + d->d_un.d_ptr);
// .init_array 的大小来自 DT_INIT_ARRAYSZ
break;
}
}

// 按顺序调用所有 .init_array 条目
// 加固 SO 的解密函数在此处被调用
for (size_t i = 0; i < init_array_count; ++i) {
reinterpret_cast<linux_linker_function_t>(
load_bias + init_array[i])();
}
}

面试常考问题

Q1: OLLVM 的控制流平坦化(-fla)是如何工作的?它的局限性和绕过方法是什么?

A:

工作原理:
(1)LLVM Pass 在 IR(中间表示)层面对函数做变换。将原始的基本块序列改造为:一个中心分发器(switch-case) + 所有基本块被重排为无顺序的 case 分支。
(2)引入状态变量(state variable),每个基本块执行完后更新 state 并跳回分发器,分发器根据 state 决定下一个基本块。
(3)这会打破原始的顺序控制流结构,使反编译工具生成的伪代码变成一个巨大的 while(true) { switch(state) { … } } 结构。

局限性:
(1)性能开销:每个基本块都需要额外的跳转指令回到分发器,分发器本身也有开销。整体性能下降 2x-5x。
(2)体积增大:引入的状态变量、分发器代码和额外跳转指令导致代码膨胀。
(3)可被反混淆:学术界有专门针对 OLLVM -fla 的反混淆算法,通过符号执行(Symbolic Execution)恢复控制流图。Angr 和 Miasm 可以自动化还原部分平坦化控制流。
(4)分发器特征明显:固定的 switch-case 分发模式是 OLLVM -fla 的指纹特征,容易被检测和针对性 bypass。

绕过方法:
(1)符号执行恢复:使用 Angr 框架对混淆函数进行符号执行,记录所有可行路径和状态转移关系,重建原始控制流。
(2)Trace 分析:使用 Frida Stalker 动态追踪函数的实际执行路径,收集足够多的执行 trace 后重建控制流。
(3)静态模式匹配:识别分发器(通常是 switch-case + 状态变量更新 + 跳回分发器)的典型特征,自动移除分发器逻辑。
(4)机器学习辅助:训练模型识别平坦化控制流中”真正的基本块”和”分发器”之间的区别。

AOSP 中 LLVM 工具链路径:/prebuilts/clang/host/linux-x86/ 下的 NDK 编译器链支持 OLLVM 的集成。

Q2: ptrace 自占反调试能否被绕过?如何构造针对性的绕过工具?

A:

可以绕过。攻击方法按难度递增排列:

方法一:Frida spawn 模式
在应用启动前(ptrace 尚未执行)就注入 frida-agent。使用 frida -U -f com.target.app -l script.js --no-pause。此时 frida-server 先于应用进程执行 ptrace(TRACEME),占用了唯一的调试位,应用的 ptrace 调用会失败。

方法二:Hook ptrace 系统调用
使用 Frida Interceptor 或 Magisk 模块 Hook libc 的 ptrace 函数,使其始终返回 0(假装成功):

Interceptor.attach(Module.findExportByName(null, "ptrace"), {
onEnter: function(args) { this.request = args[0].toInt32(); },
onLeave: function(retval) {
if (this.request === 0) { // PTRACE_TRACEME
retval.replace(0); // 返回 0 = 成功
}
}
});

方法三:内核级修改
使用 Magisk 模块(如 MagiskHide、Shamiko)在内核层修改 ptrace 行为,允许多个 tracer 同时附加。这需要修改内核的 ptrace 实现(/kernel/msm-4.19/kernel/ptrace.c)。

方法四:修改 so 中的反调试代码
直接 patch 应用 so 中的 ptrace 调用:将 BL ptrace 替换为 MOV R0, #0(ARM32)或 MOV X0, #0(AArch64),使反调试代码以为自己成功执行了 ptrace(TRACEME)。

方法五:使用硬件调试器
JTAG/SWD 硬件调试器绕过所有软件级别的反调试。CPU 的调试接口(如 ARM CoreSight DAP)不经过操作系统,直接访问 CPU 内部状态。

但加固的策略是组合使用多种检测(ptrace + TracerPid + wchan + 端口检测 + 内存扫描),任何单一手段被绕过都不会导致整体失效。全面的绕过需要分析并处理所有检测点。

Q3: 代码段加密与普通 SO 加载有什么不同?加密 SO 的加载流程涉及哪些关键系统调用?

A:

普通 SO 加载流程:
(1)linker 通过 open 打开 SO 文件。
(2)mmap(通常使用 MAP_PRIVATE)将 SO 文件映射到进程地址空间。.text 段映射为 PROT_READ | PROT_EXEC(不可写)。
(3)处理重定位(Relocation):将符号引用解析为实际地址。
(4)调用 .init_array 中的构造函数。

加密 SO 的区别:
(1)节区修改:.text 段在 SO 文件中是加密存储的。编译后使用后处理工具加密 .text 段内容。
(2).init_array 差异:加密 SO 的 .init_array 中包含解密函数(排在构造函数列表的最前面),这些解密函数会在其他构造函数之前执行。
(3)权限修改:解密函数先调用 mprotect.text 段所在的内存页权限从 PROT_READ | PROT_EXEC 改为 PROT_READ | PROT_WRITE(使代码可写)。
(4)解密执行:使用 AES/XOR 等算法在内存中解密 .text 段。
(5)缓存刷新:调用 __builtin___clear_cache(等价于 DC + DSB + IC + ISB 指令序列)确保 CPU 的指令缓存看到的是解密后的新指令。
(6)权限恢复:再次调用 mprotect 将权限恢复为 PROT_READ | PROT_EXEC(移除写权限)。
(7)继续加载:解密完成后,执行后续的 .init_array 构造函数。

关键系统调用序列:

open("libprotect.so", O_RDONLY)
→ mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0)
→ mmap(text_offset, text_size, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED, fd, text_file_offset)
→ close(fd)

// ... linker 处理重定位 ...

// .init_array 执行(解密)
mprotect(text_page_start, text_page_size, PROT_READ|PROT_WRITE) // 使代码可写
→ AES_CTR_decrypt(text_start, text_size, key) // 解密
→ __builtin___clear_cache(text_start, text_end) // Cache flush
→ mprotect(text_page_start, text_page_size, PROT_READ|PROT_EXEC)// 恢复执行权限

AOSP 中 linker 源码路径:/bionic/linker/linker.cpp(soinfo::call_constructors 负责执行 .init_array)和 /bionic/linker/linker_soinfo.cpp(soinfo::prelink_image 负责解析 .dynamic section)。

Q4: 如何检测一个 SO 文件是否被加固?静态分析加固 SO 的第一步该怎么做?

A:

检测 SO 加固的迹象:
(1).text 段的熵值异常:使用 ent 工具或 Python 计算 .text 段的 Shannon 熵。正常编译的代码熵值在 5.0-6.5,加密的代码段熵值接近 7.5-8.0(接近随机数据)。

(2)Section 异常:使用 readelf -S libxxx.so 检查 section headers。加固 SO 可能包含自定义 section(如 .encrypted_text.key_fragment 等),或 section headers 被完全剥离。

(3).init_array 异常内容:检查 .init_array 的大小和内容。正常 SO 的 .init_array 通常很小(1-5 个条目),加固 SO 可能包含大量构造函数(用于解密、反调试、签名校验等初始化)。

(4)Segment 权限异常:使用 readelf -l libxxx.so 检查 LOAD segment。正常 SO 的 text segment 为 RE(Read+Execute),加固 SO 可能在运行时修改为 RW(Read+Write)。

(5)符号表异常:使用 nm -D libxxx.so 检查动态符号。加固 SO 可能导出异常的符号(如 ptrace、mmap、mprotect 等系统调用的包装函数)。

静态分析第一步:
(1)file libxxx.so → 确认 file type 和架构。
(2)readelf -h libxxx.so → 查看 ELF header 基本信息。
(3)readelf -l libxxx.so → 查看 Program Headers(LOAD segment 的布局)。
(4)readelf -S libxxx.so → 查看 Section Headers(如果未被 strip)。
(5)readelf -d libxxx.so → 查看 .dynamic section(DT_INIT、DT_INIT_ARRAY 等)。
(6)nm -D libxxx.so → 查看动态符号(导出函数)。
(7)计算各 section 的熵值,判断是否存在加密段。
(8)使用 IDA Pro 或 Ghidra 加载 SO,关注:
- .init_array 中的函数(IDA 可以在 Exports 窗口中看到 init 函数)
- JNI_OnLoad 函数(如果是 JNI 库)
- 导出函数(动态符号表)
- 查找对 ptracemprotectcacheflush__clear_cache 的引用

Q5: SO 加固方案中的字符串加密、符号剥离、OLLVM 混淆和代码段加密四种手段,各自的防护目标是什么?它们相互之间如何配合?

A:

各手段的防护目标:
(1)符号剥离:阻止攻击者通过函数名理解代码结构。防护目标是将 IDA Pro/Ghidra 中的函数列表从”有意义的函数名”变成”无意义的 sub_XXXX”。攻击者需要手动分析每个函数的功能,无法通过函数名快速定位目标逻辑。

(2)字符串加密:阻止攻击者通过 strings 命令或 IDA 字符串窗口快速定位关键代码。防护目标是掩盖敏感信息(密钥、URL、API 端点、文件路径),迫使攻击者从寄存器/栈中动态获取字符串内容。攻击者若依赖静态字符串搜索来定位关键逻辑(如搜索 “AES” 找到加密函数),将完全失效。

(3)OLLVM 混淆:阻止攻击者通过静态反编译理解控制流和算法逻辑。防护目标是将清晰的控制流转换为极其复杂的 switch-case 分发结构,使代码的语义理解变得吃力。即使攻击者定位到了目标函数,也需要花费大量时间才能理解其逻辑。

(4)代码段加密:阻止攻击者直接从 SO 文件中 extract 明文代码进行静态分析。防护目标是确保 SO 文件中的 .text 段是乱码,攻击者无法直接在 IDA Pro 中分析(看到的都是加密数据)。

配合关系(分层递进):
字符串加密 → 符号剥离 → OLLVM 混淆 → 代码段加密

(1)代码段加密是第一道防线:如果攻击者没有绕过(获得解密后的 .text),后面的所有保护手段都不需要被触发。加密使得 SO 在磁盘上是不可分析的。

(2)OLLVM 混淆是第二道防线:如果攻击者绕过了代码段加密(通过内存 dump 获取了解密后的 .text),面对的是 OLLVM 深度混淆后的代码。控制流平坦化、指令替换和虚假控制流使得反编译结果极其复杂。

(3)符号剥离是第三道防线:配合 OLLVM 混淆,攻击者看不到函数名,在数百个 sub_XXXX 函数中找到关键函数如同大海捞针。

(4)字符串加密是最后一道防线:即使攻击者找到了目标函数,也无法通过字符串引用快速理解代码意图。密钥和 API 端点不可以通过静态分析直接提取。

配合攻击场景的理解:假设攻击者想要逆向一个付费算法。(1)SO 文件加密 → 需要先运行时 dump 解密后的 SO;(2)dump 后的 SO 函数全是 sub_XXXX → 需要通过行为分析定位目标函数;(3)定位后看到的代码是 OLLVM 混淆后的 switch-case 循环 → 需要符号执行或长时间人工分析;(4)想通过字符串搜索快速定位算法相关代码 → 所有敏感字符串都是加密的。

每一步都显著增加攻击时间,四重组合使得整体攻击成本呈乘法级增长而非加法级增长。这就是纵深防御在 SO 保护中的体现。

打赏
  • 微信
  • 支付宝

评论