发布于 

Frida 辅助分析 OLLVM 指令替换和虚假控制流程

OLLVM 指令替换概述

OLLVM(Obfuscator-LLVM)是目前 Android Native 代码混淆中最流行的工具之一。其中"指令替换"(Instruction Substitution)是其核心混淆手段之一,它将简单的算术和逻辑运算替换为数学上等价但更复杂的表达式,极大地增加了逆向分析的难度。

指令替换的基本原理

指令替换的核心思想是:利用数学等价关系,将一条简单指令替换为多条复杂指令的组合。例如:

原始指令 等价替换
a + b (a ^ b) + 2 * (a & b)
a - b (a ^ b) - 2 * (~a & b)
a * 2 a + aa << 1
a * 3 (a << 2) - a
a / 2 a >> 1
a & b ~(a ^ b) & ~(a ^ ~b)
a | b ~(~a & ~b)
a ^ b (a | b) - (a & b)

在 IDA Pro 中分析经过指令替换的代码时,你会看到大量看似无关的运算,实际上它们最终等价于一个简单的操作。静态分析时很难判断哪些运算是多余的,这时就需要借助 Frida 进行动态验证。

特征识别:等价表达式模式

静态特征

在 IDA 中打开经过指令替换混淆的函数,通常能看到以下特征:

  1. 密集的位运算:大量使用 XOR、AND、OR、NOT 等位运算指令
  2. 冗余的运算:两个连续的运算结果最终被第三个运算合并
  3. 不自然的常量:代码中出现 0xAAAAAAAA0x55555555 等位模式常量
  4. 膨胀的代码量:原本 10 条指令的函数可能膨胀到 50+ 条

动态识别策略

使用 Frida 追踪函数执行,对比输入输出,可以快速判断哪些运算是有效的:

// 追踪加法运算的等价关系
// 假设原始代码: result = a + b
// OLLVM 替换后: result = (a ^ b) + 2 * (a & b)
function traceInstructionSubstitution(moduleName, funcOffset) {
    var mod = Process.findModuleByName(moduleName);
    if (!mod) return;

    var funcAddr = mod.base.add(funcOffset);

    Interceptor.attach(funcAddr, {
        onEnter: function (args) {
            var a = this.context.x0.toInt32();
            var b = this.context.x1.toInt32();
            console.log("\n[输入] a=0x" + a.toString(16) +
                        " b=0x" + b.toString(16));
            console.log("  a + b = 0x" + (a + b).toString(16));

            // 手动计算等价表达式
            var expr1 = (a ^ b) + 2 * (a & b);
            console.log("  等价式: (a^b) + 2*(a&b) = 0x" + expr1.toString(16));

            // 保存输入供 onLeave 对比
            this._a = a;
            this._b = b;
        },
        onLeave: function (retval) {
            var expected = this._a + this._b;
            console.log("[输出] 实际返回: 0x" + retval.toInt32().toString(16));
            console.log("  直接 a+b: 0x" + expected.toString(16));
            console.log("  匹配: " +
                (retval.toInt32() === expected ? "YES ✓" : "NO ✗"));
        }
    });
}

traceInstructionSubstitution("libnative.so", 0x1A2B);

使用 Frida Hook 追踪运算结果

批量验证等价关系

当面对大量被替换的指令时,可以编写通用脚本来批量验证等价关系:

// 通用等价关系验证器
function verifyEquivalence(moduleName, funcOffset, testCases, pureFunc) {
    var mod = Process.findModuleByName(moduleName);
    var funcAddr = mod.base.add(funcOffset);

    console.log("\n=== 等价关系验证 ===");
    console.log("函数: " + moduleName + "+0x" + funcOffset.toString(16));

    testCases.forEach(function (tc) {
        var expected = pureFunc(tc[0], tc[1]);

        Interceptor.attach(funcAddr, {
            onEnter: function (args) {
                if (args[0].toInt32() === tc[0] &&
                    args[1].toInt32() === tc[1]) {
                    this._match = true;
                } else {
                    this._match = false;
                }
            },
            onLeave: function (retval) {
                if (this._match) {
                    var match = (retval.toInt32() === expected) ? "✓" : "✗";
                    console.log("  (" + tc[0] + ", " + tc[1] +
                        ") => 期望:" + expected +
                        " 实际:" + retval.toInt32() + " " + match);
                }
            }
        });
    });
}

