[syzkaller源码阅读] 1. Loading of config and corpus

在前一篇文章中,我们从文档了解了syzkaller的fuzz结构和使用方法。这一篇我们将正式开始源码阅读,从一切的入口开始。

Entry & Config

程序的入口是在syz-manager/manager.go.

1
2
3
4
5
6
7
8
func main() {
flag.Parse()
if !prog.GitRevisionKnown() {
log.Fatalf("bad syz-manager build: build with make, run bin/syz-manager")
}
log.EnableLogCaching(1000, 1<<20)
cfg, err := mgrconfig.LoadFile(*flagConfig)
...

main函数解析参数,设置日志缓存长度,然后把配置文件解析到cfg变量。

flag.Parse()会将命令行参数-xxx解析为flagXxx,这里的flagConfig就是在启动syz-manager时传入的配置文件路径。

我们直接跟进pkg/mgrconfig/config.go 看Config结构体定义,反引号包裹的结构体标签描述了此配置项在json格式配置文件中的key,注释中解释了每一个配置项的含义。

  • workdir_template : 指定一个目录,这个目录将在每一次创建虚拟机时被拷贝到供该虚拟机使用的临时目录中,这个临时目录的位置可以在qemu参数中使用{{TEMPLATE}} 指代,我们可以看到注释中给出了这样的示例:"qemu_args": "-fda {{TEMPLATE}}/fd" ,即将模版中的fd挂载到软盘驱动器A
  • kernel_obj : vmlinux所在目录,如果不是直接在源码树目录中执行的编译 ,还需要指定一下kernel_src ,否则syzkaller会无法生成源码上的coverage报告

其余部分不在这里详细列出(懒得写),我们在后面的审计中碰到再进行描述。

我们接着看这一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
...
var mode *Mode
for _, m := range modes {
if *flagMode == m.Name {
mode = m
break
}
}
if mode == nil {
flag.PrintDefaults()
log.Fatalf("unknown mode: %v", *flagMode)
}
if mode.CheckConfig != nil {
if err := mode.CheckConfig(cfg); err != nil {
log.Fatalf("%v mode: %v", mode.Name, err)
}
}
if !mode.UseDashboard {
cfg.DashboardClient = ""
cfg.HubClient = ""
}
...

其中,modes是一些预定义的Mode结构体:

1
2
3
4
5
6
7
8
modes = []*Mode{
ModeFuzzing,
ModeSmokeTest,
ModeCorpusTriage,
ModeCorpusRun,
ModeRunTests,
ModeIfaceProbe,
}

我们可以在syz-manager 的帮助信息里看到对此的说明:

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

-mode string
mode of operation, one of:
- fuzzing: the default continuous fuzzing mode
- smoke-test: run smoke test for syzkaller+kernel
The test consists of booting VMs and running some simple test programs
to ensure that fuzzing can proceed in general. After completing the test
the process exits and the exit status indicates success/failure.
If the kernel oopses during testing, the report is saved to workdir/report.json.
- corpus-triage: triage corpus and exit
This is useful mostly for benchmarking with testbed.
- corpus-run: continuously run the corpus programs
- run-tests: run unit tests
Run sys/os/test/* tests in various modes and print results.
- iface-probe: run dynamic part of kernel interface auto-extraction
When the probe is finished, manager writes the result to workdir/interfaces.json file and exits.
(default "fuzzing")

不指定mode就默认是fuzzing模式,也就是正常的模糊测试。

  • smoke-test:执行一些简单样例检测syzkaller和内核配置是否正常
  • corpus-traige:语料库筛选
  • corpus-run:持续运行语料库中的程序
  • run-tests:运行 sys/os/test/* 目录下的测试
  • iface-probe:动态提取内核接口并写入 workdir/interfaces.json

寻找CheckConfig ,发现只有ModeIfaceProbe 实现了这个函数:

1
2
3
4
5
6
7
8
9
CheckConfig: func(cfg *mgrconfig.Config) error {
if cfg.Snapshot {
return fmt.Errorf("snapshot mode is not supported")
}
if cfg.Sandbox != "none" {
return fmt.Errorf("sandbox \"%v\" is not supported (only \"none\")", cfg.Sandbox)
}
return nil
}

也就是这个模式的要求是config中不能指定Snapshot为True(snapshot选项允许vm从上一次运行的快照中热启动),且sandbox需要为none。

检查完config和mode之后,进入RunManager(mode, cfg) ,这个函数用来初始化Manager并进入fuzz循环,它主要做了这么一些事情:

  • 根据config传入的vm类型创建vm池
  • 创建一个Reporter,这个东西的主要组件是一系列正则表达式,用于从vm输出中提取各种信息
  • 从workdir中加载语料库(如果有的话)
  • 启动一个HTTP Server,用于运行dashboard
  • 启动RPC Server,用于和vm中的executor交互
  • 进入Fuzz主循环

vm池

在RunManager的开头,创建了虚拟机池

1
2
3
4
5
6
7
8
9
10
11
func RunManager(mode *Mode, cfg *mgrconfig.Config) {
var vmPool *vm.Pool
if !cfg.VMLess {
var err error
vmPool, err = vm.Create(cfg, *flagDebug)
if err != nil {
log.Fatalf("%v", err)
}
defer vmPool.Close()
}
...

跟进vm.Create, flagDebug是从命令行参数-debug 解析而来,表示调试模式,在这个模式下只有vm池中将只有一个vm,且vm中同一时刻将只有一个进程在执行。

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
func Create(cfg *mgrconfig.Config, debug bool) (*Pool, error) {
typ, ok := vmimpl.Types[vmType(cfg.Type)]
if !ok {
return nil, fmt.Errorf("unknown instance type '%v'", cfg.Type)
}
env := &vmimpl.Env{
Name: cfg.Name,
OS: cfg.TargetOS,
Arch: cfg.TargetVMArch,
Workdir: cfg.Workdir,
Image: cfg.Image,
SSHKey: cfg.SSHKey,
SSHUser: cfg.SSHUser,
Timeouts: cfg.Timeouts,
Snapshot: cfg.Snapshot,
Debug: debug,
Config: cfg.VM,
KernelSrc: cfg.KernelSrc,
}
impl, err := typ.Ctor(env)
if err != nil {
return nil, err
}
count := impl.Count()
if debug && count > 1 {
log.Logf(0, "limiting number of VMs from %v to 1 in debug mode", count)
count = 1
}
return &Pool{
impl: impl,
typ: typ,
workdir: env.Workdir,
template: cfg.WorkdirTemplate,
timeouts: cfg.Timeouts,
count: count,
snapshot: cfg.Snapshot,
hostFuzzer: cfg.SysTarget.HostFuzzer,
statOutputReceived: stat.New("vm output", "Bytes of VM console output received",
stat.Graph("traffic"), stat.Rate{}, stat.FormatMB),
}, nil
}

vmimpl.Types 预置了一些Type,这些预置值在vm包下的其他文件中注册,例如vm/qemu/qemu.go :

1
2
3
4
5
6
7
func init() {
var _ vmimpl.Infoer = (*instance)(nil)
vmimpl.Register("qemu", vmimpl.Type{
Ctor: ctor,
Overcommit: true,
})
}

Ctor是Constructor,Type结构体包含了Ctor和两个布尔值,显然Ctor就是vm pool创建的核心逻辑,我们就只看qemu,别的vm不管了。

1
2
3
4
5
6
7
8
9
10
11
12
13
func ctor(env *vmimpl.Env) (vmimpl.Pool, error) {
archConfig := archConfigs[env.OS+"/"+env.Arch]
cfg := &Config{
Count: 1,
CPU: 1,
Mem: 1024,
ImageDevice: "hda",
Qemu: archConfig.Qemu,
QemuArgs: archConfig.QemuArgs,
NetDev: archConfig.NetDev,
Snapshot: true,
}
...

看一下archConfigs["linux/amd64"]

1
2
3
4
5
6
7
8
9
10
11
12
13
"linux/amd64": {
Qemu: "qemu-system-x86_64",
QemuArgs: "-enable-kvm -cpu host,migratable=off",
// e1000e fails on recent Debian distros with:
// Initialization of device e1000e failed: failed to find romfile "efi-e1000e.rom
// But other arches don't use e1000e, e.g. arm64 uses virtio by default.
NetDev: "e1000",
RngDev: "virtio-rng-pci",
CmdLine: []string{
"root=/dev/sda",
"console=ttyS0",
},
},

这些都是用于创建qemu虚拟机的命令行参数,archConfigs用于构建默认Config,这里的Config是qemu包的Config,区别于manager使用的Config。

1
2
3
4
5
6
func ctor(env *vmimpl.Env) (vmimpl.Pool, error) {
...
if err := config.LoadData(env.Config, cfg); err != nil {
return nil, fmt.Errorf("failed to parse qemu vm config: %w", err)
}
...

这里的env.Config的值从cfg.VM继承而来,这个字段是一个裸json :

1
VM json.RawMessage `json:"vm"`

读到这里我们也能知道syzkaller config中的vm字段定义是依赖于vm类型(即config中的type字段)的。

再往后是一些基本的参数检查,检查结束后创建一个vmimpl.Pool结构体

1
2
3
4
5
6
7
8
9
10
11
func ctor(env *vmimpl.Env) (vmimpl.Pool, error) {
...
pool := &Pool{
env: env,
cfg: cfg,
version: version,
target: targets.Get(env.OS, env.Arch),
archConfig: archConfig,
}
return pool, nil
}

返回到vm.Create函数,这里的返回值赋给impl ,再套进vm.Pool结构体中,至此vm池创建结束。

Corpus Preload

1
2
3
4
5
6
7
8
9
10
func RunManager(mode *Mode, cfg *mgrconfig.Config) {
...
mgr.initStats() // 初始化各种统计指标
if mgr.mode.LoadCorpus {
go mgr.preloadCorpus()
} else {
close(mgr.corpusPreload)
}
...
}

这里进行了语料库的载入,条件是mgr.mode.LoadCorpus 为true,只有ModeFuzzingModeCorpusTriageModeCorpusRun 三种模式满足。

跟进preloadCorpus 方法。

1
2
3
4
5
6
7
8
9
func (mgr *Manager) preloadCorpus() {
info, err := manager.LoadSeeds(mgr.cfg, false)
if err != nil {
log.Fatalf("failed to load corpus: %v", err)
}
mgr.fresh = info.Fresh
mgr.corpusDB = info.CorpusDB
mgr.corpusPreload <- info.Candidates
}

LoadSeeds:

1
2
3
4
func LoadSeeds(cfg *mgrconfig.Config, immutable bool) (Seeds, error) {
...
info.CorpusDB, err = db.Open(filepath.Join(cfg.Workdir, "corpus.db"), !immutable)
...

注意到immutable这个参数,其字面意思是不可变。

全局搜索LoadSeeds的引用就能发现只有两处进行了调用,一次是这里,进入fuzz loop前,传入false;另一处在pkg/manager/diff.go#RunDiffFuzzer,传入的是true,向上追踪,只有两处调用了这个方法,分别是tools/syz-diff/diff.go#main,syz-cluster/workflow/fuzz-step/main.go#run。都不是syz-manager会经过的路径,我们暂且不看。

接着看db.Open,syzkaller的db是一个自己实现的键值对数据库,这个函数负责将workdir中的corpus.db文件加载到db.DB结构体中,repair=true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Open opens the specified database file.
// If the database is corrupted and reading failed, then it returns an non-nil db
// with whatever records were recovered and a non-nil error at the same time.
func Open(filename string, repair bool) (*DB, error) {
db := &DB{
filename: filename,
}
var deserializeErr error
db.Version, db.Records, db.uncompacted, deserializeErr = deserializeFile(db.filename)
// Deserialization error is considered a "soft" error if repair == true,
// but compact below ensures that the file is at least writable.
if deserializeErr != nil && !repair {
return nil, deserializeErr
}
if err := db.compact(); err != nil {
return nil, err
}
return db, deserializeErr
}

desrialize的具体细节不重要,注意到这里对反序列化错误的处理:当读取失败且repair为true时,基于已经读取到的键值对进行文件格式恢复,即重新序列化,这样就可以应对语料库文件尾破损的情况。

回到LoadSeeds 里,返回值保存到info.corpusDB ,这里面的键值对(Record)是byte[]形式,还需要将字节流还原为种子(Seed),我们已经解释过,种子实质是fuzz过程中产生的有价值的输入程序(syscall序列),在syzkaller的源码中对应结构体prog.Prog

1
2
3
4
5
6
7
8
9
func LoadSeeds(cfg *mgrconfig.Config, immutable bool) (Seeds, error) {
...
outputs := make(chan *input, 32)
chErr := make(chan error, 1)
go func() {
chErr <- readInputs(cfg, info.CorpusDB, outputs)
close(outputs)
}()
...

看一眼readInputs

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
func readInputs(cfg *mgrconfig.Config, db *db.DB, output chan *input) error {
procs := runtime.GOMAXPROCS(0)
inputs := make(chan *input, procs)
var wg sync.WaitGroup
wg.Add(procs)

defer wg.Wait()
defer close(inputs)
for p := 0; p < procs; p++ {
go func() {
defer wg.Done()
for inp := range inputs {
inp.Prog, inp.Err = ParseSeed(cfg.Target, inp.Data)
output <- inp
}
}()
}

for key, rec := range db.Records {
inputs <- &input{
Key: key,
Data: rec.Val,
}
}
seedPath := filepath.Join("sys", cfg.TargetOS, "test")
seedDir := filepath.Join(cfg.Syzkaller, seedPath)
if osutil.IsExist(seedDir) {
seeds, err := os.ReadDir(seedDir)
if err != nil {
return fmt.Errorf("failed to read seeds dir: %w", err)
}
for _, seed := range seeds {
data, err := os.ReadFile(filepath.Join(seedDir, seed.Name()))
if err != nil {
return fmt.Errorf("failed to read seed %v: %w", seed.Name(), err)
}
inputs <- &input{
IsSeed: true,
Path: filepath.Join(seedPath, seed.Name()),
Data: data,
}
}
}
return nil
}

syzkaller会从两个地方加载种子,一个是workdir下的corpus.db,一个是syzkaller源码目录下的sys/{os}/test ,如果后者加载失败,那么LoadSeeds将直接返回一个空集合。这里种子的加载是一个生产-消费模式的写法,procs是可用处理器数量,inputs是缓冲长度为procs的输入通道,同时procs个goroutine负责从inputs中读取和解析数据,解析结果输入到output这个channel中。

再回到LoadSeeds中,我们会发现对outputs的处理也是一个异步结构,readInputs是用goroutine启动的,下面的for循环等待从outputs中接收数据,将有效种子加入candidates,candidates是fuzz的初始输入,在执行这些初始输入的基础上去做变异等一系列后续操作。

这里对“有效”的定义是inp.Prog 是否为nil,即如果从原始数据中还原出了一个当前fuzz目标可执行的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
func LoadSeeds(cfg *mgrconfig.Config, immutable bool) (Seeds, error) {
...
for inp := range outputs {
if inp.Prog == nil {
if inp.IsSeed {
if errors.Is(inp.Err, ErrSkippedTest) {
skippedSeeds++
log.Logf(2, "seed %s is skipped: %s", inp.Path, inp.Err)
} else {
brokenSeeds++
log.Logf(0, "seed %s is broken: %s", inp.Path, inp.Err)
}
} else {
brokenCorpus = append(brokenCorpus, inp.Key)
}
continue
}
flags := corpusFlags
if inp.IsSeed {
if _, ok := info.CorpusDB.Records[hash.String(inp.Prog.Serialize())]; ok {
continue
}
// Seeds are not considered "from corpus" (won't be rerun multiple times)
// b/c they are tried on every start anyway.
flags = fuzzer.ProgMinimized
}
candidates = append(candidates, fuzzer.Candidate{
Prog: inp.Prog,
Flags: flags,
})
}
...

观察到candidates保存了Prog和Flags两个变量,后者用于标识此程序的类型,其默认来源于corpusFlags := versionToFlags(info.CorpusDB.Version) ,整个语料库的统一标识;当一个有效种子不在info.CorpusDB.Records 中,即由sys/{os}/test 目录加载而来时,设置标志为ProgMinimized ,字面上就是“已经最小化的”,关于最小化这个事情我们放到后面再解释。

总之,被预加载的语料最终在preloadCorpus的结尾会被输送到mgr,我们注意到这是贯穿fuzz始终的最高层全局变量。

Summary

本文从 syz-manager 的入口函数出发,详细梳理了 syzkaller 在启动阶段的若干关键步骤:加载配置文件、创建虚拟机池,并开始载入已有的语料库(corpus)。
下一篇文章,我们将接着审计RunManager函数,深入RPC Server的构建和启动细节。