花指令 (junk code) 是一种专门用来迷惑反编译器的指令片段,这些指令片段不会影响程序的原有功能,但会使得反汇编器的结果出现偏差,从而使破解者分析失败。(对于只会 F5 的人来说,就是地狱)
Note
“众所周知,逆向这个活七分靠猜,因为在 compilation 的过程中大量信息丢失了,如分支跳转,类型,oop 里的跳转表,尾递归优化,死码消除等,所以逆向不可能完全靠精确无误的 algorithm,还需要可能出错但是大部分时候正确的 heuristic(经验原则)。
“我现在很悲观,二进制分析看起来最靠谱的方法是对着编译器的优化 case-by-case 写分析,或者寻求人工智能,处理这些需要靠猜来解决的问题。”
——All You Ever Wanted to Know About x86 x64 Binary Disassembly but Were Afraid to Ask
花指令的原理
反汇编算法简介
常用的反汇编算法分为两类:线性扫描 (linear sweep) 和递归下降 (recursive decend)。
-
线性扫描
从入口开始,依次将每一条字节序列解析为指令,遇到分支指令不会递归进入分支。这种反汇编算法非常老实,但问题是:不是所有字节都是指令,因此中间会解析出一些花指令。
在指令区域中找到数据并不罕见,尤其是在一些现代编译器的处理产物中。在指令之间插入只读数据的一个原因是,编译器必须遵循某些架构约束,以便使代码运行得更快。
区分代码和数据非常困难。目前我们没有任何确定的算法能够正确识别出哪些被用作代码,哪些被用作数据。大多数分析工具实现了某些启发式算法来尝试区分指令和数据。Note反汇编任何数据,都可能产生看似有效的 x86 代码。
——Recognizing and Avoiding Disassembled Junk
在二进制文件中区分代码和数据是一个完全不可解问题。
——Disassembly Challenges伪代码:
1
2
3
4
5
6target = getFunctionAddress(main)
targetEnd = getFunctionEnd(main)
currentAddr = target
while currentAddr < targetEnd:
currentLen = disAsm(addr)
currentAddr += currentLen -
递归下降
从入口开始,依次将每一条字节序列解析为指令,遇到分支指令递归进入分支以优先反汇编可达的代码。关联资料What is the algorithm used in Recursive Traversal disassembly?
IDA 使用的算法是线性扫描和递归下降的综合。递归下降是基于代码控制流分析的算法,可以在一定程度上模拟程序的实际执行流程。对于反汇编的指令,我们立即评估其语义能力。如果正在分析的指令改变了流程控制,我们将针对该指令目标和下一条指令启动反汇编。
欺骗算法
x86 指令集的长度不是固定的。有些指令很短,只有 1 字节;有一些指令比较长,可以达到 5 字节。如果通过巧妙构造,引导反汇编引擎解析一条错误的指令,扰乱解析指令的长度,就能使反汇编引擎无法按照正确的指令长度依次解析邻接未解析的指令,最终使得反汇编引擎返回错误结果。
接下来看一个例子:
jmp
指令的字节码构成为:0xE9
+ 4 字节的偏移值(目标地址 - 当前地址 - 5),如 0xE9 0x00 0x00 0x00 0x00
即跳转到下一条指令。
这是一个简单的基于 jmp
的花指令:
1 | 0000 E9 01 00 00 00 jmp 0006 |
正确的过程是,CPU 执行 E9 01 00 00 00
后直接跳到 0006
地址处继续执行,0005
的指令是不执行的。
但线性扫描反汇编在分析完了 0000
地址后会接着分析 0005
地址,这就是问题所在:反汇编引擎识别到 0005
处的 0xE9
时会想当然地分析为 jmp
指令。这种引擎分析出来的结果可能是这样的:
1 | 0000 E9 01 00 00 00 jmp 0006 |
逻辑完全打乱了!
前面说过,递归下降是基于控制流的算法,因此简单的 jmp
就无法搪塞过去了。但如果我们对控制流做了手脚呢?
je
是一个条件跳转指令,当反汇编引擎遇到该指令时,就会分别解析可能跳转的两个分支。
例如这种花指令:
1 | xor eax,eax |
xor
是异或运算,这里用于清零 eax
,这样在正常执行流程中,cmp eax,0
是永真的,je
必定跳过花指令而执行目标指令。
但是,递归下降必须分析两条分支,即使 je
可能已经指定了目标。这样反汇编引擎就可能分析到错误的分支,导致无法继续分析。