Windows堆栈溢出全面解析

时间:2022-07-01 12:47:59
 关于堆栈溢出,前面写的多期文章都是关于具体漏洞分析和ShellCode的编写技术,而有朋友希望我能写点入门级的文章。今天,我就和大家一起,来全面解析Windows下堆栈溢出的具体细节及利用过程。过程虽然简单,但自己又走了一遍,才发现里面的确有一些值得注意的地方。废话少说,我们直接切入正题!
基础知识
首先简单讲两个基础知识,一是函数调用时堆栈的变化;二是函数调用约定对函数调用及返回时堆栈变化的影响。
我们用高级语言编写程序的时候,函数调用是很常见的事情,但是我们却很少去观察函数调用时汇编究竟是如何实现的。其实,函数调用实现的过程很简单。父函数将函数的实参按照从右至左顺序压入堆栈;接下来,系统将父函数中函数调用指令call xxxx的下一条指令地址EIP压入堆栈;然后,父函数通过push ebp指令将基址指针EBP压入堆栈,并通过mov ebp, esp将当前堆栈指针ESP赋予EBP;最后就是通过sub esp, m(m 是字节数)为存放函数中局部变量开辟的内存。函数在执行的时候如果需要访问实参或者局部变量,都是通过EBP指针来完成的。
在Windows系统下,我们常见的函数调用约定有两种:C/C++的函数调用方式_cdecl,以及__stdcall调用方式。在VC、.net等环境下,我们编写命令行程序时的main或者_tmain函数,以及我们自己定义的很多函数,在不声明调用约定的情况下默认都是__cdecl的方式。我们在通过MFC编写图形化程序的时候,其主函数的声明为:extern "C" int WINAPI _tWinMain(参数),该函数的调用约定就是_stdcall,WINAPI、PASCAL等名称就是__stdcall的宏定义,都一个意思;此外,我们调用的API函数,绝大多数也是__stdcall的调用方式。
对于_cdecl调用方式的函数,其父函数在调用该函数的时候,会先将它的实参按照从右至左顺序压入堆栈;函数返回之后,父函数通过sub esp, n(n = 函数实参个数 * 4)指令来负责恢复堆栈。对于__stdcall调用约定函数,函数调用时实参入栈顺序也是从右至左,但堆栈恢复是该函数返回时自己通过ret n(n = 函数实参个数 * 4)指令来完成的。
如果以前大家没有注意这些基础知识,可能理解起来不是很清晰。不过没关系,下面我们再结合具体例子仔细讲讲,之所以要在这里提出来,是因为这些知识对我们溢出漏洞的利用有比较明显的影响。
实例解析
大家看看下面这个具有溢出漏洞的示例代码1,我机器用的是Windows 2000 sp4 + VC6.0:
#include "windows.h"
#include "stdio.h"
#include "string.h"
int fun(char *szIn, int nTest)
{
char szBuf[8];
printf("%d/n", nTest);
strcpy(szBuf, szIn);
return 0;
}
int main()
{
char sz_In[] = "1234567890abcdefghijklmn";
fun(sz_In, 888);
return 0;
}
编译、执行,弹出异常对话框:

显然是发生了堆栈溢出,这是从直观的角度进行了解。大家花1分钟时间看下程序代码,然后我们来仔细看看这个溢出的发生细节。
好了,我们继续。在debug模式下, 按F10,进入单步调试状态,再按下Alt+8,进入汇编状态:

高级语言第15行代码char sz_In[] = "1234567890abcdefghijklmn"前面是main函数的启动代码,其实大家从这里就可以看到在调用主函数时堆栈的变化。一直按F10,直到第16行高级语言代码fun(sz_In, 888)停下来,这里是我们开始重点看的地方:

大家现在可以看到,main函数(前面讲的父函数)通过call @ILT+0(fun) (00401005)指令调用fun函数,在该指令之前还有两个push 指令操作。不难看出,这两条push 指令就是将fun函数的两个实参按照从右往左的顺序压入堆栈。继续按F10,直到call指令停下,用F11跟到fun函数里面去,跳到了00401005处,该处指令是一个跳转指令jmp fun (00401020),00401020就是函数fun开始的地方:

进入fun函数的第一条指令就是push ebp,即保存基址指针EBP。按F10执行完这条指令之前,我们观察当前堆栈顶部4字节是0x004010B6,也就是我们刚才调用fun函数的call指令下一条指令add esp, 8的地址,即保存了返回地址EIP。现在继续往单步执行,mov ebp, esp 使当前EBP指针指向父函数EBP指针保存的位置,并用于函数执行过程中实参和局部变量的访问。sub esp, 48h指令是为fun函数的局部变量预分配堆栈空间,后面的szBuf就位于这片预分配空间里。继续单步执行完00401051的call strcpy (004010e0)指令后停下,这里说一句,strcpy函数的调用约定是__cdecl, 因此调用完成后,fun函数作为其父函数要通过add esp, 8来恢复堆栈。好了,数据覆盖也完成了,这时候我们看看EBP+4指向的堆栈地址,即函数返回地址EIP的值为0x66656463,就是我们覆盖的数据:

函数返回之后将跳转到该地址来执行指令,而该地址上的指令无效,因此会发生前面图1中的读错误。到这一步,我们既验证了前面的基础知识,有了解了堆栈溢出的过程,虽然简单,但是却具有代表性。
下面我们来谈谈堆栈溢出漏洞的利用。堆栈溢出漏洞的利用方式有两种:JMP ESP和覆盖SEH结构,其实这两种方法我在前面的文章都提到很多,大家完全可以参考。这里我既然讲基础的东西,那我们就讲JMP ESP。前面的示例1中,如果我们把覆盖EIP指针的四个字节改为JMP ESP指令地址,再在后面填充我们的ShellCode,那么函数返回之后就是执行JMP ESP指令,继而跳转到我们的ShellCode中来执行了。为了帮大家验证这个说法,我准备了示例代码2,其中ShellCode的功能是创建用户X,而跳转地址是通用JMP ESP指令地址0x7ffa4512大家可以试试。

至于ShellCode的编写技术,也有太多现成的东西,我这里也不多讲了。到这里,我们的内容似乎就该告一段落了,但细心的朋友肯定要问了,这些和前面提到的函数调用约定有什么关系吗?当然有!大家不要忘了,前面的示例中函数是用的__cdecl调用约定。返回图3中,当函数fun返回之后,main函数也是通过add esp, 8来恢复堆栈。如果将fun函数改为__stdcall的调用约定,在添加ShellCode的时候大家就得注意了。因为fun函数在返回的时候,是自己通过ret 8指令来恢复堆栈的。当CPU跳转到JMP ESP指令时,此时的ESP已经是在原来EBP + 16的位置,而不是EBP + 8,那么覆盖数据中ShellCode的位置,也应该顺理成章地往后走8字节,中间多出来的8字节就用90H填充吧。针对这类情况,我准备了示例代码3