在上一篇文章中(中文版)我提到了一些接下来的优化内容,比如:打过补丁的二进制文件和性能计数器;我甚至还实现了部分这些功能,但是我认为这些功能重复性太高且本身并未引进任何新的内容;当时我有太多其他想要实现的想法,并且我对现有的问题毫无头绪;不过最近我决定换个角度来解决问题,我想直指本质 —— 本地检测(native instrumentation)
前情提要与计划
从之前的文章可以看出,我们正在实现一个基于代码覆盖率来引导样本变异的fuzzer;通过检测哪些代码被执行,而哪些代码没有被执行,我们就可以得到一个二进制程序的代码覆盖率信息;以上对于代码覆盖率和信息收集的解释相对简单,若想要深入了解一些覆盖率收集方面的知识,我推荐h0mbre的这篇文章
收集覆盖率信息有很多种方式,我们之前使用的是ptrace工具中带有对二进制程序基本块解析那部分的功能
这里的分辨率指的是我们收集的覆盖率信息的粒度;它是目标程序的函数或系统调用的精细程度;分辨率的选择即会影响引导样本变异的能力,也间接影响fuzzer的性能
这种方法的优缺点也是显而易见的:优点只是容易实现;缺点是这种方法性能很差,并且只收集到了基本区块的访问信息,至于这些区块的访问顺序以及访问次数,则是完全忽略的。而这次我们需要将上述缺点一 一修正
修正计划如下:我们会参与程序的编译,并插入一小段代码,这些代码将记录控制流图中每一条被访问的边(edge),并通过共享内存与fuzzer共享这些信息;或许您会注意到这与AFL极为相似,确实,我们就是在重新实现AFL,只不过我们晚了十年而已
共享内存介绍
共享内存允许在同一个操作系统的两个或多个进程之间可以访问同一段内存,并且允许无拷贝的交换数据,这正是我们需要的,以便让我们的fuzzer快速运行
Linux实现了两种共享内存的访问接口 —— System V和POSIX。这两者之间存在着一些差异,但是这些差异对我们来说并不重要;在这里我将使用POSIX变体,因为POSIX比SystemV更新
有趣的是:AFL使用是System V
使用攻下功能内存的方法是在一个进程中使用smh_open()函数来打开段;此函数会返回一个文件描述符;你可以使用mmap()函数对上述描述符做可读可写的内存操作,这样程序就可以使用它了。其他进程也会做同样的操作,只要它们在段名和某些表之上达成一致,就能看到相同的内存片段。现在,我们要忽略互斥、信号和队列等高深的内容
我们先写两个C程序,这两个程序将使用上面提到的接口相互通信;第一个程序名为setter,代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | #include <fcntl.h>#include <stdio.h>#include <string.h>#include <sys/mman.h>#include <sys/stat.h>#include <sys/types.h>#include <unistd.h>#define STORAGE_ID "/SHM_TEST"#define STORAGE_SIZE 32#define DATA "Hello, World! From PID %d"// SETTERint main(int argc, char *argv[]) { char data[STORAGE_SIZE]; sprintf(data, "Hello from %d pid", getpid()); int fd = shm_open(STORAGE_ID, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR); if (fd == -1) { perror("shm_open"); return 10; } int res = ftruncate(fd, STORAGE_SIZE); if (res == -1) { perror("ftruncate"); return 20; } void *addr = mmap(NULL, STORAGE_SIZE, PROT_WRITE, MAP_SHARED, fd, 0); if (addr == MAP_FAILED) { perror("mmap"); return 30; } // Writing to shared memory size_t len = strlen(data) + 1; memcpy(addr, data, len); res = munmap(addr, STORAGE_SIZE); if (res == -1) { perror("munmap"); return 40; } fd = shm_unlink(STORAGE_ID); if (fd == -1) { perror("shm_unlink"); return 50; } return 0;} |
在上述代码中,我们首先定义了两个常量值 —— 共享段的名称和长度;要在setter和getter之间通信就需要保持同步;共享段的大小也很重要,如果你尝试读写映射错误的内存,则程序可能会崩溃,我们需要将非目标程序本身的崩溃排除在外
上面提到需要使用shm_open()函数打开共享段;此函数的参数分别是段名称、标志和模式;名称我们之前已经解释过了,不过值得注意的是这些名称本质上可以看作是文件名,而文件名一般不使用空字节或斜线;标志则意味着段可读、可写或当段不存在的情况下创建它;而模式只有在创建文件且有适当权限时才会起作用
我们可以使用mmap()函数将成功打开共享的段映射为内存;不过在此之前建议使用ftruncate()函数来调整新创建的共享(O_CREAT标志)的大小;如果忘记了调整大小就试图读写内存的话,会导致SIGBUS和一些有趣的问题
说到内存映射,就不得不提那些控制着内存的行为和属性的标志,要想全面了解这些信息,最好参考一下man或Michael Kerrisk的书;这些信息在C和rust之间切换时尤为重要,因为某些行为(如MAP_ANONYMOUS)可能无法完全按照预期运行
你可以随意使用mmap()函数(函数会返回一个可以自由使用的void指针)写入目标内存,这是因为你需要记住刚刚映射的内存的大小
最后几行很容易理解:作为一个负责人的程序员,我们需要使用munmap()函数取消映射内存,并用shm_unlink()函数关闭共享文件
至于getter代码,除了读取内存的部分,其他内容大致相同,具体实现,就留给读者自己当作练习了
小提示:在Linux系统中,你可以在 /dev/shm 目录中找到所有活动的共享内存段
现在,如果你同时运行getter和setter(你可以在setter中适当的插入sleep()函数来帮助自己),你会发现我们已经可以使用共享内存接口交换字符串了
关于fuzzer
了解了共享内存的工作原理后,我们就可以开始实现我们的fuzzer了;之前写的fuzzer过于复杂,因此我决定从头开始便携一个新的fuzzer;为了方便阅读,我还会在代码中插入unwrap()函数和expect()函数以方便阅读,不过我会在成品中删除这部分代码以保持代码整洁
下面的代码是有关于负责运行目标程序并收集储存在共享内存中的覆盖信息的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | use std::env;use std::ffi::c_void;use std::process::{Command, Stdio};use nix::fcntl::OFlag;use nix::sys::mman;use nix::sys::mman::{MapFlags, ProtFlags};use nix::sys::stat::Mode;use nix::sys::wait::waitpid;use nix::unistd::{ftruncate, Pid};use core::num::NonZeroUsize;const MAP_NAME: &str = "/fuzz.map";const STORAGE_SIZE: i64 = 64 * 1024;fn main() { let runtime = env::args().nth(1); // open shared memory let shm_open_flags = OFlag::O_CREAT | OFlag::O_RDWR; let shm_open_mode = Mode::S_IRUSR | Mode::S_IWUSR; let mem = mman::shm_open(MAP_NAME, shm_open_flags, shm_open_mode) .expect("Failed to open shared memory"); // resize the file to L1 cache size ftruncate(&mem, STORAGE_SIZE).expect("Unable to resize file"); // map the shared memory as a memory region let mmap_prot = ProtFlags::PROT_READ | ProtFlags::PROT_WRITE; let mmap_flags = MapFlags::MAP_SHARED; let var = unsafe { mman::mmap( None, NonZeroUsize::new(STORAGE_SIZE as usize).unwrap(), mmap_prot, mmap_flags, Some(&mem), 0, ) .unwrap() } as *const u8; if let Some(r) = runtime { println!("Running fuzz target: {}", r); let p = Command::new(r) .arg("ABC") .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("[!] Failed to run process:"); let pid = Pid::from_raw(p.id() as i32); match waitpid(pid, None) { Ok(status) => { println!("[{}] Got status: {:?}", pid, status); println!("Reading from the shared memory..."); let trace = unsafe { std::slice::from_raw_parts(var, STORAGE_SIZE as usize) }; for x in 0..128 { if x != 0 && x % 32 == 0 { println!(); } print!("{:02x} ", trace[x]); } println!(); } Err(e) => { eprintln!("Error waiting for pid: {:?}", e); } }; } unsafe { mman::munmap(var as *mut c_void, STORAGE_SIZE as usize).unwrap() }; mman::shm_unlink(MAP_NAME).unwrap();} |
首先我们将使用nix crate来访问我们需要的几个系统函数,例如:mman::shm_open()、ftruncate()和mman:mmap(),这三个分别是打开内存共享、自定义内存大小和映射为变量,这些标志和模式总体并不复杂;清理操作则是由mman::munmap() 和 mman::shm_unlink()完成
可以看到,上面的代码中,我们使用了大量不安全的注解,这其实是正常情况,因为本质上,我们是在操作对于程序来说完全未知的指针,而这种指针是没办法很好的转换成rust代码的,因此这些指针需要转换成整个程序都能使用的指针,为此,我们需要调用std::slice::from_raw_parts(),并提供起始地址和字节大小;通过这个函数,我们最终可以得到一个允许自由读取的u8值片段;不过这些不安全注解会有一个巨大的问题,值类型是不确定的;回顾一下我们是如何映射内存的,我们就会发现我们会将值转换成一个u8指针,而rust作为一种高级语言,它是可以判断出我们要操作的片段包含这种类型的值的
目前为止,我们编写的fuzzer基本上只是运行提供的二进制文件,然后等待运行结束,以便读取共享内存;接下来只需要将共享内存的名称和大小相匹配就行了;将setter作为参数传入fuzzer,就能以十六进制的形式打印出子进程修改过后的共享内存,如下图所示

检测(Instrumentation)
接下来是一些难度较高的内容,也就是二进制插桩的内容,如果想要自己实现二进制插桩的内容,则需要学习编译原理,这远超出我的能力范围了,不过好在clang和llvm的开发者已经提供了检测接口以便我们使用,我们只需要写几行代码调用一下,并简单修改一下makefile就行了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #include <stdio.h>#include <stdint.h>#include <sanitizer/coverage_interface.h>extern void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) { static uint64_t N; // Counter for the guards. if (start == stop || *start) return; // Initialize only once. printf("INIT: %p %p\n", start, stop); for (uint32_t *x = start; x < stop; x++) *x = ++N; // Guards should start from 1.}extern void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if (!*guard) return; // Duplicate the guard check. printf("Edge: %p %x\n", guard, *guard);} |
上面的代码定义了两个函数,我们先从第二个__sanitizer_cov_trace_pc_guard()函数开始;编译器会在控制流的每个边插入这个函数,因为每个边都是不同的,所以*guard会指向一个唯一的内存位置;而第一个函数的两个参数:*start指针和*stop指针则标记了整个二进制文件的所有保护区,这个保护区可以设置为任何我们想要的值,这则案例中,我们选择了增量值;编译器会将第一个函数作为模块构造函数插入每个DSO中,这样从第二个函数的*guard指针中就能找到这些保护区了
在我们的例子中,我们只检测了一个分支,而在文档中,还介绍了对其他操作(例如比较、存储或取消引用指针)的检测方式;当你尝试对其他操作做检测的时候,你可能会找到一些很有趣的收集的覆盖率方法
知道了方法,那么就应该结合实际来做测试,不过我的fuzzer目前为止还是在测试阶段,并没有对实际的应用程序做测试,不过原理是相同的,我们还需要修改一下makefile,本例的makefile如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | CC=clangCFLAGS=-Wall -lrtCFLAGS_INSTR=-fsanitize-coverage=trace-pc-guard,no-prune## Universal rule for all casescase_%: sample_%.o instr_%.o $(CC) $^ -o $@ sample_%.o: sample_%.c $(CC) $^ $(CFLAGS_INSTR) -c instr_%.o: instr_%.c $(CC) $^ -o $@ -c ## Cleanup.PHONY: cleanclean: rm -rf *.o |
上面的makefile是我在本项目中的得意之作,它可以在不增加额外目标的情况下,适用于项目中所有的样本和检测变体(instrumentation variants)。这里先从instr_%.o开始分析,此文件的编译命令是:clang instr_1.c -o instr_1.o -c,-c参数的含义是将代码编译成对象,但是不链接,编译命令对我们的示例代码(本例文件是sample_1.c)同样有效,不过在本例中我们还添加了一些额外的参数:-fsanitize-coverage=trace-pc-guard,no-prune;这些参数会在编译器编译时在代码中插入一些别的内容;最后,我们将两个文件链接在一起并生成一个二进制文件:case_1
我认为每个人都应该自己动手写点代码,所以我没有提供用于添加检测的示例代码;我用的则是用于检测某个特定单词的 if 嵌套程序
这里解释一下 no-prune 参数,如果编译时不加这个选项的话,编译出来的二进制文件可能会出现某些边没有被检测到的情况,这是因为编译器在编译时会对目标程序的冗余内容进行优化,所以我会直接屏蔽这些优化项目,不过这些优化方式也是值得研究的内容
拼接
现在需要调整检测代码以配合fuzzer使用,代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | #include <fcntl.h>#include <stddef.h>#include <stdint.h>#include <stdio.h>#include <stdlib.h>#include <sys/mman.h>#include <sys/stat.h>#include <sys/types.h>#include <unistd.h>#include <sanitizer/coverage_interface.h>#define STORAGE_ID "/fuzz.map"#define STORAGE_SIZE 64 * 1024void *addr = NULL;void unmap() { munmap(addr, STORAGE_SIZE);} extern void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) { // Setup guards static uint64_t N; // Counter for the guards. if (start == stop || *start) return; // Initialize only once. for (uint32_t *x = start; x < stop; x++) *x = ++N; // Guards should start from 1. // Setup shared memory int fd = shm_open(STORAGE_ID, O_RDWR, S_IRUSR | S_IWUSR); if (fd == -1) { perror("Failed to open shm share"); return; } addr = mmap(NULL, STORAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (addr == MAP_FAILED) { perror("Failed to mmap file"); return; } atexit(unmap);}extern void __sanitizer_cov_trace_pc_guard(uint32_t *guard) { if ((size_t *)addr && *guard) { // place a bit in a map uint8_t *map_ptr = ((uint8_t *)addr + *guard); *map_ptr += 1; printf("writing to: %p\n", map_ptr); }} |
有些内容我们上面已经提到过了,比如初始化守护进程和获取共享内存段;这里唯一不同的是后面还加了用于在程序退出时负责解除内存映射的函数,因为clang本身并不提供销毁函数,所以我们要用atexit()函数自己写一个
在边保护函数中,我们使用增量数初始化了所有的保护值,这时我们可以将共享内存段视为一个简单的位图,并将每个边标记为单字节(没有标记成位是因为我们还要计算出现的次数);注意这种方法会在代码大小超过64k时失效,不过在我们的程序里是没什么问题的
当出现如下输出内容时,说明fuzzer成功的检测了本地代码

前文总结和未来计划
我们已经可以通过共享内存来获取检测到的覆盖率信息了,不过我们的代码仍然有些差强人意;首先我们已经可以通过在分支层面的跟踪而不是通过对基本块ID进行奇怪的位移来获取覆盖率信息;其次在默认情况下,我们只检测主要的分支,因此不会出现超过64k时失效的情况,不过接下来我还是想要避免在检测分支过多的情况下失效。此外我们的共享段只允许每个fuzzer运行一个目标程序,这也是需要优化的部分
除此之外,在下一部分中,我会完善fuzzer,并将重点放在性能的检测和剖析上