天天看点

Shellcode学习之编写变形的shellcode[实战篇]

上次花了很多笔墨来说明一个变形的shellcode到底是什么原理,这一次的实战篇我们就来做一个简单的变形shellcode。由于篇幅的限制,这个变形的东西还是比较幼稚,但是它已经能够逃脱几乎所有按照shellcode特征来杀 “毒”的各类软件,在shellcode不限长度的时候,你甚至可以反复的使用这个加密的头部来进行变形。具体的内容,我们在下面慢慢说。

切入正题之前还是按照老传统先科普一下。shellcode后面一部分是实现真正功能的部分,这一块我们可以用某种加密的方式来进行编码,而前面的 decode部分则是解码的。实现功能部分的编码本身就带有变形的意义,比如我用0x99(最常见的)来异或,与我们用0x98或者0xee来异或,结果是不同的,这一点外在的表现就是变形。我们需要的更多是一种能够变形的解码部分,而且要求这部分可以相对独立,这样子就像一个套子一样,套在一块代码上面就变了一次形,多套几次也没关系,最多变成其它的更加让人不知为何物的东西。

需要明白的是,我们写的这个变形shellcode是一次性的(也叫做抛弃型的),因为shellcode在一次exploit的时候只能用一次,不像病毒一样还要辛辛苦苦的传播。所以我们作的东西,只要能够生成每次不一样的shellcode就可以,shellcode不用包含自己让自己变形的部分—— 现在这句话还有点拗口,看到后面自然就会明白的。

那么,我们的变形之旅还是从一个最基本的decode部分开始:

jmp l

de: pop ebx

xor ecx, ecx

mov cl, 222h

lp: xor byte ptr [ebx], 0x99

inc ebx

loop lp

jmp stt

l: call de

stt:

看过两期连载的朋友可能都要笑了,又是这个,都看烦了。没关系,总是从最简单的开始嘛。我们假设起作用的部分是xor过0x99的,就可以专注于解码部分了。如果异或的数字不是0x99,那只需要改变第五行的那个操作数即可——这也是一个变形。这一块解码部分首先满足了相对的独立性,也就是说它不依赖于任何的环境,比如各种寄存器的值或者是栈上的数据,它只是负责将后面的数据按照异或0x99的方式解码,这样子,我们把它看作一个“帽子”,并用D来表示,将X()作为异或0x99的编码方法,对于任何一个可执行的shellcode(表示为字符串S),下面就是我们最常看到的形式:

D + X(S)

将帽子再套一层,变成了:

D + X( D + X(S) )

这也是可以执行的,就是作了两次解码工作。不过我们平时是见不到这种形式的shellcode,因为解多少次码都可以变成解一次码的方式,而黑客们总是喜欢最简便的东西。话说回来,虽然不常见到,对于变形来说,这也是很不错的方法,前提是你的shellcode没有限制长度。

仔细看这个decode部分,除了跳转以外,用到了两个寄存器,ebx和ecx。其中ecx是作为计数器使用,一旦确定了要用loop,那就不能改变,所以真正可以选择的还是ebx。考虑到栈的完整性和指令的长度,一般不建议使用esp和ebp,所以好像只有eax/ebx/edx/esi/edi五个寄存器可以选择使用,而随机的选择出一个寄存器后,就要把原始中所有的ebx全部替换成选出来的那个寄存器才行。

这里的替换不是VB中replace这么简单。我们操作的是最后生成的机器码,寄存器的变换导致字节的变换,不是简单的replace。在原理篇里面说过,总可以有某个公式来对应不同寄存器相同指令下的机器码,在说明这个问题前,先得了解寄存器的顺序问题。

寄存器本身没有高低的级别,然而对应指令的时候,它们有一个潜在的顺序。简单的举个例子,对

inc 而言,inc eax对应指令为0x40,inc ecx对应指令为0x41,inc edx对应指令为0x42……一直到inc edi对应的指令为0x47,排列的寄存器顺序就是eax/ecx/edx/ebx/esp/ebp/esi/edi。倘若给出一个顺序定义如下:

enum Register

{

    EAX = 0, ECX = 1, EDX = 2, EBX = 3,

    ESP = 4, EBP = 5, ESI = 6, EDI = 7

};

很容易得到inc exx的对应指令是0x40 + Register,同样的dec、push、pop等都满足这样简单的规律。

