这是内核漏洞挖掘技术系列的第十一篇。
第一篇:内核漏洞挖掘技术系列(1)——trinity
第二篇:内核漏洞挖掘技术系列(2)——bochspwn
第三篇:内核漏洞挖掘技术系列(3)——bochspwn-reloaded(1)
第四篇:内核漏洞挖掘技术系列(3)——bochspwn-reloaded(2)
第五篇:内核漏洞挖掘技术系列(4)——syzkaller(1)
第六篇:内核漏洞挖掘技术系列(4)——syzkaller(2)
第七篇:内核漏洞挖掘技术系列(4)——syzkaller(3)
第八篇:内核漏洞挖掘技术系列(4)——syzkaller(4)
第九篇:内核漏洞挖掘技术系列(4)——syzkaller(5)
第十篇:内核漏洞挖掘技术系列(5)——KernelFuzzer

前言

AFL(http://lcamtuf.coredump.cx/afl/)和对应的windows上的winAFL(https://github.com/googleprojectzero/winafl)可以说是用户态程序二进制漏洞挖掘无人不知的神器,关于它们的分析解读在网上也到处都是。很自然有人就做出了AFL用于内核fuzz的尝试。这篇文章介绍两个相关的工具。第一个是nccgroup的TriforceAFL(https://github.com/nccgroup/TriforceAFL),它对AFL和QEMU进行了patch,TriforceLinuxSyscallFuzzer(https://github.com/nccgroup/TriforceLinuxSyscallFuzzer)可以使用TriforceAFL对内核fuzz。第二个是德国波鸿鲁尔大学的KAFL(https://github.com/RUB-SysSec)。我们先讲解AFL中的qemu mode,然后详细研究TriforceAFL和TriforceLinuxSyscallFuzzer的实现,最后简单介绍KAFL。

AFL中的qemu mode

关于AFL的实现已经有许多分析,这里仅仅介绍AFL中的qemu mode。QEMU能够:

  1. 将一个架构(被模拟的架构)的BB(basic blocks,基本块)翻译到另一个架构(QEMU正在之上运行的架构)
  2. 将TB(translated blocks,翻译块)存储在TBC(translated block cache,翻译块缓存)中,一次翻译并多次使用
  3. 在基本块中添加prologue和epilogue,以处理基本块之间的跳转、恢复控制等操作

让我们看一下抽象的QEMU执行流程:

  1. 启动预生成的代码prologue,初始化进程并跳转到二进制文件的_start
  2. 查找缓存中包含_start PC(program counter,程序计数器)的已翻译块,如果没有生成翻译并缓存它
  3. 跳转到已翻译的块并执行它

AFL使用fork server模型来fuzz程序。运行目标的QEMU实例将用作一个fork server,它将通过fds 198(控制队列)和199(状态队列)与fuzzer进程通信。这个fork server实例的克隆用于运行测试用例。目标的执行跟踪可以通过共享内存(shm)到达fuzzer进程。
cpu_tb_exec函数负责执行TB,并且可以在其中获得诸如PC地址之类的信息。当指令指针定位于_start并执行通常的fork server操作时执行下面的代码片段。
afl_setup函数设置子进程中存储跟踪数据数组的共享内存。
afl_forkserver函数负责创建fork server并监听fd以启动克隆。
afl_maybe_log函数第一次调用setup并为每次执行TB更新共享跟踪内存。


tb_find函数负责查找TB,它会在需要翻译时调用tb_gen_code函数。在这里添加afl_request_tsl函数来通知fork server翻译这个块并将其保存在内存中,以便将来克隆。



父进程此时正在afl_forkserver函数调用的afl_wait_tsl函数里等待,最终afl_wait_tsl函数会调用tb_gen_code函数来在父进程的缓存中翻译一个块。这样未来的子进程就可以使用这个缓存了,避免每个块翻译多次。

syscall.patch在fork server发生SIGABRT的时候传递正确的pid和tgid。
elfload.patch用于记录afl_entry_point,afl_start_code和afl_end_code。它们在afl_maybe_log函数中用于某些边界检查。

TriforceAFL

TriforceAFL中的qemu mode

通常当使用AFL fuzz时,每个测试用例都会启动一个驱动程序并运行直到完成或崩溃。当对操作系统进行fuzz时,这并不总是可能的。TriforceAFL允许操作系统启动并加载一个驱动程序,该驱动程序控制fuzz的生命周期以及托管测试用例。
使用TriforceAFL对内核fuzz将执行以下步骤:启动操作系统,操作系统将调用fuzz驱动程序作为其启动过程的一部分。驱动程序将:启动AFL fork server;获取一个测试用例;启用解析器的跟踪;解析测试用例;启用内核或内核的某些部分的跟踪;基于已解析的输入进行系统调用;通知测试用例已经成功完成(如果测试用例没有因为panic而提前终止)。在为每个测试用例重复启动fork server之后,afl-fuzz程序将安排所有这些步骤。下面就来看看具体实现。
由于fuzzer在虚拟机的fork副本中运行,因此每个测试用例的内核的内存状态都是隔离的。如果操作系统使用除内存之外的任何其它资源,这些资源将不会在测试用例之间隔离。因此,通常希望使用内存文件系统(如Linux ramdisk映像)引导操作系统。

将一个称为aflCall的特殊的指令添加到CPU,在QEMU的disas_insn函数中实现了指令的翻译,具体实现是在该函数中增加了一个处理该指令的case。

它支持startForkserver/getWork/startWork/doneWork这几个操作。

startForkserver:这个调用导致虚拟机启动AFL fork server。在这个调用之后,虚拟机中的每一个操作都将在虚拟机fork的副本中运行,该副本只持续到测试用例结束。

getWork:这个调用导致虚拟机从host中的文件读取下一个输入,并将其内容复制到guest的缓冲区中。

startWork:此调用允许跟踪AFL的edge map。只对startWork调用中指定的虚拟地址范围执行跟踪。此调用可以多次执行,以调整跟踪指令的范围。可以选择在驱动程序解析输入文件时跟踪它本身,然后在基于输入文件执行系统调用时跟踪内核。AFL的搜索算法只知道被跟踪的edge,这个调用提供了一种方法来调整要跟踪的部分。

doneWork:这个调用通知虚拟机测试用例已经完成。它允许驱动程序传递退出代码。虚拟机的fork副本将使用指定的退出代码退出,该代码由fork server与AFL通信,并用于确定测试用例的结果。

增加的处理提供的命令行参数的部分,可以看到传入了getWork读取的host中的文件的名称,panic函数地址和log_store函数地址。

在gen_intermediate_code_internal函数中增加了gen_aflBBlock函数。


当是panic函数时以exit(32)结束。

当是log_store函数时记录日志。

QEMU以TB为单位进行翻译并执行。这也就是说每当在code cache执行完一个TB之后,控制权必须交还给QEMU。这很没有效率。所以只要TB执行完之后,它的跳跃目标确定且该跳跃目标也已经在code cache里,那我们就把这两个TB串接起来。这个就叫做block chaining。有时QEMU开始执行一个基本块,然后被中断。然后,它可能会从一开始就重新执行该块,或者转换尚未执行的块的一部分并执行它。这将导致edge map中出现一些额外的edge。所以首先禁用QEMU的chaining特性。

将AFL的跟踪特性从cpu_exec函数移动到cpu_tb_exec函数,以便仅在基本块执行到完成时才跟踪它(AFL的新版本也移动到cpu_tb_exec函数了)。

前面我们说过AFL的qemu mode能够避免每个块被翻译多次,当模拟只有一个地址空间的用户模式程序时这个特性可以很好地工作,但是对于在不同地址空间中有许多程序的完整系统就不太合适了。目前只对内核地址使用这个特性(/TriforceAFL/docs/triforce_internals.txt中说目前没有使用这个功能,是在驱动程序运行之前运行一个heater程序实现的,后面在TriforceLinuxSyscallFuzzer中会提)。

在QEMU的内存分配函数ram_block_add中patch掉了设置QEMU_MADV_DONTFORK标志的代码以便子进程使用TB。

QEMU在模拟操作系统时使用多个线程。在大多数UNIX系统中fork一个多线程程序时,子进程中只保留调用fork的线程。fork也不保存重要的线程状态,并可能使互斥锁、信号量等等处于未定义的状态。为了解决这个问题,我们并不立即启动fork server,而是设置了一个标志来告诉CPU停止。当CPU看到这个标志设置时退出CPU循环,向IO线程发送通知,记录一些CPU状态,然后退出CPU线程。IO线程接收它的通知并执行一个fork。此时只有两个线程——CPU线程和内部RCU线程。RCU线程已经被设计用于正确处理fork,不需要停止。在子进程中,CPU将使用之前记录的信息重新启动,并可以从停止的地方继续执行。


此外还新增了一个qemu_mode/qemu/block/privmem.c文件,这是一个存储驱动程序,模拟普通IDE磁盘并支持写时拷贝。

TriforceAFL对AFL的修改

TriforceAFL对AFL的修改不多,主要有下面几点:增加了默认内存限制。操作系统在调用fork server之前可能需要几分钟启动,所以增加了AFL等待fork server的时间。因为虚拟机使用退出代码显示panic和其它不希望出现的行为,所以将所有非零退出状态视为崩溃。一些标准的AFL实用程序不支持fork server特性。当测试程序可以在几分之一秒内执行时,这通常是可以接受的。然而,测试用例只能在漫长的操作系统启动过程之后启动,而测试用例本身只是整个执行过程的一部分。为了正确地运行测试用例需要实用程序支持fork server特性。

TriforceLinuxSyscallFuzzer

TriforceLinuxSyscallFuzzer的整体目录如下。

  • crash_reports:发现的一些crash
  • docs:文档
  • rootTemplate&makeRoot:makeRoot根据rootTemplate中的文件为根文件系统生成ramdisk镜像。把driver复制进去,并安排init来执行它
  • aflCall.c:发起hypercall,调用startForkserver/getWork/startWork/doneWork
  • argfd.c:创建并返回系统调用参数使用的文件描述符
  • driver.c:驱动程序负责接收来自AFL的输入,将它们解析为许多系统调用记录,然后执行每个系统调用。驱动程序首先fork出一个子进程,让子进程执行主要的工作,然后等待子进程死亡。

    接下来子进程启动AFL fork server,此后的所有操作都在模拟器fork出的副本中进行。然后它调用getWork从AFL获得一些输入数据。然后调用startWork在解析输入数据时开始跟踪驱动程序。然后再次调用startWork来停止跟踪驱动程序并开始跟踪内核。最后,它在调用doneWork之前执行已解析的系统调用,以通知AFL测试用例已经完成。

    从AFL运行时,startForkserver和doneWork调用之间的所有内容都将在虚拟机的fork副本中执行。这个过程将对每个输入文件重复一次。如果驱动程序在到达doneWork调用之前发生crash,则主进程将捕捉到它,并代表crash的子进程调用doneWork
  • gen.py\gen2.py\gen2-shapes.txt:生成驱动程序使用的格式的系统调用输入文件
  • getSyms:使用runCmd执行cat /proc/kallsyms然后将输出提取到kallsyms文件
  • getvmlinux:从bzImage中提取vmlinux文件
  • heater.c:之前已经提到了heater,它会调用稍后测试的系统调用
  • parse.c:一些解析函数
  • runCmd:启动内核并运行命令。如果没有参数它将执行一个shell,否则它将运行指定的
    命令,命令应该存在于rootTemplate\bin中
  • runFuzz:启动fuzz
  • runTest&testAfl.c:需要复现crash时使用
  • sysc.c:生成系统调用参数并发起系统调用

KAFL

我们再来看看KAFL。下图所示是KAFL的整体架构。

KAFL整体分为三个部分:fuzz逻辑,VM(对QEMU和KVM的patch)和用户态agent。fuzz逻辑在host上作为一个用户态进程运行,主要就是借鉴AFL的逻辑。VM由一个用户态组件(QEMU-PT)和一个内核态组件(KVM-QT)组成。guest通过hypercall与host通信。host可以读写guest内存,并在处理请求后继续VM执行。
大体上和TriforceAFL还是有一些相似之处的,一个主要的不同之处在于KVM-PT和QEMU-PT还分别实现了Intel PT数据的收集和解码用于fuzz逻辑,论文中经过对比性能优于TriforceAFL。

总结

总的来说这两个工具有一定创新,但是实际发现的漏洞都不太多,并且后续也处于没有继续维护的状态。对于一般的内核漏洞挖掘目前应该还是syzkaller各方面更好一点。

参考资料

1.Internals of AFL fuzzer - QEMU Instrumentation

点击收藏 | 1 关注 | 2 打赏
登录 后跟帖