PWN栈溢出基础

发布于 2023-03-04  269 次阅读


c语言函数调用栈

栈是什么

简单来说,栈就是一种LIFO(last in ,first out)形式的数据结构,先进栈的数据后出,后进的先出,这种形式的数据结构刚好满足c语言函数调用时的方式:父函数调用子函数,父函数在前,子函数在后,返回时,子函数先返回,父函数后返回。栈的两种基本操作PUSH与POP。PUSH将数据压入栈中,POP将数据弹出栈并存储到指定寄存器或内存中。栈是从高地址向低地址生长的。如下图:

如下方一个PUSH操作的栗子:PUSH 0x50 将0x50压入栈中,

POP操作的栗子:POP 0x50 将0x50弹出到指定寄存器中,sp向上移,也就是加一个地址大小的字节。POP操作后,战中的数据并没有被清空,只是此数据我们无法直接访问了(但可以通过其他手段访问)。

c语言函数调用时,需要开辟一个函数栈帧,用来存放被调用函数的局部变量,同时也会用来保存调用函数的之前的一个运行状态,当被调用函数调用结束的时候,就会从栈中将之前保存的运行状态恢复出来,也就是恢复寄存器的值。

栈帧是什么

下图为一个简单的栈帧模型:

栈溢出往往就发生在local variables(局部变量 的位置)。

栈帧本质也是一种栈,只不过这种栈是在函数调用时形成的,专门用于保存函数调用过程中的各种信息,如参数,返回地址,局部变量等;栈帧也由栈顶与栈底之分,高地址出处为栈底,低地址处为栈顶,SP一直指向栈顶,在X86中,ESP指向栈顶,EBP指向栈底,下图为一个栈帧示意图:

从图中不难看出,ebp到esp之间的区域当作栈帧,发生函数调用时就会生成一个新栈帧,栈帧并不唯一。在函数调用过程中,“调用者”需知道在哪儿获取"被调用者”的返回值,“被调用者”需要知道调用者传入的参数在哪儿,返回地址在哪儿。函数调用结束时,也就是被调用者返回后,ebp,esp等寄存器的值应该和调用前一致,因此,栈帧就应运而生。

函数调用:

举个函数调用的栗子:

int m(int x, int y,int z)
{
     int a,b,c;
      a=10;
      b=5;
      z=2;
     return ..;
}
int test
{
       m(1,2,3);
         .
         .
         .
}

当发生这个函数调用时,m函数的汇编代码大致如下:

     _m:
push ebp                //保存ebp的值
mov ebp,esp          //将esp的值赋给ebp,使新的ebp指向栈顶
sub esp, 0x12         //分配额外空间给本地变量
mov  qword ptr [ebp-4], 10     //对栈中的内存进行存值操作
mov  qword ptr [ebp-8],  5      //对栈中的内存进行存值操作
mov qword ptr  [ebp-12], 2     //对栈中的内存进行存值操作

看完汇编代码,来看看此时的栈对应的状态是什么:

发生函数调用时,调用者将传给被调用者的参数压入栈中,并将函数调用结束后的返回地址压入栈中,此时的栈属于调用者的栈帧;同时被调用者做了两件事:一将旧的(调用者的)ebp压入栈,此时esp指向栈里的ebp,二将esp的值赋给新的ebp,此时的ebp就有了新的值,也指向旧的ebp存放位置。此时就成了函数int m(int x,int y,int z)的栈底,这般,就保存了“调用者”的ebp,并且新建立了一个属于“被调用者”的栈帧;

在新的栈帧建立后,我们就可以为它申请空间(图中申请的大空间小为0x12)来存放本地变量了,该操作通过sub来实现,接着使用mov转移指令,配合字节数ptr[offset],便可以给a,b,c赋值了。如下图

函数的返回:

函数的返回与调用函数的过程正好相反,当函数返回时,会将esp移到ebp处,同时将局部变量都弹出栈外,然后弹出旧的ebp(也就是调用者的ebp)到ebp寄存器,这样ebp就恢复到最初的状态了。看下代码流程:

int  m(int x,int y,int z)
{
          int a ,b ,c;
           ....
          return  ...;
}

对应的汇编代码如下:

  _m:
       push ebp
       mov  ebp, esp
       ....
       mov  esp, ebp
       pop   ebp 
       ret

可以注意到汇编代码最后有一个ret指令,相当于pop + jmp 。作用时先将数据(返回地址)弹出栈并保存的eip寄存器中,这样调用函数(caller)的eip指令信息得以恢复,下图就是恢复如初的栈帧,之后就是继续执行调用函数(caller)的eip指令了。


重点来了!!!!!(敲黑板!!!!)

通过这个过程可以想到,eip的值完全是由栈中一个返回地址决定的,所以设想,我们若可以修改这个返回地址,那不就意味着我们可以控制整个程序的执行流,让程序执行我们想要执行的一些内容,而前边我也提到了栈溢出往往发生在被调用函数的局部变量的位置,而向局部变量里写入内容是从低地址到高地址写的,但是栈的生长是由高地址向低地址生长的,也就是说在局部变量里写入内容时若没有控制好输入长度时,是可以做到覆盖返回地址的,如下图:

先了解下缓冲区溢出:

栈溢出是属于缓冲区溢出的一部分,先看一下栈溢出的一个模型:
图的右侧黄框里有一个 overflow函数, 里面定义了一个char类型长度为8的一个变量,所以它的缓冲区长度就为8,read函数向buf缓冲区读入了长度为16的值,代码没问题,可以编译和运行,但是却造成的危险的漏洞,栈溢出。图中可以看出AAAABBBB填充满了buf位置,CCCC溢出到stack frame pointer 的位置并填充满,最后DDDD覆盖到了return address(返回地址)的位置。设想如果我们把输入的内容DDDD改为程序中已有的后门函数 ,比如system(bin/sh)这样的函数的一个地址,那就成功利用漏洞了。

举个简单的pwn栗子:

拿到elf文件先检查是32位还是64位的,再checksec检查都开启了哪些保护

然后拖进ida分析,先找main函数(也可以shift+f12在字符串中找敏感字眼)
记住gets函数是个危险函数,可以造成栈溢出,至于能不能利用此函数造成溢出,继续往下分析。点进v4
可以看出v4在栈中的内存长度是0x10,距离s(s就是ebp)0x10长度;r(返回地址),也就是要覆盖的目标;也就是说我们要输入一个0x10+8长度的字符就能覆盖到返回地址。用什么来覆盖呢,一般就是system这样的函数地址去覆盖,进而拿到shell。如下图:

编写EXP

拿到shell,攻击成功,可以ls读取信息列表。


穿过云层我试着努力向你奔跑