使用 Frida Stalker 进行 OLLVM_AES 算法的追踪

前言

在上一篇文章中,我们讨论了 OLLVM 混淆下 MD5、SHA1、Base64 等算法的还原策略。本文将进一步深入,针对 AES 这类结构更复杂、轮函数更多的对称加密算法,讲解如何使用 Frida Stalker 在 OLLVM 混淆的代码中追踪和还原算法执行流程。AES 的还原难度远高于简单的哈希和编码算法,因此需要更精细的追踪策略。

Frida Stalker 工作机制回顾

Stalker 的核心架构

Frida Stalker 是一个基于 JIT 编译的代码追踪引擎。它的工作原理是:

  1. 代码插桩:当调用 Stalker.follow() 时,Stalker 会对目标线程的代码进行实时 JIT 重编译,在编译过程中插入追踪回调
  2. 事件回调:通过 transform 回调函数,可以在每个基本块或每条指令处插入自定义的回调逻辑
  3. 执行控制:Stalker 接管目标线程的执行,记录完整的执行路径

Stalker 的三种追踪模式

// 模式一:基于调用(Call-based)
Stalker.follow(tid, {
    events: {
        call: true,       // 函数调用事件
        ret: true,        // 函数返回事件
        exec: false,      // 指令执行事件
        block: false      // 基本块事件
    }
});

// 模式二:基于基本块(Block-based)—— 推荐
Stalker.follow(tid, {
    transform: function(iterator) {
        var instruction;
        while ((instruction = iterator.next()) !== null) {
            iterator.putCallout(function(context) {
                // 每个基本块开始时触发
                console.log("[BLOCK] " + instruction.address);
            });
        }
    }
});

// 模式三:基于编译(Compile-based)
Stalker.follow(tid, {
    transform: function(iterator) {
        // 可以修改指令、插入新指令
        var instruction;
        while ((instruction = iterator.next()) !== null) {
            // 插入日志指令
        }
    }
});

对于算法追踪,模式二(基本块模式) 是最合适的,它提供了足够的精度同时避免了指令级追踪带来的巨大性能开销。

追踪 OLLVM 混淆下的 AES 算法

场景描述

假设某 APP 使用了一个自实现的 AES 加密函数,位于 SO 文件中的 aes_encrypt 函数,且该 SO 文件经过了 OLLVM 控制流平坦化混淆。我们的目标是通过动态追踪,还原出完整的 AES 执行流程和密钥。

Step 1:定位目标函数

首先需要找到 AES 加密函数的地址。如果函数名未导出,可以通过以下方式定位:

// 方法一:通过字符串交叉引用
// 搜索 "AES" 相关字符串,找到引用它的函数

// 方法二:通过已知调用者
// 如果知道上层 Java 方法调用了 native 函数,通过 JNI 注册找到 native 地址

// 方法三:通过密文特征
// Hook 所有 native 函数,检查输出是否符合 AES 特征(16 字节对齐的密文)
Java.perform(function() {
    var Cipher = Java.use("javax.crypto.Cipher");
    Cipher.doFinal.overload('[B').implementation = function(input) {
        var output = this.doFinal(input);
        if (output.length > 0 && output.length % 16 === 0) {
            console.log("[*] Possible AES: input=" + input.length + 
                       " output=" + output.length);
        }
        return output;
    };
});

Step 2:设置 Stalker 追踪

定位到目标函数后,使用 Stalker 进行追踪:

var targetModule = "libnative.so";
var targetFunc = "aes_encrypt";
var targetAddr = Module.findExportByName(targetModule, targetFunc);

if (targetAddr === null) {
    console.log("[-] Function not found by name, searching...");
    // 通过偏移量定位
    var baseAddr = Module.findBaseAddress(targetModule);
    targetAddr = baseAddr.add(0x1234);  // 替换为实际偏移
}

var blockLog = [];
var callLog = [];

// 设置 Stalker 追踪
Stalker.follow(Process.getCurrentThreadId(), {
    transform: function(iterator) {
        var instruction;
        while ((instruction = iterator.next()) !== null) {
            // 记录基本块执行
            var addr = instruction.address;
            
            iterator.putCallout(function(context) {
                blockLog.push(addr.toString());
            });
            
            // 可选:记录 CALL 指令(用于识别子函数调用)
            if (instruction.mnemonic === "bl" || instruction.mnemonic === "blx") {
                iterator.putCallout(function(context) {
                    callLog.push({
                        from: instruction.address.toString(),
                        to: instruction.opStr  // 目标地址
                    });
                });
            }
        }
    }
});

// 调用目标函数(通过 Hook 或主动调用触发)
// ...

// 收集结果
Stalker.unfollow();
console.log("[+] Traced " + blockLog.length + " blocks");

Step 3:从执行序列中识别 AES 轮函数

AES 加密的核心是 10/12/14 轮迭代,每轮包含 SubBytes、ShiftRows、MixColumns、AddRoundKey 四个操作。在 OLLVM 混淆下,这些操作被分散到控制流平坦化的基本块中,但执行序列仍然保留了 AES 的结构特征。

识别 SubBytes

SubBytes 的核心是 S-box 查找操作,每次处理 16 字节。在追踪中表现为:

  • 一个循环执行 16 次(对 AES-128 的 16 字节块)
  • 每次迭代中有一个内存读取操作(读取 S-box)
  • 内存读取的地址在一个固定的 256 字节范围内
// 在 Stalker transform 中识别 S-box 查找
var sboxAccesses = {};

iterator.putCallout(function(context) {
    // 检查是否在 S-box 地址范围内
    if (isInSboxRange(instruction.address)) {
        var offset = instruction.address.sub(sboxBase);
        sboxAccesses[offset] = (sboxAccesses[offset] || 0) + 1;
    }
});

识别 ShiftRows

ShiftRows 是纯粹的数据移动操作(行移位),不涉及查表或复杂计算。在追踪中表现为一系列寄存器操作和内存写入,没有明显的循环或查表特征。

识别 MixColumns

MixColumns 是 GF(2^8) 上的矩阵乘法,涉及乘法运算。在 ARM 汇编中,可能通过查表或位运算实现。识别特征:

  • 处理 4 字节一组(一列)
  • 涉及 XOR 操作和循环移位
  • 在查表实现中,会访问另外的查找表(T-tables)

识别 AddRoundKey

AddRoundKey 是轮密钥与状态矩阵的异或操作。在追踪中表现为:

  • 读取密钥数据(从内存或寄存器)
  • 执行 XOR 操作
// 记录 XOR 操作频率(AddRoundKey 中 XOR 操作密集)
var xorCount = 0;
iterator.putCallout(function(context) {
    if (instruction.mnemonic === "eor" || instruction.mnemonic === "eor.w") {
        xorCount++;
    }
});

过滤虚假分支的干扰

OLLVM 的虚假控制流会在执行路径中插入大量垃圾基本块。如果不过滤,追踪结果会被淹没在噪声中。

过滤策略一:执行频率分析

AES 的核心操作(特别是 SubBytes 的 S-box 查找和 MixColumns 的矩阵运算)会以固定频率出现。虚假分支通常只执行一次或执行频率极低。

function analyzeBlockFrequency(log) {
    var freq = {};
    log.forEach(function(addr) {
        freq[addr] = (freq[addr] || 0) + 1;
    });
    
    // 对于 AES-128 CBC 加密一个块(10 轮):
    // SubBytes: 16 次查找/轮 × 10 轮 = 160 次
    // ShiftRows: 执行 10 次
    // MixColumns: 执行 9 次(最后一轮没有 MixColumns)
    // AddRoundKey: 执行 11 次(初始 + 10 轮)
    
    // 过滤掉只执行 1 次的基本块(很可能是虚假分支)
    var filtered = Object.entries(freq)
        .filter(function(entry) { return entry[1] > 1; })
        .sort(function(a, b) { return b[1] - a[1]; });
    
    console.log("[+] Frequent blocks (freq > 1):");
    filtered.forEach(function(entry) {
        console.log("  Block " + entry[0] + ": " + entry[1] + " times");
    });
    
    return filtered;
}

过滤策略二:内存访问模式

真实的 AES 操作会访问特定的内存区域(S-box、轮密钥、输入/输出缓冲区)。虚假分支通常不产生有意义的内存访问。

var memoryAccessLog = [];

iterator.putCallout(function(context) {
    // 检查当前指令是否为内存加载/存储
    var isLoad = instruction.groups && instruction.groups.load;
    var isStore = instruction.groups && instruction.groups.store;
    
    if (isLoad || isStore) {
        memoryAccessLog.push({
            addr: instruction.address,
            type: isLoad ? 'R' : 'W',
            pc: context.pc
        });
    }
});

// 分析结束后,检查哪些基本块产生了一致的内存访问模式

过滤策略三:时间窗口分析

// 记录每个基本块的执行时间戳
var blockTimings = [];

iterator.putCallout(function(context) {
    blockTimings.push({
        addr: instruction.address.toString(),
        time: Date.now()
    });
});

// 分析:如果某个基本块在异常短的时间内执行完毕,可能是虚假分支
// AES 的真实操作(特别是 MixColumns)需要相对较多的计算时间

重建 AES 执行流程

收集关键执行事件

结合上述过滤策略,重建 AES 的执行流程:

// 综合追踪脚本
function traceAES(moduleName, funcOffset) {
    var base = Module.findBaseAddress(moduleName);
    var funcAddr = base.add(funcOffset);
    
    var events = [];
    var startTime = Date.now();
    
    // 预先确定 S-box 可能的地址范围
    var sboxRangeStart = null;
    var sboxRangeEnd = null;
    
    // 扫描 .rodata 段查找可能的 S-box
    var ranges = Process.enumerateRangesSync('r--');
    ranges.forEach(function(range) {
        try {
            var firstByte = Memory.readU8(range.base);
            // AES S-box 第一个字节是 0x63
            if (firstByte === 0x63 && range.size >= 256) {
                var secondByte = Memory.readU8(range.base.add(1));
                if (secondByte === 0x7c) {  // S-box 第二个字节
                    sboxRangeStart = range.base;
                    sboxRangeEnd = range.base.add(256);
                    console.log("[+] Possible S-box at " + range.base);
                }
            }
        } catch(e) {}
    });
    
    return events;
}

构建轮函数执行图

将追踪结果整理为轮函数的执行图:

Round 0 (Initial):
  - AddRoundKey: [block addresses...]
  
Round 1-9:
  - SubBytes:    [block addresses...]
  - ShiftRows:   [block addresses...]
  - MixColumns:  [block addresses...]
  - AddRoundKey: [block addresses...]
  
Round 10 (Final):
  - SubBytes:    [block addresses...]
  - ShiftRows:   [block addresses...]
  - AddRoundKey: [block addresses...]

提取 S-box 和密钥扩展逻辑

提取 S-box

通过追踪 S-box 的内存读取操作,可以验证和提取 S-box:

// 在 Stalker 回调中收集 S-box 查找的索引
var sboxIndices = [];

iterator.putCallout(function(context) {
    // 如果当前指令是加载操作且目标在 S-box 范围内
    if (sboxRangeStart && instruction.mnemonic === "ldr") {
        try {
            var operands = instruction.opStr.split(", ");
            if (operands.length >= 2) {
                // 尝试解析目标地址
                // ...
            }
        } catch(e) {}
    }
});

// 通过 Hook 直接读取 S-box 内容
function dumpSbox(address) {
    console.log("[+] S-box dump:");
    for (var i = 0; i < 256; i += 16) {
        var row = [];
        for (var j = 0; j < 16; j++) {
            row.push(("0" + Memory.readU8(address.add(i + j)).toString(16)).slice(-2));
        }
        console.log("  " + row.join(" "));
    }
}

提取密钥扩展逻辑

AES 密钥扩展(Key Expansion)将原始密钥扩展为所有轮所需的轮密钥。追踪密钥扩展的方法:

// 监控内存写入,查找轮密钥的存储位置
Interceptor.attach(funcAddr, {
    onEnter: function(args) {
        this.keyPtr = args[1];  // 假设第二个参数是密钥指针
        console.log("[*] Key pointer: " + this.keyPtr);
        console.log("[*] Key data: " + hexdump(this.keyPtr, {length: 16}));
    },
    onLeave: function(retval) {
        // 检查密钥扩展后的轮密钥存储区域
        // AES-128 的轮密钥总共 176 字节(11 × 16)
        console.log("[*] Expanded key:");
        for (var i = 0; i < 176; i += 16) {
            var roundKey = Memory.readByteArray(this.keyPtr.add(i), 16);
            console.log("  Round " + (i/16) + ": " + bytes2hex(roundKey));
        }
    }
});

完整案例演示

目标

还原某 APP 的 AES 加密,该 APP 使用了 OLLVM 混淆的自实现 AES-CBC。

Step 1:信息收集

通过抓包和初步分析,确认以下信息:

  • 加密模式:CBC(密文长度为明文长度向上取 16 的倍数)
  • 密钥长度:128 位(16 字节)
  • IV 长度:16 字节

Step 2:完整追踪脚本

