OLLVM 混淆下的算法还原,如 OLLVM_MD5、OLLVM_SHA1、OLLVM_Base64

前言

OLLVM(Obfuscator-LLVM)是目前 Android 逆向分析中最常见的混淆手段之一。经过 OLLVM 混淆后的 SO 文件,其控制流被严重打乱,常量被加密,传统的通过搜索魔数常量来识别算法的方法往往失效。本文将讲解 OLLVM 混淆对算法识别的影响,以及针对 OLLVM_MD5、OLLVM_SHA1、OLLVM_Base64 的还原策略和实战技巧。

OLLVM 混淆对算法识别的影响

OLLVM 的三种核心混淆

1. 控制流平坦化(Control Flow Flattening, BCF)

控制流平坦化将函数的基本块重构为一个大的 switch-case 结构(状态机)。原始的顺序执行流程被打断,通过一个分发变量(dispatch variable)来决定下一个执行的基本块。这使得 IDA 的反编译结果变得极其复杂,难以理解真实的执行逻辑。

// 混淆前的代码
int a = 1;
int b = 2;
int c = a + b;
return c;

// 混淆后类似
int state = 0xA;
while (true) {
    switch (state) {
        case 0xA: a = 1; state = 0xB; break;
        case 0xB: b = 2; state = 0x1F; break;
        case 0x1F: c = a + b; state = 0x33; break;
        case 0x33: return c;
        default: state = random_state(); break;  // 虚假分支
    }
}

2. 指令替换(Instruction Substitution, SUB)

指令替换将简单的运算替换为功能等价但更复杂的运算。例如 a + b 可能被替换为 (a ^ b) + 2 * (a & b)(a | b) + (a & b)。这让算法中的数学运算变得难以识别。

3. 虚假控制流(Bogus Control Flow, BCF)

虚假控制流在原始代码中插入永远不会执行的假分支。这些分支包含大量垃圾代码,增加了分析工作量。

对算法识别的具体影响

识别方法 OLLVM 前的效果 OLLVM 后的效果
字符串搜索 可直接找到 “MD5”、“SHA1” 等字符串 字符串被加密,无法直接搜索
常量搜索 搜索 0x67452301 等魔数 常量被拆分/加密/间接引用
函数调用识别 EVP_ 系列函数名明确 函数名被剥离或混淆
代码结构分析 循环结构清晰可辨 控制流被打乱,循环结构不可见

OLLVM_MD5 的还原策略

策略一:魔数特征恢复

即使经过 OLLVM 混淆,MD5 的核心常量(初始化向量和 T 表)仍然存在于代码中,只是被隐藏了。还原方法:

1. 运行时提取常量

在 MD5 初始化阶段,4 个链接变量 A/B/C/D 会被赋值为魔数值。通过在函数入口处设置断点或 Hook,可以在运行时捕获这些值:

// Frida Hook SO 中疑似 MD5 的函数
Interceptor.attach(Module.findExportByName("libnative.so", "target_func"), {
    onEnter: function(args) {
        // 查看函数内部寄存器状态
    },
    onLeave: function(retval) {
        // 查看返回的哈希值是否为 16 字节(MD5 特征)
    }
});

2. 内存搜索常量

在运行时搜索内存中是否存在 MD5 的特征常量:

// 在进程内存中搜索 MD5 初始化常量
function searchMD5Constants() {
    var modules = Process.enumerateModules();
    for (var i = 0; i < modules.length; i++) {
        var ranges = modules[i].enumerateRanges('r--');
        for (var j = 0; j < ranges.length; j++) {
            var pattern = "01 23 45 67";  // 0x67452301 的小端序
            var matches = Memory.scanSync(ranges[j].base, ranges[j].size, pattern);
            if (matches.length > 0) {
                console.log("[+] Found 0x67452301 at " + matches[0].address);
            }
        }
    }
}

策略二:轮函数识别

MD5 的 64 步操作遵循固定模式:4 轮 × 16 步。即使在控制流平坦化下,这 64 步的数学运算仍然存在。识别方法:

  1. 追踪寄存器活动:观察循环中哪些寄存器被频繁使用和更新
  2. 分析位运算模式:MD5 的每步操作包含 AND/OR/XOR/NOT 和循环左移
  3. 统计操作频率:64 步操作是一个强特征,如果发现某个代码块被执行 64 次,大概率是 MD5

策略三:黑盒验证

如果无法从代码层面确认算法,可以通过以下方式验证:

import hashlib

# 已知明文测试
test_inputs = ["", "a", "abc", "message digest"]
expected_md5 = [
    "d41d8cd98f00b204e9800998ecf8427e",
    "0cc175b9c0f1b6a831c399e269772661",
    "900150983cd24fb0d6963f7d28e17f72",
    "f96b697d7cb7938d525a2f31aaf161d0"
]

