extract.go

看到网上大部分对于syzkaller的分析都是对于fuzz linux内核的流程的,尝试性的写一下其fuzz windows这样的闭源内核的流程。

先看一下docs/windows/readme.txt。给我们提供了一些有效信息在于

1
2
3
4
若要更新描述,请运行(假设 cl 交叉编译器在 PATH 中):
syz-extract -os=windows
syz-sysgen
sys/windows/windows.txt 是通过 tools/syz-declextract 从 Windows 头文件自动提取的。

那么咱们追踪一下,在syz-extract文件传入os=windows时会发生什么。在sys/sys-extract/extract.go文件中有如下定义,将flagOS设置为runtime.GOOS或者用户传入的-os值

1
flagOS = flag.String("os", runtime.GOOS, "target OS")

那么上面的命令行传入后flagOS会被设置为windows。在下面的main函数中有

1
2
3
4
5
6
7
8
9
OS := *flagOS
extractor := extractors[OS]
if extractor == nil {
tool.Failf("unknown os: %v", OS)
}
arches, nfiles, err := createArches(OS, archList(OS, *flagArch), flag.Args())
if err != nil {
tool.Fail(err)
}

将OS设置为上文的flagOS,extractor是什么呢?在该文件的前文有定义

1
2
3
4
5
6
7
8
9
10
11
12
var extractors = map[string]Extractor{
targets.Akaros: new(akaros),
targets.Linux: new(linux),
targets.FreeBSD: new(freebsd),
targets.Darwin: new(darwin),
targets.NetBSD: new(netbsd),
targets.OpenBSD: new(openbsd),
"android": new(linux),
targets.Fuchsia: new(fuchsia),
targets.Windows: new(windows),
targets.Trusty: new(trusty),
}

相当于new(windows),windows这个结构体就定义在同一个文件夹的windows.go中,主要内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type windows struct{}

func (*windows) prepare(sourcedir string, build bool, arches []*Arch) error {
return nil
}

func (*windows) prepareArch(arch *Arch) error {
return nil
}

func (*windows) processFile(arch *Arch, info *compiler.ConstInfo) (map[string]uint64, map[string]bool, error) {
params := &extractParams{
DeclarePrintf: true,
TargetEndian: arch.target.HostEndian,
}
return extract(info, "cl", nil, params)
}

可以看到结构体中有三个函数,prepare,prepareArch都直接返回nil,主要就看最后这个processFile函数,接收两个参数,分别是系统架构信息和编译器的架构信息,返回三个参数,一个str->uint64的集合,一个str->bool的集合,一个error信息。而返回信息是通过extract函数得到的,具体的extract函数后文再说,先回到刚才的main函数中。

如果extractor无法识别,就报错。否则的话,调用createArches函数。该函数也在extract.go文件中。不过咱们先搞懂传入的到底是些个什么东西第一个参数OS不说了,第二个参数使用archList函数获取的,传入这个函数的两个参数分别是OS和archFlag,这个是用户输入的架构,那么现在来看archList函数

1
2
3
4
5
6
7
8
9
10
11
func archList(OS, arches string) []string {//返回值为一个str数组
if arches != "" {
return strings.Split(arches, ",")
}//如果arches不为空,就返回按照逗号切割的结果
var archArray []string
for arch := range targets.List[OS] {
archArray = append(archArray, arch)
}//如果是空的,就讲targets.List的对应内容放到archArray数组中,在排序后再返回
sort.Strings(archArray)
return archArray
}

接着追一下targets.List,在sys/targets/targets.go中可以找到相关的定义,可以看到,对于windows只考虑AMD64这一种,其中包括了该架构下的指针大小,页大小,大小端序等等。

1
2
3
4
5
6
7
8
9
10
11
12
var List = map[string]map[string]*Target{
...
Windows: {
AMD64: {
PtrSize: 8,
// TODO(dvyukov): what should we do about 4k vs 64k?
PageSize: 4 << 10,
LittleEndian: true,
},
},
...
}

