0x00 写在前面

本文主要对 Syzkaller 的学习过程进行记录,其中不少内容都是来自个人对源码的研读。由于是目的导向性的,所以整个文本都以某种功能为需求,然后再找相关代码的实现进行记录的,因此可能并不适合新学者进行系统性的学习。

由于是初学者写的笔记,对 golang 也不是特别熟悉,基本就是能看懂,能简单改改的地步,所以可能有些谬误之处。

0x01 coverage 检测方法

该部分来自对 coverage 的整理和翻译。

Syzkaller 使用 kcov 来收集内核的覆盖率信息。kcov 将会探索每个被执行的基本块的地址,然后 Syzkaller 在运行时会使用 binutils (objdump , nm , addr2line , readelf) 中的工具来映射这些地址为源代码中的函数和 lines

Binutils

readelf

使用 readelf 来获取虚拟内存偏移。具体使用的命令是:

1
readelf -SW kernel_image
  • -S : 列举 kernel image 文件中的所有 section headers。
  • -W : 每一行单独输出每个 section 的 header 。

输出的数据大约是下面这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
There are 59 section headers, starting at offset 0x3825258:

Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS ffffffff81000000 200000 e010f7 00 AX 0 0 4096
[ 2] .rela.text RELA 0000000000000000 23ff488 684720 18 I 56 1 8
[ 3] .rodata PROGBITS ffffffff82000000 1200000 2df790 00 WA 0 0 4096
[ 4] .rela.rodata RELA 0000000000000000 2a83ba8 0d8e28 18 I 56 3 8
[ 5] .pci_fixup PROGBITS ffffffff822df790 14df790 003180 00 A 0 0 16
[ 6] .rela.pci_fixup RELA 0000000000000000 2b5c9d0 004a40 18 I 56 5 8
[ 7] .tracedata PROGBITS ffffffff822e2910 14e2910 000078 00 A 0 0 1
[ 8] .rela.tracedata RELA 0000000000000000 2b61410 000120 18 I 56 7 8
[ 9] __ksymtab PROGBITS ffffffff822e2988 14e2988 011b68 00 A 0 0 4
[10] ...

Syzkaller 中的 executor 会将 PC 中的值截断保存为 uint32 的值,而后发送给 syz-managersyz-manager 将会使用 section header 中的信息来恢复计算偏移。Syzkaller 中只考虑 TypePROGBITS 的 section header (这个字段是啥含义?为啥只考虑这个部分)。Address 字段表示一个 section 在内存中的虚拟地址。要求所有的 PROGBITS 的 section 具有相同的 32 位高地址 (0xffff ffff),这 32 位将用于进行偏移恢复。

Reporting coverage data

MakeReportGenerator 这个玩意创建了一个数据库对象来进行报告。它需要目标数据,以及有关源文件和构建目录位置的信息。而构建数据库的第一步是从目标二进制文件中提取函数数据。

nm

nm 是用于处理 kernel 中每个函数的地址和大小的。