# 通过 Hook 提取 APP 的实际输出,与标准 MD5 对比

OLLVM_SHA1 的还原策略

初始常量识别

SHA1 的初始化常量为 H0-H4,其中 H0-H3 与 MD5 相同。即使在 OLLVM 混淆下,可以通过以下方式识别:

// SHA1 的独有常量 0xC3D2E1F0
function searchSHA1Constant() {
    // 搜索 0xC3D2E1F0 的小端序
    var pattern = "F0 E1 D2 C3";
    var matches = Process.enumerateRangesSync('r--').reduce(function(acc, range) {
        return acc.concat(Memory.scanSync(range.base, range.size, pattern));
    }, []);
    
    matches.forEach(function(match) {
        console.log("[+] Found SHA1 H4 constant at " + match.address);
    });
}

轮常量识别

SHA1 的 4 个轮常量也是重要的识别线索:

K1 = 0x5A827999  // 小端序: 99 27 82 5A
K2 = 0x6ED9EBA1  // 小端序: A1 EB D9 6E
K3 = 0x8F1BBCDC  // 小端序: DC BC 1B 8F
K4 = 0xCA62C1D6  // 小端序: D6 C1 62 CA

区分 MD5 和 SHA1

由于 MD5 和 SHA1 共享前 4 个初始化常量,区分它们需要依赖:

  1. 输出长度:MD5 输出 16 字节,SHA1 输出 20 字节
  2. SHA1 独有常量:H4 = 0xC3D2E1F0
  3. 执行步数:MD5 是 64 步,SHA1 是 80 步
  4. 轮常量:SHA1 有 4 个不同的轮常量,MD5 有 64 个 T 表常量

OLLVM_Base64 的还原策略

编码表识别

Base64 的编码表是一个 64 字节的字符串常量。OLLVM 混淆通常会加密这个字符串,但在运行时会解密。识别方法:

1. Hook 运行时解密后的字符串

// 监控内存分配,查找可能的编码表
Interceptor.attach(Module.findExportByName(null, "malloc"), {
    onLeave: function(retval) {
        if (retval.toInt32() !== 0) {
            try {
                var str = Memory.readUtf8String(retval, 64);
                if (str && /^[A-Za-z0-9+\/]{64}$/.test(str)) {
                    console.log("[+] Base64 table found at " + retval + ": " + str);
                }
            } catch(e) {}
        }
    }
});

2. 输入输出模式匹配

Base64 编码有固定的输入输出比例:3 字节输入产生 4 字符输出。如果观察到一个函数接收二进制数据,输出只包含 A-Z、a-z、0-9、+、/、= 字符,且输出长度约为输入长度的 4/3 倍,则很可能是 Base64 编码。

识别变体 Base64

很多 APP 使用自定义 Base64 变体,例如:

// 检测自定义编码表
Interceptor.attach(Module.findExportByName("libnative.so", "encode_func"), {
    onEnter: function(args) {
        // 记录输入
        this.inputPtr = args[0];
        this.inputLen = args[1].toInt32();
    },
    onLeave: function(retval) {
        // 检查输出是否为标准 Base64
        var output = Memory.readUtf8String(retval);
        if (output && /^[A-Za-z0-9+\/=]+$/.test(output)) {
            console.log("[+] Output looks like Base64: " + output);
            // 进一步检查编码表是否标准
        }
    }
});

使用符号执行恢复真实控制流

符号执行概述

符号执行(Symbolic Execution)是一种程序分析技术,它将输入和程序变量表示为符号表达式而非具体值,通过探索所有可能的执行路径来分析程序行为。

在 OLLVM 混淆分析中,符号执行的主要用途是 恢复控制流平坦化后的真实执行顺序

使用 angr 进行符号执行

angr 是一个功能强大的二进制分析框架,支持符号执行:

import angr
import claripy

# 加载 SO 文件
project = angr.Project("libnative.so", auto_load_libs=False)

# 设置目标函数的起始地址
start_addr = 0x1234  # 替换为实际地址

# 创建符号执行状态
state = project.factory.call_state(start_addr)

# 添加约束
# ...

# 创建模拟管理器
simgr = project.factory.simulation_manager(state)

# 探索执行路径
simgr.explore(find=0x5678)  # find 设置为目标返回地址

if simgr.found:
    found_state = simgr.found[0]
    print("[+] Found path!")
    print(found_state.history.bbl_addrs.hardcopy)  # 执行的基本块地址序列

使用 miasm 进行反混淆

miasm 是另一个二进制分析框架,提供了控制流去平坦化的工具:

from miasm.analysis.binary import Container
from miasm.analysis.machine import Machine
from miasm.core.locationdb import LocationDB

