间接跳转是一种常见的高级代码混淆技术,广泛应用于保护软件逻辑、增加逆向分析难度。这类混淆通过动态计算跳转目标,使得静态分析工具(如IDA、Binary Ninja)难以构建准确的控制流图(CFG),从而导致反编译失败或结果混乱。本文将深入探讨如何利用模拟执行技术,特别是基于 Unidbg 框架,有效去除 ARM64 架构下的间接跳转,以恢复可读的程序逻辑。
间接跳转的原理与危害
间接跳转的核心在于使用寄存器而非固定地址进行跳转,例如经典的 jmp eax 指令。这种设计让反编译器无法在静态分析阶段确定跳转的目标,进而破坏了控制流分析。
一个典型的混淆样本如下:
unsigned char dizhi[] = { 0x1, 0x2, 0x3, 0x4, 0x5 };
void rc4_crypt(unsigned char* Data, unsigned long Len_D, unsigned char* key, unsigned long Len_k)
{
unsigned char s[256];
rc4_init(s, key, Len_k);
_asm {
lea eax, label1
add eax, 8
sub eax, 7
push ebx
mov ebx, OFFSET dizhi
movzx ecx, byte ptr [ebx+2]
pop ebx
add eax, ecx
jmp eax
label1:
_emit 0x90
_emit 0x90
_emit 0x90
_emit 0x90
}
}
在此代码中,jmp eax 的目标地址由一系列复杂的计算决定。静态分析工具因无法解析 dizhi 数据段的值,导致常量传播失败,最终丢失了完整的 CFG。
模拟执行:破解混淆的利器
面对此类混淆,手动计算跳转地址虽可行,但对于包含数千种变体的大型程序而言,效率极低。此时,自动化模拟执行成为首选方案。
模拟执行框架通过模拟 CPU 指令执行过程,动态地计算出真实的跳转目标。主流的工具有:
- Unicorn:提供裸机级别的 CPU 和内存模拟,轻量但需手动处理系统调用。
- Qiling:在 Unicorn 基础上集成了系统调用 API,可模拟一个微型操作系统。
- Unidbg:专为 Android Native 层(SO 文件)设计,补齐了 Linker、JNI 等关键组件,非常适合分析安卓应用中的混淆逻辑。
- Angr:专注于符号执行和路径探索,适用于复杂约束求解。
对于安卓 SO 文件的分析,Unidbg 因其对安卓生态的良好支持而脱颖而出。
Unidbg 基础实战:单个间接跳转的去除
我们以一个简单的 jmp eax 为例,演示如何使用 Unidbg 和 Unicorn 结合的方式去除混淆。
首先,我们需要初始化 Unidbg 模拟器:
public jingqi(){
emulator=AndroidEmulatorBuilder.for64Bit()
.addBackendFactory(new Unicorn2Factory(true))
.setProcessName("com.primite.drillbeam")
.build();
Memory memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
vm = emulator.createDalvikVM(new File("D:\\下载\\ctf题目\\2025京麒杯\\drillbeam.apk"));
vm.setJni(this);
vm.setVerbose(true);
NativeApi = vm.resolveClass("com.primite.drillbeam.Check");
DalvikModule dm = vm.loadLibrary("re0", true);
Module module = dm.getModule();
dm.callJNI_OnLoad(emulator);
}
接下来,我们编写一个 Hook 函数来捕获 jmp eax 指令:
public void logIns() {
emulator.getBackend().hook_add_new(new CodeHook() {
@Override
public void hook(Backend backend, long address, int size, Object user) {
Capstone capstone = new Capstone(Capstone.CS_ARCH_ARM64, Capstone.CS_MODE_ARM);
byte[] bytes = emulator.getBackend().mem_read(address, 4);
Instruction[] disasm = capstone.disasm(bytes, 0);
System.out.printf("%x:%s %s\n", address - module.base, disasm[0].getMnemonic(), disasm[0].getOpStr());
}
@Override
public void onAttach(UnHook unHook) {}
@Override
public void detach() {}
}, module.base, module.base + module.size, null);
}
当捕获到 jmp eax 时,我们读取 eax 寄存器的值,即为真实的跳转地址。随后,我们可以使用 Keystone 引擎将 jmp eax 动态 patch 成 jmp 0x411cce 这样的直接跳转指令。
应对复杂场景:京麒CTF2024的Drillbeam挑战
实际场景远比上述例子复杂。以“京麒CTF2024”的 drillbeam 题目为例,该题结合了 OLLVM 平坦化和基于 CMP/CSEL/BR 的间接跳转,其模式如下:
CMP W8, W19
CSEL W9, W26, W24, LT
LDR X9, [X28,W9,UXTW#3]
ADD X9, X9, X20
BR X9
这种模式的难点在于,CSEL 指令会根据比较结果选择不同的值,这意味着 BR 指令有两个潜在的跳转目标。简单的单次模拟无法覆盖所有路径。
为此,我们设计了一套更复杂的处理流程:
- 识别模式:通过 Hook 指令流,匹配
CMP -> (可选的中间指令) -> CSEL/CINC -> BR 的模式。
- 收集状态:在执行过程中,将每条指令及其执行时的寄存器状态压入栈中。
- 双重模拟:第一次模拟得到一个跳转目标后,启动一个新的 Unicorn 模拟器实例。我们将第一次模拟时的内存快照和寄存器状态复制过去,并篡改
NZCV 标志寄存器,强制程序走向另一条分支,从而计算出第二个跳转目标。
- Patch 回写:获得两个目标地址后,将原指令序列 patch 为两条条件跳转指令,例如
BLT target1 和 B target2,并用 NOP 填充剩余空间。
此方法虽然效率较低(需要多次模拟),但兼容性好,能够处理 LT、EQ、NE 等多种条件。
总结与展望
通过结合 Unidbg 的环境模拟能力和 Unicorn 的精确指令执行,我们成功实现了对复杂间接跳转的自动化去除。尽管该方法存在执行路径覆盖不全、环境补全繁琐等不足,但它为分析高度混淆的二进制程序提供了强有力的武器。未来,可进一步探索与符号执行工具(如 Angr)的集成,以提高路径覆盖率和自动化程度。
参考资料
[1] 去间接跳转/模拟执行学习, 微信公众号:mp.weixin.qq.com/s/aDHkOsTZ7Yc3FuOs7MiGqA
版权声明:本文由 云栈社区 整理发布,版权归原作者所有。