kernote 复现
该题目的文件系统是 ext4
(不是常规 kernel pwn 使用的 ramfs
),我们可以使用 mount
命令将其挂载以查看并修改其内容
1 | mkdir rootfs |
读取信息:
1 | bzImage: Linux kernel x86 boot executable bzImage, version 5.11.9 (yzloser@yzloser-rubbish) #2 SMP Wed Sep 22 23:03:52 CST 2021, RO-rootFS, swap_dev 0x9, Normal VGA |
- version 5.11.9
1 | !/bin/sh |
- smep,smap,kaslr,KPTI
1 | !/bin/sh |
/proc/sys/kernel/dmesg_restrict
/proc/sys/kernel/kptr_restrict
漏洞分析
1 | if ( (_DWORD)cmd == 0x6668 ) // FREE_NOTE |
- 在内核内存释放时,只置空了全局变量 buf,而没有置空 note
1 | if ( (_DWORD)cmd == 0x6669 ) // EDIT_NOTE |
- 允许向全局变量 note 中写入数据
入侵思路
有 UAF,申请的大小为 0x8 字节,在 Slub 中是 kmalloc-8
但本题目没有使用常规的 Slub,而是 Slab:
1 | CONFIG_SLAB=y |
- Random Freelist:slab 的 freelist 会进行一定的随机化
- Hardened Freelist:slab 的 freelist 中的 object 的 next 指针会与一个 cookie 进行异或
- Hardened Usercopy:在向内核拷贝数据时会进行检查
- 地址是否存在
- 是否在堆栈中
- 是否为 slab 中 object
- 是否非内核
.text
段内地址
- Static Usermodehelper Path:
modprobe_path
为只读,不可修改
在 Slab 中的最小 object 为32字节,因此本程序会申请 kmalloc-32,于是选择使用 ldt_struct + modify_ldt
来泄露内核基地址(在之前的博客中提到过)
1 | SYSCALL_DEFINE3(modify_ldt, int , func , void __user * , ptr , |
- 这里需要注意
read_ldt
和write_ldt
函数:
1 | static int write_ldt(void __user *ptr, unsigned long bytecount, int oldmode) |
- 在
write_ldt
中会调用alloc_ldt_struct
,然后根据用户输入的大小来重新分配ldt_struct
结构体
1 | static int read_ldt(void __user *ptr, unsigned long bytecount) |
- 在
read_ldt
会调用copy_to_user
将ldt_struct->entries
中的数据返回给用户态
泄露的思路如下:
- 申请并释放一个
object
- 使用
write_ldt
中的alloc_ldt_struct
函数复用这个object
- 使用 UAF 修改
object->entries
- 使用
read_ldt
将object->entries
输送给用户态
由于在通常情况下内核会开启 hardened usercopy
保护,当 copy_to_user
的源地址为内核 .text
段(包括 _stext
和 _etext
)时会引起 kernel panic
我们只能先爆破线性映射区 direct mapping area
(kmalloc 使用的空间),然后通过 read_ldt
在堆上读取一些可利用的内核指针并泄露内核基地址
大致模板如下:
如果直接搜索整个线性映射区域,很有可能触发 hardened usercopy 的检查(目前不知道原因)
可以通过 fork
函数来绕过该检查,原理如下:
1 | sys_fork() |
1 | int ldt_dup_context(struct mm_struct *old_mm, struct mm_struct *mm) |
- 在这里会通过
memcpy
将父进程的ldt->entries
拷贝给子进程,是完全处在内核中的操作,因此不会触发 hardened usercopy 的检查 - 只需要在父进程中设定好搜索的地址之后再开子进程来用
read_ldt
读取数据即可
接下来有两种提权思路:
- 使用
seq_operations + pt_regs
绕过 KPTI - 在
write_ldt
中进行条件竞争,使用 Double fetch 修改进程 uid 完成提权
seq_operations + pt_regs
结构体 seq_operations
的条目如下:
1 | struct seq_operations { |
- 打开
/proc/self/stat
文件就可以分配一个seq_operations
- 对其进行 Read 即可触发
seq_operations->start
结构体 pt_regs
的条目如下:
1 | struct pt_regs { |
- 当执行
entry_SYSCALL_64
时会将所有的寄存器压入内核栈上,形成一个pt_regs
结构体 - 在系统调用的过程
r8 ~ r15
其实是用不上的,可以在这里放置 ROP 链 - 由于开了 KPTI,则需要在 ROP 末尾放上
swapgs_restore_regs_and_return_to_usermode + offset
用于在回到用户态前修改 CR4 寄存器 - PS:这个
offset
在不同的内核版本中有所不同,可以通过调试来确定其值
测试代码如下:
1 | __asm__( |
GDB 打印内存如下:
1 | pwndbg> telescope 0xffffa950001b3f68 |
- 在合适和位置放置合适的 gadget 就好了
完整 exp 如下:
1 |
|
ldt_struct + modify_ldt + 条件竞争
利用条件竞争可以在 write_ldt
中实现任意写:
1 | static int write_ldt(void __user *ptr, unsigned long bytecount, int oldmode) |
- 基础的逻辑为:
- 新申请一个
ldt_struct
- 执行
memcpy
把旧的ldt_struct
数据拷贝到新的ldt_struct
中
- 新申请一个
- 注意最后一句
new_ldt->entries[ldt_info.entry_number] = ldt
ldt
是我们写入的数据
- 通过条件竞争的方式在
memcpy
过程中将new_ldt->entries
更改为我们的目标地址从而完成任意地址写,即 Double Fetch
使用条件竞争通常都是修改 task_struct
结构体:
1 | struct task_struct { |
我们可以用和爆破内核基地址类似的方式来爆破 task_struct
:
- 字段
comm[TASK_COMM_LEN]
存放着该进程的名字 - 使用
prctl(PR_SET_NAME, "name")
可以修改comm[TASK_COMM_LEN]
字段 - 使用
memmem
可以在一块内存中寻找匹配另一块内存的内容的第一个位置
参考爆破脚本如下:
1 | while(1) |
在我们获得了 cred
的地址之后,我们只需要将 cred->euid
更改为 0 就能拥有 root 权限,之后再调用 setreuid
等一系列函数完成全面的提权,详细步骤如下:
- 开启一个子进程(为了不触发
hardened usercopy
) - 在子进程中再开启一个子进程,申请多个 note 并释放(为了后续的
ldt_struct
可以命中 UAF 堆块) - 并不断往全局变量 note 写入
cred_addr+4
(为了修改new_ldt->entries
为cred_addr+4
) - 在父进程中调用
write_ldt
在满足如下两个条件时,就可以成功提权:
- 在
alloc_ldt_struct
执行之后,生成的ldt_struct
成功命中 UAF 堆块 - 在
new_ldt->entries[ldt_info.entry_number] = ldt
执行之前,成功写入cred_addr+4
最后条件竞争的过程有点抽象,我尝试了 30~40 遍才成功一次
- 为了提高利用的成功率,可以使用
sched_setaffinity
将相应的进程绑定到单个 CPU 上(在 run.sh 中定义了两个核)
完整 exp 如下:
1 |
|
小结:
感谢 arttnba3 大佬的博客:TCTF2021-FINAL 两道 kernel pwn 题解 - arttnba3’s blog
学习到了 ldt_struct + modify_ldt
通过条件竞争进行的 WAA(虽然成功率有点低)
通过调试已经基本掌握了 seq_operations + pt_regs
这种利用手法