花指令分析


花指令:在代码中插入的不影响程序运行的垃圾指令(脏字节),让反编译器无法反编译起到混淆代码的作用。

花指令主要用于对抗逆向中的静态分析。

原理:反汇编算法的设计缺陷

反汇编算法主要可以分为两类:递归下降算法和线性扫描算法。

  1. 线性扫描算法

    线性扫描算法p1从程序的入口点开始反汇编,然后整个代码段进行扫描,反汇编扫描过程中所遇到的每条指令。线性扫描算法的缺点在于在冯诺依曼体系下,无法区分数据和代码,从而导致将代码段中嵌入的数据误解释为指令的操作码,一致最后得到错误的反汇编结果

  2. 递归下降

    递归下降采取另外一种不同的方法来定位指令。递归下降算法强调控制流的概念。控制流根据一条指令是否被另一条指令引用来决定是否对其进行反汇编,遇到非控制转移指令时顺序进行反汇编,而遇到控制转移指令时则从转移地址处开始进行反汇编。通过构造必然条件或者互补条件,使得反汇编出错。

关于花指令的构造

花指令的名言:

构造永恒跳转,插入垃圾数据

我们通过在代码中内嵌汇编指令实现插入花指令。在不同的编译器中我们嵌入汇编的格式也不同。

主要的编译器分别为 msvc 和 gcc,我们每个例子都会写出两种不同编译器的实现方法。

  • msvc内嵌汇编
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;
}
  • gcc内嵌汇编
1
2
3
4
5
6
7
8
9
10
11
#include<stdio.h>
int main() {
#gcc编译器内嵌汇编
__asm__ __volatile__ (
"jmp s\n\t" // 跳转指令,跳转到s标签
".byte 0xe9\n\t" // 插入字节0xC7
"s:\n\t" // s标签
);
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指令

1
2
3
jmp s
_emit 0xe9;
s:

这种 jmp 单次跳转只能骗过线性扫描算法,会被ida识别(递归下降)。

实现

  • msvc 编译器实现
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;
}
  • gcc 编译器实现
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成功

image-20240709201746629

查看汇编代码,发现了我们插入的花指令。

很明显这种花指令无法骗过ida。

image-20240709201707508

多层跳转

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识别。

实现

  • msvc 编译器实现
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;
}
  • gcc 编译器实现
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。

image-20240709202052512

查看汇编代码,查看我们插入的花指令。

显然这种花指令也无法骗过ida。

jnx和jx互补跳转

这种花指令是 CTF 中最常见的。

1
2
3
4
5
 __asm {
jz s;
jnz s;
_emit 0xC7;
s:

这种花指令去除方式也很简单,特征也很明显,因为是近跳转,所以ida分析的时候会分析出jz或者jnz会跳转几个字节,这个时候我们就可得到垃圾数据的长度,将该长度字节的数据全部nop掉即可解混淆。

实现

  • msvc 编译器实现
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;
}
  • gcc 编译器实现
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" // 插入字节0xC7
"s:\n\t" // 标签
);

printf("hello world!\n");
return 0;
}

分析

ida打开文件报红,f5反编译不成功。

很明显这种花指令骗过了ida。

接下来我们进行去除。

image-20240709202318272

看到条件跳转到s,则中间的全部为垃圾数据。将其全部nop掉。

选中nop掉的数据的那一行,按快捷键c将数据解释为代码。

然后选中main函数头部,按快捷键p创建函数。

然后即可f5反编译。

image-20240709202958577

跳转指令构造花指令

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;
}

实现

  • msvc 编译器实现
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;
}
  • gcc 编译器实现
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" // 插入字节0xC7
"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,所以我们只需要将垃圾数据0xe9nop掉即可。

根据汇编代码判断 s1 永远不会执行。

然后将插入的垃圾数据nop掉。

image-20240709203654926

之后选中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,正好盖过代码中的函数指令和垃圾数据。

实现

  • msvc 编译器实现
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;
}
  • gcc 编译器实现
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打开看到我们插入的花指令,

image-20240709204923822

分析代码,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 eaxdec eax抵消影响。

在这里插入图片描述

实现

  • msvc 编译器实现
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;
}
  • gcc 编译器实现
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

我们首先通过快捷键uloc_1151标签处的汇编指令解释为机器码。

然后先将0x1152后面的汇编指令解释为机器码,之后在0x1152处按快捷键c将其解释为汇编代码。

可以看到现在的汇编代码符合程序功能,我们通过此判断0xebh0x48h都是花指令。

我们重新将他们通过快捷键u解释为机器码,然后在0x48h的下面即0x1154处按快捷键c将它们解释为汇编代码。

然后将0xebh0x48h全部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 无法正常识别

  1. 互补条件跳转(比较好处理)
  2. 永真条件跳转(各种永真条件比较难匹配)
  3. call&ret跳转(比较难处理)

所以就只对第一种jnxjx的花指令进行自动化处理

所有的跳转指令,互补跳转指令只有最后一个不同

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#start addr
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博客