二进制安全
0day2
01 基础知识
02 栈溢出原理和利用
03 shellcode 开发
其它
某固件提取资产网络指纹数据
利用异常的思路
x64 shellcode 内存加载器
本文档使用 MrDoc 发布
-
+
首页
02 栈溢出原理和利用
## 栈原理 - 进程内存划分: 1. **代码区**:这个区域存储着被装入执行的二进制机器代码,处理器会到这个区域取指并执行。 2. **数据区**:用于存储全局变量等。 3. **堆区**:进程可以在堆区动态地请求一定大小的内存,并在用完之后归还给堆区。动态分配和回收是堆区的特点。 4. **栈区**:用于动态地存储函数之间的调用关系,以保证被调用函数在返回时恢复到母函数中继续执行。 - 代码中请求开辟动态内存,则会在内存的堆区分配一块大小合适的区域返回给代码区的代码使用;当函数调用发生时,函数的调用关系等信息会动态地保存在内存的栈区。下面是进程内存示意图: ![](/media/202404/2024-04-28_154745_4691210.5262179243749345.png) - 栈:只能从栈顶存取数据。也就是只有 PUSH 和 POP 操作 - 基础概念: 1. PUSH:为栈增加一个元素的操作叫做 PUSH,相当于在这摞扑克牌的最上面再放上一张。 2. POP:从栈中取出一个元素的操作叫做 POP,相当于从这摞扑克牌取出最上面的一张。 3. TOP:标识栈顶位置,并且是动态变化的。每做一次 PUSH 操作,它都会自增 1;相反,每做一次 POP 操作,它会自减 1。栈顶元素相当于扑克牌最上面一张,只有这张牌的花色是当前可以看到的。 4. 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; } ``` ![](/media/202404/2024-04-28_160228_4924450.4795301849816961.png) - **栈是如何参与到函数调用的**:当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,并把它压入栈中。这个栈帧中的内存空间被它所属的函数独占,正常情况下是不会和别的函数共享的。当函数返回时,系统栈会弹出该函数所对应的栈帧。如下图所示: ![](/media/202404/2024-04-28_160410_7875410.7076870483581387.png) - 函数调用过程如下。 - main ![](/media/202404/2024-04-28_163537_3104730.5480846436271816.png) - func_A ![](/media/202404/2024-04-28_163619_3600860.3702567322691199.png) - func_B ![](/media/202404/2024-04-28_163712_4876200.9993602928709813.png) ### 寄存器 ``` ESP:栈指针寄存器(extended stack po inter),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。 EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。 EIP:指令寄存器(Extended Instruction Pointer),其内存放着一个指针,该指针永远指向下一条等待执行的指令地址 ``` - EBP 指向当前位于系统栈最上边一个栈帧的底部,而不是系统栈的底部。严格说来,**“栈帧底部”**和**“栈底”**是不同的概念 ### 函数调用约定 ![](/media/202404/2024-04-28_161331_6664270.0260221329826833.png) ![](/media/202404/2024-04-28_161349_2786290.13873272760121658.png) - 除了上边的参数入栈方向和恢复栈平衡操作位置的不同之外,参数传递有时也会有所不同。例如,每一个 C++类成员函数都有一个 this 指针,在 Windows 平台中,这个指针一般是用 ECX 寄存器来传递的,但如果用 GCC 编译器编译,这个指针会作为最后一个参数压入栈中。 - 函数调用过程: 1. 参数入栈:将参数从右向左依次压入系统栈中。 2. 返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继 续执行。 3. 代码区跳转:处理器从当前代码区跳转到被调用函数的入口处。 4. 栈帧调整,具体包括: - 保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP 入栈); - 将当前栈帧切换到新栈帧(将 ESP 值装入 EBP,更新栈帧底部); - 给新栈帧分配空间(把 ESP 减去所需空间的大小,抬高栈顶); - 举例:__stdcall: ```masm ; 调用前 push 参数3 ; 假设函数有三个参数,从右向左依次入栈 push 参数2 push 参数1 call 函数地址 ; call指令同时完成两个工作: ; 1. 向栈中压入当前指令在内存中的位置,即保存返回地址 ; 2. 跳转到所调用函数的入口地址 push ebp ; 保存旧栈帧的底部 mov ebp,esp ; 设置新栈帧的底部(栈帧切换) sub esp,xxx ; 设置新栈帧的顶部(抬高栈顶,为新栈帧开辟空间) ``` - 函数调用示意图 ![](/media/202405/2024-05-06_142430_0734120.7113108871854995.png) - 函数返回步骤: 1. 保存返回值:通常将函数的返回值保存在寄存器 EAX 中。 2. 弹出当前栈帧,恢复上一个栈帧。具体包括: - 在堆栈平衡的基础上,给 ESP 加上栈帧的大小,降低栈顶,回收当前栈帧的空间。 - 将当前栈帧底部保存的前栈帧 EBP 值弹入 EBP 寄存器,恢复出上一个栈帧。 - 将函数返回地址弹给 EIP 寄存器。 3. 跳转:按照函数返回地址跳回母函数中继续执行。 - 函数返回举例: ```masm add esp,xxx ; 降低栈顶,回收当前栈帧 pop ebp ; 将上一个栈帧底部恢复到 ebp retn ; 弹出当前栈顶元素,即弹出栈帧保存的返回地址。栈帧恢复工作完成 ; 处理器跳转到弹出的返回地址,恢复调用前的代码区。 ``` - 函数返回示意图 ![](/media/202405/2024-05-06_142523_6241420.4386861844602442.png) ## 修改邻接变量 - 观察上面的函数返回示意图,会发现【局部变量 var_A】和【前栈帧的 EBP】和【返回地址】是相邻的,如果该变量是个数组,并且程序中存在数组越界的缺陷,那么越界的数组元素就有可能破坏栈中相邻变量的值,甚至破坏栈帧中所保存的 EBP 值、返回地址等重要数据。 - 举例代码: ```c #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; } } } ``` - 代码注意以下两点: 1. verify_password()函数中的局部变量 char buffer[8]的**声明位置**。 2. 字符串比较之后的 strcpy(buffer,password)。 - 这两处修改实际上没啥用,只是为了人为制造漏洞。但是要注意出于编译优化等目的,**变量在栈中的存储顺序可能会有变化**,需要在动态调试时具体问题具体分析。 - 下图是上述代码中的 verify_password()函数栈帧布局。 ![](/media/202405/2024-05-06_144303_1814490.7002291419447317.png) - 观察下即可发现:**如果输入的密码超过了 7 个字符(注意:字符串截断符 NULL 将占用一个字节),则越界字符的 ASCII 码会修改掉 authenticated 的值。**比如我们输入 8 个 “a”: ![](/media/202405/2024-05-06_145210_6879110.08203786578404582.png) - 可以发现,本该是 1 的 authenticated 变成了 0,最终导致了我们输入错误的结果也返回正确的值: ![](/media/202405/2024-05-06_145515_1672470.39722587455348024.png)。 - 原理如下: 1. 我们输入“aaaaaaaa”,在内存中是“aaaaaaaa\0” 2. 在前面的比较过程中,由于“aaaaaaaa”>“1234567”,所以返回值是 1,此刻 authenticated 的值是 0x00000001,由于是**小端排序,在内存中保存的是 01 00 00 00** 3. stcpy 函数执行,将 “aaaaaaaa\0” 复制到 buffer 中,在内存中是 “61 61 61 61 61 61 61 61 00”,“61” 是“a”的 ascii 码。最后的一个 00 已经越界了,也就是占用了 authenticated 的最低位,而 authenticated 的最低位存的值是 01,被覆盖成了 00。当 authenticated 为 0 时,表示验证成功;反之,验证不成功。也就导致了结果被判定为相等。实际上,这里输入任意的 **ASCII 大于“1234567”的字符串**都可以成功绕过。小于则不能绕过判定,原因如下: ![](/media/202405/2024-05-06_150917_1409790.6548866751388169.png) ## 修改函数返回地址 - 上面通过溢出修改了临界变量,让程序绕过判定过程直接成功。已知在函数返回的“retn”指令执行时,栈顶元素恰好是这个返回地址。“retn”指令会把这个返回地址弹入 EIP 寄存器,之后跳转到这个地址去执行。那么可以通过溢出修改返回地址,让系统跳转到我们希望执行的代码执行。 ### 控制程序的执行流程 - 由于我们键盘无法输入一些不可见字符,所以修改程序读取文件判断。如下: ```c #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); } ``` - 现在如果需要修改返回地址,我们需要借助二进制编辑器,如下: ![](/media/202405/2024-05-06_152720_8471960.4733162272063858.png) - 通过溢出更多的内容,覆盖掉 authenticated 和【前一个栈帧的 EBP】直到返回地址,将返回地址修改为 “0x00401122”。 ![](/media/202405/2024-05-06_153145_9694750.9818695904793625.png) - 跳转到判断成功处的代码 ![](/media/202405/2024-05-06_153246_3921290.603403392816992.png) ![](/media/202405/2024-05-06_153330_9806350.2353456509193339.png) ## 代码植入 - 前面我们通过溢出返回地址执行了已有的代码,下面将通过代码植入,让系统执行原本不存在的代码。为了演示,代码修改如下: ```c #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); } ``` - 对比前面代码,修改如下: 1. 增加了头文件 windows.h,以便程序能够顺利调用 LoadLibrary 函数去装载 user32.dll。 2. verify_password 函数的局部变量 buffer 由 8 字节增加到 44 字节,这样做是为了有足够的空间来“承载”我们植入的代码。 3. main 函数中增加了 LoadLibrary("user32.dll")用于初始化装载 user32.dll,以便在植入代码中调用 MessageBox。 - 加入需要执行 MessageBox,步骤如下: 1. 分析并调试漏洞程序,获得淹没返回地址的偏移。 2. 获得 buffer 的起始地址,并将其写入 password.txt 的相应偏移处,用来冲刷返回地址。 3. 向 password.txt 中写入可执行的机器代码,用来调用 API 弹出一个消息框。 - buffer[] 有 44 个字节,第 45-48 是 authenticated,第 49-52 是前一个栈帧 EBP,第 53-56 是返回地址。假设调用 MessageBox 函数,需求如下 3 个步骤: 1. 装载动态链接库 user32.dll。 MessageBoxA 是动态链接库 user32.dll 的导出函数。虽然大多数有图形化操作界面的程序都已经装载了这个库,但是我们用来实验的 console 版并没有默认加载它。 2. 在汇编语言中调用这个函数需要获得这个函数的入口地址。 3. 在调用前需要向栈中按从右向左的顺序压入 MessageBoxA 的 4 个参数。 - 调用 MessageBox 的时候,需要计算虚拟内存地址。注意**dll 的基地址和其中导出函数的偏移地址与操作系统版本号、补丁版本号等诸多因素相关,故用于实验的计算机上的函数入口地址很可能与这里不一致。**书中推荐的工具是 dependencywalker,但是目前说已经过时了,现在推荐的是 dependencies。我看了下也挺久没更新了 Issues 中很多问题反馈也没人解答。在这里换个办法吧: - x64dbg 打开该程序,**运行到 user32.dll 加载进入内存(否则没有)。**打开符号窗口,选择 dll 即可。 ![](/media/202405/2024-05-06_165015_9772210.682136340426905.png) - 可以看到 user32.dll 的基地址是 0x75770000,MessageBoxA 的地址是 0x757F15F0 。通过一个代码来调用该函数: ``` 机器码(十六进制) 汇编指令 注释 33DB XOR EBX,EBX 压入 NULL 结尾的 “failwest”字符串。之所以 53 PUSH EBX 用 EBX 清零后入栈作为字符串的截断符, 6877657374 PUSH 74736577 是为了避免“PUSH 0”中的NULL,否则植入的机器码 686661696C PUSH 6C696166 会被 strcpy 函数截断。 8BC4 MOV EAX,ESP EAX 里是字符串指针 53 PUSH EBX 4 个参数按照从右向左的顺序入栈, 50 PUSH EAX 分别是(0,failwest,failwest,0) 50 PUSH EAX 消息框为默认风格 53 PUSH EBX 文本区和标题都是“failwest” B8F0157F75 MOV EAX,0x757F15F0 调用 MessageBoxA。注意:不同的机器这里的 FFD0 CALL EAX 函数入口地址可能不同,请按照实际值填入 ``` - 另外注意溢出的返回地址也可能不同,写的时候注意点 ![](/media/202405/2024-05-06_233546_2946900.7795165610488236.png) - 完成 ![](/media/202405/2024-05-06_233745_7238350.6949812464714372.png) - (由于某些原因,这些实验不完全在一台电脑上完成,所以图片中的地址可能会有些出入,问题不大,理解逻辑就好)
别卷了
2024年5月7日 09:19
转发文档
收藏文档
上一篇
下一篇
手机扫码
复制链接
手机扫一扫转发分享
复制链接
Markdown文件
分享
链接
类型
密码
更新密码