0x01 栈溢出基本原理

栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。此外,我们也不难发现,发生栈溢出的基本前提是:

  • 程序必须向栈上写入数据。
  • 写入的数据大小没有被良好地控制。

当函数正在执行内部指令的过程中我们无法拿到程序的控制权,只有在发生函数调用或者结束函数调用时,程序的控制权会在函数状态之间发生跳转,这时才可以通过修改函数状态来实现攻击。而控制程序执行指令最关键的寄存器就是 EIP,所以栈溢出利用就是让 EIP载入攻击指令的地址。

1、函数调用结束时

先来看看函数调用结束时,如果要让 EIP指向攻击指令,需要哪些准备。

首先,在Pop过程中,返回地址会被传给 EIP,所以我们只需要让溢出数据用攻击指令的地址来覆盖返回地址就可以了。其次,我们可以在溢出数据内包含一段攻击指令,也可以在内存其他位置寻找可用的攻击指令。

2、函数调用开始时

再来看看函数调用开始时,如果要让 EIP指向攻击指令,需要哪些准备。

这时,EIP会指向原程序中某个指定的函数,我们没法通过改写返回地址来控制了,不过我们可以“偷梁换柱”——将原本指定的函数在调用时替换为其他函数。

0x02 Demo

编写一段简单的调用gets()函数代码,主要目的读取一个字符串,并将其输出,可明显知道是存在栈溢出漏洞的,我们可以控制程序执行pwn()函数实现栈溢出效果测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <string.h>
void pwn() { puts("Stack Overflow! Hacked By Mi1k7ea."); }
void vulnerable() {
char s[12];
gets(s);
puts(s);
return;
}
int main(int argc, char **argv) {
vulnerable();
return 0;
}

编译该c文件:gcc -m32 -fno-stack-protector stack_test.c -o stack_test

gcc 编译指令中,-m32 指的是生成 32 位程序; -fno-stack-protector 指的是不开启堆栈溢出保护,即不生成 canary。 此外,为了更加方便地介绍栈溢出的基本利用方式,这里还需要关闭 PIE(Position Independent Executable),避免加载基址被打乱。不同 gcc 版本对于 PIE 的默认配置不同,我们可以使用命令gcc -v查看 gcc 默认的开关情况。如果含有--enable-default-pie参数则代表 PIE 默认已开启,需要在编译指令中添加参数-no-pie

可以看出,gcc警告说gets()是一个危险函数,它从不检查输入字符串的长度,而是以回车来判断输入是否结束,导致栈溢出漏洞存在。

下面就来简单描述一下栈溢出攻击利用的步骤吧。

1、运行程序,了解功能

程序功能很简单,输入什么输出什么:

2、file查看elf文件是否动态链接

可以看到,该ELF文件是动态链接的:

为什么要多做这可能没用的一步呢?因为后面会让我们提前知道这题到底会不会设计到libc相关的内容,若为静态文件则无需这方面的考虑。

3、checksec检查安全编译选项

可以看到,只开启了NX,和GCC编译时的选项是一致的:

4、使用IDA进行静态汇编代码分析

使用IDA打开该ELF文件,F5,点击vulnerable()函数,关注到其中的gets()函数是存在栈溢出漏洞的,而它的s参数在其上方定义了,关注到IDA给出变量s相对于ESP和EBP的偏移量分别为ESP+4h和EBP-14h:

由此知道,gets()导致存在栈溢出漏洞,而其参数s距离EBP的偏移地址为14h。

同时,点击查看我们需要溢出至调用的目标函数pwn(),记下它的地址0x0804843B:

5、计算偏移地址

构造payload之前,需要计算能够控制的内存的起始地址距离main()函数的返回地址的字节数。这里为计算变量s距离main()函数的返回地址的字节数。

由函数调用栈可知,要计算能够控制的内存的起始地址距离main()函数的返回地址的字节数,都是先通过计算出能够控制的内存的起始地址距离EBP的字节数,再加上4即可,看下图便一目了然:

