在本系列的第一篇文章中,我们介绍了Linux内核入口代码的作用,以及如何进行JIT汇编和调用系统调用。在本文中,我们将为读者更进一步介绍标志寄存器、堆栈指针、段寄存器、调试寄存器以及进入内核的不同方法。
更多标志(%rflags)
方向标志只是我们众多感兴趣的标志之一。维基百科上关于%rflags的文章列出了我们感兴趣的其他一些标志:
· bit 8:陷阱标志(用于单步调试)
· bit 18:对齐检查
大多数与算术相关的标志(进位标志等)并不是我们感兴趣的对象,因为它们在普通代码的正常运行过程中变化较大,这意味着内核对这些标志的处理很可能已经过了充分的测试。而另外一些标志(如中断启用标志)可能无法被用户空间修改,所以即使尝试也没什么用。
我们需要重点关注陷阱标志,因为设置该标志后,CPU在每条指令后都会传递一个调试异常,自然也会干扰输入代码的正常运行。
对齐检查标志也应当重点关注,因为当一个错误对齐的指针被解除引用时,它会使CPU传递一个对齐检查异常。虽然CPU在0环中执行时不应该执行对齐检查,但是检查是否存在因为对齐检查异常而进入内核的相关漏洞还是很有意思的(我们稍后再谈)。
维基百科的文章给出了修改这些标志的程序,但我们可以做得更好一点。
0: 9c pushfq 1: 48 81 34 24 00 01 00 00 xorq $0x100,(%rsp) 9: 48 81 34 24 00 04 00 00 xorq $0x400,(%rsp) 11: 48 81 34 24 00 00 04 00 xorq $0x40000,(%rsp) 19: 9d popfq
这段代码将%rflags的内容压入堆栈上,然后直接修改堆栈上的标志值,再将该值弹出到%rflags中。实际上,我们在这里可以选择使用orq或者xorq指令;我选择xorq,因为它可以切换寄存器中的任何值。这样一来,如果我们连续进行多次系统调用(或内核入口),我们可以随机切换标志,而不必关心现有的值是什么。
既然我们无论如何都要修改%rflags寄存器,那么我们不妨把方向标志的修改纳入进去,把三个标志的修改合并到一条指令中。虽然这是一个很小的优化,但没有理由不这么做,最后的结果如下所示:
// pushfq *out++ = 0x9c; uint32_t mask = 0; // trap flag mask |= std::uniform_int_distribution // direction flag mask |= std::uniform_int_distribution // alignment check mask |= std::uniform_int_distribution // xorq $mask, 0(%rsp) *out++ = 0x48; *out++ = 0x81; *out++ = 0x34; *out++ = 0x24; *out++ = mask; *out++ = mask >> 8; *out++ = mask >> 16; *out++ = mask >> 24; // popfq *out++ = 0x9d;
如果我们不希望进程在设置陷阱标志时立即被SIGTRAP杀死,我们需要注册一个信号处理程序来有效地忽略这个信号(显然使用SIG_IGN是不够的):
static void handle_child_sigtrap(int signum, siginfo_t *siginfo, void *ucontext) { // this gets called when TF is set in %rflags; do nothing } ... struct sigaction sigtrap_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_ONSTACK标志,我们将在下一节讨论。
堆栈指针(%rsp)
在修改%rflags之后,我们其实就不需使用堆栈了,这意味着我们可以在不影响程序执行的情况下,自由地更改栈指针。不过我们为什么要修改栈指针呢?内核又不会用我们的用户空间栈来做任何事情,对吧?事实上,它可能会。
像ftrace和perf这样的调试工具偶尔会在系统调用跟踪期间取消对用户空间堆栈的引用。事实上,我在这方面至少发现了两个不同的漏洞:
· report 1 (July 16, 2019),
· report 2 (May 10, 2020).
当向用户空间传递信号时,信号处理程序的堆栈帧由内核创建,通常位于被中断线程的当前堆栈指针的上方。
如果由于某些错误,%rsp会被内核直接访问,那么在正常操作期间可能不会被注意到,因为堆栈指针通常总是指向一个有效地址。要捕捉这种漏洞,我们可以简单地将其指向一个非映射地址(甚至是内核地址!)。
为了帮助我们测试堆栈指针的各种可能感兴趣的值,我们可以定义一个helper:
static void *page_not_present; static void *page_not_writable; static void *page_not_executable; static uint64_t get_random_address() { // very occasionally hand out a non-canonical address if (std::uniform_int_distribution return 1UL << 63; uint64_t value = 0; switch (std::uniform_int_distribution case 0: break; case 1: value = (uint64_t) page_not_present; break; case 2: value = (uint64_t) page_not_writable; break; case 3: value = (uint64_t) page_not_executable; break; case 4: static const uint64_t kernel_pointers[] = { 0xffffffff81000000UL, 0xffffffff82016000UL, 0xffffffffc0002000UL, 0xffffffffc2000000UL, }; value = kernel_pointers[std::uniform_int_distribution // random ~2MiB offset value += PAGE_SIZE * std::uniform_int_distribution break; } // occasionally intentionally misalign it if (std::uniform_int_distribution value += std::uniform_int_distribution return value; } int main(...) { page_not_present = mmap(NULL, PAGE_SIZE, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_32BIT, -1, 0); page_not_writable = mmap(NULL, PAGE_SIZE, PROT_READ | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | MAP_32BIT, -1, 0); page_not_executable = mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_32BIT, -1, 0); ... }
在这里,我使用了自己机器上的/proc/kallsyms中找到的一些内核指针。它们不一定是很好的选择,只是用于演示。正如我前面所提到的,我们需要找到一个平衡点,既要选择那些疯狂到没有人想过要处理它们的值(我们毕竟在这里试图寻找的是边缘案例),又要不迷失在巨大的非目标值的海洋中;我们可以统一选择随机的64位值,但这很难带来任何有效的指针(其中大部分可能是非规范的地址)。模糊测试的部分艺术是通过对哪些有可能和哪些不可能的关系进行有根据的猜测来抽出相关的边缘案例。
现在只是设置值的问题,幸运的是,我们可以直接将64位的值加载到%rsp中:
movq $0x12345678aabbccdd, %rsp
可以使用下列代码:
uint64_t rsp = get_random_address(); // movq $imm, %rsp *out++ = 0x48; *out++ = 0xbc; for (int i = 0; i < 8; ++i) *out++ = rsp >> (8 * i);
但是,对于上面提到的%rflags来说,有一点需要引起我们的高度注意:一旦我们在%rflags中启用了单步标志,CPU就会在随后执行的每条指令中传递一个调试异常。内核将通过向进程传递一个SIGTRAP信号来处理调试异常。默认情况下,这个信号是通过堆栈传递的,而堆栈上的值就是%rsp的值……如果%rsp无效,内核会用一个不可触发的SIGSEGV来杀死进程。
为了处理这样的情况,内核提供了一个函数,以便在传递信号时将%rsp设置为一个已知的有效值:sigaltstack()。我们要做的就是像下面这样来调用它:
stack_t ss = {}; 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必须含有有效的16位段选择器,指向GDT(全局描述符表)或LDT(局部描述符表)中的有效条目。
· %cs不能使用mov指令加载,但我们可以使用ljmp(远/长跳转)指令。
· %cs的CPL(当前权限级别)字段是CPU正在执行的权限级别。通常情况下,64位用户空间进程运行的%cs为0x33,即GDT的索引6,特权级别为3,内核运行的%cs为0x10,即GDT的索引2,特权级别为0(因此称为ring 0)。
· 实际上我们可以使用modify_ldt()系统调用在LDT中安装条目,但要注意的是,内核会对条目进行消毒,所以我们不能创建一个指向DPL 0的段的调用门。
· %fs和%gs的基地址是由MSRs指定的。这些寄存器通常分别用于用户空间进程和内核的TLS(线程本地存储)和per-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 #include for (unsigned int i = 0; i < 4; ++i) { struct user_desc desc = {}; desc.entry_number = i; desc.base_addr = std::uniform_int_distribution desc.limit = std::uniform_int_distribution desc.seg_32bit = std::uniform_int_distribution desc.contents = std::uniform_int_distribution desc.read_exec_only = std::uniform_int_distribution desc.limit_in_pages = std::uniform_int_distribution desc.seg_not_present = std::uniform_int_distribution desc.useable = std::uniform_int_distribution syscall(SYS_modify_ldt, 1, &desc, sizeof(desc)); }
我们可能要检查这里的返回值;我们不应该生成无效的LDT条目,所以知道我们是否存在这种条目是很有用的。
static uint16_t get_random_segment_selector() { unsigned int index; switch (std::uniform_int_distribution case 0: // The LDT is small, so favour smaller indices index = std::uniform_int_distribution break; case 1: // Linux defines 32 GDT entries by default index = std::uniform_int_distribution break; case 2: // Max table size index = std::uniform_int_distribution break; } unsigned int ti = std::uniform_int_distribution unsigned int rpl = std::uniform_int_distribution return (index << 3) | (ti << 2) | rpl; }
数据段(%ds)
下面展示如何使用数据段:
if (std::uniform_int_distribution uint16_t sel = get_random_segment_selector(); // movw $imm, %ax *out++ = 0x66; *out++ = 0xb8; *out++ = sel; *out++ = sel >> 8; // movw %ax, %ds *out++ = 0x8e; *out++ = 0xd8; }
%fs与 %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()调用时就可能发生)。如果我们想生成系统调用来做这件事,我们可以通过支持代码进行协助:
enum machine_register { // 0 RAX, RCX, RDX, RBX, RSP, RBP, RSI, RDI, // 8 R8, R9, R10, R11, R12, R13, R14, R15, }; const unsigned int REX = 0x40; const unsigned int REX_B = 0x01; const unsigned int REX_W = 0x08; static uint8_t *emit_mov_imm64_reg(uint8_t *out, uint64_t imm, machine_register reg) { *out++ = REX | REX_W | (REX_B * (reg >= 8)); *out++ = 0xb8 | (reg & 7); for (int i = 0; i < 8; ++i) *out++ = imm >> (8 * i); return out; } static uint8_t *emit_call_arch_prctl(uint8_t *out, int code, unsigned long addr) { // int arch_prctl(int code, unsigned long addr); 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); // syscall *out++ = 0x0f; *out++ = 0x05; return out; }
需要注意的是,除了需要一些寄存器来执行系统调用本身之外,syscall指令还用返回地址(即syscall指令后的指令地址)覆盖%rcx,所以我们可能要在做其他事情之前进行这些调用。
小结
在本文中,我们为读者更进一步介绍了各种标志寄存器、堆栈指针以及部分段寄存器,在下一篇文章中,我们将为读者介绍调试寄存器以及进入内核的不同方法。
本文翻译自:https://blogs.oracle.com/linux/fuzzing-the-linux-kernel-x86-entry-code%2c-part-2-of-3如若转载,请注明原文地址: