rgzz 发表于 2023-2-15 23:08

栈溢出---《0day2安全》

# 基础知识

## 二进制文件概述

### PE文件格式

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

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

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



# 系统栈的工作原理

## 内存的不同用途

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

进程使用的内存划分:

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


## 函数调用过程

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

```c
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;
}
```

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


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


## 函数调用相关约定


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

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

**函数调用步骤:**

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

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

```asm
                ;调用前
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 寄存器。


3.跳转

函数返回时,相关指令:

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


# 修改邻接变量

## 修改邻接变量原理

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

```c
#include <stdio.h>
#define PASSWORD "1234567"
int verify_password (char *password)
{
    int authenticated;
    char buffer;// add local buffto be overflowed
    authenticated=strcmp(password,PASSWORD);
    strcpy(buffer,password);//over flowed here!
    return authenticated;
}
main()
{
    int valid_flag=0;
    char password;
    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)时,栈帧状态如下图:


**改变程序流程思路:**

可以发现,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”,通过验证,结果如下图所示:


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


| 局部变量名    | 内存地址   | 偏移 3 处的值 | 偏移 2 处的值 | 偏移 1 处的值 | 偏移 0 处的值 |
| ------------- | ---------- | ------------- | ------------- | ------------- | ------------- |
| buffer| 0x0012FB18 | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    |
| buffer| 0x0012FB1C | NULL          | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    |
| authenticated | 0x0012FB20 | 0x00          | 0x00          | 0x00          | 0x01          |

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

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


| 局部变量名             | 内存地址   | 偏移 3 处的值 | 偏移 2 处的值 | 偏移 1 处的值 | 偏移 0 处的值 |
| ---------------------- | ---------- | ------------- | ------------- | ------------- | ------------- |
| buffer               | 0x0012FB18 | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    |
|                        | 0x0012FB1C | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    |
| authenticated 被覆盖前 | 0x0012FB20 | 0x00          | 0x00          | 0x00          | 0x01          |
| authenticated 被覆盖后 | 0x0012FB20 | NULL          | 0x74 (‘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,如下图所示:


| 局部变量名             | 内存地址   | 偏移 3 处的值 | 偏移 2 处的值 | 偏移 1 处的值 | 偏移 0 处的值 |
| ---------------------- | ---------- | ------------- | ------------- | ------------- | ------------- |
| buffer               | 0x0012FB18 | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    |
|                        | 0x0012FB1C | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    |
| authenticated 被覆盖前 | 0x0012FB20 |               |               |               | 0x01          |
| authenticated 被覆盖后 | 0x0012FB20 |               |               |               | 0x00 (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 处的值 |
| ------------- | ---------- | ------------- | ------------- | ------------- | ------------- |
| buffer      | 0x0012FB18 | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    |
|               | 0x0012FB1C | NULL          | 0x71 (‘q’)    | 0x71 (‘q’)    | 0x71 (‘q’)    |
| authenticated | 0x0012FB20 | 0x00          | 0x00          | 0x00          | 0x01          |
| 前栈帧 EBP    | 0x0012FB24 | 0x00          | 0x12          | 0xFF          | 0x80          |
| 返回地址      | 0x0012FB28 | 0x00          | 0x40          | 0x10          | 0xEB          |

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

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




| 局部变量名                | 内存地址   | 偏移 3 处的值 | 偏移 2 字节 | 偏移 1 字节 | 偏移 0 字节 |
| ------------------------- | ---------- | ------------- | ----------- | ----------- | ----------- |
| buffer               | 0x0012FB18 | 0x31 (‘1’)    | 0x32 (‘2’)| 0x33 (‘3’)| 0x34 (‘4’)|
| buffer               | 0x0012FBIC | 0x31 (‘1’)    | 0x32 (‘2’)| 0x33 (‘3’)| 0x34 (‘4’)|
| authenticated(被覆盖前) | 0x0012FB20 | 0x00          | 0x00      | 0x00      | 0x01      |
| authenticated(被覆盖后) | 0x0012FB20 | 0x31 (‘1’)    | 0x32 (‘2’)| 0x33 (‘3’)| 0x34 (‘4’)|
| 前栈帧 EBP(被覆盖前)    | 0x0012FB24 | 0x00          | 0x12      | 0xFF      | 0x80      |
| 前栈帧 EBP(被覆盖后)    | 0x0012FB24 | 0x31 (‘1’)    | 0x32 (‘2’)| 0x33 (‘3’)| 0x34 (‘4’)|
| 返回地址(被覆盖前)      | 0x0012FB28 | 0x00          | 0x40      | 0x10      | 0xEB      |
| 返回地址(被覆盖后)      | 0x0012FB28 | 0x00(NULL)    | 0x32 (‘2’)| 0x33 (‘3’)| 0x34 (‘4’)|


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

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

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

## 控制程序的执行流程

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

```c
#include <stdio.h>
#define PASSWORD "1234567"
int verify_password (char *password)
{
    int authenticated;
    char buffer;
    authenticated=strcmp(password,PASSWORD);
    strcpy(buffer,password);//over flowed here!
    return authenticated;
}
main()
{
    int valid_flag=0;
    char password;
    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调出程序执行的流程图,分析一下程序执行流程。

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

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

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

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


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

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

构造步骤:

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

2.   用 Ultraedit32 打开 password.txt

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

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


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


| 局部变量名                | 内存地址   | 偏移 3 处的值 | 偏移 2 处的值 | 偏移 1 处的值 | 偏移 0 处的值 |
| ------------------------- | ---------- | ------------- | ------------- | ------------- | ------------- |
| buffer               | 0x0012FB14 | 0x31 (‘1’)    | 0x32 (‘2’)    | 0x33 (‘3’)    | 0x34 (‘4’)    |
| buffer               | 0x0012FB18 | 0x31 (‘1’)    | 0x32 (‘2’)    | 0x33 (‘3’)    | 0x34 (‘4’)    |
| authenticated(被覆盖前) | 0x0012FB1C | 0x00          | 0x00          | 0x00          | 0x01          |
| authenticated(被覆盖后) | 0x0012FB1C | 0x31 (‘1’)    | 0x32 (‘2’)    | 0x33 (‘3’)    | 0x34 (‘4’)    |
| 前栈帧 EBP(被覆盖前)    | 0x0012FB20 | 0x00          | 0x12          | 0xFF          | 0x80          |
| 前栈帧 EBP(被覆盖后)    | 0x0012FB20 | 0x31 (‘1’)    | 0x32 (‘2’)    | 0x33 (‘3’)    | 0x34 (‘4’)    |
| 返回地址(被覆盖前)      | 0x0012FB24 | 0x00          | 0x40          | 0x11          | 0x07          |
| 返回地址(被覆盖后)      | 0x0012FB24 | 0x00          | 0x40          | 0x11          | 0x22          |

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

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

yonghu99999 发表于 2023-2-16 07:20

跟着大佬一起学习

zhangjj001 发表于 2023-2-16 08:09

牛逼牛逼!

okman110 发表于 2023-2-16 08:17

看不懂啊

tompz2002 发表于 2023-2-16 08:25

虽然目前看不懂,但是还是要谢谢大佬的付出

EXiaoLu 发表于 2023-2-16 08:32

学习了,感谢大佬

鹤舞九月天 发表于 2023-2-16 08:50

写的好,有时间我也试下

aodamiao45 发表于 2023-2-16 11:16

大佬,学习了

aa2923821a 发表于 2023-2-16 11:31

谢谢 正好学习一下

旧尘 发表于 2023-2-16 12:19

感谢楼主分享
页: [1] 2 3
查看完整版本: 栈溢出---《0day2安全》