回过头来看那个decode,要做的工作是选择寄存器然后改变代码,自然而然的就要去寻找其中的规律,inc的那个已经说了,剩下pop和xor byte ptr[exx], 0xXX就要去动手找一下。在VC中嵌入汇编然后查看代码后可以清楚地看到,前面说到的五个寄存器,基本上满足的是如下两个公式:

pop exx:

0x58 + Register

xor bytr ptr[exx], 0xXX:

[80] [0x30 + Register] [XX]

XOR是一个三字节的指令,前面0x80固定,最后一个是操作数。在实验的时候你也看到了,对于esp和ebp,这是一个四字节的指令,长度不一样,也是我们要抛弃的一个原因。

准备工作已经就绪,就从一个程序开始,按照原理篇的几个部分来做。第一个程序是test0.cpp,我已经写好了,这个没有什么特别的地方,只是告诉你这是最基础的部分,看看可以熟悉一下解码部分的最基本写法,而且在后面我们也可以把每一步生成的decode部分拿过来测试测试。作为一个基础,我把它命名成了0,也是C程序员的习惯吧~

程序test1.cpp就是变形的开始。我们先人为的把decode部分分成了八份,基本上每个指令就是一份——本来这里有九条指令的,但是xor ecx,ecx和mov cl, 222h其实就是mov ecx, 222h一个指令,不过是我们为了避免0x00出现而耍的花招而已,大体上还是把他们看作一个指令为好。这八个部分,真正与选择寄存器有关的还是2、4和 5,第一步“寄存器的选择”,焦点就集中在这三个部分上。

选择寄存器不用说了,初始化一个随机种子,然后就可以按照获取的随机数来选择一个。根据这个寄存器,按照上面的公式,第二句的pop exx的机器码就应该是0x58 + Register,第四句的XOR中,第二个字节应该是0x30 + Register。同样,第五句的inc exx应该成了0x40 + Register。具体的实现在test1.cpp里面对应了step1函数,函数虽然很短,但程序微长,建议大家还是打开看看。

除了选择寄存器以外,还有一个函数是combine,这是将分散的头部写成一个统一的头部。这个函数还有一个另外的功用,就是对代码进行一些可能的调整,交换指令次序一部分也可以在这里来实现。

不管你相不相信,就这么简单的一段程序(test1.cpp),已经是一个变形的头部了。遗憾的是他的变形能力还非常有限,因为归根结底这段代码里面只用到了一个寄存器,我们选择寄存器的组合方式只有区区的五个。如果有更多的寄存器在decode部分出现,同时我们要选出很多个寄存器备用的话,这样子组合下来的结果就更多,变形的效果更好(相应的会更复杂,所以还是简单的来做例子比较好)。

原理篇里面说到的第二种方法是交换指令的顺序,在这个地方也可以办到,看着两段:

pop ebx

xor ecx, ecx

mov cl, 222h

还是将后面两句看成一个整体。弹出栈顶的值给寄存器或是赋值给ecx,这两步没有绝对的先后次序,也就是说谁在前面并不影响到最终的结果。因而我们可以随意的调换两者的位置(虽然是“随意”,说到底也就两种方式而已,如果很多条指令可以互换次序的话,情况就麻烦了),对应的实现在函数Step2()中,交换一下字符串的内容,这样的话不影响到后面的一系列函数。

最麻烦的还是所谓的插入NOP-like指令。

最容易想到变形方法就是这个,然而却是最难实现的。decode部分不可避免的要有一些相对跳转和相对调用的指令,一旦其中的某一个指令长度发生了变化,几乎要影响到所有相对跳转的地方,因此,要加入NOP-like指令的时候,需要对每一个指令进行考虑。

对于我们上面写的这个decode而言,我们已经人为的将其分成了八个部分,之所以这样做,有一个好处是我们可以在加入NOP-like指令的时候,仅仅是加入每一个部分中去,当作这个部分的一个整体,而不是安插在某两个部分之间,难于理解不说,同时也难于处理。

