SyzDescribe: Principled, Automated, Static Generation of Syscall Descriptions for Kernel Drivers
这篇工作中作者提出了SyzDescribe工具,自动化生成syscall规约,不过用的是模版化方法,相当于KernelGPT前置工作。关于KernelGPT的介绍,可以参考blog。
作者来自University of California Riverside -Security Lab,同一团队其它系统安全方面的工作还包括:
-
UBITech:一个结合flow-sensitive type qualifier analysis和symbolic execution(klee)的针对linux kernel的Use-Before-Initialization Bugs(UBI bug)检测工具。
-
SUTURE:一个针对OS kernel的高阶taint style漏洞检测工具。
1.Introduction
目前针对系统内核的fuzzing工具主要是syzkaller,syzkaller的一个必要组件是一组系统调用规约(或者叫描述(description),和codeql的query类似),通常由人类专家编写。由于用户空间和内核空间之间的主要接口是系统调用,fuzzing工具需要了解
-
操作系统内核上可用的系统调用。
-
每个系统调用的关注值(value of interest)。
-
系统调用之间的显式依赖关系。
对于syzkaller,可以使用一种称为syzlang的声明性语言编写系统调用规约。然而,当前的系统调用规约主要是手动编写,这既耗时又容易出错。事实上,在作者的研究中,作者发现现有的手动编写的系统调用规约可能会缺少依赖关系和/或系统调用接口,甚至存在不正确或过时的规约。研究还表明,不完整的系统调用规约是限制syzkaller代码覆盖率的主要原因 [ 2 ] ^{[2]} [2]。
此外,系统调用规约的生成不是一次性的工作。随着操作系统内核的演进,规约需要不断更新。同时,并非所有内核驱动程序都有手动编写的系统调用规约。与Linux内核核心代码相比,内核驱动程序占据了内核代码的大约71.9%的量,因此也需要占据测试内容的很大一部分 [ 2 ] ^{[2]} [2]。
规约已有的自动化规约生成工具包括DIFUSE、KSG。
-
DIFUZE 尝试通过对内核驱动程序源代码的静态分析来自动生成系统调用规约。然而,由于缺乏对内核驱动程序基本的编程约定建模,生成的规约明显不准确。
-
KSG通过动态分析实现了相同的目标,以恢复驱动程序和相关接口。然而,它对已在活动系统上加载的驱动程序的覆盖范围有限,同时在已重新编译和插桩过的内核上进行动态分析的要求可能是一个很高的门槛。
作者在对linux内核驱动程序的编程约定和相关不变量总结后提出了规约生成工具SyzDescribe。并与DIFUZE、KSG生成的规约、以及手动编写的规约进行比较。作者发现SyzDescribe比DIFUZE和KSG具有更高的准确性和覆盖范围,与手动编写的syzkaller规约相比生成了更多的规约,并且最接近ground-truth。
具体而言,作者发现手动编写的规约只覆盖了SyzDescribe生成的内核驱动程序不到一半。有趣的是,即使syzkaller已经覆盖了某个驱动程序,作者仍然发现其中存在许多“错误”,原因是人为错误或缺乏持续维护(内核代码和规约不同步)。此外,作者通过一系列fuzzing实验证明了SyzDescribe的有效性。此外,通过将该解决方案应用于Pixel 6 Android智能手机的内核驱动程序,其中没有现有的系统调用规约可用,我们能够发现18个独自发现的crash。
2.Background and Motivation
2.1.Linux Kernel Drivers and Descriptions
1.内核驱动
在Linux中,驱动程序以内核模块的形式存在,可以在引导时或以后按需加载。每个内核模块通常都包含由宏(例如 module_init(x)
和 module_exit(x)
)定义的明确定义的入口和出口点。通常一个驱动程序在一个内核模块中定义。然而,也可能一个驱动程序跨越多个具有相互依赖关系的内核模块进行定义,例如,由subsys_initcall(alsa_sound_init)
和 module_init(alsa_seq_init)
定义的两个内核模块共同构成了一个声音序列驱动程序。相反,一个单独的内核模块也可以定义多个内核驱动程序,例如,在 module_init(loop_init)
中定义了 loop-control
驱动程序和 loop
驱动程序。
总体而言,内核驱动程序的初始化发生在一个或多个入口点,在此期间将定义向用户空间提供的接口:
-
首先,在
/dev
目录下会定义一个或多个设备文件名。 -
其次,将定义并注册一些系统调用处理程序,例如
open()
和ioctl()
。
这样的初始化允许用户空间应用程序与驱动程序进行交互(在3.1会提供示例说明)。
从概念上讲,每个内核驱动程序都与某种类型的设备相关联,因为其目标是“驱动”该设备。在Linux内核中,有三种第一级别的设备类型:字符设备、块设备和网络接口。这里SyzDescribe只考虑字符设备和块设备,因为它们可以通过 /dev
目录中的设备文件从用户空间访问。在每个第一级设备类型中还有一些有限的子类型,将在后面描述。
设备由设备号(device number)唯一标识,该设备号是主设备号(Major number)和次设备号(Minor number)的组合。设备号不仅唯一标识设备及其文件名,还与一组系统调用处理程序(例如 ioctl()
)关联。尽管这些细节通常不会被希望与内核驱动程序交互的用户空间应用程序感知,但实际上对于生成系统调用规约以执行内核驱动程序是至关重要的。
2.系统调用规约
图1包含一个KVM内核驱动系统调用规约示例,包含下面内容:
-
1.系统调用接口(Syscall Interface):用户空间应用程序与驱动程序进行交互的方式。例如,第3、4、5行的
open()
和ioctl()
调用,它们是最常见的。还可以有read()
、write()
等其他系统调用。 -
2.设备文件名(Device file name):
open
系统调用的参数,比如第3行最后的/dev/kvm
。 -
3.命令值(Command value):
ioctl()
调用的子接口由命令值决定,命令值为ioctl()
的第二个参数的值,如第5行中的KVM_SET_USER_MEMORY_REGION
和第4行的KVM_CREATE_VM
。ioctl()
会根据不同的命令值调用不同的子函数实现不同的功能,因此识别命令值可以使syzkaller更有效地生成测试用例。 -
4.参数类型(Argument type):系统调用接口的其他参数的类型,通常是
ioctl()
的第三个参数。例如,在第5行标注其第三个参数类型为kvm_userspace_memory_region
,该类型在第6行到第12行进行了定义。 -
5.显式依赖关系(Explicit dependency):主要涉及从一个系统调用接口返回的文件描述符(例如,
open()
的返回值),在另一个系统调用接口中使用(例如,ioctl()
的第一个参数为文件描述符)。显式依赖关系描述允许syzkaller生成遵循这些依赖关系的有效测试用例(即,允许后续的系统调用在没有提前终止的情况下进行)。请注意,文件描述符也可以由open()
之外的其他系统调用接口返回,特别是对于那些复杂的设备驱动程序。例如,从第4行的ioctl$KVM_CREATE_VM
中,可以看到它返回了fd_kvmvm
,这将作为第5行的ioctl$KVM_SET_USER_MEMORY_REGION
的第一个参数使用。作者专门将这样的依赖称为非open
文件描述符依赖。如果不了解这种非open
文件描述符依赖,几乎不可能正确地对ioctl$KVM_SET_USER_MEMORY_REGION
这样的系统调用接口进行模糊测试。
2.2.Current Attempts at Generating Syscall Descriptions
1.手动编写规约
目前,syzkaller的项目存储库包含了许多用于各种内核驱动程序的系统调用规约。作者与syzkaller的维护人员确认,这些规约是手动编写的,面临一些挑战:
-
首先,由于内核驱动程序可能很复杂且其逻辑随时间演变,这些规约可能是不完整或甚至不正确的。
-
其次,维护这样的规约成本高,因为它需要不断更新。
-
第三,由于不断有新的内核驱动程序被开发,例如,用于支持OEM特定设备驱动程序的各种Android内核驱动程序。
因此这种做法并不具有可扩展性。
2.基于动态分析的规约生成
CoLaFUZE和KSG是基于动态分析来识别内核驱动程序并恢复其接口的方法。首先,它们扫描 /dev
目录下的所有设备文件,并通过 open()
系统调用检索这些设备文件的文件描述符。它们通过在执行 open()
系统调用时寻找被显式引用的特定的系统调用处理程序结构查找syscall handler。通过这种方式,它们可以轻松地匹配设备文件名和相应的系统调用处理程序结构。按照编程规范,Linux内核系统调用处理程序结构体被定义为包含多个函数指针的结构体,函数指针包括 open
、ioctl
。在找到syscall handler,它们都应用符号执行来恢复命令值和参数类型。
3.基于静态分析的规约生成
DIFUSE是唯一基于静态分析的规约生成方案。
-
1.为了找到syscall handler,它首先尝试从预定义的结构类型列表中识别syscall handler结构体,例如,
struct cdrom_device_ops
(该列表生成方式未知)。 -
2.然后,它尝试识别syscall handler结构体被引用位置附近使用的相应设备文件名(任何常量字符串),并将它们配对。(这里感觉作者假设syscall handler结构体会被device相关结构体包含,同时device结构体会包含设备名)
-
3.在找到syscall handler结构体之后,它从
ioctl()
handler开始进行跨函数静态分析,通过所有相等约束(即,switch
分支和if
条件,这里switch (cmd) case "ls"
表示一个cmd == "ls"
的相等约束)找到命令值,以及通过检查copy_from_user()
找到参数类型,copy_from_user()
是一种用于将数据从用户空间复制到内核空间的常见内核函数。
4.动静态分析对比
静态和动态解决方案都可以实现与现有解决方案尝试的相同目标,即查找syscall handler。作者认为静态和动态解决方案是互补的。动态解决方案可以直接观察在实际系统上发生了什么,例如,它可以直接观察在加载驱动程序后已设置的系统调用处理程序结构。另一方面,动态解决方案的覆盖范围有限,因为它要求在进行分析之前必须正确加载和初始化驱动程序。静态解决方案可以扩大范围,识别不一定已加载的驱动程序,但与动态解决方案相比可能不够精确。
在本文中,作者选择通过静态方法解决这个问题。有几个原因。
-
1.仅使用动态解决方案将无法发现所有可模糊测试的驱动程序/模块,因为一个模块可能是可加载的,具体取决于各种因素,例如,通过指定
MODULE_SOFTDEP
标志依赖于另一个模块,或需要特定的硬件。 -
2.人类专家需要在没有任何运行时测试环境的情况下编写系统调用规约。例如,Android供应商可能希望在真实设备可用于模糊测试之前开始编写规约。只有静态解决方案可以帮助生成在这种情况下对人类专家有帮助的规约。
-
3.动态解决方案需要额外的工程工作,例如基于硬件的追踪、在内核中插桩或安装一个新的内核模块,这些都有一定难度。例如,Android设备可能具有锁定功能的引导加载程序,阻止刷写新内核映像。此外,根据作者自己的经验,重新编译内核并启用特定功能(例如eBPF和kprobe)可能存在兼容性问题。
作者在实验中对动态方法KSG进行了评估,第5.2.2节中实验结果表明它确实有漏报;即,遗漏了一些驱动程序、设备文件和系统调用接口。有趣的是,作者还发现导致漏报的另一个原因:动态解决方案仍然需要对某些编程规范进行建模,缺乏这些建模将直接导致查找syscall handler的失败。
不过,实现一个好的静态解决方案并非易事。在评估DIFUZE时(第5.2.1节),作者发现它在查找syscall handler和设备文件名方面既有显著的误报又有漏报。根本原因是缺乏对内核驱动程序中编程规范进行适当建模。例如:
-
它没有一个指导原则来发现所有syscall handler结构体。相反,它依赖于一个预定义的结构体类型列表,这可能是不完整和过时的,因为内核驱动程序随时间演变。
-
另一个例子是,它没有对设备文件名和syscall handler结构体如何关联进行建模。相反,它依赖于一种简单但不可靠的启发式方法,将靠近syscall handler引用位置的字符串常量视为设备文件名。
3.SyzDescribe Design
SyzDescribe的目标是自动化生成系统调用规约,这些规约可以被syzkaller直接加载,用来增强fuzzing效率。
3.1.Motivating Example
图2展示了一个内核驱动程序的例子,这有助于详细说明编程规范和不变量。由于所有驱动程序都在内核模块中定义,必须由宏module_init
(第16行)声明模块初始化函数。其定义可以在第5行找到。接下来,我们可以看到驱动程序的设备文件在模块初始化函数中初始化(第6-11行),涉及创建两个对象。根据Linux内核驱动程序开发约定:
-
一个对象对应于“驱动程序”(
struct cdev
),包含一组syscall handler的描述(见第12行); -
另一个对应于“设备”(
struct device
),包含设备文件名的描述(见第13行)。
请注意,单个syscall handler集合可以支持多个设备(例如,具有不同的设备文件名),因此有两个单独的对象。此外,每个设备必须具有唯一的设备号(第6行),与驱动程序和设备对象关联,允许将这两个对象配对在一起形成完整的驱动程序接口(即,syscall handler和设备文件名)。
在图2可以看到实际系统调用处理程序的定义,例如,处理设备文件的系统调用 open()
的 xx_open()
(第18行)和处理系统调用 ioctl()
的 xx_ioctl()
(第19行)。在 ioctl()
处理程序中,我们可以在 switch case
语句中看到命令值(从第31行到第46行)或 if
条件(第47行)。此外,我们可以看到 ioctl()
处理程序的第三个参数(第34行)声明为 long
类型,但在给定的命令值(第32行的 cmd1
)下被视为指向特定结构体类型(xx_type
)的指针。这是因为给定的命令值可能完全改变系统调用 ioctl()
的行为,因此需要作为第三个参数传递的自定义数据结构。此外,我们可以看到在命令值为 cmd2
的情况下生成并返回了一个非 open
文件描述符(第38-41行)。
最后,在开头定义了两个其他结构体(第1行到第4行的 struct xx xx
和 static struct xx_device_ops xx_ops
)。有趣的是,struct xx_device_ops
(可以通过另一个结构体访问)看起来像一个存储指向实际处理函数的函数指针的syscall handler结构体。然而,正如我们所看到的,该结构体实际上只在系统调用处理程序内部使用(见第43行至第45行),而不是用于处理系统调用。这表明,通过简单启发式方式(白名单方式)搜索看起来像syscall handler的结构体是行不通的。
3.2.Overview
SyzDescribe的工作流程如图3琐事。SyzDescribe需要Linux Kernel的LLVM Bitcode作为输入,输出是与syzkaller兼容的系统调用规约。如图3所示,SyzDescribe有两个主要阶段:
-
1.Kernel Module Analysis:SyzDescribe通过初始化函数检测内核模块,并将模块与优先级关联,该优先级确定了在内核引导时执行的顺序。然后,SyzDescribe识别可能跨越多个内核模块的任意内核驱动程序,并查找创建并提供给用户空间的基本接口,即支持的系统调用(及相应的处理程序,即syscall handler)和设备文件名。
-
2.Syscall Handler Analysis:对于每个发现的系统调用处理程序(syscall handler),SyzDescribe尝试恢复关于这些syscall的附加细节。这包括
ioctl()
处理程序支持的命令值和参数类型。此外,SyzDescribe还可以找到额外的syscall handler从而找到非open
调用文件描述符依赖。最后,SyzDescribe可以将这些信息直接转化为syzkaller可直接使用的系统调用规约。
SyzDescribe工作的前提是内核驱动程序的代码遵循指定的编程规范(其中大多数规范已经存在了十多年),包括:
-
1.驱动程序/模块初始化方式,即初始化函数在哪里定义以及它们的调用顺序;
-
2.驱动程序和设备对象相关结构体类型(例如,
struct cdev
和struct device
)以及它们如何初始化/注册; -
3.与文件相关的对象以及它们如何与syscall handler关联。
作者将在第3.3节和第3.4节中提供有关SyzDescribe工作前提的更多详细信息,以及如何基于这些前提对初始化和与文件相关的操作进行建模。对关键内核驱动程序操作的建模是区分SyzDescribe与DIFUSE关键特征。
3.3.Kernel Module Analysis
这里,作者专注于对内核模块(因此也是驱动程序)初始化过程的建模。
3.2.1.Kernel Module Identification
首先,SyzDescribe需要检测所有模块初始化函数(在2.1中定义)以识别bitcode中的内核模块(其中包含内核驱动程序)。在常见情况下,这很简单,因为大多数内核模块使用易于识别的宏 module_init()
来声明模块初始化函数(如图2所示)。然而,这并不是唯一的执行此操作的宏。例如,subsys_initcall()
也用于声卡驱动程序中声明初始化函数。不管有多少个这样的宏,它们最终都将使用名为 __define_initcall
的最底层宏。重要的是要注意,__define_initcall
接受两个参数——第一个指定要声明为模块初始化函数的函数,第二个表示相应模块初始化函数的优先级,由内核确定全局执行顺序。最终依赖于 __define_initcall
的各种高层的宏都已经定义如下图7琐事。优先级参数中——0为最高优先级,1为其次,1s为其后,以此类推。在Linux中,所有可加载的内核模块都由 module_init
声明,这意味着它们的优先级为6。基于此,SyzDescribe可以提取每个声明的模块初始化函数及其关联的优先级。
不同内核模块初始化函数的执行顺序在驱动程序跨越不同内核模块定义时很重要。这是因为驱动程序可以在一个内核模块中执行部分初始化,例如,设置某些间接调用(在第4.2节中更详细地讨论),这将影响后续的内核模块初始化函数。
上述编程规范自kernel版本2.6.19中便已存在。
3.3.2.Kernel Driver Identification
在识别所有内核模块并确定它们初始化的顺序后,下一个任务是识别内核驱动程序。正如前面提到的,一些驱动程序跨越多个内核模块,不太清楚哪些内核模块共同构成一个单独的内核驱动程序。作者的解决方案是遍历所有内核模块并根据共享数据结构或唯一设备号的模块分组。
1.驱动程序和设备对象的识别和配对
从图2中,我们知道在内核驱动程序初始化期间定义了两种关键类型的对象,它们共同定义了基本的驱动程序接口。那么现在是识别这两种类型的对象并将它们关联起来(无论它们是否在同一个内核模块中定义)。如图4所示,一种对象是被称为驱动程序对象(driver object,例如,struct cdev
)的对象,它包含关于syscall handler的描述(通常定义为文件操作结构)。另一种类型是我们称之为设备对象(device object,例如,struct device
)的对象,它包含关于设备文件名的描述。
由于只考虑字符设备和块设备,因此只有 struct cdev
和 struct gendisk
类型是相应的驱动程序对象。只有 struct device
类型对应于基本设备对象。
作者通过每种对象的设备号(例如,图2中的第6、8和11行)配对这两种类型的对象。不同的内核驱动程序将具有其唯一的设备号。如果主设备号(例如,MAJOR
)已经是唯一的,那么驱动程序对象的次设备号(例如,MINOR
)可以是可选的。否则,这两个数字组合需要在全局范围内是唯一的。请注意,设备对象必须分配次设备号。当多个设备对象(具有不同的设备文件名)应该与单个驱动程序对象配对时,它们都将共享相同的主设备号,但不会设置驱动程序对象的次设备号。这使得配对仅依赖于主设备号。
总结一下,只要能够恢复分配给每个驱动程序和设备对象的主设备号和次设备号,无论它们位于哪个内核模块中,我们就可以将它们配对。
上述编程规范自2.6.12版本就已经存在,作者将在第4.3节中详细解释可能存在哪些其他类型的设备对象以及如何处理它们。目前,作者假设存在一些预定义的设备对象类型,这意味着可以直接按类型识别驱动程序和设备对象的创建和初始化过程。
2.Syscall handler and device file name recovery
现在可以按类型跟踪驱动程序和设备对象,就像上面提到的那样,可以简单地检查它们在通过明确定义的内核函数(例如cdev_add()
)注册时相关的关键字段,以恢复需要的内容。如图4所示,syscall handler存储在文件操作结构体中,这些结构体被分配给驱动程序对象(driver object),而设备文件名存储在设备对象(device object)中。
需要注意的是,存储在同一结构体中的各种syscall handler促使 open
调用描述符依赖关系的恢复。具体来说,按照规范,如果 open
调用返回一个文件描述符,那么通常会将其输入到同结构体相邻的函数指针对应的syscall handler中,例如 ioctl()
和 read()
(在同一结构体中)。
例如,我们可以识别写入 cdev->ops
的存储指令,其源操作数将对应于syscall handler结构体。类似地,可以识别函数,例如 dev_set_name(dev,“name%”,id);
,这个函数设置了 dev->kobject.name
字段。为了便于实现,作者模拟了一组常见的API,执行初始化(完整列表在附录的图8中),包括对常见格式字符串说明符的支持,如 %s
和 %d
。实验表明,作者成功地恢复了数据集中所有设备名称的72%。
3.4.Syscall Handler Analysis
一旦恢复了syscall handler,下一步需要更详细地分析这些handler,以恢复有关向用户空间提供的结构的更多信息。这主要包括系统调用的参数和非 open
调用文件描述符的依赖关系。DIFUSE仅支持前者,由于缺乏对依赖关系的了解,这可能导致未覆盖重要代码。
3.4.1.Command Value Recovery
对于大多数内核驱动程序,主要的驱动逻辑被编码在 ioctl()
系统调用处理程序中。首先,作者通过检查它们在 switch case
语句和 if
条件中的使用(仅考虑相等比较)来识别命令值。然后,作者通过可达性分析提取特定命令值背后的基本块,即属于 if
条件或特定 switch case
的真分支的基本块。例如,在图2中,命令值 cmd_1
的可达基本块是第33 - 35行,命令值 cmd_2
的可达基本块是第38 - 41行。然后,我们可以分别分析这些基本块,以恢复更精细的系统调用描述,如下所述。此外,作者还提供了一个定制的解决方案来解决间接调用。
3.4.2.Argument Type Recovery
在对每个命令值进行可达性分析后,作者的目标是恢复相应参数的类型(紧随 ioctl
第2个参数 cmd
之后的参数)。与先前的工作类似,作者通过模拟常见的内核函数,如 copy_from_user()
,以实现这一目标。在图2第34行,我们可以看到 copy_from_user()
的目标参数类型是 struct xx_arg
(在命令值 cmd_1
下)。这使我们能够推断参数类型是指向 struct xx_arg
对象的指针类型。除了 copy_from_user()
之外,作者还模拟了在先前的工作中被忽略的 memdup_user()
函数。请注意,我们目前的解决方案不支持嵌套的参数类型,这是作者留在未来的工作。
3.4.3.Additional Syscall Handler Recovery
除了模块初始化函数之外,syscall handler本身也可以创建和注册其他的syscall handler。这在 ioctl()
处理程序中最为常见,其中会创建和注册额外的 struct file
对象。在 struct file
对象内部,有一个指向 struct file_operations
对象的指针,代表与文件对象关联的系统调用处理程序集。此外,如图5所示,按照惯例,一个 struct file
对象与将返回给用户空间的文件描述符相配对。例如,在图2中,从第37行到第41行,我们可以看到具有命令值 cmd_2
的 ioctl()
处理程序创建了一个文件描述符和一个 struct file
对象,并通过一个名为 fd_install()
的特定函数将它们配对。
如果能找到 struct file
对象的创建和初始化,以及它与文件描述符的关联,我们将能够推断两件事:(1) 相应的syscall handler将提供给用户空间,我们应该继续递归地分析其命令值、参数类型和其他syscall handler的恢复;(2) 非 open
调用文件描述符的依赖关系的恢复。换句话说,根据图2中的例子,我们知道 xx_ioctl(fd, cmd_2, arg)
的返回值应该用作在 no_fops
中定义的syscall handler的第一个参数。
作者基于一组相关的内核函数对 struct file
对象和文件描述符进行建模。有趣的是,作者发现先前的工作没有对这些行为进行建模,因此在系统调用规约中会遗漏附加syscall接口和非 open
调用 文件描述符的依赖关系。这里涉及的编程规范自Linux内核v2.6.12版本以来就一直存在。
4.Implementation
SyzDescribe是一个基于LLVM工具链的静态分析工具,使用LLVM 14实现。对于kernel module analysis,SyzDescribe执行自顶向下的跨函数、上下文敏感和域敏感分析。作为一种优化,作者剪枝了不涉及驱动程序或设备相关操作的函数。对于syscall handler analysis,SyzDescribe自顶向下的进行跨函数、上下文敏感和流敏感分析。流敏感性对于区分在不同命令值下执行的分支是必要的,其中需要提取相应的参数类型。总体而言,整个系统(包括生成syzlang格式系统调用规约)有8.2k(C++)行代码,用于构建和链接Linux内核的LLVM位代码有0.3k(Golang)行代码。
4.1.Kernel Module Identification
SyzDescribe依赖宏 __define_initcall()
来识别内核模块的声明。然而,在实际情况中,这些宏在编译器的预处理期会被展开。由于SyzDescribe的输入时LLVM IR,因此不能再观察到这些宏。实际上,即使在源代码级别,__define_initcall()
也是通过内联汇编实现的。这样的汇编代码也会传递到LLVM bitcode中。作者目前的解决方案是直接识别这种汇编的模式,这对于从v4.19版本开始的内核代码适用。对于可加载模块(与内建模块相对),即使它们仍然使用宏module_init声明,宏的展开形式也有所不同。其形式是设置一个名为 init_module
的全局函数指针,指向内核模块的入口点。作者目前的解决方案是通过对应名称在LLVM bitcode中搜索全局函数指针,这对于从v2.6.12版本开始的内核代码适用。
4.2.Indirect Call Resolution
间接调用是内核代码静态分析中一个大挑战。鉴于内核的多入口和有状态等复杂性质,其静态分析不存在完美的解决方案。尽管如此,依旧有一些性能较好的间接调用分析方法(TypeDive,参考blog)。这些解决方案的缺点是它们仍然是过拟合的,会产生许多错误的间接调用目标,这在一些实验中大大延长了整体分析时间。
作者在TypeDive的基础上增加了一个简单而有效的过滤器,以减少间接调用目标的集合。作者观察到其分析范围更侧重于模块初始化函数而不是系统调用。两者都可以设置间接调用目标并执行间接调用。但是在这些函数之间存在固有的顺序,可以利用这一点来修剪错误的间接调用目标。换句话说,仅当在先前的函数中设置了目标后,间接调用目标才可以被调用。
在第3.3.1节中提到,不同的模块以不同的优先级/顺序进行初始化。可加载的内核模块是一个例外,因为它们可以在任何时候按需加载,因此无法分配特定的顺序。该顺序也适用于模块初始化函数之外。根据定义,驱动程序的所有syscall handler只能在相应的模块初始化函数之后调用。此外,如果存在 open()
调用,那它总是在其他系统调用处理程序之前调用。
例如,假设在模块初始化函数中有一个间接调用点,它包含两个潜在调用目标(例如,funcA
和 funcB
)。假设 funcA
在具有较高优先级的另一个模块初始化函数中使用,而 funcB
在另一个无关模块的sycall handler中使用,作者将保留 funcA
并修剪 funcB
。这是因为只有 funcA
可能在间接调用点之前被赋值。这种修剪是有效的,因为通常基于类型的方法将匹配许多错误的间接调用目标,仅仅因为它们的类型匹配,而不考虑目标位于何处(在相同的模块内或外部)。在实验中,作者发现 SyzDescribe 成功地将每个调用点的平均间接调用目标数从的33.2减少到5.8。
4.3.Additional Device Object Modeling
作者在第3.3.2节中提到,识别驱动程序及其接口的关键步骤是识别和配对驱动程序和设备对象。作者提到只识别 struct cdev
和 struct gendisk
类型作为驱动程序对象,以及 struct device
类型作为设备对象,因为专注于字符和块设备。
对于设备对象,只有一种基本的 struct device
类型,有时可能会被其他类型封装。例如,struct miscdevice
就是一种封装了 struct device
的类型 - 它包含一个指向 struct device
对象的指针,如图6所示。这样的对象可以直接通过另一层抽象(例如,使用单独的API)创建和操作。例如,内核驱动程序开发人员可以选择将设备文件名分配给 struct miscdevice
的字段。但是,当对象通过 misc_register()
注册时,名称将最终被复制到封装的 struct device
对象的字段中。类似地,次要编号将传播到 struct device
对象。
注:根据第3.3.2节中描述的规范,应该由驱动程序(driver object)对象定义syscall handler,然而 struct miscdevice
包含一个名为 fops
的指针,指向一组syscall handler。这一开始可能看起来似乎矛盾。然而,在实际情况中,对于使用 struct miscdevice
的任何驱动程序,确实会有一个驱动程序对象及其syscall handler与匹配的设备编号分别注册。实际情况时,在通过 misc_register()
注册后,struct miscdevice
中将定义的新的syscall handler用于替换原始的处理程序。
原则上,可以自动识别这些扩展的设备对象,这些对象封装了 struct device
并相应地进行分析。然而,为了实现的简便性,作者通过对相关API进行建模来识别这些对象。通过搜索最新的Linux内核,作者发现只有四种这样的类型,即 struct miscdevice
、struct usb_class_driver
、struct drm_driver
和 struct snd_minor
。前三者自Linux v2.6.12 版本起定义,而最后一个自Linux v2.6.16 版本起定义。
5.Evaluation
• RQ1.SyzDescribe生成的系统调用规约数量如何与其它方法相比如何?(5.1)
• RQ2.SyzDescribe生成的系统调用规约质量如何?(5.2)
• RQ3.SyzDescribe 生成的系统调用规约在fuzzing中的有效性如何?(5.3)
实验部分太长了,作者进行了三个fuzzing实验。前两个都针对在QEMU中运行的Linux 内核,使用了 syzbot配置。最后一个是针对在Pixel 6 设备上运行的 Android 内核,通过官方的 HWAddressSanitizer配置进行编译。
在fuzz时作者成功找到了Pixel 6中的18 个崩溃,如表8所示,展示了SyzDescribe生成的系统调用规约的有效性。不幸的是,由于缺乏详细的崩溃报告和RAMDUMP MODE的文档,很难理解这些错误的根本原因。
6.Limitations and Future Work
-
1.不支持取值范围(Specific values or value ranges):目前SyzDescribe只恢复参数的类型,不支持
ioctl()
的最后一个参数应该具体取哪些值或者取值的范围。 -
2.支持的syscall有限:SyzDescribe已经能够识别syscall handler,并原则上可以生成
open()
和ioctl()
以外的任何系统调用接口,例如read()
、write()
、mmap()
等。不过,支持这些syscall的挑战在于推断适当的参数类型和值,这部分将作为未来的工作。 -
3.其它显式依赖:SyzDescribe仅支持与文件描述符相关的显式依赖关系,而不支持其他依赖关系。
-
4.合并系统调用规约:SyzDescribe生成的规约在各个方面是互补的,例如,非重叠的
CMDs
和TYPES
。这意味着将这两个系统调用规约合并成一个更完整的规约是有益的。不过,还尚无具体合并方案。