65.9K
CodeProject 正在变化。 阅读更多。
Home

Linux 中基于 x86-64 位的基本缓冲区溢出

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2019年8月30日

CPOL

17分钟阅读

viewsIcon

34737

如果您正在研究白帽黑客技术,了解用于溢出的老式技巧很有帮助。

入门

随着内存随机化、金丝雀变量、ASLR 和 64 位地址(使得在 shellcode 中逃逸坏字节变得更加困难)的引入,32 位和老式堆栈缓冲区溢出的时代似乎已经过去。尽管如此,如果我们想在安全和道德黑客领域工作,我们就需要掌握一些曾经非常普遍的黑客技能。缓冲区溢出是最重要的技能之一,它将帮助您学会黑帽黑客的思维方式。在这种情况下,我们将深入探讨 64 位缓冲区溢出。

要抓捕罪犯,必须像罪犯一样思考。重要的是您对 C 或汇编有一定程度的掌握,或者愿意在线查找补充我在此教授内容的资源。

背景

我的背景涵盖从 ERP 开发到电子商务开发,随后我进入了计算机安全领域。一旦我学到了一些东西,我就被程序员代码中的微小漏洞可能给数百万甚至数十亿用户带来毁灭性后果的方式所吸引。

对内存及其工作原理的一些理解

现代计算机中的内存被分段成多个不同的区域。每个区域都有其特定的主要用途,有助于保持条理清晰。它们是:

  • 文本
  • Data
  • BSS
  • 堆(从较低的内存地址向下到较高的内存地址)
  • 堆栈(从较高的内存地址向上到较低的内存地址)

所有这些段都有特定的功能。例如:

  • 代码段 [固定大小,只读] 包含要执行的已编译和链接的汇编代码。
  • 数据段 [固定大小,预初始化,可写] 包含已初始化的静态和全局变量,例如 static int i = 9;
  • BSS 段 [包含占位符] 包含未初始化的 staticglobal 变量,例如 static int i;
  • 堆 [可变大小,可读,可写] 包含值也可变大小的变量。这部分内存使用 new()malloc() 分配。简单来说:这是运行时分配内存的内存控制结构。
  • 堆栈 [初始化时设置大小,可读,可写] 包含大小可变的变量。它们包含在操作系统设置的预分配堆栈边界内。

我们今天将要溢出的堆栈变量具有固定边界,这些边界由操作系统在程序启动后设置。

堆栈上的变量可以是传递给函数的参数,也可以是函数内部的变量。为了便于理解,每个变量都与一个“堆栈帧”或函数相关联。

从一个简单的 C 程序开始 (simplec.c)

您可能认为 C 语言是最安全的语言之一。然而,C 语言非常容易受到多种漏洞的攻击。让我们创建一个简单的可利用程序,该程序可能会被修改超出其正常功能。

我们将在程序中使用两个头文件。

//stdio.h for standard i/o functionality and string.h for string functions
#include <stdio.h>
#include <string.h>

这是完整的程序 simplec.c

#include <stdio.h>
#include <string.h>

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

正如您所见,我们创建了一个简单的程序,它只是使用 strcpy() 函数将传递给 argv[1] 数组的参数复制到 buffer[] 字符数组中。因此,似乎没有什么问题。然而,有一个部分被忽略了。

  • 在复制之前检查要复制到 buffer[] 中的值,其最大长度为 64 个 char 或约 64 个 byte,因为每个 char 是 1 个 byte,不能超过。

在复制前不检查长度的情况下。无论长度如何,传递给 argv[1] 的任何内容都将被复制到 buffer[]。现在我们都知道当我们试图将比容器能容纳的更大的东西塞进去时会发生什么。就像我们试图向一个装满水的玻璃杯里倒更多的水会溢出来一样。buffer[] 数组将溢出。在玻璃杯的情况下,水会溢到盛放玻璃杯的表面或其支撑结构上,同样,buffer[] 将溢出到同一堆栈帧中与其相邻的其他内存区域(溢出到 main() 中与其相邻的区域)。通过这样做,我们或许能够找到一些我们可以写入的内存区域,只需溢出到它们即可。这使得我们程序的执行甚至程序中的变量都可能发生变化。

