【FUZZ.0x03】syzkaller - III:syz-fuzzer 源码分析

本文最后更新于:2023年12月30日 晚上

(摘下眼镜)Furry?(戴上眼镜)Fuzzer!

0x00. 一切开始之前

上一篇文章当中我们分析了 syzkaller 三大组件当中的 syz-manager 的源码,本篇文章我们继续来分析 syz-fuzzer 的源码

与 syz-manager 所不同的是,syz-fuzzer 位于 Guest VM 当中,本质上涵盖了一个传统 fuzzer 的所有基本功能:输入生成与变异、启动新进程(syz-executor)执行输入、获取覆盖率信息…

在 syz-fuzzer 运行过程中其会通过 RPC 调用 syz-manager 中的部分函数,因此本篇也会分析上篇所未涉及到的 syz-manager 中的一些函数:)

这一系列文章确实鸽了挺久了,但最近确实是比较忙没啥时间写博客(这一篇其实之前大致的初稿就写好了,但是后面又鸽掉了…),不知道下一篇会是什么时候了 XD

0x01. 基本结构体

相比于直接开始一头雾水地分析源码,笔者认为还是有必要在此之前先列出一些基本的结构体,你也可以把这一节当成一个表来查 :)

Fuzzing 过程相关

1. Syscall:单个系统调用的基本信息

syzkaller 以系统调用为基本输入单位,定义于 prog/types.go 中的 Syscall 结构体被用来表示单个系统调用的基本信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Syscall struct {
ID int
NR uint64 // kernel syscall number
Name string
CallName string
MissingArgs int // number of trailing args that should be zero-filled
Args []Field
Ret Type
Attrs SyscallAttrs

inputResources []*ResourceDesc
outputResources []*ResourceDesc
}

当我们编写 syzlang 文件时,我们可以为系统调用添加一些属性,这被封装于内嵌在 Syscall 里的 SyscallAttrs 结构体中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SyscallAttrs 表示 syzlang 的调用属性.
//
// 该结构为系统所有其他部分的 source of truth.
// pkg/compiler 使用该结构对描述进行语法分析.
// syz-sysgen 使用该结构为 executor 生成代码.
//
// 目前仅支持 `bool`s 与 `uint64`s.
//
// 对于独立属性的描述,参见 docs/syscall_descriptions_syntax.md
type SyscallAttrs struct {
Disabled bool
Timeout uint64
ProgTimeout uint64
IgnoreReturn bool
BreaksReturns bool
NoGenerate bool
NoMinimize bool
}

2. ChoiceTable:系统调用生成优先级表

定义于 prog/prio.go 中的 ChoiceTable 是 syz-fuzzer 中非常核心的一个结构,其用于表示对于给定的系统调用 x,在添加入系统调用 y 后,代码覆盖率会上升的可能性

1
2
3
4
5
6
7
8
// ChooseTable(译注:原文如此)允许对给定的一个系统调用,
// 对一个系统调用进行一次权重选择,这基于调用到调用的优先级与开启&可生成的系统调用集合
type ChoiceTable struct {
target *Target /* 目标架构信息 */
runs [][]int32 /* 优先级表 */
calls []*Syscall /* 系统调用信息 */
noGenerateCalls map[int]bool /* 不用于输入生成的系统调用号 */
}

优先级权重值存放在二维数组 run 中,需要注意的是这个优先级为累加值,例如有给定的三个系统调用 A、B、C,有这样的一个生成优先级表 prios

1
2
3
4
A B C
A 1 2 1
B 2 1 2
C 3 1 2

那么在 run 表中则会将一列的结果逐个加起来,从而有这样的一个结果:

1
2
3
4
A B C
A 1 3 4
B 2 3 5
C 3 4 6

