Shellcode学习之编写变形的shellcode[原理篇]

X'Con04据说轰轰烈烈的结束了,马上安焦就毫无保留地把所有的演讲用幻灯都放了出来。作为菜鸟,我也好事地下载了一份来看,首先看到的是Plan9 大虾的《高级shellcode设计技巧》。这篇文章似乎总结了一下当前Shellcode的各种技巧和设计思路,比较有意思的是当中提到的 ADMMutate,虽然在2001年就有见过这个工具,但是似乎一直都在*nix下而且这么多年来没见过升级。闲下来没有事情的时候,我就想做上这么一个东西来打发时间,面向的平台是Win32——至少有一点要与ADMMutate不一样吧?
切入正题之前先科普并八卦一下。摆弄shellcode似乎是蛮好玩的事情,Phrack从老早老早就几乎每一期都有相关的文章,从全字母数字的,到 390的,然后到符合Unicode或UTF8的shellcode都有。这些文章大多都给出的是一些路子,甚至是一些可用的指令集而已,像我们这种菜鸟看了不免一头雾水。不过深入一点就可以发现,需要解决的问题就是如何利用有限的指令,来“修补”出所有可能的指令来——正如这个系列前面所说的,关键的是解码的部分,因为我们可以想出各种各样的算法来对起作用的shellcode来编码,但是解码的工作却会有各种各样的限制(特殊字符啊之类的),这些文章也就是解决了解码部分的问题。从这个意义上来说,这些技术不单单可以应用在shellcode上面,它很可能最早来自病毒的技术,而且可以同样的适用于对文件进行加密等应用上。
ADMMutate是将shellcode变形的工具。变形的概念似乎最早出自于病毒方面(具体何时我没法考证,因为估计变形病毒出现的时候我还在流着鼻涕摆弄Apple呢),大概的意思就是说生成出来的shellcode总是有不同的形式。这看起来似乎有点不可思议,但是确实是可行的,举个简单的例子来说,比如我们要问一个在北京的四川人昨天晚上干什么去了,北京人可以说“你丫挺的昨晚上那晃悠去了?”,四川人可以说“龟儿子昨晚黑抓子去老?”,都是可以让对方了解的——这就是同样意思的不同表达方式;或者北京人可以说“昨晚你丫挺的上那晃悠去了?”,四川人说“昨晚黑龟儿子抓子去老?”,对方也可以理解——这是在前面的基础上,调换了一下表达的顺序,这种调换不影响意思的表达;再或者北京人可以说“你丫挺的昨晚好事不做上那晃悠去了?”,四川人说“昨晚黑你个龟儿子的不落教抓子去老哦?”,添加了很多没有用的东西,这样还是可以清晰地表达。这里说的,对于同样的意思,不同方言的表达,不同语句顺序的表达,插入无关紧要的字句,就是变形的基本方式。换个角度,我们可以认为是表达的意思不变的情况下,对其中的因素进行变换次序或者插入无关因素,就是一种变形。
那,我们就一个一个的来看基本的变形方法。先是基本的概念,然后才是实战嘛。

“你丫挺的” VS “龟儿子” —— 选择不同的寄存器

同样的意思,可以有多种不同的表达方式,最简单的一种就是用不同的方言来替换。同样的,对于计算机能够理解的语言,要实现同样方式的不同表达,最简单的就是选用不同的寄存器。
早期的计算机上,寄存器的使用似乎不像现在这样随便,这从寄存器的名字就可以看出来,eax是累加(accumulate)寄存器,ecx是计数(counter)寄存器,不过后来这种限制就不多了,对内存的操作几乎所有的寄存器都可以。在这样的情况下,几乎所有的寄存器都可以被我们选用来做为操作所需的那个寄存器,简单的举个例子,我们要通过寄存器放一个数入栈,可以:

mov eax, xxxxxxxx
push eax

也可以:

mov ecx, xxxxxxxx
push ecx

两个是等价的。
那么,就出来可能的第一个问题,这样子变换最终生成的机器码长度是否是一样的呢?为了编写变形shellcode的方便,我们当然希望简单的对寄存器的选择变换,最后生成的机器码长度一样,因为这样我们比较好定位,然后通过直接对机器码的变换来实现每一次不同的变形。事实上很多指令长度是固定的,比如 push一个寄存器,不管你push的是什么(正常的寄存器,FS之类的不算),都是一个字节——甚至,我们可以一个小公式来算出来:

pushcode = '"x50' + register;

其中,每一个寄存器的对应值是:

EAX = 0, ECX = 1, EDX = 2, EBX = 3, ESP = 4, EBP = 5, ESI = 6, EDI = 7

直接带入register就可以算出来。即倘若是eax,那么对应该是'"x50' + 0,push eax的指令就是0x50。
这样的公式有什么用呢?如果我们写了一段汇编的代码,里面某些指令全部用的eax操作的,而且这些指令的位置我们清楚地知道,我们就能通过这样一类公式来直接计算,同样的操作替换成其他寄存器(比如ecx)的时候相对应的机器码应该是什么,然后用新计算出来的替换掉老的,这不就是一个变形了么?
这一步可以完全由计算机来做,而不用人手工替换,需要的代价是我们写出一个类似于汇编编译器的部分,这是可以实现的。上面的公式也并不是拼凑出来的,而是汇编在编译的时候遵从的一些约定,限于篇幅,这一类的公式就不一一给出了,如果有兴趣的话,可以查看汇编手册,自己稍微花点时间来列举一下。
感性化一点的解释可以是这样,我们用四川话、北京(普通)话和英语来表示不同的寄存器操作,假设有一个完成某项任务的指令表示为:

小样 tonight 在哪儿 eat 白食? [普通话 + 英语]

然后按照公式(如果有的话,嘿嘿)计算出普通话对应的四川话和英语对应的普通话,然后替换掉,就成了:

龟儿 今晚 到哪点 吃 趴活?

替换后还是一个意思,都是问对方晚上那里蹭饭去了。但是,没有两个字重复是不是,这就是替换寄存器的变形。
上面说的最后替换出来的长度都一样,如果变换寄存器后指令长度不一样(比如xor xxx, xxx),那就很麻烦,我们留在后面的“插入无关语句”部分去说。
还有需要提醒的是,并不是随便什么寄存器都可以拿来随便用,如果你有loop一类的指令,那就要避免改变ecx的值,如果在栈上有操作,就不要把寄存器替换成esp或者ebp。类似需要注意到的地方还有很多,在你写的时候,注意一下就可以,不是什么很重要的问题,所以就不详细的写了。

“昨晚你丫” VS “你丫昨晚” ——调整块的顺序

我们说话的时候,如果前后没有什么很大的联系的话,语序的调换也不会影响表达。像两个人见面的时候要问“在哪里发财啊?老婆孩子好啊?火车转弯灯要不要?”,这三句话前后没有必然的逻辑关系,所以按句子进行整体的调换也没有什么问题,先问对方要不要火车转弯等也行。
同样的,如果shellcode中也像这样有着明显的功能模块,调换其前后顺序不会产生影响,这是最大粒度上的通过调换顺序的变形。
粒度更小的,可以是调换若干单一指令之间的顺序,当然前提条件是不能改变表达的意思,例如有一句话是“一边打电话一边打架”,换成“一边打架一边打电话”也无妨,前提条件就是互相不影响,结果不会给别人造成困扰就行。
还有一种中间粒度的调换,这个例子不好举,只好用比较形式化的方式来描述一下:比如有两个操作序列A和B,分别完成两个功能且互相不干扰(包括寄存器), A的序列是A1/A2/A3/A4……,B的序列是B1/B2/B3/B4……,然后我们像洗牌一样把这两个序列洗一下,得出来的结果还是没有问题的,像序列A1/A2/B1/A3/B2/B3/B4/A4或者是序列A1/B1/B2/B3/B4/A2/A3/A4都没有关系,只要保证抽取出来的所有关于 A的序列和所有关于B的序列都正常即可。
一个不太恰当的例子是用两种语言来说两件事情,普通话对应上面的A,英语对应上面的B。普通话是“火车的 转弯灯 要不要?”,英语是“I want home”,那么这样说是可以的:

火车的 I 转弯灯 要不要 want home

这样也可以:

I want 火车的 转弯灯 home 要不要

