在本系列文章中,我们将分享我们对内核代码模糊测试的见解。简介对于长期关注Linux内核开发或者系统调用模糊测试的读者来说,很可能是trinity(地址:https://lwn.net/Articles/536173/)和syzkaller(地址:https://lwn.net/Articles/677764/)并不陌生。近年来,安全研究人员使用这两个工具发现了许多内核漏洞。事实上,它们的工作原理很简单:随机向内核抛出一些系统调用,希望某些调用会导致内核崩溃,或者在内核代码中触发可检测的漏洞(例如缓冲区溢出漏洞)。虽然这些模糊器能够有效地模糊系统调用本身(以及通过系统调用可访问的代码);然而,这两个工具几乎无法控制用户空间和内核之间的边界发生的事情。事实上,在这个边界发生的事情比我们想象的要复杂:这里的代码是用汇编语言编写的,在内核可以安全地开始执行它的C代码之前,必须检查各种体系结构状态(CPU状态)。进行安全检查或“消毒”。本文将和读者一起探讨如何在x86平台上编写一个针对Linux内核入口代码的Fuzzer工具。在继续之前,我们先简单了解一下64位内核涉及的两个主要文件:·entry_64.S:64位进程的入口代码。·entry_64_compat.S:32位进程的入口代码。一般来说,入口代码有1700行左右的汇编代码(包括注释),所以阅读这些代码的工作量不小,同时这也只是整个内核代码的一小部分。memset()示例首先,我想举一个具体的例子,说明内核从用户空间进入内核时需要验证的CPU状态。在x86平台上,memset()通常由repstos指令实现,因为该指令已被CPU/微代码高度优化,用于在连续字节范围内进行写入操作。从概念上讲,这是一个硬件循环,它重复(rep)多次存储操作(stos);目标地址由%RDI寄存器指定,迭代次数由%RCX寄存器指定。例如,您可以使用内联汇编实现memset(),如下所示:staticinlinevoidmemset(void*dest,intvalue,size_tcount){asmvolatile("repstosb"//4:"+D"(dest),"+c"(count)//1,2:"a"(value)//3:"cc","memory");//5}对于上面的内联汇编代码,它的作用是告诉GCC:1.将变量dest保存到%rdi寄存器(+表示该值可以被内联汇编代码修改);2、将变量count保存到%rcx寄存器;3.将变量值保存到%eax寄存器中(我们是否将其放入%rax、%eax、%ax或%al寄存器中并不重要,因为repstosb指令只使用对应的低字节到%al寄存器中的值);4.把repstosb指令插入到汇编代码中;5.重载可能取决于条件代码(“cc”,即x86平台上的%rflags寄存器)或内存的任何值。作为参考,你也可以看看x86平台上memset()的主流实现代码。重要的是,%rflags寄存器中有一个很少使用的位,称为DF位(或方向标志)。这个标志决定了repstos将在每个字节写入后递增或递减%rdi的值。当DF位设置为0时,受影响的内存范围是从%rdi到(%rdi+%rcx);当DF位设置为1时,受影响的内存范围是从(%rdi-%rcx)到%rdi!由于它对memset()的最终结果有重大影响,因此最好确保DF位始终设置为0。实际上,根据x86_64SysVABI的要求,DF位在入口时必须始终为0到函数并返回(有关详细信息,请参见第15页):“必须在进入函数和从函数返回时清除%rFLAGS寄存器中的方向标志DF(将方向设置为“向前”)。其他用户标志未分配标准调用序列中的角色,并且不会在不同的调用中保留。“事实上,这是内核内部非常依赖的约定;如果在调用memset()时将DF标志以某种方式设置为1,则会错误地覆盖一些内存。因此,内核输入代码的任务是确保在进入任何内核C代码之前DF标志始终为0。我们可以使用指令cld(即清除方向标志指令)来实现。内核的许多入口路径都是这样做的。详细信息请参阅paranoid_entry()或error_entry()的实现代码,在fuzzer中可以看到,即使是CPU状态的一个标志位,对内核的影响也是巨大的,接下来我们将枚举入口代码中所有的CPU状态变量需要处理:标志寄存器(%rflags)栈指针(%rsp)段寄存器(%cs,%fs,%gs)调试寄存器(%dr0to%dr3,%dr7)到目前为止我们已经避免了是的,还有很多从用户空间进入内核的不同方式,而不仅仅是系统调用(而且不仅仅是系统调用的机制)。这些方式包括:·int指令·sysenter指令·syscall指令·INT3/INTO/INT1指令·除零·调试异常·断点异常·溢出异常·操作码无效·一般保护错误·页面错误·浮点异常·外部硬件中断·不可屏蔽中断/内核转换的所有可能组合。在理想情况下,我们会进行穷举搜索,但如果考虑所有可能的寄存器值和入口方法的组合,搜索空间就太大了。因此,我们将通过两个主要策略来提高发现错误的机会。1.关注我们怀疑更有可能导致有趣/不寻常事情发生的值/案例。为此,需要查看x86文档(维基百科、英特尔手册等)以及入口代码本身。例如,入口代码记录了几个处理器勘误表案例,我们可以直接使用这些案例来识别已知的边缘案例。2.压榨那些我们认为没有影响的价值类型。Forexample,whenpickingrandomvalues??toloadintoregisters,itisimportanttotrydifferenttypesofpointers(e.g.,kernel-space,user-space,non-canonical,mapped,unmapped,etc.)ratherthantryingallpossible价值。值得一提的是,内核为x86代码提供了一个优秀的回归测试套件,位于tools/testing/selftests/x86/目录下,主要开发者是AndyLutomirski。它提供了各种进入/离开内核的方法的测试用例,我们可以从中得到启发。高层架构我们这里要开发的fuzzer其实是一个用户空间程序,供内核运行,完成相应的fuzzing工作。由于我们需要非常精确地控制触发向内核转换的指令,因此实际上我们不会直接用C编写此代码;相反,我们将在运行时动态生成x86机器代码,然后执行它。为简单起见,并避免在设置所需的CPU状态后恢复到干净状态(如果可以的话),我们将在子进程中执行生成的机器代码,并能够在进入内核后丢弃它。下面,我们从一个基本的fork循环开始。#include#include#include#include#include#include#includestaticvoid*mem;staticvoidemit_code();typedefvoid(*generated_code_fn)(void);intmain(intargc,char*argv[]){mem=mmap(NULL,PAGE_SIZE,//protPROT_READ|PROT_WRITE|PROT_EXEC,//flagsMAP_PRIVATE|MAP_ANONYMOUS|MAP_32BIT,//fd,offset-1,0);if(mem==MAP_FAILED)error(EXIT_FAILURE,errno,"mmap()");while(1){emit_code();pid_tchild=fork();if(child==-1)error(EXIT_FAILURE,errno,"fork()");if(child==0){//we'rethechild;callournewlygeneratedfunction((generated_code_fn)mem)();exit(EXIT_SUCCESS);}//we'retheparent;waitforthechildtoexitwhile(1){intstatus;if(waitpid(child,&status,0)==-1){if(errno==EINTR)continue;error(EXIT_FAILURE,errno,"waitpid()");}break;}}return0;}然后我们还将实现一个非常简单的emit_code(),到目前为止只创建了一个retq指令函数:staticvoidemit_code(){uint8_t*out=(uint8_t*)mem;//retq*out++=0xc3;}如果仔细阅读代码,你可能会疑惑:为什么要使用MAP_32BIT标志来创建地图?这是因为我们希望fuzzer在32位兼容模式下运行时进入内核,所以首先需要能够在有效的32位地址上运行。进行系统调用在x86平台上,系统调用的历史有点混乱。首先,系统调用最初是在32位系统上使用相对较慢的int指令开发的。后来,英特尔和AMD开发了自己的快速系统调用机制(分别使用新的且相互不兼容的sysenter和syscall指令)。更糟糕的是,64位系统需要同时处理32位进程(使用任何32位系统调用机制)、64位进程和(可能)第三种操作模式x32,其中代码为64-bit和往常一样(并且可以访问64位寄存器),然而,指针是32位的-据说这样做的原因是为了节省内存。由于它们在进入内核模式时保存/修改的CPU状态不同,因此大多数这些不同的系统调用机制在内核入口代码中采用的路径也各不相同。这也是入门代码难懂的原因之一!有关x86上系统调用的更深入介绍,请参阅LWN网站上的优秀文章,例如:系统调用剖析,第1部分系统调用剖析,第2部分熟悉系统调用的好方法是,自己使用GNU汇编器对汇编代码片段进行原型制作,然后用于fuzzer。例如,像这样对内核执行read(STDIN_FILENO,NULL,0)调用:.text.globalmainmain:movl$0,%eax#SYS_read/__NR_readmovl$0,%edi#fd=STDIN_FILENOmovl$0,%esi#buf=NULLmovl$0,%edx#count=0syscallmovl$0,%eaxretq从这段代码可以看出,在使用syscall指令时,系统调用号本身是通过%rax寄存器传递的,而参数是通过%rdi,%rsi传递的,%rdx,等寄存器被传递。据我所知,Linuxx86SysCallABI在入口代码本身的entry_syscall_64()中“正式”记录(我们在这里使用%eXX寄存器而不是%rXX寄存器,因为这里的机器代码更短;当设置%eXX时为0,%rXX的高32位被清除)。我们可以使用gccread.S命令构建上面的代码(假设上面的汇编代码保存在一个名为read.S的文件中),并且可以使用strace来检查是否正确:$strace./a.outexecve("./a.out",["./a.out"],[/*53vars*/])=0[...]read(0,NULL,0)=0exit_group(0)=?+++exitedwith0+++得到汇编后机器码的字节内容,我们可以先用gcc-cread.s编译,然后用objdump-dread.o得到对应的内容:00000000000000000:b800000000mov$0x0,%eax5:bf00000000mov$0x0,%edia:be00000000mov$0x0,%esif:ba00000000mov$0x0,%edx14:0f05syscall16:b800000000mov$0x0,%eax1b:c3retq要将此字节序列添加到我们的JIT汇编函数中,我们可以使用以下代码://mov$0,%eax*out++=0xb8;*out++=0x00;*out++=0x00;*out++=0x00;*out++=0x00;[...]//系统调用*out++=0x0f;*out++=0x05;回到memset()和方向标志对于上面的memset()示例,编写测试所需的大部分代码现在已经就位。为了设置df位,我们可以在进行系统调用之前执行std指令(该指令用于设置方向标志)://std*out++=0xfd;既然要写fuzzer,那么自然要随机分配这个标志位。如果我们使用的编程语言是C++,PRNG可以通过如下代码初始化:#includestaticstd::default_random_engineernd;intmain(...){std::random_devierdev;rnd=std::default_random_engine(rdev());。..}然后,我们可以在进行系统调用之前设置(或清除)标志:switch(std::uniform_int_distributioncase0://cld*out++=0xfc;break;case1://std*out++=0xfd;break;}同样,这些字节只是用来手动组装一个简短的测试程序,然后查看objdump的输出。注意:在子进程中生成随机数时,我们必须格外小心;因为我们不希望所有子进程都生成相同的数字!这就是为什么我们实际上在父进程中生成代码并在子进程中简单地执行它们。本文翻译自:https://blogs.oracle.com/linux/fuzzing-the-linux-kernel-x86-entry-code%2C-part-1-of-3如需转载请注明出处:
