0day安全
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.3 修改函数返回地址

2.3.1 返回地址与程序流程

上节实验介绍的改写邻接变量的方法是很有用的,但这种漏洞利用对代码环境的要求相对比较苛刻。更通用、更强大的攻击通过缓冲区溢出改写的目标往往不是某一个变量,而是瞄准栈帧最下方的EBP和函数返回地址等栈帧状态值。

回顾上节实验中输入7个‘q’程序正常运行时的栈状态,如表2-3-1所示。

表2-3-1 栈帧数据

如果继续增加输入的字符,那么超出buffer[8]边界的字符将依次淹没authenticated、前栈帧EBP、返回地址。也就是说,控制好字符串的长度就可以让字符串中相应位置字符的ASCII码覆盖掉这些栈帧状态值。

按照上面对栈帧的分析,不难得出下面的结论。

(1)输入11个‘q’,第9~11个字符连同NULL结束符将authenticated冲刷为0x00717171。

(2)输入15个‘q’,第9~12个字符将authenticated冲刷为0x71717171;第13~15个字符连同NULL结束符将前栈帧EBP冲刷为0x00717171。

(3)输入19个‘q’,第9~12个字符将authenticated冲刷为0x71717171;第13~16个字将前栈帧EBP冲刷为0x71717171;第17~19个字符连同NULL结束符将返回地址冲刷为0x00717171。

这里用19个字符作为输入,看看淹没返回地址会对程序产生什么影响。出于双字对齐的目的,我们输入的字符串按照“4321”为一个单元进行组织,最后输入的字符串为“4321432143214321432”,运行情况如图2.3.1所示。

图2.3.1 栈溢出导致程序崩溃

用OllyDbg加载程序,在字符串复制函数调用结束后观察栈状态,如图2.3.2所示。实际的内存状况和我们分析的结论一致,此时的栈状态如表2-3-2所示。

表2-3-2 栈帧数据

图2.3.2 溢出前栈中的布局

前面已经说过,返回地址用于在当前函数返回时重定向程序的代码。在函数返回的“retn”指令执行时,栈顶元素恰好是这个返回地址。“retn”指令会把这个返回地址弹入EIP寄存器,之后跳转到这个地址去执行。

在这个例子中,返回地址本来是0x004010EB,对应的是main函数代码区的指令,如图2.3.3所示。

图2.3.3 正常情况下函数返回后的指令

现在我们已经把这个地址用字符的ASCII码覆盖成了0x00323334,函数返回时的状态如图2.3.4所示。

我们可以从调试器中的显示看出计算机中发生的事件。

(1)函数返回时将返回地址装入EIP寄存器。

(2)处理器按照EIP寄存器的地址0x00323334取指。

(3)内存0x00323334处并没有合法的指令,处理器不知道该如何处理,报错。

图2.3.4 溢出后程序返回到无效地址0x00323334

由于0x00323334是一个无效的指令地址,所以处理器在取指的时候发生了错误使程序崩溃。但如果这里我们给出一个有效的指令地址,就可以让处理器跳转到任意指令区去执行(比如直接跳转到程序验证通过的部分),也就是说,我们可以通过淹没返回地址而控制程序的执行流程。以上就是通过淹没栈帧状态值控制程序流程的原理,也是本节实验要做的事。

2.3.2 控制程序的执行流程

用键盘输入字符的ASCII表示范围有限,很多值(如0x11、0x12等符号)无法直接用键盘输入,所以我们把用于实验的代码稍作改动,将程序的输入由键盘改为从文件中读取字符串。

#include <stdio.h>
#define PASSWORD "1234567"
int verify_password (char *password)
{
          int authenticated;
          char buffer[8];
          authenticated=strcmp(password,PASSWORD);
          strcpy(buffer,password);//over flowed here!
          return authenticated;
}
main()
{
          int valid_flag=0;
          char password[1024];
          FILE * fp;
          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("Congratulation! You have passed the verification!\n");
          }
          fclose(fp);
}