// 测试用例
var cases = [
    [10, 20], [0x100, 0x200], [0xFFFF, 1], [0, 0], [-1, 1]
];

// 假设我们怀疑原始操作是加法
verifyEquivalence("libnative.so", 0x1A2B, cases,
    function (a, b) { return a + b; });

追踪关键变量的值变化

在复杂的运算链中,可以在多个点设置 Hook 来追踪变量值的演变:

// 在函数的多个偏移点追踪寄存器变化
function traceRegisterChain(moduleName, offsets) {
    var mod = Process.findModuleByName(moduleName);

    offsets.forEach(function (off) {
        var addr = mod.base.add(off);
        Interceptor.attach(addr, {
            onEnter: function (args) {
                console.log("[0x" + off.toString(16) + "] " +
                    "x0=0x" + this.context.x0.toInt32().toString(16) + " " +
                    "x1=0x" + this.context.x1.toInt32().toString(16) + " " +
                    "x2=0x" + this.context.x2.toInt32().toString(16));
            }
        });
    });
}

// 追踪运算链:每隔若干指令设一个追踪点
traceRegisterChain("libnative.so", [0x1A2B, 0x1A3B, 0x1A50, 0x1A68, 0x1A7C]);

虚假控制流程(BCF)分析

BCF 原理

虚假控制流程(Bogus Control Flow,BCF)会在函数中插入永远不会执行的"虚假"代码块,配合条件跳转来迷惑分析者。其核心是一个"Opaque Predicate"——一个在编译时就能确定结果但逆向分析者难以直接判断的条件表达式。

常见的 Opaque Predicate 类型:

  1. 基于代数恒等式x * x + x = x * (x + 1),对任意 x 恒成立
  2. 基于指针对齐(ptr & 1) == 0,堆分配的指针始终对齐
  3. 基于数组大小sizeof(array) > 0,永远为真
  4. 基于常量表达式2 * (x - x) == 0,恒为真

BCF 的代码模式

基本块 A (真实代码)
  ↓
判断 opaque predicate
  ├─ true  → 基本块 B (虚假代码) → 跳回 A 之后
  └─ false → 跳回 A 之后

在反汇编视图中,BCF 表现为大量永远不会到达的代码块和不必要的条件跳转。

Opaque Predicate 识别

静态识别

// 使用 Frida 动态验证 opaque predicate
function identifyOpaquePredicate(moduleName, branchOffset) {
    var mod = Process.findModuleByName(moduleName);
    var branchAddr = mod.base.add(branchOffset);
    var trueCount = 0;
    var falseCount = 0;
    var iterations = 100;

    Interceptor.attach(branchAddr, {
        onEnter: function (args) {
            // 读取条件判断的结果(通常是 CMP 指令后的状态标志)
            var ctx = this.context;
            // 在 ARM64 中,条件分支通常依赖 NZCV 标志
            // 我们可以通过观察实际走哪条路径来判断
        }
    });

    // 通过多次触发记录分支方向
    console.log("[BCF 分析] 触发 " + iterations + " 次判断...");
    // 实际场景中这里需要结合业务逻辑触发多次调用
}

动态路径记录

// 使用 Interceptor 在基本块入口处设置探针
function recordExecutionPath(moduleName, blockOffsets) {
    var mod = Process.findModuleByName(moduleName);
    var path = [];

    blockOffsets.forEach(function (off) {
        var addr = mod.base.add(off);
        Interceptor.attach(addr, {
            onEnter: function (args) {
                path.push("0x" + off.toString(16));
            }
        });
    });

    // 在函数出口输出执行路径
    var exitAddr = mod.base.add(blockOffsets[blockOffsets.length - 1] + 0x50);
    Interceptor.attach(exitAddr, {
        onEnter: function (args) {
            console.log("\n[执行路径] " + path.join(" → "));
            console.log("总经过 " + path.length + " 个基本块");

            // 标记哪些块可能是虚假的(只走了一次的分支路径上的块)
            var blockCount = {};
            path.forEach(function (b) {
                blockCount[b] = (blockCount[b] || 0) + 1;
            });

            console.log("\n[块访问频率]");
            Object.keys(blockCount).forEach(function (b) {
                var marker = blockCount[b] < 3 ? " ← 可能虚假" : "";
                console.log("  " + b + ": " + blockCount[b] + " 次" + marker);
            });
        }
    });
}

// 分析函数中的基本块执行路径
recordExecutionPath("libnative.so", [0x1A2B, 0x1A40, 0x1A55, 0x1A68, 0x1A80]);

Frida 辅助去除虚假分支

基本思路

虚假分支的去除策略是:通过动态执行记录哪些分支永远不会被走到,然后在 IDA 中将对应的块标记为不可达,简化控制流图。

动态 patch 虚假分支

// 动态 patch 虚假的条件跳转为无条件跳转
function patchBogusBranch(moduleName, branchAddr, alwaysTrue) {
    var mod = Process.findModuleByName(moduleName);
    var addr = mod.base.add(branchAddr);

    Memory.protect(addr, 4, 'rwx');

    if (alwaysTrue) {
        // ARM64: 将 B.cond 替换为 B(无条件跳转到 true 路径)
        // 具体编码需要根据实际指令确定
        console.log("[Patch] 0x" + branchAddr.toString(16) +
                    " → 强制走 true 路径");
    } else {
        console.log("[Patch] 0x" + branchAddr.toString(16) +
                    " → 强制走 false 路径");
    }
}

记录永不执行的块

// 对比多次执行路径,找出从不执行的块
var allBlocks = [0x1A2B, 0x1A35, 0x1A40, 0x1A50, 0x1A55,
                 0x1A60, 0x1A68, 0x1A78, 0x1A80, 0x1A90];
var hitBlocks = {};
var runCount = 0;

function markBlocksOnEntry(moduleName) {
    var mod = Process.findModuleByName(moduleName);

    allBlocks.forEach(function (off) {
        if (!hitBlocks[off]) hitBlocks[off] = 0;
        var addr = mod.base.add(off);
        try {
            Interceptor.attach(addr, {
                onEnter: function () {
                    hitBlocks[off]++;
                }
            });
        } catch (e) {
            // 跳过无法 Hook 的地址
        }
    });
}

// 多次触发后分析结果
function analyzeBogusBlocks() {
    console.log("\n[虚假块分析]");
    console.log("执行次数: " + runCount);
    allBlocks.forEach(function (off) {
        var hits = hitBlocks[off] || 0;
        var status = hits === 0 ? "❌ 从不执行(虚假块)" :
                     hits < runCount * 0.5 ? "⚠️ 低频(可能虚假)" :
                     "✓ 正常";
        console.log("  0x" + off.toString(16) + ": " +
                    hits + "/" + runCount + " " + status);
    });
}

指令替换+虚假控制流组合分析

实际 APP 中,OLLVM 通常同时启用多种混淆 pass,指令替换和虚假控制流经常组合出现。分析策略如下:

组合分析策略

Step 1: 动态执行,记录真实路径
   ↓
Step 2: 标记虚假分支,简化控制流
   ↓
Step 3: 沿真实路径追踪寄存器值变化
   ↓
Step 4: 对比等价表达式,还原原始运算
   ↓
Step 5: 在 IDA 中用简化后的逻辑重建函数

综合分析脚本

// OLLVM 指令替换+BCF 综合分析脚本
function analyzeOllvmFunc(moduleName, funcOffset) {
    var mod = Process.findModuleByName(moduleName);
    console.log("\n========== OLLVM 函数分析 ==========");
    console.log("目标: " + moduleName + " + 0x" +
                funcOffset.toString(16));

    // 1. Hook 函数入口和出口
    var funcAddr = mod.base.add(funcOffset);
    Interceptor.attach(funcAddr, {
        onEnter: function (args) {
            console.log("\n[函数调用]");
            for (var i = 0; i < 4; i++) {
                console.log("  x" + i + " = 0x" +
                    this.context["x" + i].toInt32().toString(16));
            }
            this._startTime = Date.now();
        },
        onLeave: function (retval) {
            var elapsed = Date.now() - this._startTime;
            console.log("[函数返回] x0 = 0x" +
                this.context.x0.toInt32().toString(16));
            console.log("[耗时] " + elapsed + "ms");

            // 如果执行时间异常长,可能走了虚假分支
            if (elapsed > 100) {
                console.log("⚠️ 执行时间较长,可能包含虚假控制流");
            }
        }
    });
}

analyzeOllvmFunc("libnative.so", 0x1A2B);

从 Native 层到算法还原的完整流程

以一个实际的加密函数为例,展示完整的分析流程:

  1. 定位函数:通过字符串引用或调用链在 IDA 中找到加密函数
  2. 识别混淆:观察函数代码特征,确认使用了指令替换和 BCF
  3. 动态追踪:使用 Frida Hook 函数入口出口,记录输入输出
  4. 路径简化:多次执行,标记虚假分支
  5. 运算还原:沿真实路径追踪等价关系
  6. 算法验证:用还原出的算法自行计算,对比原始函数的输出
  7. 代码重构:用 C/Python 重写还原后的算法
// 最终验证:对比原始函数和还原算法
function verifyRestoredAlgorithm(moduleName, funcOffset, restoredFunc) {
    var mod = Process.findModuleByName(moduleName);
    var funcAddr = mod.base.add(funcOffset);

    var testInputs = [
        [0x48656C6C, 0x6F576F72],
        [0x41414141, 0x42424242],
        [0xDEADBEEF, 0xCAFEBABE]
    ];

    console.log("\n[算法验证] 对比原始函数 vs 还原算法");
    var allMatch = true;

    testInputs.forEach(function (input, idx) {
        Interceptor.attach(funcAddr, {
            onEnter: function (args) {
                if (args[0].toInt32() === input[0]) {
                    this._shouldCompare = true;
                    this._a = input[0];
                    this._b = input[1];
                } else {
                    this._shouldCompare = false;
                }
            },
            onLeave: function (retval) {
                if (this._shouldCompare) {
                    var original = retval.toInt32();
                    var restored = restoredFunc(this._a, this._b);
                    var match = original === restored;
                    if (!match) allMatch = false;
                    console.log("  测试" + (idx + 1) + ": " +
                        "原始=0x" + original.toString(16) + " " +
                        "还原=0x" + restored.toString(16) + " " +
                        (match ? "✓" : "✗ 不匹配!"));
                }
            }
        });
    });

    setTimeout(function () {
        console.log("\n[结果] " + (allMatch ? "✓ 所有测试通过" :
            "✗ 存在不匹配,需继续分析"));
    }, 2000);
}

总结

本文介绍了使用 Frida 辅助分析 OLLVM 指令替换和虚假控制流的方法。核心思路是利用动态执行的确定性——在多次执行中记录真实的执行路径和寄存器值变化,从而区分虚假代码和真实逻辑,验证等价表达式关系。这种动态分析方法与 IDA 静态分析相结合,能够有效还原被 OLLVM 混淆的算法实现。