花指令入门
之前打DASCTF二进制专项就被花指令耍了,上nss刷题又被花指令耍了。md,跟花指令拼了!
0.什么是花指令
假设我开发了一个程序,打包成了exe,我在这个程序里面运用了前无古人后无来者的算法。为了防止我的算法被某些逆向佬发现,我决定在我的程序中狠狠的加入花指令,欺骗有缺陷的反汇编器,这样逆向佬静态分析我的程序的时候就只能看到依托答辩了。
目前常见反汇编算法就两类,一类是线性反汇编,对输入的数据逐字节翻译成汇编代码。这种反汇编算法多数时候工作地很好,但是他无法区分数据与代码,会错误地将代码中嵌入的数据解释为指令,最后得到错误的反汇编结果。(OllyDbg大概属于这种?)
对于这类算法,他的工作流程是:从exe的入口AddressOfEntryPoint处开始,依序扫描字节码,并转换为汇编,比如第一个16进制字节码是0xE8,一般0xE8代表汇编里的CALL指令,且后面跟着的4个字节数据跟地址有关,那么反编译器就读取这一共5个字节,反编译为CALL 0x地址。如果中间只要一个地方反编译出错,例如两条汇编指令中间突然多了一个字节0xE8,那反编译器就会将其跟着的4个字节处理为CALL指令地址相关数据给反编译成一条CALL 0x地址指令。但实际上0xE8后面的四个字节是单独的字节码指令。
另一类是基于代码流分析的算法,这类算法的特点是不会无脑地继续反汇编跳转指令之后的代码,而是去优先反汇编 可达 的代码。(IDA使用的就是这种算法)。像是我们在 C 里面写
if (1) {
}
else {
/* junk code */ }
对于足够聪明的编译器,else 分支就是明确无误的垃圾。对于这种反汇编算法,可以通过可执行的花指令来欺骗,或构造反汇编器无法判断真假的恒真/恒假分支,再插入不可执行的花指令来达到欺骗效果。
1.简单写一个花指令
为了能够狠狠加入花指令,我们需要了解内联汇编。简单来讲,通过内联汇编,我们可以在高级语言的代码里写入汇编语言,以提升运行效率或者搞一些骚操作。(需要注意的是,汇编语法分为intel和AT&T,GCC默认使用AT&T汇编语法,如果用intel语法去写是编译不出来的,若是非要用Intel语法写,汇编内容第一行需要有".intel_syntax noprefix\n",可以在后面加上-masm=intel。另外,不同编译器(GCC和VC)所要求的写法也有区别。)
//vs编写 #include <iostream> using namespace std; int add(int a, int b){ int c = 0; c = a + b; return c; } int main() { int add(int a, int b); int a, b; cin >> a >> b; printf("%d", add(a, b)); return 0; }
打包exe并且扔给IDA反编译后
int __cdecl main(int argc, const char **argv, const char **envp) { __int64 v3; // rax unsigned int v4; // eax int v6; // [rsp+28h] [rbp-8h] BYREF int v7; // [rsp+2Ch] [rbp-4h] BYREF _main(argc, argv, envp); v3 = std:istream:operator>>(refptr__ZSt3cin, &v7); std:istream:operator>>(v3, &v6); v4 = add(v7, v6); printf("%d", v4); return 0; }
非常完美,那么让我们逝一逝加了花指令的。
//vs编写 #include <iostream> using namespace std; int add_with_junk(int a, int b) { int c = 0; __asm { jz label; jnz label; _emit 0xe8; label : } c = a + b; return c; } int main() { int add_with_junk(int a, int b); int a, b; cin >> a >> b; printf("%d", add_with_junk(a, b)); return 0; }
需要注意的是,万恶的VS不支持64位平台下的汇编,如果使用内联汇编会爆使用了非标准扩展: 不支持在此结构上使用“__asm”关键字的错,改用win32的项目即可解决问题。(CSDN上对于这个问题的解答将我引向了一个又一个新问题,珍爱生命,远离CSDN)。
int __cdecl main() { int v0; // eax int v1; // eax int b; // [esp+D0h] [ebp-18h] int a; // [esp+DCh] [ebp-Ch] __CheckForDebuggerJustMyCode(&9E39079A_12_cpp); v0 = std:istream:operator>>(std:cin); std:istream:operator>>(v0); v1 = add_with_junk(a, b); j__printf("%d", v1); return 0; }
__int64 __fastcall add(int a1, int a2) { return (unsigned int)(a1 + a2); }
乍一看很正常。但是当我们点进了add_with_junk函数



//vs编写 #include "stdio.h" int main(int argc, char const* argv[]) { _asm { jz label jnz label _emit 0xe9 label: } printf("Hello world!\n"); return 0; }

值得庆贺的是,我们终于让我们的代码在IDA里面看起来像依托答辩了。接下来,我们将更加深入地探索花指令。
2.深入了解花指令
不可执行花指令
我们首先需要了解的是,在大部分reverse题目里,我们遇到的都是这样的花指令。对于不可执行式花指令,有一个永恒纲领:构造永恒跳转,添加垃圾数据! 在这一部分内容中,我们将不断践行这句话。
这种花指令的原则是在原始代码中加入一段不影响原代码逻辑的无用字节。它不会被正常运行,不会改变寄存器的值,反汇编器会把他们误认为指令,从而导致我们看不到正确的反汇编结果。
Cullen等人指出为了能够有效“迷惑“静态反汇编工具,同时保证代码的正确运行,花指令必须满足两个基本特征,即:
1)垃圾数据必须是某个合法指令的一部分:
2)程序运行时,花指令必须位于实际不可执行的代码路径。
Ⅰ.jmp无条件跳转
在最开始的介绍里面,我们了解到线性反汇编算法被骗的部分原理,如果在两条指令中间插入一段字节,反编译器就会认为这是一段指令,从而导致后面反编译出错。这里介绍_emit。它的作用是在这个位置插入一个数据。针对线性反汇编算法
__asm
{
jmp Label1
_emit 0xE8
Label1:
}
jmp是一个无条件的跳转指令,它可以保证_emit 0xE8不会被执行,但是反汇编器依然会处理这段东西,从而导致出错。
当然也可以多写几个,把他们连起来
#多节形式
JMP Label1
Db thunkcode1
Label1:
……
JMP Label2
Db thunkcode2
Label2:
……
JMP Label1
Db thunkcode1
Label2:
……
JMP Label3
Db thunkcode3
Label1:
…….
JMP Label2
Db thunkcode2
Label3:
……
当然,也可以把它们乱序排列,这里不赘述。
Ⅱ.互补条件跳转
上面那些花指令都骗不了IDA。善于发现的师傅们或许注意到了在上一个部分的两段代码中,我均添加了
jz label;
jnz label;
_emit 0xe8;
label :
其中
jnz
例如jnz,那么就是说不等于0的时候跳转n是not不,z是zero零.jnz检验的标志位就是zf,这个标志位通过test改变了zf,然后jnz进行跳转.当test比较的结果不等于0,就跳转
jz
test比较结果等于0才跳转.
例如 test eax,eax. 当eax等于0的时候. 0 按位逻辑与0的结果还是0, 那么 下面执行jz的时候就直接跳转了
将这两个条件跳转放在一起,构成互补跳转。这样写与jmp无条件跳转是等价的,但这足以骗过IDA。下面还有其他类型的互补条件跳转。

