两周前在*CTF上遇到一道Arm64的Linux kernel pwn,虽然漏洞是非常明显的栈溢出,但是学会怎么利用确实花费了一番功夫,因为确实不是很懂Arm64的一些机制,主要是不知道如何正确地从内核返回用户态。虽然最后磕磕绊绊地做出来了,但是最后没法执行system或者execve直接开shell,无奈只能orw读flag,赛后不论是官方发出的WP还是其他师傅的博客,都没有找到解决这个问题的满意的答案。
于是自己花了点时间分析了一下,终于是找到了根本原因,遂写下此文记录一番,同时希望借此互相学习交流。
题目分析
由于官方WP的放出,这里就不再进行赘述,简而言之,它终究还是个Linux kernel,提权的思路都是一致的,就是调用commit_creds(prepare_kernel_cred)
,然后返回用户态开shell或者orw。
而具体的流程上无非通过ROPgadget dump一下Image中的gadget,然后寻找一些可用的来控制寄存器进行ROP。
这里主要还是想分享一下自己在赛后寻找主要原因的过程。
验证猜想:是否是某些关键寄存器没有设置
最开始的时候,我个人认为可能是某些寄存器在ROP的过程中没有设置正确,影响了后续再进行系统调用的过程,但是仔细想想为什么只有在执行system或者execve的时候才会出现Segmentation Fault,orw却不受影响。
于是我从Arm64处理系统调用的逻辑入手,综合网上的一些资料加上Linux 源码,试图找到哪些Arm64下的寄存器才是“罪魁祸首”(毕竟Arm64的寄存器是在是太多了)。
系统调用处理流程
从用户态svc 0
进入内核态开始,其流程大概为:
进行KPTI的前置处理,其定义通过宏
tramp_ventry
实现,涉及ttbr1_el1
(Translation Table Base Register 1 (EL1),即存放内核页表基址的寄存器)的设置,以及定位异常向量表: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.macro tramp_ventry, vector_start, regsize, kpti, bhb
...
tramp_map_kernel x30
tramp_data_read_var x30, vectors
...
ldr x30, =vectors
add x30, x30, #(1b - \vector_start + 4)
ret
...
.endm
.macro tramp_map_kernel, tmp
mrs \tmp, ttbr1_el1
add \tmp, \tmp, #TRAMP_SWAPPER_OFFSET
bic \tmp, \tmp, #USER_ASID_FLAG
msr ttbr1_el1, \tmp
.endm
.macro tramp_data_read_var dst, var
tramp_data_page \dst
add \dst, \dst, #:lo12:__entry_tramp_data_\var
ldr \dst, [\dst]
.endm
.macro tramp_data_page dst
adr_l \dst, .entry.tramp.text
sub \dst, \dst, PAGE_SIZE
.endm进入异常向量表中对应的处理函数入口,其定义通过宏
kernel_ventry
实现,主要是在栈上为GPRs(General-Purpose Registers)预留一段空间以保存用户态的寄存器,以及对栈指针sp
的溢出检查:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24.macro kernel_ventry, el:req, ht:req, regsize:req, label:req
...
sub sp, sp, #PT_REGS_SIZE
add sp, sp, x0 // sp' = sp + x0
sub x0, sp, x0 // x0' = sp' - x0 = (sp + x0) - x0 = sp
tbnz x0, #THREAD_SHIFT, 0f
sub x0, sp, x0 // x0'' = sp' - x0' = (sp + x0) - sp = x0
sub sp, sp, x0 // sp'' = sp' - x0 = (sp + x0) - x0 = sp
b el\el\ht\()_\regsize\()_\label
...
.endm
.macro entry_handler el:req, ht:req, regsize:req, label:req
SYM_CODE_START_LOCAL(el\el\ht\()_\regsize\()_\label)
kernel_entry \el, \regsize
mov x0, sp
bl el\el\ht\()_\regsize\()_\label\()_handler
.if \el == 0
b ret_to_user
.else
b ret_to_kernel
.endif
SYM_CODE_END(el\el\ht\()_\regsize\()_\label)
.endm之后通过
kernel_entry
宏定义的逻辑,在内核栈上保存寄存器值,包括用户态的SP
,PC
,PSTATE
(类似于x86-64下的RSP
,RIP
,RFLAGS
):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.macro kernel_entry, el, regsize = 64
stp x0, x1, [sp, #16 * 0]
stp x2, x3, [sp, #16 * 1]
stp x4, x5, [sp, #16 * 2]
stp x6, x7, [sp, #16 * 3]
stp x8, x9, [sp, #16 * 4]
stp x10, x11, [sp, #16 * 5]
stp x12, x13, [sp, #16 * 6]
stp x14, x15, [sp, #16 * 7]
stp x16, x17, [sp, #16 * 8]
stp x18, x19, [sp, #16 * 9]
stp x20, x21, [sp, #16 * 10]
stp x22, x23, [sp, #16 * 11]
stp x24, x25, [sp, #16 * 12]
stp x26, x27, [sp, #16 * 13]
stp x28, x29, [sp, #16 * 14]
clear_gp_regs
mrs x21, sp_el0
ldr_this_cpu tsk, __entry_task, x20
msr sp_el0, tsk
...
mrs x22, elr_el1
mrs x23, spsr_el1
stp lr, x21, [sp, #S_LR]
stp xzr, xzr, [sp, #S_STACKFRAME]
add x29, sp, #S_STACKFRAME
stp x22, x23, [sp, #S_PC]
mov w21, #NO_SYSCALL
str w21, [sp, #S_SYSCALLNO]
.endm
.macro clear_gp_regs
.irp n,0,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
mov x\n, xzr
.endr
.endm最后以当前
SP
作为参数,进入到实际的处理函数中el\el\ht\()_\regsize\()_\label\()_handler
->el0t_64_sync_handler
中: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
52asmlinkage void noinstr el0t_64_sync_handler(struct pt_regs *regs)
{
unsigned long esr = read_sysreg(esr_el1);
switch (ESR_ELx_EC(esr)) {
case ESR_ELx_EC_SVC64:
el0_svc(regs);
break;
case ESR_ELx_EC_DABT_LOW:
el0_da(regs, esr);
break;
case ESR_ELx_EC_IABT_LOW:
el0_ia(regs, esr);
break;
case ESR_ELx_EC_FP_ASIMD:
el0_fpsimd_acc(regs, esr);
break;
case ESR_ELx_EC_SVE:
el0_sve_acc(regs, esr);
break;
case ESR_ELx_EC_FP_EXC64:
el0_fpsimd_exc(regs, esr);
break;
case ESR_ELx_EC_SYS64:
case ESR_ELx_EC_WFx:
el0_sys(regs, esr);
break;
case ESR_ELx_EC_SP_ALIGN:
el0_sp(regs, esr);
break;
case ESR_ELx_EC_PC_ALIGN:
el0_pc(regs, esr);
break;
case ESR_ELx_EC_UNKNOWN:
el0_undef(regs);
break;
case ESR_ELx_EC_BTI:
el0_bti(regs);
break;
case ESR_ELx_EC_BREAKPT_LOW:
case ESR_ELx_EC_SOFTSTP_LOW:
case ESR_ELx_EC_WATCHPT_LOW:
case ESR_ELx_EC_BRK64:
el0_dbg(regs, esr);
break;
case ESR_ELx_EC_FPAC:
el0_fpac(regs, esr);
break;
default:
el0_inv(regs, esr);
}
}由于异常原因是
svc
指令,故进入到el0_svc
中处理,直接找到最底层invoke_syscall
,就是根据系统调用号从系统调用表里查找相应函数,然后执行,并设置返回值。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
63
64
65
66
67
68
69
70
71
72
73
74static void noinstr el0_svc(struct pt_regs *regs)
{
enter_from_user_mode(regs);
cortex_a76_erratum_1463225_svc_handler();
do_el0_svc(regs);
exit_to_user_mode(regs);
}
void do_el0_svc(struct pt_regs *regs)
{
sve_user_discard();
el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table);
}
static void el0_svc_common(struct pt_regs *regs, int scno, int sc_nr,
const syscall_fn_t syscall_table[])
{
unsigned long flags = read_thread_flags();
regs->orig_x0 = regs->regs[0];
regs->syscallno = scno;
local_daif_restore(DAIF_PROCCTX);
if (flags & _TIF_MTE_ASYNC_FAULT) {
syscall_set_return_value(current, regs, -ERESTARTNOINTR, 0);
return;
}
if (has_syscall_work(flags)) {
if (scno == NO_SYSCALL)
syscall_set_return_value(current, regs, -ENOSYS, 0);
scno = syscall_trace_enter(regs);
if (scno == NO_SYSCALL)
goto trace_exit;
}
invoke_syscall(regs, scno, sc_nr, syscall_table);
if (!has_syscall_work(flags) && !IS_ENABLED(CONFIG_DEBUG_RSEQ)) {
local_daif_mask();
flags = read_thread_flags();
if (!has_syscall_work(flags) && !(flags & _TIF_SINGLESTEP))
return;
local_daif_restore(DAIF_PROCCTX);
}
trace_exit:
syscall_trace_exit(regs);
}
static void invoke_syscall(struct pt_regs *regs, unsigned int scno,
unsigned int sc_nr,
const syscall_fn_t syscall_table[])
{
long ret;
add_random_kstack_offset();
if (scno < sc_nr) {
syscall_fn_t syscall_fn;
syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)];
ret = __invoke_syscall(regs, syscall_fn);
} else {
ret = do_ni_syscall(regs, scno);
}
syscall_set_return_value(current, regs, 0, ret);
choose_random_kstack_offset(get_random_int() & 0x1FF);
}
static long __invoke_syscall(struct pt_regs *regs, syscall_fn_t syscall_fn)
{
return syscall_fn(regs);
}
从系统调用返回
现在需要关注的就是,如何在内核中ROP提权完安全返回,即是从ioctl
这个syscall返回。
因为从__invoke_syscall
返回后,一切内核原本需要做的动作都没法恢复了,此时有理由怀疑是否遗漏哪些关键寄存器的值没有被恢复,导致内核状态受到了影响。
这里我曾单步跟踪过从syscall返回后的汇编指令,主要是观察是否对哪些寄存器设置了值,并根据手册查找相关寄存器的作用,发现在执行ret_to_user
之前,那些寄存器似乎都不会造成什么影响。
至于ret_to_user
,这也是一段汇编指令:
1 | SYM_CODE_START_LOCAL(ret_to_user) |
以及最后针对KPTI的处理,也会在tramp_exit
中完成:
1 | .macro tramp_exit, regsize = 64 |
如果设置ROP的gadget为kernel_exit
中该处指令:
1 | ldp x21, x22, [sp, #S_PC] // load ELR, SPSR |
那么在栈上布置好必要的寄存器,如SP
,PC
,PSTATE
似乎完全没有任何问题。
execve到底有什么问题
单步调试
于是我选择了一个很蠢的方法,就是单步调试execve
系统调用,看看到底是哪一步出现了问题。
1 | static int do_execve(struct filename *filename, |
我本以为在bprm_execve
中,exec_binprm
会失败,然后goto out
,终止当前用户态进程,报错SIGSEGV。
然而实际上,它竟然正常返回了,我依然单步跟踪到了最后tramp_exit
中的eret
,却发现ELR_EL1
以及SPSR_EL1
都是0,也就是说程序Segmenation Fault并不是因为execve
系统调用本身发生了什么问题,而是从系统调用中返回时,返回到用户的地址为0。
为什么ELR_EL1会为0
显然在正常情况下,用户通过系统调用陷入内核,之后再返回用户态不会出现这样的问题,于是我尝试设置硬件断点,在进入svc 0
,内核执行完在栈上保存用户GPRs的逻辑后,在PC
所在的位置上设置硬件断点。
结果发现内核在某个位置通过memset
将这段内核栈数据清0了,而memset
的长度参数为0x150。
由于调试过程中,没法通过backtrace获取到调用栈,于是选择通过上层函数的地址,去/proc/kallsyms
符号表里搜,发现来自于load_elf_binary
,其调用关系为:
1 | do_execve |
所以这里memset
是在初始化进程的寄存器,但是为什么这些寄存器在栈上?
继续追踪这里regs
来源:
1 | regs = current_pt_regs(); |
即内核栈大小为16KB,而pt_regs
所在的位置就是栈顶0x150 bytes的空间,而通过svc 0
陷入内核中时,用户态的寄存器信息保存的位置却与其稍有偏差,即位于栈顶下方一定偏移处。
根本原因分析
内核栈偏差
从正常角度理解execve
的逻辑,其将内核栈上的pt_regs
初始化为0,后续再设置相应的PC
和SP
等关键寄存器,最后从内核eret
返回时,就会跳转到正确的PC
上执行。
然而这里由于在进行内核ROP时,破坏了内核栈,使得SP
不再指向内核栈顶所在位置,在进行后续的系统调用时,内核保存用户态的寄存器不再正好位于内核栈顶的pt_regs
的位置;而execve
在初始化寄存器的时候,仍然是通过栈顶的pt_regs
进行设置,因此在内核栈没被破坏的正常情况下,内核返回时会从栈顶的pt_regs
获取保存的用户态的寄存器信息。
因此,之所以调用execve
会Segmentaion Fault,是因为初始化寄存器时将fake pt_regs
中的PC
,PSTATE
等关键寄存器值清0了;设置相应的用户态PC
,PSTATE
等关键寄存器时又没有写在fake pt_regs
上,导致从内核返回时尝试执行用户态0地址位置的代码。
如果继续调试可以发现,程序在还没有进行内核ROP之前,如果进入内核态,最开始的SP
值都是一致的;内核ROP提权之后,SP
的值就发生了变化,出现了一定的偏移,所以内核栈位置很可能是在eret
的时候自动保存在某个地方,从而下次再进入内核时直接恢复。(这里仅是猜想)
为什么x86下不需要考虑这种问题
简单看了下x86-64的内核入口代码,发现内核态rsp
都需要从PER_CPU_VAR(cpu_current_top_of_stack)
获取一遍,因此每次进入内核时rsp
都能保证正确:
1 | SYM_CODE_START(entry_SYSCALL_64) |
回到题目
那么现在我想在内核ROP提权后,返回用户态开启shell就很简单了:只要在执行完commit_creds(prepare_kernel_cred(0))
之后,再调整一下栈的位置,使其平衡到正常状态再返回用户态即可:
1 |
|
补充一下
在分析过程中自己其实很多细节方面的原理也没有搞得很清楚,可能也存在不正确的地方,如果有发现问题,希望能够批评指正。