使用 Frida Stalker 进行 OLLVM_AES 算法的追踪
前言
在上一篇文章中,我们讨论了 OLLVM 混淆下 MD5、SHA1、Base64 等算法的还原策略。本文将进一步深入,针对 AES 这类结构更复杂、轮函数更多的对称加密算法,讲解如何使用 Frida Stalker 在 OLLVM 混淆的代码中追踪和还原算法执行流程。AES 的还原难度远高于简单的哈希和编码算法,因此需要更精细的追踪策略。
Frida Stalker 工作机制回顾
Stalker 的核心架构
Frida Stalker 是一个基于 JIT 编译的代码追踪引擎。它的工作原理是:
- 代码插桩:当调用
Stalker.follow()时,Stalker 会对目标线程的代码进行实时 JIT 重编译,在编译过程中插入追踪回调 - 事件回调:通过
transform回调函数,可以在每个基本块或每条指令处插入自定义的回调逻辑 - 执行控制: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 倍)。优化建议:
- 缩小追踪范围:只追踪关键函数,不要追踪整个 SO
- 减少回调频率:使用基本块级别而非指令级别的回调
- 限制数据量:避免在回调中执行 hexdump 等重操作
- 使用 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 实现。