OneShell

I fight for a brighter tomorrow

0%

afl qemu mode中关于forkserver的作用

afl qemu-mode 中关于 forkserver 作用

之前在看 afl 源码,粗略看了大概,没有对判断逻辑仔细查看。我原本理解的 afl 创建目标程序进程的方式为:afl-fuzz 父进程先通过 init_forkserver 创建一个 forkserver 子进程,然后对于每次新产生的变异数据,都在 forkserver 进程中通过 fork 创建子进程,通过之前 dup2 函数将变异数据传递到目标程序进程的标准输入,再 execv 加载目标程序,进行执行。

在 runtarget 函数中,只有当如下标志被设置了,才会使用上面描述的每次 fork 一个子进程,再 execve 这种每次需要额外花费进程生命周期(创建->执行->销毁)的方式:

1
if (dumb_mode == 1 || no_forkserver) 

afl 采用的 forkserver 技术,实际上是只进行一次 execve 函数执行,之后对于目标程序进程,是通过写时拷贝技术从已经停止的目标程序进程直接拷贝进程镜像,这样就节约了大量的性能。

最近在实现对于 cgi 的 fuzz,并且也还没有看过 afl-gcc 插桩的原理,此处就以 afl 的 qemu-mode 为例(似乎更加简介明了介绍 forkserver,源码编译的话,是汇编代码),简单介绍一下 afl 中 forkserver 的作用以及实现机制。

afl qemu-mode 中的 forkserver

首先简单说明一下 qemu 的基本执行流程,当 qemu 执行一个程序的时候,需要将 elf 进行加载,从被执行程序的入口点开始对基本块进行翻译。为了提升效率,qemu 会将翻译出来的基本块 TB 存放在 cache 中,当 qemu 执行一个基本块的时候首先判断基本块是否在 cache 中,如果在 cache 则命中直接执行基本块 TB,否则翻译再执行。

afl 的 qemu mode 在编译的时候对 qemu 的源码进行了修改 patch。首先在 elf 加载的时候在 elf 入口点、代码段的 start 和 end 三个地方修改,获得相关地址;然后在 cpu 执行也就是基本块翻译执行的地方进行了修改,也就是进行了与覆盖率统计和 forkserver 相关的插桩。详细的介绍可以看下我的这篇文章:afl qemu 模式简介

afl 的 qemu 模式,在每次执行一个基本块的时候,会调用宏定义 AFL_QEMU_CPU_SNIPPET2 来与 afl-fuzz 进程通信。这也就是在 qemu 实现对于目标程序插桩的原理。

1
2
3
4
5
6
7
#define AFL_QEMU_CPU_SNIPPET2 do { \
if(itb->pc == afl_entry_point) { \
afl_setup(); \
afl_forkserver(cpu); \
} \
afl_maybe_log(itb->pc); \
} while (0)

如果当前执行的基本块是 afl_entry_point,也就是目标程序的入口点,就通过 afl_setup 函数初始化管道和共享内存,然后初始化 forkserver。然后通过 afl-maybe-log 往共享内存中设置覆盖率通信相关信息。此处主要是看一下 forkserver 是如何进行设置的。

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
/* Fork server logic, invoked once we hit _start. */
// forkserver,会在程序的_start入口处激活一次
static void afl_forkserver(CPUState *cpu) {

static unsigned char tmp[4];

if (!afl_area_ptr) return;

/* Tell the parent that we're alive. If the parent doesn't want
to talk, assume that we're not running in forkserver mode. */
// 通过状态管道向afl-fuzz主进程说明,forkserver已经启动
// 如果写入失败,默认没有使用forkserver模式,return结束
if (write(FORKSRV_FD + 1, tmp, 4) != 4) return;
// 获取自身的进程号
afl_forksrv_pid = getpid();

/* All right, let's await orders... */
// forkserver的主循环,也是在这个地方一直执行目标程序
while (1) {

pid_t child_pid;
int status, t_fd[2];

/* Whoops, parent dead? */
// 从afl-fuzz进程的控制管道读取控制信息,如果读取失败,说明afl-fuzz主进程已经结束
if (read(FORKSRV_FD, tmp, 4) != 4) exit(2);

/* Establish a channel with child to grab translation commands. We'll
read from t_fd[0], child will write to TSL_FD. */

if (pipe(t_fd) || dup2(t_fd[1], TSL_FD) < 0) exit(3);
close(t_fd[1]);

child_pid = fork();
if (child_pid < 0) exit(4);
// 进入子进程,
if (!child_pid) {

/* Child process. Close descriptors and run free. */
// 关闭无关的管道描述符
afl_fork_child = 1;
close(FORKSRV_FD);
close(FORKSRV_FD + 1);
close(t_fd[0]);
return;

}
// 进入forkserver进程
/* Parent. */

close(TSL_FD);

if (write(FORKSRV_FD + 1, &child_pid, 4) != 4) exit(5);

/* Collect translation requests until child dies and closes the pipe. */

afl_wait_tsl(cpu, t_fd[0]);

/* Get and relay exit status to parent. */
// 获取目标程序进程的结束信息,并通过状态管道写回到afl-fuzz主进程中
if (waitpid(child_pid, &status, 0) < 0) exit(6);
if (write(FORKSRV_FD + 1, &status, 4) != 4) exit(7);

}

}

forkserver 的代码主要流程就是:

  1. 首先通过状态管道发送数据给 afl-fuzz 主进程,说明 forkserver 创建成功,然后进入 while(1) 循环。在循环中 forkserver 会 read 阻塞在控制管道,等待 alf-fuzz 主进程发送消息。
  2. 从控制管道接受到 afl-fuzz 主进程发送的数据后,forkserver fork 出新的子进程,此时的子进程也就是新的目标程序进程,会关闭与afl-fuzz通信的管道,返回继续向下执行目标程序进程在 qemu 中的代码。此时父进程 forkserver 则将新 fork 出来的目标程序进程 pid 通过状态管道发送给afl-fuzz
  3. 之后 forkserver 进程进入 afl_wait_tsl,不断循环处理目标程序进程翻译基本块的请求。
  4. 最后当目标程序进程执行完毕后,forkserver 获取结束信息,将结束信息通过控制管道发送给 afl-fuzz 用于判断处理 crash。

相关函数解析

如上是 qemu mode 中 forkserver 的基本流程,如下对中间涉及到的函数进行简单说明:

static void afl-setup(void)

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
/* Set up SHM region and initialize other stuff. */
// 设置共享内存和相关初始化
static void afl_setup(void) {
// 从环境变量SHM_ENV_VAR中获取共享内存ID
char *id_str = getenv(SHM_ENV_VAR),
*inst_r = getenv("AFL_INST_RATIO");

int shm_id;

if (inst_r) {

unsigned int r;

r = atoi(inst_r);

if (r > 100) r = 100;
if (!r) r = 1;

afl_inst_rms = MAP_SIZE * r / 100;

}

if (id_str) {
// 获取共享内存ID
shm_id = atoi(id_str);
// 获取共享内存指针
afl_area_ptr = shmat(shm_id, NULL, 0);

if (afl_area_ptr == (void*)-1) exit(1);

/* With AFL_INST_RATIO set to a low value, we want to touch the bitmap
so that the parent doesn't give up on us. */

if (inst_r) afl_area_ptr[0] = 1;


}

if (getenv("AFL_INST_LIBS")) {

afl_start_code = 0;
afl_end_code = (abi_ulong)-1;

}

/* pthread_atfork() seems somewhat broken in util/rcu.c, and I'm
not entirely sure what is the cause. This disables that
behaviour, and seems to work alright? */
// 线程安全相关?
rcu_disable_atfork();

}

在 afl_setup 函数中,最主要的就是从相关环境变量获取到共享内存的指针,用于之后进行覆盖率信息统计。

static void afl_wait_tsl(CPUState *cpu, int fd)

首先通过分析 qemu mode 的 patch 可知,qemu 在翻译每一个基本块之后,都会执行宏 AFL_QEMU_CPU_SNIPPET1,

1
2
3
4
5
6
@@ -365,6 +369,7 @@
if (!tb) {
/* if no translated code available, then translate it now */
tb = tb_gen_code(cpu, pc, cs_base, flags, 0);
+ AFL_QEMU_CPU_SNIPPET1;
}
1
2
3
#define AFL_QEMU_CPU_SNIPPET1 do { \
afl_request_tsl(pc, cs_base, flags); \
} while (0)

在宏中主要是执行函数 afl_request_tsl,代码如下,基本流程就是:目标程序进程如果需要翻译一个新的基本块,将基本块信息发送给forkserver,让其加入到基本块 cache 中。这样 forkserver 下次 fork 出来一个新的目标程序进程的时候,就可从 cache 中不用再次翻译基本块,提高性能。该函数是在 forkserver fork 出来的目标程序进程中执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* This code is invoked whenever QEMU decides that it doesn't have a
translation of a particular block and needs to compute it. When this happens,
we tell the parent to mirror the operation, so that the next fork() has a
cached copy. */
// 此代码只有当qemu翻译一个新的基本块tb时才会执行,并会将其加入到forkserver中,使得下一次fork目标程序进程有cache备份
static void afl_request_tsl(target_ulong pc, target_ulong cb, uint64_t flags) {

struct afl_tsl t;

if (!afl_fork_child) return;

t.pc = pc;
t.cs_base = cb;
t.flags = flags;
// 通过管道发送给forkserver,加入到基本块tb cache中
if (write(TSL_FD, &t, sizeof(struct afl_tsl)) != sizeof(struct afl_tsl))
return;

}

再次就是 afl_wait_tsl 函数,该函数在 forkserver 中执行。代码的流程是,forkserver 执行该函数进入死循环不断接受来自目标程序进程的基本块翻译情况,接收到信息说明目标程序进程翻译了一个新的基本块,那么 forkserver 就在自身的 cache 中搜索该基本块。如果该基本块不在 cache 中,则将该基本块加入到 cache,这样下一次 forkserver fork 新进程就可以使用该缓存,避免重复翻译,提高性能。

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
/* This is the other side of the same channel. Since timeouts are handled by
afl-fuzz simply killing the child, we can just wait until the pipe breaks. */

static void afl_wait_tsl(CPUState *cpu, int fd) {

struct afl_tsl t;
TranslationBlock *tb;

while (1) {

/* Broken pipe means it's time to return to the fork server routine. */
// 循环接受来自fork出来的子进程的基本块翻译请求
if (read(fd, &t, sizeof(struct afl_tsl)) != sizeof(struct afl_tsl))
break;
// 从forkserver的基本块缓存中搜索fork出来的目标进程的基本块
tb = tb_htable_lookup(cpu, t.pc, t.cs_base, t.flags);
// 如果forkserver的基本块缓存中没有搜索到,则翻译基本块并加入到forkserver的缓存中
if(!tb) {
mmap_lock();
tb_lock();
tb_gen_code(cpu, t.pc, t.cs_base, t.flags, 0);
mmap_unlock();
tb_unlock();
}

}

close(fd);

}

小结

总结这篇文章的原因是自己一直没有理解到 forkserver 是如何执行的,毕设涉及到 qemu mode,也需要稍微了解下其中的原理。其实 afl 的 qemu mode 从原理进行分析还是挺简单的,直接分析 patch 文件,看在 qemu 的哪些源码处进行了修改。然后再简单看一下是如何在 qemu 的翻译基本块前后进行相关判断和插桩的。代码量并不多,看完之后对 afl 有了更深的了解,也理解到了 forkserver 在 qemu 模式中是如何启动的,以及通过将每次新翻译的基本块加入到 forkserver 的 cache 中实现性能优化。

参考链接