为什么要这么做笔者也不知道 :(

3. weights:权重

定义于 prog/prio.go 中的 weights 结构体用于表示【目标系统调用的权重】:

1
2
3
4
5
type weights struct {
call int /* 系统调用号 */
in int32 /* 以该系统调用作为输入的权重值? */
inout int32 /* 以该系统调用作为输出的权重值? */
}

4. Call:用作输入的单个系统调用

syzkaller 的 fuzzing 以 syscall 为单位,Syscall 结构体用于表示单个系统调用的基本信息,Call 结构体则表示带有参数的一个系统调用,是 syzkaller 中所谓的最小输入单位

1
2
3
4
5
6
7
type Call struct {
Meta *Syscall
Args []Arg
Ret *ResultArg
Props CallProps
Comment string
}

5. ArgCtx:带上下文的参数

系统调用的参数用 Arg 接口表示, ArgCtx 为实现了该接口的带有上下文的一个参数:

1
2
3
4
5
6
7
type ArgCtx struct {
Parent *[]Arg // GroupArg.Inner (for structs) or Call.Args containing this arg.
Fields []Field // Fields of the parent struct/syscall.
Base *PointerArg // Pointer to the base of the heap object containing this arg.
Offset uint64 // Offset of this arg from the base.
Stop bool // If set by the callback, subargs of this arg are not visited.
}

6. Prog:单个输入程序

通常情况下我们并不仅以一个系统调用作为输入,而是以一组系统调用序列作为输入单位——即一个程序,这也是 syzkaller 中实质上的最小输入单位,在 syz-fuzzer 当中使用 Prog 结构体来表示单个输入

1
2
3
4
5
type Prog struct {
Target *Target /* 目标架构信息 */
Calls []*Call /* 系统调用序列 */
Comments []string /* 注释? */
}

6. Candidate:序列化的单个输入项

我们通常会有多个 syz-fuzzer 进程与 VM,而我们更希望在 fuzzer 之间共享那些有用的输入,这通常通过 syz-fuzzersyz-manager 发起 RPC poll() 来完成——candidate 用以表示序列化后的输入,从而方便在不同 进程间传递:

1
2
3
4
5
type Candidate struct {
Prog []byte /* 序列化后的程序 */
Minimized bool /* 是否最小化? */
Smashed bool /* 是否要打碎重组? */
}

7. CallInfo:单个系统调用的运行结果

执行完程序之后我们自然想要知道程序的执行结果,在 syz-fuzzer 当中使用 CallInfo 结构体表示单个系统调用的执行结果

1
2
3
4
5
6
7
8
type CallInfo struct {
Flags CallFlags
Signal []uint32 // 反馈信号, 若设置了 FlagSignal 则填充
Cover []uint32 // 每个系统调用的覆盖率, 若设置了 FlagSignal 且 cover == true 则填充,
// if dedup == false, 则 cov 实际上包含了一个 trace, 否则重复项被删除
Comps prog.CompMap // 每个系统调用的比较操作数
Errno int // 调用的 errno (若调用成功则为 0)
}

其中 CompMap 为一个二重映射 uint64 → 【uint64 → bool】 ,我们将在后文解释这个东西:

1
2
3
4
5
6
7
8
// Example: for comparisons {(op1, op2), (op1, op3), (op1, op4), (op2, op1)}
// this map will store the following:
//
// m = {
// op1: {map[op2]: true, map[op3]: true, map[op4]: true},
// op2: {map[op1]: true}
// }.
type CompMap map[uint64]map[uint64]bool

8. ProgInfo:单个程序的运行结果

syzkaller 中单次运行的最小粒度其实是由一组系统调用构成的一个程序,因此程序的运行结果很自然地就是由一组 CallInfo 构成:

1
2
3
4
type ProgInfo struct {
Calls []CallInfo
Extra CallInfo // stores Signal and Cover collected from background threads
}

9. Env:syz-executor 的执行环境信息

Env 结构体用来记录 syz-executor 的执行环境信息,如输入、输出、执行的命令行等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Env struct {
in []byte
out []byte

cmd *command
inFile *os.File
outFile *os.File
bin []string
linkedBin string
pid int
config *Config

StatExecs uint64
StatRestarts uint64
}

10. WorkTriage/WorkCandidate/WorkSmash :实际的单个输入项

在 fuzzing 过程当中 我们实际上不直接以 raw 的 Prog 作为输入,我们还想要在输入上标识更多额外的信息——因此便需要加上一层 wrapper,一共有如下三种类型

  • WorkTriage:可能提供新的覆盖率的程序
1
2
3
4
5
6
7
8
9
10
// WorkTriage 为我们在初次执行时所注意到的潜在新覆盖的程序.
// 但我们并不能确定是否这是真实的覆盖率.
// 在分类中我们明白了这些程序是否真的给出了新的覆盖率,
// 若是,则将其最小化并添加到语料库中.
type WorkTriage struct {
p *prog.Prog /* 系统调用序列 */
call int /* interesting 的系统调用号? */
info ipc.CallInfo /* 运行结果 */
flags ProgTypes
}
  • WorkCandidate:由 hub(也就是 syz-manager)传来的程序
1
2
3
4
5
6
7
// WorkCandidate 为来自 hub 的程序.
// 我们暂不知道她们对当前 fuzzer 是否有用.
// 进程以对本地生成/变异程序相同的方式处理她们.
type WorkCandidate struct {
p *prog.Prog /* 系统调用序列 */
flags ProgTypes
}
  • WorkSmash:刚刚被加入到语料库中的程序
1
2
3
4
5
6
7
// WorkSmash 为刚刚添加到语料库中的程序.
// 在 smashing 过程中这些程序将收到一次特别的关注
// (emit faults, collect comparison hints, etc).
type WorkSmash struct {
p *prog.Prog /* 系统调用序列 */
call int /* interesting 的系统调用号? */
}

11. Cover:覆盖率信息

主流的 fuzzer 都是基于覆盖率指导的,syzkaller 也不例外,在 syz-fuzzer 中使用 Cover 结构体表示覆盖率信息:

没有注释所以笔者也没太看明白是怎么个意思:(

目前推测 key 是 program counter,val 目前无实际意义只是用来占位表示覆盖到了该地址

1
type Cover map[uint32]struct{}

输入生成变异相关

0. hint 机制

我们首先来看 syz-executor 中一个重要的变异机制——mutate with hint (可以理解为 hint 辅助的变异),在 hint.go 开头有如下注释:

1
2
3
4
5
6
7
8
9
10
11
// 一个 hint 大体而言为一个由指向一个程序中一个系统调用的一个参数的指针
// 与一个应当被赋给该参数的值(我们称之为 replacer)组成.
//(译注:什么B长难句)

// 一个简易版本的 hints workflow 形如:
// 1. Fuzzer 启动一个程序 (称之为 hint 种子) 并为程序中的每个系统调用
// 收集所有的比较数据.
// 2. 接下来其尝试将所获得的比较操作数的值于输入参数值进行匹配.
// 3. 对于每一个这样的匹配,fuzzer 通过将指向的参数使用保存的值进行替换来变异程序.
// 4. 若获得了一个合法的程序, fuzzer 将其启动并检查是否获得了新的覆盖率.
// 要了解更多关于特定突变的信息,参见 prog/hints_test.go.

1.CompMap:用作比较的参数的收缩/扩展替换取值表【核心】

CompMap 为一个 uint64 [uint64 到 bool 的映射] 的映射,用来在输入变异的时候进行参数值的替换

1
2
3
4
5
6
7
8
// 🌰: 对于比较对 {(op1, op2), (op1, op3), (op1, op4), (op2, op1)}
// 该映射将存储如下:
//
// m = {
// op1: {map[op2]: true, map[op3]: true, map[op4]: true},
// op2: {map[op1]: true}
// }.
type CompMap map[uint64]map[uint64]bool

看着比较抽象,笔者看了半天也不知道这™是个啥玩意,下面我们结合 prog/hints_test.go 中的示例来看:)

条件分支是现代编程语言中的一个基本结构,例如我们有如下函数,其会根据不同的取值进入不同的分支:

1
2
3
4
5
6
7
8
9
10
// Models the following code:
// void f(u64 qw) {
// u8 b = (u8) qw
// u16 w = (u16) qw
// u32 dw = (u32) qw
// if (b == 0xab) {...}
// if (w == 0xcdcd) {...}
// if (dw == 0xefefefef) {...}
// if (qw == 0x0101010101010101) {...}
// }; f(0x1234567890abcdef);

我们给该函数传入的参数为 0x1234567890abcdef 在该函数当中参数由于强制类型转换而发生了截断,我们称之为收缩(shrink),由于参数截断之后一个条件都匹配不上,所以我们一个分支都进不去:(

那么如果我们想要走进不同的分支该怎么办呢? 0x1234567890abcdef 截断成 u8 后变成 0xef,如果我们想要走进第一个条件分支那就要替换成 0xab;类似地, 0x1234567890abcdef 截断成 u16 后变成 0xcdef,如果我们想要走进第二个条件分支那就要替换成 0xcdcd0x1234567890abcdef 截断成 u32 后变成 0xabcdef,如果我们想要走进第三个条件分支那就要替换成 0xefefef ; 而如果我们想要走进最后一个条件分支,则需要将参数整个替换成 0x0101010101010101由此我们得到如下 CompMap

1
2
3
4
5
6
CompMap{
0xef: compSet(0xab, 0xef),
0xcdef: compSet(0xcdcd),
0x90abcdef: compSet(0xefefefef),
0x1234567890abcdef: compSet(0x0101010101010101),
},

根据该 CompMap ,我们最后得到可以用来替换原参数的新参数值为

1
2
3
4
5
6
[]uint64{
0x0101010101010101,
0x1234567890abcdab,
0x1234567890abcdcd,
0x12345678efefefef,
},

在 syzkaller 的实际运行过程当中,这些比较操作数对应的是内核中的代码分支,syzkaller 通过 KCOV 获取这些比较操作数,从而对程序输入变异以获取更高的代码覆盖率

全局管控相关

1. Fuzzer:基本信息

Fuzzer 结构体用以存储一个 syz-fuzzer 的所有基本信息,定义于 syz-fuzzer/fuzzer.go 中:

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
type Fuzzer struct {
name string
outputType OutputType
config *ipc.Config
execOpts *ipc.ExecOpts
procs []*Proc
gate *ipc.Gate
workQueue *WorkQueue
needPoll chan struct{}
choiceTable *prog.ChoiceTable
noMutate map[int]bool
// stats 域不幸的无法仅是一个 uint64 数组, 因为其在 32 位平台上
// 会造成 "unaligned 64-bit atomic operation" 错误
stats []uint64
manager *rpctype.RPCClient
target *prog.Target
triagedCandidates uint32
timeouts targets.Timeouts

faultInjectionEnabled bool
comparisonTracingEnabled bool
fetchRawCover bool

corpusMu sync.RWMutex
corpus []*prog.Prog
corpusHashes map[hash.Sig]struct{}
corpusPrios []int64
sumPrios int64

signalMu sync.RWMutex
corpusSignal signal.Signal // 语料库中的输入的信号
maxSignal signal.Signal // 包括 flakes 在内的所观察到的最大信号
newSignal signal.Signal // 自上次与 master 同步以来的 diff of maxSignal

checkResult *rpctype.CheckArgs
logMu sync.Mutex
}

2. Target:目标架构信息

定义于 prog/target.go 中的 Target 结构体用以表示 fuzzing 目标架构的基本信息:

懒得翻译了,太多字了,自己看:)

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
// Target describes target OS/arch pair.
type Target struct {
OS string
Arch string
Revision string // unique hash representing revision of the descriptions
PtrSize uint64
PageSize uint64
NumPages uint64
DataOffset uint64
LittleEndian bool
ExecutorUsesShmem bool

Syscalls []*Syscall
Resources []*ResourceDesc
Consts []ConstValue

// MakeDataMmap creates calls that mmaps target data memory range.
MakeDataMmap func() []*Call

// Neutralize neutralizes harmful calls by transforming them into non-harmful ones
// (e.g. an ioctl that turns off console output is turned into ioctl that turns on output).
// fixStructure determines whether it's allowed to make structural changes (e.g. add or
// remove arguments). It is helpful e.g. when we do neutralization while iterating over the
// arguments.
Neutralize func(c *Call, fixStructure bool) error

// AnnotateCall annotates a syscall invocation in C reproducers.
// The returned string will be placed inside a comment except for the
// empty string which will omit the comment.
AnnotateCall func(c ExecCall) string

// SpecialTypes allows target to do custom generation/mutation for some struct's and union's.
// Map key is struct/union name for which custom generation/mutation is required.
// Map value is custom generation/mutation function that will be called
// for the corresponding type. g is helper object that allows generate random numbers,
// allocate memory, etc. typ is the struct/union type. old is the old value of the struct/union
// for mutation, or nil for generation. The function returns a new value of the struct/union,
// and optionally any calls that need to be inserted before the arg reference.
SpecialTypes map[string]func(g *Gen, typ Type, dir Dir, old Arg) (Arg, []*Call)

// Resources that play auxiliary role, but widely used throughout all syscalls (e.g. pid/uid).
AuxResources map[string]bool

// Additional special invalid pointer values besides NULL to use.
SpecialPointers []uint64

// Special file name length that can provoke bugs (e.g. PATH_MAX).
SpecialFileLenghts []int

// Filled by prog package:
SyscallMap map[string]*Syscall
ConstMap map[string]uint64

init sync.Once
initArch func(target *Target)
types []Type
resourceMap map[string]*ResourceDesc
// Maps resource name to a list of calls that can create the resource.
resourceCtors map[string][]*Syscall
any anyTypes

// The default ChoiceTable is used only by tests and utilities, so we initialize it lazily.
defaultOnce sync.Once
defaultChoiceTable *ChoiceTable
}

3. Proc:syz-executor 实例对象

在 syz-fuzzer 当中 Proc 结构体被用以表示一个 syz-executor 实例

1
2
3
4
5
6
7
8
9
10
11
// Proc 表示一个单独的 fuzzing 程序 (executor).
type Proc struct {
fuzzer *Fuzzer
pid int
env *ipc.Env
rnd *rand.Rand
execOpts *ipc.ExecOpts
execOptsCollide *ipc.ExecOpts
execOptsCover *ipc.ExecOpts
execOptsComps *ipc.ExecOpts
}

其中 env 成员用以表示单个 syz-executor 的运行环境,定义于 pkg/ipc/ipc.go 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Env struct {
in []byte
out []byte

cmd *command
inFile *os.File
outFile *os.File
bin []string
linkedBin string
pid int
config *Config

StatExecs uint64
StatRestarts uint64
}

Env->cmd 则用以表示执行 syz-executor 的命令信息,包括 pid、配置、命令行参数等:

1
2
3
4
5
6
7
8
9
10
11
12
type command struct {
pid int
config *Config
timeout time.Duration
cmd *exec.Cmd
dir string
readDone chan []byte
exited chan error
inrp *os.File
outwp *os.File
outmem []byte
}

command->cmd 为最终用来执行 syz-executor 的命令行,来自于 golang 原生的 exec 库

4. WorkQueue:待使用的单 syz-fuzer 的全局输入队列

在 syz-fuzzer 当中所有的输入都被存放在 WorkQueue 队列当中,一个 syz-fuzzer 可能会启动多个 syz-executor 进程,他们共享同一个 WorkQueue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// WorkQueue 存放全局的 non-fuzzing 工作项目 (参见下面的 Work* 结构体).
// WorkQueue 也在工作项目间进行优先排序,例如我们想要在我们碎片化程序前
// 分类并向 manager 发送新的输入以在 VM 崩溃的情况下不会永久失去有趣的程序.
//(译注:interesting input 在 fuzzing 中是经典概念了,这里应该不需要笔者注释了)
type WorkQueue struct {
mu sync.RWMutex
triageCandidate []*WorkTriage
candidate []*WorkCandidate
triage []*WorkTriage
smash []*WorkSmash

procs int
needCandidates chan struct{}
}

我们可以看到 WorkQueueWorkTriage/WorkCandidate/WorkSmash 都分别建立了一个新的队列

fuzzer & executor 通信相关

1. executeReq:fuzzer→executor 请求头

fuzzing 的主体进程由 syz-fuzzer 完成,syz-executor 仅需要完成单个输入程序的运行,因此 syz-fuzzer 需要多次与 syz-executor 间通信:发送待运行的数据并接收运行结果

syz-fuzzer 与 syz-executor 间通信通过管道完成,因此每次通信前 syz-fuzzer 需要手动告诉 syz-executor 本次待接收的数据类型、长度等信息,这些信息被封装在固定大小的 executeReq 结构体当中:

1
2
3
4
5
6
7
8
9
10
11
12
type executeReq struct {
magic uint64
envFlags uint64 // env flags
execFlags uint64 // exec flags
pid uint64
syscallTimeoutMS uint64
programTimeoutMS uint64
slowdownScale uint64
progSize uint64
// 该结构体之后跟着一个 encodingexec 格式的序列化的测试程序.
// Both when sent over a pipe or in shared memory.
}

我们可以理解为 executeReq 为单次请求的请求头,后面跟着的序列化输入程序 progData 为单次请求的请求体,故 syz-fuzzer 向 syz-executor 发送单个程序执行请求的示例如下:

image.png

2. executeReply & callReply:executor 方响应的单个系统调用执行结果

有收便有发,syz-executor 在完成执行后会通过另一个管道将执行结果发送给 syz-fuzzer,由于 syzkaller 以单个系统调用为执行的最小粒度,因此syz-executor 每次返回给 syz-fuzzer 的便是单个系统调用的执行结果,使用一个头部 executeReply 标识信息,使用 callReply 存储具体结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type executeReply struct {
magic uint32
// If done is 0, then this is call completion message followed by callReply.
// If done is 1, then program execution is finished and status is set.
done uint32
status uint32
}

type callReply struct {
magic uint32
index uint32 // call index in the program
num uint32 // syscall number (for cross-checking)
errno uint32
flags uint32 // see CallFlags
signalSize uint32
coverSize uint32
compsSize uint32
// signal/cover/comps follow
}

executeReply 中的 done 标志位用以标识传输是否结束,对于来自 syz-fuzzer 的单次执行请求,syz-executor 的返回结果如下(以一个 done != 0executeReply 作为终止):

image.png

0x02. main():与 manager 建立 RPC 连接,进行初始化工作

我们还是按照惯例从程序入口点进行分析,syz-fuzzer()main() 函数做的工作比较简单,主要还是一些初始化工作以及引导后续工作流程

初始化参数解析与 RPC 连接,获取语料库与输入

Step.I - 一些初始化工作

一开始还是惯例的各种初始化和配置参数解析工作

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
// nolint: funlen
func main() {
debug.SetGCPercent(50)

var (
flagName = flag.String("name", "test", "unique name for manager")
flagOS = flag.String("os", runtime.GOOS, "target OS")
flagArch = flag.String("arch", runtime.GOARCH, "target arch")
flagManager = flag.String("manager", "", "manager rpc address")
flagProcs = flag.Int("procs", 1, "number of parallel test processes")
flagOutput = flag.String("output", "stdout", "write programs to none/stdout/dmesg/file")
flagTest = flag.Bool("test", false, "enable image testing mode") // used by syz-ci
flagRunTest = flag.Bool("runtest", false, "enable program testing mode") // used by pkg/runtest
flagRawCover = flag.Bool("raw_cover", false, "fetch raw coverage")
)
defer tool.Init()()
outputType := parseOutputType(*flagOutput)
log.Logf(0, "fuzzer started")

target, err := prog.GetTarget(*flagOS, *flagArch)
if err != nil {
log.Fatalf("%v", err)
}

config, execOpts, err := ipcconfig.Default(target)
if err != nil {
log.Fatalf("failed to create default ipc config: %v", err)
}
if *flagRawCover {
execOpts.Flags &^= ipc.FlagDedupCover
}
timeouts := config.Timeouts
sandbox := ipc.FlagsToSandbox(config.Flags)

接下来会创建一个 shutdown channel 并启动一个协程进行监测,当该 channel 有数据时说明遇到了一些情况,此时 syz-fuzzer 需要主动退出

1
2
3
4
5
6
7
8
shutdown := make(chan struct{})
osutil.HandleInterrupts(shutdown)
go func() {
// Handles graceful preemption on GCE.
<-shutdown
log.Logf(0, "SYZ-FUZZER: PREEMPTED")
os.Exit(1)
}()

osutil.HandleInterrupts() 则是一个根据不同的目标 os 进行定制的函数,对于 unix 系 os 而言该函数定义于 osutil_unix.go 中,其会等待收到三个 SIGINT 信号才真正关闭程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// HandleInterrupts 在第一个 SIGINT 时关闭 shutdown chan
// (希望程序能优雅地 shutdown 并 exit)
// 并在第三个 SIGINT 时终止程序.
func HandleInterrupts(shutdown chan struct{}) {
go func() {
c := make(chan os.Signal, 3)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
<-c
close(shutdown)
fmt.Fprint(os.Stderr, "SIGINT: shutting down...\n")
<-c
fmt.Fprint(os.Stderr, "SIGINT: shutting down harder...\n")
<-c
fmt.Fprint(os.Stderr, "SIGINT: terminating\n")
os.Exit(int(syscall.SIGINT))
}()
}

Step.II - 与 syz-manager 间建立 RPC 通信,加载语料库

回到 main() 中,接下来会收集 Guest 信息并尝试与 Host(syz-manager) 间建立 RPC 连接,若失败则直接退出,这里我们传给 syz-manager 的是 rpctype.ConnectArgs,收到的响应结果为 rpctype.ConnectRes ,其中包含有很多信息:

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
checkArgs := &checkArgs{
target: target,
sandbox: sandbox,
ipcConfig: config,
ipcExecOpts: execOpts,
gitRevision: prog.GitRevision,
targetRevision: target.Revision,
}
if *flagTest {
testImage(*flagManager, checkArgs)
return
}

machineInfo, modules := collectMachineInfos(target)

log.Logf(0, "dialing manager at %v", *flagManager)
manager, err := rpctype.NewRPCClient(*flagManager, timeouts.Scale)
if err != nil {
log.Fatalf("failed to create an RPC client: %v ", err)
}

log.Logf(1, "connecting to manager...")
a := &rpctype.ConnectArgs{
Name: *flagName,
MachineInfo: machineInfo,
Modules: modules,
}
r := &rpctype.ConnectRes{}
if err := manager.Call("Manager.Connect", a, r); err != nil {
log.Fatalf("failed to call Manager.Connect(): %v ", err)
}

syz-fuzzer 中的 RPC 都会调用到 syz-manager/rpc.go 中的 RPCServer 的成员函数,我们最终会调用到 syz-manager 上的 Connect() 函数进行连接,其核心是调用 RPCServer.RPCManagerView.fuzzerConnect() 最小化语料库并拷贝 BugFrame 等信息,这里就不展开分析了:)

接下来解析要开启的特性标志位;随后检查与 syz-manager 进行 RPC 连接的响应结果中的 CoverFilterBitmap 是否为空,不为空则写入到 "syz-cover-bitmap" 文件中,该信息主要是给 syz-executor 用的:

1
2
3
4
5
6
7
8
9
featureFlags, err := csource.ParseFeaturesFlags("none", "none", true)
if err != nil {
log.Fatal(err)
}
if r.CoverFilterBitmap != nil {
if err := osutil.WriteFile("syz-cover-bitmap", r.CoverFilterBitmap); err != nil {
log.Fatalf("failed to write syz-cover-bitmap: %v", err)
}
}

之后若 syz-manager 返回结果中的 CheckResult 为空,则重新生成配置信息后通过 RPC 调用 syz-manager 的 Manager.Check() ,若有错误则直接退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if r.CheckResult == nil {
checkArgs.gitRevision = r.GitRevision
checkArgs.targetRevision = r.TargetRevision
checkArgs.enabledCalls = r.EnabledCalls
checkArgs.allSandboxes = r.AllSandboxes
checkArgs.featureFlags = featureFlags
r.CheckResult, err = checkMachine(checkArgs)
if err != nil {
if r.CheckResult == nil {
r.CheckResult = new(rpctype.CheckArgs)
}
r.CheckResult.Error = err.Error()
}
r.CheckResult.Name = *flagName
if err := manager.Call("Manager.Check", r.CheckResult, nil); err != nil {
log.Fatalf("Manager.Check call failed: %v", err)
}
if r.CheckResult.Error != "" {
log.Fatalf("%v", r.CheckResult.Error)
}
}

这次 RPC 实际上会调用到 syz-manager 中的 machineChecked(),该函数会调用 loadCorpus() 完成语料库的加载

1
2
3
4
5
6
7
8
9
func (mgr *Manager) machineChecked(a *rpctype.CheckArgs, enabledSyscalls map[*prog.Syscall]bool) {
mgr.mu.Lock()
defer mgr.mu.Unlock()
mgr.checkResult = a
mgr.targetEnabledSyscalls = enabledSyscalls
mgr.target.UpdateGlobs(a.GlobFiles)
mgr.loadCorpus()
mgr.firstConnect = time.Now()
}

回到 syz-fuzzer,若 CheckResult 不为空则调用 target.UpdateGlobs() 将 manager 返回数据存放到 GlobFiles 映射中,再调用 Setup() 根据所开启的特性配置 executor 的参数并调用 osutil.RunCmd() 在 5min 后启动 executor,这里就不展开了

1
2
3
4
5
6
else {
target.UpdateGlobs(r.CheckResult.GlobFiles)
if err = host.Setup(target, r.CheckResult.Features, featureFlags, config.Executor); err != nil {
log.Fatal(err)
}
}

Step.III - 初始化 Fuzzer 结构信息,获取 input 与 candidate

之后日志输出开启的系统调用数量及特性信息,创建 IPC 配置信息,若设置了 runtest 标志位(默认为 false)则调用 runTest() 后直接返回,这个主要是测试用的这里就不展开了:

1
2
3
4
5
6
7
8
9
10
11
log.Logf(0, "syscalls: %v", len(r.CheckResult.EnabledCalls[sandbox]))
for _, feat := range r.CheckResult.Features.Supported() {
log.Logf(0, "%v: %v", feat.Name, feat.Reason)
}
createIPCConfig(r.CheckResult.Features, config)

if *flagRunTest {
runTest(target, manager, *flagName, config.Executor)
return
}

之后创建 needPoll channel 并写入数据,主要用来标识是否需要向 syz-manager() 发起 poll(向 syz-manager 获取语料库与 candidate);接下来创建了一个标识当前 syz-fuzzer 信息的 Fuzzer 结构体以及一个 ipc.Gate 结构体(用于将并发级别和窗口限制为给定的值):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

needPoll := make(chan struct{}, 1)
needPoll <- struct{}{}
fuzzer := &Fuzzer{
name: *flagName,
outputType: outputType,
config: config,
execOpts: execOpts,
workQueue: newWorkQueue(*flagProcs, needPoll),
needPoll: needPoll,
manager: manager,
target: target,
timeouts: timeouts,
faultInjectionEnabled: r.CheckResult.Features[host.FeatureFault].Enabled,
comparisonTracingEnabled: r.CheckResult.Features[host.FeatureComparisons].Enabled,
corpusHashes: make(map[hash.Sig]struct{}),
checkResult: r.CheckResult,
fetchRawCover: *flagRawCover,
noMutate: r.NoMutateCalls,
stats: make([]uint64, StatCount),
}
gateCallback := fuzzer.useBugFrames(r, *flagProcs)
fuzzer.gate = ipc.NewGate(2**flagProcs, gateCallback)

接下来是一个小循环,调用 poll()向 syz-manager 获取语料库与 candidate 加入到本地工作队列中

1
2
3
4
5
6
for needCandidates, more := true, true; more; needCandidates = false {
more = fuzzer.poll(needCandidates, nil)
// 该循环在 qemu 模拟中为 "no output" , 以告诉 manager 我们还没死.
log.Logf(0, "fetching corpus: %v, signal %v/%v (executing program)",
len(fuzzer.corpus), len(fuzzer.corpusSignal), len(fuzzer.maxSignal))
}

poll():通过 RPC 向 syz-manager 获取语料库与 candidate 加入本地工作队列中

该函数主要做了两件事:

  • 通过 RPC 向 syz-manager 获取语料库与 candidate
  • 将获得的 candidate 加入本地工作队列中
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
func (fuzzer *Fuzzer) poll(needCandidates bool, stats map[string]uint64) bool {
a := &rpctype.PollArgs{
Name: fuzzer.name,
NeedCandidates: needCandidates,
MaxSignal: fuzzer.grabNewSignal().Serialize(),
Stats: stats,
}
r := &rpctype.PollRes{}
/* RPC 向 syz-manager 请求信息 */
if err := fuzzer.manager.Call("Manager.Poll", a, r); err != nil {
log.Fatalf("Manager.Poll call failed: %v", err)
}
/* 解析:最大信号数量 */
maxSignal := r.MaxSignal.Deserialize()
log.Logf(1, "poll: candidates=%v inputs=%v signal=%v",
len(r.Candidates), len(r.NewInputs), maxSignal.Len())
fuzzer.addMaxSignal(maxSignal)
/* 解析:syz-manager 给予的从其他 fuzzer 获得的新输入 */
for _, inp := range r.NewInputs {
fuzzer.addInputFromAnotherFuzzer(inp)
}
/* 解析:candidate */
for _, candidate := range r.Candidates {
fuzzer.addCandidateInput(candidate)
}
if needCandidates && len(r.Candidates) == 0 && atomic.LoadUint32(&fuzzer.triagedCandidates) == 0 {
atomic.StoreUint32(&fuzzer.triagedCandidates, 1)
}
return len(r.NewInputs) != 0 || len(r.Candidates) != 0 || maxSignal.Len() != 0
}

该函数会通过 RPC 调用到 syz-manager 中 syz-manager/rpc.go 下的 Poll() 函数:

  • 首先会获取在 syz-manager 中该 syz-fuzzer 的信息,并检查最大信号量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (serv *RPCServer) Poll(a *rpctype.PollArgs, r *rpctype.PollRes) error {
serv.stats.mergeNamed(a.Stats)

serv.mu.Lock()
defer serv.mu.Unlock()

f := serv.fuzzers[a.Name]
if f == nil {
// 若我们调用了 shutdownInstance 、但已经有一个来自该实例的待处理请求,则这是有可能的
log.Logf(1, "poll: fuzzer %v is not connected", a.Name)
return nil
}
newMaxSignal := serv.maxSignal.Diff(a.MaxSignal.Deserialize())
if !newMaxSignal.Empty() {
serv.maxSignal.Merge(newMaxSignal)
serv.stats.maxSignal.set(len(serv.maxSignal))
for _, f1 := range serv.fuzzers {
if f1 == f || f1.rotated {
continue
}
f1.newMaxSignal.Merge(newMaxSignal)
}
}
  • 如果该 syz-fuzzer 对应的 VM 实例正在轮转运行,则直接返回
1
2
3
4
if f.rotated {
// 让轮转中的 VMs 独立运行,不要向他们发送任何东西
return nil
}
  • 接下来写入新的最大信号,之后检查 syz-fuzzer 是否向 syz-manager 请求了新的 candidate,若是则调用 candidateBatch() 获取,该函数的作用主要是从 syz-manager 上的 candidate 队列中获取一定数量的 candidate
1
2
3
4
r.MaxSignal = f.newMaxSignal.Split(2000).Serialize()
if a.NeedCandidates {
r.Candidates = serv.mgr.candidateBatch(serv.batchSize)
}
  • 若获取到的 candidate 数量为 0,说明是第一次 poll(),这时便从初始化时所读取的语料库中获取输入作为 candidate 返还给 syz-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
	if len(r.Candidates) == 0 {
batchSize := serv.batchSize
// 当 fuzzer 启动时, 其会抽取所有的语料库.
// 若我们使用最后的 batchSize 进行操作, 这将会非常慢
// (对于 50k 的语料库与慢内核而言,一批大小为 6 可能需要10min以上的时间).
// 所以在最初就使用更大的批量 (we use no stats as approximation of initial pump).
const initialBatch = 50
if len(a.Stats) == 0 && batchSize < initialBatch {
batchSize = initialBatch
}
for i := 0; i < batchSize && len(f.inputs) > 0; i++ {
last := len(f.inputs) - 1
r.NewInputs = append(r.NewInputs, f.inputs[last])
f.inputs[last] = rpctype.Input{}
f.inputs = f.inputs[:last]
}
if len(f.inputs) == 0 {
f.inputs = nil
}
}
log.Logf(4, "poll from %v: candidates=%v inputs=%v maxsignal=%v",
a.Name, len(r.Candidates), len(r.NewInputs), len(r.MaxSignal.Elems))
return nil
}

构建【系统调用生成优先级表】

接下来是正式进行 fuzzing 之前最核心的一步——构建【系统调用生成优先级表】 choiceTable ,该表的作用是:

  • 计算对于给定的系统调用 x,在添加入系统调用 y 后,代码覆盖率会上升的可能性

从而在后续的 fuzzing 过程当中使用该表对输入进行变异

1
2
3
4
5
calls := make(map[*prog.Syscall]bool)
for _, id := range r.CheckResult.EnabledCalls[sandbox] {
calls[target.Syscalls[id]] = true
}
fuzzer.choiceTable = target.BuildChoiceTable(fuzzer.corpus, calls)

choiceTable 的计算通过 定义于prog/prio.go 中的 BuildChoiceTable() 完成:

  • 首先建立两个表:
    • Syscall→bool 的表 enable,表示启用的系统调用,并将所有系统调用都初始化为 true
    • int→bool 的表 noGenerateCalls ,表示不用于生成的系统调用
  • 接下来遍历系统调用属性(在 syzlang 规则文件中编写),若某个系统调用不开启则将其从 enable 表中去除,若某个系统调用不用于生成则将其从 enable 表中去除并添加到 noGenerateCalls
  • 之后创建第三个表 generateCalls,为 enable 表的拷贝,表示用于生成的系统调用,并根据系统调用号进行从小到大的排序

这里的【生成】指的是生成新的输入,即我们是否在输入生成时添加该系统调用

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
func (target *Target) BuildChoiceTable(corpus []*Prog, enabled map[*Syscall]bool) *ChoiceTable {
if enabled == nil {
enabled = make(map[*Syscall]bool)
for _, c := range target.Syscalls {
enabled[c] = true
}
}
noGenerateCalls := make(map[int]bool)
for call := range enabled {
if call.Attrs.Disabled {
delete(enabled, call)
} else if call.Attrs.NoGenerate {
noGenerateCalls[call.ID] = true
delete(enabled, call)
}
}
var generatableCalls []*Syscall
for c := range enabled {
generatableCalls = append(generatableCalls, c)
}
if len(generatableCalls) == 0 {
panic("no syscalls enabled and generatable")
}
sort.Slice(generatableCalls, func(i, j int) bool {
return generatableCalls[i].ID < generatableCalls[j].ID
})
  • 接下来判断语料库中是否包含有禁用的系统调用,若是则直接退出
1
2
3
4
5
6
7
8
for _, p := range corpus {
for _, call := range p.Calls {
if !enabled[call.Meta] && !noGenerateCalls[call.Meta.ID] {
fmt.Printf("corpus contains disabled syscall %v\n", call.Meta.Name)
panic("disabled syscall")
}
}
}
  • 最后调用 target.CalculatePriorities(corpus) 基于语料库进行系统调用生成优先级表的计算
  • 完成后生成一个新的表 run 作为结果与生成&不生成系统调用表一起返回,其中存储的是对于启用的系统调用而言的加权权重的累加和

例如有给定的三个系统调用 A、B、C,有这样的一个生成优先级表 prios

1
2
3
4
  A B C
A 1 2 1
B 2 1 2
C 3 1 2

那么在 run 表中则会将一列的结果逐个加起来,从而有这样的一个结果:

1
2
3
4
  A B C
A 1 3 4
B 2 3 5
C 3 4 6

为什么要这么做笔者也不知道:(

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
	prios := target.CalculatePriorities(corpus)
run := make([][]int32, len(target.Syscalls))
// ChoiceTable.runs[][] 包含对加权优先级数字的累加总和.
// 这在生成程序时有助于带权快速二叉搜索.
// 这仅用于在目标上所启用的系统调用.
for i := range run {
if !enabled[target.Syscalls[i]] {
continue
}
run[i] = make([]int32, len(target.Syscalls))
var sum int32
for j := range run[i] {
if enabled[target.Syscalls[j]] {
sum += prios[i][j]
}
run[i][j] = sum
}
}
return &ChoiceTable{target, run, generatableCalls, noGenerateCalls}
}

【核心】target.CalculatePriorities(corpus):计算 call-to-call 的权重表

定义于 prog/prio.go 中的 CalculatePriorities(corpus) 为系统调用生成优先级表的核心计算过程,代码其实比较简单(不过注释很长),主要分为静态优先级动态优先级两部分进行计算,默认进行静态优先级的计算,若语料库不为空则再计算动态优先级:

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
// 计算 call-to-call 的优先级.
// 对于一对给定的调用 X 与 Y, 优先级便是我们对于
// 将一个额外的调用 Y 添加到一个包含有调用 X 的程序当中
// 是否有可能给出新的覆盖率的猜测.
// 当前的算法有两个部分:静态与动态.
// 静态部分基于对参数类型的分析. 举个🌰,
// 若调用 X 与 Y 都接受 fd[sock], 则他们更有可能一起给出新的覆盖率.
// 动态部分则基于语料库中一对特定的系统调用在一个程序中出现的频率.
// 例如,若 socket 与 connect 频繁地在一个程序中一起出现,
// 我们给这对系统调用更高的优先级.
// 注意: 当前的实现非常简陋, 任何常量背后都没有理论支持.

func (target *Target) CalculatePriorities(corpus []*Prog) [][]int32 {
static := target.calcStaticPriorities()
if len(corpus) != 0 {
dynamic := target.calcDynamicPrio(corpus)
for i, prios := range dynamic {
dst := static[i]
for j, p := range prios {
dst[j] = dst[j] * p / prioHigh
}
}
}
return static
}

对于给定的系统调用 X,添加系统调用 Y 的优先级为:
$$
最终优先级 = 静态优先级 * 动态优先级 / prioHigh
$$
其中 prioHigh 为一个常量 1000

① calcStaticPriorities():静态优先级计算

Step.I - 计算资源使用情况

注:这里的【资源】更严谨地说应该是 syzlang 中的 resource

calcStaticPriorities() 用以完成静态优先级的计算,该函数首先会调用 calcResourceUsage() 获取当前的【资源使用情况】:

  • 该函数的返回结果为一个二重映射 资源名(string)→【系统调用号(int)→权重(weights)】
  • 该函数的核心逻辑便是调用 ForeachType() 进行分析
1
2
3
4
5
6
7
8
9
10
func (target *Target) calcStaticPriorities() [][]int32 {
uses := target.calcResourceUsage()
//...

func (target *Target) calcResourceUsage() map[string]map[int]weights {
uses := make(map[string]map[int]weights)
ForeachType(target.Syscalls, func(t Type, ctx *TypeCtx) {
//...
return uses
}

我们稍后再来分析传入的闭包函数,我们首先分析 ForeachType() ,其会遍历系统调用表并调用 foreachTypeImpl()

1
2
3
4
5
func ForeachTypePost(syscalls []*Syscall, f func(t Type, ctx *TypeCtx)) {
for _, meta := range syscalls {
foreachTypeImpl(meta, false, f)
}
}

foreachTypeImpl() 用以处理单个系统调用,其首先会创建一个 类型→bool 的映射 seen,接下来会创建一个闭包函数 rec ,该函数会根据传入类型的不同进行不同操作:

注:这里的类型应当为 syzlang 中的类型

  • 若是指针类型 (PtrType ),则递归调用 rec
  • 若是数组类型(ArrayType ),则递归调用 rec
  • 若是结构体类型(StructType)且不在 seen 中,将其添加到 seen 中,并为每一个结构体成员调用 rec
  • 若是联合类型(UnionType)且不在 seen 中,将其添加到 seen 中,并为每一个联合成员调用 rec
  • 其他合法类型则直接跳过,非法类型(syzlang 编译期错误)则 panic
  • 最后调用上层传入的闭包函数(我们在 ForeachTypePost() 中传入 preorder = false
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
func foreachTypeImpl(meta *Syscall, preorder bool, f func(t Type, ctx *TypeCtx)) {
// 注意: 我们特意不在 ForeachType 中创建 seen..
// 其能更好地对递归进行剪枝 (在系统调用间), 但大量的使用者需要
// 对单个系统调用访问每个结构体 (例如 prio, used resources).
seen := make(map[Type]bool)
var rec func(*Type, Dir)
rec = func(ptr *Type, dir Dir) {
ctx := &TypeCtx{Meta: meta, Dir: dir, Ptr: ptr}
if preorder {
f(*ptr, ctx)
if ctx.Stop {
return
}
}
switch a := (*ptr).(type) {
case *PtrType:
rec(&a.Elem, a.ElemDir)
case *ArrayType:
rec(&a.Elem, dir)
case *StructType:
if seen[a] {
break // 通过对structs/unions的指针来剪枝掉递归
}
seen[a] = true
for i, f := range a.Fields {
rec(&a.Fields[i].Type, f.Dir(dir))
}
case *UnionType:
if seen[a] {
break // 通过对structs/unions的指针来剪枝掉递归
}
seen[a] = true
for i, f := range a.Fields {
rec(&a.Fields[i].Type, f.Dir(dir))
}
case *ResourceType, *BufferType, *VmaType, *LenType, *FlagsType,
*ConstType, *IntType, *ProcType, *CsumType:
case Ref:
// 仅 pkg/compiler 需要.
default:
panic("unknown type")
}
if !preorder {
f(*ptr, ctx)
if ctx.Stop {
panic("Stop is set in post-order iteration")
}
}
}

foreachTypeImpl() 实际上便是为传入的系统调用的每个参数都调用 rec(),若存在返回值也为其调用 rec

1
2
3
4
5
6
7
	for i := range meta.Args {
rec(&meta.Args[i].Type, DirIn)
}
if meta.Ret != nil {
rec(&meta.Ret, DirOut)
}
}

现在我们回到 calcResourceUsage() 看其传给 ForeachType() 的闭包函数,也是根据系统调用的参数的类型不同进行处理:

这里的 a 为 Type 接口, a.Desc 为 ResourceDesc类型,即对一个资源的描述,其 Kind 域为一个 string 数组,Values 域为一个 uint64 数组

  • 若是资源类型 (ResourceType ):

    • 若在 AuxResources 表(辅助资源表,即扮演着辅助角色但是在所有系统调用中都会用到的资源(例如uid/gid))里已有记录,则调用 noteUsage() 更新权重值,这里给的权重值为 1
    • 否则,遍历 a.Desc.Kind ,调用 noteUsage() 更新权重值,这里给除了最后一个 Kind 以外的权重值为 2,最后一个为 10
  • 若是指针类型(PttrType),则判断指针所指对象类型(结构体/联合体/数组)并调用 noteUsage() 更新权重值,这里给的权重值都为 10

  • 若是 buffer 类型(BufferType),则根据 a.Kind 进行不同处理:

    • BufferBlobRand, BufferBlobRange, BufferText, BufferCompressed :无处理
    • BufferString, BufferGlob :若 a.SubKind != "",则调用 noteUsage() 更新权重值,这里给的权重值为 2
    • BufferFilename:调用 noteUsage() 更新权重值,这里给的权重值为 10
  • 若为 VMA 类型 (VmaType),则调用 noteUsage() 更新权重值,这里给的权重值为 5

  • 对于整型则检查 a.Kind 是否为 IntPlain, IntRange,若不是则直接 panic

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
func(t Type, ctx *TypeCtx) {
c := ctx.Meta
switch a := t.(type) {
case *ResourceType:
if target.AuxResources[a.Desc.Name] {
noteUsage(uses, c, 1, ctx.Dir, "res%v", a.Desc.Name)
} else {
str := "res"
for i, k := range a.Desc.Kind {
str += "-" + k
w := int32(10)
if i < len(a.Desc.Kind)-1 {
w = 2
}
noteUsage(uses, c, w, ctx.Dir, str)
}
}
case *PtrType:
if _, ok := a.Elem.(*StructType); ok {
noteUsage(uses, c, 10, ctx.Dir, "ptrto-%v", a.Elem.Name())
}
if _, ok := a.Elem.(*UnionType); ok {
noteUsage(uses, c, 10, ctx.Dir, "ptrto-%v", a.Elem.Name())
}
if arr, ok := a.Elem.(*ArrayType); ok {
noteUsage(uses, c, 10, ctx.Dir, "ptrto-%v", arr.Elem.Name())
}
case *BufferType:
switch a.Kind {
case BufferBlobRand, BufferBlobRange, BufferText, BufferCompressed:
case BufferString, BufferGlob:
if a.SubKind != "" {
noteUsage(uses, c, 2, ctx.Dir, fmt.Sprintf("str-%v", a.SubKind))
}
case BufferFilename:
noteUsage(uses, c, 10, DirIn, "filename")
default:
panic("unknown buffer kind")
}
case *VmaType:
noteUsage(uses, c, 5, ctx.Dir, "vma")
case *IntType:
switch a.Kind {
case IntPlain, IntRange:
default:
panic("unknown int kind")
}
}
})

这里大量用到了 noteUsage() 函数,该函数其实就是用来更新传入的表中 X 到 Y 的权重值(取最大值):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func noteUsage(uses map[string]map[int]weights, c *Syscall, weight int32, dir Dir, str string, args ...interface{}) {
id := fmt.Sprintf(str, args...)
if uses[id] == nil {
uses[id] = make(map[int]weights)
}
callWeight := uses[id][c.ID]
callWeight.call = c.ID
if dir != DirOut {
if weight > uses[id][c.ID].in {
callWeight.in = weight
}
}
if weight > uses[id][c.ID].inout {
callWeight.inout = weight
}
uses[id][c.ID] = callWeight
}
Step.II - 建立 call-to-call 的权重表

现在我们回到 calcStaticPriorities() 中,在完成了资源使用表 uses 的建立之后,接下来会建立 call-to-call 的权重表 prios

  • 遍历资源使用表中的每个权重,基于参数方向赋予静态权重值(例如,当 c0 为创建了一个资源的调用,而 c1 为使用该资源的调用,则一个更高的权重将被赋予)
  • 调用 normalizePrio() 将表中权重值进行标准化
  • 对于自我系统调用权重进行单独赋值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
	prios := make([][]int32, len(target.Syscalls))
for i := range prios {
prios[i] = make([]int32, len(target.Syscalls))
}
for _, weights := range uses {
for _, w0 := range weights {
for _, w1 := range weights {
if w0.call == w1.call {
// 自身权重在下方赋值.
continue
}
// 静态权重值基于参数方向赋值.当 c0 为创建了一个资源的调用
// 而 c1 为使用该资源的调用,则一个更高的权重将被赋予.
prios[w0.call][w1.call] += w0.inout*w1.in*3/2 + w0.inout*w1.inout
}
}
}
normalizePrio(prios)
// 自身权重值(call wrt itself) 的赋值应当高些, 但不要太高.
for c0, pp := range prios {
pp[c0] = prioHigh * 9 / 10
}
return prios
}

完成基于资源使用的权重值计算之后静态优先级就算计算完毕了:)

② calcDynamicPrio():动态优先级计算

动态优先级的计算基于现有的语料库完成,这一部分的代码比较简单,主要是遍历语料库,对于同时出现的系统调用对 X、Y 的优先级值 +1,完成后调用 normalizePrio() 进行标准化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (target *Target) calcDynamicPrio(corpus []*Prog) [][]int32 {
prios := make([][]int32, len(target.Syscalls))
for i := range prios {
prios[i] = make([]int32, len(target.Syscalls))
}
for _, p := range corpus {
for idx0, c0 := range p.Calls {
for _, c1 := range p.Calls[idx0+1:] {
prios[c0.Meta.ID][c1.Meta.ID]++
}
}
}
normalizePrio(prios)
return prios
}

③ normalizePrio():优先级值标准化

这个函数逻辑比较简单,主要就是将权重值标准化到 [prioLow..prioHigh] 范围:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const (
prioLow = 10
prioHigh = 1000
)

// normalizePrio 将权重值标准化到 [prioLow..prioHigh] 范围.
func normalizePrio(prios [][]int32) {
for _, prio := range prios {
max := int32(1)
for _, p := range prio {
if max < p {
max = p
}
}
for i, p := range prio {
prio[i] = prioLow + p*(prioHigh-prioLow)/max
}
}
}

为每个 syz-executor 启动一个协程,正式开始 fuzz

完成前面的所有准备工作之后,接下来我们终于可以正式开始进行 fuzzing 了,syz-fuzzer 会调用 newProc() 为每个要启动的 syz-executor 创建一个 Proc 实例对象(都存放在 fuzzer.procs 列表中),并为每个 syz-executor 启动一个新的协程负责具体的 fuzzing 工作proc.Loop()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if r.CoverFilterBitmap != nil {
fuzzer.execOpts.Flags |= ipc.FlagEnableCoverageFilter
}

log.Logf(0, "starting %v fuzzer processes", *flagProcs)
for pid := 0; pid < *flagProcs; pid++ {
proc, err := newProc(fuzzer, pid)
if err != nil {
log.Fatalf("failed to create proc: %v", err)
}
fuzzer.procs = append(fuzzer.procs, proc)
go proc.loop()
}

最后主线程调用 pollLoop(),循环等待需要 poll 的情况

1
2
	fuzzer.pollLoop()
}

0x03. pollLooop():循环等待处理 fuzzing 协程请求,与 syz-manager 通信

fuzzing 的工作是由协程 proc.loop() 完成的,在继续深入 fuzzing 过程之前我们先来看看主线程最后还会做些什么:)

主线程在启动这些协程之后所需要做的工作其实就是响应这些协程的请求,并负责与 syz-manager 间进行 RPC 通信,通过一个不会返回的 pollLoop() 函数完成,该函数核心其实就是一个无限循环

  • 循环等待 ticker (每 3s 响应一次的计时器)或 fuzzer.needPoll 这两个 channel 之一有数据传来
  • 如果是 fuzzer.needPoll 传来请求或是距离上次 poll 的时间大于 10s:
    • 检查 workQueue 是否需要新的 candidate(candidate 数量少于 executor 数量),若不是且本次请求处理为 fuzzer.needPoll 传来请求,则等到到距离上次 poll 的时间大于 10s
    • 收集 executor 数据,调用 poll() 通过 RPC 向 syz-manager 获取新的 candidate
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
unc (fuzzer *Fuzzer) pollLoop() {
var execTotal uint64
var lastPoll time.Time
var lastPrint time.Time
ticker := time.NewTicker(3 * time.Second * fuzzer.timeouts.Scale).C
for {
poll := false
select {
case <-ticker: /* 3s 一次的计时器 */
case <-fuzzer.needPoll: /* fuzzing 协程的 poll 请求 */
poll = true
}
if fuzzer.outputType != OutputStdout && time.Since(lastPrint) > 10*time.Second*fuzzer.timeouts.Scale {
// Keep-alive for manager.
log.Logf(0, "alive, executed %v", execTotal)
lastPrint = time.Now()
}
/* 对于计时器而言至少 10s 才 poll 一次 */
if poll || time.Since(lastPoll) > 10*time.Second*fuzzer.timeouts.Scale {
/* workqueue 里 work item 数量少于 executor 数量才 poll */
needCandidates := fuzzer.workQueue.wantCandidates()
if poll && !needCandidates {
continue
}
stats := make(map[string]uint64)
for _, proc := range fuzzer.procs {
stats["exec total"] += atomic.SwapUint64(&proc.env.StatExecs, 0)
stats["executor restarts"] += atomic.SwapUint64(&proc.env.StatRestarts, 0)
}
for stat := Stat(0); stat < StatCount; stat++ {
v := atomic.SwapUint64(&fuzzer.stats[stat], 0)
stats[statNames[stat]] = v
execTotal += v
}
if !fuzzer.poll(needCandidates, stats) {
lastPoll = time.Now()
}
}
}
}

0x04. proc.loop():真正负责 fuzzing 的核心协程

下面我们来到 syz-fuzzer 的另一个核心——真正的 fuzzing 过程,syz-fuzzer 会为每个 syz-executor 启动一个新的协程负责具体的 fuzzing 工作(对于 executor 来说相当于督工和不断给任务的上司属于是),该协程对应 proc.loop() 函数,该函数的核心其实还是一个无限循环

1
2
3
4
5
6
7
8
func (proc *Proc) loop() {
generatePeriod := 100
if proc.fuzzer.config.Flags&ipc.FlagSignal == 0 {
// 如果我们没有真的覆盖率信号, 更加频繁地生成程序
// 因为反馈信号很弱.
generatePeriod = 2
}
for i := 0; ; i++ {

从全局 WorkQueue 中获取输入,分类处理执行

大循环的核心逻辑之一为不断地从全局 WorkQueue 中获取新的输入,按照其类型不同调用不同的处理函数,完成后又继续下一轮循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
item := proc.fuzzer.workQueue.dequeue()
if item != nil {
switch item := item.(type) {
case *WorkTriage:
proc.triageInput(item)
case *WorkCandidate:
proc.execute(proc.execOpts, item.p, item.flags, StatCandidate)
case *WorkSmash:
proc.smashInput(item)
default:
log.Fatalf("unknown work type: %#v", item)
}
continue
}

① proc.triageInput():执行输入&尝试最小化后发送给 manager 并添加到本地语料库,若未 smash 则添加到 smash 队列

该函数用来处理 WorkQueue 中的 WorkTraige,即可能会提供新覆盖率的程序

  • 一开始先调用 signalPrio() 检查该输入之前执行结果的 errno,为 0prio |= 1 << 1,同时还会检查是否 item.ptarget 不包含 item.p.Calls[call] 对应的调用,若是则 prio |= 1 << 0
  • 判断是否产生了语料库中没有的新信号,若无则直接返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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())

完成前面的这些判断工作之后,接下来来到一个会运行三次的小循环:

  • 调用 proc.executeRaw() 将该输入重新执行一次,若执行失败(这里的 reexecutionSuccess() 主要检查信号长度是否不为 0)则重新开始循环,失败 2次直接返回
  • 获取执行所得信号与覆盖率,将覆盖率合并到 inputCover 中,如果 rawCover 为空则还会往里边放一份
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
var inputCover cover.Cover
const (
signalRuns = 3
minimizeAttempts = 3
)
// 计算输入覆盖与 non-flaky 信号以最小化.
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) {
// 调用未被执行或失败了.
notexecuted++
if notexecuted > signalRuns/2+1 {
return // 发生得太频繁,放弃
}
continue
}
thisSignal, thisCover := getSignalAndCover(item.p, info, item.call)
if len(rawCover) == 0 && proc.fuzzer.fetchRawCover {
rawCover = append([]uint32{}, thisCover...)
}
newSignal = newSignal.Intersection(thisSignal)
// 没有 !minimized 检查的情况下 manager 在每次重启后开始丢失相当大量的覆盖率.
// Mechanics of this are not completely clear.
if newSignal.Empty() && item.flags&ProgMinimized == 0 {
return
}
inputCover.Merge(thisCover)
}

