3.2.6 指令混淆

为什么需要指令混淆

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

常见的混淆方法

代码变形

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

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

mov eax, 12345678h

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

push 12345678h
pop eax

更进一步:

pushfd
mov eax, 1234
shl eax, 10
mov ax, 5678
popfd

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

继续替换:

pushfd
push 1234
pop eax
shl eax, 10
mov ax 5678

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

再看下面的例子:

jmp {label}

可以变成:

push {label}
ret

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

指令:

call {label}

可以替换成:

push {call指令后面的那个label}
push {label}
ret

指令:

push {op}

可以替换成:

sub esp, 4
mov [esp], {op}

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

mov eax, ebx
mov ecx, eax

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

mov cx, bx
mov ax, cx
mov ch, bh
mov ah, bh

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

花指令

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

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

add eax, ebx
mul ecx

加入花指令之后:

xor esi, 011223344h
add esi, eax
add eax, ebx
mov edx, eax
shl edx, 4
mul ecx
xor esi, ecx

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

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

01003689    50          push eax
0100368A    53          push ebx

加入花指令后:

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

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

01003689    50          push eax
0100368A    EB 01       jmp short 0100368D
0100368C    90          nop
0100368D    53          push ebx

扰乱指令序列

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

01003689    push eax
0100368A    push ebx
0100368B    xor eax, eax
0100368D    cmp eax, 0
01003690    jne short 01003695
01003692    inc eax
01003693    jmp short 0100368D
01003695    pop ebx
01003696    pop eax

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

01003689    push eax
0100368A    jmp short 01003694
0100368C    xor eax, eax
0100368E    jmp short 01003697
01003690    jne short 0100369F
01003692    jmp short 0100369C
01003694    push ebx
01003695    jmp short 0100368C
01003697    cmp eax, 0
0100369A    jmp short 01003690
0100369C    inc eax
0100369D    jmp short 01003697
0100369F    pop ebx
010036A0    pop eax

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

多分支

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

01003689    push eax
0100368A    push ebx
0100368B    push ecx
0100368C    push edx

变形如下:

01003689    push eax
0100368A    je short 0100368F
0100368C    push ebx
0100368D    jmp short 01003690
0100368F    push ebx
01003690    push ecx
01003691    push edx

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

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

01003689    push eax
0100368A    je short 0100368F
0100368C    push ebx
0100368D    jmp short 01003693
0100368F    push eax
01003690    mov dword ptr [esp], ebx
01003693    push ecx
01003694    push edx

不透明谓词

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

下面的代码中:

mov esi, 1
... ; some code not touching esi
dec esi
...
cmp esi, 0
jz real_code
; fake luggage
real_code:

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

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

add eax, ebx
mul ecx
add eax, esi

间接指针

dummy_data1 db      100h dup (0)
message1    db      'hello world', 0

dummy_data2 db      200h dup (0)
message2    db      'another message', 0

func        proc
            ...
            mov     eax, offset dummy_data1
            add     eax, 100h
            push    eax
            call    dump_string
            ...
            mov     eax, offset dummy_data2
            add     eax, 200h
            push    eax
            call    dump_string
            ...
func        endp

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

代码虚拟化

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

+------------+
| 头部指令序列 | -------> | 代码虚拟机入口 |
|------------|                  |
|            |          | 保存代码现场 |
|            |                  |
| 中间指令序列 |          | 模拟执行中间指令序列 |
|            |                  |
|            |          | 设置新的代码现场 |
|------------|                  |
| 尾部指令序列 | <------- | 代码虚拟机出口 |
+------------+

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

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

Last updated