定制特殊的shellcode


现在很多的溢出,都对shellcode有一些特殊的要求,比如不能出现某些特殊字符,或者要求长度小于多少多少等等。刚学会了写溢出,遇到这些问题还是很头痛的,想在外面去找现成的,好像一时半会儿不容易找到,只好自己写或者把外面公布的拿来修改了。
关于有长度限制的shellcode,解决起来相对来说比较容易一些。一个方法是针对具体的漏洞来写比较小的shellcode,外面有很多的模版,看懂 了可以根据自己的需要来定制。还有一个方法是绕开长度的限制,例如只做一个简单的搜索shellcode的代码,真正的shellcode放到其他地方 等。作为例子,很容易想到的是Serv-U中mdtm时区溢出问题,时区限定了长度,但其实几乎所有的exploit都选择把shellcode放到同一 个请求的文件名中去。
如果是有字符上的限制就比较麻烦,前面几期《菜鸟版Exploit编写指南》讲到的CMail的一个漏洞,要求shellcode中不能含有大写字母,这 时候shellcode如何去选取就成了一个困难。遇到不能有特殊字符的,有个解决方法是写一个不包含有这些特殊字符的搜索代码,把真正的 shellcode放到其他地方,或者是就放在后面,用搜索代码去寻找内存中原始的串。针对具体的某一个漏洞来说,不包含某些字符的shellcode可 以很容易地写出来,但是通用性不见得很好,于是我们很自然的就像到,可不可以写出一个修改shellcode的模版,可以在比较方便的情况下快速获得或者 是动态生成满足要求的shellcode。这篇文章就试图定义的一个方法,希望能够在比较小的改动下就能够做出比较通用的shellcode出来。
在切入正题之前,有一些假设需要说明,首先是shellcode对字符串的长度没有限制。倘若是对长度和字符都有严格的限制,我想讨论范围已经远远超过了 我的能力,满足要求的代码能不能写出来已经成为问题,更不用说做到通用了。另外一点假设是这个shellcode不针对覆盖SEH或者栈上ret地址,因 为根据具体的情况,也许还有更好的方法。为了方便起见,下面提到的shellcode,都指原始的只经过XOR处理的shellcode,encoded code指的是按照我们的方法编码后的shellcode,而decode code指的是运行时候解码并生成可执行的shellcode的代码。

我们知道,外面发布的shellcode,都是由两部分组成的,前面一段是decode code,后面一段是编码后的shellcode。第一段的存在,因为第二段(也就是具有真正意义上绑定、反弹或者其他功能的shellcode)如果不 经过编码,通常会含有很多ASCII码为0的字符,而溢出时候shellcode通常通过一个字符串的形式传送过去,为0的字符会被认为是字符串的结束标 记而导致后面字符全部被截断,所以,出现第一段也是一个原始的防止特殊字符出现的方法。流行的情况是,第二段也就是可以运行的shellcode与 0x99异或一下,第一段作为一个解码器,在执行时候动态定位第二段,然后将第二段编码与0x99异或解码后跳转运行真正的shellcode。
我们还是按照encoded code和decode code分开的思路想下去,单独用异或的话,只能保证仅有的几个字符能够避免,如果换一个方法,比如把encoded code看成一个很长的数字,并按照减法拆开,由于组合的存在,也许可以避免更多的字符出现。这里是这种方法的描述:

假设原始的shellcode中任意一个字符是o,我们把他拆成(固定的)几个数字的和,比如x、y、z,也就是要求满足

o = x + y + z