这里这个 Merge() 其实笔者没太看明白 :(

目前笔者推测 pcprogram counter ,这样比较能说得通,不过这样为什么不用 map[uint32]bool 呢?

1
2
3
4
5
6
7
8
9
10
func (cov *Cover) Merge(raw []uint32) {
c := *cov
if c == nil {
c = make(Cover)
*cov = c
}
for _, pc := range raw {
c[pc] = struct{}{}
}
}

循环结束后会检查 item 的标志位,如果未设置 ProgMinimized 说明该输入还未进行最小化,此时调用 prog.Minimize() 将输入进行最小化

这里传入的闭包函数主要是一个 minimizeAttempts (3)次的小循环,其中会调用 proc.execute() 执行最小化后的程序,执行失败则返回,若三次循环执行中无法再产生新的信号信号则返回 true,否则返回 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
})
}

最后将这个输入序列化后发送给 syz-manager 并将其添加到本地语料库中,如果未设置 ProgSmashed 标志位则再将这个输入放到 WorkSmash 队列中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
	data := item.p.Serialize()
sig := hash.Hash(data)

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,
})

proc.fuzzer.addInputToCorpus(item.p, inputSignal, sig)

if item.flags&ProgSmashed == 0 {
proc.fuzzer.workQueue.enqueue(&WorkSmash{item.p, item.call})
}
}

② proc.execute():执行输入,将有意思的输入放到 traige 队列

该函数的逻辑比较简单,主要便是调用 proc.executeRaw() 执行传入的输入,并调用 checkNewSignal() 检查执行的结果,将其中有意思的那些放入 traige 队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (proc *Proc) execute(execOpts *ipc.ExecOpts, p *prog.Prog, flags ProgTypes, stat Stat) *ipc.ProgInfo {
info := proc.executeRaw(execOpts, p, stat)
if info == nil {
return nil
}
calls, extra := proc.fuzzer.checkNewSignal(p, info)
for _, callIndex := range calls {
proc.enqueueCallTriage(p, flags, callIndex, info.Calls[callIndex])
}
if extra {
proc.enqueueCallTriage(p, flags, -1, info.Extra)
}
return info
}

executeRaw() 函数的主要逻辑其实就是先检查禁用的系统调用然后是一个伪无限循环调用 proc.env.Exec() 执行输入(好多层套娃),若执行失败则进行错误记录并休眠 1s 后重新进行,执行成功则直接返回

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
func (proc *Proc) executeRaw(opts *ipc.ExecOpts, p *prog.Prog, stat Stat) *ipc.ProgInfo {
proc.fuzzer.checkDisabledCalls(p)

// 限制并发窗口,每隔一段时间进行一次泄漏检查.
ticket := proc.fuzzer.gate.Enter()
defer proc.fuzzer.gate.Leave(ticket)

proc.logProgram(opts, p)
for try := 0; ; try++ {
atomic.AddUint64(&proc.fuzzer.stats[stat], 1)
output, info, hanged, err := proc.env.Exec(opts, p)
if err != nil {
if err == prog.ErrExecBufferTooSmall {
// 若我们系统地在序列化程序上失败则非常糟糕,
// 但目前为止除了统计以外我们没有更好的处理方式.
// 该错误在 seeded seeded syz_mount_image 调用上观察到很多.
atomic.AddUint64(&proc.fuzzer.stats[StatBufferTooSmall], 1)
return nil
}
if try > 10 {
log.Fatalf("executor %v failed %v times: %v", proc.pid, try, err)
}
log.Logf(4, "fuzzer detected executor failure='%v', retrying #%d", err, try+1)
debug.FreeOSMemory()
time.Sleep(time.Second)
continue
}
log.Logf(2, "result hanged=%v: %s", hanged, output)
return info
}
}

Env.Exec():启动 syz-executor 并将输入程序序列化后交给其执行

Env.Exec() 函数用来启动 syz-executor 并将输入交给其进行执行,syz-executor 在被启动后会一直等待 syz-fuzzer 传来的输入并执行,而不是每个输入都要重新启动一次 executor:

  • 首先将输入序列化到 env.in (作为 syz-executor 的输入,syz-executor 会反序列化后再将其执行),并将 env.out 的前 4 字节(ncmd and nsig )置 0;这里我们注意到 syz-fuzzer 与 syz-executor 间有两种传递数据的方式:管道/共享内存
  • 接下来检查 executor 是否已启动,若未启动( env.cmd == nil )则调用 makeCommand() 启动 syz-executor
  • 接下来调用 cmd.exec() 真正开始让 syz-executor 执行程序,若出错则关闭 syz-executor(等 executeRaw() 的下次循环重新启动)
  • 最后解析程序输出,返回结果
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 启动 executor 二进制文件以执行程序 p 并返回关于执行的信息:
// output: 程序输出
// info: per-call info
// hanged: 程序挂起且被杀死
// err0: 启动程序失败或是 executor 自身有问题.
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]
}
// 清零前两个字 (ncmd and nsig), 由此若 executor 在写入非垃圾数据前崩溃,
// 我们在这里便没有垃圾.
for i := 0; i < 4; i++ {
env.out[i] = 0
}