# 加载二进制文件
loc_db = LocationDB()
container = Container.from_file("libnative.so", loc_db)
machine = Machine(container.arch)

# 反汇编
dis_engine = machine.dis_engine
mdis = dis_engine(container.bin_stream, loc_db=loc_db)

# 反编译
ira = machine.ira(loc_db)
ircfg = mdis.dis_multiblock(0x1234)

# 去平坦化
from miasm.analysis.deobfuscator import Deobfuscator
deobf = Deobfuscator(ira, ircfg)
deobf.build(ddg=ddg)  # 使用数据依赖图辅助去混淆

使用动态追踪记录执行路径

Frida Stalker 基础

Frida Stalker 是 Frida 的代码追踪引擎,可以记录函数级别的执行路径。对于 OLLVM 混淆的代码,Stalker 可以揭示真实的执行顺序:

// 使用 Stalker 追踪目标函数
function traceFunction(moduleName, funcName) {
    var funcAddr = Module.findExportByName(moduleName, funcName);
    
    Stalker.follow(Thread.backtrace().length > 0 ? 
        Stalker.currentThreadId : Process.getCurrentThreadId(), {
        transform: function(iterator) {
            var instruction;
            while ((instruction = iterator.next()) !== null) {
                // 记录每个基本块
                iterator.putCallout(function(context) {
                    console.log("[TRACE] " + instruction.address);
                });
            }
        }
    });
    
    // 调用目标函数
    // ...
    
    // 停止追踪
    Stalker.unfollow(Stalker.currentThreadId);
}

过滤虚假分支

OLLVM 的虚假控制流会产生大量无效的执行路径。过滤方法:

  1. 频率分析:真实的基本块会被多次执行(如果有循环),虚假分支通常只执行一次
  2. 调用图分析:目标算法的内部函数不会被虚假分支调用
  3. 时间分析:虚假分支的执行时间通常极短
var blockCount = {};

Stalker.follow(tid, {
    transform: function(iterator) {
        var instruction;
        while ((instruction = iterator.next()) !== null) {
            iterator.putCallout(function(context) {
                var addr = instruction.address.toString();
                blockCount[addr] = (blockCount[addr] || 0) + 1;
            });
        }
    }
});

// 追踪结束后,按执行频率排序
function printFrequentBlocks() {
    var sorted = Object.entries(blockCount)
        .sort(function(a, b) { return b[1] - a[1]; })
        .slice(0, 50);
    
    console.log("[+] Top 50 frequently executed blocks:");
    sorted.forEach(function(entry) {
        console.log("  " + entry[0] + " : " + entry[1] + " times");
    });
}

结合静态和动态方法的还原流程

对于 OLLVM 混淆下的算法还原,推荐以下综合流程:

Step 1:静态扫描

即使 OLLVM 混淆了代码,仍然首先进行静态扫描:

  • 使用 FindCrypt/KANAL 搜索已知的密码学常量
  • 搜索可能的字符串引用(即使被加密,部分 SO 的字符串表可能未被完全保护)
  • 分析导出函数和符号表

Step 2:动态定位

  • 通过 Frida Hook 关键函数的入口和出口
  • 监控函数输入输出的特征(长度、字符集、数值范围)
  • 使用 Stalker 记录执行路径

Step 3:特征验证

  • 对比已知算法的输入输出特征
  • 使用标准库计算预期结果并对比
  • 通过修改输入观察输出的变化规律

Step 4:参数提取

  • 通过 Hook 提取算法的密钥、IV 等参数
  • 记录完整的函数调用链和参数传递过程
  • 对于动态生成的密钥,追踪密钥派生过程

Step 5:完整还原

# 以还原 OLLVM_Base64 为例
import base64
import struct

def custom_base64_decode(encoded_str):
    """根据 Hook 提取的自定义编码表进行解码"""
    # 如果是标准 Base64
    if is_standard_table:
        return base64.b64decode(encoded_str)
    
    # 如果是自定义 Base64,构建解码表
    custom_table = extract_custom_table_from_hook()  # 从 Hook 数据中获取
    standard_table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    
    # 构建翻译表
    trans = str.maketrans(custom_table, standard_table)
    standard_encoded = encoded_str.translate(trans)
    
    return base64.b64decode(standard_encoded)

小结

OLLVM 混淆虽然增加了算法识别的难度,但并不能完全掩盖算法的本质特征。核心思路是:将静态分析(常量搜索、模式匹配)与动态分析(Stalker 追踪、运行时提取)相结合,先识别算法类型,再提取算法参数,最后用标准库完成还原。 符号执行工具(angr、miasm)可以辅助恢复控制流,但对于复杂的 OLLVM 变体,手动结合动态追踪仍然是最高效的方法。