这是我的学期论文的 HTML 版本,可在此处下载 PDF 此处。
模糊测试已成为发现软件漏洞的“最有效方法之一”。许多当前与模糊测试相关的论文都以这种或类似的论断开篇 [google-scholar]。 我们上一篇关于“易受攻击的物联网”主题的学期论文的主要目标是找到一个内存相关漏洞,然后为这个漏洞编写利用程序。我们通过逆向固件成功找到了一个漏洞,但没有发现内存相关的漏洞。手动逆向二进制文件来发现缓冲区溢出不仅耗时,而且需要大量经验。与此同时,模糊测试正是为了成为发现此类内存相关漏洞的“最有效方式”。例如,谷歌推出了 OSS-Fuzz,它持续对开源软件进行模糊测试,并已在 1000 个项目中发现了超过 10,000 个漏洞 [oss-fuzz]。
这篇学期论文的目标同样是发现一个内存相关漏洞,但这次是通过模糊测试。目标漏洞应当能够在不知道管理员凭证的情况下通过网络被利用。本文描述了实现这一目标的方法。为此,本文分为两个部分。第一部分重点介绍如何找到一个有力的目标、可以使用哪些工具,以及一个好的模糊测试目标应该具备什么条件。第二部分则描述如何开发和调试一个能够对二进制中特定函数进行模糊测试的测试驱动程序。然后使用开发出的测试驱动程序,通过 AFL++ 对目标函数进行模糊测试。接下来,简要介绍相关背景以及物联网设备模糊测试的当前技术水平。
在本学期论文范围内创建的所有文件也已在 GitHub 上完整发布,可通过以下 URL 访问: otsmr/blackbox-fuzzing。
对物联网设备进行模糊测试并不像对开源项目进行模糊测试那样容易。源代码通常是专有的,这使得为了获得最佳模糊测试性能而对源代码进行插桩的灰盒模糊测试无法实现 [afl-persistent]。 此外,CPU 架构往往不被模糊器原生支持,这需要使用像 QEMU 这样的模拟器 [qemu],这也会降低模糊测试速度 [afl-persistent]。 另一个问题是硬件外设,这使得通用方法的开发变得复杂。论文《嵌入式模糊测试:挑战、工具与解决方案综述》 [embedded-fuzzing] 概述了不同的模糊测试策略,例如基于硬件的嵌入式模糊测试。这些策略中的大多数都需要目标程序的源代码,例如在将 AFL 等模糊器的源码移植到基于 ARM 的物联网设备上以在物联网硬件上运行模糊器时。在设备硬件上运行模糊器也存在性能问题,因为这些设备通常配备低端 CPU,比普通台式机 CPU 更慢。该论文提出的另一种方法是基于模拟的嵌入式模糊测试,即在模拟器中执行单个目标程序以进行覆盖率引导的模糊测试,或者模拟整个系统。
上述方法都通过使用模拟器或对源代码进行插桩来直接针对二进制文件。这些方法需要通常必须为单个物联网设备专门定制的模糊测试环境,并且难以通用化。为此,研究人员创建了一个名为 IoTFuzzer 的程序,旨在成为一个自动化模糊测试框架,目标是“在无法访问固件镜像的情况下发现内存损坏漏洞
[iotfuzzer]。” IoTFuzzer 基于这样一个观察:大多数物联网设备都有一个用于控制它们的移动应用程序,而这些应用程序包含有关与设备通信所用协议的信息。然后,该程序识别并重用特定于程序的逻辑来变异测试用例,以有效测试物联网目标
[iotfuzzer]。
测试驱动程序(harness)描述了一系列处理模糊器提供输入的 API 调用。与通常不需要测试驱动程序的普通应用程序相反,一个实现可重用函数的库必须使用正确的参数并按正确的顺序调用,以便维持多个共享函数调用之间的状态。如果不构建状态机而对库进行随机模糊测试,不太可能成功,反而会在库依赖关系未得到强制执行时产生大量误报崩溃。例如,当模糊器跳过了缓冲区大小检查,导致出现虚假的缓冲区溢出时,就可能发生这种情况。
在本文中,我们将对普通应用程序进行模糊测试,但由于使用套接字和多线程所带来的硬件依赖,我们也需要为它们创建测试驱动程序。该测试驱动程序在二进制的上下文中加载,并能调用目标程序的内部函数,如 代码 10 所示。
术语“corpus”(语料库)描述的是有效输入样本或测试用例,并作为在模糊测试过程中生成新输入数据的基础参考。在 代码 10 中,例如,它可以是一个 HTTP 请求。然后,模糊器利用该语料库生成变异或多样化的测试用例,通过探索各种输入场景来帮助检测软件漏洞。
黑盒模糊测试中最耗时的部分是查找固件中潜在的可漏洞函数。第一步是找到有趣的二进制文件,例如,可通过网络访问、使用不安全函数或未启用栈金丝雀(stack canary)等安全功能的二进制文件——栈金丝雀是缓冲区溢出保护机制。我们之前的论文
([iovt])
已经描述了如何从目标路由器中提取固件以及如何找到潜在危险的二进制文件。为此,使用了 EMBA 工具 [emba]。EMBA 根据固件中发现的二进制文件的不安全函数(如 strcpy)数量、网络访问情况以及栈金丝雀或 NX 位等安全保护对它们进行排序;在利用缓冲区溢出时这些信息会变得很有价值,如 代码 1 所示。
代码 1:不安全使用函数 strcpy 的 EMBAs 结果。
由于本文的目标是找到一种可以在不知道管理员凭据的情况下通过网络利用的内存漏洞,因此 易受攻击的函数必须能够通过网络调用,并应直接与所提供的用户输入交互。但具有网络交互性 并不意味着该二进制文件也可以直接通过网络访问。为了确定哪些二进制文件正在监听, 我们可以使用已在 [iovt] 中建立的 UART 根 shell。
```txt ~ # netstat -tulpn Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 127.0.0.1:20002 0.0.0.0:* LISTEN 1045/tmpd tcp 0 0 0.0.0.0:1900 0.0.0.0:* LISTEN 1034/upnpd tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 1027/httpd tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1224/dropbear udp 0 0 0.0.0.0:20002 0.0.0.0:* 1048/tdpd [...] ```代码 2:使用 UART root shell 执行 netstat
第一个看起来有希望的二进制是 wscd。该二进制包含最多不安全的 strcpy 调用(除了 libcmm.so 库之外)以及网络交互,就 wscd 而言,这意味着它会连接到一个 UPnP 设备,并且不监听特定端口。如下文所示,它有一个易于模糊测试的函数,这就是为什么本文选择该二进制作为示例来解释一般流程。在逆向之前,我们可以使用 UART root shell 来确认该二进制是否正在运行以及它是如何启动的。
代码3:使用ps命令显示所有正在运行的程序。
通过ps,我们不仅可以看到二进制文件正在运行,还可以看到其参数,这些参数
对于验证某个潜在功能是否被调用非常重要。这些参数的含义可以
从CLI帮助中获取,该帮助在不带任何参数调用二进制文件时会显示。
代码 4:二进制文件 wscd 的选项。
如 代码 4 所示,wscd 以“Enabled UPnP Device service”启动,这看起来
很有希望。确认该二进制文件确实在路由器上运行后,就可以
使用 Ghidra 分析该二进制文件,以搜索可疑函数。对于模糊测试,
解析函数尤其值得关注,因为它们通常很复杂,而且被解析的输入
往往带有长度字段来标识所包含的数据,就像 TCP 数据包包含有效载荷的
长度一样。