1
nm -Ptx kernel_image
  • -P - 使用便携的输出格式(标准输出
  • -tx - 以 hex 的格式写数字

输出数据大概是下面的样子:

1
2
3
tracepoint_module_nb d ffffffff84509580 0000000000000018
...
udp_lib_hash t ffffffff831a4660 0000000000000007
  • 第一列是符号的名字。
  • 第二列是它的类型(text section , data section , debugging symbol , undefined , zero-init section , etc)
  • 第三列是它 hex 形式的符号值。
  • 第四列是它的大小。在 Syzkaller 中大小总是四舍五入到 16。

对于覆盖率报告, Syzkaller 只关注于代码段(code section),所以会对 nm 的输出进行过滤,只要类型是 t or T 的符号。最终的结果是一个使用符号作为键,使用符号的起始和终止地址为值的字典。这个数据将用于覆盖数据到符号(函数)的映射。需要此步骤来查明是否调用了某些函数。

Object Dump and Symbolize

为了给 Syzkaller 提供必要的信息用于捕获覆盖率信息,需要对编译器进行插桩,让其在编译阶段在每个基本块生成的时候,插入 __sanitizer_cov_trace_pc 调用。该调用可以作为一个锚点指令,返回覆盖的代码的行数(backtrack the covered code lines)。

objdump

objdump 被用于处理内核镜像中每个调用 __sanitizer_cov_trace_pc 时的 PC 的值。(这时的 PC 值就是下一个要执行的指令的地址,而这个又是我们给每个基本块中插入的,那这样不就捕获到了每个被执行的基本块的地址了吗?)这些 PC 值表示内核映像中内置的所有代码。将 kcov 导出的 PC 值与这些值进行比较以确定覆盖率。(我不明白,不相信kcov?还是不相信自己的插桩?这个做法还会有什么错的情况吗?)

内核 image 可以使用下面的命令反汇编。

1
objdump -d --no-show-raw-insn kernel_image
  • -d - 反汇编可执行代码块
  • -no-show-raw-insn - 防止在符号反汇编的同时打印十六进制

部分输出大概长这个样子

1
2
3
4
5
6
7
8
9
10
11
12
13
...
ffffffff81000f41: callq ffffffff81382a00 <__sanitizer_cov_trace_pc>
ffffffff81000f46: lea -0x80(%r13),%rdx
ffffffff81000f4a: lea -0x40(%r13),%rsi
ffffffff81000f4e: mov $0x1c,%edi
ffffffff81000f53: callq ffffffff813ed680 <perf_trace_buf_alloc>
ffffffff81000f58: test %rax,%rax
ffffffff81000f5b: je ffffffff8100110e <perf_trace_initcall_finish+0x2ae>
ffffffff81000f61: mov %rax,-0xd8(%rbp)
ffffffff81000f68: callq ffffffff81382a00 <__sanitizer_cov_trace_pc>
ffffffff81000f6d: mov -0x40(%r13),%rdx
ffffffff81000f71: mov 0x8(%rbp),%rsi
...

从这个输出来看,覆盖率 trace 调用时被标记来判别可执行区块的起始地址的。(从这个输出覆盖跟踪调用被识别以确定可执行块地址的开始)。(From this output coverage trace calls are identified to determine the start of the executable block addresses:)(我的理解是,应该是说上面这一堆中,我们应该关注的有用的信息是下面这两行)

1
2
ffffffff81000f41:	callq  ffffffff81382a00 <__sanitizer_cov_trace_pc>
ffffffff81000f68: callq ffffffff81382a00 <__sanitizer_cov_trace_pc>

addr2line

addr2line 被用于映射 kcov 发现的 PC 值,然后被 objdump 进一步处理成源码文件和行。

1
addr2line -afip -e kernel_image
  • -afip - 显示地址、函数名字和 unwind ilined functions 和适合人类读。
  • -e - 是用于指定可执行文件,而不是使用默认值 a.out

addr2line 从标准输入中读取 16 进制地址,然后在标准输出打印每个地址的文件名、函数和行号。

使用示例,其中 >> 表示询问, << 表示 addr2line 的响应:

1
2
3
4
>> ffffffff8148ba08
<< 0xffffffff8148ba08
<< generic_file_read_iter
<< /home/user/linux/mm/filemap.c:2363

但是我实际实测发现输出的都是 ?? 这样的样子,我还以为是没有符号表,网上查了查,好像是这个命令只接受相对偏移而不是一个绝对地址,应该减去起始地址。但是我还是只能整出来这些 ??

最终的目标是构建一个 frames 的哈希表,键是 PC 值,值是一个 frame array 由下列的信息组成:

  • PC - 64 位的 program counter 的值,与键一样。
  • Func - 这个 frame 属于的函数的名字
  • File - 文件名(函数或者 frame 所属的)
  • Line - PC 映射所对应的一个文件中的行号
  • Inline - 布尔值 inlining 信息。由于 inlining 的存在,可能会有很多个 frame 能够被连接到同一个 PC 值上。

Create report

一旦 frames 和函数地址范围的数据库构建成功后,下一步就是去确定程序(program)的覆盖率。每个 program 在这里就是一系列 PC 值。此时每个函数的地址范围都是已知的,所以很容易通过简单迭代比较 PC 中的值和函数的范围来判断到底是哪个函数被调用了。此外基于以 PC 值为键的 frame 哈希表中,覆盖率信息被聚集到了源文件上。这些被称为 coveredPCs 。覆盖率结果并不是基于行的而是基于基本块的。最终的结果是保存在如下的文件结构中。

  • lines - 文件中被覆盖的行。(lines coverd in the file
  • totalPCs - 这个文件中总共被标注的 PC
  • coveredPCS - 这个 program 执行过程中被执行了的 PC
  • totalInline - 被映射到 inlined frames 上的 PC 的数量
  • coveredInline - 在这个 program 执行的过程中被映射到 inlined frames 上的 PC

问题:不是很懂这个文件开始保存的时机或者说这些 PC 拿到时候的时机。

0x02 go func & feature

对一些 go 的语法和函数进行一个解释, syzkaller 中关于随机相关的定义在 prog/rand.go:line 561

go 疑似函数命名必须驼峰;不用的变量,例如遍历字典时需要使用 _ 不然就会报错

r.Intn(n)

每次调用 r.Intn(n) 是重新随机执行,返回一个 [0, n) 中的整数。参考自: link .

1
2
3
4
5
6
7
// Intn, Int31n, and Int63n limit their output to be < n.
// They do so more carefully than using r.Int()%n.
r.Intn(10) // 返回一个 -1 < x < 10 的整数

// 每次调用都是重新进行随机
show("Intn(10)", r.Intn(10), r.Intn(10), r.Intn(10))
// Intn(10) 1 2 5

r.oneOf(n)

每次调用 r.oneOf(n) 是重新随机执行,返回一个 [0, n) 中的整数是否是 0 ,即 以 1/n 的概率返回 truelink .

1
2
3
4
// 显然这个就是以 1/n 的概率返回 true , n 越大 概率越小
func (r *randGen) oneOf(n int) bool {
return r.Intn(n) == 0
}

r.nOutOf(n, oufOf)

每次调用 r.nOutOf(n, outOf) 是重新执行,以 n/outOf 的概率返回 truelink .

1
2
3
4
5
6
7
8
9
// r.nOutOf(n, outOf) 以 n/outOf 的概率返回 true
// nOutOf returns true n out of outOf times.
func (r *randGen) nOutOf(n, outOf int) bool { // line: 354
if n <= 0 || n >= outOf {
panic("bad probability")
}
v := r.Intn(outOf)
return v < n
}

r. biasedRand(n, k)

每次调用 biasedRand(n, k) 返回一个 [1,n) 中的值,但是返回 n-1 的概率是 0k 倍。

1
2
3
4
5
6
7
8
// biasedRand returns a random int in range [0..n),
// probability of n-1 is k times higher than probability of 0.
func (r *randGen) biasedRand(n, k int) int {
nf, kf := float64(n), float64(k)
rf := nf * (kf/2 + 1) * r.Float64()
bf := (-1 + math.Sqrt(1+2*kf*rf/nf)) * nf / kf
return int(bf)
}

Sort.Search(int, func)

该函数 Search uses binary search to find and return the smallest index i in [0, n) at which f(i) is true,

1
func Search(n int, f func(int) bool) int

switch

实测 goswitch 会自动 break

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

func main() {
opt := 1
switch {
case opt == 1:
fmt.Println("1")
case opt == 2:
fmt.Println("2")
default:
fmt.Println("default")
}
}

运行之后会只打印 1 而不会有后面的值。

1
2
$ go run test.go
1

0x03 mutation 规则

对 Syzkaller 的具体变异规则的简单分析。

变异模式

其实就是以一定的概率选择下面的变异模式:

  1. squashAny

    随机选择 p 中一个复杂指针并将其参数压缩为 ANY。随后,如果 ANY 包含 blob,则变异一个随机 blob

    我感觉就是挑一个复杂的指针变异其中的某一个参数即可。

    • 如果 p 中维护的复杂指针数组 (complexPtrs) 是空的,直接退出,变异失败。
    • 否则随机在其中选择一个成为 ptr ,如果不幸选中的 ptr 所属的 call 不可变异,则直接退出,变异失败。
    • 如果当前 ptr.arg 没有任何指针,则主动调用 squashPtr 将其压缩成指针。
    • 然后对 ptr 中的数据参数和相应基址分别压缩到两个数组中。如果发现最后 blob 数组为空,直接退出,变异失败。
    • 然后需要在对 blob 进行变异前进行分析,不然可能因为这个变异引入越界,导致分析过程越界报错。
    • 然后对 blob 中随机选择一个参数来进行数据变异,最后返回变异成功。
  1. splice

    • 如果当前种子库为空、或当前 p 一个 call 都没有、或当前 p 拥有足够数量的 call ,则直接退出,变异失败。

    • 从当前种子库中随机选择一个 p0 ,然后在当前 p 中随机选一个位置 idx

    • p0 插入到 pidx 前面,组合成一个超级大的 p1p1=p[:idx]+p0+p[idx:]

    • 然后将 p1 中的 call 从末尾开始一个一个进行移除,直到 p1 中的 call 的数量刚好够 ncall

感觉有可以优化的空间,如果太长了可以直接计算填满所缺的长度,然后切片就好了啊,为啥要无脑拼接,然后一一移除。
  1. insertCall

    • 如果程序已经有不少于 ncallcall 了,则直接退出,变异失败。

    • 在现有的 p 中随机选择一个位置(偏向于现有 p 的末尾,末尾的概率是开头的 5 倍),在其前方插入一个新生成的 call (通过调用 generateCall 来生成)。

    • 若插入之后 pcall 的数量超过了 ncall ,则会主动调用 RemoveCall 移除刚才选中的那个 call 。(感觉怎么都不会走到这一步呀,前面明明只有小于时才会插入一个 call 那最多也是等于,怎会大于呢?

  2. mutateArg

    随机选择一个 call 的参数进行变异。

    • 如果当前 p 一个参数都没有,直接退出,变异失败。
    • 基于 call 的参数的复杂性来选择一个 call ,这里似乎没有随机,总会选到当前 p 中参数最复杂的 call ,不同类型的参数会有一个计算规则,总之是可以计算得到一个浮点数的,然后最后加起来排序即可。如果所有的 call 都没有参数,那么直接退出,变异失败。
    • 如果不幸选中的这个 call 是不能进行变异的,直接退出,变异失败。
    • 然后开启一个循环,退出条件 (依据类型进行参数变异成功 and 1/3 的概率)
    • 调用 analyze 根据选择表、种子库、p 、选中的 call 进行分析得到状态 s
    • 然后调用 (*Target)mutateArg 真实进行参数变异,根据参数的类型选择实现设计好的变异策略。然后还会维护好上下文中的基地址不能变。
    • (*Target)mutateArg 变异得到一串 calls
    • 然后将变异得到的 calls 插入到前面选中的 call 前面。如果插入后过长了(大于 ncall),就开始对一个一个去移除 p 中的 call 直到等于 ncall
    • 然后判断 一些不应该出现的异常情况后,返回变异成功。
  3. removeCall

    • 如果当前 p 是空的,则直接退出,变异失败。
    • 否则从 p 中随机选择一个 idx ,然后主动调用 RemoveCall 移除。

具体变异过程

在源码 /prog/mutation.go#L26 可以找到和变异相关的函数。

当调用函数 Mutate 后会传入相关的一些必要的参数对当前的prog进行变异,该部分描述的是对一个prog进行的最外层的粗粒度的5种变异手法,这5种变异中除了mutateArgsquashAny 的选择有一定的设计外,其余的都是简单的随机法则 ,只保证不会少于0个,不会多于 ncall个和不会出现黑名单中的syscall。

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
// Mutate program p.
//
// p: The program to mutate.
// rs: Random source.
// ncalls: The allowed maximum calls in mutated program.
// ct: ChoiceTable for syscalls.
// noMutate: Set of IDs of syscalls which should not be mutated.
// corpus: The entire corpus, including original program p.
func (p *Prog) Mutate(rs rand.Source, ncalls int, ct *ChoiceTable, noMutate map[int]bool, corpus []*Prog) {
r := newRand(p.Target, rs) // 初始化一个随机相关的玩意
if ncalls < len(p.Calls) { // 变异后的prog的长度至少是当前还没变异的prog的长度(不明白
ncalls = len(p.Calls)
}
ctx := &mutator{ // 维护变异相关上下文
p: p, // The program to mutate.
r: r, // The randGen instance.
ncalls: ncalls, // The allowed maximum calls in mutated program.
ct: ct, // ChoiceTable for syscalls.
noMutate: noMutate, // Set of IDs of syscalls which should not be mutated.
corpus: corpus, // The entire corpus, including original program p.
}
// 开始循环,退出条件为 (变异成功 and prog 长度不为0 and 1/3 的概率),否则继续进行
for stop, ok := false, false; !stop; stop = ok && len(p.Calls) != 0 && r.oneOf(3) {
switch {
case r.oneOf(5): // 以 1/5 的概率选择这个模式
// Not all calls have anything squashable,
// so this has lower priority in reality.
ok = ctx.squashAny() //
case r.nOutOf(1, 100): // 在剩下的情况中, 以 1/100 的概率选择这个模式
ok = ctx.splice() // 在corpus中随机挑一个插入当前prog的随机一个位置
case r.nOutOf(20, 31): // 在剩下的情况中, 以 2/3 的概率选择这个模式
ok = ctx.insertCall() // 倾向于靠近prog的后段插入一个syscall
case r.nOutOf(10, 11): // 在剩下的情况中, 以 9/10 的概率选择这个模式
ok = ctx.mutateArg()
default: // 最后剩下的情况就是选择这个模式
ok = ctx.removeCall() // 随机移除一个 syscall
}
// 所以总的来说,真实的概率就是
// squashAny: 1000/5000 = 0.2000,
// splice: 40/5000 = 0.0080,
// insertCall: 2640/5000 = 0.5280,
// mutateArg: 1188/5000 = 0.2376,
// removeCall: 132/5000 = 0.0264,

// 简单排个序
// splice: 40/5000 = 0.0080,
// removeCall: 132/5000 = 0.0264,
// squashAny: 1000/5000 = 0.2000,
// mutateArg: 1188/5000 = 0.2376,
// insertCall: 2640/5000 = 0.5280,
}

p.sanitizeFix() //
p.debugValidate() // 进行 debug 验证
if got := len(p.Calls); got < 1 || got > ncalls { // 长度如果是 0 应该是没法退出前面的 for 的
panic(fmt.Sprintf("bad number of calls after mutation: %v, want [1, %v]", got, ncalls))
}
}

mutateArg 规则

当选择 mutateArg 模式时,会随机变异一个系统调用的参数。具体实施时:

  1. 如果当前 prog 长度为 0 ,直接返回变异失败。
  2. 根据当前 prog 中的系统调用的参数的复杂性计算一个优先级,然后基于优先级随机选择一个系统调用,此时选择不会忽略特殊系统调用。某个参数的优先级计算规则是根据 参数变异优先级 进行。
    1. 某一个系统调用的某一个参数的优先数据与其类型相关,有一个类型优先级计算规则。
    2. 某一个系统调用的优先级理论上等于其所有参数的优先级之和。但是会在扫描到其含有某些特殊类型的参数的某些条件时提前终止对后续参数的扫描,从而提前得到当前系统调用的优先级。
    3. 当扫描完,得知所有的系统调用都没有参数时,返回 -1,使得上层变异返回变异失败。
    4. 否则基于 prog 中每个系统调用的优先级随机返回一个系统调用下标。
  3. 然后开始迭代变异,退出条件为变异成功并且1/3的概率。
    1. 每次迭代重新收集选中的系统调用 c 的参数信息。
    2. 然后对当前 prog 中的状态信息进行分析,但是会忽略c及其之后的系统调用导致的状态中的 resources 的改变。
    3. 基于 c 的参数的优先级随机选择一个参数进行参数变异。参数变异则是根据 变异规则按类型 的变异规则进行 。
    4. 如果变异失败则回到迭代开始处。
    5. 否则变异成功则将变异新生成的 calls 插入到原 prog 中 c 之前的位置,并将插入后超过最大长度限制的 syscall 从末尾处移除。
    6. 验证移除之后的prog中长度必须有效,且 idx 对应的位置还应该是 c,不然就 panic。(如果变异生成的 calls 实际上只有一个 call 的话,只有可能被选中的 c 在原 prog 中位于最后并且 prog 长度达到了最长,才会导致原有的 c 被移除,然后才会报错这个才对。

squashAny 规则

// Picks a random complex pointer and squashes its arguments into an ANY.
// Subsequently, if the ANY contains blobs, mutates a random blob.

当选择 squashAny 模式时,会随机选择一个复杂指针,然后将其参数压缩成 ANY,随后,如果 ANY 包含了多个 blob,则随机选择一个 blob 来进行变异。

复杂指针来源于定义在 prog/any.go 中的 prog.complexPtrs() 该函数会遍历当前 prog 的每一个系统调用的每一个参数,然后依据一定的规则判定某个参数是否是复杂指针。

例如下面的这些就不是复杂指针:

  • 参数返回值为空。
  • 参数方向不是 in。
  • 指针类型。

下面的类型是复杂指针:

  • 参数本身就是一个指针类型并且指向任何数组。
  • union类型并且field大于5。
  • 有长度的结构体类型。

参数变异优先级

在 Syzkaller(f325deb0) 版本中,mutation.go 中的函数 getMutationPrio 对变异优先级有如下的注释。

// TODO: find a way to estimate optimal priority values.
// Assign a priority for each type. The boolean is the reference type and it has
// the minimum priority, since it has only two possible values.

首先定义了三个全局变量,用于描述优先级的范围

1
2
3
4
5
const (
maxPriority = float64(10)
minPriority = float64(1)
dontMutate = float64(0)
)
  • 整型,不会停止迭代

    • 对于没有数值范围的或者范围不超过 256 的整型而言,其底层数据类型的位数越多优先级越高(最多64位)。 bitSize+0.1*maxPriority
    • 对于有范围的,则根据范围大小而定优先级。
      • 范围为 0 优先级就是 0, 范围是 1 优先级就是 minPriority
      • 范围不超过 15 的,假设认为是与 FlagsType 相当的,因为大多数 syscall 的可能的 flag 是小于15的范围的。所以将会去尝试所有的值。具体做法,优先级与范围大小成比例。在阈值之后,优先级是恒定的。 min(size/3, 0.9)*maxPriority
      • 范围不超过 256 的,才会被认定有范围,因为这个是一个字节最多能表示的范围。优先级恒定 maxPriority
  • 结构体类型

    • 如果这个结构体是应该被忽略的或者不特殊,则返回不变异 dontMutate ,并不停止迭代。
    • 否则则返回最高优先级,且停止迭代。
  • 枚举类型(UnionType

    • 如果这个类型应该被忽略或者(不特殊并且只有一个 field) 则返回不变异 dontMutate ,并不停止迭代。
    • 否则如果只是不特殊但是又多个field,则希望变异枚举类型本身和当前选项的值。返回最高优先级,并不停止迭代。
    • 否则就是特殊的,那就直接返回最高优先级,同时停止迭代。
  • flag类型(FlagsType),不会停止迭代

    与前面小范围整型一样,根据flag的大小正相关优先级。返回 min(size/3, 0.9)*maxPriority ,但是如果有 BitMask 则会再加一个 0.1*maxPriority

  • 指针类型(PtrType),不会停止迭代

    • 如果是特殊的指针,则返回不变异。(TODO,认为应该进行变异,但是还没有相应的代码)
    • 否则返回 0.3*maxPriority
  • 常量类型(ConstType),返回不变异,不会停止迭代。

  • CsumType,自定义类型?

    • 返回不变异,不会停止迭代。
  • ProcType?

    • 返回 0.5*maxPriority,不会停止迭代。
  • 资源类型(ResourceType)

    • 返回 0.5*maxPriority,不会停止迭代。
  • 虚拟页?(VmaType)

    • 返回 0.5*maxPriority,不会停止迭代。
  • 长度类型(LenType)

    • 返回 0.5*maxPriority,不会停止迭代,(因为根据描述,变异这个类型只会导致不正确的结果。
  • 缓冲区类型(BufferType)

    • 如果缓冲区方向是出(剩下的缓冲期方向还有入和出入两种 in&inout)或者变量没长度? 返回不变异,不会停止迭代。
    • 如果是字符串型缓冲区并且值长度为1,则通常是常量,尤其是文件名。返回不变异,不会停止迭代。
    • 如果是压缩的缓冲区(例如压缩的镜像),则应该优先变异,直接返回最高优先级,不会停止迭代。
    • 否则返回 0.8*maxPriority,不会停止迭代。
  • 数组类型(ArrayType)

    • 如果开始范围等于结束范围并且它的种类是数组范围长度,则返回不变异,不会停止迭代。
    • 否则返回最大优先级,不会停止迭代。

变异规则按类型

mutateInt

对需要变异的整数进行小范围的变化,增加或者减少 4 以内,或者随机翻转一位。

1
2
3
4
5
6
7
8
9
10
func mutateInt(r *randGen, a *ConstArg, t *IntType) uint64 {
switch {
case r.nOutOf(1, 3): // 1/3 的概率
return a.Val + (uint64(r.Intn(4)) + 1) // 对这个值随机增加 1~4
case r.nOutOf(1, 2): // 1/3 的概率
return a.Val - (uint64(r.Intn(4)) + 1) // 对这个值随机减少 1~4
default: // 1/3 的概率 随机选择一位取反
return a.Val ^ (1 << uint64(r.Intn(int(t.TypeBitSize()))))
}
}

mutateAlignedInt

对需要进行对齐的整型进行变异,主要就是在允许的范围内,变异这个数值对齐的位置。即想像成分页数据,则变异的是页码,而对应的页内位置不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func mutateAlignedInt(r *randGen, a *ConstArg, t *IntType) uint64 {
// 对需要对齐的 int 进行变异
rangeEnd := t.RangeEnd // uint64
if t.RangeBegin == 0 && int64(rangeEnd) == -1 {
// Special [0:-1] range for all possible values.
rangeEnd = uint64(1<<t.TypeBitSize() - 1) // 1000000...
}
index := (a.Val - t.RangeBegin) / t.Align // 以对齐的为单位 计算当前值所处偏移节
misalignment := (a.Val - t.RangeBegin) % t.Align // 计算偏移节里面的偏移
switch {
case r.nOutOf(1, 3): // 1/3 的概率给偏移节增加 1~4
index += uint64(r.Intn(4)) + 1
case r.nOutOf(1, 2): // 1/3 的概率给偏移节减少 1~4
index -= uint64(r.Intn(4)) + 1
default: // 1/3 的概率给偏移节 的随机一位取反
index ^= 1 << uint64(r.Intn(int(t.TypeBitSize())))
}
lastIndex := (rangeEnd - t.RangeBegin) / t.Align // 变化之后不能越界
index %= lastIndex + 1
return t.RangeBegin + index*t.Align + misalignment // 起始加上对偏移节的变异加上节内偏移就是对齐的偏移
}

(t *IntType) mutate

真正对整型数据进行变异。一半的概率直接重新生成,一半的概率根据是否需要对齐进行变异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (t *IntType) mutate(r *randGen, s *state, arg Arg, ctx ArgCtx) (calls []*Call, retry, preserve bool) {
if r.bin() { // 1/2 的概率直接重新生成
return regenerate(r, s, arg)
}
a := arg.(*ConstArg) // 剩下 1/2 的概率根据是否对齐进行变异
if t.Align == 0 {
a.Val = mutateInt(r, a, t)
} else {
a.Val = mutateAlignedInt(r, a, t)
}
// 将变异后的数值转化为满足类型位数要求的数据(即mod
a.Val = truncateToBitSize(a.Val, t.TypeBitSize())
return
}

(t *FlagsType) mutate

略微有点复杂,但是源代码中写了注释,还是简单解释了一下为啥是这样一些概率和设定。

1
2
3
4
5
6
7
func (t *FlagsType) mutate(r *randGen, s *state, arg Arg, ctx ArgCtx) (calls []*Call, retry, preserve bool) {
a := arg.(*ConstArg)
for oldVal := a.Val; oldVal == a.Val; {
a.Val = r.flags(t.Vals, t.BitMask, a.Val) // 略微复杂 prog/rand.go
}
return
}

(t *LenType) mutate

(t ResourceType/\VmaType/*ProcType) mutate

直接重新生成

(t *BufferType) mutate

数据变异函数

在 mutation.go 中的函数数组 mutateDataFuncs 定义了一系列数据变异的函数。这些变异函数统一在一个变异数组 mutateDataFuncs 中。

这个数组通过函数 mutateData 进行变异调度,以一个循环每次随机选择一个变异函数,然后当变异成功并且1/3的概率会停止迭代变异。

这个函数只在两个地方被调用了:

  • (t *BufferType) mutate 的类型为 BufferBlobRand, BufferBlobRangeBufferString 时;
  • squashAny 中。
1
2
3
4
5
6
7
func mutateData(r *randGen, data []byte, minLen, maxLen uint64) []byte {
for stop := false; !stop; stop = stop && r.oneOf(3) {
f := mutateDataFuncs[r.Intn(len(mutateDataFuncs))] // 随机选择一个变异函数
data, stop = f(r, data, minLen, maxLen)
}
return data
}

具体变异措施包括下面的函数,这些函数一般都会变异成功,毕竟只是对 bytes 进行操作,除非数据越界超过了预定义的最大或者最小范围:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Flip bit in byte. 
// 从传入的bytes中随机选择一个字节的一位进行翻转

//Insert random bytes.
// 从传入bytes中随机选择一个位置插入随机不超过16个字节的随机数据,保证不超过最长长度,同时超过最大长度后或者有1/2的概率会在插入数据后,从末尾移除多余的字节保持原来的长度

//Remove bytes.
// 从传入的bytes中随机移除中间随机不超过16个字节的数据。如果小于了最小长度或者有1/2的概率会在后面补齐0,保持原来的长度。

//Append a bunch of bytes.
// 在不超过256的范围内倾向于选择一个更小的n,然后往传入的bytes后面一直填充0,保证不会超过最大长度。

//Replace int8/int16/int32/int64 with a random value.
// 随机生成一个宽度 1 2 4 8
// 然后将传入数据的后几个不超过上面宽度的字节以上面随机宽度的随机值来替换

//Add/subtract from an int8/int16/int32/int64.
// 随机生成一个宽度 1 2 4 8
// 然后将传入数据的后几个不超过上面宽度的字节加上一个 [-maxDelta, maxDelta] 的随机数

//Set int8/int16/int32/int64 to an interesting value.
// 随机生成一个宽度 1 2 4 8
// 然后将传入数据的后几个不超过上面宽度的字节 随机一个数uint64 然后以1/10的概率选择是否要将这个随机数以8bits为单位循环右移,然后将传入数据的后几位换成这个数

syz-fuzzer

工作流程大概是这个样子

Syzkaller_work_flow

syzkaller/syz-fuzzer 文件夹下面是关于在被 fuzz 机器中执行组件的代码,这个部分是具体负责:

syz-fuzzer 进程则运行在大概率会不稳定的虚拟机上(被测试的内核上),该进程会对模糊测试进程的输入生成(generation)、变异(mutation)和最小化(minimization)等进行引导。同时会将触发了新的覆盖率的输入数据通过 RPC 返回给 syz-manager ,它还会临时启动并将需要运行的输入(program)发送给 syz-executor 进程执行。

在这个代码中,一个 proc 实际是一个在虚拟机中跑的实例,负责自己的一系列 fuzz 事情。

// Proc represents a single fuzzing process (executor).

newProc 就是生成一个新的 fuzz 进程,其中会配置一些相关的参数和变量,然后返回一个 proc 实例。

1
func newProc(fuzzer *Fuzzer, pid int) (*Proc, error)

loop 就是一个 fuzz 实例的具体主循环部分了,这是一个死循环,一直进行持续的 fuzz 直到有退出信号。正常情况下,生成和变异的比例大约是 1:99prog.RecommendedCalls 是用于指示生成和变异的 prog 中的 ncall 即指示系统调用数量。定义在 syz-fuzzer/proc.go:line 62

对该部分代码进行再次仔细的分析,会发现当 fuzzer 的工作队列不为空时将不会执行下面的生成和变异,即并不会去通过fuzzer快照生成一个新的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
// proc.go

func (proc *Proc) loop() {
generatePeriod := 100 // 生成周期,当达到这个周期时,就调用生成一个,否则就变异一个。
if proc.fuzzer.config.Flags&ipc.FlagSignal == 0 {
// If we don't have real coverage signal, generate programs more frequently
// because fallback signal is weak.
generatePeriod = 2 // 如果反馈信号很弱,就缩短生成周期,也就是提高生成的比例
}
for i := 0; ; i++ { // 任何地方开始进行执行并处理时 都会经过一个判断 然后往当前fuzzer的工作队列中添加对应的工作状态
item := proc.fuzzer.workQueue.dequeue()
if item != nil {
switch item := item.(type) { // 判断当前 fuzzer 的工作状态
case *WorkTriage:
proc.triageInput(item) // 具体对执行情况进行鉴别
case *WorkCandidate: // 就是直接执行 prog
proc.execute(proc.execOpts, item.p, item.flags, StatCandidate)
case *WorkSmash: // 在粉碎输入,由triageInput 发现添加了新输入后并结合配置文件来添加此阶段, 该阶段会强制对这个输入进行100次变异得到100个新输入,执行100次?
proc.smashInput(item)
default:
log.Fatalf("unknown work type: %#v", item)
}
continue
}
// 只有当前fuzzer的工作队列为空的时候才会进行新 synthesis 一个 prog 然后执行并收集
ct := proc.fuzzer.choiceTable // 获取当前 fuzzer 的选择表
fuzzerSnapshot := proc.fuzzer.snapshot() // 保存当前 fuzzer 获取的快照
if len(fuzzerSnapshot.corpus) == 0 || i%generatePeriod == 0 { // 种子库为空 或者 生成周期到了
// Generate a new prog.
p := proc.fuzzer.target.Generate(proc.rnd, prog.RecommendedCalls, ct) // 直接生成一个新输入
log.Logf(1, "#%v: generated", proc.pid)
proc.executeAndCollide(proc.execOpts, p, ProgNormal, StatGenerate)
} else { // 否则是选择进行变异而不是生成
// Mutate an existing prog.
p := fuzzerSnapshot.chooseProgram(proc.rnd).Clone() // 从当前快走的种子库中选择一个种子
p.Mutate(proc.rnd, prog.RecommendedCalls, ct, proc.fuzzer.noMutate, fuzzerSnapshot.corpus) // 调用上面的变异函数进行变异 得到一个变异的输入 p
log.Logf(1, "#%v: mutated", proc.pid)
proc.executeAndCollide(proc.execOpts, p, ProgNormal, StatFuzz) // 简单跑一下看
}
}
}

对于 fuzzerSnapshot.chooseProgram 定义在 syz-fuzzer/fuzzer.go:line 517 。会根据当前 fuzzer 中的一个参数值 sumPrios (猜测这个参数与当前的种子数有关),获取一个小于这个参数的随机数 randVal,然后顺序查找种子库中的第一个优先级大于等于 randVal 种子进行返回。

1
2
3
4
5
6
7
func (fuzzer *FuzzerSnapshot) chooseProgram(r *rand.Rand) *prog.Prog {
randVal := r.Int63n(fuzzer.sumPrios + 1)
idx := sort.Search(len(fuzzer.corpusPrios), func(i int) bool {
return fuzzer.corpusPrios[i] >= randVal
})
return fuzzer.corpus[idx]
}

对于 proc.fuzzer.target.Generate 定义在 prog/generation.go:line 12

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
// Generate generates a random program with ncalls calls.
// ct contains a set of allowed syscalls, if nil all syscalls are used.
func (target *Target) Generate(rs rand.Source, ncalls int, ct *ChoiceTable) *Prog {
p := &Prog{
Target: target,
}
r := newRand(target, rs)
s := newState(target, ct, nil)
for len(p.Calls) < ncalls {
calls := r.generateCall(s, p, len(p.Calls)) // (state, prog, insertionPoint)
for _, c := range calls {
s.analyze(c)
p.Calls = append(p.Calls, c)
}
}
// For the last generated call we could get additional calls that create
// resources and overflow ncalls. Remove some of these calls.
// The resources in the last call will be replaced with the default values,
// which is exactly what we want.
for len(p.Calls) > ncalls {
p.RemoveCall(ncalls - 1)
}
p.sanitizeFix()
p.debugValidate()
return p
}

而对于其中的 r.generateCall 定义在 prog/rand.go:line 561

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (r *randGen) generateCall(s *state, p *Prog, insertionPoint int) []*Call {
biasCall := -1
if insertionPoint > 0 {
// Choosing the base call is based on the insertion point of the new calls sequence.
insertionCall := p.Calls[r.Intn(insertionPoint)].Meta
if !insertionCall.Attrs.NoGenerate {
// We must be careful not to bias towards a non-generatable call.
biasCall = insertionCall.ID
}
}
idx := s.ct.choose(r.Rand, biasCall)
meta := r.target.Syscalls[idx]
return r.generateParticularCall(s, meta)
}

对于 executeAndCollide 定义在 syz-fuzzer/proc.go:line 283 。到这里我就没有继续跟了,有空再来跟。

1
2
3
4
5
6
7
8
9
10
11
12
func (proc *Proc) executeAndCollide(execOpts *ipc.ExecOpts, p *prog.Prog, flags ProgTypes, stat Stat) {
proc.execute(execOpts, p, flags, stat)

if proc.execOptsCollide.Flags&ipc.FlagThreaded == 0 {
// We cannot collide syscalls without being in the threaded mode.
return
}
const collideIterations = 2
for i := 0; i < collideIterations; i++ {
proc.executeRaw(proc.execOptsCollide, proc.randomCollide(p), StatCollide)
}
}

调用 proc.executeRaw() 后,反正是进入了 ipc.Env.Exec 来进行执行的。

ipc

该部分组件定义在 ipc/ ,其中 ipc.Env.Exec 定义在 ip c.go:line 255

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
// Exec starts executor binary to execute program p and returns information about the execution:
// output: process output
// info: per-call info
// hanged: program hanged and was killed
// err0: failed to start the process or bug in executor itself.
func (env *Env) Exec(opts *ExecOpts, p *prog.Prog) (output []byte, info *ProgInfo, hanged bool, err0 error) {
// Copy-in serialized program.
progSize, err := p.SerializeForExec(env.in)
if err != nil {
err0 = err
return
}
var progData []byte
if !env.config.UseShmem {
progData = env.in[:progSize]
}
// Zero out the first two words (ncmd and nsig), so that we don't have garbage there
// if executor crashes before writing non-garbage there.
for i := 0; i < 4; i++ {
env.out[i] = 0
}

atomic.AddUint64(&env.StatExecs, 1)
if env.cmd == nil {
if p.Target.OS != targets.TestOS && targets.Get(p.Target.OS, p.Target.Arch).HostFuzzer {
// The executor is actually ssh,
// starting them too frequently leads to timeouts.
<-rateLimit.C
}
tmpDirPath := "./"
atomic.AddUint64(&env.StatRestarts, 1)
env.cmd, err0 = makeCommand(env.pid, env.bin, env.config, env.inFile, env.outFile, env.out, tmpDirPath)
if err0 != nil {
return
}
}
output, hanged, err0 = env.cmd.exec(opts, progData)
if err0 != nil {
env.cmd.close()
env.cmd = nil
return
}

info, err0 = env.parseOutput(p, opts)
if info != nil && env.config.Flags&FlagSignal == 0 {
addFallbackSignal(p, info)
}
if !env.config.UseForkServer {
env.cmd.close()
env.cmd = nil
}
return
}

覆盖收集

triageInput 定义在 syz-fuzzer/proc.go:line 102 ,在这里测算覆盖率,进行重执行,进行最小化操作,将输入添加到种子库中,发送该种子信息给 syz-manager

实际测算覆盖率时,其实是每个输入覆盖的PC的数量。

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
76
77
78
79
80
81
82
83
84
85
86
87
func (proc *Proc) triageInput(item *WorkTriage) {
log.Logf(1, "#%v: triaging type=%x", proc.pid, item.flags)

prio := signalPrio(item.p, &item.info, item.call)
inputSignal := signal.FromRaw(item.info.Signal, prio)
newSignal := proc.fuzzer.corpusSignalDiff(inputSignal)
if newSignal.Empty() {
return
}
callName := ".extra"
logCallName := "extra"
if item.call != -1 {
callName = item.p.Calls[item.call].Meta.Name
logCallName = fmt.Sprintf("call #%v %v", item.call, callName)
}
log.Logf(3, "triaging input for %v (new signal=%v)", logCallName, newSignal.Len())
var inputCover cover.Cover
const (
signalRuns = 3
minimizeAttempts = 3
)
// Compute input coverage and non-flaky signal for minimization.
notexecuted := 0
rawCover := []uint32{}
for i := 0; i < signalRuns; i++ {
info := proc.executeRaw(proc.execOptsCover, item.p, StatTriage)
if !reexecutionSuccess(info, &item.info, item.call) {
// The call was not executed or failed.
notexecuted++
if notexecuted > signalRuns/2+1 {
return // if happens too often, give up
}
continue
}
thisSignal, thisCover := getSignalAndCover(item.p, info, item.call)
if len(rawCover) == 0 && proc.fuzzer.fetchRawCover {
rawCover = append([]uint32{}, thisCover...)
}
newSignal = newSignal.Intersection(thisSignal)
// Without !minimized check manager starts losing some considerable amount
// of coverage after each restart. Mechanics of this are not completely clear.
if newSignal.Empty() && item.flags&ProgMinimized == 0 {
return
}
inputCover.Merge(thisCover)
}
if item.flags&ProgMinimized == 0 {
item.p, item.call = prog.Minimize(item.p, item.call, false,
func(p1 *prog.Prog, call1 int) bool {
for i := 0; i < minimizeAttempts; i++ {
info := proc.execute(proc.execOpts, p1, ProgNormal, StatMinimize)
if !reexecutionSuccess(info, &item.info, call1) {
// The call was not executed or failed.
continue
}
thisSignal, _ := getSignalAndCover(p1, info, call1)
if newSignal.Intersection(thisSignal).Len() == newSignal.Len() {
return true
}
}
return false
})
}

// 这里将输入进行序列化 并计算了序列化之后到 hash
data := item.p.Serialize()
sig := hash.Hash(data)

// 打印添加到库中的日志 并发送到 manager
log.Logf(2, "added new input for %v to corpus:\n%s", logCallName, data)
proc.fuzzer.sendInputToManager(rpctype.Input{
Call: callName,
CallID: item.call,
Prog: data,
Signal: inputSignal.Serialize(),
Cover: inputCover.Serialize(),
RawCover: rawCover,
})

// 通过该 fuzzer 添加到种子库中
proc.fuzzer.addInputToCorpus(item.p, inputSignal, sig)

// 这里应该是判断当前还是否应该继续回到工作队列中继续工作
if item.flags&ProgSmashed == 0 {
proc.fuzzer.workQueue.enqueue(&WorkSmash{item.p, item.call})
}
}

0x04 cov web 的查看

Syzkaller 专门提供了web页面的覆盖率查看功能。具体实现大致在 /syzkaller/pkg/cover 中。web 页面的实现主要看文件 /syzkaller/pkg/cover/html.go

DoHTML

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// /syzkaller/pkg/cover/html.go line:27
func (rg *ReportGenerator) DoHTML(w io.Writer, progs []Prog, coverFilter map[uint32]uint32) error {
progs = fixUpPCs(rg.target.Arch, progs, coverFilter)
files, err := rg.prepareFileMap(progs)
if err != nil {
return err
}
d := &templateData{
Root: new(templateDir),
RawCover: rg.rawCoverEnabled,
}
haveProgs := len(progs) > 1 || progs[0].Data != ""
fileOpenErr := fmt.Errorf("failed to open/locate any source file")
for fname, file := range files {
pos := d.Root
path := ""
for {
if path != "" {
path += "/"
}
sep := strings.IndexByte(fname, filepath.Separator)
if sep == -1 {
path += fname
break
}
dir := fname[:sep]
path += dir
if pos.Dirs == nil {
pos.Dirs = make(map[string]*templateDir)
}
if pos.Dirs[dir] == nil {
pos.Dirs[dir] = &templateDir{
templateBase: templateBase{
Path: path,
Name: dir,
},
}
}
pos = pos.Dirs[dir]
fname = fname[sep+1:]
}
var TotalInCoveredFunc int
for _, function := range file.functions {
if function.covered > 0 {
TotalInCoveredFunc += function.pcs
}
}
f := &templateFile{
templateBase: templateBase{
Path: path, // 路径
Name: fname, // 文件名
Total: file.totalPCs, // 文件的总PC数
TotalInCoveredFunc: TotalInCoveredFunc, // 文件中有覆盖的函数的总PC数的和
Covered: file.coveredPCs, // 文件中有多少覆盖了的 PC
},
HasFunctions: len(file.functions) != 0, // 该文件是否含有函数
}
pos.Files = append(pos.Files, f)
if file.coveredPCs == 0 {
continue
}
addFunctionCoverage(file, d)
contents := ""
lines, err := parseFile(file.filename)
if err == nil {
contents = fileContents(file, lines, haveProgs)
fileOpenErr = nil
} else {
// We ignore individual errors of opening/locating source files
// because there is a number of reasons when/why it can happen.
// We fail only if we can't open/locate any single source file.
// syz-ci can mess state of source files (https://github.com/google/syzkaller/issues/1770),
// or bazel lies about location of auto-generated files,
// or a used can update source files with git pull/checkout.
contents = html.EscapeString(err.Error())
if fileOpenErr != nil {
fileOpenErr = err
}
}
d.Contents = append(d.Contents, template.HTML(contents))
f.Index = len(d.Contents) - 1
}
if fileOpenErr != nil {
return fileOpenErr
}
for _, prog := range progs {
d.Progs = append(d.Progs, templateProg{
Sig: prog.Sig,
Content: template.HTML(html.EscapeString(prog.Data)),
})
}

processDir(d.Root) // 进一步对文件夹进行递归处理 转下
return coverTemplate.Execute(w, d)
}

processDir

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
// /syzkaller/pkg/cover/html.go line:740
func processDir(dir *templateDir) {
for len(dir.Dirs) == 1 && len(dir.Files) == 0 {
for _, child := range dir.Dirs {
dir.Name += "/" + child.Name
dir.Files = child.Files
dir.Dirs = child.Dirs
}
}
sort.Slice(dir.Files, func(i, j int) bool {
return dir.Files[i].Name < dir.Files[j].Name
})
for _, f := range dir.Files {
dir.Total += f.Total // 文件夹PC总数为其中的文件的PC总数的和
dir.Covered += f.Covered // 文件夹覆盖的PC总数为其中文件的覆盖总数的和
dir.TotalInCoveredFunc += f.TotalInCoveredFunc // 文件夹的有触发的函数的PC总数为其中所有的文件中的所有的有触发的函数的PC总数的和。
f.Percent = percent(f.Covered, f.Total) // 文件覆盖率=文件覆盖数/文件总PC数
if f.TotalInCoveredFunc > 0 {
f.PercentInCoveredFunc = percent(f.Covered, f.TotalInCoveredFunc)
// 文件函数触发覆盖率=文件覆盖数/文件中的所有的有触发的函数的PC总数的和
}
}
for _, child := range dir.Dirs {
processDir(child) // 递归的方式处理其中所有子文件夹
dir.Total += child.Total // 文件夹PC总数为其子文件(夹)PC总数之和
dir.Covered += child.Covered // 文件夹覆盖PC总数为其子文件(夹)覆盖PC总数之和
dir.TotalInCoveredFunc += child.TotalInCoveredFunc
// 文件夹的有触发函数的PC总数为为其子文件(夹)的有触发函数的PC总数的和
}
dir.Percent = percent(dir.Covered, dir.Total)
if dir.TotalInCoveredFunc > 0 {
dir.PercentInCoveredFunc += percent(dir.Covered, dir.TotalInCoveredFunc)
// 文件夹函数触发覆盖率=文件夹覆盖数/文件夹中的有触发的函数的PC总数的和
}
if dir.Covered == 0 {
dir.Dirs = nil
dir.Files = nil
}
}

实例分析

覆盖率分析

Syzkaller 提供了web接口进行查看,同时也提供了命令行工具进行查看。(docs/coverage.md)

这里对web-interface进行解释。

首先是左侧边栏中的数据。

  • 左侧表示内核中的文件夹和文件。
  • 中间有两个百分数表示当前对这个文件/文件夹的覆盖率。
    • 括号外面的是:当前覆盖到的PC数量除以整个文件或者文件夹中的所有的PC数得到的百分比,也就是绝对覆盖率。
    • 括号里面是:当前覆盖到的PC数量除以当前文件或者文件夹中所有有被覆盖到的函数的总PC数量得到的百分比。反映的就是到达的函数中,这些函数有多大的比例已经被完全覆盖了。
  • 右侧两个数表示当前文件/文件夹的PC总数量。
    • 括号外面是这个文件/文件夹中的所有的PC的总数量。
    • 括号里面是,当前有到达过的函数的PC的总数量。所以随着fuzz过程继续,这个值应该会持续变大。

然后是右侧内容框中的数据。

  • 左侧表示函数的名字。
  • 中间表示这个函数覆盖的PC数除以该函数的PC总数,即该函数的覆盖率。
  • 右侧这个数字表示这个函数所拥有的PC总数。
  • 最下面的 SUMMARY 则是表示一个平均,即当前这个文件中所有的到达的PC数除以当前文件中有被覆盖到的函数的PC总数的和得到的覆盖率。(即上一个括号中的比例。
  • 最下面右侧统计的则是,当前文件中所有的有到达的函数拥有的PC总数。

源码分析与查看

而当点击文件名时,会出来这个文件对应的源码。web-interface 有解释。

全黑的行表示这一行对应的所有的PC值都被完全覆盖了。左侧会有一个数字,表明有多少个prog已触发执行与该行关联的 PC 值。点击该数字会展示最后一个执行的prog是啥。

橙色则是表示这一行相关的PC值没有完全被执行覆盖。左侧的数字含义相同。

赤红色表示 weak-uncovered,意思是这一行所示的函数或者符合根本不可能到达。但可能会因为变异优化符号表缺失或者函数修改内嵌等方式有误。

红色表示理论上可以到但是没有被覆盖到的单行?

Line is uncovered. Function (symbol) this line is in is executed and one of the PC values associated to this line. Example below shows how single line which is not covered is shown.

红色行

灰色表示没有被插桩的。PC关联过去的行根本没有被插桩或者该源码行根本没有生成任何code。

0x05 执行 prog

在Syzkaller报告了某个 bug 之后,可以自己再执行该输入。

教程文档在(docs/executing_syzkaller_programs.md)

关于执行和判别触发 crash 的输入文件 (docs/eproducing_crashes.md)将很有用。

基本思路是,用qemu将目标内核拖起来,然后将必要的文件发送到目标系统中,然后执行。

  • 首先将目标系统拖起来,这里和编译Syzkaller时一样,直接qemu拖起来即可。

  • 然后使用命令将必要的文件发送进去。
    -P 10021 是拖起来的待测试系统的端口
    -i 指定的是和目标系统 ssh 通信的认证文件,一般是 bullseye.id_rsa
    syz-execprog 就是读取 prog 文件,然后调用 executor 进行执行的程序。
    syz-executor 就是在目标系统中解析我们给定的prog并进行执行的可执行文件
    program 就是给出的想执行的 prog,疑似就是 repro.prog 中的数据即可。

    1
    scp -P 10021 -i bullseye.img.key bin/linux_amd64/syz-execprog bin/linux_amd64/syz-executor program root@localhost:

    执行命令可能会报错:Offending ECDSA key in /home/th1nk5t4ti0n/.ssh/known_hosts:18 根据错误下面的提示,会让执行一个删除命令,根据这个删除命令,把文件中冲突的已知主机的key删除即可。

  • 然后在目标系统中执行下面的命令即可。
    其中一些参数可以根据 repro.prog 中的值进行设定。

    1
    ./syz-execprog -repeat=0 -procs=8 program

eproducing_crashes.md 还讲述了怎么从多个里面找到某个、尝试最小化和使用prog2c工具。

0x06 Crash

link .

Once syzkaller detected a kernel crash in one of the VMs, it will automatically start the process of reproducing this crash (unless you specified "reproduce": false in the config). By default it will use 4 VMs to reproduce the crash and then minimize the program that caused it. This may stop the fuzzing, since all of the VMs might be busy reproducing detected crashes.

0x07 实际编译测试

Linux-6.22

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
sudo make defconfig
sudo make kvm_guest.config

sudo ./scripts/config \
-e PCI \
-e VIRTIO_PCI \
-e KCOV \
-e DEBUG_INFO \
-e KASAN \
-e KASAN_INLINE \
-e CONFIGFS_FS \
-e DEBUG_INFO_DWARF4 \
-e CONFIGFS_FS \
-e SECURITYFS \
-e CONFIG_PCI \
-e CONFIG_VIRTIO_PCI \
-e CONFIG_DEBUG_INFO \
-e CONFIG_KASAN \
-e CONFIG_KASAN_INLINE \
-e CONFIG_CONFIGFS_FS \
-e CONFIG_KCOV \
-e CONFIG_DEBUG_INFO_DWARF4

sudo make olddefconfig
sudo make -j`nproc`

0x08 一些过程命令脚本

syz_install.sh 我在实验过程中的一些实用性脚本

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# go
wget https://dl.google.com/go/go1.20.1.linux-amd64.tar.gz
tar -xf go1.20.1.linux-amd64.tar.gz

export GOROOT=`pwd`/go
export PATH=$GOROOT/bin:$PATH


sudo apt update
sudo apt install make gcc flex bison libncurses-dev libelf-dev libssl-dev git g++


# kernel
git clone --branch v6.2 git://mirrors.ustc.edu.cn/linux.git $KERNEL
cd linux
export KERNEL=`pwd`

# if you want to use clang, add `CC=clang` after make.
make defconfig # make CC=clang defconfig
make kvm_guest.config # make CC=clang kvm_guest.config

sudo ./scripts/config \
-e KCOV \
-e KCOV_ENABLE_COMPARISONS \
-e DEBUG_INFO_DWARF4 \
-e KASAN \
-e KASAN_INLINE \
-e CONFIGFS_FS \
-e SECURITYFS \
-e CONFIG_KCOV \
-e CONFIG_DEBUG_INFO_DWARF4 \
-e CONFIG_KASAN \
-e CONFIG_KASAN_INLINE \
-e CONFIG_CONFIGFS_FS \
-e CONFIG_SECURITYFS \
-e CONFIG_KCOV_ENABLE_COMPARISONS

# compare? CONFIG_KCOV_ENABLE_COMPARISONS=y

make olddefconfig # make CC=clang olddefconfig
# when writing output to /tmp/cct4h6sz.s no space left on device
make -j`nproc` # make CC=clang -j`nproc`
sudo chmod -R 777 $KERNEL

ls $KERNEL/vmlinux
# sample output - $KERNEL/vmlinux
ls $KERNEL/arch/x86/boot/bzImage
# sample output - $KERNEL/arch/x86/boot/bzImage


# cd $KERNEL/..
# image
sudo apt install debootstrap

mkdir image
cd image/
export IMAGE=`pwd`
wget https://raw.githubusercontent.com/google/syzkaller/master/tools/create-image.sh -O create-image.sh
# about line:147
# mirrors can be found at: http://www.debian.org/misc/README.mirrors (without CN ...)
# seems that ubuntu also works ?
# sudo debootstrap $DEBOOTSTRAP_PARAMS "https://mirrors.tuna.tsinghua.edu.cn/debian/"
sudo chmod +x create-image.sh

# rm --> sudo rm (about line:179)
./create-image.sh --feature full --add-perf
# if create dead, `I: Base system installed successfully.`
# and can not remove $IMAGE
# cat /etc/mtab
# sudo umount -l $IMAGE/chroot/proc
# sudo umount -l $IMAGE/chroot/sys
# sudo rm -rf $IMAGE
# ========
# BUT, I still fail to solve this problem, it will always stop after `I: Base system installed successfully.`
# even I just enter `sudo ./create-image.sh`

# if `Cannot check Release signature; keyring file not available /usr/share/keyrings/debian-archive-keyring.gpg`
# then add `--no-check-gpg`

# qemu
sudo apt install qemu-system-x86

# if you are tesing in a VM (Vmware), you should open the kvm support provide by VMware. Virtualization Engine
# use these command to check
# ls -l /dev/kvm
# lsmod | grep kvm
# verify
sudo qemu-system-x86_64 \
-m 2G \
-smp 2 \
-kernel $KERNEL/arch/x86/boot/bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=serial net.ifnames=0" \
-drive file=$IMAGE/bullseye.img,format=raw \
-net user,host=10.0.2.10,hostfwd=tcp:127.0.0.1:10021-:22 \
-net nic,model=e1000 \
-enable-kvm \
-nographic \
-pidfile vm.pid \
2>&1 | tee vm.log

# # open another terminal, and connect
# sudo ssh -i $IMAGE/bullseye.id_rsa -p 10021 -o "StrictHostKeyChecking no" root@localhost

# exit from the qemu
# 1. press ctrl+a
# 2. then press x


# test a prog use Syzkaller
scp -P 10021 -i /home/asd/Desktop/image/bullseye.id_rsa bin/linux_amd64/syz-execprog bin/linux_amd64/syz-executor program root@localhost:

# https://github.com/google/syzkaller/blob/master/docs/executing_syzkaller_programs.md
# then you need to adjust syz-execprog flags based on the values in the header. Namely, Threaded/Procs/Sandbox directly relate to -threaded/-procs/-sandbox flags. If Repeat is set to true, add -repeat=0 flag to syz-execprog.
# # repro.prog --> cat to program
# # {Threaded:false Repeat:false RepeatTimes:0 Procs:1 Slowdown:1 Sandbox: SandboxArg:0 Leak:false NetInjection:false NetDevices:false NetReset:false Cgroups:false BinfmtMisc:false CloseFDs:false KCSAN:false DevlinkPCI:false NicVF:false USB:false VhciInjection:false Wifi:false IEEE802154:false Sysctl:false UseTmpDir:false HandleSegv:false Repro:false Trace:false LegacyOptions:{Collide:false Fault:false FaultCall:0 FaultNth:0}}
# r0 = syz_open_dev$sg(&(0x7f0000000000), 0x0, 0x0)
# ioctl$SG_IO(r0, 0x2285, &(0x7f0000000580)={0x53, 0xfffffffffffffffc, 0x6, 0x0, @buffer={0x0, 0x1004, &(0x7f0000001700)=""/4100}, &(0x7f0000000040)="b79525bdb38d", 0x0, 0x4, 0x0, 0x0, 0x0})

# so run:
./syz-execprog -procs=1 -threaded=false -coverfile=coverfile program

# copy back
# input this command in vm, and then input password
# the ssh server is the target addr
scp -P 22 filename [email protected]:path_you_want_to_save





# 装 clang 疑似还有另外的教程 再看看重新整
# 这个人的 pass 的教程还有注释 https://blog.yuuoniy.cn/posts/llvm-pass-1/
# llvm
# 先装一个 clang11
sudo apt install clang-11 --install-suggests
# $ clang --version
# Ubuntu clang version 11.0.0-2~ubuntu20.04.1
# Target: x86_64-pc-linux-gnu
# Thread model: posix
# InstalledDir: /usr/bin


# 然后装 cmake 3.16.3
sudo apt install cmake


# 然后下载并根据这个测试 https://github.com/sampsyo/llvm-pass-skeleton
# 具体测试时 执行这个 $ clang -Xclang -load -Xclang build/skeleton/libSkeletonPass.* something.c
# -flegacy-pass-manager 不要了 不管其用处



# 在 编译内核时 引入我们的 pass
# 原本要执行的命令是 make CC=clang -j`nproc`
# 由于我们的 clang 引用自己的 pass 需要加很多额外的参数 clang -Xclang -load -Xclang /home/asd/Desktop/llvm-pass-skeleton/build/skeleton/libSkeletonPass.*
# 所以,我们简单搜索了一下 how to build kernel with pass ,然后不负众望的 stackoverflow 解决了问题
# https://stackoverflow.com/questions/40442218/how-to-pass-compiler-options-during-linux-kernel-compilation
# 在 内核的 makefile 中有这样的代码

# # Add user supplied CPPFLAGS, AFLAGS, CFLAGS and RUSTFLAGS as the last assignments
# KBUILD_CPPFLAGS += $(KCPPFLAGS)
# KBUILD_AFLAGS += $(KAFLAGS)
# KBUILD_CFLAGS += $(KCFLAGS)
# KBUILD_RUSTFLAGS += $(KRUSTFLAGS)

# 然后 make "KCFLAGS=-pipe -Wsomething" 就可以传递自己的自定义参数了
# 于是简单缝合一下 我们的 make CC=clang -j`nproc` 命令变成了
make "KCFLAGS=-Xclang -load -Xclang /home/asd/Desktop/llvm-pass-skeleton/build/skeleton/libSkeletonPass.*" CC=clang -j`nproc`

# # 然后就成功了,输出如下
# $ make "KCFLAGS=-Xclang -load -Xclang /home/asd/Desktop/llvm-pass-skeleton/build/skeleton/libSkeletonPass.*" CC=clang -j`nproc`
# HOSTCC scripts/basic/fixdep
# SYSHDR arch/x86/include/generated/uapi/asm/unistd_32.h
# SYSHDR arch/x86/include/generated/uapi/asm/unistd_64.h
# ......
# LINK /home/asd/Desktop/linux/tools/objtool/objtool
# HOSTCC scripts/mod/mk_elfconfig
# CC scripts/mod/empty.o
# CC scripts/mod/devicetable-offsets.s
# MKELF scripts/mod/elfconfig.h
# I saw a function called main!
# HOSTCC scripts/mod/modpost.o
# ......
# CHKSHA1 include/linux/atomic/atomic-instrumented.h
# CHKSHA1 include/linux/atomic/atomic-long.h
# I saw a function called main!
# CC arch/x86/kernel/asm-offsets.s
# I saw a function called main!
# I saw a function called common!
# CALL scripts/checksyscalls.sh
# LDS scripts/module.lds
# CC init/main.o
# HOSTCC usr/gen_init_cpio
# CC init/do_mounts.o
# CC ipc/compat.o
# CC certs/system_keyring.o


# clang 的另外安装
# https://github.com/actions/runner-images/issues/2819
# https://ubuntu.pkgs.org/20.04/ubuntu-updates-universe-arm64/llvm-11-dev_11.0.0-2~ubuntu20.04.1_arm64.deb.html
# https://llvm.org/doxygen/SimplifyCFGPass_8cpp_source.html

0x09 参考链接

主要参考自 Syzkaller 官方仓库,一些即时的参考链接已经在文中给出。