atomic.AddUint64(&env.StatExecs, 1)
if env.cmd == nil { /* syz-executor 未启动 */
if p.Target.OS != targets.TestOS && targets.Get(p.Target.OS, p.Target.Arch).HostFuzzer {
// executor 实际上是 ssh,
// 太频繁地启动他们会导致延迟.
<-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 { /* 出错,关闭 executor */
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
}

【核心】makeCommand():建立通信管道,启动 syz-executor

makeCommand() 函数是真正启动 syz-executor 的函数,其首先会先创建一个临时文件夹 syzkaller-testdir 并更改权限为 0777

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
func makeCommand(pid int, bin []string, config *Config, inFile, outFile *os.File, outmem []byte,
tmpDirPath string) (*command, error) {
dir, err := os.MkdirTemp(tmpDirPath, "syzkaller-testdir")
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %v", err)
}
dir = osutil.Abs(dir)

timeout := config.Timeouts.Program
if config.UseForkServer {
// 在启用了 fork server 时,Executor 有一个内部的 timeout,可以防止大部分的挂起,
// 因此我们用一个非常大的 timeout. Executor 可以因为命名空间中的全局锁与其他东西而变慢,
// 故我们最好等待,而非上报虚假的误导性 crashes.
timeout *= 10
}

c := &command{
pid: pid,
config: config,
timeout: timeout,
dir: dir,
outmem: outmem,
}
defer func() {
if c != nil {
c.close()
}
}()

if err := os.Chmod(dir, 0777); err != nil {
return nil, fmt.Errorf("failed to chmod temp dir: %v", err)
}

接下来创建三个用以与 syz-executor 通信的管道,分别用于捕获输出、向 executor 传递命令、从 executor 接收命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Output 捕获管道.
rp, wp, err := os.Pipe()
if err != nil {
return nil, fmt.Errorf("failed to create pipe: %v", err)
}
defer wp.Close()

// executor->ipc 命令管道.
inrp, inwp, err := os.Pipe()
if err != nil {
return nil, fmt.Errorf("failed to create pipe: %v", err)
}
defer inwp.Close()
c.inrp = inrp

// ipc->executor 命令管道.
outrp, outwp, err := os.Pipe()
if err != nil {
return nil, fmt.Errorf("failed to create pipe: %v", err)
}
defer outrp.Close()
c.outwp = outwp

c.readDone = make(chan []byte, 1)

接下来会调用 osutil 中的 Command() 创建一个 exec.Cmd 实例(该函数为 golag 原生的 exec.Command() 的 wrapper),executor 实际上要等到后面显式调用 Start()Run() 才正式开始运行(参见创建进程 · Go语言标准库):

1
2
3
4
5
6
7
8
9
cmd := osutil.Command(bin[0], bin[1:]...)
if inFile != nil && outFile != nil {
cmd.ExtraFiles = []*os.File{inFile, outFile}
}
cmd.Dir = dir
// Tell ASAN to not mess with our NONFAILING.
cmd.Env = append(append([]string{}, os.Environ()...), "ASAN_OPTIONS=handle_segv=0 allow_user_segv_handler=1")
cmd.Stdin = outrp
cmd.Stdout = inwp

若是未设置 FlagDebug 则还会启动一个新的协程持续读取 syz-executor 输出管道中的内容:

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
if config.Flags&FlagDebug != 0 {
close(c.readDone)
cmd.Stderr = os.Stdout
} else {
cmd.Stderr = wp
go func(c *command) {
// 读出输出以防 executor 持续地打印一些东西。
const bufSize = 128 << 10
output := make([]byte, bufSize)
var size uint64
for {
n, err := rp.Read(output[size:])
if n > 0 {
size += uint64(n)
if size >= bufSize*3/4 {
copy(output, output[size-bufSize/2:size])
size = bufSize / 2
}
}
if err != nil {
rp.Close()
c.readDone <- output[:size]
close(c.readDone)
return
}
}
}(c)
}

最后调用 cmd.Start() 真正启动 syz-executor(该方法不会阻塞父进程),并启动一个新的协程等待 syz-executor 的退出:

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
	if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start executor binary: %v", err)
}
c.exited = make(chan error, 1)
c.cmd = cmd
go func(c *command) {
err := c.cmd.Wait()
c.exited <- err
close(c.exited)
// 若 cmd.Stderr 已被泄露给另一个活动进程,防止 livelock.
rp.SetDeadline(time.Now().Add(5 * time.Second))
}(c)
wp.Close()
// 注意: 尽管我们在上面 defer 了,我们在调用🤝前明确地关闭 inwp.
// 若我们不这么做且 executor 在写入🤝答复前退出了,
// 由于我们持有另一个打开的管道末端,从 inrp 上读取将挂起.
inwp.Close()

if c.config.UseForkServer {
if err := c.handshake(); err != nil {
return nil, err
}
}
tmp := c
c = nil // disable defer above
return tmp, nil
}

【核心】cmd.exec():将单个程序输入传递给 syz-executor 执行

command.exec() 函数用以将一个序列化后的输入传递给 syz-executor 进程执行,传递的方式主要是通过在 makeCommand() 中建立的 ipc→executor 管道完成的:

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
func (c *command) exec(opts *ExecOpts, progData []byte) (output []byte, hanged bool, err0 error) {
req := &executeReq{
magic: inMagic,
envFlags: uint64(c.config.Flags),
execFlags: uint64(opts.Flags),
pid: uint64(c.pid),
syscallTimeoutMS: uint64(c.config.Timeouts.Syscall / time.Millisecond),
programTimeoutMS: uint64(c.config.Timeouts.Program / time.Millisecond),
slowdownScale: uint64(c.config.Timeouts.Scale),
progSize: uint64(len(progData)),
}
reqData := (*[unsafe.Sizeof(*req)]byte)(unsafe.Pointer(req))[:]
if _, err := c.outwp.Write(reqData); err != nil {
output = <-c.readDone
err0 = fmt.Errorf("executor %v: failed to write control pipe: %v", c.pid, err)
return
}
if progData != nil {
if _, err := c.outwp.Write(progData); err != nil {
output = <-c.readDone
err0 = fmt.Errorf("executor %v: failed to write control pipe: %v", c.pid, err)
return
}
}
// 在这个点程序已经开始运行了.

对于单个输入程序的传输实际上会先传递一个 executeReq 头部,接下来再传递序列化后的输入程序:

image.png

当数据传递过去之后输入程序就已经开始执行了,因此该函数后面的部分就都是对结果的处理;)

完成数据发送后会启动一个协程定时等待 executor 执行完毕,若超时则会将 executor 给 kill 掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
done := make(chan bool)
hang := make(chan bool)
go func() {
t := time.NewTimer(c.timeout)
select {
case <-t.C:
c.cmd.Process.Kill()
hang <- true
case <-done:
t.Stop()
hang <- false
}
}()

随后是一个无限大循环,从 executor→ipc 管道中读取 syz-executor 的执行结果,同样是一个 header executeReply 带一份数据 callReply ,单次请求可能有多份返回数据因此这里是一个无限循环,通过 executeReply 中的 done 标志位标识结束:

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
exitStatus := -1
completedCalls := (*uint32)(unsafe.Pointer(&c.outmem[0]))
outmem := c.outmem[4:]
for {
reply := &executeReply{}
replyData := (*[unsafe.Sizeof(*reply)]byte)(unsafe.Pointer(reply))[:]
if _, err := io.ReadFull(c.inrp, replyData); err != nil {
break
}
if reply.magic != outMagic {
fmt.Fprintf(os.Stderr, "executor %v: got bad reply magic 0x%x\n", c.pid, reply.magic)
os.Exit(1)
}
if reply.done != 0 {
exitStatus = int(reply.status)
break
}
callReply := &callReply{}
callReplyData := (*[unsafe.Sizeof(*callReply)]byte)(unsafe.Pointer(callReply))[:]
if _, err := io.ReadFull(c.inrp, callReplyData); err != nil {
break
}
if callReply.signalSize != 0 || callReply.coverSize != 0 || callReply.compsSize != 0 {
// 暂不支持.
fmt.Fprintf(os.Stderr, "executor %v: got call reply with coverage\n", c.pid)
os.Exit(1)
}
copy(outmem, callReplyData)
outmem = outmem[len(callReplyData):]
*completedCalls++
}

syz-fuzzer 接收 syz-executor 返回数据的示例如下图所示:

image.png

最后检查本次执行结果,如果说执行结果一切顺利则直接返回,否则会终止 syz-executor 的继续运行

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
	close(done)
if exitStatus == 0 {
// Program was OK.
<-hang
return
}
c.cmd.Process.Kill()
output = <-c.readDone
if err := c.wait(); <-hang {
hanged = true
if err != nil {
output = append(output, err.Error()...)
output = append(output, '\n')
}
return
}
if exitStatus == -1 {
exitStatus = osutil.ProcessExitStatus(c.cmd.ProcessState)
}
// 忽略其他的所有错误.
// 在没有 fork server 的情况下 executor 可以合法地退出 (program contains exit_group),
// 在带有 fork server 的情况下若 top process 想要特殊的处理,则其可以带着 statusFail 退出.
if exitStatus == statusFail {
err0 = fmt.Errorf("executor %v: exit status %d\n%s", c.pid, exitStatus, output)
}
return
}

③ proc.smashInput():执行并变异刚刚被加入到语料库中的程序【核心】

proc.smashInput() 用以执行并变异刚刚被加入到语料库中的程序(即 WorkSmash ):

  • 首先检查 faultInjectionEnabled ,若设置了则调用 proc.failCall()
    • 该函数用以将一个 fault 注入到程序中,其会循环一百次,设置 newProg.Calls[call].Props.FailNth = nthnewProg 为输入程序的克隆)后调用 proc.executeRaw() 进行执行
  • 接下来检查 comparisonTracingEnabled ,若设置了则调用 proc.executeHintSeed() 使用 hint 进行变异
  • 随后获取 syz-fuzzer 当前快照,并循环一百次:调用 prog.Mutate() 将输入程序进行变异后再调用 proc.executeAndCollide() 执行变异后的程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (proc *Proc) smashInput(item *WorkSmash) {
if proc.fuzzer.faultInjectionEnabled && item.call != -1 {
proc.failCall(item.p, item.call)
}
if proc.fuzzer.comparisonTracingEnabled && item.call != -1 {
proc.executeHintSeed(item.p, item.call)
}
fuzzerSnapshot := proc.fuzzer.snapshot()
for i := 0; i < 100; i++ {
p := item.p.Clone()
p.Mutate(proc.rnd, prog.RecommendedCalls, proc.fuzzer.choiceTable, proc.fuzzer.noMutate, fuzzerSnapshot.corpus)
log.Logf(1, "#%v: smash mutated", proc.pid)
proc.executeAndCollide(proc.execOpts, p, ProgNormal, StatSmash)
}
}

