当前位置: 首页 > 科技观察

LinuxKernel(x86)EntryCodeFuzzingGuidePart2(Part1)

时间:2023-03-16 23:08:20 科技观察

在本系列的第一篇文章中,我们介绍了Linux内核入口代码的功能,以及如何进行JIT汇编和调用系统调用。在本文中,我们将带领读者更进一步,介绍标志寄存器、堆栈指针、段寄存器、调试寄存器以及进入内核的不同方式。更多标志(%rflags)方向标志只是我们感兴趣的众多标志之一。维基百科关于%rflags的文章列出了一些我们感兴趣的其他标志:bit8:trapflag(forsingle-stepping)bit18:对齐检查大多数与算术相关的标志(进位标志等)不是我们感兴趣的对象,因为它们在普通代码的正常运行期间变化很大,这意味着内核对这些标志的处理可能已经过良好测试。而其他一些标志位(比如中断使能标志位)可能是用户空间无法修改的,所以试了也没用。我们需要注意trap标志,因为设置了这个标志后,CPU会在每条指令后传递一个调试异常,自然会干扰输入代码的正常运行。对齐检查标志也应该引起极大的关注,因为它会导致CPU在取消引用未对齐的指针时通过对齐检查异常。虽然CPU在ring0执行时不应该进行对齐检查,但是检查一下因为对齐检查异常进入内核的相关漏洞还是很有意思的(后面会讲到)。维基百科文章给出了修改这些标志的过程,但我们可以做得更好。0:9cpushfq1:4881342400010000xorq$0x100,(%rsp)9:4881342400040000xorq$0x400,(%rsp)11:4881342400000400xorq$0x40000,(%rsp)19:4881342400040000xorq$0x4000,(%rsp)11:4881342400000400xorq$0x40000,(%rsp)19:9dpopfq修改flag上的内容会直接push内容堆栈,然后将值弹出到%rflags中。我们其实可以在这里选择使用orq或者xorq指令;我选择xorq是因为它可以切换寄存器中的任何值。这样,如果我们连续进行多个系统调用(或内核条目),我们可以随机切换标志而不用关心现有值是什么。既然我们无论如何都要修改%rflags寄存器,那么我们不妨将方向标志的修改合并起来,将三个标志的修改合并到一条指令中。虽然这是一个小的优化,但没有理由不这样做,最终结果如下所示://pushfq*out++=0x9c;uint32_tmask=0;//trapflagmask|=std::uniform_int_distribution//directionflagmask|=std::uniform_int_distribution//alignmentcheckmask|=std::uniform_int_distribution//xorq$mask,0(%rsp)*out++=0x48;*out++=0x81;*out++=0x34;*out++=0x24;*out++=掩码;*out++=掩码>>8;*out++=mask>>16;*out++=mask>>24;//popfq*out++=0x9d;如果我们不希望进程在设置陷阱标志时立即被SIGTRAP杀死,我们需要注册一个信号处理程序来有效地忽略这个信号(显然使用SIG_IGN是不够的):staticvoidhandle_child_sigtrap(intsignum,siginfo_t*siginfo,void*ucontext){//thisgetscalledwhenTFissetin%rflags;donothing}...structsigactionsigtrap_act={};sigtrap_act.sa_sigaction=&handle_child_sigtrap;sigtrap_act.sa_flags=SA_SIGINFO|SA_ONSTACK;if(sigaction(SIGTRAP,&sigtrap_act,NULL)==-1)error(EXIT_FAILURE,errno,"sigaction(SIGTRAP)");关于上面的SA_ONSTACKflag,我们会分节讨论。堆栈指针(%rsp)修改了%rflags后,我们实际上并不需要使用堆栈,也就是说我们可以随意更改堆栈指针,而不影响程序执行。但是我们为什么要修改栈指针呢?内核不会对我们的用户空间堆栈做任何事情,是吗?事实上,它可能会。ftrace和perf等调试工具偶尔会在系统调用跟踪期间取消引用用户空间堆栈。事实上,我在这方面至少发现了两个不同的漏洞:报告1(2019年7月16日)、报告2(2020年5月10日)。向用户空间传递信号时信号处理程序的堆栈帧由内核创建,通常位于被中断线程的当前堆栈指针之上。如果%rsp由于某些错误而被内核直接访问,则在正常操作期间可能不会注意到它,因为堆栈指针通常总是指向有效地址。要捕获这种漏洞,我们可以简单地将它指向一个未映射的地址(甚至是内核地址!)。为了帮助我们测试堆栈指针的各种可能有趣的值,我们可以定义一个helper:staticvoid*page_not_present;staticvoid*page_not_writable;staticvoid*page_not_executable;staticuint64_tget_random_address(){//非常偶尔handoutanon-canonicaladdressif(std::uniform_int_distributionreturn1UL<<6;uint64_tvalue=0;开关(std::uniform_int_distributioncase0:break;case1:value=(uint64_t)page_not_present;break;case2:value=(uint64_t)page_not_writable;break;case3:value=(uint64_t)page_not_executable;break;case4:4staticconstuint6[]={0xffffffff81000000UL,0xffffffff82016000UL,0xffffffffc0002000UL,0xffffffffc2000000UL,};value=kernel_pointers[std::uniform_int_distribution//random~2MiBoffsetvalue+=PAGE_SIZE*std::uniform_int_distributionbreak;}//occasionallyintentionallymisalignitif(std::uniform_int_distributionvalue+=std::uniform_int_distributionreturnvalue;}intmain(...){page_not_present=mmap(NULL,PAGE_SIZE,PROT_NONE,MAP_PRIVATE|MAP_ANONYMOUS|MAP_32BIT,-1,0);page_not_writable=mmap(NU在这里,我使用了一些在我机器上的/proc/kallsyms中找到的内核指针。它们不一定是好的选择,只是为了演示。正如我之前提到的,我们需要在选择如此疯狂以至于没有人想过处理它们的值之间找到平衡(我们毕竟试图在这里找到边缘情况)而不迷失在巨大的海洋中非目标值;我们可以统一选择随机的64位值,但这几乎不会产生任何有效的指针(其中大部分可能是非规范地址)。模糊测试艺术的一部分是通过对哪些关系是可能的、哪些不是可能的进行有根据的猜测来提取相关的边缘案例。现在只是设置值的问题,幸运的是我们可以直接将64位值加载到%rsp中:movq$0x12345678aabbccdd,%rsp可以使用以下代码:uint64_trsp=get_random_address();//movq$imm,%rsp*out++=0x48;*out++=0xbc;for(inti=0;i<8;++i)*out++=rsp>>(8*i);但是,对于上面提到的%rflags,有一点需要引起我们的注意:一旦我们在%rflags中启用单步执行标志,CPU将在随后执行的每条指令上传递调试异常。内核将通过向进程传递SIGTRAP信号来处理调试异常。默认情况下,这个信号是通过栈传递的,栈上的值就是%rsp的值...如果%rsp无效,内核会用不可触发的SIGSEGV杀死进程。为了处理这种情况,内核提供了一个函数来在传递信号时将%rsp设置为已知的有效值:sigaltstack()。我们所要做的就是这样调用它:stack_tss={};ss.ss_sp=malloc(SIGSTKSZ);if(!ss.ss_sp)error(EXIT_FAILURE,errno,"malloc()");ss.ss_size=SIGSTKSZ;ss.ss_flags=0;if(sigaltstack(&ss,NULL)==-1)error(EXIT_FAILURE,errno,"sigaltstack()");然后,将SA_ONSTACK传递给处理SIGTRAP中间的sigaction()调用的sa_flags变量。段寄存器说到段寄存器,经常会看到这样的说法:其实在64位上用处不大。然而,这并不是全部真相。诚然,您不能更改基地址或段大小,但几乎所有其他内容仍然相关。特别是与我们相关的东西,例如:%cs、%ds、%es和%ss必须包含指向GDT(全局描述符表)或LDT(本地描述符表)有效条目的有效16位段选择器。·%cs不能使用mov指令加载,但我们可以使用ljmp(远/长跳转)指令。%cs的CPL(当前特权级别)字段是CPU正在执行的特权级别。通常情况下,64位用户空间进程运行的%cs为0x33,也就是GDT的索引6,特权级为3,内核运行的%cs为0x10,也就是GDT的索引2,特权级别为0(因此称为ring0)。我们实际上可以使用modify_ldt()系统调用在LDT中安装条目,但要注意内核会清理条目,因此我们不能创建指向DPL0中的段的调用门。·%fs和%gs的基地址是由MSR指定。这些寄存器通常分别用于用户空间进程和内核的TLS(线程本地存储)和每个CPU数据。我们可以使用arch_prctl()系统调用来更改这些寄存器的值。在某些CPU/核心上,我们可以使用wrfsbase和wrgsbase指令。用mov或pop指令设置%ss将导致CPU在mov或pop指令后的一条指令中屏蔽中断、NMI、断点和单步陷阱。如果下一条指令导致内核入口,这些中断、NMI、断点或单步陷阱将在CPU开始在内核空间中执行后生效。这就是CVE-2018-8897出现的地方,内核没有正确处理这种情况。LDT由于我们可以从LDT加载段寄存器,我们不妨从设置LDT开始。由于modify_ldt()没有glibc包装器,我们必须使用syscall()函数来调用它:#include#include#include#includefor(unsignedinti=0;i<4;++i){structuser_descdesc={};desc。entry_number=i;desc.base_addr=std::uniform_int_distributiondesc.limit=std::uniform_int_distributiondesc.seg_32bit=std::uniform_int_distributiondesc.contents=std::uniform_int_distributiondesc.read_exec_only=std::uniform_int_distributiondesc.limit_in_pagesegforms_inttrient_notriments=std::uniform_int_distributiondesc.limit_in_pageseg_inttrient_notstd::uniform_int_distributiondesc.useable=std::uniform_int_distributionsyscall(SYS_modify_ldt,1,&desc,sizeof(desc));}我们可能想在这里检查返回值;我们不应该生成无效的LDT条目,所以知道我们是否有这样的项目是有用的。staticuint16_tget_random_segment_selector(){unsignedintindex;开关(std::uniform_int_distributioncase0://TheLDTissmall,sofavoursmallerindicesindex=std::uniform_int_distributionbreak;case1://Linuxdefines32GDTentriesbydefaultindex=std::uniform_int_distributionbreak;case2://Maxtablesizeindex=std::uniform_stdsignedtitributionbreak;}::uniform_int_distributionunsignedintrpl=std::uniform_int_distributionreturn(index<<3)|(ti<<2)|rpl;}Datasegment(%ds)下面展示数据段的使用方法:if(std::uniform_int_distributionuint16_tsel=get_random_segment_selector();//movw$imm,%ax*out++=0x66;*out++=0xb8;*out++=sel;*out++=sel>>8;//movw%ax,%ds*out++=0x8e;*out++=0xd8;}%fsand%gs对于%fs和%gs,我们需要使用系统调用arch_prctl(),在普通(非JIT汇编)代码中,可以这样使用:#include#include...syscall(SYS_arch_prctl,ARCH_SET_FS,get_random_address());syscall(SYS_arch_prctl,ARCH_SET_GS,get_random_address());不幸的是,这样做很有可能导致glibc/libstdc++在任何使用线程本地存储的代码上崩溃(即使在第二个get_random_address()调用上)。如果我们想生成系统调用来执行此操作,我们可以协助支持代码:enummachine_register{//0RAX,RCX,RDX,RBX,RSP,RBP,RSI,RDI,//8R8,R9,R10,R11,R12,R13,R14,R15,};constunsignedintREX=0x40;constunsignedintREX_B=0x01;constunsignedintREX_W=0x08;staticuint8_t*emit_mov_imm64_reg(uint8_t*out,uint64_timm,machine_registerreg){*out++=REX|REX_B*());*out++=0xb8|(reg&7);for(inti=0;i<8;++i)*out++=imm>>(8*i);returnout;}staticuint8_t*emit_call_arch_prctl(uint8_t*out,intcode,unsignedlongaddr){//intarch_prctl(intcode,unsignedlongaddr);out=emit_mov_imm64_reg(out,SYS_arch_prctl,RAX);out=emit_mov_imm64_reg(out,code,RDI);out=emit_mov_imm64_reg(out,addr,RSI);//系统调用*out++=0x0f;*out++=0x05;returnout;}需要注意的是,syscall指令除了需要一些寄存器来执行系统调用本身外,还会用返回地址(即syscall指令后指令的地址)覆盖%rcx,所以我们可能想在做其他事情之前打这些电话呃事情。小结在本文中,我们进一步为读者介绍了各种标志寄存器、堆栈指针和一些段寄存器。在下一篇文章中,我们将为读者介绍调试寄存器和进入内核的不同方法。本文翻译自:https://blogs.oracle.com/linux/fuzzing-the-linux-kernel-x86-entry-code%2c-part-2-of-3如有转载请注明出处