以插入NOP 0x90为例(其他的NOP-like指令我们已经在原理篇里面讨论过了,不是么~)。如果我们插入到第一句jmp l后面,毫无疑问的,直接影响到的只有自身而已,最后面的一个call de也可以算一个,不过call的地方是一条有意义的指令或者是一个NOP关系不是很大,简便起见,索性就不修改call de指令。这个修改反映在test3的Step3()函数中,在对第一句加入了NOP-like以后,jmp的操作数应该相应的加上增长的字节数,所以 head1[1]就视情况有所修改。

对head2的插入就更为复杂。pop exx后面加入NOP-like以后,除了第一个jmp以外,后面的call也受到了影响,同样的xor ecx,ecx和mov cl加入NOP-like以后也有同样的影响,这两个在前面说过是可以交换的,也容易证明交换后对前后指令影响一致,所以可以一同处理,即:加入Nop- like后前面的jmp要多跳一跳指令,后面call的目标也要向挪一个。

后面的修正,可以挨着挨着的做,都是同样的方法。test3.cpp中还举了一个inc exx后的插入,这里就不具体的解释了,道理和前面差不多,不过插入的不再是NOP,而是《原理篇》里面提到过的指令对——inc eax和dec eax(0x4048)。Nop-like的指令多种多样,在网上也有相关的讨论,有兴趣的话可以去看看cnhonker.com的相关文章。

还有一个misc()函数。这个函数是通过decode部分的本身性质来变形的,例如上面的循环次数,也就是要解码多少个字节,这个数目可大可小,只要能够保证所有编码过的字符都能够被解到即可。像此类的变形不太能说清楚属于什么方面,只能视情况而定,所以放到杂类中了。

到这个时候,差不多一个变形的decode部分已经完成。剩下一件小事情,就是将其作用的shellcode用一个数字来异或,然后将对应的数字填入 decode部分即可,代码我已经有一个简单的实现(见光盘中给出的test4.cpp),具体的细节不再做解释,大家看代码一下子就可以明白。

光盘里的东西到此为止,然而变形的路子并没有就此结束,还有一些值得讨论的,顺带在文章中简单的提提。对这个过于简单例子有所不满的朋友,下面的话是可以进一步做到的,希望您能和我交流一下。

第一是关于起作用的部分,也就是所谓real shellcode部分的编码方式。例子中给出的是很普通的单字节异或,据我的实验来看,似乎四个字节一组或者是四个字节一组的异或效果比较好,不过限于篇幅的关系,没有给出这样的代码。四个字节一组的主要思想就是平衡解码部分的生成难度和变形能力,对于32位机而言,简单处理情况下四字节(DWORD)刚好是一次性处理的极限;七字节主要考虑的是变形的能力,这种情况下显然不能一次性异或七个字节,而可能要4-2-1或者2-2-2-1或者其他分次异或的方法,对应的指令集比较分散,只是解码部分稍微麻烦了一点。当然,其他的编码方式也可以,只不过写起来可能还要复杂一些。

第二是解码部分的编写。这里给出的编写方法显然太过于复杂,好的办法是在你编写的上面套一层像编译器一样的东西,这样需要做的不过就是不断地加指令,相对位置的调整还有机器码的生成都可以让程序自己完成。我写了个简单的,有兴趣的话可以交换一下,省是省力些,不过不太好用就是。

第三是有关解码头部本身的。这个头部,在前面说过了,可以反复的加,反复的用,没有关系的,代码实现起来也很方便。变形病毒的话,这个头部是集成在了 real shellcode里面,负责在传播的时候生成新的解码部分,这里我们编写变形的shellcode不需要这么麻烦(抛弃型的),就单独把头部的生成提取出来做成了程序,这也就是前面说的“不用包含自己让自己变形的部分”。

生成变形的shellcode不是一件很难的事情,只要你能写出一种编码的方法,然后写出解码的头部就可以了,然而麻烦的是如何在长度(复杂度)与变形的能力之间寻求到一个平衡点。写病毒的话,考虑的可能不是这么多,因为只要能找到足够的空间可以隐藏,变形能力越强,对杀毒软件的考验越大。写 shellcode则不然,通常exploit需要的shellcode不能太长,而且ids/ips借以判定的字串往往还不是shellcode,感觉上只要能写出一个让杀毒软件不认识的shellcode就可以了,从这一点上看,变形shellcode的唯一好处是每次能够给你一个基本上全新的 shellcode,只要你不公开你的算法,杀毒软件厂商没有哪个精力(也许是能力)来分析你的东西

上一篇: shellcode初识