Frida 辅助分析 OLLVM 字符串加密和控制流平坦化
OLLVM 字符串加密原理
在 Android 逆向分析中,字符串是最重要的信息载体之一。APP 中的 API 地址、密钥、URL、调试信息等通常以明文字符串的形式存在于 SO 文件中。OLLVM 的字符串加密 pass(String Encryption)通过在编译时将字符串加密为密文,在运行时通过解密函数还原,有效防止了静态分析。
编译时加密
OLLVM 的字符串加密在 LLVM IR 层面工作,其流程如下:
原始代码: char* url = "https://api.example.com"
↓ (编译时 OLLVM Pass)
加密后: char* url = _Zencrypted_str_1234;
↓ (运行时自动调用)
解密后: "https://api.example.com"
编译时,OLLVM 会:
- 扫描所有字符串常量
- 对每个字符串进行加密(通常是简单的异或或替换加密)
- 将密文存储为全局数组
- 在使用字符串的位置插入对解密函数的调用
运行时解密函数的特征
在 IDA 中,字符串解密函数通常具有以下特征:
- 函数名以
_Z开头:这是 C++ 的 name mangling 格式 - 大量异或操作:解密通常使用 XOR
- 循环结构:逐字节解密
- 被大量调用:一个解密函数可能被调用数十次
使用 Frida Hook 解密函数获取明文字符串
定位解密函数
首先需要在 IDA 中找到字符串解密函数的偏移地址:
// Hook 所有以 _Z 开头且被频繁调用的函数
function findDecryptFunction(moduleName) {
var mod = Process.findModuleByName(moduleName);
if (!mod) return;
var exports = mod.enumerateExports();
console.log("[*] 枚举 " + exports.length + " 个导出函数");
exports.forEach(function (exp) {
if (exp.name.indexOf("_Z") === 0 && exp.type === "function") {
console.log(" [导出] " + exp.name + " @ " + exp.address);
}
});
}
findDecryptFunction("libnative.so");
Hook 解密函数捕获明文
// Hook 字符串解密函数,捕获所有被解密的字符串
function hookDecryptFunction(moduleName, funcOffset) {
var mod = Process.findModuleByName(moduleName);
var decryptAddr = mod.base.add(funcOffset);
var decryptedStrings = [];
Interceptor.attach(decryptAddr, {
onEnter: function (args) {
// 解密函数通常接收密文指针和长度作为参数
this._encryptedPtr = args[0];
this._len = args[1] ? args[1].toInt32() : 64;
console.log("\n[解密函数调用]");
console.log(" 密文地址: " + this._encryptedPtr);
},
onLeave: function (retval) {
// 返回值通常是指向解密后字符串的指针
try {
var decrypted = retval.readUtf8String();
if (decrypted && decrypted.length > 0) {
console.log(" [明文] " + decrypted);
decryptedStrings.push(decrypted);
// 打印调用栈,找出哪个函数在使用这个字符串
console.log(" [调用栈]");
var bt = Thread.backtrace(this.context,
Backtracer.ACCURATE);
bt.forEach(function (addr, i) {
var sym = DebugSymbol.fromAddress(addr);
console.log(" " + i + ": " + sym);
});
}
} catch (e) {
// 读取失败,可能不是 UTF8 字符串
}
}
});
// 提供查询接口
return {
getStrings: function () { return decryptedStrings; },
printAll: function () {
console.log("\n=== 已收集 " + decryptedStrings.length +
" 个解密字符串 ===");
decryptedStrings.forEach(function (s, i) {
console.log(" " + (i + 1) + ". " + s);
});
}
};
}
var collector = hookDecryptFunction("libnative.so", 0x2A10);
控制流平坦化(FLA)原理
Switch-Dispatcher 模式
控制流平坦化(Control Flow Flattening)是 OLLVM 最具破坏性的混淆手段。它将函数原本清晰的 if-else、switch-case、循环等控制结构"展平"为一个大的 switch-case 分发器,彻底打乱了代码的逻辑结构。
原始代码的控制流:
Block A → Block B → Block C → Block D → Exit
↓
Block E → Exit
平坦化后的控制流:
┌──────────────────┐
│ Dispatcher │
│ switch(state) { │
│ case 0: │ ← state 初始值
│ Block A │
│ state = 3 │
│ break │
│ case 1: │
│ Block C │
│ state = 4 │
│ break │
│ case 2: │
│ Block E │
│ state = 4 │
│ break │
│ case 3: │
│ Block B │
│ state = 1 │ ← 分支选择
│ break │
│ case 4: │
│ Block D │
│ state = -1 │
│ break │
│ default: │
│ Exit │
│ } │
└──────────────────┘
FLA 对逆向分析的影响
- 控制流图不可读:原本线性的代码变成了一大团互相跳转的块
- 分支顺序被打乱:真实的执行顺序隐藏在 state 变量的赋值中
- 虚假路径增加:可能存在永远不会被执行的 case 分支
- 与指令替换叠加:state 的计算可能也被指令替换混淆
Frida 追踪真实执行路径
记录 Switch Case 执行顺序
// 追踪 switch-dispatcher 的执行顺序
function traceFlattenedControlFlow(moduleName, dispatcherOffset) {
var mod = Process.findModuleByName(moduleName);
var dispatcherAddr = mod.base.add(dispatcherOffset);
console.log("[FLA 追踪] 目标: " + moduleName +
" + 0x" + dispatcherOffset.toString(16));
var caseSequence = [];
Interceptor.attach(dispatcherAddr, {
onEnter: function (args) {
var ctx = this.context;
// 读取 state 变量(通常存储在某个寄存器或栈上)
// 具体寄存器需根据 IDA 分析确定
var state = ctx.x19.toInt32();
caseSequence.push(state);
},
onLeave: function (retval) {
// 检查是否退出循环(state == -1 或类似退出条件)
}
});
// 在目标函数的出口打印完整路径
return {
getPath: function () { return caseSequence; },
printPath: function () {
console.log("\n[FLA 执行路径]");
console.log("Case 执行顺序: " + caseSequence.join(" → "));
console.log("共经过 " + caseSequence.length + " 个 case");
// 统计每个 case 被执行的次数
var caseCount = {};
caseSequence.forEach(function (c) {
caseCount[c] = (caseCount[c] || 0) + 1;
});
console.log("\n[Case 频率统计]");
Object.keys(caseCount).sort(function (a, b) {
return a - b;
}).forEach(function (c) {
console.log(" Case " + c + ": " + caseCount[c] + " 次");
});
}
};
}
var flaTracer = traceFlattenedControlFlow("libnative.so", 0x3A20);
追踪 State 变量的变化
// 监控 state 变量的赋值,还原 case 之间的跳转关系
function traceStateTransitions(moduleName, stateVarOffset) {
var mod = Process.findModuleByName(moduleName);
// 假设 state 变量存储在某个已知位置
// 通过在多个关键点设置 Hook 来追踪变化
var transitionLog = [];
// 方案:在每个 case 块的末尾设置 Hook
var caseBlocks = [0x3A50, 0x3A80, 0x3AB0, 0x3AE0, 0x3B10];
caseBlocks.forEach(function (off) {
var addr = mod.base.add(off);
Interceptor.attach(addr, {
onEnter: function (args) {
// 读取即将跳转到的下一个 case(state 新值)
var nextState = this.context.x20.toInt32();
transitionLog.push({
from: "0x" + off.toString(16),
nextState: nextState
});
}
});
});
return {
printTransitions: function () {
console.log("\n[State 转移图]");
transitionLog.forEach(function (t) {
console.log(" " + t.from + " → Case " + t.nextState);
});
}
};
}
还原真实控制流图
基于 Frida 收集到的执行路径数据,可以在 IDA 中重建函数的真实逻辑:
// 根据执行路径生成 IDA 脚本
function generateIdaScript(caseSequence, caseAddresses) {
console.log("// IDA Python 脚本 - 重命名基本块");
console.log("// 根据 Frida 追踪的真实执行路径重建");
caseSequence.forEach(function (caseId, i) {
var addr = caseAddresses[caseId];
if (addr) {
console.log("idaapi.set_name(0x" + addr.toString(16) +
", 'real_block_" + i + "_" + caseId + "', 0)");
}
});
// 生成路径注释
console.log("\n// 执行路径注释");
for (var i = 0; i < caseSequence.length - 1; i++) {
var fromAddr = caseAddresses[caseSequence[i]];
var toAddr = caseAddresses[caseSequence[i + 1]];
console.log("// Block " + caseSequence[i] + " (0x" +
fromAddr.toString(16) + ") → Block " +
caseSequence[i + 1] + " (0x" + toAddr.toString(16) + ")");
}
}
字符串加密+控制流平坦化的组合分析方法
当 OLLVM 同时启用字符串加密和控制流平坦化时,分析难度会成倍增加。以下是一套组合分析方法:
整体分析流程
Phase 1: 字符串解密 Hook
→ 获取所有运行时解密的明文字符串
→ 标注到 IDA 中的对应位置
Phase 2: 控制流平坦化路径追踪
→ 记录真实执行路径
→ 识别虚假 case 分支
Phase 3: 关键函数深度分析
→ 结合解密字符串确定函数功能
→ 沿真实路径分析算法逻辑
Phase 4: 算法还原
→ 逐步还原被混淆的运算
→ 用 Frida 验证还原结果
组合分析脚本
// OLLVM 综合分析框架
var OllvmAnalyzer = {
decryptedStrings: [],
executionPaths: {},
// 阶段 1: Hook 字符串解密
hookStringDecryption: function (moduleName, decryptOffset) {
var mod = Process.findModuleByName(moduleName);
var addr = mod.base.add(decryptOffset);
Interceptor.attach(addr, {
onLeave: function (retval) {
try {
var str = retval.readCString();
if (str && str.length > 0 &&
str.length < 1024) {
this.decryptedStrings.push({
str: str,
addr: retval.toString(),
caller: Thread.backtrace(this.context,
Backtracer.FUZZY)[0]
});
}
} catch (e) {}
}
}.bind(this));
console.log("[Phase 1] 字符串解密 Hook 已设置");
},
// 阶段 2: 追踪控制流
traceControlFlow: function (moduleName, funcOffset) {
var mod = Process.findModuleByName(moduleName);
var funcAddr = mod.base.add(funcOffset);
var path = [];
Interceptor.attach(funcAddr, {
onEnter: function (args) {
this._path = [];
this._callId = Date.now();
},
onLeave: function (retval) {
this.executionPaths[this._callId] = this._path;
}
}.bind(this));
console.log("[Phase 2] 控制流追踪已设置");
},
// 报告结果
report: function () {
console.log("\n====== OLLVM 分析报告 ======");
console.log("[解密字符串] 共 " +
this.decryptedStrings.length + " 个");
this.decryptedStrings.forEach(function (item, i) {
console.log(" " + (i + 1) + ". " + item.str);
});
}
};
// 使用
OllvmAnalyzer.hookStringDecryption("libnative.so", 0x2A10);
总结
本文介绍了 OLLVM 字符串加密和控制流平坦化两种混淆的原理及 Frida 动态分析方法。字符串加密通过 Hook 解密函数可以在运行时恢复所有明文字符串;控制流平坦化则通过追踪 switch-dispatcher 的真实执行路径来还原代码逻辑。两种混淆的组合分析需要分阶段进行,先解密字符串获取语义信息,再追踪执行路径还原控制流,最终完成算法还原。