那咱们就可以确定了,传入extract函数的第二个参数就是一个只有AMD64的字符串数组,第三个参数flag.Args则传入了未能解析的命令行参数。弄明白了参数就可以去分析createArches函数了。

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
func createArches(OS string, archArray, files []string) ([]*Arch, int, error) {
errBuf := new(bytes.Buffer)
eh := func(pos ast.Pos, msg string) {
fmt.Fprintf(errBuf, "%v: %v\n", pos, msg)
}
top := ast.ParseGlob(filepath.Join("sys", OS, "*.txt"), eh) //将对应操作系统的txt文件解析为ast
if top == nil {
return nil, 0, fmt.Errorf("%v", errBuf.String())
}
allFiles := compiler.FileList(top, OS, eh)
if allFiles == nil {
return nil, 0, fmt.Errorf("%v", errBuf.String())
}
if len(files) == 0 {
for file := range allFiles {
files = append(files, file)
}
}//如果files列表为空,就将allfiles中的文件填入files列表中
nfiles := 0
var arches []*Arch
for _, archStr := range archArray { //遍历arch数组,创建builddir文件夹
buildDir := ""
if *flagBuild {
dir, err := os.MkdirTemp("", "syzkaller-kernel-build")
if err != nil {
return nil, 0, fmt.Errorf("failed to create temp dir: %w", err)
}
buildDir = dir
} else if *flagBuildDir != "" {
buildDir = *flagBuildDir
} else {
buildDir = *flagSourceDir
}

target := targets.Get(OS, archStr) //通过操作系统和架构确定目标
if target == nil {
return nil, 0, fmt.Errorf("unknown arch: %v", archStr)
}

arch := &Arch{//创建 Arch 对象
target: target,
sourceDir: *flagSourceDir,
includeDirs: *flagIncludes,
buildDir: buildDir,
build: *flagBuild,
done: make(chan bool),
}
var archFiles []string //过滤allfiles中可以处理的放入archfiles
for _, file := range files {
meta, ok := allFiles[file]
if !ok {
return nil, 0, fmt.Errorf("unknown file: %v", file)
}
if meta.NoExtract || !meta.SupportsArch(archStr) {
continue
}
archFiles = append(archFiles, file)
}
sort.Strings(archFiles)
for _, f := range archFiles {//给每个文件创建一个file对象
arch.files = append(arch.files, &File{
arch: arch,
name: f,
done: make(chan bool),
})
}
//更新架构和文件列表
arches = append(arches, arch)
nfiles += len(arch.files)
}
return arches, nfiles, nil
}

总而言之这里返回了对应架构的结构体数组,文件数量,和错误情况。

接下来,调用了extractor类的prepare函数

1
2
3
if err := extractor.prepare(*flagSourceDir, *flagBuild, arches); err != nil {
tool.Fail(err)
}

主要就是定义了三个函数,windows这个target类实现了这些函数,不过根据前文,这个函数是直接返回nil的,不用看了,嘿嘿

1
2
3
4
5
6
7
jobC := make(chan interface{}, len(arches)+nfiles)//创建一个大小为len(arches)+nfiles的通道
for _, arch := range arches {//将arch中的每个元素发给jobc
jobC <- arch
}
for p := 0; p < runtime.GOMAXPROCS(0); p++ {//这是go语言中的并发操作,每个“线程”去处理一个任务,任务的信息有extractor和jobC(也就是arch)
go worker(extractor, jobC)
}

这里要看一下worker函数的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func worker(extractor Extractor, jobC chan interface{}) {
for job := range jobC {
switch j := job.(type) {
case *Arch://最开始肯定会进入这个分支
infos, err := processArch(extractor, j)//这一步主要是生成const信息,下面再看这个函数
j.err = err
close(j.done)
if j.err == nil {
for _, f := range j.files { //把相关的文件都放到files结构体里,然后再放到jobC里
f.info = infos[filepath.Join("sys", j.target.OS, f.name)]
jobC <- f
}
}
case *File: //刚才放进去的文件在这里处理
j.consts, j.undeclared, j.err = processFile(extractor, j.arch, j)
close(j.done)
}
}
}

proc.go