// 完整的 OLLVM AES 追踪脚本
function traceOLLVM_AES() {
    var moduleName = "libsecurity.so";
    var funcOffset = 0x1A3C0;  // 目标函数偏移
    
    var base = Module.findBaseAddress(moduleName);
    var funcAddr = base.add(funcOffset);
    
    console.log("[*] Target function: " + funcAddr);
    
    // 1. Hook 函数入口,提取密钥和 IV
    Interceptor.attach(funcAddr, {
        onEnter: function(args) {
            this.input = args[0];
            this.key = args[1];
            this.iv = args[2];
            this.output = args[3];
            this.len = args[4].toInt32();
            
            console.log("\n[=== AES Encrypt Called ===]");
            console.log("Input: " + hexdump(this.input, {length: Math.min(this.len, 64)}));
            console.log("Key:   " + hexdump(this.key, {length: 16}));
            console.log("IV:    " + hexdump(this.iv, {length: 16}));
            console.log("Len:   " + this.len);
        },
        onLeave: function(retval) {
            console.log("Output: " + hexdump(this.output, {length: Math.min(this.len, 128)}));
        }
    });
    
    // 2. 使用 Stalker 追踪内部执行
    var tid = Process.getCurrentThreadId();
    var blockExec = {};
    
    Stalker.follow(tid, {
        transform: function(iterator) {
            var instruction;
            while ((instruction = iterator.next()) !== null) {
                var blockStart = instruction.address;
                iterator.putCallout(function(ctx) {
                    var key = blockStart.toString();
                    blockExec[key] = (blockExec[key] || 0) + 1;
                });
            }
        }
    });
    
    // 追踪完成后分析
    console.log("[*] Analysis running...");
    
    // 3. 输出执行频率分析
    var sorted = Object.entries(blockExec)
        .filter(function(e) { return e[1] > 5; })
        .sort(function(a, b) { return b[1] - a[1]; });
    
    console.log("[+] Hot blocks (executed > 5 times):");
    sorted.forEach(function(e) {
        var offset = "0x" + (parseInt(e[0], 16) - base).toString(16);
        console.log("  " + offset + ": " + e[1] + " times");
    });
    
    Stalker.unfollow(tid);
    
    return {
        key: Memory.readByteArray(this.key, 16),
        iv: Memory.readByteArray(this.iv, 16)
    };
}

Step 3:验证还原

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

def aes_cbc_decrypt(key_hex, iv_hex, ciphertext_hex):
    key = bytes.fromhex(key_hex)
    iv = bytes.fromhex(iv_hex)
    ciphertext = bytes.fromhex(ciphertext_hex)
    
    cipher = AES.new(key, AES.MODE_CBC, iv)
    plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
    
    return plaintext.decode('utf-8')

# 使用 Hook 提取的参数
key_hex = "..."   # 从 Frida 输出中提取
iv_hex = "..."    # 从 Frida 输出中提取
ct_hex = "..."    # 从 Frida 输出中提取

result = aes_cbc_decrypt(key_hex, iv_hex, ct_hex)
print("[+] Decrypted: " + result)

Step 4:确认参数提取完整

最后需要确认:

  • 密钥是固定值还是动态生成的
  • IV 是固定值还是每次随机生成
  • 填充方式是 PKCS5/PKCS7 还是 ZeroPadding
  • 如果密钥动态生成,需要继续追踪密钥派生函数

性能优化建议

使用 Frida Stalker 追踪 OLLVM 混淆代码时,性能开销非常大(可能慢 10-100 倍)。优化建议:

  1. 缩小追踪范围:只追踪关键函数,不要追踪整个 SO
  2. 减少回调频率:使用基本块级别而非指令级别的回调
  3. 限制数据量:避免在回调中执行 hexdump 等重操作
  4. 使用 compile 参数:在 transform 中缓存结果
// 优化版:使用缓存减少开销
var blockCache = {};

transform: function(iterator) {
    var instruction;
    while ((instruction = iterator.next()) !== null) {
        if (!blockCache[instruction.address]) {
            blockCache[instruction.address] = true;
            iterator.putCallout(function(context) {
                blockLog.push(instruction.address.toString());
            });
        }
    }
}

小结

使用 Frida Stalker 追踪 OLLVM 混淆下的 AES 算法,关键在于:通过执行频率分析和内存访问模式识别真实的 AES 轮函数,过滤掉 OLLVM 虚假分支的干扰。 核心流程是:定位目标函数 → Stalker 追踪基本块执行 → 分析执行频率和内存访问 → 识别 S-box 查找和轮函数模式 → 提取密钥和 IV → 使用标准库验证还原结果。这种方法虽然需要一定的经验积累,但可以应对大多数经过 OLLVM 混淆的 AES 实现。