变异函数 prog.Mutate() 我们将在下一节中深入分析,本节我们先来看另外两个函数:

proc.executeHintSeed():使用 hint 变异程序并执行

该函数首先会将输入程序执行一次,接下来调用 prog.MutateWithHints 使用执行所得的 CompMap 对输入程序中的一些参数值进行替换,最后再将该输入程序执行一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (proc *Proc) executeHintSeed(p *prog.Prog, call int) {
log.Logf(1, "#%v: collecting comparisons", proc.pid)
// 首先执行原始程序以从 KCOV 获取比较值.
info := proc.execute(proc.execOptsComps, p, ProgNormal, StatSeed)
if info == nil {
return
}

// 接下来为每个系统调用参数于一个比较操作数的匹配对变异原始程序.
// 执行每一个这样的变异,以检查是否给出了新的覆盖率.
p.MutateWithHints(call, info.Calls[call].Comps, func(p *prog.Prog) {
log.Logf(1, "#%v: executing comparison hint", proc.pid)
proc.execute(proc.execOpts, p, ProgNormal, StatHint)
})
}

MutateWithHints() 当中输入进程会先被克隆一份,接下来使用 ForeachArg 遍历指定系统调用中的每个参数,并传入了一些闭包函数套娃(套中套中套属于是):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 使用存储在 compMaps中的比较操作数变异程序.
// 对于每个变种,执行 exec 回调.
func (p *Prog) MutateWithHints(callIndex int, comps CompMap, exec func(p *Prog)) {
p = p.Clone()
c := p.Calls[callIndex]
execValidate := func() {
// 不要尝试修复 candidate 程序.
// 假设原来的调用被 sanitized, 我们会得到一个坏的调用作为 hint 的替代结果,将其丢掉就行.
if p.Target.sanitize(c, false) != nil {
return
}
p.debugValidate()
exec(p)
}
ForeachArg(c, func(arg Arg, _ *ArgCtx) {
generateHints(comps, arg, execValidate)
})
}

类似于我们上文分析的 ForeachType()ForeachArg() 会为该调用的返回值与每一个参数都调用 foreachArgImpl(),该函数首先会调用上层传入的闭包 generateHints(),接下来会根据参数类型进行不同操作:

  • GroupArg (逻辑上的一组参数,即结构体数组):获取并遍历其中的每个成员,递归调用 foreachArgImpl()
  • PointerArg (指针类型):判断其指向 (pointee)是否为 nil,若否,递归调用 foreachArgImpl()
  • UnionArg (联合体类型):递归调用 foreachArgImpl()
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
func ForeachArg(c *Call, f func(Arg, *ArgCtx)) {
ctx := &ArgCtx{}
if c.Ret != nil {
foreachArgImpl(c.Ret, ctx, f)
}
ctx.Parent = &c.Args
ctx.Fields = c.Meta.Args
for _, arg := range c.Args {
foreachArgImpl(arg, ctx, f)
}
}

func foreachArgImpl(arg Arg, ctx *ArgCtx, f func(Arg, *ArgCtx)) {
ctx0 := *ctx
defer func() { *ctx = ctx0 }()
f(arg, ctx)
if ctx.Stop {
return
}
switch a := arg.(type) {
case *GroupArg:
overlayField := 0
if typ, ok := a.Type().(*StructType); ok {
ctx.Parent = &a.Inner
ctx.Fields = typ.Fields
overlayField = typ.OverlayField
}
var totalSize uint64
for i, arg1 := range a.Inner {
if i == overlayField {
ctx.Offset = ctx0.Offset
}
foreachArgImpl(arg1, ctx, f)
size := arg1.Size()
ctx.Offset += size
if totalSize < ctx.Offset {
totalSize = ctx.Offset - ctx0.Offset
}
}
claimedSize := a.Size()
varlen := a.Type().Varlen()
if varlen && totalSize > claimedSize || !varlen && totalSize != claimedSize {
panic(fmt.Sprintf("bad group arg size %v, should be <= %v for %#v type %#v",
totalSize, claimedSize, a, a.Type().Name()))
}
case *PointerArg:
if a.Res != nil {
ctx.Base = a
ctx.Offset = 0
foreachArgImpl(a.Res, ctx, f)
}
case *UnionArg:
foreachArgImpl(a.Option, ctx, f)
}
}

现在我们来看 generateHints() 函数,该函数首先会调用 Arg 接口的 Type() 函数获取类型并进行判断,对于大部分类型好像都是直接返回,仅有以下通过:

  • ConstType:会检查 IsPad 标志位,若不为真才会继续
  • BufferType 中的 BufferString, BufferGlob:会检查值的长度,为 0 才会继续
  • 其他的不在如下代码中的类型
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
func generateHints(compMap CompMap, arg Arg, exec func()) {
typ := arg.Type()
if typ == nil || arg.Dir() == DirOut {
return
}
switch t := typ.(type) {
case *ProcType:
// 随机的程序不会通过验证.
// 我们可以将其变异,但仅当结果值在合法范围内.
return
case *ConstType:
if IsPad(typ) {
return
}
case *CsumType:
// Csum 将不会通过验证,且总会被计算.
return
case *BufferType:
switch t.Kind {
case BufferFilename:
// 其可以生成逃逸路径,且通常不会太有用.
return
case BufferString, BufferGlob:
if len(t.Values) != 0 {
// 这些通常是文件名或完整的枚举。
// 若我们拦截 strcmp,将其变异可能是有用的
// (并过滤掉文件名).
return
}
}
}

最后会根据参数实际的存储类型进行判断:

  • 对于常量参数 ConstArg 类型会调用 checkConstArg() 处理
  • 对于数据参数 DataArg 类型,首先会判断是否为 BufferCompressed 类型,若是则调用 checkCompressedArg() ,否则调用 checkDataArg() 处理
1
2
3
4
5
6
7
8
9
10
11
	switch a := arg.(type) {
case *ConstArg:
checkConstArg(a, compMap, exec)
case *DataArg:
if typ.(*BufferType).Kind == BufferCompressed {
checkCompressedArg(a, compMap, exec)
} else {
checkDataArg(a, compMap, exec)
}
}
}

这三个函数都会调用到 shrinkExpand(),所以我们先来看看这个函数的实现,该函数用以使用给定的 CompMap 对参数进行替换,返回结果为可以用来进行替换的新参数值,其具体实现我们就不深入了,这里来看注释:

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
// Shrink and expand mutations 在当系统调用参数被转化为更狭窄(与更宽阔)的整型类型时
// 对这些情况进行建模.
//
// 收缩的动机:
//
// void f(u16 x) {
// u8 y = (u8)x;
// if (y == 0xab) {...}
// }
//
// 若我们调用 f(0x1234), 我们将看到在 0x34 与 0xab 间的比较,
// 我们将没法将参数 0x1234 与任何比较操作数匹配.
// 由此我们将 0x1234 收缩到 0x34 并尝试匹配 0x34.
// 若对于收缩后的值存在一个匹配,则我们替换输入中相应的字节
// (在给出的例子中我们将获得 0x12ab).
// 有的时候其他的比较操作数将比收缩后的值要宽
// (在上面的例子中考虑比较 if (y == 0xdeadbeef) {...}).
// 这种情况下我们忽略这样的比较因为我们无法给出做着类似事情的合法代码🌰.
// 为了避免这样的比较,我们我们使用 leastSize() 检查其 size.
//
// 扩展的动机:
//
// void f(i8 x) {
// i16 y = (i16)x;
// if (y == -2) {...}
// }
//
// 假如我们调用 f(-1), 我们将看到在 0xffff 与 0xfffe 间的比较,
// 并无法将输入与任何操作数匹配. 由此我们对输入进行符号扩展并检查扩展.
// 与收缩一样,我们忽略了另一个操作数更宽的情况.
// 需要注意的是 executor 将所有的比较操作数符号扩展至 int64.

下面我们来看这三个 check*Arg() ,我们首先看用来处理常量的 checkConstArg(),主要逻辑是嗲用 shrinkExpand() 并对其结果(CompMap 类型)进行遍历,将 arg.Val 依次替换为其提供的 replacer 值,并调用上层回调函数(也就是 execValidate() ,里面还会调用上层传入的闭包函数,最后会调用 proc.execute() ,也就是说对于 shrinkExpand() 提供的每个替换结果其都会执行一次):

1
2
3
4
5
6
7
8
9
10
func checkConstArg(arg *ConstArg, compMap CompMap, exec func()) {
original := arg.Val
// Note: because shrinkExpand returns a map, order of programs is non-deterministic.
// This can affect test coverage reports.
for _, replacer := range shrinkExpand(original, compMap, arg.Type().TypeBitSize(), false) {
arg.Val = replacer
exec()
}
arg.Val = original
}

接下来看 checkCompressedArg() ,主要就是将数据解压后逐 4 字节进行遍历,每次遍历时都会再调用 shrinkExpand() 获取可替换值并再对其结果进行遍历,调用上层传入的闭包函数执行替换数据后的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func checkCompressedArg(arg *DataArg, compMap CompMap, exec func()) {
data0 := arg.Data()
data, dtor := image.MustDecompress(data0)
defer dtor()
// Images are very large so the generic algorithm for data arguments
// can produce too many mutants. For images we consider only
// 4/8-byte aligned ints. This is enough to handle all magic
// numbers and checksums. We also ignore 0 and ^uint64(0) source bytes,
// because there are too many of these in lots of images.
bytes := make([]byte, 8)
for i := 0; i < len(data); i += 4 {
original := make([]byte, 8)
copy(original, data[i:])
val := binary.LittleEndian.Uint64(original)
for _, replacer := range shrinkExpand(val, compMap, 64, true) {
binary.LittleEndian.PutUint64(bytes, replacer)
copy(data[i:], bytes)
arg.SetData(image.Compress(data))
exec()
}
copy(data[i:], original)
}
arg.SetData(data0)
}

checkDataArg() 逻辑基本上与 checkCompressedArg() 一致,不过是逐字节遍历且没有解压过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func checkDataArg(arg *DataArg, compMap CompMap, exec func()) {
bytes := make([]byte, 8)
data := arg.Data()
size := len(data)
if size > maxDataLength {
size = maxDataLength
}
for i := 0; i < size; i++ {
original := make([]byte, 8)
copy(original, data[i:])
val := binary.LittleEndian.Uint64(original)
for _, replacer := range shrinkExpand(val, compMap, 64, false) {
binary.LittleEndian.PutUint64(bytes, replacer)
copy(data[i:], bytes)
exec()
}
copy(data[i:], original)
}
}

proc.executeAndCollide():简易的变异执行:

该函数的逻辑比较简单,首先会将程序执行一次,之后循环执行两次,不过会调用 proc.randomCollide() 先将原始程序进行处理后再执行:

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)
}
}

randomCollide() 流程如下:

  • 首先会有 20% 的几率调用 prog.DoubleExecCollide() (把源程序拷贝一份附加到其自身的末,并使用第一部分的资源),若未出错则直接返回
  • 接下来有 20% 的几率调用 prog.DupCallCollide() (将程序中的部分调用进行复制并标记为异步),若未出错则直接返回
  • 最后会调用 prog.AssignRandomAsync() (确保使用一个异步调用生产的资源的调用与其至少间隔一个非异步调用)并有 50% 的几率调用 prog.AssignRandomRerun()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

func (proc *Proc) randomCollide(origP *prog.Prog) *prog.Prog {
if proc.rnd.Intn(5) == 0 {
// Old-style collide with a 20% probability.
p, err := prog.DoubleExecCollide(origP, proc.rnd)
if err == nil {
return p
}
}
if proc.rnd.Intn(4) == 0 {
// Duplicate random calls with a 20% probability (25% * 80%).
p, err := prog.DupCallCollide(origP, proc.rnd)
if err == nil {
return p
}
}
p := prog.AssignRandomAsync(origP, proc.rnd)
if proc.rnd.Intn(2) != 0 {
prog.AssignRandomRerun(p, proc.rnd)
}
return p
}

生成新输入/变异现有输入,执行

继续回到 loop() 的无限循环中,如果说全局 WorkQueue 空了,说明这个时候我们要自己想办法弄新的输入了,首先我们要获取 syz-fuzzer 当前的快照(其中包括语料库、语料库权重值、总的权重值),之后进行判断:

  • 若语料库为空,或是已经进行了 generatePeriod 次循环,此时调用 target.Generate() 生成新的输入程序
  • 否则,选择一份现有的输入,调用 prog.Mutate() 将该输入进行变异,生成新的输入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
		ct := proc.fuzzer.choiceTable
