栈溢出

[toc]

基础知识

二进制文件概述

PE文件格式

PE(Portable Executable)是 Win32 平台下的可执行文件(如:"*.exe","*.dll"),PE文件规定了所有信息(二进制机器代码、字符串、菜单、图标、位图、字体等)在可执行文件中如何组织。

PE 文件格式把可执行文件分成若干个数据节(section):

  • .text 二进制的机器代码
  • .data 初始化的数据块
  • .idata 动态链接库
  • .rsrc 程序的资源

系统栈的工作原理

内存的不同用途

缓冲区溢出:大缓冲区向小缓冲区复制,撑爆了小缓冲区,从而冲掉了和小缓冲区相邻内存区域的其它数据而引起的内存问题。

进程使用的内存划分:

  1. 代码区
  2. 数据区
  3. 堆区
  4. 栈区

image-20220519164948399

函数调用过程

同一文件不同函数的代码在内存代码区中是散乱无关的,但都在同一个 PE 文件的代码所映射的一个 “节” 里。

intfunc_B(int arg_B1, int arg_B2)
{
    int var_B1, var_B2;
    var_B1=arg_B1+arg_B2;
    var_B2=arg_B1-arg_B2;
    return var_B1*var_B2;
}
intfunc_A(int arg_A1, int arg_A2)
{
    int var_A;
    var_A = func_B(arg_A1,arg_A2) + arg_A1;
    return var_A;
}
int main(int argc, char **argv, char **envp)
{
    int var_main;
    var_main=func_A(4,3);
    return var_main;
}

当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,并把它压入栈中。这个栈帧的内存空间被它所属的函数独占。当函数返回时,系统栈会弹出该函数所对应的栈帧。

image-20220221193711651

函数调用时,栈中的变化:

image-20220519170737556

函数调用相关约定

image-20220519171050801

如果要明确使用某一种调用约定,在函数前加上调用约定的声名即可。默认调用是__stdcall 调用方式,从右向左将参数入栈。

特例:C++类成员中的 this 指针,一般用 ECX 寄存器传递。用GCC编译器编译,他会作为最后一个参数压栈。

函数调用步骤:

  1. 参数入栈
  2. 返回地址入栈
  3. 代码区跳转
  4. 栈帧调整: 保存当前栈帧状态值,已备后面恢复本栈帧时使用( EBP 入栈); 将当前栈帧切换到新栈帧(将 ESP 值装入 EBP,更新栈帧底部); 给新栈帧分配空间(把 ESP 减去所需空间的大小,抬高栈顶);

__stdcall 调用约定,函数调用指令:

		;调用前
push 参数 3 ;假设该函数有 3 个参数,将从右向左依次入栈
push 参数 2
push 参数 1
call 函数地址;call 指令将同时完成两项工作: 
;a)向栈中压入当前指令在内存中的位置,即保存返回地址。 
;b)跳转到所调用函数的入口地址函数入口处
push ebp ;保存旧栈帧的底部
mov ebp, esp ;设置新栈帧的底部(栈帧切换)
sub esp, xxx ;设置新栈帧的顶部(抬高栈顶,为新栈帧开辟空间)

函数返回的步骤:

  1. 保存返回值:通常保存在 EAX 中。

  2. 弹出当前栈帧,恢复上一个栈帧。 具体操作:

    1. 在堆栈平衡的基础上,给 ESP 加上栈帧的大小,降低栈顶,回收当前栈帧的空间
    2. 将当前栈帧底部保存的前栈帧 EBP 值弹入 EBP 寄存器,恢复出上一个栈帧。
    3. 将函数返回地址弹给 EIP 寄存器。

    image-20220519172202601

  3. 跳转

函数返回时,相关指令:

add esp, xxx ;降低栈顶,回收当前的栈帧
pop ebp;将上一个栈帧底部位置恢复到 ebp,
retn;这条指令有两个功能: 
;a)弹出当前栈顶元素,即弹出栈帧中的返回地址。
;至此,栈帧恢复工作完成。 
;b)让处理器跳转到弹出的返回地址,恢复调用前的代码区

image-20220221170612199

修改邻接变量

修改邻接变量原理

函数的局部变量在栈中相邻排列。如果局部变量有数组之类的缓冲区,并且程序中存在数组越界缺陷,那么越界的数组就能破坏相邻变量,甚至能破坏 EBP 、返回地址。