其实由第四步的IDA分析可以知道,参数s距离EBP的偏移地址为14h。

但是有时候并不能完全相信IDA计算出来的偏移,最为准确的是用GDB打断点调试出来,下面介绍两种GDB方法。

(1)GDB断点调试获取

先用IDA查看gets()函数的地址,获取到其地址为0x08048461:

在GDB中在该地址打下断点并运行,看到程序在调用gets()函数前停下,这时看到EBP为0xffffd028、ESP为0xffffd000:

由前面可知,变量s相对于ESP和EBP的偏移量分别为ESP+4h和EBP-14h,这里只看其距离EBP的距离,计算出s的地址为0xffffd028-14h=0xffffd014。

为了验证一下本次IDA计算出的偏移值是否准确,我们接着在gets()函数的下一条指令地址处即0x08048466中打下断点,再c继续往下运行,要求输入字符串,这里输入“hello”之后程序就停止在断点处:

可以看到,存放“hello”的内存地址即s的地址为0xffffd014,且ESP下一次会指向该地址。该地址和使用IDA给出的偏移量计算出来的结果一致,即这次的IDA计算结果可信。

由此可知,s的地址为0xffffd014,则s距离EBP偏移量为14h,则s与函数返回地址的偏移为14h+4=18h。

(2)使用GDB pattern字符串溢出计算偏移量

GDB的pattern_create创建计算溢出偏移量的字符串,在输入内容时输入即可:

记下此刻的EIP值,即0x44414128或字符“(AAD”,再输入pattern_offset中计算出偏移值:

得出s与函数返回地址的偏移为24,即18h=14h+4。

6、编写payload

在下面payload中,前面14h个字节码用“a”覆盖,将EBP覆盖为“bbbb”,最后插入小端存储形式的pwn()函数地址:

1
2
3
4
5
6
7
8
from pwn import *

sh = process("./stack_test")
pwn_addr = 0x0804843B
payload = flat(["a" * 0x14, "bbbb", p32(pwn_addr)])
# payload = flat(["a" * 0x14, p32(1), p32(pwn_addr)])
sh.sendline(payload)
print sh.recvall()

运行payload,直接栈溢出执行了pwn()函数:

整个栈溢出的漏洞发现及利用过程大致如此,当然还有其他的一些技巧这里还未涉及。

0x03 栈溢出步骤小结

Demo是具体细化的步骤,总体而言主要分为两个步骤,先是找到危险函数确定存在栈溢出漏洞,然后就是通过调试分析计算出栈溢出攻击利用需要溢出的偏移量,最后就可以顺利地写exp进行利用。

寻找危险函数

通过寻找危险函数,我们快速确定程序是否可能有栈溢出,以及有的话,栈溢出的位置在哪里。常见的危险函数如下

  • 输入
    • gets,直接读取一行,忽略’\x00’
    • scanf
    • vscanf
  • 输出
    • sprintf
  • 字符串
    • strcpy,字符串复制,遇到’\x00’停止
    • strcat,字符串拼接,遇到’\x00’停止
    • bcopy

确定填充长度

这一部分主要是计算我们所要操作的地址与我们所要覆盖的地址的距离。常见的操作方法就是打开 IDA,根据其给定的地址计算偏移。一般变量会有以下几种索引模式:

  • 相对于栈基地址的的索引,可以直接通过查看 EBP 相对偏移获得
  • 相对应栈顶指针的索引,一般需要进行调试,之后还是会转换到第一种类型。
  • 直接地址索引,就相当于直接给定了地址。

一般来说,我们会有如下的覆盖需求:

  • 覆盖函数返回地址,这时候就是直接看 EBP 即可。
  • 覆盖栈上某个变量的内容,这时候就需要更加精细的计算了。
  • 覆盖 bss 段某个变量的内容
  • 根据现实执行情况,覆盖特定的变量或地址的内容。

之所以我们想要覆盖某个地址,是因为我们想通过覆盖地址的方法来直接或者间接地控制程序执行流程

0x04 参考

栈溢出原理

手把手教你栈溢出从入门到放弃(上)