fuzzerSnapshot := proc.fuzzer.snapshot()
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)
log.Logf(1, "#%v: mutated", proc.pid)
proc.executeAndCollide(proc.execOpts, p, ProgNormal, StatFuzz)
}
}
}

【核心】target.Generate():生成一个新的输入程序

该函数本体其实比较简短,主要就是随机生成指定数量的系统调用并打包到一个新的 Prog 结构体当中,生成的依据主要是我们前面给出的 ChoiceTable 表,最后移除程序末尾多余的系统调用便直接返回了:

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
// Generate 生成一个带有 ncalls 个调用的随机程序.
// ct 包含一组允许的系统调用, 若为 nil 则将使用所有的系统调用.
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))
for _, c := range calls {
s.analyze(c)
p.Calls = append(p.Calls, c)
}
}
// 对于生成的最后一个调用而言,我们有可能在创造资源的同时增加额外的系统调用,
// 从而导致调用数量超过 ncalls。移除部分调用。
/* 译注:例如 read 需要一个 fd,那可能前面会再补一个 open */
// 在最后的调用中的资源会被替换为默认值,这便是我们所想要的。
for len(p.Calls) > ncalls {
p.RemoveCall(ncalls - 1)
}
p.sanitizeFix()
p.debugValidate()
return p
}

生成单个系统调用的核心函数是 generateCall()

  • 首先会检查 insertPoint 是否不为 0(为 0 则说明刚开始生成第一个系统调用),若是则随机选取程序中已有的一个调用,若其未设置 NoGenerate 属性则将其 id (系统调用号)作为 biasCall,即生成的第一个系统调用完全随机,后续系统调用则根据优先级表进行生成
  • 接下来调用 ChoiceTable 的 choose 方法,对于第一个系统调用而言其会从 ChoiceTable 中随机选取一个系统调用作为 biasCall,接下来从优先级表中选取 biasCall 对应的优先级数据,从中随机选取一个范围中优先级最高的一个返回
  • 最后调用 generateParticularCall() 生成该系统调用所要用到的数据,这里就不展开了
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
func (ct *ChoiceTable) choose(r *rand.Rand, bias int) int {
if bias < 0 { /* 第一个系统调用,随机选择 */
bias = ct.calls[r.Intn(len(ct.calls))].ID
}
if !ct.Generatable(bias) {
fmt.Printf("bias to disabled or non-generatable syscall %v\n", ct.target.Syscalls[bias].Name)
panic("disabled or non-generatable syscall")
}
run := ct.runs[bias]
x := int32(r.Intn(int(run[len(run)-1])) + 1) /* 选择优先级范围 */
res := sort.Search(len(run), func(i int) bool {
return run[i] >= x
})
if !ct.Generatable(res) {
panic("selected disabled or non-generatable syscall")
}
return res
}

// ...

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)
}

【核心】prog.Mutate():变异一个输入程序

该函数的核心其实是一个随机变异的循环,这里的变量 r 为一个随机数生成器:

  • 生成一个 [0, 5) 之间的随机数,若为 0 则执行 mutator.squashAny()
  • 上一条未命中,则生成一个 [0, 100) 间随机数,若 >= 1 则执行 mutator.splice()
  • 上一条未命中,则生成一个 [0, 31) 间随机数,若 >= 20 则执行 mutator.insertCall()
  • 上一条未命中,则生成一个 [0, 11) 间随机数,若 >= 10 则执行 mutateArg()
  • 皆未命中,执行 mutator.removeCall()

终止循环的条件是 上述变异操作之一成功执行 & 系统调用数不为 0 ,此外还有 2/3 的概率重新进入循环

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
// Mutate program p.
//
// p: 要变异的程序.
// rs: 随机数资源池.
// ncalls: 变异后程序中允许的最大调用数量.
// ct: 系统调用的 ChoiceTable.
// noMutate: 一组不该被变异的系统调用的 ID 集合.
// corpus: 包括原始程序 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) {
ncalls = len(p.Calls)
}
ctx := &mutator{
p: p,
r: r,
ncalls: ncalls,
ct: ct,
noMutate: noMutate,
corpus: corpus,
}
for stop, ok := false, false; !stop; stop = ok && len(p.Calls) != 0 && r.oneOf(3) {
switch {
case r.oneOf(5):
// Not all calls have anything squashable,
// so this has lower priority in reality.
ok = ctx.squashAny()
case r.nOutOf(1, 100):
ok = ctx.splice()
case r.nOutOf(20, 31):
ok = ctx.insertCall()
case r.nOutOf(10, 11):
ok = ctx.mutateArg()
default:
ok = ctx.removeCall()
}
}
p.sanitizeFix()
p.debugValidate()
if got := len(p.Calls); got < 1 || got > ncalls {
panic(fmt.Sprintf("bad number of calls after mutation: %v, want [1, %v]", got, ncalls))
}
}

接下来我们来看这五个不同的变异方案

mutator.squashAny():随机变异一个系统调用的参数

该函数一开始会调用 Prog.complexPtr(),其中主要会调用 ForEachArg() 遍历用例程序的参数

1
2
3
4
5
// 选择一个随机的复杂指针并将其参数变为 ANY.
// 之后,若 ANY 中包含有 blob,变异一个随机的 blob.
func (ctx *mutator) squashAny() bool {
p, r := ctx.p, ctx.r
complexPtrs := p.complexPtrs()

传入给 ForEachArg() 的回调函数主要调用 isComplexPtr() 判断每个调用的参数中是否存在复杂指针(多级指针),若是,则将该调用添加到作为返回值的数组中,即这个函数其实主要是针对多级指针的变异

1
2
3
4
5
6
7
8
9
10
11
func (p *Prog) complexPtrs() (res []complexPtr) {
for _, c := range p.Calls {
ForeachArg(c, func(arg Arg, ctx *ArgCtx) {
if ptrArg, ok := arg.(*PointerArg); ok && p.Target.isComplexPtr(ptrArg) {
res = append(res, complexPtr{ptrArg, c})
ctx.Stop = true
}
})
}
return
}

接下来会从返回列表中随机选择一个指针,若其对应的调用不允许变异则直接返回,若非指针则调用 squashPtr()

1
2
3
4
5
6
7
ptr := complexPtrs[r.Intn(len(complexPtrs))]
if ctx.noMutate[ptr.call.Meta.ID] {
return false
}
if !p.Target.isAnyPtr(ptr.arg.Type()) {
p.Target.squashPtr(ptr.arg)
}

接下来会调用 ForEachSubArg()(本质上为 ForEachArgImpl() 的封装),对之前随机选取的系统调用的参数进行遍历,这里传入的回调函数主要是检查 arg.Dir() 是否为 DirOut,若没有一个参数符合则直接返回 false

1
2
3
4
5
6
7
8
9
10
11
var blobs []*DataArg
var bases []*PointerArg
ForeachSubArg(ptr.arg, func(arg Arg, ctx *ArgCtx) {
if data, ok := arg.(*DataArg); ok && arg.Dir() != DirOut {
blobs = append(blobs, data)
bases = append(bases, ctx.Base)
}
})
if len(blobs) == 0 {
return false
}

最后一部分主要是调用 analyze() 进行分析,之后调用 mutateData() 来对参数的数据进行变异,以及对指针的更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
	// 注意: 我们需要在变异之前调用 analyze.
// 在变异后其可以增长到超过数据区域的范围,
// 在标记现有分配时 analyze 将为 OOB 访问崩溃.
s := analyze(ctx.ct, ctx.corpus, p, ptr.call)
// TODO(dvyukov): 我们可能想要为 ANY 弄一个特别的变异.
// 例如,合并相邻的 ANYBLOBs (我们并不创造他们,
// 但他们能在将来出现); 或是用一个 blob 替代 ANYRES
// (并使用相邻的 blobs 将其合并).
idx := r.Intn(len(blobs))
arg := blobs[idx]
base := bases[idx]
baseSize := base.Res.Size()
arg.data = mutateData(r, arg.Data(), 0, maxBlobLen)
// 若 size 变大了则更新基指针.
if baseSize < base.Res.Size() {
newArg := r.allocAddr(s, base.Type(), base.Dir(), base.Res.Size(), base.Res)
*base = *newArg
}
return true
}

mutator.splice():随机选取语料库中一个程序的系统调用插入到程序随机位置中

该函数为编译过程当中概率最大的主分支,不过其逻辑比较简短:

  • 首先从语料库中随机复制一个程序
  • 选择一个随机下标 idx ,在待变异程序该下标处插入 p0 的系统调用组
  • 从末尾移除多余的调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 译注:注释写得比较抽象这里就不贴了:)
func (ctx *mutator) splice() bool {
p, r := ctx.p, ctx.r
if len(ctx.corpus) == 0 || len(p.Calls) == 0 || len(p.Calls) >= ctx.ncalls {
return false
}
p0 := ctx.corpus[r.Intn(len(ctx.corpus))]
p0c := p0.Clone()
idx := r.Intn(len(p.Calls))
p.Calls = append(p.Calls[:idx], append(p0c.Calls, p.Calls[idx:]...)...)
for i := len(p.Calls) - 1; i >= ctx.ncalls; i-- {
p.RemoveCall(i)
}
return true
}

mutator.insertCall():随机生成一个系统调用插入到程序中随机位置

该函数的逻辑也比较简短,若程序中调用数量已经达到 ncalls 的限制则直接返回,否则会先调用 analyze() 进行分析,随后调用 generateCall() 生成一个系统调用并插入到程序中的一个随机位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 在一个随机点(倾向于现有程序的末尾)插入一个随机调用
// 若该程序调用数量早已达到 ncalls(译注:限制数量)则不会插入)
func (ctx *mutator) insertCall() bool {
p, r := ctx.p, ctx.r
if len(p.Calls) >= ctx.ncalls {
return false
}
idx := r.biasedRand(len(p.Calls)+1, 5)
var c *Call
if idx < len(p.Calls) {
c = p.Calls[idx]
}
s := analyze(ctx.ct, ctx.corpus, p, c)
calls := r.generateCall(s, p, idx)
p.insertBefore(c, calls)
for len(p.Calls) > ctx.ncalls {
p.RemoveCall(idx)
}
return true
}

mutator.mutateArg():随机变异程序中某一系统调用的参数

该函数的主要作用便是随机变异程序中某一系统调用的参数,这里就不深入展开分析了,感兴趣可以直接看源码:

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
func (ctx *mutator) mutateArg() bool {
p, r := ctx.p, ctx.r
if len(p.Calls) == 0 {
return false
}

idx := chooseCall(p, r)
if idx < 0 {
return false
}
c := p.Calls[idx]
if ctx.noMutate[c.Meta.ID] {
return false
}
updateSizes := true
for stop, ok := false, false; !stop; stop = ok && r.oneOf(3) {
ok = true
ma := &mutationArgs{target: p.Target}
ForeachArg(c, ma.collectArg)
if len(ma.args) == 0 {
return false
}
s := analyze(ctx.ct, ctx.corpus, p, c)
arg, argCtx := ma.chooseArg(r.Rand)
calls, ok1 := p.Target.mutateArg(r, s, arg, argCtx, &updateSizes)
if !ok1 {
ok = false
continue
}
p.insertBefore(c, calls)
idx += len(calls)
for len(p.Calls) > ctx.ncalls {
idx--
p.RemoveCall(idx)
}
if idx < 0 || idx >= len(p.Calls) || p.Calls[idx] != c {
panic(fmt.Sprintf("wrong call index: idx=%v calls=%v p.Calls=%v ncalls=%v",
idx, len(calls), len(p.Calls), ctx.ncalls))
}
if updateSizes {
p.Target.assignSizesCall(c)
}
}
return true
}

mutator.removeCall():随机移除程序中的一个系统调用

该函数也比较简短,主要就是随机移除程序中的一个系统调用,如果程序中没有系统调用则不会进行移除操作:

1
2
3
4
5
6
7
8
9
func (ctx *mutator) removeCall() bool {
p, r := ctx.p, ctx.r
if len(p.Calls) == 0 {
return false
}
idx := r.Intn(len(p.Calls))
p.RemoveCall(idx)
return true
}

至此,syz-fuzzer 主体源码逻辑分析完毕

简单总结的话,笔者感觉其实主要都是偏工程性的东西比较多🤔


【FUZZ.0x03】syzkaller - III:syz-fuzzer 源码分析
https://arttnba3.github.io/2023/09/27/FUZZ-0X03-SYZKALLER-III_SOURCE_SYZFUZZER/
作者
arttnba3
发布于
2023年9月27日
许可协议