发布于 

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 会:

  1. 扫描所有字符串常量
  2. 对每个字符串进行加密(通常是简单的异或或替换加密)
  3. 将密文存储为全局数组
  4. 在使用字符串的位置插入对解密函数的调用

运行时解密函数的特征

在 IDA 中,字符串解密函数通常具有以下特征:

  1. 函数名以 _Z 开头:这是 C++ 的 name mangling 格式
  2. 大量异或操作:解密通常使用 XOR
  3. 循环结构:逐字节解密
  4. 被大量调用:一个解密函数可能被调用数十次

使用 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 对逆向分析的影响

  1. 控制流图不可读:原本线性的代码变成了一大团互相跳转的块
  2. 分支顺序被打乱:真实的执行顺序隐藏在 state 变量的赋值中
  3. 虚假路径增加:可能存在永远不会被执行的 case 分支
  4. 与指令替换叠加: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 的真实执行路径来还原代码逻辑。两种混淆的组合分析需要分阶段进行,先解密字符串获取语义信息,再追踪执行路径还原控制流,最终完成算法还原。