理 论上说,满足条件的x、y、z的组合有很多种,假如我们用xyz的方法来存储原始的shellcode,就可以避免几乎2/3的ASCII字符。而且这样 子来存储shellcode的话,除开decode code部分,可以有多种变形,可能对于防止一些杀毒软件和防火墙有着一定的效果。
进一步的,如果对shellcode的长度没有要求,我们甚至可以几乎无限的扩展拆分原始shellcode的方法,但是具体操作中为了decode code的可行性,可能还是采取一些比较简单的拆分方法为好。因为完整的shellcode实际上应该包括decode code,而一旦在字符上有所规定,首先decode code必须满足这个要求。而一个方法再好,如果某一部分不能实现,那也是没有用的。
我们现在这里完成一个实现,后面再说说其他的方法。
还是按照上面的假定,我们先写一个简单的C++代码,把shellcode按照这种方式拆分开来。具体地说,将shellcode取出来后,穷举一下所有的可能情况,如果一个拆分可以满足不包含任何指定的特殊字符,就认为这种拆分是可能的。看代码,很短的。

    unsigned char p, q;
    unsigned int a;
    for(i=0; i<sizeof(sc)-1;i++)
    {
        for(int j=1; j<127; j++)
        {
            p = (char)j;
            a = sc[i];
            if(a<j)
                printf(""na=%d j=%d i=%d"n", a, j, i);
            q = (char)(a-j);
            for(int k=0;k<sizeof(badchar);k++)
                if(q==badchar[k] || p==badchar[k])
                 goto l;
            printf("""x%0.2x""x%0.2x", p, q);
            break;
            l:;
        }
        if(j==128)
            printf("failed!");
    }
依次来解释一下。
首先p和q是两个拆分,a作为一个临时变量来临是获取shellcode中的每一个字符。外面一重循环是对原始的shellcode进行循环,每一次取一个字节,到内循环去。
里面一个循环稍微麻烦一点。首先解释一下为什么要从1循环到127,因为每一个单一的字节ASCII码是从0到255的,拆分字符的话,没有必要从1循环 到255,只需要到一半的时候即127的时候,所有的可能组合都已经出来了。接下来,先确定p的值,然后确定q的值。考虑p和q的值的时候,判定 a<j是出于暂时不考虑进位情况(如果考虑进位的情况,相邻的字符必须要同时处理,这样子程序将会异常复杂,对解码部分也有很大的影响)。这个内部 的循环基本上是对于每一个原始shellcode的字符,穷举所有的可能组合情况,然后在最里面的循环中,依次比较拆分后的情况是否含有特殊字符(这个比 较使用sizeof(badchar)作为循环的结束,隐含的包括了对结束字符'"x00'的比较),成功了,就进行下一个字符的拆分。
最后一个if(j==128)的判定,是基于对失败情况的考虑,可以看到,如果内层循环是正常退出的话,说明所有的可能性都穷举完了,但是依然不能满足要求,这种时候我们视为失败,也许要重新选择另外的编码方式。
再做一点点改进。这个改进完全是为了后面书写decode code这个头部需要,因为现在的存储方式是xyxyxyxyxyxy.....每相邻的两个组成原始的shellcode,但是在32位环境下,似乎每 四个字节来处理比较方便,我们可以将存储的方式改为每四个字节来存储,即xxxxyyyyxxxxyyyy....的方式,这样子将原来的输出方式改一 下,每得到四个字符再输出,改进如下:

unsigned char re[8];
... ...
re[i%4]=p, re[i%4+4]=q; //can be exchanged
if(i%4==3)
printf("""x%0.2x""x%0.2x""x%0.2x""x%0.2x""x%0.2x""x%0.2x""x%0.2x""x%0.2x",
re[0], re[1], re[2], re[3], re[4], re[5], re[6], re[7]);

用上面的来替代原来代码中的输出那一行。
到这一步,基本上可以有效的解决编码后shellcode中包含特殊字符的问题。如果还想进一步的改进,也是可以的。比如内层j的循环,我们是从1开始, 如果这里同样是遍例1~127的情况,但却换的是乱序形式,那就是一个对shellcode的很好的变形方式。在算法中,这是很简单的一个模拟洗牌算法, 这里只是提一下,就不加赘述了,为了shellcode的完美,你也可以加上这一段功能。
下面来说说看decode code这个头该如何表示。
decode code就没有办法通过自动化的手段来生成,一般都是手工来写,不过还好工作量不是很大。下面的实现实例,是比较好的一个,几乎可以避开常见的特殊字符, 如果仍然不满足要求,可以按照后面写的一些提示来进行简单修改,如果还是不行,就要从头开始重新构造另外的算法了。
decode code满足的功能很简单:得到encoded shellcode的头,依次做“取四个字节,取四个字节,相加,放入内存”的工作,用代码来描述,就是如下(在VC中嵌入汇编,很简单方便,也很短小):

_asm
{
    jmp short getaddr
dstart:
    pop ebx
    xor ecx, ecx
    mov cx, 0x112        ;ShellcodeLength / 2
    xor esi, esi
    xor edi, edi
decode:
    mov eax, [ebx+esi]    ;取四个字节
    add esi, 4
    add eax, [ebx+esi]    ;取下面四个字节相加
    mov [ebx+ edi], eax    ;放入内存
    add esi, 4
    add edi, 4
    loop decode
    jmp short scstart
getaddr:
    call dstart
scstart:
    nop            ;并不真正需要这个nop,跟上前面说的encode shellcode就可以了
}

有shellcode编写经验的朋友应该一下子就能看出来,这是很简单的一个decode。jmp short getaddr一直到pop ebx这一段,就是为了动态获得执行时当前的内存地址,注意这里没有dec ebx,原因在于后面我们并没有用ecx同时作为循环的次数控制和地址偏移(那样子很困难)。对cx的赋值,因为我们每四个字节来取,而且对原始的 shellcode编码后变长了两倍,所以只要是原始shellcode的一半长度就可。decode部分,采用的是上面所说的算法,最后当循环结束,也 就是解码结束以后,一个小的跳转到真正的shellcode上面去。
这个短小的解码部分,很多地方是可以微调的,也就是说为了避免特殊字符的出现,可以有很多简单的修改方法。比如第一句的short jmp,机器码中的EB是没有办法的,但是可以增加getaddr前面的指令,加上一些NOP或者类似的指令(附带说一句,这个类似指令在 cnhonker上面有一个非常详细的表),来改变跳转的长度。这个调整同时会影响到call dstart和jmp short scstart的指令码,有可能要选择合适的来同时满足三个地方的需求。再比如,所有的寄存器是可以替换的,像所有的ebx都换成edx的话,不改变本身 的含义,但是改变了一些字符。还有,add esi, 4之类,可以有很多等价的实现方法,比如连续或者是交错的四个inc esi或者add esi, 1或者dec esi, -1等等。
好像在实际情况中,我都是简单的调整一下代码就可以满足要求(做好了后看是否满足要求的方法,简单的可以在VC的调试模式下看反汇编,就是F10然后Ctrl+F11)。如果上面的微调都无没有办法满足要求的话,那就比较麻烦了,可能需要选择另外的算法。
这里再说说另外一种可行的算法。如果对字符的要求相对来说不是非常严格,可以在最原始的shellcode上做很有限的修改,就是修改异或的值。
最古老的方式是按照没一字节都与同一个字符(最常见的0x98和0x99),但是如果按照一定的顺序循环的与多个字符(例如四个,0x12345678) 异或,出现特殊字符的可能性就会大大减小。和前面一个算法一样,这个是可以动态生成不同的shellcode的。比如我们设定异或的字符为四个字节,那么 我们选择原始shellcode中序列数模四为零的所有字符,与ASCII码从1到255的字符X异或,如果出现了所有的字符与X异或以后都没有出现特殊 字符,那么我们就确定这个X为可行字符。同样的,我们按照模四的标准等价划分原始的shellcode,依次找出所有的可行字符,倘若都能找到,那么这个 方法就是可行的,剩下的就是按照对于字符的限制写出相关的decode code了。
和前一个算法一样,考虑到解码的难度,并不是异或字符数越多越好,一般倾向于四个或者八个,再多的化,编写的难度就几乎同编写一个新的shellcode 难度相当。限于篇幅,这里只是说明一下shellcode的编码方法,没有给出具体的代码,相信看了前面的,你是能够很容易编写出来的。
作为《菜鸟版Exploit编写指南》的姊妹篇,这篇文章想独立的讲一下针对需求的shellcode编写,希望对读者您有所帮助。前面一种实现方法的代码可以在光盘中找到,如果您有更好的方法,希望也不吝赐教。

posted on 2007-08-24 16:58  dhb133  阅读(735)  评论(0编辑  收藏  举报

导航