栈溢出
[toc]
基础知识
二进制文件概述
PE文件格式
PE(Portable Executable)是 Win32 平台下的可执行文件(如:"*.exe","*.dll"),PE文件规定了所有信息(二进制机器代码、字符串、菜单、图标、位图、字体等)在可执行文件中如何组织。
PE 文件格式把可执行文件分成若干个数据节(section):
- .text 二进制的机器代码
- .data 初始化的数据块
- .idata 动态链接库
- .rsrc 程序的资源
系统栈的工作原理
内存的不同用途
缓冲区溢出:大缓冲区向小缓冲区复制,撑爆了小缓冲区,从而冲掉了和小缓冲区相邻内存区域的其它数据而引起的内存问题。
进程使用的内存划分:
- 代码区
- 数据区
- 堆区
- 栈区
函数调用过程
同一文件不同函数的代码在内存代码区中是散乱无关的,但都在同一个 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;
}
当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,并把它压入栈中。这个栈帧的内存空间被它所属的函数独占。当函数返回时,系统栈会弹出该函数所对应的栈帧。
函数调用时,栈中的变化:
函数调用相关约定
如果要明确使用某一种调用约定,在函数前加上调用约定的声名即可。默认调用是__stdcall 调用方式,从右向左将参数入栈。
特例:C++类成员中的 this 指针,一般用 ECX 寄存器传递。用GCC编译器编译,他会作为最后一个参数压栈。
函数调用步骤:
- 参数入栈
- 返回地址入栈
- 代码区跳转
- 栈帧调整: 保存当前栈帧状态值,已备后面恢复本栈帧时使用( EBP 入栈); 将当前栈帧切换到新栈帧(将 ESP 值装入 EBP,更新栈帧底部); 给新栈帧分配空间(把 ESP 减去所需空间的大小,抬高栈顶);
__stdcall 调用约定,函数调用指令:
;调用前
push 参数 3 ;假设该函数有 3 个参数,将从右向左依次入栈
push 参数 2
push 参数 1
call 函数地址;call 指令将同时完成两项工作:
;a)向栈中压入当前指令在内存中的位置,即保存返回地址。
;b)跳转到所调用函数的入口地址函数入口处
push ebp ;保存旧栈帧的底部
mov ebp, esp ;设置新栈帧的底部(栈帧切换)
sub esp, xxx ;设置新栈帧的顶部(抬高栈顶,为新栈帧开辟空间)
函数返回的步骤:
保存返回值:通常保存在 EAX 中。
弹出当前栈帧,恢复上一个栈帧。 具体操作:
- 在堆栈平衡的基础上,给 ESP 加上栈帧的大小,降低栈顶,回收当前栈帧的空间
- 将当前栈帧底部保存的前栈帧 EBP 值弹入 EBP 寄存器,恢复出上一个栈帧。
- 将函数返回地址弹给 EIP 寄存器。
跳转
函数返回时,相关指令:
add esp, xxx ;降低栈顶,回收当前的栈帧
pop ebp;将上一个栈帧底部位置恢复到 ebp,
retn;这条指令有两个功能:
;a)弹出当前栈顶元素,即弹出栈帧中的返回地址。
;至此,栈帧恢复工作完成。
;b)让处理器跳转到弹出的返回地址,恢复调用前的代码区
修改邻接变量
修改邻接变量原理
函数的局部变量在栈中相邻排列。如果局部变量有数组之类的缓冲区,并且程序中存在数组越界缺陷,那么越界的数组就能破坏相邻变量,甚至能破坏 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)时,栈帧状态如下图:
改变程序流程思路:
可以发现,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[0~3] | 0x0012FB18 | 0x71 (‘q’) | 0x71 (‘q’) | 0x71 (‘q’) | 0x71 (‘q’) |
buffer[4~7] | 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[0~3] | 0x0012FB18 | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) |
buffer[4~7] | 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 等符号)无法直接用键盘输入,所以我们将程序的输入由键盘改为从文件中读取字符串。
#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调出程序执行的流程图,分析一下程序执行流程。
从上面的流程图中,可以发现,在401111
处的指令进行了程序验证。
0x00401102
调用了 verify_password 函数,之后在 0x0040110A
处将EAX中的返回值取出,在 0x0040110D
处与0比较,然后决定跳转到提示验证通过的分支或是提示验证失败的分支。
提示验证通过的分支从 0x00401122
处的参数压栈开始。如果我们把返回地址覆盖成这个地址,那么在 0x00401102
处的函数调用返回后,程序将跳转到验证通过的分支,而不是进入分支判断代码。
通过动态调试,发现栈帧中的变量分布情况基本没变。这样我们按如下方法构造 password.txt 中的数据。
构造思路:用2个 “4321”来填充 buffer[8],第3个“4321”来覆盖 authenticated,第4个“4321”覆盖前栈帧 EBP,第5个“4321” 的 ASCII码值 0x34333231 修改成验证通过分支的指令地址 0x00401122。
在构造 password.txt 时,我们需要用到一个软件 Ultraedit,通过它来编辑十六进制。
构造步骤:
创建一个 password.txt文件,写入5个“4321”,放在实验程序的目录中。
用 Ultraedit32 打开 password.txt
切换至十六进制编辑模式。
将最后4个字节修改为新的返回地址 0x00401122,注意:由于“大顶端”,我们需要逆序输入这4个字节
将 password.txt 保存后,用 OllyDbg 加载程序并调试,可以看到最终的栈状态如表所示。
局部变量名 | 内存地址 | 偏移 3 处的值 | 偏移 2 处的值 | 偏移 1 处的值 | 偏移 0 处的值 |
---|---|---|---|---|---|
buffer[0~3] | 0x0012FB14 | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) |
buffer[4~7] | 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被覆盖为无效值,使得程序在退出时堆栈无法平衡,导致崩溃。