#include <stdio.h>
#define PASSWORD "1234567"
int verify_password (char *password)
{
    int authenticated;
    char buffer[8];// add local buffto be overflowed
    authenticated=strcmp(password,PASSWORD);
    strcpy(buffer,password);//over flowed here!
    return authenticated;
}
main()
{
    int valid_flag=0;
    char password[1024];
    while(1)
    {
        printf("please input password: ");
        scanf("%s",password);
        valid_flag = verify_password(password);
        if(valid_flag)
        {
        	printf("incorrect password!\n\n");
        }
        else
        {
            printf("Congratulation! You have passed the
            verification!\n");
            break;
        }
    }
}

当程序执行到 int verify_password(char *password)时,栈帧状态如下图:

image-20220221170630782

改变程序流程思路:

可以发现,authenticated 变量来源于 strcmp 函数的返回值,它被返回给main函数作为验证标志。当 authenticated 为 0 时,标识验证成功;反之,验证不成功。

当我们输入超过 7 个字符的密码(注意:字符截断符 NULL 将占用一个字节),就有机会把 authenticated 覆盖为 0,从而绕过密码验证。

突破密码验证程序

推荐使用的环境备 注
操作系统Windows XP SP3其他 Win32 操作系统也可进行本实验
编译器Visual C++ 6.0如使用其他编译器,需重新调试
编译选项默认编译选项VS2003 和 VS2005 中的 GS 编译选项会使栈溢出实验失败
build 版本debug 版本如使用 release 版本,则需要重新调试

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

(1)先验证一下正确密码,输入“1234567”,通过验证,结果如下图所示:

image-20220519210442088

(2)再来分析一下具体覆盖时,栈中的情况,输入“qqqqqqq”,因为“qqqqqqq”>“1234567”,所以 strcmp 应该返回 1,即 authenticated 为 1。

image-20220519213526513

局部变量名内存地址偏移 3 处的值偏移 2 处的值偏移 1 处的值偏移 0 处的值
buffer[0~3]0x0012FB180x71 (‘q’)0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)
buffer[4~7]0x0012FB1CNULL0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)
authenticated0x0012FB200x000x000x000x01

观察内存时,注意 “内存数据” 与 “数值数据” 的区别。Win32 系统在内存中由低位向高位存储一个 4 字节的双字(DWORD),但在作为 ”数值“ 应用的时候,却是按照由高位字节向低位字节进行解释。“内存数据” 中的 DWORD 和我们逻辑上使用的 “数值数据” 是按字节序逆序过的。

(3)输入超过 7 个字符,“qqqqqqqqrst”,结果如下图:

image-20220519214229348

局部变量名内存地址偏移 3 处的值偏移 2 处的值偏移 1 处的值偏移 0 处的值
buffer0x0012FB180x71 (‘q’)0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)
0x0012FB1C0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)
authenticated 被覆盖前0x0012FB200x000x000x000x01
authenticated 被覆盖后0x0012FB20NULL0x74 (‘t’)0x73 (‘s’)0x72(‘r’)

我们已经知道,通过溢出 buffer 我们能修改 authenticated 的值,若要改变程序流程,就需要把 authenticated 覆盖为 0,而我们的字符截断符 NULL,就刚好能实现,当我们输入 8 个 ‘q’ 时,buffer所拥有的 8 个字节将全部被 ’q‘ 填充,而 NULL 则刚好写入内存 0x0012FB20 出,即下一个双字的低位字节,恰好能把 authenticated 从 0x 00 00 00 01 改成 0x 00 00 00 00,如下图所示:

image-20220519215420951

局部变量名内存地址偏移 3 处的值偏移 2 处的值偏移 1 处的值偏移 0 处的值
buffer0x0012FB180x71 (‘q’)0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)
0x0012FB1C0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)
authenticated 被覆盖前0x0012FB200x01
authenticated 被覆盖后0x0012FB200x00 (NULL)

经上述分析,我们只要输入 8 个**(大于 ”1234567“)** 字符的字符串,那么最后的 NULL 就能将 authenticated 低字节中的 1 覆盖为 0,从而绕过验证程序。

authenticated = strcmp( password, PASSWORD ), 当输入的字符串大于 ”1234567“时,返回1(0x 00 00 00 01),这时可以用NULL 淹没 authenticated 的低位字节从而突破验证; 当输入的字符串小于 ”1234567“时,返回 -1(0x FF FF FF FF),这时如果任然用上述方法淹没,其值变为 0xFF FF FF 00,所以这时是不能冲破验证程序的。

修改函数返回地址

返回地址与程序流程

更改邻接变量对环境要求很苛刻。而更改 EBP 和函数返回地址,往往更通用,更强大。

上节实验输入 7 个 “q“ ,程序栈状态:

局部变量名内存地址偏移 3 处的值偏移 2 处的值偏移 1 处的值偏移 0 处的值
buffer0x0012FB180x71 (‘q’)0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)
0x0012FB1CNULL0x71 (‘q’)0x71 (‘q’)0x71 (‘q’)
authenticated0x0012FB200x000x000x000x01
前栈帧 EBP0x0012FB240x000x120xFF0x80
返回地址0x0012FB280x000x400x100xEB

