日期: 2025-04-03
作者: Mr-hello
介绍: 一些简单的数据栈知识。
0x01基础知识、概念
众所周知,一个程序的运行需要占用系统的内存,例如,一个由 C/C++
编写的程序占用的内存分为如下几个部分:
-
栈区(stack):由编译器自动分配释放,存放函数的参数值,局部变量的值等。
-
堆区(heap):一般由程序员分配释放,若程序员不释放,程序结束时可能由
OS
回收。(与数据结构中的堆结构不同) -
全局区、静态区(static):全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。
-
文字常量区:常量字符串就是放在这里的,程序结束后由系统释放。
-
程序代码区:存放函数体的二进制代码。
int a = 0;//全局初始化区
char *p1;//全局未初始化区
main()
{
int b;//栈
char s[] = "abc";//栈
char *p2;//栈
char *p3 = "123456";//字符串123456在常量区,p3在栈上。
static int c = 0;//全局(静态)初始化区
p1 = (char *)malloc(10);
p2 = (char *)malloc(20);//分配得来得10和20字节的区域就在堆区。
strcpy(p1, "123456");//字符串123456放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。
}
0x02数据栈结构、特性
在数据结构中的数据栈结构中,遵循 LIFO
(后进先出,Last In First Out
)的顺序。
程序中的数据栈结构和数据结构中的栈是类似的,在程序的运行过程中,数据栈就是用来支持函数的调用和执行。栈的申请和释放是由系统层面进行操作的,用户层无法进行操作。并且数据栈的效率高,使用方便,在 intel x86
的系统中,堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同),如下图所示:
因此,栈顶地址是不断减小的,越后入栈的数据,所处的地址也就越低。在32
位系统中,堆栈每个数据单元的大小为4
字节。小于等于4
字节的数据,比如字节、字、双字和布尔型,在堆栈中都是占4
个字节的,大于4
字节的数据在堆栈中占4
字节整数倍的空间。
和数据栈的操作相关的两个寄存器是 EBP
寄存器和 ESP
寄存器,通俗易懂的去讲,EBP
和 ESP
是两个指针,ESP
指针指向数据栈的栈顶,当向数据栈中压入数据时,ESP
指针执行 -4
操作,然后将数据写入 ESP
指向的地址。向数据栈中取出数据的时候,先将 ESP
指针指向的地址上储存的数据取出到内存地址/寄存器中,然后 ESP
指针执行 +4
操作。EBP
指针是用来访问数据栈中的数据。它指向堆栈中间的某个位置(具体位置后文会具体讲解),函数的参数地址比 EBP
的值高,而函数的局部变量地址比 EBP
的值低,因此参数或局部变量总是通过 EBP
加减一定的偏移地址来访问的,比如,要访问函数的第一个参数为 EBP+8
。
数据栈中存储的数据包括:函数的参数、函数的局部变量、函数的返回地址、寄存器的值(用以恢复寄存器)以及用于结构化异常处理的数据。这些数据是按照一定的顺序组织在一起的,我们称之为一个堆栈帧。一个堆栈帧对应一次函数的调用。在函数开始时,对应的堆栈帧已经完整地建立了(所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的),在函数退出时,整个函数帧将被销毁。
0x03数据栈的建立
接下来,我们去了解一下数据栈的建立过程,上文提到,数据栈是用来支持函数的调用和执行,所以我们使用下面代码进行函数调用时数据栈工作机制的讲解。
int function1(int m, int n)
{
int p = m * n;
return p;
}
int function(int a, int b)
{
int c = a + 1;
int d = b + 1;
int e = function1(c,d);
return e;
}
int main()
{
int result = function(3,4);
return 0;
}
我们从 main
函数执行的第一行代码,即int result = function(3,4);
开始跟踪。这时 main
以及之前的函数对应的堆栈帧已经存在在堆栈中,如下图所示:
参数入栈
当 function
函数被调用,首先,caller
(此时 caller
为 main
函数)把 function
函数的两个参数:a=3,b=4
压入堆栈。参数入栈的顺序是由函数的调用约定决定的,这里不做详细介绍。一般来说,参数都是从右往左入栈的,因此,b=4
先压入堆栈,a=3
后压入,如图:
返回地址入栈
我们知道,当函数结束时,代码要返回到上一层函数继续执行,那么,函数如何知道该返回到哪个函数的什么位置执行呢?函数被调用时,会自动把下一条指令的地址压入堆栈,函数结束时,从堆栈读取这个地址,就可以跳转到该指令执行。如果当前 call function
指令的地址是 0x00001482
,由于 call
指令占 5
个字节,那么下一个指令的地址为0x00001487
,将被压入堆栈:
代码跳转到被调用函数执行
返回地址入栈后,代码跳转到被调用函数 function
中执行。到目前为止,堆栈帧的前一部分,是由 caller
构建的;而在此之后,堆栈帧的其他部分是由 callee
来构建。
EBP指针入栈
在 function
函数中,首先将 EBP
寄存器的值压入堆栈。因为此时 EBP
寄存器的值还是归属于 main
函数,用来访问 main
函数的参数和局部变量,因此需要将它暂存在堆栈中,在 function
函数退出时恢复。同时,给 EBP
赋予新值。
-
将 EBP
压入堆栈 -
把 ESP
的值赋给EBP
EBP
寄存器指向的堆栈地址就是 EBP
先前值的地址,你还会发现,EBP+4
的地址就是函数返回值的地址,EBP+8
通常是函数的第一个参数的地址(第一个参数地址并不一定是 EBP+8
)。因此,通过 EBP
很容易查找函数是被谁调用的或者访问函数的参数(或局部变量)。为局部变量分配地址
接着,function
函数将为局部变量分配地址。程序并不是将局部变量一个个压入堆栈,而是将 ESP
减去某个值,直接为所有的局部变量分配空间,比如在 function
函数中有 ESP=ESP-0x00E4
,如图所示:
通用寄存器入栈
最后,将函数中使用到的通用寄存器入栈,暂存起来,以便函数结束时恢复。在 function
函数中用到的通用寄存器是 EBX,ESI,EDI
,将它们压入堆栈,如图所示:
0x04数据栈的销毁
数据栈的销毁和数据栈的创建是相反的。当函数做完所有操作时候,将返回值写入到相应位置后,函数开始清理数据栈。
>> 如果有对象存储在堆栈帧中,对象的析构函数会被函数调用。>> 从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。>> ESP
加上某个值,回收局部变量的地址空间(加上的值和堆栈帧建立时分配给局部变量的地址大小相同)。>> 从堆栈中弹出先前的EBP
寄存器的值,恢复EBP
寄存器。>> 从堆栈中弹出函数的返回地址,准备跳转到函数的返回地址处继续执行。>> ESP
加上某个值,回收所有的参数地址。
0x05总结
熟悉并了解堆栈工作原理,是一个二进制选手的基本功。因为只有充分了解了堆栈工作机制,才可能根据代码执行流程中发现存在的逻辑错误点,进而进行后续溢出等工作。例如在数据栈的销毁篇章中,在第5
步进行弹出函数的返回地址,此处如果在函数操作过程中,某变量进行了溢出操作,从而导致返回地址被改写。那么我们就可以做到劫持程序,从而运行我们需要的函数。
免责声明:本文仅供安全研究与讨论之用,严禁用于非法用途,违者后果自负。
点此亲启
原文始发于微信公众号(宸极实验室):『CTF』Pwn入门之数据栈
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论