Ⅲ.永真条件跳转
__asm {
push ebx;
xor ebx, ebx;
test ebx, ebx;
jnz LABEL7;
jz LABEL8;
LABEL7:
_emit 0xC7;
LABEL8:
pop ebx;
}
先对ebx进行xor之后,再进行test比较,zf标志位肯定为1,就肯定执行jz LABEL8,也就是说中间0xC7永远不会执行。不过这种一定要注意:记着保存ebx的值先把ebx压栈,最后在pop出来。
通过设置永真或者永假的条件,程序一定会执行。也可以调用某些函数会返回的确定值来构造永真或永假条件。ida和OD都被骗过去了
Ⅳ.call&ret构造花指令
__asm {
call LABEL9;
_emit 0x83;
LABEL9:
add dword ptr ss : [esp] , 8;
ret;
__emit 0xF3;
}
call指令:将下一条指令地址压入栈,再跳转执行
ret指令:将保存的地址取出,跳转执行
代码中的esp存储的就是函数返回地址,对[esp]+8,就是函数的返回地址+8,正好盖过代码中的函数指令和垃圾数据。
Ⅴ.破坏堆栈平衡
汇编中函数如果有参数或局部变量,在调用前会对堆栈进行保护 ,在返回前要还原函数调用前的堆栈,这一过程程序在编译时会自动加上,如果反编译器检测到指令破坏了堆栈平衡,即函数返回时与调用时堆栈状态发生了变化,就会报错。可以利用这一点构造破坏堆栈平衡的花指令,如下代码:
#include "stdio.h"
int main(int argc, char const* argv[]){
__asm{
test eax,0 // 构造必然条件实现跳转,绕过破坏堆栈平衡的指令
jz label
add esp,0x1
label:
}
printf("Hello world!\n");
return 0;
}
这类破环堆栈平衡的指令实际不会执行,但是由于IDA在反汇编分析时会分别从两个条件跳转处开始分析,因此判定堆栈不平衡导致反汇编失败。
以上的花指令已经比较老套,跳转的意图很明显。在源码里他们长这个样子,在IDA里他们也长这个样子,累计了一些经验以后就可以轻松识别他们。接下来,我们需要来一点创意。
a.替换ret指令
__asm
{
call LABEL9;
_emit 0xE8;
_emit 0x01;
_emit 0x00;
_emit 0x00;
_emit 0x00;
LABEL9:
push eax;
push ebx;
lea eax, dword ptr ds : [ebp - 0x0];
#将ebp的地址存放于eax
add dword ptr ss : [eax-0x50] , 26;
#该地址存放的值正好是函数返回值,
#不过该地址并不固定,根据调试所得。
#加26正好可以跳到下面的mov指令,该值也是调试计算所得
pop eax;
pop ebx;
pop eax;
jmp eax;
_emit 0xE8;
_emit 0x03;
_emit 0x00;
_emit 0x00;
_emit 0x00;
mov eax,dword ptr ss:[esp-8];
#将原本的eax值返回eax寄存器
}
由于:
call指令的本质:push 函数返回地址然后jmp 函数地址
ret指令的本质:pop eip
两者都是对寄存器eip中存放的地址的操作。
所以我们可以在call指令之后,清楚的明白函数返回地址存放于esp,可以将值取出,用跳转指令跳转到该地址,即可代替ret指令。
当然,这种构造跳转指令可以变化多样。
b.控制标志寄存器跳转
这一部分需要精通标志寄存器,每一个操作码都会对相应的标志寄存器产生相应的影响,如果我们对标志寄存器足够熟练,就可以使用对应的跳转指令构造永恒跳转!。
c.利用函数返回确定值
有些函数返回值是确定的,比如我们自己写的函数,返回值可以是任意非零整数,就可以自己构造永恒跳转。
还有些api函数也是如此:
一方面可以传入一些错误的参数,如LoadLibraryA
HMODULE LoadLibraryA(
LPCSTR lpLibFileName
);
如果我们故意传入一个不存在的模块名称,那么他就会返回一个确定的值NULL,我们就可以通过这个构造永恒跳转
另一方面,某些api函数,我们既然使用他,肯定就是一定要调用成功的,而这些api函数基本上只要调用成功就就会返回一个确定的零或者非零值,如MessageBox
int MessageBox(
HWND hWnd,
LPCTSTR lpText,
LPCTSTR lpCaption,
UINT uType
);
3.日花指令的工业革命
没有任何人力能够快的过脚本,对于那些同质化的花指令,脚本才是处理他们的最佳途径。
互补条件跳转时最容易解决的,因为其特征明显。所有的跳转指令,互补跳转指令只有最后一个bit位不同。
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
这里贴一下其他人的代码。
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")
可执行花指令
这一部分内容在程序运行的过程中会被执行,但是运行这些内容没有意义,只会加大静态分析难度。
Ⅰ.利用函数调用
函数调用时将调用指令的下一条指令地址压栈,然后跳转到函数位置执行,相当于:PUSH 下一条指令地址、MOV EIP,函数位置,相应地,函数返回时将函数调用时的压栈地址恢复给EIP,相当于POP EIP。
可以使用如下代码
#include "stdio.h"
int main(int argc, char const* argv[]) {
_asm {
push eax
push ecx
jmp label_1
label_2:
mov eax,[esp]
add [esp],8
jmp eax
label_1:
call label_2
pop eax
}
printf("Hello world!\n");
return 0;
}
通过压栈、退栈和跳转混淆代码,IDA将退栈指令识别为破坏堆栈平衡而报错。识别这类花指令后,将跳转和无效压栈等操作指令全部NOP掉即可。
Ⅱ.混淆特征码
跟传统认知中的花指令不太一样,这种行为是将一部分指令替换为与之等效的指令,从而对反汇编分析造成干扰。
如下一些指令功能可以用替代指令完成,目的即增加反汇编分析的复杂程度:
mov op1,op2 ----> push op2 / pop op1
jmp label ----> push label / ret
call label ----> push label_next_instruction / push label / ret
push op ----> sub esp,4 / mov [esp],op
参考:
Linkwww.anquanke.com/post/id/236490#h3-10
这里本来还有另一个链接的,但是原文被CSDN删了。