【NOTES.0x06】Linux Kernel Pwn III:使用 syzkaller 进行漏洞挖掘

本文最后更新于:2021年11月24日 凌晨

尝试遍历所有的世界线.jpg

0x00.一切开始之前

syzkaller 是由 Google 开发的一个十分强大的针对内核的 fuzzer,自其面世以来已经帮助全世界的内核安全研究员发现了数量惊人的内核漏洞

本篇文章中笔者将简述 syzkaller 的使用方法

0x01.环境配置

这里参照官方文档进行配置: https://github.com/google/syzkaller

笔者本地环境:Ubuntu 21.04

在安装之前请确保你的电脑具有足够的运行内存与存储空间!(笔者的2G阿里云学生机就被搞炸了

要使用 syzkaller 进行漏洞挖掘,我们需要:

  • Go compiler and syzkaller itself
  • C compiler with coverage support
  • Linux kernel with coverage additions
  • Virtual machine or a physical device

安装依赖

1
2
$ sudo apt update
$ sudo apt install make gcc flex bison libncurses-dev libelf-dev libssl-dev clang clang-format

配置 golang 环境

首先配置 golang 环境,可以参照官方文档

1
2
3
4
$ wget https://dl.google.com/go/go1.14.2.linux-amd64.tar.gz
$ tar -xf go1.14.2.linux-amd64.tar.gz
$ mv go goroot
$ mkdir gopath

/etc/profile 中写入如下配置,重启,这里需要注意的是 YourGoPath应当替换为你实际存放 go 的路径,在上一步的终端中输入 pwd 后将其值替换到下方

1
2
3
4
export GOPATH=YourGoPath/gopath
export GOROOT=YourGoPath/goroot
export PATH=$GOPATH/bin:$PATH
export PATH=$GOROOT/bin:$PATH

编译 syzkaller

安装 syzkaller 本体

1
2
3
$ go get -u -d github.com/google/syzkaller/prog
$ cd gopath/src/github.com/google/syzkaller/
$ make

编译目标内核

从镜像站随便拉一个版本的源码过来就行,笔者这里拉了一个 5.11 版本的内核

1
$ wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v5.x/linux-5.11.tar.gz

解压

1
$ tar -zxvf linux-5.11.tar.gz 

然后执行下面这两条指令

1
2
$ make CC="/usr/bin/gcc" defconfig
$ make CC="/usr/bin/gcc" kvm_guest.config

对于老版本内核,应当为:

1
2
$ make CC="/usr/bin/gcc" defconfig
$ make CC="/usr/bin/gcc" kvmconfig

接下来编辑 .config 文件,在其末尾添加如下:

1
2
3
4
5
6
CONFIG_KCOV=y
CONFIG_DEBUG_INFO=y
CONFIG_KASAN=y
CONFIG_KASAN_INLINE=y
CONFIG_CONFIGFS_FS=y
CONFIG_SECURITYFS=y

接下来开始编译内核,这个时候可以简单开一局你喜欢的游戏慢慢等待(笑)

1
2
$ make CC="/usr/bin/gcc" olddefconfig
$ make CC="/usr/bin/gcc" -j64

这里笔者还遇到一个问题,**笔者的 gcc 版本太高了…**于是编译的时候又出现了这个错误:

1
cc1: error: ‘-fcf-protection’ is not compatible with this target

切换回官方推荐的 gcc8 后重新进行编译

笔者向生活妥协了,暂时没找到高版本 gcc 在这个配置下能够成功编译的方法

1
2
3
$ sudo apt-get install gcc-8 gcc-8-multilib
$ sudo apt-get install g++-8 g++-8-multilib
$ sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-8 1

出现下面这行就标志着编译完成

1
Kernel: arch/x86/boot/bzImage is ready  (#1)

编译出来的 bzImage 在 arch/x86/boot/bzImage,vmlinux 则就在源码根目录下,这两个文件前者是压缩后的内核后者是原始内核文件

配置 ext4 硬盘镜像文件

这里我们使用 debootstrap 来创建ext4硬盘镜像

1
2
3
4
5
6
$ sudo apt-get install debootstrap
$ mkdir image
$ cd image
$ wget https://raw.githubusercontent.com/google/syzkaller/master/tools/create-image.sh -O create-image.sh
$ chmod +x create-image.sh
$ ./create-image.sh

wget 的这一步需要翻墙raw.githubusercontent.com 在国内似乎是被墙了,总之笔者记忆里从没成功在不翻墙的情况下成功上去过),若嫌麻烦可以直接 copy 笔者已经下好的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
#!/usr/bin/env bash
# Copyright 2016 syzkaller project authors. All rights reserved.
# Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.

# create-image.sh creates a minimal Debian Linux image suitable for syzkaller.

set -eux

# Create a minimal Debian distribution in a directory.
DIR=chroot
PREINSTALL_PKGS=openssh-server,curl,tar,gcc,libc6-dev,time,strace,sudo,less,psmisc,selinux-utils,policycoreutils,checkpolicy,selinux-policy-default,firmware-atheros,debian-ports-archive-keyring

# If ADD_PACKAGE is not defined as an external environment variable, use our default packages
if [ -z ${ADD_PACKAGE+x} ]; then
ADD_PACKAGE="make,sysbench,git,vim,tmux,usbutils,tcpdump"
fi

# Variables affected by options
ARCH=$(uname -m)
RELEASE=stretch
FEATURE=minimal
SEEK=2047
PERF=false

# Display help function
display_help() {
echo "Usage: $0 [option...] " >&2
echo
echo " -a, --arch Set architecture"
echo " -d, --distribution Set on which debian distribution to create"
echo " -f, --feature Check what packages to install in the image, options are minimal, full"
echo " -s, --seek Image size (MB), default 2048 (2G)"
echo " -h, --help Display help message"
echo " -p, --add-perf Add perf support with this option enabled. Please set envrionment variable \$KERNEL at first"
echo
}

while true; do
if [ $# -eq 0 ];then
echo $#
break
fi
case "$1" in
-h | --help)
display_help
exit 0
;;
-a | --arch)
ARCH=$2
shift 2
;;
-d | --distribution)
RELEASE=$2
shift 2
;;
-f | --feature)
FEATURE=$2
shift 2
;;
-s | --seek)
SEEK=$(($2 - 1))
shift 2
;;
-p | --add-perf)
PERF=true
shift 1
;;
-*)
echo "Error: Unknown option: $1" >&2
exit 1
;;
*) # No more options
break
;;
esac
done

