花指令:在代码中插入的不影响程序运行的垃圾指令(脏字节),让反编译器无法反编译起到混淆代码的作用。
花指令主要用于对抗逆向中的静态分析。
原理:反汇编算法的设计缺陷
反汇编算法主要可以分为两类:递归下降算法和线性扫描算法。
线性扫描算法
线性扫描算法p1从程序的入口点开始反汇编,然后整个代码段进行扫描,反汇编扫描过程中所遇到的每条指令。线性扫描算法的缺点在于在冯诺依曼体系下,无法区分数据和代码,从而导致将代码段中嵌入的数据误解释为指令的操作码,一致最后得到错误的反汇编结果。
递归下降
递归下降采取另外一种不同的方法来定位指令。递归下降算法强调控制流的概念。控制流根据一条指令是否被另一条指令引用来决定是否对其进行反汇编,遇到非控制转移指令时顺序进行反汇编,而遇到控制转移指令时则从转移地址处开始进行反汇编。通过构造必然条件或者互补条件,使得反汇编出错。
关于花指令的构造
花指令的名言:
构造永恒跳转,插入垃圾数据
我们通过在代码中内嵌汇编指令实现插入花指令。在不同的编译器中我们嵌入汇编的格式也不同。
主要的编译器分别为 msvc 和 gcc,我们每个例子都会写出两种不同编译器的实现方法。
1 2 3 4 5 6 7 8 9 10 11 12
| #include<stdio.h> int main() { #嵌入汇编代码 __asm { jmp s; #emit指令用于插入垃圾数据 _emit 0xe9; s: } printf("hello world!\n"); return 0; }
|
1 2 3 4 5 6 7 8 9 10 11
| #include<stdio.h> int main() { #gcc编译器内嵌汇编 __asm__ __volatile__ ( "jmp s\n\t" ".byte 0xe9\n\t" "s:\n\t" ); printf("hello world!\n"); return 0; }
|
常用的脏字节编码
构造跳转使插入的垃圾数据不被执行来欺骗反编译器。
如果我们插入的花指令是一个操作码,那么后面程序原本的机器猫就会被误认为是这个操作码的操作数,从而导致反汇编引擎的解析错误。
我们将插入的垃圾数据称为脏字节。
下面就是我们插入花指令常用的脏字节。
1 2 3 4 5 6 7 8
| call immed16 ----> E8 // 3字节指令,immed16为2字节,代表跳转指令的目的地址与下一条指令地址的距离 call immed32 ----> 9A // 5字节指令,immed32为4字节,代表跳转指令的目的地址与下一条指令地址的距离 jmp immed8 ----> EB jmp immed16 ----> E9 jmp immed32 ----> EA loop immed8 ----> E2 ret ----> C2 retn ----> C3
|
接下来我们介绍常见的花指令实现方式和清除方法。
jmp指令
这种 jmp 单次跳转只能骗过线性扫描算法,会被ida识别(递归下降)。
实现
1 2 3 4 5 6 7 8 9 10
| #include<stdio.h> int main() { __asm { jmp s; _emit 0xe9; s: } printf("hello world!\n"); return 0; }
|
1 2 3 4 5 6 7 8 9 10 11
| #include<stdio.h> int main() {
__asm__ __volatile__ ( "jmp s\n\t" ".byte 0xe9\n\t" "s:\n\t" ); printf("hello world!\n"); return 0; }
|
分析
我们将程序编译后利用ida打开查看
直接f5成功
查看汇编代码,发现了我们插入的花指令。
很明显这种花指令无法骗过ida。
多层跳转
1 2 3 4 5 6 7 8 9 10 11 12
| __asm{ jmp s1; _emit 68h; s1: jmp s2; _emit 0cdh; _emit 20h; s2: jmp s3; _emit 0e8h; s3: }
|
和单次跳转一样,这种也会被ida识别。
实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| #include<stdio.h> int main() { __asm { jmp s1; _emit 0xe9; s1: jmp s2; s2: jmp s3; s3: } printf("hello world!\n"); return 0; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #include<stdio.h>
int main(){ __asm__ __volatile__( "jmp s1\n\t" ".byte 0xe9\n\t" "s1:\n\t" "jmp s2\n\t" ".byte 0x68\n\t" "s2:\n\t" "jmp s3\n\t" ".byte 0x20\n\t" "s3:\n\t" ); printf("hello world\n"); return 0; }
|
分析
同样ida打开成功f5。
查看汇编代码,查看我们插入的花指令。
显然这种花指令也无法骗过ida。
jnx和jx互补跳转
这种花指令是 CTF 中最常见的。
1 2 3 4 5
| __asm { jz s; jnz s; _emit 0xC7; s:
|
这种花指令去除方式也很简单,特征也很明显,因为是近跳转,所以ida分析的时候会分析出jz
或者jnz
会跳转几个字节,这个时候我们就可得到垃圾数据的长度,将该长度字节的数据全部nop
掉即可解混淆。
实现
1 2 3 4 5 6 7 8 9 10 11
| #include<stdio.h> int main(){ __asm{ jz s: jnz s: _emit 0xe9; s: } printf("hello world!\n"); return 0; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #include <stdio.h>
int main() { printf("Before jump and emit instructions\n");
__asm__ __volatile__ ( "jz s\n\t" "jnz s\n\t" ".byte 0xC7\n\t" "s:\n\t" );
printf("hello world!\n"); return 0; }
|
分析
ida打开文件报红,f5反编译不成功。
很明显这种花指令骗过了ida。
接下来我们进行去除。
看到条件跳转到s,则中间的全部为垃圾数据。将其全部nop掉。
选中nop
掉的数据的那一行,按快捷键c
将数据解释为代码。
然后选中main
函数头部,按快捷键p
创建函数。
然后即可f5
反编译。
跳转指令构造花指令
1 2 3 4 5 6 7 8 9 10 11
| __asm { push ebx; xor ebx, ebx; test ebx, ebx; jnz s1; jz s2; s1: _emit 0xC7; s2: pop ebx; }
|
实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| #include<stdio.h> int main(){ __asm{ push ebx; xor ebx,ebx; test ebx,ebx; jnz s1: jz s2: s1 _emit 0xe9; s2: pop ebx; } printf("hello world!\n"); return 0; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| #include <stdio.h>
int main() { printf("Before jump and emit instructions\n");
__asm__ __volatile__ ( "pushq %rbx\n\t" "xor %rbx,%rbx\n\t" "test %rbx,%rbx\n\t" "jz s1\n\t" "jnz s2\n\t" "s1:\n\t" ".byte 0xc7\n\t" "s2:\n\t" "popq %rbx\n\t" );
printf("hello world!\n"); return 0; }
|
很明显,先对ebx
进行xor
之后,再进行test
比较,zf
标志位肯定为1,就肯定执行跳转 s1,也就是说中间0xC7
永远不会执行。
不过这种一定要注意:记着保存ebx
的值先把ebx
压栈,最后在pop
出来。
解混淆的时候也需要稍加注意,需要分析一下哪里是哪里是真正会跳到的位置,然后将垃圾数据nop
掉,本质上和前面几种没什么不同。
分析
ida打开程序无法反编译,可以看到我们插入的花指令。
根据汇编代码判断程序执行流会直接跳转到s2
,所以我们只需要将垃圾数据0xe9
给nop
掉即可。
根据汇编代码判断 s1 永远不会执行。
然后将插入的垃圾数据nop
掉。
之后选中nop
掉的数据的那一行,按快捷键c
将数据解释为代码。
然后选中main
函数头部,按快捷键p
创建函数。
然后即可f5
反编译。
call&ret构造花指令
1 2 3 4 5 6 7 8
| __asm { call LABEL9; _emit 0x83; LABEL9: add dword ptr ss : [esp] , 8; ret; __emit 0xF3; }
|
call
指令的本质:push
将函数返回地址入栈然后jmp
函数地址
ret
指令的本质:pop eip
代码中的esp
存储的是函数返回地址,对[esp]+8
,就是函数的返回地址+8,正好盖过代码中的函数指令和垃圾数据。
实现
1 2 3 4 5 6 7 8 9 10 11 12 13
| #include<stdio.h> int main(){ __asm{ call s; _emit 0x83; s: add dword ptr ss:[esp],8; ret; _emit 0xf3; } printf("hello world!\n"); return 0; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| #include <stdio.h>
int main() { printf("Before jump and emit instructions\n");
__asm__ __volatile__ ( "call s\n\t" ".byte 0x83\n\t" "s:\n\t" "addq 8,(%rsp)\n\t" "ret\n\t" ".byte 0xf3\n\t" );
printf("hello world!\n"); return 0; }
|
分析
ida打开看到我们插入的花指令,
分析代码,call s
直接跳转到s
并将下一行代码入栈。
则0x83h
永远不会被执行,并且将其地址入栈。
add
指令将栈顶数据即0x83h
地址加上8,则栈上地址为0x115e
。
经过retn
指令后,执行流直接跳到0x48h
。
所以0x48h
是执行指令的一部分,我们将0x48h
及其后面的0x8dh
解释为指令。
在0xf3h
处按快捷键u
将他们全部解释为字节码,然后在指令0x48h
处按快捷键c
将它们解释为汇编代码。
这样我们可以看到从call s
指令开始到0xf3h
都是无用的花指令,我们直接将它们全部nop
掉。
然后我们将main
开始的push
指令到retn
之间的所有汇编代码全部用快捷键u
解释为字节码,然后在main
函数开头按快捷键c
再将它们重新解释为汇编代码。
之后选中main
函数头部,按快捷键p
创建函数。
然后即可f5
反编译。
汇编指令共用opcode
jmp
的操作数是inc eax
的第一个字节,inc eax
和dec eax
抵消影响。
实现
1 2 3 4 5 6 7 8
| #include<stdio.h> int main(){ __asm{ _emit 0xeb,0xff,0xc0,0x48 } printf("hello world!\n"); return 0; }
|
1 2 3 4 5 6 7 8
| #include <stdio.h>
int main() { __asm__ __volatile__ ( ".byte 0xeb,0xff,0xc0,0x48\n\t" ); printf("hello world!\n"); }
|
分析
这里我们先运行一下程序,发现程序只是用于输出hello world!
。
通过汇编代码逻辑分析我们发现jmp
指令跳转到了loc_1151
标签的后一字节处即0x1152
。
我们首先通过快捷键u
将loc_1151
标签处的汇编指令解释为机器码。
然后先将0x1152
后面的汇编指令解释为机器码,之后在0x1152
处按快捷键c
将其解释为汇编代码。
可以看到现在的汇编代码符合程序功能,我们通过此判断0xebh
到0x48h
都是花指令。
我们重新将他们通过快捷键u
解释为机器码,然后在0x48h
的下面即0x1154
处按快捷键c
将它们解释为汇编代码。
然后将0xebh
到0x48h
全部nop
掉。
之后选中main
函数头部,按快捷键p
创建函数。
然后即可f5
反编译。
快速插入花指令
我们可以通过将花指令定义宏来快速插入花指令。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| #include <stdio.h> #include <stdlib.h>
#define FLOWER \ __asm__ __volatile__ ( \ "jz s\n\t" \ "jnz s\n\t" \ ".byte 0xC7\n\t" \ "s:\n\t" \ )
int main(){ FLOWER; printf("hello world\n"); return 0; }
|
脚本清除花指令
除汇编指令共用之外,上面有3个类别花指令 ida 无法正常识别
- 互补条件跳转(比较好处理)
- 永真条件跳转(各种永真条件比较难匹配)
- call&ret跳转(比较难处理)
所以就只对第一种jnx
和jx
的花指令进行自动化处理
所有的跳转指令,互补跳转指令只有最后一个不同
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 70 <–> JO(O标志位为1跳转) 71 <–> JNO 72 <–> JB/JNAE/JC 73 <–> JNB/JAE/JNC 74 <–> JZ/JE 75 <–> JNZ/JNE 76 <–> JBE/JNA 77 <–> JNBE/JA 78 <–> JS 79 <–> JNS 7A <–> JP/JPE 7B <–> JNP/JPO 7C <–> JL/JNGE 7D <–> JNL/JGE 7E <–> JLE/JNG 7F <–> JNLE/JG
|
抄大佬的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| from ida_bytes import get_bytes,patch_bytes start= 0x401000 end = 0x422000 buf = get_bytes(start,end-start)
def patch_at(p,ln): global buf buf = buf[:p]+b"\x90"*ln+buf[p+ln:]
fake_jcc=[] for opcode in range(0x70,0x7f,2): pattern = chr(opcode)+"\x03"+chr(opcode|1)+"\x01" fake_jcc.append(pattern.encode()) pattern = chr(opcode|1)+"\x03"+chr(opcode)+"\x01" fake_jcc.append(pattern.encode())
print(fake_jcc) for pattern in fake_jcc: p = buf.find(pattern) while p != -1: patch_at(p,5) p = buf.find(pattern,p+1)
patch_bytes(start,buf) print("Done")
|
后言
参考:花指令总结-安全客 - 安全资讯平台 (anquanke.com)
参考:逆向分析基础 — 花指令实现及清除_jmp花指令逆向-CSDN博客
参考:CTF逆向Reverse 花指令介绍 and NSSCTF靶场入门题目复现_花指令原理ctf-CSDN博客