所属:第二篇 经典混淆技术
本章导读:数据混淆是对程序里存储的数据动手脚——字符串加密、数组打乱、变量拆分,让逆向者即使看到数据也不知道原始含义。比如明文字符串
"License expired"在二进制里被加密存储,运行时才解密还原。如果说控制流混淆是让人看不懂"程序在做什么",那数据混淆就是让人看不懂"程序在操作什么"。即使不熟悉底层内存布局也没关系——6.0 节会先介绍程序数据的存储基础。本章知识定位:本章中,字符串加密的识别与解密(6.1 节)是逆向分析中最高频、最直接的实操任务之一——dump 加密字符串、定位解密函数、动态提取明文,几乎是每个样本分析都会遇到的工作。相对地,变量拆分/数组重组/数据结构变形等章节(6.2-6.4)的底层设计思路偏理论支撑——它们帮你理解"为什么这段数据结构长得这么怪",但多数情况下你不需要亲手设计这类混淆(除非你站在防御方视角)。阅读时请留意每节的定位提示,区分"这是我要上手做的"还是"这是帮我建立理解框架的背景知识"。
6.0 基础知识:程序数据的存储布局
知识定位:理论支撑。程序内存布局(ELF 各段)和字符串作为逆向路标的原理,是你后续分析字符串加密、常量编码时需要的背景知识,日常工作中你不会"操作"这些布局本身,但要知道去哪里找(.rodata)。
要理解数据混淆,首先需要知道程序中的数据存储在哪里,以及逆向工程师如何找到它们。
6.0.1 程序内存布局
一个典型的 ELF(Executable and Linkable Format,可执行与可链接格式)进程的内存布局(从低地址到高地址):
各段与混淆的关系[2]:
| 段 | 内容 | 逆向价值 | 混淆目标 |
|---|---|---|---|
.text | 机器指令 | 最高——核心逻辑所在 | 控制流混淆、虚拟化 |
.rodata | 字符串常量、常量表 | 高——搜索字符串可快速定位功能 | 字符串加密 |
.data | 全局变量初始值 | 中——泄露配置参数、魔数 | 常量编码 |
.bss | 未初始化全局变量 | 低——运行时才有值 | 动态化 |
| Stack | 局部变量 | 中——函数参数和临时值 | 堆栈动态化 |
| Heap | 动态分配 | 低——地址不确定 | 已天然较难分析 |
6.0.2 字符串:逆向工程的"路标"
字符串是逆向分析中最高效的入口点。原因:
- 语义丰富:字符串通常是人类可读的——错误信息、日志、配置键名、URL、SQL 语句等直接暴露功能含义
- 交叉引用(xref,Cross Reference)确定:编译器将字符串地址硬编码到代码中,反编译器可以精确追踪"谁引用了这个字符串"
IDA(Interactive DisAssembler,交互式反汇编器)Pro 的字符串分析工作流[9]:
1. Shift+F12 打开 Strings 窗口 → 自动扫描 .rodata/.rdata 中所有可打印字符串 2. 搜索关键词(如 "license"、"password"、"error") → 秒级定位目标 3. 双击字符串 → 跳转到 .rodata 中的地址 → 按 X 查看交叉引用 → 直接跳到使用该字符串的代码 4. F5 反编译 → 上下文清晰可见实例:假设你在 .rodata 中找到"Invalid license key",按 X 查看 xref 会直接定位到 License 验证函数——整个过程不到 30 秒。
结论:字符串加密不是"可选的额外保护"——如果你的二进制中有明文字符串泄露了功能线索,其他所有混淆都会被绕过。
6.1 字符串加密与运行时解密
知识定位:实践应用,是本章最核心、最高频的实操技能。识别字符串加密方案、定位解密函数、通过动态执行或 hook 截获明文,是逆向分析中几乎每天都会用到的工作;本节的 XOR/XTEA 示例和解密时机策略能帮你判断"这个样本的字符串保护强度如何、该用什么方法突破"。
字符串加密(String Encryption)的核心思想很直观:把代码里的明文字符串变成密文存储,运行时再解密还原。打个比方——就像把一本书里所有关键词都用暗号替换,只有在真正需要阅读时才"翻译"回来。这是数据混淆中最基础也最重要的一环,因为明文字符串是逆向工程师定位关键功能的首选入口(详见 6.0.2 节的分析)。
6.1.1 基础方案:编译期 XOR 加密
// 编译期:将字符串与密钥 XOR // 运行时:解密恢复 static char encrypted[] = {0x29, 0x2D, 0x37, 0x37, 0x34, ...}; static const char key = 0x41; char* decrypt_string() { static char decrypted[sizeof(encrypted)]; for (int i = 0; i < sizeof(encrypted); i++) { decrypted[i] = encrypted[i] ^ key; } return decrypted; // "Hello" }弱点: - 单字节 XOR 密钥 → 频率分析秒破(与古典维吉尼亚密码一样脆弱) - 所有字符串使用同一密钥 → 破解一个 = 破解全部 - 解密函数模式固定 → 反混淆脚本一键识别
6.1.2 进阶方案:多密钥 + 不同算法
每字符串独立密钥:
// 编译期生成:每个字符串独立的 key 和 nonce struct encrypted_string { uint8_t *data; uint32_t length; uint32_t key; // 每个字符串不同 uint8_t algo; // 0=XOR, 1=XTEA, 2=AES-CTR }; const char* decrypt_string(struct encrypted_string *es) { static __thread char buf[4096]; switch (es->algo) { case 0: xor_decrypt(es->data, buf, es->length, es->key); break; case 1: xtea_decrypt(es->data, buf, es->length, es->key); break; case 2: aes_ctr_decrypt(es->data, buf, es->length, es->key); break; } return buf; }XTEA(eXtended Tiny Encryption Algorithm,扩展微型加密算法)轻量级方案(代码体积极小,适合嵌入式和移动端):
// XTEA: 极轻量的分组密码(代码仅 ~10 行核心) void xtea_decrypt(uint32_t *v, const uint32_t *key, unsigned rounds) { uint32_t v0 = v[0], v1 = v[1]; uint32_t delta = 0x9E3779B9; uint32_t sum = delta * rounds; for (unsigned i = 0; i < rounds; i++) { v1 -= (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + key[(sum >> 11) & 3]); sum -= delta; v0 -= (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + key[sum & 3]); } v[0] = v0; v[1] = v1; } // 优势:代码极小(~100 字节机器码),不依赖任何库 // 安全性:比 XOR 强得多,但不如 AES——用于混淆场景足够6.1.3 自动化字符串加密 LLVM Pass 设计
在 LLVM IR 层自动加密所有字符串常量 [3, 5, 6, 7]:
Pass 工作流程: 1. 遍历 Module 中所有 GlobalVariable 2. 筛选 .rodata 中的字符串常量(ConstantDataArray 类型) 3. 对每个字符串: a. 生成随机密钥 b. 将明文字符串加密为密文 c. 替换 GlobalVariable 的初始值为密文 d. 在每个使用点前插入解密调用 4. 将解密函数(带混淆)也编译进模块// LLVM Pass 伪代码 struct StringEncryptPass : public PassInfoMixin<StringEncryptPass> { PreservedAnalyses run(Module &M, ModuleAnalysisManager &AM) { for (auto &GV : M.globals()) { if (!GV.hasInitializer() || !GV.isConstant()) continue; auto *Init = dyn_cast<ConstantDataArray>(GV.getInitializer()); if (!Init || !Init->isString()) continue; // 1. 获取明文 StringRef plaintext = Init->getAsString(); // 2. 生成随机密钥 uint32_t key = random_key(); // 3. 加密 std::vector<uint8_t> cipher = encrypt(plaintext, key); // 4. 替换全局变量初始值为密文 auto *newInit = ConstantDataArray::get(M.getContext(), cipher); GV.setInitializer(newInit); GV.setConstant(false); // 运行时需要解密修改 // 5. 在每个使用点前插入解密调用 for (auto *U : GV.users()) { if (auto *I = dyn_cast<Instruction>(U)) { IRBuilder<> B(I); B.CreateCall(decryptFunc, {&GV, key_const}); } } } return PreservedAnalyses::none(); } };6.1.4 解密时机策略
| 策略 | 实现 | 安全性 | 性能 |
|---|---|---|---|
.init_array统一解密 | __attribute__((constructor))一次性解密所有字符串 | 最低——dump 进程内存即可获取所有明文 | 最好——解密只执行一次 |
| 惰性解密 | 首次调用时解密,缓存结果 | 中——内存中最终都有明文 | 好——仅解密实际使用的字符串 |
| 每次使用即时解密 + 擦除 | 使用前解密,使用后立即memset(0) | 最高——明文窗口极短 | 差——每次使用都有加解密开销 |
| 逐字符解密 | 不存储完整明文,逐字符解密后直接处理 | 极高——内存中从不出现完整明文 | 最差——但对低频字符串可接受 |
// 基于 .init_array 的自动解密 __attribute__((constructor)) void init_strings() { // 在 main() 之前自动执行 for (int i = 0; i < NUM_STRINGS; i++) { decrypt_in_place(&string_table[i]); } } // 惰性解密(首次调用时解密) const char* get_string(int id) { static int decrypted[MAX_STRINGS] = {0}; if (!decrypted[id]) { decrypt_in_place(&string_table[id]); decrypted[id] = 1; } return string_table[id]; }6.1.5 已知攻破与对策
Quarkslab 实验揭示的核心问题:无论字符串如何加密,只要程序运行时需要使用明文,Agent 就可以通过模拟执行在解密发生后截获明文。
攻击路径: 加密字符串 → [运行时解密函数] → 明文字符串 ↑ Agent 在此处设置 hook/观察点有效的对策: - 解密后立即使用、使用后立即擦除(零化内存) - 不在内存中存储完整的明文字符串——逐字符解密使用 - 将字符串使用逻辑与解密逻辑深度融合(不存在一个独立的"解密函数"可以被 hook) - 对解密函数本身进行虚拟化保护(第 7 章)
6.2 变量拆分与合并
知识定位:以理论支撑为主。变量拆分/合并、秘密共享的数学设计思路,是帮你理解"为什么这个常量被拆成几份分散在代码里"的背景知识;除非你是防御方工程师,否则你一般不需要亲手设计这类编码方案,但理解其原理能帮你在分析时识别出这是一种变量拆分而非随机噪声。
6.2.1 变量拆分
将一个变量拆分为多个,分散存储和计算 [1]:
// 原始 int secret = 0xDEADBEEF; // 拆分后 int part1 = 0xDEAD0000; int part2 = 0x0000BEEF; // 使用时: actual_value = part1 | part2; // 进一步混淆: part1 和 part2 可以在不同函数、不同时机计算高级拆分——基于秘密共享的思路:
Shamir 秘密共享 [4] 的核心思想:将秘密 S 分为 n 份,任意 k 份可恢复 S,少于 k 份得不到任何信息。
// 简化版 2-of-3 秘密共享(模 2^32 算术) // 秘密 S = 0xDEADBEEF // 选择随机多项式 f(x) = S + a1*x (mod 2^32) uint32_t S = 0xDEADBEEF; uint32_t a1 = 0x12345678; // 随机系数 // 生成 3 份 share uint32_t share1 = S + a1 * 1; // f(1) uint32_t share2 = S + a1 * 2; // f(2) uint32_t share3 = S + a1 * 3; // f(3) // 恢复:用任意 2 份通过拉格朗日插值恢复 S // 例如用 share1 和 share2: // S = f(0) = share1 * (0-2)/(1-2) + share2 * (0-1)/(2-1) // = share1 * 2 - share2 (mod 2^32)混淆价值:将关键常量拆分为 3 份,分散在代码的不同位置。逆向者需要找到至少 2 份并理解重组算法才能恢复原始值。
6.2.2 变量合并
将多个逻辑独立的变量打包到一个物理变量中:
// 原始 int flag_a = 1; // 布尔 int flag_b = 0; // 布尔 int counter = 42; // 0-255 范围 // 合并到一个 int int packed = (counter << 2) | (flag_b << 1) | flag_a; // 提取: flag_a = packed & 1; // flag_b = (packed >> 1) & 1; // counter = packed >> 2;高级合并——使用非平凡编码:
// 用 MBA(Mixed Boolean-Arithmetic,混合布尔-算术)表达式进行编码 [8] // 将 (a, b) 两个 16-bit 值编码为一个 32-bit 值 uint32_t encode(uint16_t a, uint16_t b) { // 不是简单的移位拼接,而是混合编码 uint32_t x = (uint32_t)a; uint32_t y = (uint32_t)b; return (x ^ y) + 2 * (x & y) + (y << 16); // 等价于 x + y + (y << 16),但形式更复杂 } // 解码需要逆运算——逆向者必须理解编码方案6.3 数组重组
知识定位:理论支撑。仿射索引变换的数学推导帮助理解"为什么数组访问顺序看起来是乱的",属于背景知识;实际分析中你更可能是观察到索引变换的现象后,反推出变换函数,而不是从头推导数学条件。
6.3.1 基本变换
- 数组扁平化[2]:二维数组
arr[M][N]重组为一维arr[M*N],索引变换arr[i][j]→arr[i*N+j] - 数组折叠:一维数组拆分为二维
- 索引变换:
arr[i]→arr[f(i)],其中f是一个可逆的索引变换函数
6.3.2 仿射索引变换的数学推导
索引变换函数选择仿射变换是因为它在模 N 下可逆(当系数与 N 互素时):
变换: f(i) = (a * i + b) mod N 逆变换: f^{-1}(j) = a^{-1} * (j - b) mod N 条件: gcd(a, N) = 1(a 与 N 互素,保证可逆)完整示例(N=16, a=3, b=7):
// 原始数组 int data[16] = {d0, d1, d2, d3, d4, d5, d6, d7, d8, d9, d10, d11, d12, d13, d14, d15}; // 仿射变换 f(i) = (3*i + 7) % 16 // 重排后的数组 int shuffled[16]; for (int i = 0; i < 16; i++) { shuffled[(3 * i + 7) % 16] = data[i]; } // 结果映射: // data[0] → shuffled[7] (3*0+7=7) // data[1] → shuffled[10] (3*1+7=10) // data[2] → shuffled[13] (3*2+7=13) // data[3] → shuffled[0] (3*3+7=16%16=0) // ... // 访问时:data[index] 变为 shuffled[(3*index + 7) % 16] // 逆变换: 3^{-1} mod 16 = 11 (因为 3*11=33≡1 mod 16) // index = (11 * (shuffled_index - 7)) % 16多维仿射变换:
对于二维数组 arr[M][N]: (i', j') = | a b | × | i | + | e | mod (M, N) | c d | | j | | f | 条件: det(矩阵) 与 M*N 互素6.4 数据结构变形
知识定位:理论支撑,帮助建立"反编译器为什么失效"的认知框架。链表/数组互转、结构体打散、布局随机化的设计思路不要求你亲手实现,但理解其原理有助于你在遇到异常内存访问模式时判断这是混淆而非正常代码。
6.4.1 链表↔数组互转
将数组实现替换为链表(反之亦然),改变内存访问模式 [1]:
// 原始:数组实现 int scores[100]; int total = 0; for (int i = 0; i < 100; i++) total += scores[i]; // 变形后:链表实现(反编译器的类型推断失效) struct node { int val; struct node *next; }; struct node *head = build_linked_list(scores_encrypted, 100); int total = 0; for (struct node *p = head; p; p = p->next) total += p->val ^ runtime_key;6.4.2 结构体字段打散
// 原始结构体 struct UserInfo { char name[32]; int age; char email[64]; }; // 打散后 char g_name_buf[32]; // 全局区域 A int g_misc_counter; // 无关变量(伪装) int g_age_store; // 全局区域 B char g_padding[128]; // 填充 char g_email_buf[64]; // 全局区域 C6.4.3 对象布局随机化
类似 ASLR(Address Space Layout Randomization,地址空间布局随机化)的思路,应用于结构体字段:
// 编译期随机化结构体布局 // 每次编译,字段偏移不同 // 编译 #1: struct UserInfo_v1 { char _pad0[23]; // 随机填充 int age; // offset = 23 char _pad1[7]; char name[32]; // offset = 34 char email[64]; // offset = 66 char _pad2[41]; }; // 编译 #2: struct UserInfo_v2 { char email[64]; // offset = 0 char _pad0[12]; char name[32]; // offset = 76 int age; // offset = 108 char _pad1[16]; }; // 访问通过宏/内联函数: #define GET_AGE(ptr) (*(int*)((char*)(ptr) + AGE_OFFSET)) // AGE_OFFSET 是编译期确定的随机值效果:逆向者在版本 A 中发现offset+23是 age 字段,升级到版本 B 后这个偏移完全失效——所有基于固定偏移的 Frida(跨平台动态插桩工具)脚本都需要重写。
6.5 堆栈变量动态化
知识定位:理论支撑。堆栈变量动态化的原理解释了"为什么反编译器的别名分析和类型推断失效",是理解分析难度来源的背景知识,不是你会亲手操作的内容。
将局部变量从栈迁移到堆,并通过混淆的指针间接访问:
// 原始 void process() { int key = 42; int result = compute(key); } // 动态化后 void process() { int *p = (int*)malloc(sizeof(int)); *p = 42 ^ runtime_mask(); int result = compute(*p ^ runtime_mask()); free(p); }为什么这对反编译器有效: - 栈变量的优势是位置确定([rbp-offset]),便于反编译器识别 - 迁移到堆后,反编译器面对的是不确定的指针——别名分析退化,类型推断失效 -malloc返回的地址在运行时才确定,静态分析无法追踪
6.6 LLM 抗性分析
知识定位:理论支撑,用于建立整体认知框架。本节汇总各技术对 LLM/Agent 的抗性,帮助你判断"遇到这类混淆该不该寄希望于自动化工具",是方法选择层面的参考。
| 技术 | LLM 能否突破 | Agent 能否突破 | 分析 |
|---|---|---|---|
| 字符串加密 | ❌ 无法直接解密 | ✅ 动态执行截获明文 | LLM 抗性取决于防动态分析能力 |
| 变量拆分(简单) | ⚠️ 小规模可理解 | ✅ 追踪数据流 | 2 个变量的拆分 LLM 能处理 |
| 变量拆分(秘密共享) | ❌ 数学复杂度超出 | ⚠️ 需要理解数学关系 | 有效 |
| 数组索引变换 | ⚠️ 简单仿射可推断 | ✅ 穷举验证 | 仿射变换的代数结构可被识别 |
| 结构体打散 | ❌ 难以追踪 | ⚠️ 需要大量 trace | 字段分散到全局区域后模式消失 |
| 布局随机化 | ❌ 每版本不同 | ❌ 需要重新分析 | 对自动化攻击最有效 |
| 堆动态化 | ❌ 指针间接访问 | ⚠️ 可动态追踪 | 对 LLM 影响最大 |
6.7 实操案例:从明文到全面数据混淆
知识定位:实践应用。这是把本章所有技术串联起来的综合案例,模拟了真实逆向分析中从"搜索字符串找不到"到"需要动态分析+理解 MBA"的完整思路演进过程,贴近实际工作场景。
原始代码——一个典型的 License 验证函数:
// license_check.c — 未保护版本 #include <string.h> #include <stdio.h> #define LICENSE_KEY 0x4A7B3C2D #define MAX_RETRIES 3 struct LicenseInfo { char user_name[64]; uint32_t license_type; uint32_t expiry_date; }; int verify_license(struct LicenseInfo *info) { if (info->license_type != LICENSE_KEY) { printf("Invalid license key\n"); return 0; } if (info->expiry_date < get_current_date()) { printf("License expired\n"); return 0; } printf("License valid for user: %s\n", info->user_name); return 1; }逆向者视角:在 IDA 中搜索 "Invalid license" → 一步定位 → 看到0x4A7B3C2D常量 → 完全理解验证逻辑。总耗时:< 1 分钟。
混淆后版本:
// license_check_obfuscated.c — 全面数据混淆 // 1. 字符串加密(XTEA) static uint8_t enc_invalid[] = {0xA3, 0x7F, 0x...}; // "Invalid license key" 密文 static uint8_t enc_expired[] = {0xB1, 0x2C, 0x...}; // "License expired" 密文 static uint8_t enc_valid[] = {0xD4, 0x9E, 0x...}; // "License valid..." 密文 // 2. 常量用 MBA 编码 // LICENSE_KEY = 0x4A7B3C2D // 编码为: ((k1 ^ k2) + 2 * (k1 & k2)) 其中 k1, k2 编译期生成 static uint32_t k_part1 = 0x7F3A1E0B; static uint32_t k_part2 = 0x35412426; // (k_part1 ^ k_part2) + 2*(k_part1 & k_part2) = 0x4A7B3C2D ✓ // 3. 结构体打散 static char g_buf_a[128]; // user_name 存这里 static uint32_t g_val_x; // license_type(带掩码) static uint32_t g_val_y; // expiry_date(带掩码) static uint32_t g_mask; // 运行时掩码 // 4. 验证函数(配合控制流混淆后更有效) int verify_license_obf(void *opaque_data) { // 运行时解包(从 opaque 指针提取打散的字段) unpack_fields(opaque_data, g_buf_a, &g_val_x, &g_val_y); g_mask = derive_runtime_mask(); uint32_t actual_type = g_val_x ^ g_mask; uint32_t expected = (k_part1 ^ k_part2) + 2 * (k_part1 & k_part2); if (actual_type != expected) { char *msg = decrypt_string(enc_invalid, sizeof(enc_invalid)); output(msg); wipe(msg); // 立即擦除 return 0; } uint32_t actual_expiry = g_val_y ^ g_mask; if (actual_expiry < get_current_date()) { char *msg = decrypt_string(enc_expired, sizeof(enc_expired)); output(msg); wipe(msg); return 0; } char *msg = decrypt_string(enc_valid, sizeof(enc_valid)); output(msg); wipe(msg); return 1; }逆向者视角(混淆后): 1. 搜索字符串 → 找不到(已加密) 2. 搜索常量0x4A7B3C2D→ 找不到(已 MBA 编码) 3. 交叉引用分析 → 字段打散后 xref 链断裂 4. 需要动态分析 + 理解 MBA + 理解解密逻辑 → 成本从分钟级升到小时/天级
参考资料
论文
[1] Christian Collberg, Clark Thomborson, Douglas Low, "Manufacturing Cheap, Resilient, and Stealthy Opaque Constructs", POPL 1998 — 数据混淆的理论基础
[2] Christian Collberg, Clark Thomborson, Douglas Low, "A Taxonomy of Obfuscating Transformations", 1997 — 混淆技术分类学,数据混淆部分的经典来源
[3] Pascal Junod et al., "Obfuscator-LLVM: Software Protection for the Masses", SPRO 2015 — OLLVM(Obfuscator-LLVM,基于 LLVM 的开源代码混淆框架)字符串加密 Pass 的设计
[4] Adi Shamir, "How to Share a Secret", CACM 1979 — 秘密共享方案的原始论文
工具
[5] OLLVM String Encryption: https://github.com/obfuscator-llvm/obfuscator — 字符串加密 Pass 参考实现
[6] Hikari: https://github.com/HikariObfuscator/Hikari — 增强的字符串加密和函数封装
[7] dProtect: https://github.com/nicedayzhu/dProtect — Android 平台字符串保护
扩展阅读
[8] Henry S. Warren Jr.,Hacker's Delight, 2nd Edition — 位运算技巧百科,变量拆分/合并的灵感来源
[9] Chris Eagle,The IDA Pro Book— 第 12 章详细介绍了字符串分析和交叉引用