一
简介
在这一期的“野蛮fuzz”中,我们将继续由菜鸟为菜鸟的模糊测试之旅,尝试理解代码覆盖的概念及其重要性。据我所知,代码覆盖在高层次上是模糊测试器试图追踪/增加模糊测试器输入所能覆盖的目标应用程序代码的程度。其理念是,你的模糊测试器输入覆盖的代码越多,攻击面越大,你的测试就越全面,还有其他一些我目前还不理解的高级概念。
我一直在提升我的pwn技能,但会短暂休息一下,写点C代码,看看@gamozolabs的直播。@gamozolabs在其中一个直播中详细讲解了代码覆盖的重要性,我怎么也找不到那个片段,但我记得大概内容,于是设置了一些测试用例,专门用于我的测试,以演示为什么“愚蠢的”模糊测试器相比于代码覆盖引导的模糊测试器是如此劣势。准备好一些(可能不正确的????)八年级概率理论吧。在这篇博客文章结束时,我们至少应该能够大致理解1990年最先进的模糊测试器是如何工作的。
二
我们的模糊测试器
我们有这个美丽、无错误、完美编写的单线程jpeg变异模糊测试器,我们已经从之前的博客文章中将其移植到C语言,并为我们的实验目的进行了一些调整。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>
int crashes = 0;
struct ORIGINAL_FILE {
char * data;
size_t length;
};
struct ORIGINAL_FILE get_data(char* fuzz_target) {
FILE *fileptr;
char *clone_data;
long filelen;
// open file in binary read mode
// jump to end of file, get length
// reset pointer to beginning of file
fileptr = fopen(fuzz_target, "rb");
if (fileptr == NULL) {
printf("[!] Unable to open fuzz target, exiting...\n");
exit(1);
}
fseek(fileptr, 0, SEEK_END);
filelen = ftell(fileptr);
rewind(fileptr);
// cast malloc as char ptr
// ptr offset * sizeof char = data in .jpeg
clone_data = (char *)malloc(filelen * sizeof(char));
// get length for struct returned
size_t length = filelen * sizeof(char);
// read in the data
fread(clone_data, filelen, 1, fileptr);
fclose(fileptr);
struct ORIGINAL_FILE original_file;
original_file.data = clone_data;
original_file.length = length;
return original_file;
}
void create_new(struct ORIGINAL_FILE original_file, size_t mutations) {
//
//----------------MUTATE THE BITS-------------------------
//
int* picked_indexes = (int*)malloc(sizeof(int)*mutations);
for (int i = 0; i < (int)mutations; i++) {
picked_indexes[i] = rand() % original_file.length;
}
char * mutated_data = (char*)malloc(original_file.length);
memcpy(mutated_data, original_file.data, original_file.length);
for (int i = 0; i < (int)mutations; i++) {
char current = mutated_data[picked_indexes[i]];
// figure out what bit to flip in this 'decimal' byte
int rand_byte = rand() % 256;
mutated_data[picked_indexes[i]] = (char)rand_byte;
}
//
//---------WRITING THE MUTATED BITS TO NEW FILE-----------
//
FILE *fileptr;
fileptr = fopen("mutated.jpeg", "wb");
if (fileptr == NULL) {
printf("[!] Unable to open mutated.jpeg, exiting...\n");
exit(1);
}
// buffer to be written from,
// size in bytes of elements,
// how many elements,
// where to stream the output to :)
fwrite(mutated_data, 1, original_file.length, fileptr);
fclose(fileptr);
free(mutated_data);
free(picked_indexes);
}
void exif(int iteration) {
//fileptr = popen("exiv2 pr -v mutated.jpeg >/dev/null 2>&1", "r");
char* file = "vuln";
char* argv[3];
argv[0] = "vuln";
argv[1] = "mutated.jpeg";
argv[2] = NULL;
pid_t child_pid;
int child_status;
child_pid = fork();
if (child_pid == 0) {
// this means we're the child process
int fd = open("/dev/null", O_WRONLY);
// dup both stdout and stderr and send them to /dev/null
dup2(fd, 1);
dup2(fd, 2);
close(fd);
execvp(file, argv);
// shouldn't return, if it does, we have an error with the command
printf("[!] Unknown command for execvp, exiting...\n");
exit(1);
}
else {
// this is run by the parent process
do {
pid_t tpid = waitpid(child_pid, &child_status, WUNTRACED |
WCONTINUED);
if (tpid == -1) {
printf("[!] Waitpid failed!\n");
perror("waitpid");
}
if (WIFEXITED(child_status)) {
//printf("WIFEXITED: Exit Status: %d\n", WEXITSTATUS(child_status));
} else if (WIFSIGNALED(child_status)) {
crashes++;
int exit_status = WTERMSIG(child_status);
printf("\r[>] Crashes: %d", crashes);
fflush(stdout);
char command[50];
sprintf(command, "cp mutated.jpeg ccrashes/%d.%d", iteration,
exit_status);
system(command);
} else if (WIFSTOPPED(child_status)) {
printf("WIFSTOPPED: Exit Status: %d\n", WSTOPSIG(child_status));
} else if (WIFCONTINUED(child_status)) {
printf("WIFCONTINUED: Exit Status: Continued.\n");
}
} while (!WIFEXITED(child_status) && !WIFSIGNALED(child_status));
}
}
int main(int argc, char** argv) {
if (argc < 3) {
printf("Usage: ./cfuzz <valid jpeg> <num of fuzz iterations>\n");
printf("Usage: ./cfuzz Canon_40D.jpg 10000\n");
exit(1);
}
// get our random seed
srand((unsigned)time(NULL));
char* fuzz_target = argv[1];
struct ORIGINAL_FILE original_file = get_data(fuzz_target);
printf("[>] Size of file: %ld bytes.\n", original_file.length);
size_t mutations = (original_file.length - 4) * .02;
printf("[>] Flipping up to %ld bytes.\n", mutations);
int iterations = atoi(argv[2]);
printf("[>] Fuzzing for %d iterations...\n", iterations);
for (int i = 0; i < iterations; i++) {
create_new(original_file, mutations);
exif(i);
}
printf("\n[>] Fuzzing completed, exiting...\n");
return 0;
}
这里不会花太多时间讨论模糊测试器的功能(有什么功能?),但关于模糊测试器代码的一些重要事项:
◆它以文件作为输入,并将文件中的字节复制到一个缓冲区中。
◆它计算缓冲区的字节长度,然后通过随机覆盖任意字节来变异2%的字节。
◆负责变异的函数create_new
不跟踪哪些字节索引被变异,所以理论上,同一个索引可能会被多次选择进行变异,因此实际上,模糊测试器最多变异2%的字节。
我们这里只使用了一种变异方法以保持事情的简单性,在此过程中,我实际上学到了一些之前没有清晰考虑过的非常有用的东西。在之前的一篇文章中,我曾尴尬地大声思考并写到,随机比特翻转与随机字节覆盖(翻转?)有多大不同。事实证明,它们非常不同。让我们花点时间看看。
假设我们正在变异一个名为bytes
的字节数组。我们正在变异索引5。未变异的原始文件中,bytes[5] == \x41
(十进制为65)。如果我们只进行比特翻转,我们在变异这个字节的程度上非常有限。65的二进制表示是01000001
。让我们看看任意翻转一位会有多大变化:
◆翻转第一位:11000001 = 193,
◆翻转第二位:00000001 = 1,
◆翻转第三位:01100001 = 97,
◆翻转第四位:01010001 = 81,
◆翻转第五位:01001001 = 73,
◆翻转第六位:01000101 = 69,
◆翻转第七位:01000011 = 67,
◆翻转第八位:010000001 = 64。
如你所见,我们被限制在一个非常有限的可能性范围内。
因此,对于这个程序,我选择用一种方法代替这种变异方法,即直接替换一个随机字节,而不是字节内的比特。
三
易受攻击的程序
我写了一个简单的卡通化程序来演示“愚蠢的”模糊测试器发现漏洞的难度。想象一个目标应用程序在二进制的反汇编视图中有几个决策树。该应用程序对输入执行2-3次检查,以查看其是否满足某些条件,然后再将输入传递给某种易受攻击的函数。我的意思是这样的:
我们的程序正是这样做的,它获取输入文件的字节,并检查文件长度的1/3、1/2和2/3处的字节,看看这些位置的字节是否与一些硬编码的值(任意的)匹配。如果所有检查都通过,应用程序会将字节缓冲区复制到一个小缓冲区中,导致段错误以模拟一个易受攻击的函数。以下是我们的程序:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
struct ORIGINAL_FILE {
char * data;
size_t length;
};
struct ORIGINAL_FILE get_bytes(char* fileName) {
FILE *filePtr;
char* buffer;
long fileLen;
filePtr = fopen(fileName, "rb");
if (!filePtr) {
printf("[>] Unable to open %s\n", fileName);
exit(-1);
}
if (fseek(filePtr, 0, SEEK_END)) {
printf("[>] fseek() failed, wtf?\n");
exit(-1);
}
fileLen = ftell(filePtr);
if (fileLen == -1) {
printf("[>] ftell() failed, wtf?\n");
exit(-1);
}
errno = 0;
rewind(filePtr);
if (errno) {
printf("[>] rewind() failed, wtf?\n");
exit(-1);
}
long trueSize = fileLen * sizeof(char);
printf("[>] %s is %ld bytes.\n", fileName, trueSize);
buffer = (char *)malloc(fileLen * sizeof(char));
fread(buffer, fileLen, 1, filePtr);
fclose(filePtr);
struct ORIGINAL_FILE original_file;
original_file.data = buffer;
original_file.length = trueSize;
return original_file;
}
void check_one(char* buffer, int check) {
if (buffer[check] == '\x6c') {
return;
}
else {
printf("[>] Check 1 failed.\n");
exit(-1);
}
}
void check_two(char* buffer, int check) {
if (buffer[check] == '\x57') {
return;
}
else {
printf("[>] Check 2 failed.\n");
exit(-1);
}
}
void check_three(char* buffer, int check) {
if (buffer[check] == '\x21') {
return;
}
else {
printf("[>] Check 3 failed.\n");
exit(-1);
}
}
void vuln(char* buffer, size_t length) {
printf("[>] Passed all checks!\n");
char vulnBuff[20];
memcpy(vulnBuff, buffer, length);
}
int main(int argc, char *argv[]) {
if (argc < 2 || argc > 2) {
printf("[>] Usage: vuln example.txt\n");
exit(-1);
}
char *filename = argv[1];
printf("[>] Analyzing file: %s.\n", filename);
struct ORIGINAL_FILE original_file = get_bytes(filename);
int checkNum1 = (int)(original_file.length * .33);
printf("[>] Check 1 no.: %d\n", checkNum1);
int checkNum2 = (int)(original_file.length * .5);
printf("[>] Check 2 no.: %d\n", checkNum2);
int checkNum3 = (int)(original_file.length * .67);
printf("[>] Check 3 no.: %d\n", checkNum3);
check_one(original_file.data, checkNum1);
check_two(original_file.data, checkNum2);
check_three(original_file.data, checkNum3);
vuln(original_file.data, original_file.length);
return 0;
}
请记住,这只是一个类型的条件检查,二进制文件中存在几种不同类型的条件检查。我选择这个条件是因为这些检查非常具体,可以夸张地展示仅靠随机性达到新代码是多么困难。
我们的示例文件,即我们将变异并输入到这个易受攻击的应用程序中的文件,仍然是之前文章中的文件,即带有exif数据的Canon_40D.jpg
文件。
h0mbre@pwn:~/fuzzing$ file Canon_40D.jpg
Canon_40D.jpg: JPEG image data, JFIF standard 1.01, resolution (DPI), density 72x72, segment length 16, Exif Standard: [TIFF image data, little-endian, direntries=11, manufacturer=Canon, model=Canon EOS 40D, orientation=upper-left, xresolution=166, yresolution=174, resolutionunit=2, software=GIMP 2.4.5, datetime=2008:07:31 10:38:11, GPS-Data], baseline, precision 8, 100x68, frames 3
h0mbre@pwn:~/fuzzing$ ls -lah Canon_40D.jpg
-rw-r--r-- 1 h0mbre h0mbre 7.8K May 25 06:21 Canon_40D.jpg
该文件有7958字节长。让我们将其输入到易受攻击的程序中,看看选择了哪些索引进行检查:
h0mbre@pwn:~/fuzzing$ vuln Canon_40D.jpg
[>] Analyzing file: Canon_40D.jpg.
[>] Canon_40D.jpg is 7958 bytes.
[>] Check 1 no.: 2626
[>] Check 2 no.: 3979
[>] Check 3 no.: 5331
[>] Check 1 failed.
我们可以看到,索引2626、3979和5331被选中进行测试,并且文件在第一次检查时失败了,因为该位置的字节不是\x6c。
实验1:仅通过一个检查
让我们去掉第二和第三个检查,看看当我们只需要通过一个检查时,愚蠢的模糊测试器对二进制文件的表现如何。
我将注释掉第二和第三个检查:
check_one(original_file.data, checkNum1);
//check_two(original_file.data, checkNum2);
//check_three(original_file.data, checkNum3);
vuln(original_file.data, original_file.length);
现在,我们将使用未修改的jpeg文件,该文件自然不会通过第一个检查,并让我们的模糊测试器对其进行变异并发送到易受攻击的应用程序,希望能导致崩溃。请记住,模糊测试器在每次模糊测试迭代中最多变异7958字节中的159字节。如果模糊测试器随机在索引2626
处插入一个\x6c
,我们将通过第一个检查,执行将传递到易受攻击的函数并导致崩溃。让我们运行我们的愚蠢模糊测试器100万次,看看我们得到了多少次崩溃。
h0mbre@pwn:~/fuzzing$ ./fuzzer Canon_40D.jpg 1000000
[>] Size of file: 7958 bytes.
[>] Flipping up to 159 bytes.
[>] Fuzzing for 1000000 iterations...
[>] Crashes: 88
[>] Fuzzing completed, exiting...
在100万次迭代中,我们得到了88次崩溃。所以在大约0.0088%的迭代中,我们满足了通过检查1的条件并触发了易受攻击的函数。让我们仔细检查一下我们的崩溃,以确保我们的代码中没有错误(我在QEMU模式下使用AFL对启用所有检查的易受攻击程序进行了14小时的模糊测试,但未能使程序崩溃,所以我希望没有我不知道的错误 )。
h0mbre@pwn:~/fuzzing/ccrashes$ vuln 998636.11
[>] Analyzing file: 998636.11.
[>] 998636.11 is 7958 bytes.
[>] Check 1 no.: 2626
[>] Check 2 no.: 3979
[>] Check 3 no.: 5331
[>] Passed all checks!
Segmentation fault
所以,实际上将一个崩溃输入提供给易受攻击的程序确实会导致崩溃。很酷。
免责声明:这里会涉及一些数学计算,我不保证这些数学计算是正确的。我甚至向一些非常聪明的人求助,比如@Firzen14,但我仍然对我的数学计算不完全自信,哈哈。不过,我确实进行了数亿次的系统模拟,得到的经验数据结果与可能有误的数学计算结果非常接近。
所以,如果它不正确,至少也足够接近证明我想要展示的观点。
让我们尝试弄清楚我们通过第一个检查并导致崩溃的可能性。我们需要通过的第一个障碍是,我们需要选择索引2626
进行变异。如果它没有被变异,我们知道默认情况下它不会持有我们需要的值,我们将无法通过检查。由于我们选择变异159次,而我们有7958个字节可供选择,我们变异索引2626
处的字节的概率大概接近159/7958
,即0.0199798944458407
。
第二个障碍是,我们需要它恰好持有\x6c
值,而模糊测试器有255个字节值可供选择。所以,一旦选择了这个字节进行变异,将其变异为恰好\x6c
的概率是1/255
,即0.003921568627451
。
所以这两件事发生的概率应该接近0.0199798944458407
*0.003921568627451
,大约是0.0078%,乘以100万次迭代,大约是78次崩溃。我们得到88次崩溃,已经非常接近了。由于我们是随机进行的,会有一些差异。
总结实验1,我们能够可靠地通过这种类型的检查,并通过我们的愚蠢模糊测试器到达易受攻击的函数。让我们看看在添加第二个检查时情况会如何变化。
实验2:通过两个检查
这里的数学问题变得更加复杂;然而,正如我之前所说,我进行了数亿次事件模拟,结果与我认为的数学计算非常接近。
让字节值正确相对简单,我认为总是1/255,但在只有159次选择可用的情况下,选择两个索引进行变异让我困扰。我运行了一个模拟器,看看选择两个索引进行变异的频率,并让它运行了一段时间,在超过3.9亿次迭代中,总共大约发生了155,000次。
<snip>
Occurences: 155070 Iterations: 397356879
Occurences: 155080 Iterations: 397395052
Occurences: 155090 Iterations: 397422769
<snip>
155090/397422769
==0.0003902393423261565
。我认为数学计算应该接近于(159/7958) * (158/7958)
,结果是0.0003966855142551934
。所以你可以看到它们非常接近,考虑到一些随机差异,它们相差不大。这应该足够接近来展示问题。
现在我们需要通过两个检查,我们可以用数学方法总结出我们愚蠢模糊测试器发生这种情况的概率如下:
((159/7958) * (1/255)) == odds to pass first check
odds to pass first check * (158/7958) == odds to pass first check and have second index targeted for mutation
odds to pass first check * ((158/7958) * (1/255)) == odds to have second index targeted for mutation and hold the correct value
((159/7958) * (1/255)) * ((158/7958) * (1/255)) == odds to pass both checks
((0.0199798944458407 * 0.003921568627451) * (0.0198542347323448 * 0.003921568627451)) == 6.100507716342904e-9
所以,我们被选择进行变异的两个索引并且这两个索引都被变异为所需值的概率大约是0.000000006100507716342904
,即0.0000006100507716342904%
。
启用一个检查时,我们大约每12,820次迭代应该会预期一次崩溃。
启用两个检查时,我们大约每1.63亿次迭代应该会预期一次崩溃。
这是一个相当大的问题。我们的模糊测试器需要运行很长时间才能在平均情况下达到这么多次迭代。在虚拟机中运行时,模糊测试器大约每秒进行1,600次迭代。达到1.63亿次迭代大约需要28小时。你可以看到,只启用一个额外的检查,我们找到漏洞的机会就呈指数级下降。想象一下再添加一个第三个检查会怎样!
四
代码覆盖跟踪如何帮助我们
如果我们的模糊测试器能够跟踪代码覆盖率,我们可以将这个问题变得更容易处理。
一般来说,我们的模糊测试器中的代码覆盖跟踪系统会跟踪哪些输入达到了应用程序中的新代码。
有很多方法可以做到这一点。有时当你有源代码时,你可以重新编译二进制文件,添加一些工具,当达到新代码时通知模糊测试器,还有仿真等。@gamozolabs 有一个非常酷的Windows用户态代码覆盖系统,它利用一个极快的调试器,在目标二进制文件中设置数百万个断点,并在达到断点后慢慢移除断点,称为‘mesos’。
一旦你的模糊测试器知道某个变异输入达到了新代码,它就会保存该输入,以便可以重新使用并进一步变异以达到更多代码。这是一个非常简单的解释,但希望它能清楚地描绘出画面。
我还没有为模糊测试器实现代码覆盖技术,但我们可以轻松地模拟一个。假设我们的模糊测试器大约每13,000次中有1次能够通过第一次检查并到达程序中的第二次检查。
第一次输入到达这个第二次检查时,会被视为新的代码覆盖。因此,我们现在的智能模糊测试器会保存这个输入,因为它导致了新代码的到达。然后,这个输入将被重新输入到变异器中,并希望再次达到相同的新代码,同时有可能达到更多的新代码。
让我们演示一下。让我们修改一下文件Canon_40D.jpg
,使2626
索引处的字节为\x6c
,并将其传递给我们的漏洞应用程序。
h0mbre@pwn:~/fuzzing$ vuln Canon_altered.jpg
[>] Analyzing file: Canon_altered.jpg.
[>] Canon_altered.jpg is 7958 bytes.
[>] Check 1 no.: 2626
[>] Check 2 no.: 3979
[>] Check 2 failed.
如你所见,我们通过了第一次检查,但在第二次检查时失败了。让我们现在用这个Canon_altered.jpg
文件作为我们用于变异的基本输入,模拟我们在模糊测试器中有代码覆盖跟踪的事实,看看在仅测试两个检查时我们会得到多少次崩溃。
h0mbre@pwn:~/fuzzing$ ./fuzzer Canon_altered.jpg 1000000
[>] Size of file: 7958 bytes.
[>] Flipping up to 159 bytes.
[>] Fuzzing for 1000000 iterations...
[>] Crashes: 86
[>] Fuzzing completed, exiting...
所以,通过使用通过了第一次检查的文件(即增加了代码覆盖率的文件)作为基础文件并将其重新输入变异器,我们能够通过第二次检查86次。我们基本上将之前那个指数级困难的问题转变回了我们原本只需要通过一个检查的问题。真实的模糊测试器还需要考虑很多其他因素,但我只是想简单地演示一下它如何帮助将指数级问题简化为更易管理的问题。
我们将((0.0199798944458407 * 0.003921568627451) * (0.0198542347323448 * 0.003921568627451)) == 6.100507716342904e-9
的问题简化为更接近于(0.0199798944458407 * 0.003921568627451)
的问题,这对我们来说是一个巨大的胜利。
这里有一些细微差别,将修改后的文件重新输入变异过程可能会做几件事。它可能重新变异索引2626
处的字节,这样我们甚至无法通过第一次检查。它可能会将文件变异得太多(记住,它已经与第一次变异后的有效jpeg有2%的不同),以至于漏洞应用程序直接拒绝输入,我们浪费了模糊测试的循环。
所以还有很多其他因素需要考虑,但希望这能简单地演示代码覆盖如何帮助模糊测试器完成对目标二进制文件的更全面测试。
五
结论
关于不同的代码覆盖技术有很多资源,如果你对此感兴趣,绝对值得进一步阅读。@carste1n有一个很棒的系列,他逐步改进一个模糊测试器,你可以在这里看到最新的文章:https://carstein.github.io/2020/05/21/writing-simple-fuzzer-4.html
将来某个时候,我们可以在这篇文章中的简单模糊测试器中添加一些代码覆盖逻辑,并可以使用漏洞程序作为一种基准来评估代码覆盖技术的有效性。
一些有趣的笔记,我用AFL模糊测试了启用所有三个检查的漏洞应用程序大约13小时,却无法使其崩溃!我不确定为什么这么困难。仅启用两个检查时,AFL能够很快找到崩溃。也许我的测试有问题,我不太确定。
原文链接:https://h0mbre.github.io/Fuzzing-Like-A-Caveman-3/
看雪ID:pureGavin
https://bbs.kanxue.com/user-home-1000123.htm
# 往期推荐
球分享
球点赞
球在看
点击阅读原文查看更多