# Handle cases where qemu and Debian use different arch names
case "$ARCH" in
ppc64le)
DEBARCH=ppc64el
;;
aarch64)
DEBARCH=arm64
;;
arm)
DEBARCH=armel
;;
x86_64)
DEBARCH=amd64
;;
*)
DEBARCH=$ARCH
;;
esac

# Foreign architecture

FOREIGN=false
if [ $ARCH != $(uname -m) ]; then
# i386 on an x86_64 host is exempted, as we can run i386 binaries natively
if [ $ARCH != "i386" -o $(uname -m) != "x86_64" ]; then
FOREIGN=true
fi
fi

if [ $FOREIGN = "true" ]; then
# Check for according qemu static binary
if ! which qemu-$ARCH-static; then
echo "Please install qemu static binary for architecture $ARCH (package 'qemu-user-static' on Debian/Ubuntu/Fedora)"
exit 1
fi
# Check for according binfmt entry
if [ ! -r /proc/sys/fs/binfmt_misc/qemu-$ARCH ]; then
echo "binfmt entry /proc/sys/fs/binfmt_misc/qemu-$ARCH does not exist"
exit 1
fi
fi

# Double check KERNEL when PERF is enabled
if [ $PERF = "true" ] && [ -z ${KERNEL+x} ]; then
echo "Please set KERNEL environment variable when PERF is enabled"
exit 1
fi

# If full feature is chosen, install more packages
if [ $FEATURE = "full" ]; then
PREINSTALL_PKGS=$PREINSTALL_PKGS","$ADD_PACKAGE
fi

sudo rm -rf $DIR
sudo mkdir -p $DIR
sudo chmod 0755 $DIR

# 1. debootstrap stage

DEBOOTSTRAP_PARAMS="--arch=$DEBARCH --include=$PREINSTALL_PKGS --components=main,contrib,non-free $RELEASE $DIR"
if [ $FOREIGN = "true" ]; then
DEBOOTSTRAP_PARAMS="--foreign $DEBOOTSTRAP_PARAMS"
fi

# riscv64 is hosted in the debian-ports repository
# debian-ports doesn't include non-free, so we exclude firmware-atheros
if [ $DEBARCH == "riscv64" ]; then
DEBOOTSTRAP_PARAMS="--keyring /usr/share/keyrings/debian-ports-archive-keyring.gpg --exclude firmware-atheros $DEBOOTSTRAP_PARAMS http://deb.debian.org/debian-ports"
fi
sudo debootstrap $DEBOOTSTRAP_PARAMS

# 2. debootstrap stage: only necessary if target != host architecture

if [ $FOREIGN = "true" ]; then
sudo cp $(which qemu-$ARCH-static) $DIR/$(which qemu-$ARCH-static)
sudo chroot $DIR /bin/bash -c "/debootstrap/debootstrap --second-stage"
fi

# Set some defaults and enable promtless ssh to the machine for root.
sudo sed -i '/^root/ { s/:x:/::/ }' $DIR/etc/passwd
echo 'T0:23:respawn:/sbin/getty -L ttyS0 115200 vt100' | sudo tee -a $DIR/etc/inittab
printf '\nauto eth0\niface eth0 inet dhcp\n' | sudo tee -a $DIR/etc/network/interfaces
echo '/dev/root / ext4 defaults 0 0' | sudo tee -a $DIR/etc/fstab
echo 'debugfs /sys/kernel/debug debugfs defaults 0 0' | sudo tee -a $DIR/etc/fstab
echo 'securityfs /sys/kernel/security securityfs defaults 0 0' | sudo tee -a $DIR/etc/fstab
echo 'configfs /sys/kernel/config/ configfs defaults 0 0' | sudo tee -a $DIR/etc/fstab
echo 'binfmt_misc /proc/sys/fs/binfmt_misc binfmt_misc defaults 0 0' | sudo tee -a $DIR/etc/fstab
echo -en "127.0.0.1\tlocalhost\n" | sudo tee $DIR/etc/hosts
echo "nameserver 8.8.8.8" | sudo tee -a $DIR/etc/resolve.conf
echo "syzkaller" | sudo tee $DIR/etc/hostname
ssh-keygen -f $RELEASE.id_rsa -t rsa -N ''
sudo mkdir -p $DIR/root/.ssh/
cat $RELEASE.id_rsa.pub | sudo tee $DIR/root/.ssh/authorized_keys

# Add perf support
if [ $PERF = "true" ]; then
cp -r $KERNEL $DIR/tmp/
BASENAME=$(basename $KERNEL)
sudo chroot $DIR /bin/bash -c "apt-get update; apt-get install -y flex bison python-dev libelf-dev libunwind8-dev libaudit-dev libslang2-dev libperl-dev binutils-dev liblzma-dev libnuma-dev"
sudo chroot $DIR /bin/bash -c "cd /tmp/$BASENAME/tools/perf/; make"
sudo chroot $DIR /bin/bash -c "cp /tmp/$BASENAME/tools/perf/perf /usr/bin/"
rm -r $DIR/tmp/$BASENAME
fi

# Add udev rules for custom drivers.
# Create a /dev/vim2m symlink for the device managed by the vim2m driver
echo 'ATTR{name}=="vim2m", SYMLINK+="vim2m"' | sudo tee -a $DIR/etc/udev/rules.d/50-udev-default.rules

# Build a disk image
dd if=/dev/zero of=$RELEASE.img bs=1M seek=$SEEK count=1
sudo mkfs.ext4 -F $RELEASE.img
sudo mkdir -p /mnt/$DIR
sudo mount -o loop $RELEASE.img /mnt/$DIR
sudo cp -a $DIR/. /mnt/$DIR/.
sudo umount /mnt/$DIR

这一步不知道是因为网络原因还是别的原因总而言之非常的慢(比上面编译内核耗时还长),完成之后如下:

1
2
3
4
5
6
7
8
9
$ ll
total 554772
drwxrwxr-x 3 arttnba3 arttnba3 4096 Nov 12 03:02 ./
drwxrwxr-x 7 arttnba3 arttnba3 4096 Nov 12 00:53 ../
drwxr-xr-x 21 root root 4096 Nov 12 02:58 chroot/
-rwxrwxr-x 1 arttnba3 arttnba3 6360 Nov 11 23:25 create-image.sh*
-rw------- 1 arttnba3 arttnba3 2602 Nov 12 03:02 stretch.id_rsa
-rw-r--r-- 1 arttnba3 arttnba3 569 Nov 12 03:02 stretch.id_rsa.pub
-rw-rw-r-- 1 arttnba3 arttnba3 2147483648 Nov 12 03:02 stretch.img

我们可以在文件目录下找到一个名为 stretch.img 的文件,这个文件就是构建好的磁盘镜像文件

安装 qemu

这一步还是比较简单的,需要注意的是如果你和笔者一样在 VMware 上使用 Linux 则应当在设置中把 虚拟化 Intel VT-x/EPT 或 AMD-V/RVI(V) 打开

1
$ sudo apt install qemu-system-x86

完成这一切之后看看内核是否能够被成功启动,启动脚本如下(别忘了替换内核镜像与硬盘镜像的路径):

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
qemu-system-x86_64 \
-m 2G \
-smp 2 \
-kernel ./bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=serial net.ifnames=0" \
-drive file=./stretch.img,format=raw \
-net user,host=10.0.2.10,hostfwd=tcp:127.0.0.1:10021-:22 \
-net nic,model=e1000 \
-enable-kvm \
-nographic \
-pidfile vm.pid \
2>&1 | tee vm.log

默认 root 账户无密码,成功登入

image.png

前面在配置硬盘镜像文件时还给我们提供了 ssh key,我们也可以用 ssh 来直接连接至虚拟机:

1
$ ssh -i ./stretch.id_rsa -p 10021 -o "StrictHostKeyChecking no" root@localhost

至此,需要的环境就都配置完成了

0x02.开始使用 syzkaller

PRE.工作原理

对于 syzkaller 的架构,官方给出了这样的一张 Overview

image.png

  • syz-manager :syzkaller 的控制中枢,其会启动多个 VM 实例(如图所示的一个黄色卡片就是一个实例)并进行监视,同时通过 RPC 来启动 syz-fuzzer

  • syz-fuzzer :负责引导整个 fuzz 的过程:

    • 生成 input
    • 启动 syz-executor 进程进行 fuzz
    • 从被 fuzz 的 kernel 的 /sys/kernel/debug/kcov 获得覆盖(coverage)的相关信息
    • 通过 RPC 将新的覆盖回送到 syz-manager
  • syz-executor:负责执行单个输入——从 syz-fuzzer 处接受 input 并执行,最后回送结果

配置文件(for test)

我们需要为 syzkaller 编写额外的配置文件,一个简单的例子如下,这里需要注意替换为你自己的路径,包括 workdir 文件夹你应当手动 mkdir 一个:

config.cfg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"target": "linux/amd64",
"http": "127.0.0.1:56741",
"workdir": "/home/arttnba3/Documents/golang/gopath/src/github.com/google/syzkaller/workdir",
"kernel_obj": "/home/arttnba3/Desktop/kernel/linux-5.11",
"image": "/home/arttnba3/Desktop/kernel/image/stretch.img",
"sshkey": "/home/arttnba3/Desktop/kernel/image/stretch.id_rsa",
"syzkaller": "/home/arttnba3/Documents/golang/gopath/src/github.com/google/syzkaller",
"procs": 8,
"type": "qemu",
"vm": {
"count": 4,
"kernel": "/home/arttnba3/Desktop/kernel/syzkaller/bzImage",
"cpu": 2,
"mem": 2048
}
}

启动 syzkaller

在 syzkaller 目录下输入如下命令启动 syzkaller:

1
$ ./bin/syz-manager -config=config.cfg

成功启动后我们可以通过访问 localhost:56741 来获取 syzkaller 的状态,效果如下图所示:

image.png

*可能会遇到的问题

无法启动 vm instance

有可能会遇到无法启动 vm instance 的问题,报错形式大致如下:

