<>[超详细]通过实例讲解栈溢出漏洞
文章目录
* [超详细]通过实例讲解栈溢出漏洞
<https://blog.csdn.net/Breeze_CAT/article/details/89788864#_0>
* 代码简介 <https://blog.csdn.net/Breeze_CAT/article/details/89788864#_12>
* 分析程序整体执行流程 <https://blog.csdn.net/Breeze_CAT/article/details/89788864#_55>
* 程序执行细节及栈空间变化
<https://blog.csdn.net/Breeze_CAT/article/details/89788864#_105>
* 栈溢出 <https://blog.csdn.net/Breeze_CAT/article/details/89788864#_192>
* 通过栈溢出控制程序执行结果
<https://blog.csdn.net/Breeze_CAT/article/details/89788864#_216>
* 通过栈溢出插入代码 <https://blog.csdn.net/Breeze_CAT/article/details/89788864#_304>
本篇文章通过《0day安全:软件漏洞分析技术》书中第二章中所用到的一个程序的栈溢出漏洞的复现,以及使用OD一步步调试来学习栈溢出完整的原理。程序虽然简单,但在一些基础薄弱的人眼中还是无法理解,所以导致很多新手到了这里就被“劝退”。
所以在这篇博客中我会差不多一句一句的解释汇编代码的意思,一步一步的看栈的变化,即使遇到一些“常识”我也会进行介绍,来帮助理解栈溢出漏洞
使用软件:
* vc++6.0 (用来编译程序)
* OllyDbg
* 十六进制编辑软件
<>代码简介
#include<stdio.h> #include<string.h> #include<stdlib.h> #define PASSWORD
"1234567"//写入静态密码 int verify_password(char *password)//确认密码是否输入正确 { int
authenticated; char buffer[8]; authenticated=strcmp(password,PASSWORD); strcpy(
buffer,password); //存在栈溢出的函数 return authenticated; } void main() { int
valid_flag=0; char password[1024]; scanf("%s",password); //输入密码 valid_flag=
verify_password(password); if(valid_flag) //返回0代表正确,返回1代表错误 { printf("incorrect
password!\n"); } else { printf("success\n"); } getchar();//暂停一下 }
运行结果如下:
好,接下来开始演示栈溢出的原理。
<>分析程序整体执行流程
首先使用Debug编译器(最好不要使用release编译器),Debug和Release编译后的程序的区别就是,在调用一些系统函数的时候,Debug会保留调用点,而转去系统函数所在地继续执行;而Release会直接将系统函数粘贴过来,不用实现调用便可完成。使用Debug能更直观看出函数执行到了什么位置。使用OD打开编译好的.exe文件。
典型的VC编译的程序的样子,但程序的入口点并不是main函数的入口点,接下来我们要找到main函数,有两种方法:
*
单步跟踪(F8),在遇到call调用的时候使用F7跳转到跟踪函数内部,直到进入(我们可以判断出是)main函数的地方,如:
*
根据“常识”,在GetCommandLineA后不远处就是main入口,在main之前会有(三个)连续的压栈操作,如:
找到main函数之后,F7跟进main函数里,继续分析:
如图所示,根据左侧ASCII码的提示,和代码中调用系统函数的名字,我们很轻易的找到了scanf函数的位置,按从右至左的顺序将参数(password就是[local.257]和“%s”)入栈之后调用scanf。中间的一段初始化代码为初始化申请的空间为0xcc,其中valid_flag(即[local.1],后面会说)要初始化为0。
根据接下来的ASCII码,可以判断在中间的调用语句call a.00401005就是verify_password函数的位置。
之后F7继续跟进verify_password函数内部继续分析:
进入函数,首先看见的就是常规操作,将前栈基址入栈,抬高栈顶(给局部变量申请空间),之后可以看到在调用strcmp函数之前将他的两个参数从右至左入栈,静态值“1234567”是写在程序的数据段中的,另一个是参数password,在第一个(只有一个)参数即[arg.1]位置。strcmp执行结果在eax寄存器中,然后将结果赋值给变量authenticated,即第一个变量,就是[local.1]位置(图里不小心画到strcpy参数入栈里了)。然后进行strcpy函数的参数入栈,从右至左是password和buffer,buffer就是第二个参数(因为长度是8,所以[local.3]),将他们入栈之后调用strcpy,将password内容拷贝到buffer里。然后(进行清理栈空间操作后)返回。
这里介绍一下汇编语言中的几个概念:
函数的调用:
调用函数之前先将参数依次入栈,然后调用,调用结束之后将入栈的参数出栈(清理栈空间),根据不同的调用约定,入栈的顺序不同。C语言默认的调用方式是__cdecl,参数从右到左入栈。更多关于调用约定可以百度。
在函数刚被调用的时候通常会进行这样的操作:
push ebp //将上一个函数的栈基址(栈底)入栈 move ebp,esp //将目前的栈顶作为新函数栈的栈底 sub esp,0xxx
//将栈顶抬高xx,目的是为这个函数中声明的局部变量申请空间
然后会push几个寄存器就是保存状态,在函数return之前会pop恢复的。
局部变量和参数的表示:根据局部变量在函数中声明的顺序,用[local.数字]来表示,[local.1]表示[EBP-4],声明的局部变量会放在之前抬高栈顶得到的空间中,所以这里[EBP
-数字]只需根据局部变量的出现顺序和长度
就可以推断出来(也就是说[local.2]不一定是第2个局部变量)。可[local.1]类似的是[arg.1]代表第一个参数也就是[EBP
+8],调用函数之前会将函数的参数依次入栈(默认从右到左入栈),而由于[EBP+4]是调用函数前存入的返回地址,所以第一个参数是[EBP+8],这个也是根据顺序和长度简单判断就能确定指的是哪个参数,不过多赘述。
<>程序执行细节及栈空间变化
整个程序的执行流程上面已经进行演示了,下面按照函数的顺序讲解一下栈空间的变化。直接进入main函数,执行到此处:
可见刚刚将上一个栈基址入栈之后,直接将栈顶抬高了0x448,也就是申请了0x448(十进制1096)个字节,其中有1024个是我们申请的password字符串,还有一个int型的authenticated。看源代码中两个参数出现的位置,可以分析出,[local.1]是authenticated变量,[local.257]是password字符串([local.2]~[local.257]正好256*4
=
1024字节)。其他空间干啥用的不知道,现在也没必要知道。然后将ebx,esi,edi三个寄存器入栈,这里的入栈都是在抬高栈顶之后的操作,跟我们分析栈(中缓冲区)的结构没有什么关系,不多赘述。
这段代码就是将刚申请的0x448*4的空间初始化成0xcc。然后将valid_flag初始化为0。
将scanf的两个参数%s和password(从右至左)入栈,然后调用scanf,然后到控制窗口输入。
输入“1234567”后,之后回到OD继续下一步,查看此时栈的状态:
0x31就是ASCII码“1”,00是字符串结束符,下面的C就是没用上的空间。验证一下,目前的EBP是:
[local.257]就是[ebp+257*4]正好是0x0012FB7C,刚刚的判断没有错,将栈拉倒最下面(地址最高,栈反向增长,由高地址向低地址增长):
可以看见这是authenticated变量,值为0,下面(前面带白色矩形框)的内存是上一个函数的栈区。
接下来的add
esp操作是栈顶降低0x8,就是将刚刚的两个参数出栈,因为VC中的调用约定默认是__cdecl约定,在这个约定下由调用者管理栈空间,所以这里需要主函数自己清理用过的参数。然后接下来即将调用verify_password函数,将函数需要的参数password入栈。在调用之前我们将代码停在这个位置然后看一下栈和EIP寄存器的状态和栈顶状态,在进入verify_password函数:
可以看见在调用之前的状态,EIP指向下一调语句,就是call,在call执行的一瞬间EIP会变成call的下一条语(地址0x004010D5)句并且会被压栈,截不到图,文字叙述一下。之后我们跟踪进入verify_password函数内部,之后再看栈顶。
进入函数的一瞬间,栈顶多了一个地址,就是刚刚我们看见的0x004010B5,记住他的位置,然后继续看verify_password函数。
verify_password函数和main函数刚开始差不多,都是先将旧栈基址入栈,然后抬高栈顶,然后一些寄存器状态入栈,同样在抬高栈顶之后入栈,不过多分析。然后初始化缓冲区。注意这里抬高栈顶0x4c的空间,也就是76字节空间。我们只需知道[local.1]是authenticated,[local.3]是buffer(buffer长度8)就好。
然后将strcmp(字符串比较)函数的两个参数,分别是静态的“1234567”写在程序的数据段,就是a.0042601C和password(password也是主函数传给这个函数的第一个参数,表示为[arg.1])从右至左依次入栈。然后运行到执行完strcmp:
函数的执行结果会在eax中
0表示相等,如果输入>"1234567"会是0x1,反之小于是-1也就是0xffffffff(补码)。接下来就是清理用过的参数,然后将strcmp执行结果赋值给authenticated,即[local.1]。
然后进行最关键的部分,strcpy(字符串拷贝)函数,首先将函数用到的两个参数password([arg.1])和buffer([local.3])入栈,然后调用strcpy,之后看栈的空间变化,调用前:
调用strcpy后:
从上到下画方框的依次是buffer,authenticated,前EBP,函数返回地址。看到这里大家基本也就能够判断这里存在的栈溢出了,至于利用方法,在下面一节叙述。
之后的内容就是清理用过的参数,然后将authenticated的值给eax作为返回值,然后恢复之前保存过的几个寄存器的状态,最后返回。
然后回到主函数,清理参数,获取返回值,根据返回值决定输出什么。整体流程就是这样。
<>栈溢出
整个程序的执行流程上面已经进行非常细致的演示了,相信看完之后无论如何也有了很多种想法了吧。这里再简单赘述一下什么是栈溢出:栈溢出就是指栈中的(缓冲区)内容被写入了大于原本(申请的)大小的内容,导致多余的内容覆盖了缓冲区后面的其他地址空间内原有的内容,一张图:
这张图中是反过来的,是下面放的buff写入了大量的超过原有空间的A导致直接覆盖了buf后面的f函数帧结构和EBP还有EIP,只不过覆盖EIP的不是A而是攻击者构造的返回地址。更多关于栈溢出的原理性描述可以自己上网搜索,这里不过多赘述。
回到我们的程序,栈溢出的位置就在strcpy函数这里,password是一个长度为1024的字符串,复制进入一个长度为8的缓冲区,只要我们输入的内容超过8(因为字符串后面会有结束符\0)就会覆盖其他数据。
首先验证一下,这次在输入的时候输入20个字符串(正好覆盖到EIP)内容就是“12341234123412341234”
运行正常时的栈:
可见,原返回地址的地方被最后一组“1234”覆盖了,之后再继续执行到rtn处就会报错:
报错的地址都和我们覆盖的一样,看来这次尝试成功证明了栈溢出漏洞的存在,并且找到了覆盖EIP的位置所在。
<>通过栈溢出控制程序执行结果
根据上一节找到的栈溢出漏洞,这里我们要完成一个挑战,就是输入一个非“1234567”的字符串,让程序输出“success”。
第一种方法:
我们首先输入一个不正确的密码"8888888"(7个8),来看一下strcpy之后的栈空间:
可以看见“7个8”之后正好是字符串结束符0x00,之后紧接着就是0x00000001中的最后一个字节01,也就是说只要我输入“8个8”那么结束符0x00就会覆盖01使authenticated变量从0x00000001变成0x00000000。这里为什么前一个变量的末尾值会直接覆盖后一个变量的末尾值是因为,
0x00000001这个变量是倒着存在内存中的,而在栈窗口中显示也是倒着显示的(栈是高地址向低地址生长),所以看着就是正的,在数据窗口看是这样的(输入7个8):
输入8个8:
数据窗口中是从低地址到高地址显示的,可以看出,字符串buffer是从内存中读的,所以是正序,而0x00000001是来自寄存器eax所以是逆序。所以我们只要再多输一个字符,那么字符串结束符0x00就会覆盖0x01,而将authenticated变为0,那么最终就会输出“success”,现在执行到结束,成功覆盖并且输出了success。
当然这里如果输入的内容比“1234567”小,那么并不能用这种方式覆盖,因为小于“1234567”比较的结果是-1,也就是补码的0xffffffff,覆盖一个字节变为0并不能使整个值变为0。
第二种方法:
可否直接覆盖到返回地址到“输出success”的地方,也就是说我们要先找到输出success的位置。
也就是说,地址为0x004010d0的代码就是要输出success的所在了,那么我们要尝试将返回地址覆盖为0x004010d0。
由于命令行中只能输入ASCII码,有些16进制值无法用ASCII码表示出来,需要稍微修改一下函数代码,把手动输入改为从文件输入,但修改之后刚找到的地址也会改变,不过已经很明显了,接下来再找到也不会很难,代码修改如下:
void main() { int valid_flag=0; char password[1024]; FILE * fp; if(!(fp=fopen(
"password.txt","rw+"))) { exit(0); } fscanf(fp,"%s",password);
//scanf("%s",password); valid_flag=verify_password(password); if(valid_flag) {
printf("incorrect password!\n"); } else { printf("success\n"); } fclose(fp);
getchar(); getchar(); }
新的输出success的地址是0x0040fbaf:
之后我们将文件password.txt使用十六进制编辑器打开,构造20字节的内容,并且最后四字节是要覆盖的地址(反着写):
然后进入OD查看栈的覆盖情况,strcpy前:
strcpy后:
可见,栈中的返回地址成功的被修改了,之后继续运行,查看输出结果,rtn之后直接来到了这里:
输出了success:
之后继续运行会报一个错误,是因为我们覆盖过程中将原EBP也覆盖了,导致返回main之后找不到EBP,找不到EBP就会在返回的时候找不到之前的返回值,但这并不影响我们成功输出了success。
<>通过栈溢出插入代码
要进行代码植入,我们还要对目前代码进行修改:
#include<stdio.h> #include<string.h> #include<stdlib.h> #include<windows.h> #
define PASSWORD "1234567" int verify_password(char *password) { int
authenticated; char buffer[60]; authenticated=strcmp(password,PASSWORD); strcpy(
buffer,password); return authenticated; } void main() { int valid_flag=0; char
password[1024]; FILE * fp; LoadLibrary("user32.dll"); if(!(fp=fopen(
"password.txt","rw+"))) { exit(0); } fscanf(fp,"%s",password); valid_flag=
verify_password(password); if(valid_flag) { printf("incorrect password!\n"); }
else { printf("success\n"); } fclose(fp); getchar(); }
主要修改的地方是verify_password函数中buffer的空间由8改为了60,一遍我们在里面写入代码;在主函数中增加了一句
LoadLibrary("user32.dll");加载user32.dll模块,之后我们写入的代码要调用这里面的MessageBox函数。
所以,我们要通过向栈中写入代码的方式,来让程序弹出一个消息窗(也就是说写入一个消息窗的代码并让它执行)。
那么想要完成这个任务,我们要确定下面几件事:
* MessageBox函数的调用方式
* MessageBox函数的位置
* 完成整个调用汇编代码的编写
* 确定修改之后的程序的栈空间走向和EIP位置(即如何覆盖)
那么首先确定MessageBox函数的调用方式,查阅资料后MessageBox一共有四个参数:
int MessageBox( HWND,//这个参数代表窗口所属,如果为NULL则代表不属于任何窗口
LPCTSTR,//这个参数代表消息框中显示内容的字符串 LPCTSTR,//这个参数代表消息标题显示内容的字符串
UINT//这个参数代表框的风格,NULL为默认 )
进一步了解我们知道,MessageBox函数是系统通过对中间两个字符创参数的类型(ASCII或UNICODE)来决定调用MessageBoxA或者是MessageBoxB,我们这里使用ASCII型字符创,那么我们可以直接去寻找MessageBoxA的地址,寻找方式如下:
找到VC++6.0中Tools目录下的DEPENDS Walker工具,位置如下:
之后随便将一个“有窗口的软件”拖进工具,然后我们查找user32.dll中的MessageBoxA,找到user32.dll的基地址和MessageBoxA的偏移地址:
计算可得MessageBoxA的地址为:0x77d10000+0x000407ea=0x77d507ea。之后我们进行汇编编码(汇编代码可以在od中选择一块区域nop掉然后编写,会自动生成二进制格式):
33DB xor ebx,ebx //这里是为了让ebx为0,因为有两个参数值是NULL并且字符创需要一个结束符,如果直接mov
ebx,0会使二进制代码中出现0,在strcpy时会被认为字符创结束符而结束复制。所以使用这种方式。 53 push ebx
//先入栈一个0作为窗口显示字符创的结束符 68 6f50776e push 0x6e77506f
//这两句是构造窗口显示的字符创,我们这里让窗口都显示“HelloPwn” 68 48656c6c push 0x6c6c6548 8BC4 mov
eax,esp //字符串(字符串开始地址就是esp栈顶)给eax 53 push ebx //四个参数从右至左依次入栈 50 push eax 50
push eax 53 push ebx B8 EA07d577 mov eax,0x77d507EA //将MessageBoxA的地址给eax FFD0
call eax //调用
如果在整个过程中出现了00(一般是由于数字出现00造成的),那么我们应该换一种表达方法,如(上述代码中使用了xor指令也是可以的):
B8 5b000400 mov eax,0x0004005b 改为 B8 6b101410 mov eax,0x1014106b 2D 10101010
sub eax,0x10101010
接下来我们确定栈空间,我们将6组“1234567890”写在文件中,然后在OD中查看,还是在执行完strcpy之后,查看栈空间,strcpy前:
strcpy后:
可见由于buffer总共只有60字节的空间,我们输入的长度为60的字符创沾满了buffer,然后第61个字符串结束符00覆盖了authenticated的最后一个字节01(和上面讲的原理一样),下面就是EBP和返回地址。所以我们需要做的就是在文件中构造如下的内容,即先写代码,
然后用90(nop填充)在69~72字节处填写buffer的初始地址,用来覆盖返回值,直接返回到我们写的代码上执行,就是上图的0x0012fadc
,构造的文件如下:
上面框是调用MessageBoxA的代码,一堆90之后是覆盖返回地址的地址,之后执行一下,成功弹窗,点击确定后程序会崩溃,如果没有成功弹窗,比如提示一些地址不可执行之类的,可以继续按照上面的调试方法,一步一步的看一下栈中的内容,返回地址是否完整覆盖,覆盖的返回地址是否是代码的起始地址等:
以上就是简单的栈溢出的详细讲解,关于更多内容,大家可以参考《0day安全,软件漏洞分析技术》这本书。
热门工具 换一换