以上节实验中的代码为基础,稍作修改后得到上述代码。程序的基本逻辑和上一节中的代码大体相同,只是现在将从同目录下的password.txt文件中读取字符串,而不是用键盘输入。我们可以用十六进制的编辑器把我们想写入但不能直接键入的ASCII字符写进这个password.txt文件。

实验环境如表2-3-3所示。

表2-3-3 实验环境

如果完全采用实验指导所推荐的实验环境,将精确地重现指导中所有的细节,否则需要根据具体情况重新调试。

用VC6.0将上述代码编译链接(使用默认编译选项,Build成debug版本),在与PE文件同目录下建立password.txt并写入测试用的密码之后,就可以用OllyDbg加载调试了。

开始动手之前,我们先理清思路,看看要达到实验目的我们都需要做哪些工作。

(1)要摸清楚栈中的状况,如函数地址距离缓冲区的偏移量等。这虽然可以通过分析代码得到,但我还是推荐从动态调试中获得这些信息。

(2)要得到程序中密码验证通过的指令地址,以便程序直接跳去这个分支执行。

(3)要在password.txt文件的相应偏移处填上这个地址。

这样verify_password函数返回后就会直接跳转到验证通过的正确分支去执行了。

首先用OllyDbg加载得到可执行PE文件,如图2.3.5所示。

图2.3.5 提示验证通过的代码位置

阅读图2.3.5中显示的反汇编代码,可以知道通过验证的程序分支的指令地址为0x00401122。

0x00401102处的函数调用就是verify_password函数,之后在0x0040110A处将EAX中的函数返回值取出,在0x0040110D处与0比较,然后决定跳转到提示验证错误的分支或提示验证通过的分支。

提示验证通过的分支从0x00401122处的参数压栈开始。如果我们把返回地址覆盖成这个地址,那么在0x00401102处的函数调用返回后,程序将跳转到验证通过的分支,而不是进入0x00401107处分支判断代码。这个过程如图2.3.6所示。

通过动态调试,发现栈帧中的变量分布情况基本没变。这样我们就可以按照如下方法构造password.txt中的数据。

图2.3.6 栈溢出攻击示意图

仍然出于字节对齐、容易辨认的目的,我们将“4321”作为一个输入单元。

buffer[8]共需要两个这样的单元。

第3个输入单元将authenticated覆盖;第4个输入单元将前栈帧EBP值覆盖;第5个输入单元将返回地址覆盖。

为了把第5个输入单元的ASCII码值0x34333231修改成验证通过分支的指令地址0x00401122,我们将借助十六进制编辑工具UltraEdit来完成(0x40、0x11等ASCII码对应的符号很难用键盘输入)。

步骤1:创建一个名为password.txt的文件,并用记事本打开,在其中写入5个“4321”后保存到与实验程序同名的目录下,如图2.3.7所示。

图2.3.7 制作触发栈溢出的输入文件

步骤2:保存后用UltraEdit_32重新打开,如图2.3.8所示。

图2.3.8 制作触发栈溢出的输入文件

步骤3:将UltraEdit_32切换到十六进制编辑模式,如图2.3.9所示。

图2.3.9 制作触发栈溢出的输入文件

步骤4:将最后4个字节修改成新的返回地址,注意这里是按照“内存数据”排列的,由于“大顶机”的缘故,为了让最终的“数值数据”为0x00401122,我们需要逆序输入这4个字节,如图2.3.10所示。

图2.3.10 制作触发栈溢出的输入文件

步骤5:这时我们可以切换回文本模式,最后这4个字节对应的字符显示为乱码,如图2.3.11所示。

图2.3.11 制作触发栈溢出的输入文件

将password.txt保存后,用OllyDbg加载程序并调试,可以看到最终的栈状态如表2-3-4所示。

表2-3-4 栈帧数据

程序执行状态如图2.3.12所示。

图2.3.12 栈溢出成功改变了程序执行流程

由于栈内EBP等被覆盖为无效值,使得程序在退出时堆栈无法平衡,导致崩溃。虽然如此,我们已经成功地淹没了返回地址,并让处理器如我们设想的那样,在函数返回时直接跳转到了提示验证通过的分支。