02 栈溢出介绍及初级栈溢出
栈溢出基础
C语言函数调用栈
函数调用栈
函数调用栈是指程序运行时侯,内存的一段连续的区域。用来保护函数运行时候的状态信息(函数参数,局部变量等)。
称之为“栈”的原因是因为发生函数调用时,调用函数_(caller)_的状态被保存在栈内,被调用函数(callee)的状态压入调用栈的栈顶。
在函数调用结束的时候,栈顶的被调用函数(callee)状态被弹出,栈顶恢复到调用函数(caller)的状态。
调用函数栈在内存中从高地址向低地址生长,所以栈顶对应内存地址在压栈时变小,退栈时候变大。

比如下面这个代码:
1 |
|
其中,str被保存在main的函数栈区中,当我调用printf的时候,str便会放入到printf函数的栈区中。此时printf就是被调用函数callee,而caller就是调用函数main。
栈帧结构
就像我上面说的,每次使用某个函数的时候就会创造相对于的“调用函数的状态”或者“被调用函数的状态”。而这个栈的结构是怎么样的呢:

简单介绍一下,这边我们的从_局部变量(Local variables)_到_栈帧指针(stack frame pointer)_的范围内都是我们的被调用寄存器,而返回地址上面(向高地址)的参数则是前一个函数的参数,在前面是上一个函数的栈帧指针。所以一般而言,我们只要看_局部变量(Local variables)_到_栈帧指针(stack frame pointer)_的范围就行了。
然后这张图是完全按照高地址到低地址(上到下)来呈现的。
函数状态寄存器
要表示一个函数此时此刻栈帧中的状态,主要涉及到三个寄存器:esp,ebp和eip。(在64位架构下这些指针应该叫做rsp,rbp和rip)(下边大部分时候都拿32位的称呼去做操作演示)
**EIP RIP:**全称叫做_指令指针(Instruction Pointer, IP)_,它用于存储吓一跳将被执行的指令在代码段中的偏移地址。CPU通过EIP寄存器来确定从内存中的哪一个位置来读取下一条指令。当我运行printf(“Helloworld”)的时候,eip会先指向指令mov rdi, [Helloworld字符串地址],然后再指向call printf这个指令。这两条指令构成了在某一函数内输出Helloworld的方式。
**ESP RSP:**全称叫做_栈指针(Extended Stack Pointer,SP,又叫做栈顶指针)_,栈遵循先进后出原则。ESP指针一般指向栈顶,当有值被压入栈的时候,栈顶指针就会根据被压入栈中参数的长短做偏移形成存有新值的ESP指针。简而言之,不管栈如何变化,ESP寄存器一般而言都会存有当前栈顶地址的状态。
EBP RBP:全称叫做_基址指针(Base Pointer,BP,又叫做栈底指针),_当栈被创建的时候,EBP就指向栈的最低端,此时EBP==ESP,随后当栈发生变化(有数据压入或者弹出),ESP在变化的时候,EBP还是存有栈底的地址,不会变化。简而言之,不管栈如何变化,EBP寄存器一般而言都会存有当前函数栈底地址的状态。
函数栈形成与状态
首先将被调用函数callee的参数按照逆序依次压入栈内。如果callee不需要参数,则没有这一步骤。这些参数仍会保存在caller的函数状态内。之后压入栈内的数据就会作为callee的函数状态保存,如下图所示:

将被调用函数(Callee)的参数压入栈中1
然后将调用函数(caller)进行调用之后的下一条指令地址作为返回地址压入返回地址压入栈内。这样caller的eip信息就得以保存了,如下图所示:

将被调用函数(Callee)的参数压入栈中2
再将当前的ebp寄存器的值(也就是caller的基地址)压入栈内,并将ebp寄存器的值更新位当前栈顶的地址。这样调用函数caller的ebp信息得以保存,同时,ebp被更新为callee的基地址,如下图所示:

将caller的ebp压入栈内,并将当前栈顶地址传入ebp中
在压栈的过程中,esp的寄存器的值不断变小(对应栈从内存高地址向低地址生长)。压入栈内的数据包括调用参数、返回地址、调用函数的及地址、局部变量这些参数的状态。其中调用参数以外的数据共同构成了被调用函数callee的状态。
在发生调用时候,程序还会将callee的指令地址存到eip寄存器内,这样程序就可以依次执行callee的指令了。
最后将被调用函数(callee)的局部变量压入栈内,如下图所示:

将caller的ebp压入栈内,并将当前栈顶地址传入ebp中
看过了函数调用发生时的情况,就不难理解函数调用结束时候的变化。变化的和性就是丢弃callee的状态,将栈顶恢复成caller的状态。首先callee的局部变量会先从栈内直接弹出,栈顶会指向callee的基地址。
然后将基地址的内储存调用函数caller的基地址从栈内弹出,并且存到ebp寄存器中,这样caller的ebp信息得以恢复。此时栈顶会指向返回地址。
最后将返回地址pop到eip中,随后esp再向上一格,回到caller的栈顶位置,ebp保持不变。如下图所示:

栈溢出(stack overflow)
栈溢出原理
讲完上面的基础知识,我们可以理解:当函数正在执行内部指令的过程中,我们无法拿到程序的控制权,只有在发生函数调用或者结束函数调用的时候,程序的控制权会在函数状态之间发生跳转,这时我们可以通过修改函数状态(修改返回地址)来实现攻击。而控制程序执行指令最关键的寄存器就是eip,所以我们的目标就是让rip载入攻击指令的地址。
:::info
缓冲区溢出(Buffer overflow)
编写程序的时候没有考虑到或者错误的控制用户输入的长度,本质就是向定长的缓冲区中写入了超长的数据,造成超出的数据覆写了合法内存区域。
栈溢出(Stack overflow):最常见、漏洞比例最高、危害最大的二进制漏洞。在CTF PWN中往往是漏洞利用的基础。
堆溢出(Heap overflow):关系到堆管理器系统,比较复杂,利用花样多。CTF PWN中的常见题型
Data段溢出:比如.bss段,修改关键变量,比较少见。攻击效果依赖于Data段上存放了何种控制数据。
:::
栈溢出的基本利用
如果说有这么一个函数(32位):
1 | int overflow(){ |
当我调用这个函数的时候,函数将初始化esp和ebp寄存器,且将eip指针指向这个代码中。而下面这张图的左边就是overflow的栈帧视图,右边则为我输入AAAABBBBCCCCDDDD之后栈帧中实际的值存储情况。

我们会发现当我们输入这么多东西之后,return address就被篡改成了’DDDD’所表示的地址,也就是0x44444444。而此时此刻也会出现一个问题:那就程序中没有0x44444444这个地址。
所以如果我们一旦把DDDD这一块的内容改成某个具体的地址化,实际上就会跳转到这个地址,并且运行这个地址里面的内容了。也就是——篡改栈帧上的返回地址,为程序中已有的函数。
学完上面的内容,我们可以配合一套例题来食用:overflow_ez_32
Canary_pie绕过
canary机制
canary原理
canary是一个用来防护栈溢出的保护机制。其原理是在一个函数的入口处,先从fs/gs寄存器中取出一个四字节或者八字节(取决于系统架构是32还是64)的值存到栈上去。当函数结束时会检查这个栈上的值是否和一开始存进去的值一样。程序每次运行的时候,canary的值都会不一样。对于我们而言,canary的值几乎是一个随机值。我们无法知道值是多少。
像我们上面举例的例子,再有canary的情况下就会变成这样:

如果像这样,我们将Canary篡改之后,就会触发`__Stack_chk_fail`这个函数,并且会报错。
不过就像上面所说,如果我们知道canary的值是多少,那么我们便可以在栈溢出的时候保留canary的值,且就可以成功溢出了。
:::info
说起来我也是无聊,当时学canary的时候,很好奇这个翻译过来叫做“金丝雀”的东西为什么会是一种保护机制。
这个名字非常有趣,他和我们的近代工业史有关——19世纪时候的煤矿工人下井挖矿的时候,会带着一只金丝雀。矿井里面会有一些无色无味的有毒气体,像是一氧化碳,人类要吸入一定剂量才会出现问题,而且那个时候已经逃不掉了。不过金丝雀这种小动物容易出现明显反应。矿工们通过观察金丝雀的状态,就能提前察觉危险,及时撤离。
还有一种说法是(我觉得两种都有),矿工下矿前会把金丝雀先拿绳子下放到矿井中,过一会儿再拿上来查看反应。这个就很像栈溢出的时候canary先放入栈中试探,等到函数运行结束的时候再拿上来对比值一样了。
canary保护顾名思义,和金丝雀一样脆弱。每当一次canary绕过失败的时候,你可以理解为计算机里面死了一只电子金丝雀(难崩)。
:::
canary绕过方法
如此,其实绕过canary的最重要的一步就是如何获取canary的值,canary绕过大概有以下几种绕过方式:
**1、格式化字符串绕过canary:**通过格式化字符串读取canary的值
**2、canary爆破(针对有fork的程序):**我在pwn及计算机原理基础知识这里说明了fork函数的具体作用。fork函数相当于自我复制,每一次复制出来的程序,内存布局都是一样的,当然canary的值也是一样的。所以可以通过这个机制堆canary逐位爆破,如果程序崩溃了就说明这一位不对。如果程序可以正常那就接着跑下一位,直到跑出正确的canary。
3、Stack samashing(故意触发**canary_spp leak**)
**4、劫持__stack_chk_fail:**修改got表中__stack_chk_fail函数的地址,在栈溢出后执行该函数,不过因为我们修改了__stack_chk_fail函数所指向的地址,程序运行__stack_chk_fail的时候就会跳转到我们当时修改的地址。
格式化字符串绕过canary
(我懒得画图了,这一块我就用Excel来画图演示,会比较方便)
有关格式化字符串的介绍可以去看暗线中的:01 格式化字符串,稍微学一点即可,现在全学完学不明白。
假设有一个%s这样的格式化字符串,如果我们把输入的内容和canary连在一起,那么在用%s这种格式化字符串输出的时候就会将canary一起打印出来。
需要注意的是,canary的最后一位一定是’\0’,用于放置连带输出,所以我们改的时候,要把canary的最后一位也改了(其实理解成“最前面一位是’\0’”会比较合适,因为这里的,所谓“最后一位”实际上是小端序)。

例题:
PIE机制原理
PIE技术是一个针对代码段(.text)、数据段(.data)、未初始化全局变量段(.bss)等固定地址的一个防护技术,如果程序开启了PIE保护,那么在每次加载程序时都会变换加载地址,从而不能通过ROPgadget等一些工具来帮助解题。
在一个开启PIE保护的程序中,所有代码段的地址都只有最后三个数字是已知的:

这些数字分别是这一行数据相对于基地址的偏移量。
还有一点需要强调的是,程序的加载地址一般都是以内存页为单位的,所以程序的基地址的最后三个数字一定是0,也就是说我们看到的这些所谓的**“偏移量”就是内存中实际地址的最后三位数**。
所以,虽然我不知道完整的地址,但我知道最后的三个数,那么我就可以利用栈上已有的地址,只修改他们的最后两个字节(最后四位数)即可。
所以对于绕过PIE保护的核心思想就是**partial writing(部分地址改写)**
例题:
所以这种方法,从某种意义上按照道理每次爆破都只有1/16的概率能进。
还有泄露基地址。