因为他们分别表达了不同的意思,而且互相之间不干扰理解。但是如果相互之间有干扰的话,这种方法就不在适用,比如“火车的 转弯灯 要不要”和“你装不装 宽带网”,打乱顺序组合一下就有可能是“火车的 你 装不装 转弯灯 宽带网 要不要”,似乎成了问别人装不装转弯灯,意思完全变了。
总之,调换块的顺序就是在不影响表达的情况下调换可能的顺序。通常上面三种粒度上的调换是综合使用的,第一个最简单,实现其来也最方便,最后一个比较麻烦,而且适用的范围似乎不是很广。

“你” vs “你!@#$#!@#$”——插入无关的字句

插入无关的字句作为变形是最容易理解的,这种方法的基本出发点就是只要不是喧宾夺主的废话,基本上都可以被省略掉。从这个意义上来说,我写的这篇文章也在大量的进行变形,尤其以插入哈哈、嘻嘻之类没有意义的助词或者是拟声词为最,可是在你读起来,最多嘴角向上一翘,并没有影响你了解文章的内容,这就是成功的变形。
插入的字句一般被称之为nop-like,从字面上理解就是插入一些类似于NOP的指令。这种指令包括很多,最常见的就是0x90即NOP,这个指令还有另外一个解释(也许是原本的解释),那就是xchg eax, eax——这就是一个纯粹的没用的指令,效果类似于文章中的“呵呵”。同样的,xchg exx, exx基本上都是,不过除了交换两个eax,交换其他两个相同的寄存器最后生成出来指令长度都是两个字节,所以大家不爱用。
除了这种纯粹没用的指令以外,还可以选择一些对偶指令,让其执行后没有任何影响。比如说,inc edx + dec edx一类的,开始对edx进行增一操作,然后马上又进行edx的减一操作,最后对任何东西几乎都没有影响(对标志位还是可能有影响),这有点类似于一个人说话,开始说了一句“我天下无敌了”然后接上一句“那是不可能的”,别人听了以后觉得没有什么问题,你的正常表述几乎不受任何影响。
还有一类的无关语句是在其他寄存器上的操作。比如我整个要变形的部分只用到了eax和ecx,那么中间无论什么地方插入对ebx的操作,都没有问题。这个有点像说话的时候,你一直用普通话来表达你所要说的,这时候突然你走火说了两句英文,好像也没有多大的关系,至少别人理解你要表达的东西上没有受到影响。
插入无关的字句理论上最简单,但在实践过程中可能存在一个巨大的问题,那就是一些相对跳转或者是相对调用的指令会受到影响。举个最简单的例子而言:

a: xor dword ptr [eax], ebx
loop a

a: xor dword ptr [eax], ebx
nop
loop a

两个完全一样功能的代码,后面一个是前面一个的变形。但是单单的插入一个nop到前面一个去肯定会出错,因为前面一个和后面一个loop a两个编译出来指令码相差1,这是不得不考虑的!前面说所的,替换寄存器后指令长度是否一样也是一个重要问题,就指的这些方面——我们不得不精心的去调整我们需要变形的部分,而这种调整最主要的应该集中到相对跳转类指令上去,避免它们出问题。

其他,相同语义的不同表达

写完一本书,可以叫杀青,也可以直接说写完了准备付梓,这是同一种语义的不同表达方式。一般意义上而言,所有绑定cmd的shellcode都可以看作是同样语义(目的)的不同实现,当然可以算作一种变形。只不过这种变形似乎代价稍微大了一点,而且不容易自动化生成。
小范围而言,这种相同语义的不同表达是可以的,前面说的替换寄存器就是简单的实现。另外的,把jmp替换成jno和jo或者是诸如此类的东西,也是一个不错的变形方案,这种变形可能需要一个很大的库来支持,而且相同语义的选择需要人为的预先定义。一旦有了一个庞大的库来支持,剩下的事情就是选择其中的某些内容来进行组合,工作量就小多了,而且效果是很好的。因为实现起来比较困难,所以这个使用不是太普遍,简单提一下就好。

基础性的东西介绍完毕以后,下面就可以开始实战操练了

上次花了很多笔墨来说明一个变形的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,只要你不公开你的算法,杀毒软件厂商没有哪个精力(也许是能力)来分析你的东西
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章