一、为什么要对 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 这样的无意义符号。
LOCAL_CFLAGS := -fvisibility=hidden
$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android-strip \ --strip-all libmylib.so
readelf -s libmylib.so nm -D libmylib.so objdump -T libmylib.so
|
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); }
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;
int result; int opaque = (x * 0x3D79) % 0x5B5F;
if (opaque == -1) { result = fake_computation_1(); } else if (opaque == -2) { result = fake_computation_2(); } else { result = compute(x); }
int p = x * (x + 1) % 2; if (p == 1) { }
|
二.3 字符串加密
自定义 LLVM Pass 在编译时将字符串常量加密存储,运行时调用统一的解密函数还原,防止通过 strings 命令或 IDA 的字符串窗口直接定位到敏感信息。
"https://api.example.com/v1/auth" "AES/CBC/PKCS5Padding" "license_check_failed"
|
字符串加密 LLVM 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(); encryptAndReplace(F, I, str); } } } } } } } return true; }
void encryptAndReplace(Function &F, Instruction &I, StringRef str) { uint8_t key = rand() % 256;
std::string encrypted = str; for (char &c : encrypted) c ^= key;
} };
|
运行时解密函数:
static const uint8_t xor_key = 0x4F;
static char* encrypted_strings[] = { (char*)(enc_str_1), (char*)(enc_str_2), };
__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++; } } }
|
三、运行时保护手段
三.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
|
__attribute__((constructor)) void decrypt_text_section() { void* base = get_module_base("libprotect.so"); if (!base) goto cleanup;
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;
uint8_t key[32]; collect_key_fragments(key);
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);
AES_CTX ctx; AES_init_ctx(&ctx, key); AES_CTR_xcrypt(&ctx, (uint8_t*)text_start, text_size);
__builtin___clear_cache((char*)text_start, (char*)text_start + text_size);
mprotect(page_start, page_size, PROT_READ | PROT_EXEC);
cleanup: 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) { 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 是否挂起。
void anti_debug() { if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) { __android_log_print(ANDROID_LOG_ERROR, "protect", "ptrace failed, exit"); _exit(0); } 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 反调试检测:
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; }
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] = '
|