3.2.6 指令混淆

为什么需要指令混淆

软件的安全性严重依赖于代码复杂化后被分析者理解的难度,通过指令混淆,可以将原始的代码指令转换为等价但极其复杂的指令,从而尽可能地提高分析和破解的成本。

常见的混淆方法

代码变形

代码变形是指将单条或多条指令转变为等价的单条或多条其他指令。其中对单条指令的变形叫做局部变形,对多条指令结合起来考虑的变成叫做全局变形。

例如下面这样的一条赋值指令:

  1. mov eax, 12345678h

可以使用下面的组合指令来替代:

  1. push 12345678h
  2. pop eax

更进一步:

  1. pushfd
  2. mov eax, 1234
  3. shl eax, 10
  4. mov ax, 5678
  5. popfd

pushfdpopfd 是为了保护 EFLAGS 寄存器不受变形后指令的影响。

继续替换:

  1. pushfd
  2. push 1234
  3. pop eax
  4. shl eax, 10
  5. mov ax 5678

这样的结果就是简单的指令也可能会变成上百上千条指令,大大提高了理解的难度。

再看下面的例子:

  1. jmp {label}

可以变成:

  1. push {label}
  2. ret

而且 IDA 不能识别出这种 label 标签的调用结构。

指令:

  1. call {label}

可以替换成:

  1. push {call指令后面的那个label}
  2. push {label}
  3. ret

指令:

  1. push {op}

可以替换成:

  1. sub esp, 4
  2. mov [esp], {op}

下面我们来看看全局变形。对于下面的代码:

  1. mov eax, ebx
  2. mov ecx, eax

因为两条代码具有关联性,在变形时需要综合考虑,例如下面这样:

  1. mov cx, bx
  2. mov ax, cx
  3. mov ch, bh
  4. mov ah, bh

这种具有关联性的特定使得通过变形后的代码推导变形前的代码更加困难。

花指令

花指令就是在原始指令中插入一些虽然可以被执行但是没有任何作用的指令,它的出现只是为了扰乱分析,不仅是对分析者来说,还是对反汇编器、调试器来说。

来看个例子,原始指令如下:

  1. add eax, ebx
  2. mul ecx

加入花指令之后:

  1. xor esi, 011223344h
  2. add esi, eax
  3. add eax, ebx
  4. mov edx, eax
  5. shl edx, 4
  6. mul ecx
  7. xor esi, ecx

其中使用了源程序不会使用到的 esi 和 edx 寄存器。这就是一种纯粹的垃圾指令。

有的花指令用于干扰反汇编器,例如下面这样:

  1. 01003689 50 push eax
  2. 0100368A 53 push ebx

加入花指令后:

  1. 01003689 50 push eax
  2. 0100368A EB 01 jmp short 0100368D
  3. 0100368C FF53 6A call dword ptr [ebx+6A]

乍一看似乎很奇怪,其实是加入因为加入了机器码 EB 01 FF,使得线性分析的反汇编器产生了误判。而在执行时,第二条指令会跳转到正确的位置,流程如下:

  1. 01003689 50 push eax
  2. 0100368A EB 01 jmp short 0100368D
  3. 0100368C 90 nop
  4. 0100368D 53 push ebx

扰乱指令序列

指令一般都是按照一定序列执行的,例如下面这样:

  1. 01003689 push eax
  2. 0100368A push ebx
  3. 0100368B xor eax, eax
  4. 0100368D cmp eax, 0
  5. 01003690 jne short 01003695
  6. 01003692 inc eax
  7. 01003693 jmp short 0100368D
  8. 01003695 pop ebx
  9. 01003696 pop eax

指令序列看起来很清晰,所以扰乱指令序列就是要打乱这种指令的排列方式,以干扰分析者:

  1. 01003689 push eax
  2. 0100368A jmp short 01003694
  3. 0100368C xor eax, eax
  4. 0100368E jmp short 01003697
  5. 01003690 jne short 0100369F
  6. 01003692 jmp short 0100369C
  7. 01003694 push ebx
  8. 01003695 jmp short 0100368C
  9. 01003697 cmp eax, 0
  10. 0100369A jmp short 01003690
  11. 0100369C inc eax
  12. 0100369D jmp short 01003697
  13. 0100369F pop ebx
  14. 010036A0 pop eax

虽然看起来很乱,但真实的执行顺序没有改变。

多分支

多分支是指利用不同的条件跳转指令将程序的执行流程复杂化。与扰乱指令序列不同的时,多分支改变了程序的执行流。举个例子:

  1. 01003689 push eax
  2. 0100368A push ebx
  3. 0100368B push ecx
  4. 0100368C push edx

变形如下:

  1. 01003689 push eax
  2. 0100368A je short 0100368F
  3. 0100368C push ebx
  4. 0100368D jmp short 01003690
  5. 0100368F push ebx
  6. 01003690 push ecx
  7. 01003691 push edx

代码里加入了一个条件分支,但它究竟会不会触发我们并不关心。于是程序具有了不确定性,需要在执行时才能确定。但可以肯定的时,这段代码的执行结果和原代码相同。

再改进一下,用不同的代码替换分支处的代码:

  1. 01003689 push eax
  2. 0100368A je short 0100368F
  3. 0100368C push ebx
  4. 0100368D jmp short 01003693
  5. 0100368F push eax
  6. 01003690 mov dword ptr [esp], ebx
  7. 01003693 push ecx
  8. 01003694 push edx

不透明谓词

不透明谓词是指一个表达式的值在执行到某处时,对程序员而言是已知的,但编译器或静态分析器无法推断出这个值,只能在运行时确定。上面的多分支其实也是利用了不透明谓词。

下面的代码中:

  1. mov esi, 1
  2. ... ; some code not touching esi
  3. dec esi
  4. ...
  5. cmp esi, 0
  6. jz real_code
  7. ; fake luggage
  8. real_code:

假设我们知道这里 esi 的值肯定是 0,那么就可以在 fake luggage 处插入任意长度和复杂度的指令,以达到混淆的目的。

其它的例子还有(同样假设esi为0):

  1. add eax, ebx
  2. mul ecx
  3. add eax, esi

间接指针

  1. dummy_data1 db 100h dup (0)
  2. message1 db 'hello world', 0
  3. dummy_data2 db 200h dup (0)
  4. message2 db 'another message', 0
  5. func proc
  6. ...
  7. mov eax, offset dummy_data1
  8. add eax, 100h
  9. push eax
  10. call dump_string
  11. ...
  12. mov eax, offset dummy_data2
  13. add eax, 200h
  14. push eax
  15. call dump_string
  16. ...
  17. func endp

这里通过 dummy_data 来间接地引用 message,但 IDA 就不能正确地分析到对 message 的引用。

代码虚拟化

基于虚拟机的代码保护也可以算是代码混淆技术的一种,是目前各种混淆中保护效果最好的。简单地说,该技术就是通过许多模拟代码来模拟被保护的代码的执行,然后计算出与被保护代码执行时相同的结果。

  1. +------------+
  2. | 头部指令序列 | -------> | 代码虚拟机入口 |
  3. |------------| |
  4. | | | 保存代码现场 |
  5. | | |
  6. | 中间指令序列 | | 模拟执行中间指令序列 |
  7. | | |
  8. | | | 设置新的代码现场 |
  9. |------------| |
  10. | 尾部指令序列 | <------- | 代码虚拟机出口 |
  11. +------------+

当原始指令执行到指令序列的开始处,就转入代码虚拟机的入口。此时需要保存当前线程的上下文信息,然后进入模拟执行阶段,该阶段是代码虚拟机的核心。有两种方案来保证虚拟机代码与原始代码的栈空间使用互不冲突,一种是在堆上开辟开辟新的空间,另一种是继续使用原始代码所使用的栈空间,这两种方案互有优劣,在实际中第二种使用较多。

对于怎样模拟原始代码,同样有两种方案。一种是将原本的指令序列转变为一种具有直接或者间接对应关系的,只有虚拟机才能理解的代码数据。例如用 0 来表示 push, 1 表示 mov 等。这种直接或间接等价的数据称为 opcode。另一种方案是将原始代码的意义直接转换成新的代码,类似于代码变形,这种方案基于指令语义,所以设计难度非常大。