1
2
qemu-system-x86_64: error: failed to set MSR 0x48f to 0x7fffff00036dfb
qemu-system-x86_64: ../../target/i386/kvm/kvm.c:2753: kvm_buf_set_msrs: Assertion `ret == cpu->kvm_msr_buf->nmsrs' failed.

按照官方给出的解决办法是在 qemu 的启动参数中去掉 -cpu host,migratable=off,我们需要在配置文件的 vm 项中添加 qemu-args项,值为 -enable-kvm,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"target": "linux/amd64",
"http": "127.0.0.1:56741",
"workdir": "/home/arttnba3/Documents/golang/gopath/src/github.com/google/syzkaller/workdir",
"kernel_obj": "/home/arttnba3/Desktop/kernel/linux-5.11",
"image": "/home/arttnba3/Desktop/kernel/image/stretch.img",
"sshkey": "/home/arttnba3/Desktop/kernel/image/stretch.id_rsa",
"syzkaller": "/home/arttnba3/Documents/golang/gopath/src/github.com/google/syzkaller",
"procs": 8,
"type": "qemu",
"vm": {
"count": 4,
"kernel": "/home/arttnba3/Desktop/kernel/syzkaller/bzImage",
"cpu": 2,
"mem": 2048,
"qemu_args":"-enable-kvm"
}
}

网络设备问题

在 syzkaller 挖掘过程中可能出现如下报错信息:

1
[FAILED] Failed to start Raise network interfaces.

虽然似乎还是能正常启动的

crash 分析

笔者本来想写一个有漏洞点内核模块来人为制造 crash,不过现在刚开挖没几分钟就出了一个 crash,由于用的是 5.11 版本的内核,已经不算太新了,笔者认为应该是挖到了现有的 CVE,通过 Google 笔者也找到了一个基本上是一样的 crash,不过我们还是来简单分析一下

image.png

在 Description 项中说明了 crash 的简要信息,点开分为两项:logreport

image.png

log 中给出的是 fuzz 的流程,包括运行的系统调用、输入参数等一系列信息,因为是自动生成的所以一般不会特别好看

image.png

report 中给出的则是 kernel 相关信息,例如调用栈回溯等

image.png

比较可惜的是这个 crash 没法重现,在一个 crash 刚刚生成时 syzkaller 会尝试进行重现,此时 report 的状态会显示为 reproducing,若成功了则会显示对应的结果,品相比较好的一种 report 就是 has C repo:有产生该 crash 的 C 代码

image.png

0x03.使用 syzlang 编写描述文件进行 fuzz

直接就这样挂着肯定不能直接就把洞挖出来虽然笔者前面没挂一会就出了一个crash,因此接下来我们需要人工配置系统调用模板,以有针对性地进行漏洞挖掘

syzkaller 使用它自己的声明式语言(Syscall Description Language,aka syzlang(读作[siːzˈlæŋg]笔者以前一直读作 [saiːzˈlæŋg]…))来描述系统调用模板,在安装目录下的 docs/syscall_descriptions.mddocs/syscall_descriptions_syntax.md 中有着相关的说明,在笔者看来是类似 C 的一门描述语言

我们需要使用 syzlang 来编写特定的系统调用描述文件(也叫规则文件,后文中这两个词指的是同一个东西),syzkaller 会根据我们的描述文件有针对性地进行 fuzz

这是 Google 给出的一个例子

笔者看着也是比较头大的…还是慢慢来吧…

以下主要是翻译谷歌官方的文档(谁叫国内没有中文文档呢),外加一些笔者自己本人的理解以及补充说明

syzlang 语法

syzlang 的语法结构如下,看完你也能快速上手!

1
2
3
4
5
6
7
8
syscallname "(" [arg ["," arg]*] ")" [type] ["(" attribute* ")"]
arg = argname type
argname = identifier
type = typename [ "[" type-options "]" ]
typename = "const" | "intN" | "intptr" | "flags" | "array" | "ptr" |
"string" | "strconst" | "filename" | "glob" | "len" |
"bytesize" | "bytesizeN" | "bitsize" | "vma" | "proc"
type-options = [type-opt ["," type-opt]]

这个时候你需要把自己当作一个 scanner + parser(大雾

正则表达式相信大家应该都学过,哪怕没学过编译原理课上你总会学到的,那么按照正则来看的话其实这个语法结构并不难解析

以下是对其中的一些符号说明,不是 syzlang 实际语法内容

  • 双引号 "" 表示这个符号内的东西表示要按其原样进行匹配,丢弃双引号

  • 或符号 | 表示值可以取左边也可以取右边

  • 等于号 = 表示左边的表达式应当为右边的形式

  • 中括号 [] 表示一个可选表达式,取其内的值并丢弃中括号

  • 星号 * 表示闭包,即 0 次或多次的自我连接 什么?你还没学过离散数学

这是谷歌官方给出的一个例子:

syzkaller uses declarative description of syscall interfaces to manipulate programs (sequences of syscalls). Below you can see (hopefully self-explanatory) excerpt from the descriptions:

1
2
3
4
open(file filename, flags flags[open_flags], mode flags[open_mode]) fd
read(fd fd, buf buffer[out], count len[buf])
close(fd fd)
open_mode = S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IWGRP, S_IXGRP, S_IROTH, S_IWOTH, S_IXOTH

斯密🦄赛,👴没法做到 self-explanatory

O.注释与文件包含

syzlang 中的注释与 shell 脚本注释形式相同,为以 # 开头的单行注释

这是一个🌰:

1
# this is a useful sentence! do not delete it!

在 syzlang 中,我们可以额外引入内核源码文件作为参数、系统调用…等等一系列的补充,形式与 C 语言的 include 语句大致相同,不过**没有了开头的”#”**(因为 # 开头是注释)

这是一个🌰:

1
include <linux/fs.h>

I.参数(arg)与参数名(argname)

我们输入给系统调用模板的 参数 (arg) 应当为如下形式:

1
arg = argname type

即一个参数由 参数名 (argname) 类型(type) 组成

其中,参数名便是 标识符(identifier)

example

这么讲有些空泛,我们来简单看一个例子,在 Linux 中系统调用 read 的声明(其实是定义)如下:

1
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)

当我们使用 libc 的 wrapper 进行 read 系统调用时,形式如下:

1
2
3
4
unsigned int 	my_file_fd = open("/dev/a3dev", O_RDONLY);
char my_buf[114514];
size_t my_count = 114514;
read(my_file_fd, my_buf, my_count);

在这个例子当中,fd、buf、count 便是 argnamemy_file_fd、my_buf、my_count 便是 type

那么,在我们使用 syzlang 编写系统调用模板时,例如 read 的第一个参数,我们应该写成下面这个样子(假设 my_file_fd 已定义为一个 resources(后面会讲)):

1
fd my_file_fd

II.类型(type)

其实这么翻译笔者觉得好像不大准确,不过笔者英文不大行所以这里暂且直译…

前面我们讲到一个 arg 由 一个 argname type 组成,argname 我们已经讲了,现在我们来讲 type

type 的定义同样由两部分组成——类型名(typename)类型选项(type-options)

1
type = typename [ "[" type-options "]" ]

类型名(typename)

即该 type 的类型,例如 C 当中的int、char、void 等等

常规选项包括:

  • opt:这是一个可选参数(例如 mmap 的 fd)

其余 type-options 是基于特定 type 的,如下:

  • const:整型常数

    • 类型选项:
      • 值(value):例如 0
      • 基础类型(underlying type):intNintptr 之一
  • intNintptr:一个有着特殊含义的整型,下文会进行详细说明

    • 类型选项:
      • 可选范围区间:例如 "1:100" 表示取值值的区间为 [1, 100]
      • 可选参数
  • flags:值的集合

    • 类型选项:
      • 对 flags 描述的引用
      • 基础整型类型:例如 int32
  • array:一个可变长/固定长度的数组

    • 类型选项:
      • 元素的 type
      • 可选长度区间:例如固定长度 "5" 或者长度范围 "5:10"(包括边界)
  • ptrptr64:指向一个对象的指针

    • 类型选项:
      • 方向:inoutinout
      • 对象的 type
    • 无论对象指针大小如何,ptr64 永远为 8 字节
  • string:一块有着 0 终止符的内存缓冲区

    • 类型选项:
      • 常量字符串/对字符串的引用
        • 前者:例如 "foo"作为常规字符串进行解析,或者`deadbeef`作为4个 16 进制字节进行解析
        • 后者:若是特殊类型 filename 则会生成文件名
  • stringnoz:一块没有 0 终止符的内存缓冲区

    • 类型选项:(同 string)
  • glob:匹配目标文件的 glob(?)模式

  • fmt:一个表示一个整数的字符串

    • 类型选项:
      • 格式与值:前者可取值为 dechexoct;后者可以是一个 resource、int、flags、const 或 proc
    • 最终的结果通常是固定尺寸的
  • len:另一个 字段 的长度(对于 array 而言为元素的数量)

    • 类型选项:
      • 对象的 argname
  • bytesize:与 len 类似,不过单位是字节

    • 类型选项:
      • 对象的 argname
  • bitsize:与 len 类型,不过单位是比特位

    • 类型选项:
      • 对象的 argname
  • offsetof:一个 字段 在其 parent struct 中的偏移(笔者怎么译都没那感觉,故保留原词)

    • 类型选项:
      • 字段
  • vmavma64:指向一组页的指针(用作 mmap/munmap/mremap/madvise 的输入)

    • 类型选项:
      • (可选)页的数量或页的范围:前者例如 vma[7],后者例如 vma[2-4]
    • vma64 的长度恒为 8 字节
  • proc:单个进程的整型(详见下面的描述)

    • 类型选项:
      • 值的区间的起始
      • 每个进程的值的数量
      • 基础类型
  • text:特定 type 的机器码

    • 类型选项:
      • 代码类型:x86_real, x86_16, x86_32, x86_64, arm64
  • void:type with static size 0(自己体会,怎么译都没那种感觉…)

    • 通常在模板以及可变长(varlen)联合体中使用,不能用作系统调用的参数

结构体/联合体/指针 中使用时,flags/len/flags 的构成中尾部还可以跟着 type type-options

唉呀你说了这么多谁听得懂啊,还是快把🌰搬上来吧

关于 flags 的补充说明

flags 通常具有如下形式:

1
flagname = const ["," const]*

这是一个🌰:

1
my_flags = 1,2,3,4,5,6,7,8,9,0

对于 string 类型的 flag,其应当具有如下形式:

1
flagname = "\"" literal "\"" ["," "\"" literal "\""]*

这是一个🌰:

1
my_str_flags = "arttnba3", "arttnba4", "arttnba5"

类型选项(type-options)

在类型当中,类型选项其实也是可选项(又搁这绕口令了

type-options 在笔者的理解中为对一个特定 type 的补充说明,其应当具有如下形式:

1
type-options = [type-opt ["," type-opt]]

我们不难看出 type-options 在 syzlang 中为可选项,同样地,对于一个 type 其可以有多个 type-options

查看前面 type 的形式说明可知,在使用类型选项时,我们应当使用 [] 将之包裹

以下是一个简单的🌰(作为系统调用参数输入,而非单独的参数定义):

1
flags flags[open_flags]

从左向右解析:对于这个系统调用的 flags 参数,我们的输入是一个 flags 类型,其类型选项为对一个 flags 描述 open_flags 的引用,意为取值为 open_flags 中之一

其中 open_flags 被定义如下,这些值通过 include 语句从内核源文件中被包含进来:

1
open_flags = O_WRONLY, O_RDWR, O_APPEND, FASYNC, O_CLOEXEC, O_CREAT, O_DIRECT, O_DIRECTORY, O_EXCL, O_LARGEFILE, O_NOATIME, O_NOCTTY, O_NOFOLLOW, O_NONBLOCK, O_PATH, O_SYNC, O_TRUNC, __O_TMPFILE

III.系统调用模板

我们将上面的结果进行整合,一个系统调用的形式应当如下:

1
syscallname "(" [arg ["," arg]*] ")" [type] ["(" attribute* ")"]

接下来笔者通过一个🌰进行分解说明

基本形式

我们将其最小化,我们应当书写为如下形式,类似于常规的 C 语言函数调用:

1
syscallname(arg)

例如对于 open 这个系统调用,我们可以写成这个样子:

1
2
3
4
open(file ptr[in, filename], flags flags[open_flags], mode flags[open_mode])
#...
open_flags = O_WRONLY, O_RDWR, O_APPEND, FASYNC, O_CLOEXEC, O_CREAT, O_DIRECT, O_DIRECTORY, O_EXCL, O_LARGEFILE, O_NOATIME, O_NOCTTY, O_NOFOLLOW, O_NONBLOCK, O_PATH, O_SYNC, O_TRUNC, __O_TMPFILE
open_mode = S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IWGRP, S_IXGRP, S_IROTH, S_IWOTH, S_IXOTH

对于 open 系统调用的三个参数,我们给了这样的输入:

  • file 参数:一个指针类型,其 type-opetions 的第一个为 in,意为由该指针指向特定对象,第二个为 filename,为特殊的 string 对象,对于 filename,syzlang 会进行文件生成,将文件名作为输入
  • flags 参数:一个 flags 类型,其 type-options 为 open_flags ,意为从我们定义的 flags——open_flags 中取值
  • mode 参数:一个 flags 类型,其 type-options 为 open_mode ,意为从我们定义的 flags——open_mode 中取值

返回值

接下来我们再继续深入,系统调用通常都有返回值,我们可以选择接收也可以选择忽视,若是我们需要进行接收,则应当在系统调用的末尾添加 type ,例如 open 这个系统调用会返回一个文件描述符,我们现在想将其返回的文件描述符存到一个变量比如说 a3fd(假如已被声明为_资源_(下文解释)) 当中,我们应当写成下面这个形式:

1
open(file ptr[in, filename], flags flags[open_flags], mode flags[open_mode]) a3fd

那么 open 的返回值便被存储到了 a3fd 这一个 type 当中,我们在后续便可以将 fd 用作其他系统调用的参数,例如:

1
close(fd a3fd)

call attributes

系统调用模板当中还有一个可选项是 attributes,即这一个系统调用的属性,可以取的值如下:

  • disabled:该系统调用将不用于 fuzzing;这个属性通常用于临时禁用某些系统调用,或者禁用特定的参数组合
  • timeout[N]:系统调用在默认值以外的额外的超时时间,单位为毫秒(ms)
  • prog_timeoout[N]:若一个程序包含了该系统调用,则该属性为整个程序的执行的超时时间,若存在多个定义了该属性的系统调用则取最大值
  • ignore_return:在回退反馈(笔者推指的是 syzkaller 的 fuzz 机制之一,根据返回值判断路径覆盖之类的)中忽视这个系统调用的返回值;用于不返回固定的错误码(例如 -EFAULT)而是返回其他值的系统调用
  • break_returns:忽略回退反馈中程序中所有后续系统调用的返回值(文档中说 can't be trusted,笔者暂时不理解…)

变种(variants)

对于系统调用的变种形式,我们应当在系统调用名后面使用 $ 符号进行额外的指定

下面是一个🌰:

1
open$dir(file ptr[in, filename], flags flags[open_flags], mode flags[open_mode]) fd_dir

按笔者的理解应该是为其取一个别名?比如说对于 syzkaller 而言 open$diropen$a3dir 就是两个东西,而若是在两个不同的文件中都出现了 open$dir 则会在_重编译_(下文解释)时发生冲突

IV.整型(integer)

整型也是一种 type,其可选项为 int8int16int32int64,表示相应大小的整型

intptr 用以表示一个指针大小的整型,对应 C 语言中的 long

通过添加 be 后缀表示这个整型存储为大端序

这是一个🌰:

1
read$eventfd(fd fd_event, val ptr[out, int64], len len[val])

我们可以用这样的形式来指定 int 的范围:int32[0:100]——意为该整型的取值范围为 [0,100]

我们还可以额外指定取值的跨度,例如 int32[1:10, 2] 意为其取值为 {1, 3, 5, 7, 9}

我们还可以额外指定一个整型的取值范围,单位为比特位,例如 int64:20 意为这个整型只取其 20 bit 的值进行随机化

V.结构体、联合体与其成员(字段)

在 syzlang 中同样可以定义结构体/联合体,结构体/联合体的成员被称之为 字段(field)

结构体(struct)

在 syzlang 中,一个结构体应为如下形式:

1
2
3
structname "{" "\n"
(fieldname type ("(" fieldattribute* ")")? "\n")+
"}" ("[" attribute* "]")?

对于字段而言,其可以在后面的 () 中指定字段属性,但与 type 的属性不同,唯一的属性只有方向: in/out/inout,对于指定的字段,其方向属性会被上层属性给覆盖

在结构体定义的尾部,我们可以额外指定一些属性(使用 [] 包裹),可选属性有:

  • packed:该结构体不同字段之间没有 padding(例如 C 中有一个结构体 struct T{int a; char b;};,char 为 1 字节,int 为 4 字节,那么该结构体便会对 4 字节对齐,在其两个字段之间就会有 3 字节的 padding)
  • align[N]:指定该结构体对 N 字节对齐,padding 的内容并未指定(通常为0)
  • size[N]:结构体被填充到指定的大小 N

其实和我们在 C 语言中写结构体差不多,下面是一个🌰:

1
2
3
4
5
6
my_struct {
a3f1 int8 # 一个随机的 1 字节的 int
a3f2 const[0xdeadbeef, int32be] # 一个固定的 8 字节的 int,取值为 0xdeadbeef,大端序
a3f3 int32[0:100] # 一个随机的 4 字节的 int,取值范围为 [0,100]
a3f4 int64:20 # 一个随机的 8 字节的 int,只取20个比特的值(其他bit置0?)
} [packed]

联合体(union)

与结构体基本相同,如下:

1
2
3
unionname "[" "\n"
(fieldname type ("(" fieldattribute* ")")? "\n")+
"]" ("[" attribute* "]")?

不同的是其属性的可选项,有:

  • varlen:联合体的大小可变(为指定的字段的长度),若未指定则该联合体大小为其最大字段的大小(类型 C 语言)
  • size[N]:该联合体被填充到指定的大小 N

VI.资源(resources)

资源(resources)用作那些需要作为一个系统调用的输出的值传递给另一个系统调用做输入的值。

这么说可能有些空泛,笔者来举个🌰, close 系统调用接收一个文件描述符作为参数,而这个文件描述符应当为你在之前进行 open pipe 系统调用时获得的返回值,为了达成这个目的,我们需要将文件描述符(比如说叫 fd)声明为一个资源

resources 的形式如下:

1
"resource" identifier "[" underlying_type "]" [ ":" const ("," const)* ]

其中的 underlying_type 可以是 int8, int16, int32, int64, intptr 或者是另一个资源(可以是其子类,比如说一个 socket 便是 文件描述符的“子类”)

常量集合可以作为可选参数,表示该资源的特殊值(比如说 0xdeadbeef),特殊值偶尔被用作资源的值,若未指定特殊值,则会使用特殊值 0

资源也可以被用作类型(types),这是官方给出的一个🌰:

1
2
3
4
5
6
7
resource fd[int32]: 0xffffffffffffffff, AT_FDCWD, 1000000
resource sock[fd] # 继承 fd 类型
resource sock_unix[sock] # 继承 sock 类型

socket(...) sock
accept(fd sock, ...) sock
listen(fd sock, backlog int32)

资源并不一定要是系统调用的返回值,例如:

1
2
3
4
5
6
7
8
9
resource my_resource[int32]

request_producer(..., arg ptr[out, my_resource])
request_consumer(..., arg ptr[inout, test_struct])

test_struct {
...
attr my_resource
}

对于更为复杂的生产者/消费者场景,字段属性也可以被利用,例如:

1
2
3
4
5
6
7
8
9
10
resource my_resource_1[int32]
resource my_resource_2[int32]

request_produce1_consume2(..., arg ptr[inout, test_struct])

test_struct {
...
field0 my_resource_1 (out)
field1 my_resource_2 (in)
}

VII.类型别名(Type Alias)

笔者认为可以理解为 C 中的一种特殊的 typedef,其格式如下:

1
type identifier underlying_type

这么看可能有些空泛,我们来看一个🌰:

1
2
3
4
5
6
7
8
9
10
11
type bool8		int8[0:1]
type bool16 int16[0:1]
type bool32 int32[0:1]
type bool64 int64[0:1]
type boolptr intptr[0:1]

type fileoff[BASE] BASE

type filename string[filename]

type buffer[DIR] ptr[DIR, array[int8]]

在这个例子当中我们需要使用布尔值,其取值只有 0 或 1 ,所以我们需要写成 intN[0:1],但是若是在每一个需要用到布尔值的地方都这么写就太麻烦了,也不利于理解,这个时候就可以给他定义一个类型别名 boolN,简单易懂

VIII.类型模板(Type Template)

类型模板应定义为如下形式:

1
2
3
4
type optional[T] [
val T
void void
] [varlen]

唉呀谷歌你这么讲谁能够看得懂啊,还是赶紧把🌰给掏出来吧

下面是一个简单的用法🌰:

1
2
3
4
5
6
7
8
9
10
11
type buffer[DIR] ptr[DIR, array[int8]]
type fileoff[BASE] BASE
type nlattr[TYPE, PAYLOAD] {
nla_len len[parent, int16]
nla_type const[TYPE, int16]
payload PAYLOAD
} [align_4]

#...

syscall(a buffer[in], b fileoff[int64], c ptr[in, nlattr[FOO, int32]])

笔者也没看明白,暂时就先不误人子弟了

IX.长度(length)

你可以使用关键字 lenbytesizebitsize 来指定结构体当中特定字段的长度

若是 len 的参数为一个指针,则其取值为指针所指对象的大小

若要表示一个 N 字节的字 中字段的长度,则应当使用 bytesizeN,其中 N 的取值可以为 1、2、4、8

example

这是谷歌官方给出的一个🌰:

1
2
3
4
5
6
write(fd fd, buf ptr[in, array[int8]], count len[buf])

sock_fprog {
len len[filter, int16]
filter ptr[in, array[sock_filter]]
}

在 write 系统调用当中,我们给其 count 参数传入了一个特殊的参数 len[buf],表示此处传入的值为参数 buf 的长度

在 sock_fprog 这个结构体当中,我们给其字段 len 设置的值为其 filter 字段的长度,类型为 int 16

若要表示父类的长度,可以使用 len[parent, intN],若要在结构体互相嵌入时表示更顶层的父类的长度,可以指定特定父类的类型名称,下面是一个🌰:

1
2
3
4
5
6
7
8
9
10
s1 {
f0 len[s2] # length of s2
}

s2 {
f0 s1
f1 array[int32]
f2 len[parent, int32]
}

len 也支持更加复杂的路径寻址,比如说如果你闲着没事干你可以写成谷歌给出的这个🌰里的样子:

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
s1 {
a ptr[in, s2]
b ptr[in, s3]
c array[int8]
}

s2 {
d array[int8]
}

s3 {
# This refers to the array c in the parent s1.
e len[s1:c, int32]
# This refers to the array d in the sibling s2.
f len[s1:a:d, int32]
# This refers to the array k in the child s4.
g len[i:j, int32]
# This refers to syscall argument l.
h len[syscall:l, int32]
i ptr[in, s4]
}

s4 {
j array[int8]
}

foo(k ptr[in, s1], l ptr[in, array[int8]])

X.进程(proc)

进程 proc 类型可以用于表示分进程整型值,即为每一个执行程序设置一个单独的值的范围,这样他们之间就不会互相干扰,🌰如端口号就不能够被共享,而是需要每个进程有一个自己的端口

这里举一个简单的🌰, proc[20000, 4, int16be] 表示为每个进程生成一个大端序的 int16 的值,为每个进程分配其中的 4 个值,从 20000 开始分配,比如说第 N 个 executor 分配到的值范围便是 [20000 + n * 4, 20000 + (n + 1) * 4)

XI.整型常量(Integer Constants)

整型常量可以指定为十进制、0x 开头的十六进制、用单引号 ' 包裹的字符,或者从内核头文件中提取出来的由 define 定义的常量(比如说 O_RDONLY

这是一个🌰:

1
2
3
4
5
6
foo(a const[10], b const[-10])
foo(a const[0xabcd])
foo(a int8['a':'z'])
foo(a const[PATH_MAX])
foo(a ptr[in, array[int8, MY_PATH_MAX]])
define MY_PATH_MAX PATH_MAX + 2

XII.杂项(Misc)

描述文件还包括用以进行内核头文件包含的 include 指令,用以包含内核头文件目录的 incdir 指令,以及用以设置常量的 define 指令

syzkaller executor 还定义了一些 pseudo system calls ,我们可以在描述文件中使用这些伪系统调用。这些伪系统调用被扩展为 C 代码,可以执行用户自定义的一些操作,这里是一些🌰

要写出优秀的描述文件,这里是一些 tips

编写并使用描述文件

Step I.编写描述文件

我们需要在 syzkaller 目录下的 syzkaller/sys/linux/ 这个目录下面新建我们自己的描述文件,比如说笔者新建一个 a3_handsome.txt 文件如下:

1
2
3
4
5
6
7
8
9
10
include <linux/fs.h>

resource a3fd[int64]

open$a3proc(file ptr[in, string["/deve/tty"]], flags flags[a3_open_flags]) a3fd
read$a3proc(fd a3fd, buf ptr[in, array[int8]], count len[buf])
write$a3proc(fd a3fd, buf ptr[in, array[int8]], count len[buf])
close$a3proc(fd a3fd)

a3_open_flags = O_WRONLY, O_RDWR, O_APPEND, FASYNC, O_CLOEXEC, O_CREAT, O_DIRECT, O_DIRECTORY, O_EXCL, O_LARGEFILE, O_NOATIME, O_NOCTTY, O_NOFOLLOW, O_NONBLOCK, O_PATH, O_SYNC, O_TRUNC, __O_TMPFILE

随便写的,没有任何的针对性设计

在这里变种名为 a3proc,可以理解为笔者自己取的别名,这是因为若不同的描述文件中存在相同的系统调用则编译时会发生冲突

Step II.编译 syz-extract 与 syz-sysgen

接下来我们需要编译 syz-extractsyz-sysgen,从而应用我们新编写的描述文件

1
2
$ make bin/syz-extract
$ make bin/syz-sysgen # no need for syzkaller in new version
  • syz-extract 用以提取引入的内核头文件中的 define 常量等,生成 .const 文件

  • syz-sysgen 用以结合 .txt 文件与 .const 文件进行语法分析和语义分析生成 AST ,最后生成 .go 文件

对于版本较新的 syzkaller ,其在编译时会默认编译 syz-sysgen,因此我们只需要手动编译 syz-extract 即可

Step III.处理新规则文件

使用如下命令处理我们刚刚写的规则文件

1
2
$ bin/syz-extract -os linux -arch $ARCH -sourcedir $KSRC -builddir $LINUXBLD <new>.txt
$ bin/syz-sysgen # no need for syzkaller in new version
  • $ARCH 应为你的目标架构,可选项有 amd64, 386 arm64, arm, ppc64le, mips64le

  • $KSRC 应为 fuzz 的内核的源码目录

  • $LINUXBLD 应为你的编译目录,为可选项(-builddir)

  • <new>.txt 就是你刚刚编写的规则文件的文件名

会在 syzkaller/sys/linux 下生成 .const 文件提取出常量,在正式编译时会进行替换,例如笔者上面给出的例程生成的 .const 文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Code generated by syz-sysgen. DO NOT EDIT.
arches = amd64
FASYNC = amd64:8192
O_APPEND = amd64:1024
O_CLOEXEC = amd64:524288
O_CREAT = amd64:64
O_DIRECT = amd64:16384
O_DIRECTORY = amd64:65536
O_EXCL = amd64:128
O_LARGEFILE = amd64:32768
O_NOATIME = amd64:262144
O_NOCTTY = amd64:256
O_NOFOLLOW = amd64:131072
O_NONBLOCK = amd64:2048
O_PATH = amd64:2097152
O_RDWR = amd64:2
O_SYNC = amd64:1052672
O_TRUNC = amd64:512
O_WRONLY = amd64:1
__NR_open = amd64:2
__NR_read = amd64:0
__NR_write = amd64:1
__O_TMPFILE = amd64:4194304

Step IV.重新编译 syzkaller

命令如下:

1
2
$ make generate
$ make

Step V.修改配置文件,启动 syzkaller

前面我们命名了 a3proc ,因此我们还需要在配置文件中进行 enable,在你的 .cfg 文件中添加这一项:

1
2
3
4
5
6
"enable_syscalls": [
"open$a3proc",
"read$a3proc",
"write$a3proc",
"close$a3proc"
]

之后按惯例启动即可:

1
$ ./bin/syz-manager -config=config.cfg

*工作原理

当我们使用 syzlang 编写好模板之后,这些系统调用模板会通过 syz-extractsyz-sysgen 翻译为 syzkaller 能够读懂的代码,笔者这里简述一下其原理

这里你可能需要一点编译原理的知识,不过笔者相信大家编译原理应当都及格了(笑)

什么?你说你还没上这门课

什么?用 syzkaller 挖洞只要会写 syzlang 就行了,根本不需要理解他的原理

简要而言,从源代码到可执行文件大概有如下过程:

  • 词法分析(lexical analysis):扫描器(scanner,通常是一个有限状态自动机)从源码文本中逐字符读入,过滤掉注释,将词素映射为词法单元,生成符号表,建立映射,最终输出词素序列
  • 语法分析(syntax analysis):语法分析器从词法分析器中获取词素序列,构建树形的中间表示:通常是抽象语法树(abstract syntax tree),树形中间节点表示运算分量,最终输出被称之为词法单元流的语法树
  • 语义分析(semantic analysis):语义分析器使用语法树与符号表检查源程序的语义一致性,例如一个整数和一个字符串相加是符合语法规则的,但对于大部分语言而言这并不是一个合法的运算,因此不符合语义规则(什么?你说你用 JavaScript
  • 中间代码生成与优化:中间代码生成器通过语义分析的结果生成对应的中间代码(例如三地址码),并进行一定的优化
  • 代码生成:由代码生成器将中间代码转为可执行代码

syz-extract

第一步是从内核源文件中提取符号常量的值:syz-extract 会根据 syzlang 文件从内核源文件中提取出使用的对应的宏、系统调用号等的值,生成 .const 文件

syz-sysgen

第二步便是将描述翻译成 Golang 代码:syz-sysgen 通过 syzlang 文件与 .const 文件进行语法分析与语义分析,生成抽象语法树,最终生成供 syzkaller 使用的 golang 代码,分为如下四个步骤:

  • assignSyscallNumbers:分配系统调用号,检测不支持的系统调用并丢弃

  • patchConsts:将 AST 中的常量替换为对应的值

  • check:进行语义分析

  • genSyscalls:从 AST 生成 prog 对象

0x04.实战:用 syzkaller 挖掘出 CVE-20??-????

CVE-20??-???? 是由于 ?? 原因造成的内核空间中的 ??,笔者接下来将尝试使用 syzkaller 来挖掘出该漏洞

🕊🕊🕊