如果继续增加输入的字符,我们就能让字符串中相应位置字符的 ASCII 码覆盖掉这些栈帧状态值。

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

image-20220519232154440

局部变量名内存地址偏移 3 处的值偏移 2 字节偏移 1 字节偏移 0 字节
buffer[0~3]0x0012FB180x31 (‘1’)0x32 (‘2’)0x33 (‘3’)0x34 (‘4’)
buffer[4~7]0x0012FBIC0x31 (‘1’)0x32 (‘2’)0x33 (‘3’)0x34 (‘4’)
authenticated(被覆盖前)0x0012FB200x000x000x000x01
authenticated(被覆盖后)0x0012FB200x31 (‘1’)0x32 (‘2’)0x33 (‘3’)0x34 (‘4’)
前栈帧 EBP(被覆盖前)0x0012FB240x000x120xFF0x80
前栈帧 EBP(被覆盖后)0x0012FB240x31 (‘1’)0x32 (‘2’)0x33 (‘3’)0x34 (‘4’)
返回地址(被覆盖前)0x0012FB280x000x400x100xEB
返回地址(被覆盖后)0x0012FB280x00(NULL)0x32 (‘2’)0x33 (‘3’)0x34 (‘4’)

image-20220519233124853

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

返回地址本来是 0x004010EB,对应的是 main 函数代码区的指令,现在我们通过溢出 buff 覆盖返回地址为 0x00323334,函数返回时,将 0x00323334 装入 EIP 寄存器,从内存 0x00323334 处取址,由于此处没有合法指令,处理器不知如何处理,报错。

但如果这里是一个有效的指令地址,就能让处理器跳转到任意指令区去执行,我们可以通过淹没返回地址而控制程序的执行流程。

控制程序的执行流程

用键盘输入字符的 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 文件中读取字符串。

推荐使用的环境备 注
操作系统Windows XP SP3其他 Win32 操作系统也可进行本实验
编译器Visual C++ 6.0如使用其他编译器,需重新调试
编译选项默认编译选项VS2003 和 VS2005 中的 GS 编译选项会使栈溢出实验失败
build 版本debug 版本如使用 release 版本,则需要重新调试

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

动态调试时,需要我们做的工作:

(1)摸清楚栈中的状况,如函数地址距离缓冲区的偏移量等。 (2)得到程序中密码验证通过的指令地址,以便程序直接跳去这个分支执行。 (3)在 password.txt 文件的相应偏移处填上这个地址。

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

用OllyDbg 加载 可执行文件,【找到验证的程序分支的指令地址为】按G调出程序执行的流程图,分析一下程序执行流程。 image-20220605154207339

从上面的流程图中,可以发现,在401111处的指令进行了程序验证。

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

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

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

构造思路:用2个 “4321”来填充 buffer[8],第3个“4321”来覆盖 authenticated,第4个“4321”覆盖前栈帧 EBP,第5个“4321” 的 ASCII码值 0x34333231 修改成验证通过分支的指令地址 0x00401122。

在构造 password.txt 时,我们需要用到一个软件 Ultraedit,通过它来编辑十六进制。

构造步骤:

  1. 创建一个 password.txt文件,写入5个“4321”,放在实验程序的目录中。 image-20220605163446828

  2. 用 Ultraedit32 打开 password.txt image-20220605163838554

  3. 切换至十六进制编辑模式。 image-20220605163926732

  4. 将最后4个字节修改为新的返回地址 0x00401122,注意:由于“大顶端”,我们需要逆序输入这4个字节

    image-20220605164113427

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

局部变量名内存地址偏移 3 处的值偏移 2 处的值偏移 1 处的值偏移 0 处的值
buffer[0~3]0x0012FB140x31 (‘1’)0x32 (‘2’)0x33 (‘3’)0x34 (‘4’)
buffer[4~7]0x0012FB180x31 (‘1’)0x32 (‘2’)0x33 (‘3’)0x34 (‘4’)
authenticated(被覆盖前)0x0012FB1C0x000x000x000x01
authenticated(被覆盖后)0x0012FB1C0x31 (‘1’)0x32 (‘2’)0x33 (‘3’)0x34 (‘4’)
前栈帧 EBP(被覆盖前)0x0012FB200x000x120xFF0x80
前栈帧 EBP(被覆盖后)0x0012FB200x31 (‘1’)0x32 (‘2’)0x33 (‘3’)0x34 (‘4’)
返回地址(被覆盖前)0x0012FB240x000x400x110x07
返回地址(被覆盖后)0x0012FB240x000x400x110x22

程序执行状态如下图所示。 image-20220605164434401

由于站内EBP被覆盖为无效值,使得程序在退出时堆栈无法平衡,导致崩溃。