我决定先不看extract.go了。先来看看核心内容吧。

先看看fuzz的主循环,代码在sys-fuzzer/proc.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
39
40
41
42
func (proc *Proc) loop() {
// 设置生成新程序的周期。
generatePeriod := 100
if proc.fuzzer.config.Flags&ipc.FlagSignal == 0 {
// 如果没有真实的覆盖信号,更频繁地生成程序,因为回退信号较弱。
generatePeriod = 2
}
for i := 0; ; i++ {
// 从工作队列中取出一个工作项。
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.SyzFatalf("unknown work type: %#v", item)
}
continue
}

// 获取选择表和模糊测试器快照。
ct := proc.fuzzer.choiceTable
fuzzerSnapshot := proc.fuzzer.snapshot()
if len(fuzzerSnapshot.corpus) == 0 || i%generatePeriod == 0 {
// 生成一个新程序。
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 {
// 变异一个已有程序。
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)
}
}
}

接下来具体看一下这里面的依赖,先看看WorkTriage这个类型。该定义在sys-fuzzer中的workqueue.go中

1
2
3
4
5
6
type WorkTriage struct {
p *prog.Prog //表示需要分类的程序
call int//~系统调用
info ipc.CallInfo
flags ProgTypes
}

下面来看triageInput函数具体是怎么处理WorkTriage的。这个函数会对输入进行一个“分类”,确定测试样例的优先级

先计算一个信号优先级

1
prio := signalPrio(item.p, &item.info, item.call)

该函数在同文件夹下的signalPrio中实现

1
2
3
4
5
6
7
8
9
10
11
12
func signalPrio(p *prog.Prog, info *ipc.CallInfo, call int) (prio uint8) {
if call == -1 {
return 0
}
if info.Errno == 0 {
prio |= 1 << 1
}
if !p.Target.CallContainsAny(p.Calls[call]) {
prio |= 1 << 0
}
return
}

很简短的代码,如果call为-1,那prio返回为0。应该该不存在-1的call,所以这里用-1来表示特殊情况。

1
2
3
4
5
type Prog struct {
Target *Target
Calls []*Call
Comments []string
}

在prog/prog.go的文件中可以找到这个定义。有三个元素,target、calls、和Comments。target的定义前面有说,不再赘述。主要看看这个call是怎么个事。定义就在下面

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

这里面元素比较多了,先看syscall。定义在同目录下的types.go中

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
}

含义都在变量名上写着了,不多说。接下来是Args。Arg的定义就在proc.go的下面

1
2
3
4
5
6
7
8
type Arg interface {
Type() Type
Dir() Dir
Size() uint64

validate(ctx *validCtx) error
serialize(ctx *serializer)
}

一个接口,内中有五个方法。先看第一个,但是看之前先看第一个在实现中输入的类型ArgCommon

1
2
3
4
type ArgCommon struct {
ref Ref
dir Dir
}

两个内容,一个Ref,一个Dir,Dir表示参数方向(输入还是输出)

接下来看type

1
2
3
4
5
6
func (arg ArgCommon) Type() Type {
if arg.ref == 0 {
panic("broken type ref")
}
return typeRefs.Load().([]Type)[arg.ref]
}

相关的定义如下:

1
2
3
4
var (
typeRefMu sync.Mutex
typeRefs atomic.Value // []Type
)

而上面ret中的load是go语言对atomic的一个自带方法。大概是能在多线程的情况下防止冲突。这句话可以简单的理解成返回arg.ref

而关于Dir的定义也是非常的简短,即返回arg.dir

接下来是size,设计的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
type ConstArg struct {
ArgCommon
Val uint64
}

func MakeConstArg(t Type, dir Dir, v uint64) *ConstArg {
return &ConstArg{ArgCommon: ArgCommon{ref: t.ref(), dir: dir}, Val: v}
}

func (arg *ConstArg) Size() uint64 {
return arg.Type().Size()
}

首先有个新的type:ConstArg。然后MakeConstArg这个函数负责建立一个ConstArg。Size()这个函数就相当于返回arg.ref.Size()