这里有一些简单的图示,展示了当 argv[1] 是一个 97 字节的字符串时会发生什么,例如:

  • "Hi My name is terry malanovoic, I like to overflow buffers, well it's only 64 bytes so whatever."

第一个示例将描绘我们在复制之前对 argv[1] 进行长度检查时发生的情况,以便其适合缓冲区并截断任何多余的部分。

  • char buffer[64] 配合长度检查算法,或一个带有长度选项写入缓冲区的函数。
["Hi My name is terry malanovoic, I like to overflow buffers, wel"] [0xffffdc2a]

  • char buffer[64] 未使用长度检查算法。没有进行长度检查。argv[1] 按原样复制。
["Hi My name is terry malanovoic, I like to overflow buffers, wel"] 
["l it] ["'s o"] ["nly "] ["64 b"] ["ytes"] ...

正如您所见,当我们不检查 argv[1] 的长度就将其复制到 buffer[] 时,buffer[] 之后的内存将被覆盖。通常不建议使用 strcpy(),而是倾向于使用其他函数,例如 memcpy(),它接受长度参数,可以解决此问题。

开始我们的溢出(操作系统和编译)

要在 Linux 中创建我们的第一个溢出,让我们启动您选择的 Linux 发行版(建议使用 Live USB 或虚拟机中的 Linux 发行版)。

作为第一步,我们现在必须关闭内存随机化。内存随机化有助于程序保护自己免受缓冲区溢出或类似的基于内存的攻击。要手动关闭它,请将文件中的值从 2 改为 0(未来,深入了解 ASLR 或内存随机化是有益的)。

/proc/sys/kernel/randomize_va_space

我们现在可以使用以下命令编译之前的 C 程序。

gcc -g -fno-stack-protector -z execstack simplec.c -o simplec.out

我们在编译中使用了一些标志来帮助我们第一次利用该二进制文件。虽然有很多方法可以在没有这些标志的情况下完成此操作,但这次我们将使用它们,因为这是我们的第一次缓冲区溢出利用。通常,黑帽黑客无法获得这些标志提供的信息。例如 Libre Office 的最终副本在其编译过程中不会包含这些可能容易被滥用的标志。

以下是对我们使用的标志的简化解析:

  • -g 使用全局调试符号。它允许我们查看可执行文件的调试信息,例如文件名、行号、C 代码等。
  • -fno-stack-protector 移除可执行文件的堆栈保护。通常,如果在没有此选项的情况下在 C 中溢出缓冲区,将引发段错误异常,程序将终止。
  • -z execstack 告诉我们的编译器允许堆栈执行。出于安全原因,此选项通常是关闭的。启用此选项后,我们可以执行我们通过溢出利用写入堆栈的值。
  • -o 告诉我们的编译器二进制文件的输出文件。

开始我们的溢出(GDB 和汇编)

现在我们已经编译了程序,让我们开始调查程序的内存布局。为了调查内存,我们将使用 gnu 调试器,也称为 GDB。我们可以通过以下命令在 gdb 中打开我们的可执行文件。

gdb -q ./simplec.out

现在我们看到了 GDB 屏幕。

-q 标志用于在启动程序时不显示任何启动信息,例如作者、程序名称、版本等。它基本上表示“安静”。

我们现在可以设置我们的环境。请记住,有两种查看汇编的语法,通常默认是 AT&T。

  1. AT&T 汇编
  2. INTEL 汇编

在本教程中,我将使用 INTEL 语法。我们可以通过在 GDB 中使用以下行将 INTEL 汇编设置为首选语法。

set disassembly intel

如果希望每次启动 GDB 时都使用此语法作为默认设置,我们可以创建一个名为 .gdbinit 的配置文件到我们的主目录,并在其中写入上述行。

现在我们已经设置好环境,让我们开始调查。输入命令:

run

我们需要设置一些断点,以便能够停止执行流程并检查内存。为此,让我们查看程序代码,以便确定我们想要设置断点的任何行号。我们可以使用以下命令查看程序代码。

list

这是我们得到的结果。

在这里,我们可以看到带有行号的individual代码行。我们将使用行号来设置断点。我们也可以使用函数名或指令的内存地址来设置断点。要查看我们代码在汇编中的内存地址,请键入:

disass main

其中 main() 可以是您想要反汇编的任何函数(disass)。

我们现在可以看到 main() 函数的反汇编代码。我们可以在任何 text 段内存地址设置断点,例如 0x0000000000001164 处的 +47,这是 retq 指令。

函数内存和堆栈帧

函数的内存组织在所谓的堆栈帧中,它们一个接一个地压入堆栈,这有助于由于 FILO(先进后出)结构而保持上下文。传递给函数的变量在帧之前压入堆栈。这包括返回地址。返回地址允许我们返回到上一个堆栈帧。堆栈帧的组织方式如下(为了便于理解,我将使用以下代码来表示我们的堆栈帧)。

void do(int a, int b)
{
    //code
    return;
}

void main(int argc, char* argv[])
{
    do();
    return;
}

在这里,它表示为堆栈帧。

<< Lower memory addresses

Function do() @ 0x007fffffffc6
|-------------------------------| ← Start of frame for do(). Referenced to by $RSP
|	//function prologue	        |
|-------------------------------|
|	//code				        | ← Code
|-------------------------------|
|   int b                       |
|   int a                       |
|-------------------------------| ← End of frame reference to by the $RBP
|   return address which is the ‘next instruction’ line in main() 0x007fffffffc2 |
|--------------------------------------------------------------------|

Function main() @ 0x007fffffffc2
|-------------------------------| ← Start of frame for main(). Referenced to by $RSP
|	//function prologue	        |
|-------------------------------|
|	do() @ 0x007fffffffc6       | ← Code
|   next instruction            | ← Return address from do() returns here.
|-------------------------------|
|   char* argv[]                | ← Function arguments
|   int argc                    |
|-------------------------------| ← End of frame reference to by the $RBP
|   return address              |
|-------------------------------|

Higher memory addresses >>

您可能会注意到,变量是以先进后出的方式压入的(虽然读取方式不同)。这是由于堆栈的先进后出(FILO)工作方式。这有助于保持上下文。例如,调用 do() 的函数 main() 将在 do() 之前压入堆栈。请记住,堆栈从较高的内存地址向上增长到较低的内存地址,这意味着我们必须从堆栈中移除 do() 才能移除 main() 函数。

考虑到这一切。如果我们溢出了 buffer[],我们可以到达读取特定地址可执行代码的内存区域。正如您在前图中所见,所有变量都位于返回地址之上。返回地址似乎是指向某个内存地址以继续执行。如果我们操纵它指向一个我们控制的变量所在的内存地址,而不是它被设计成的地址,会怎么样?如果我们将在 buffer[] 中放入可执行代码,同时溢出 buffer[] 以便它指向 buffer[] 并读取其内容作为可执行代码,会怎么样?

正如我们在前一个堆栈帧图中所见,有一个名为 $RBP 或 so 的基址指针$RBP$RSP 是 CPU 使用的众多指针和寄存器之一,用于指向内存并包含用于数学运算等的值。$RBP$RSP 有助于保持上下文。例如,函数开始和结束的内存位置。您可以将它们视为分隔符,以便在函数调用期间保持上下文,并且不会执行堆栈帧之前或之后的内存地址。将其想象成一本书。有一个封面,$RSP 指向它,还有一个封底,$RBP 指向它。是的,我们需要知道我们目前在书的哪个位置。为此,有 $RIP指令指针。这是我们将要使用的 3 个 CPU 指针的基本列表。

  • $RBP = 基址指针/函数或 so 堆栈帧的结束位置
  • $RSP = 源指针/函数或 so 堆栈帧的开始位置
  • $RIP = 指令指针/即将执行的指令。

您可以通过键入以下命令随时查看这些寄存器的当前状态:

info registers

开始我们的溢出(调查)

现在我们已经掌握了一些汇编和 GDB 的概念,让我们开始执行我们的第一个缓冲区溢出利用。为此,让我们在执行期间设置一个断点。

让我们在第 7 行中断。中断执行将使我们能够在程序执行时检查寄存器、指针和内存。

break main

使用 run 命令运行程序。

run

现在让我们弄清楚 $RBP 的位置。返回地址通常位于该指针之后。为了简写,而不是键入 info registers,我们还可以键入 i r,然后是我们要查看的指针或寄存器,例如 $RBP

i r $rbp

我们现在可以看到 $RBP 位于 0x7fffffffde80 - 这意味着我们的返回地址就在它之后!太好了!但我们如何看到它呢?毕竟,返回地址没有名称。这时Examine就派上用场了。Examine 命令允许我们查看不同位置的内存,数量不限。假设 $RBP 是我们的起点,我们告诉Examine命令查看 $RBP 之后的 20 个地址。Examine 命令将显示 $RBP 之后的 20 个地址,包括 $RBP

x/20xg $rbp

0x7fffffffde80 的这一行,我们看到第二个值 0x00007ffff7dea09b。这是我们的返回地址。buffer[] 将用于用我们自定义的指向我们可执行代码的地址覆盖它。问题是,如果我们能用 buffer[] 覆盖返回地址,我们不能用 buffer[] 存储恶意代码吗?

让我们先尝试覆盖我们的返回地址。让它指向 buffer[] 的地址,它将包含我们的恶意代码或 so 'shellcode'。我们可以通过确定 $RBPbuffer 之间的距离来弄清楚返回地址的距离。buffer[] 在堆栈中的 $RBP 之上。

print $rbp - buffer

$RBPbuffer[] 之间的差值为 64 字节。让我们用一个 64 字节长的参数运行程序,并在第 8 行设置一个断点。这将确保 strpcy() 被执行,buffer[] 被复制到其中,以便我们可以看到我们努力的成果。如果您的程序仍在执行,您可以使用以下命令继续执行并允许程序结束。

cont

您可以运行不带引号的程序参数,或者使用任何长度为 64 个字符的参数(考虑到 char 是 1 字节)。我们现在可以检查 20 个地址,但首先让我们将 buffer[] 作为 string 检查。

我们现在可以看到 buffer[] 包含我们的 string,让我们继续以十六进制格式检查地址。

$RBP 位于 0x7fffffffde30。是的,内存会在不同的执行过程中发生变化,尤其是在您提供新的参数值或重新编译程序时。内存非常灵活,但关闭内存随机化有助于我们在程序执行之间大致保持它们的位置。

我们可以看到各种值。请记住,两个值,例如 0x7f,是 1 字节!您在 GDB 中看到的所有内容都必须向后读取,因此 0x3837363534333231 必须向后读取为 31323343536373933x0,在 Unicode 中,31 和 32 等同于 1 和 2,就像我们在提供的参数 string 中的 1 和 2 一样。下一个向后读取的地址是 39302d3d71776572x0,其中前两个字符对应于 "90-=",正如您在参数 string 中看到的,它们是第 9、10、11 和 12 个字符。

您如何读取内存地址取决于 字节序。在此情况下,我们使用的是 小端序。这取决于您的硬件制造商、协议,或者在这种情况下,取决于您的处理器架构。

开始我们的溢出(执行)

我们之前提到过一个名为“shellcode”的特殊元素,这是我们可以写入内存地址(如 buffer[])的机器代码,然后通过更改函数的返回地址将其指向 buffer[] 来执行它,而不是其原始目的。如果我们更改返回地址为 buffer[] 的开头,我们将能够将 buffer[] 中的任何机器代码作为正常执行指令读取。要编写我们的 shellcode,我们将使用两个特定的指令:

  • 0x90
  • 0xcc

0x90 代表 NOP 或“无操作”指令,不执行任何操作,执行会继续到下一个内存地址。0xcc 被称为硬断点,它会在退出时导致程序停止并产生 SIGTRAP 异常。虽然 shellcode 允许我们做很多事情,例如生成 root 命令 shell(应用程序需要启用 SetUID)。我们使用 0xcc 作为我们的 shellcode 命令,因为它是一个最简单的单字节指令,可以向您展示缓冲区溢出是如何工作的。正如我们所知,内存地址有时会改变。为了缓解这种情况,我们必须创建一个称为 NOP sled 的东西。

NOP sled 允许我们围绕我们的执行指令 0xcc 创建 NOP 或 0x90 的填充。$RIP 将会遍历所有 NOP,最终命中 0xcc 指令。这比找到 0xcc 的确切地址要容易得多,以防内存布局发生变化。

我们将使用 Perl 语言将我们的 shellcode 输出为一个参数,该参数将在执行 run 命令时被处理并发送到程序。它使得创建 NOP sled 更加容易。同样的操作也可以在任何其他语言(如 Python 或 C)中完成。

run "$(perl -e 'print "\x90" x 31 . "\xcc" . 
"\x90" x 40 . "\xfa\xdd\xff\xff\xff\x7f"')"

请注意,到达 $RBP 所需的总字节数为 64,但为了覆盖 $RBP 并到达返回地址,我们必须加上 6 个字节,即 $RBP 中存储的字节数。00s 不是必需的,因为 buffer[] 本身的地址包含尾随零,因此由六个字节表示。

但这一切意味着什么?$() 代表执行。您也可以使用 `` 来实现相同的功能。$() 中的命令将首先执行,并作为 simplec.out 的第一个参数发送。-e 标志告诉 Perl 执行后续单引号中的命令。然后我们通过打印 NOP 字节(在我们的 shellcode 之前)来打印一个 NOP sled,正如我们之前讨论过的,它是 \xcc。由于我们使用的是 小端序。所有字节都向后写入。然后我们继续写入另一个 NOP sled,尽管这并不是必需的,除非我们有一个额外的 shellcode 在我们的第一个之后。在双引号中的最终 string 中,是我们想要覆盖返回地址的地址,指向 buffer[] 数组中的某个地址,这将开始执行我们的 NOP sled,并最终执行我们的 shellcode。我们可以将返回地址指向哪里以获得正面命中?0x7fffffffddfa 距离 buffer[] 的开始只有几个字节,这确保了即使 buffer[] 移动(除非发生剧烈变化),我们的 shellcode 也会被执行。让我们执行新的程序参数。

正如您所见,当调用 main() 函数的 $RBP 之后的返回地址时,程序以 SIGTRAP 结束。当 $RIP 离开函数并返回到堆栈中的前一个函数时,就会调用返回地址。在这种情况下,$RIP 将返回到系统。这意味着我们的 shellcode 已成功执行。如果您收到段错误,则意味着您的参数 string 中的某个步骤不正确。有时,这可能仅仅是由于内存地址的变化。

结论

缓冲区溢出是最基本的利用技术之一。尽管它们已逐渐变得不那么普遍,但它们仍然广泛存在。特别是当程序员不注意他们输出的代码时。通过使用这种技术,我们可以读取任何内存地址并执行它,只需将其写入程序中某个函数的返回地址。

免责声明

本教程中的所有信息都必须用于白帽黑客活动并遵守法律。请勿使用此材料违法。仅在您拥有书面或记录在案的许可的机器上进行此类利用。

© . All rights reserved.