overview
这一篇是笔者在上一门同linux有关的专选课程时的结课报告。
以笔者现在的眼光来看,这篇文章有相当的糊弄学的成分。笔者在阅读源代码的过程中,过多地关注了工程性的实现,而没有触及fuzz领域最核心的问题:
- syzkaller是如何抽象结构化的输入的
- syzkaller的种子变异策略
这些部分笔者将在闲暇时间来补全,留在此处的,就暂且是一篇流水帐式的源代码阅读文章了
linux内核漏洞挖掘技术概要
当前,软件的自动化漏洞利用主要有以下三种技术: 符号执行、模糊测试(Fuzz)、污点分析。
其中,linux内核作为一个逻辑复杂的庞大项目,采用符号执行和污点分析的方法,在运行时间上开销过大,因此,目前广泛使用的方法是模糊测试(Fuzz)。
模糊测试指通过种子产生大量输入,然后根据运行信息对种子进行变异,引导产生新的输入语料,并运行测试的过程。
目前通行的内核测试框架是有Google 开源的syzkaller。 syzkaller仍然是基于覆盖率引导的fuzz框架,特别之处在于,由于内核给用户态的接口是一系列的系统调用,因此,syzkaller 将内核测试输入抽象化为一系列系统调用,采用syz-manager和syz-fuzzer的双端架构,实现了内核漏洞的快速挖掘
TODO
- [ ] 变异策略
- [ ] 语料生成
- [ ] syzlang书写
syzkaller 源代码分析
syzkaller 源代码如图,存在三个核心组件:
- syz-fuzzer
- syz-manager
- syz-executor
syz-manager 进程启动、监视和重新启动多个 VM 实例,并在 VM 内启动 syz-fuzzer 进程。 syz-manager 负责长时间存储输入语料和崩溃报告。 一般在host机器上运行。
syz-fuzzer 进程运行在待测试VM中。 syz-fuzzer 指导模糊测试过程(输入生成、变异、最小化等),并通过 RPC 将触发新覆盖范围的输入发送回 syz-manager 进程。 它还负责启动 syz-executor 进程。
每个 syz-executor 进程执行一个输入(一系列系统调用)。 它接受从 syz-fuzzer 进程执行的程序并将结果发送回。 使用用 C++ 编写,编译为静态二进制文件并使用共享内存进行通信。
syz-fuzzer
main
fuzzer初始化
1 | debug.SetGCPercent(50) |
首先获取了相关参数。
1 | shutdown := make(chan struct{}) |
然后启动了一个协程实现来检测shutdown信号,如果出现shutdown,需要停机并退出。
连接manager
1 | log.Logf(1, "connecting to manager...") |
接下来启动进程连接syz-manager。manager 会检查本机环境并返回检查结果,然后根据检查结果设置一些执行选项,准备沙箱环境。
fuzzer process
1 | log.Logf(0, "starting %v fuzzer processes", *flagProcs) |
接下来根据配置启动N个fuzz协程,每个协程对应一个VM实例。
1 | fuzzer.pollLoop() |
proc.loop
proc.loop是进程运行的核心代码
1 | func (proc *Proc) loop() { |
判断配置是否没有启用真实的覆盖信号反馈(real coverage signal)。
如果没有启用真实覆盖信号,则将 generatePeriod
置为2,意味着每2个循环就随机生成一个新的测试用例。
之所以这么做是因为,如果没有真实的覆盖信号,只依赖fallback signal,那么信号会很弱。因此需要更频繁地生成新的测试用例来弥补。
1 | for i := 0; ; i++ { |
每次从 workQueue
中取出一个测试用例,并根据测试用例的不同类型来解析
1 | ct := proc.fuzzer.choiceTable |
获取choice table和corpus的快照copy。
根据条件选择逻辑:
- 如果corpus为空或每100次循环执行一次,则通过
Generate
完全随机生成一个新的测试用例prog; - 否则,从corpus中随机选择一个case作为基础,通过
Mutate
进行变异生成新的prog。
生成的prog通过executeAndCollide
执行和碰撞检测。
fuzzer.pollLoop
主线程在启动这些协程之后所需要做的工作其实就是响应这些协程的请求,并负责与 syz-manager 间进行 RPC 通信,通过一个不会返回的 pollLoop()
函数完成,该函数核心其实就是一个无限循环:
- 循环等待
ticker
(每 3s 响应一次的计时器)或fuzzer.needPoll
这两个 channel 之一有数据传来 - 如果是
fuzzer.needPoll1
传来请求或是距离上次 poll 的时间大于 10s: - 检查 workQueue 是否需要新的 candidate(candidate 数量少于 executor 数量),若不是且本次请求处理为
fuzzer.needPoll
传来请求,则等到到距离上次 poll 的时间大于 10s。
收集 executor 数据,调用 poll() 通过 RPC 向 syz-manager 获取新的 candidate
1 | func (fuzzer *Fuzzer) pollLoop() { |
syz-manager
main
1 | func main() { |
主要是解析参数和配置文件,然后调用RunManager
RunManager
1 |
|
首先创建了vmPool
, 用来管理VM资源
1 | crashdir := filepath.Join(cfg.Workdir, "crashes") |
然后初始化了测试语料库并创建了crash的记录文件, 接着初始化了一个HTTP服务器,用来在本地端口以Web页面的形式呈现测试结果
1 | mgr.preloadCorpus() // 准备输入语料 |
1 | go func() { |
这部分代码定义了一个匿名goroutine函数,主要完成了以下工作:
- 定义一个循环,按照10秒的间隔周期性执行
- 计算从上次统计到当前时间段的执行时间差值diff
- 获取mgr对象的各种统计指标:
- execTotal: 执行的测试用例总数
- crashes: 崩溃的测试用例数
- corpusCover: 测试用例覆盖的代码行数
- corpusSignal/maxSignal: 获取的代码覆盖信号总值和最大信号值
- triageQLen: 等待处理的候选测试用例数
- 加载处理测试用例的虚拟机数量,复现测试的用例数等指标
- 将上述统计指标打印输出一次日志
1 | osutil.HandleInterrupts(vm.Shutdown) |
实现了针对VM corruption的处理
1 | mgr.vmLoop() |
最后是一个主循环,用来做任务处理
manager.vmLoop
1 | log.Logf(0, "booting test machines...") |
首先初始化了一些变量,用来通信和复现
1 | mgr.mu.Lock() |
加锁访问 mgr
的相关变量
1 | for crash := range pendingRepro { |
对于没有尝试过复现的crash,加入复现队列
1 | canRepro := func() bool { |
1 | wait: |
这部分代码是用来实现虚拟机管理的核心代码:
- instances.Freed: 处理空闲的虚拟机实例
- stopRequest:发出停止虚拟机的请求
- runDone: 处理虚拟机运行结束的结果
- 如果运行失败,打印错误
- 释放实例,增加到空闲池
- 如果本次运行触发了crash,保存crash并添加到待repro队列
- reproDone: 处理repro结束的结果
- 更新repro任务计数
- 打印repro的结果
- 如果repro失败,记录信息
- 从正在repro列表删除
- 根据repro的结果保存信息
- shutdown: 检测到关闭信号,开始关闭
- hubReproQueue: 从主机获取的待repro crash
- needMoreRepros: 返回还有待repro的crash状态
- reproRequest: 返回当前正在repro的crash列表
sys-executor
sys-executor 是一个使用C++写的执行器,用来真正执行 测试语料
1 | if (argc == 2 && strcmp(argv[1], "version") == 0) { |
程序首先解析了一系列参数
1 | start_time_ms = current_time_ms(); |
1 |
|
接下来是一些准备工作
1 | use_temporary_dir(); // 创建临时目录 |
然后是关于测试覆盖率的计算:
1 | if (flag_coverage) { |
然后开始创建执行sandbox:
1 |
|
最后执行错误处理
1 | #if SYZ_EXECUTOR_USES_FORK_SERVER |
使用syzkaller进行漏洞挖掘
环境配置
编译syzkaller:
1 | go get -u -d github.com/google/syzkaller/prog |
编译目标内核的内核版本是linux-6.5.4
开启相关debug选项:
1 | CONFIG_KCOV=y |
创建镜像:
1 | sudo apt-get install debootstrap |
qemu启动脚本和qemu.cfg如下:
运行syzkaller
在经过3、4天的运行后,结果如下:
漏洞分析
这里选择 memory leak in iov_iter_extract_pages
来进行分析。
首先查看crash时的函数调用栈:
定位 iov_iter_exxtract_pages
函数, 这个函数从基于用户空间内存的迭代器中提取出一组连续的页面,并对这些页面加锁pin。
1 | // lib/iov_iter.c |
而syzkaller给出的漏洞是memory leak,即存在分配未释放的内存。
在wamt_pages_array
三个函数中筛选
1 | static int want_pages_array(struct page ***res, size_t size, |
发现只有 want_pages_array 中存在堆内存分配。
如果:
1 | if (unlikely(res <= 0)) |
此时,程序错误返回,前面分配的pages空间却并没有被释放,因此,会引发内存泄漏。
复现的C代码如下:
1 | // autogenerated by syzkaller (https://github.com/google/syzkaller) |
由于没有对syzkaller自定义syzlang,因此此漏洞大概率能被syzbot(一个基于syzkaller的内核自动测试bot)发现:
通过搜索,果然找到了2023-08-17的相关讨论:https://lore.kernel.org/all/000000000000e32603060314b623@google.com/T/
1 | diff --git a/lib/iov_iter.c b/lib/iov_iter.c |
查看修复patch,符合之前发现的漏洞情形