图 1:使用 Ghidra 搜索解析函数。
解析函数的一个好处是,它们通常不与其他部分的代码交互, 也不通过网络与用户交互。因此,解析函数可以直接用输入调用, 而无需修改二进制文件或覆盖其他函数,因此该函数可以被 模糊测试。
在开始对函数进行模糊测试之前,应检查该函数是否真的会被触发,
因为只有当函数被用户控制的输入调用时,它才值得关注。为此,
可以使用 Ghidra 搜索对目标函数的引用。以
parser_parse 函数为例,有多个途径。由于我们知道程序的启动方式,
可以将调用缩减为单一的函数调用树,如 代码 5 所示。
代码 5:函数 parser_parse 的调用树
找到目标函数后,我们现在可以创建一个模糊测试环境来对该函数进行模糊测试,这将在下一部分中描述。但首先介绍其他潜在函数。
为了撰写本文,我们手动分析了多个潜在二进制文件以寻找可疑函数。以下是发现的其他可能目标函数的简要总结。
二进制文件 httpd 是管理员 Web 界面的后端。该二进制文件可通过网络在 80 端口访问。在 httpd 中,一个有趣的函数是 httpd_parser_main 函数。使用 Ghidra 浏览解析器实现时,可以识别出几个不同的可疑代码部分。其中一个可疑部分是 Content-Type 的解析。下面是一个基本的 HTTP 请求。```txt
POST / HTTP/1.1\r\n
Content-Type: multipart/form-data; boundary=X;\r\n
Host: example.com\r\n
\r\n
\r\n
DATA\r\n
以下是 `httpd_parser_main` 函数的一个片段,该函数从用户提供的
http 请求中解析 `Content-Type`。
<div id="c6"></div>```c
// user_input_ptr points to
// "Content-Type: multipart/form-data; boundary=X;\r\nHost: example.com\r\n..."
cursor = strstr(user_input_ptr,"multipart/form-data");
if (user_input_ptr == cursor) {
cursor = strstr(user_input_ptr,"boundary=");
user_input_ptr = cursor + 9;
// user_input_ptr points now to "X;\r\nHost: example.com\r\n..."
if (cursor != (char *)0x0) {
do {
while (cursor = user_input_ptr, *cursor == " ") {
user_input_ptr = cursor + 1;
}
user_input_ptr = cursor + 1;
} while (*cursor == "\t");
// cursor points now to "X;\r\nHost: example.com\r\n..."
// strchr returns a pointer to the first occurrence of ";" in the user request.
// If ";" is not found, the function returns a null pointer.
user_input_ptr = strchr(cursor, ";");
if (user_input_ptr != (char *)0x0) {
// The character ";" is replaced by an null byte to terminate the string
*user_input_ptr = "\0";
// cursor points now to "X\0\r\nHost: example.com\r\n..."
}
// DAT_00444050 global array from 0x00444050 to 0x0044414f (255 Bytes)
strcpy(&DAT_00444050, cursor);
// DAT_00444050 contains now "X"
}
}
代码 6:函数 parser_parse 的调用树
此代码中的漏洞在于 strcpy 函数调用以及以下假设:
Content-Type 以分号结尾。由于 strcpy 会复制缓冲区直到下一个空字节,
并且如 代码 6 所示,只有在找到分号时才会添加空字节。通过移除
分号,下一个空字节将位于输入缓冲区的末尾,例如 HTTP 请求的末尾。
因此,全局变量 DAT_00444050 可能被溢出,进而覆盖地址
0x0044414f 之后的数据。难点不仅在于找到该地址之后
可被覆盖的有价值全局变量,还在于由于 strcpy 的原因不能使用任何空字节。但
既然存在这样一个错误,很可能还有其他错误可以发掘。
二进制文件 tdpd 被移动应用使用,并且可通过本地网络上的 UDP 访问。
tdpd 与 tmpd 具有几乎相同的函数,但这些函数大多从未被调用。其主
函数仅在 UDP 端口上监听消息,并总是返回基本信息
关于路由器,例如名称或型号。它几乎不处理用户提供的
输入,因此对此进行模糊测试没有意义。
另一对值得关注的二进制文件是 upnpd 和 ushare。这两个二进制文件都处理 UPnP
消息,因此需要解析 XML。 由于在二进制文件中可以找到版权字符串,
因此可以推测这些程序并非由 TP-Link 开发。```sh
$ strings usr/bin/ushare | grep "(C)"
Benjamin Zores (C) 2005-2007, for GeeXboX Team.
两个二进制文件都加载了共享库 `libupnp.so` 和 `libixml.so`,它们与
开源项目 `pupnp` [\[pupnp\]](https://github.com/pupnp/pupnp/) 具有相同的函数。由于
本文的重点是黑盒模糊测试,因此这些二进制文件被忽略。但对这个库进行灰盒模糊测试
可能具有潜力,因为在 2021 年,`libixml.so` 中发现了一个内存泄漏
[\[pupnp-mem-leak\]](https://github.com/pupnp/pupnp/issues/249)。
二进制文件 **tmpd** 是移动应用的后端。有趣的是,路由器和
移动应用通过自定义二进制协议进行通信。下面显示了从
客户端发送到服务器的一条消息。
<div id="c7"></div>```txt
00000000 01 00 05 00 00 08 00 00 00 00 00 17 50 7b 6e fe |............P{n.|
00000010 01 01 02 00 00 00 00 00 |........ |
代码 7:从移动应用发送到路由器的消息。
为了理解二进制协议,我们使用 Ghidra 对二进制文件 tmpd 进行了逆向分析。据此,代码 7 中的消息可以分解为以下内容:```txt
01 00 05 00 : Version
00 08 00 00 : Size (8 Bytes)
00 00 00 17 : Datatype
50 7b 6e fe : Checksum (CRC32)
01 01 : Options
02 00 : Function id
00 00 00 00 : Function parameters
<p class="text-align: center">代码 8:自定义二进制协议分解。</p>
这看起来很有前景,因为这类二进制协议必须被解析。但二进制协议中最可疑的部分是
不是长度字段,而是函数 ID 和函数参数的使用。
<figure id="f2">
<p><img src="https://assets.kitploit.com/production/public/readmes/48851/43cb089d7f85d30ba036234ff0fdb29b6a54dd4a3382b2bd68e065f9f6c75369.png" style="width:90.0%" /></p>
<figcaption>
<p style="text-align: center">图 2:来自 tmpd 的逆向函数,它解析函数 ID 及其参数。</p>
</figcaption>
</figure>
[图 2](#f2) 展示了自定义协议的反编译解析函数的一部分。在第 16 行,
函数 ID 被提取,然后在第 29 行调用相应的函数。可疑的
行为是,该函数使用未经任何检查便从用户控制的输入缓冲区中提取的
参数进行调用。我们现在可以尝试在
[图 3](#f3) 所示的跳转表中找到可能存在这种危险性的函数,比如当参数被用于索引缓冲区
或被解释为字符串时。与其手动逆向并搜索这超过 100 个函数,
这会非常耗时,而我们可以使用模糊测试器自动完成这项工作。
<figure id="f3">
<p><img src="https://assets.kitploit.com/production/public/readmes/48851/708cf59aee459b840ca6dbcac32948d2b80fb426c3d4cc745157eac4f9b7c28e.png"
style="width:90.0%" /></p>
<figcaption><p style="text-align: center">图 3:来自 tmpd 的逆向函数,它解析函数 ID
及其参数。</p></figcaption>
</figure>
不幸的是,`tmpd` 二进制文件仅能通过网络在本地访问,如[代码
2](#c2)所示。要连接到此二进制文件,应用首先通过 SSH 以
`direct-tcpip` 模式连接到路由器,该模式只是将数据包转发到本地进程。而 SSH 连接受
管理员凭据保护。但正如
[\[iovt\]](https://raw.githubusercontent.com/otsmr/internet-of-vulnerable-things/main/Internet_of_Vulnerable_Things.pdf)
中所述,SSH 连接很容易被破坏,因为服务器主机密钥从未被应用
检查。通过丢弃所有路由到互联网的数据包,管理员可能会被诱骗登录到
路由器,同时执行中间人攻击以窃取凭据。
## 使用 AFL++ 和 QEMU 进行模糊测试
在本节中,我们将针对先前发现的一个函数开发一个测试程序。在
测试程序开发完成后,使用最先进的模糊测试器 AFL++
[\[aflpp\]](https://github.com/AFLplusplus/AFLplusplus) 对目标函数进行模糊测试。因为
二进制文件是为 `mipsel` 架构编译的,因此使用模拟器 QEMU 来执行该
二进制文件。本文使用的基本模糊测试设置主要受 Adam Van Prooyen 的博客文章“Firmware
Fuzzing 101”[\[b101\]](https://www.mayhem.security/blog/firmware-fuzzing-101) 的启发。
### 模糊测试环境
为了轻松创建一个可复现的模糊测试环境,Docker 是最佳选择。我们创建了一个
Dockerfile,用于安装所有必要工具,例如适用于 `mipsel` CPU 架构的交叉编译器,
或者可用于调试测试程序的 `gdb-multiarch`。
此外,AFLplusplus 被下载并与 QEMU 一起编译;QEMU 构建为
经过少量调整的版本,以允许未经插桩的二进制文件在 afl-fuzz 下运行。```docker
FROM debian:latest
RUN apt update && apt install -y \
curl \
vim \
gcc-mipsel-linux-gnu \
openssh-server \
qemu-user-static \
gdb-multiarch
# Qemu statics are installed at /usr/bin/qemu-mipsel-static
# Compiling AFL++
RUN apt install -y git make build-essential clang ninja-build pkg-config libglib2.0-dev libpixman-1-dev
RUN git clone https://github.com/AFLplusplus/AFLplusplus /AFLplusplus
WORKDIR /AFLplusplus
RUN make all
WORKDIR /AFLplusplus/qemu_mode
RUN CPU_TARGET=mipsel ./build_qemu_support.sh
RUN echo "#!/bin/bash\n\nsleep infinity" >> /entry.sh
RUN chmod +x /entry.sh
WORKDIR /share
ENTRYPOINT [ "/entry.sh" ]
用于安装必要工具的 Dockerfile。
然后可以使用 docker build 构建镜像。```sh
docker build -t fuzz .
当镜像构建完成后,可以通过 `docker run` 轻松使用它,随后会启动容器。```sh
docker run -d --rm -v $PWD/:/share --name fuzz fuzz
使用 -d 选项将在后台启动容器。借助 docker exec 可以在容器内启动多个 shell,
这有助于在一个会话中使用 QEMU 启动可执行文件,
并在另一个会话中运行 gdb-multiarch。```sh
docker exec -it fuzz /bin/bash
### 覆盖 main 函数
在上一节中,我们确定了一个强大的模糊测试目标。问题在于,当执行二进制文件时,我们永远无法到达该函数调用,因为 `parser_parse` 函数仅在通过套接字收到 TCP 数据包时才会被调用。这不仅对性能不利,而且很难设置。因此,模糊器的入口应与普通的 main 函数位于不同的位置。为此,可以使用环境变量 `LD_PRELOAD`,它允许注入一个能够访问内部函数的测试框架。正如负责在运行时链接可执行文件所需共享库的 `ld.so` 手册页所述,`LD_PRELOAD` 可用于“有选择地覆盖其他共享对象中的函数[\[man-pages\]](https://www.man7.org/linux/man-pages/man8/ld.so.8.html)。”
函数 `__uClibc_main` 最适合此目的。要覆盖此函数,必须创建一个包含同名函数的 C 文件。```c
void __uClibc_main(void *main, int argc, char** argv) {
// Harness code, e.g. call the function parser_append
printf("My custom __uClibc_main was called!");
}
然后,C 文件可以被交叉编译为 mipsel 架构下的共享对象,使用
mipsel-linux-gnu-gcc。-fPIC 选项启用“位置无关代码”,这意味着
机器代码不依赖于位于特定地址,而是使用相对寻址
而非绝对寻址。```txt
$ mipsel-linux-gnu-gcc parser_parse_hook.c -o parser_parse_hook.o -shared -fPIC
新创建的共享库随后可以通过向 QEMU 命令添加环境变量 `LD_PRELOAD`
来加载。```txt
$ chroot root /qemu-mipsel-static -E LD_PRELOAD=/parser_parse_hook.o /usr/bin/wscd
My custom __uClibc_main was called!
使用 chroot 命令可以为所提供命令更改当前目录和根目录。
这很有帮助,因为可执行文件 wscd 会打开其他文件,比如
固件中的共享库。我们可以通过向 QEMU 添加 -strace 参数来观察此行为。```txt
chroot root /qemu-mipsel-static -E LD_PRELOAD=/parser_parse_hook.o -strace /usr/bin/wscd /corpus/notify.txt
38180 mmap(NULL,4096,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS|0x4000000,-1,0) = 0x7f7e7000
38180 stat("/etc/ld.so.cache",0x7ffffa48) = -1 errno=2 (No such file or directory)
38180 open("/parser_parse_hook.o",O_RDONLY) = 3
38180 fstat(3,0x7ffff920) = 0
38180 close(3) = 0
38180 munmap(0x7f7e6000,4096) = 0
38180 open("/lib/libpthread.so.0",O_RDONLY) = 3
38180 open("/lib/libc.so.0",O_RDONLY) = 3
[...]
正如我们所见,该可执行文件打开的是固件中 `/lib/` 文件夹内的多个库,而不是主机上的库。
### 开发与调试 harness
在创建好设置之后,我们现在可以开始开发 harness。如背景部分所述,harness 是 fuzzer 与目标函数之间的驱动程序。harness 加载由 AFL++ 存储在文件中的 fuzz 输入。harness 以文件路径作为参数,然后调用模糊测试目标;在本例中即 `parser_append`。这些函数可以通过地址进行调用。
<div id="c10"></div>```c
void __uClibc_main(void *main, int argc, char** argv)
{
// Verify that a filename is provided
if (argc != 2) exit(1);
// Create function pointer to the fuzz target
int (*parser_request_init)(void *, int) = (void *) 0x00412564;
int (*parser_append)(void *, void *, int) = (void *) 0x00412e98;
// Open the fuzz input file
int fd = open(argv[1], O_RDONLY);
char fuzz_buf[2048 + 1];
int fuzz_buf_len = read(fd, fuzz_buf, sizeof(fuzz_buf) - 1);
if (fuzz_buf_len < 0) exit(1);
fuzz_buf[fuzz_buf_len] = 0;
// Call the target functions
uint8_t parsed_data[220];
parser_request_init(parsed_data, 8);
int status = parser_append(parsed_data, fuzz_buf, fuzz_buf_len);
printf("Response is %d\n", status);
exit(0);
}
代码 10:使用 fuzz 目标 `parser_append`(位于二进制 wscd 中)的 Harness 代码。
如 代码 10 所示,函数 parser_parse 并非直接调用,而是通过使用函数 parser_append 来调用。在调用此函数之前,必须先调用初始化函数 parser_request_init,它负责初始化 parser_parse 函数的输出结构体。
尽管对于 parser_parse 来说,harness 的设置非常简单,但其他目标则需要更复杂的 harness,例如 httpd_parser_main 函数。例如,在调用目标之前,必须先调用函数 http_init_main,而该函数最终会触发 SIGSEGV。要找出此段错误产生的位置,使用像 gdb 这样的调试器来调试代码会很有帮助。为此,可以使用 -g 选项启动 QEMU,它会在指定端口生成一个 gdb-server。```sh
chroot root /qemu-mipsel-static -strace -g 1234 -E LD_PRELOAD="/httpd_parser_main.o" /usr/bin/httpd
corpus/httpd/simple.txt
因为二进制文件是 `mipsel` 架构,所以必须使用 `gdb-multiarch`。gdb 启动后,
可以使用 `sources <path to script>` 将以下初始化脚本加载到 gdb 中。```sh
set solib-absolute-prefix /share/root/
file /share/root/usr/bin/httpd
target remote :1234
# break bevor fuzz target is called
# break __uClibc_main
break http_parser_main
display/4i $pc
由于 chroot,脚本首先更改了绝对前缀路径,这样当二进制文件
加载共享对象时,gdb 就能找到该文件。然后设置目标文件,因为 QEMU 的
gdb-server 不支持文件传输,所以 gdb 改为尝试从磁盘加载文件。
gdb 配置完成后,脚本通过 target remote 连接到 gdb-server,并创建
一个位于目标函数起始处的断点。使用 display 只是为了改善输出,因此当
单步执行时,将显示接下来的四行汇编代码。使用 si 我们可以单步执行
一条指令,这在 harness 使用默认语料库(该语料库应该总是能正常工作)发生段错误时
非常有用。如 代码 11 所示,该二进制文件在
fprintf 函数中发生了段错误。
Program received signal SIGSEGV, Segmentation fault.
<p style="text-align: center">代码 11:printf 中的段错误。</p>
为了调查该错误,可以使用 Ghidra 来找出该函数是以哪些参数
被调用的。```c
fprintf(
*(FILE **)(iVar1 + 0x101c),
"HTTP/1.1 %d %s\r\n",
*(undefined4 *)(&DAT_0042ee68 + (uint)(byte)(&DAT_00414570)[statuscode & 0x3f] * 8),
(&PTR_DAT_0042ee6c)[(uint)(byte)(&DAT_00414570)[statuscode & 0x3f] * 2]
);
SIGSEGV 很可能是因为第一个参数不是文件描述符而是
空指针引起的。其中 iVar1 只是对 httpd_parser_main 函数输入的引用。
这意味着模糊测试输入必须在 0x101c 位置有一个文件描述符。 因此,输入必须
调整为以下结构体。```c
typedef struct {
int _a; // 4 Bytes
int _b; // 4 Bytes
int socket; // 4 Bytes
int ip; // 4 Bytes
int mac; // 4 Bytes
unsigned char body[0x1008]; // 0x101c - 4*5 = 0x1008 Bytes
FILE * fd_out; // expected to be a valid file descriptor
} HttpMainT;
由于 `fd_out` 只是一个有效的文件描述符指针,因此可以轻松地将其设置为 `stdout`。
再次执行 `httpd_parser_main` 现在将产生有效的 HTTP 输出。```c
$ chroot root /qemu-mipsel-static -E LD_PRELOAD=/httpd_parser_main.o \
/usr/bin/httpd /httpd_corpus.txt
bind: No such file or directory
[ dm_shmInit ] 086: shmget to exitst shared memory failed. Could not create shared memory.
rdp_getObj is called with: 4274932gdpr_getSystemGDPREntry Error
gdpr_getNewSystemGDPREntry OK
#Msg: getsockname error
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 24257
Set-Cookie: JSESSIONID=deleted; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Path=/; HttpOnly
Connection: close
<!DOCTYPE html>
[...]
该测试工具现在可以正常工作,可用于使用 AFL++ 对该函数进行模糊测试,具体将在下一节中说明。
如背景部分所述,种子语料库描述的是有效的输入样本,它是在模糊测试过程中生成新输入数据的基础参考。
这些输入通常被选择用来代表目标程序的不同方面。模糊测试器使用种子语料库生成变异或演化后的测试用例,然后针对目标软件运行这些用例,以发现错误、崩溃或其他问题。该语料库在引导模糊测试器进入程序的相关区域、提高发现漏洞或意外行为的概率方面发挥着重要作用。通过提供多样化且具有代表性的初始输入集合,种子语料库有助于模糊测试器更快地探索目标中的不同路径,从而提高覆盖率。
对于解析网络数据的函数,可以使用 Wireshark 记录不同的数据包来创建这些输入。
对于函数 httpd_parse_main,我们创建了四个不同的语料库。每个语料库针对二进制中的不同路径。其中一个示例是登录请求,其中包含用户名和密码。对于该语料库,必须修改测试工具,因为 TP-Link 使用(弱)加密来“保护”密码。为此,密码在浏览器中使用 AES 加密,然后在后端解密。其中,密码在浏览器中生成,然后使用 RSA 加密。随后,对加密数据进行签名。由于模糊测试器无法创建签名或加密数据,一些函数被覆盖,现在仅对数据进行 base64 解码。为此,首先使用 图 4 中所示的调试器从浏览器中以明文形式提取数据。

图 4:在加密之前提取数据。
在目标程序中,函数 rsa_tmp_decrypt_bypart 随后被覆盖,将其逻辑从解密数据替换为仅进行 base64 解码。```c
// Replacing the logic with b64_decode
int rsa_tmp_decrypt_bypart(uint8_t *input, int input_len, uint8_t *output) { // other params just key data
int (*b64_decode)(uint8_t *, int, uint8_t *, int) = (void *) 0x0040bf00;
b64_decode(output, 0x1000, input, input_len);
int * seqnumber = (int *) 0x00444db0;
*seqnumber = 0x3ac28e29-input_len+12;
return 0; // says it was okay
}
<p style="text-align: center">代码 12:函数 rsa_tmp_decrypt_bypart 现在仅解码 base64
而不是解密数据。</p>
在执行语料库时,目标函数总是返回带有“408
Request Timeout”错误的 HTML 文档。使用 Ghidra 和 GDB 可以确定问题所在。该错误总是发生
在对 `http_stream_fgets` 的函数调用之后。问题所在的行是对换行符
`\n` 的检查。```c
if (((cVar1 == '\n') && (param_3 < pcVar4)) && (pcVar4[-1] == '\r')) {
此条件强制执行,在每个换行符之后必须跟随一个回车符。在添加回车符之后,所有生成的语料库都可以正常工作了。
在上一节中,我们开发了多个 harness,并使用 QEMU 执行了它们。在本节中,
QEMU 被 AFL++ 取代,AFL++ 将生成的语料库作为种子输入,对目标
函数进行模糊测试。在“模糊测试环境”一节中,创建了一个 Docker 镜像,该镜像已经会从 GitHub 拉取 AFL++,
然后使用 AFL++ 提供的脚本构建一个打过补丁的 QEMU 版本。因此,现在可以用以下命令启动 AFL++,
该命令接收不同的参数,例如 -Q,它告诉 AFL++ 使用打过补丁的 QEMU 版本。```sh
QEMU_LD_PREFIX=/share/root AFL_PRELOAD=/share/root/httpd_parser_main.o
/AFLplusplus/afl-fuzz -Q
-i /share/root/corpus/httpd/ -o /share/afl-out/httpd/
-- /share/root/usr/bin/httpd @@
<p style="text-align: center">代码 13:使用测试工具和 <code>afl-fuzz</code> 对二进制文件 <code>httpd</code> 进行模糊测试。</p>
与之前不同,`chroot` 命令不再需要,而是由变量 `QEMU_LD_PREFIX` 取代。该变量告诉 QEMU 在何处搜索共享对象。此外,`LD_PRELOAD` 变量也被替换为 AFL 专用的 `AFL_PRELOAD`。命令中的最后一个参数是两个 `@` 字符。AFL++ 会将它们替换为包含模糊测试输入的文件路径。启动后,AFL++ 会通过 [图 5](#f5) 所示的终端界面显示进度。
<div id="f5"></div>
<figure>
<p><img src="https://assets.kitploit.com/production/public/readmes/48851/ac4b12fcf84c2041c9edc2a0871c5bb89b576d976b2eba0a2d0f843e7e708795.png" style="width:95.0%" /></p>
<figcaption><p style="text-align: center">图 5:AFL++ 的状态屏幕。</p></figcaption>
</figure>
`AFL++` 状态屏幕提供了对当前模糊测试过程的重要洞察。`AFL++` 的文档对状态屏幕中使用的术语有一个很好的概述 [\[afl-screen\]](https://aflplus.plus/docs/status_screen/)。当使用以下环境变量调试语料库时,可以禁用 UI,并通过 `AFL_DEBUG` 启用详细日志记录,这些日志会显示当前的模糊器输入以及目标程序的 `stdout`。```sh
export AFL_DEBUG=1 && export AFL_NO_UI=1
unset AFL_DEBUG && unset AFL_NO_UI
如图 5所示,对二进制程序进行模糊测试可能需要相当长的时间。根据文档,这“应预期运行数天或数周”,并且“某些任务将被允许运行数月”。为了缩短所需时间,执行速度应高于 100 次/秒。例如,当对目标 httpd_main_parser 进行模糊测试时,初始执行速度约为 30 次/秒。为了提高速度,我们在目标二进制程序中搜索了可疑函数,这些函数很可能是导致速度变慢的原因。其中一个可疑函数是 rsa_gdpr_generate_key,因为生成 RSA 密钥众所周知地耗时。在覆盖该函数后,执行速度提升到了每秒 600 次。
有助于判断何时停止模糊测试的一个指标是循环计数器(cycle counter)。当“模糊测试器在较长时间内未看到任何活动”时,AFL++ 会将该数字以绿色高亮显示,这有助于帮助决定是否停止模糊测试器。
但最有趣的数字可能是“total crashes”(总崩溃次数)。它显示程序因当前模糊测试输入而崩溃的情况,这很可能是与内存相关的缺陷。为了验证这是否是真正的缺陷,可以再次使用 gdb 来定位缺陷的位置。
模糊测试可能是发现安全漏洞最有效的方式。在本次学期论文中,我们对三个不同的函数进行了模糊测试,但未发现任何漏洞。虽然搭建黑盒模糊测试环境本身并不算复杂和耗时,但要找到一个有效的目标并开发出可用的 harness 却很困难。大多数时候,必须对 harness 进行调试,然后还需逆向分析二进制文件中的底层逻辑,这同样会耗费大量时间。