userfaultfd 与 setxattr 在条件竞争中的利用练习
通过两个 kernel ctf 题目来理解userfaultfd
以及setxattr
的用法吧!
概念梳理
userfaultfd系统调用
userfaultfd是linux的一个系统调用(无libc封装函数),它的出现(Linux 4.3)给用户态提供了缺页处理的能力。userfaultfd系统调用返回给用户态进程的是一个用于处理page faults的文件描述符。
userfaultfd(2) - Linux manual page
ioctl_userfaultfd(2) — Linux manual page
SYSCALL_DEFINE1(userfaultfd, int, flags)
从内核到用户空间(1) — 用户态缺页处理机制 userfaultfd 的使用
使用userfaultfd
一个利用userfaultfd让用户态来处理缺页异常的例子(来自man userfaultfd
):
通过userfaultfd系统调用创建userfaultfd object
1
2long uffd;
uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);用户态进程需要先通过UFFD_API这个ioctl命令,使能userfaultfd
1
2
3
4struct uffdio_api uffdio_api;
uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
ioctl(uffd, UFFDIO_API, &uffdio_api);用户态进程通过UFFDIO_REGISTER这个ioctl命令,注册设置内存地址范围
1
2
3
4
5
6
7
8
9char *addr;
struct uffdio_register uffdio_register;
addr = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
uffdio_register.range.start = (unsigned long) addr;
uffdio_register.range.len = len;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
ioctl(uffd, UFFDIO_REGISTER, &uffdio_register;最后用户态进程可使用UFFDIO_COPY或UFFDIO_ZEROPAGE两个ioctl命令来处理缺页异常。
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
27s = pthread_create(&thr, NULL, fault_handler_thread, (void *) uffd);
// fault_handler_thread()函数定义如下,梳理主要代码流程
static void *fault_handler_thread(void *arg){
// 1) 创建一个page,用于缺页处理
page = mmap(NULL, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 2) 等待uffd事件到来
pollfd.fd = uffd;
pollfd.events = POLLIN;
poll(&pollfd, 1, -1);
// 3) 从userfaultfd中读取事件信息,确认是否是缺页错误事件
read(uffd, &msg, sizeof(msg));
if (msg.event != UFFD_EVENT_PAGEFAULT) {
fprintf(stderr, "Unexpected event on userfaultfd\n");
exit(EXIT_FAILURE);
}
// 4) 将准备好的数据页内容拷贝到userfaultfd注册的空间内
uffdio_copy.src = (unsigned long) page;
uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address & ~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
ioctl(uffd, UFFDIO_COPY, &uffdio_copy)
}
用户线程的处理过程是我们可控的,通过延长用户态处理时间,该系统调用可以帮助我们提高内核条件竞争成功的概率。
防护
userfaultfd系统调用从linux4.3开始引入,刚开始的几个版本中,对该系统调用无任何限制,所有用户均可调用。
linux 5.2开始,在内核中增加了一个开关,通过sysctl_unprivileged_userfaultfd
来控制是否允许用非特权用户使用userfaultfd功能(默认情况下设置为0,不允许非特权用户使用)。
1 | if (!sysctl_unprivileged_userfaultfd && !capable(CAP_SYS_PTRACE)) |
linux 5.11开始,又增加了UFFD_USER_MODE_ONLY
标志位,决定了userfaultfd是否仅处理用户空间的page fault。Blocking userfaultfd() kernel-fault handling 文章中指出,新版本内核中默认禁止非特权进程使用userfaultfd,非特权进程只能通过设置sysctl_unprivileged_userfaultfd
从而安全地调用userfaultfd(只能处理用户态的缺页错误,启用UFFD_USER_MODE_ONLY
标志着userfaultfd不允许在内核态使用)。
1 | if (!sysctl_unprivileged_userfaultfd && |
那么,在做题过程中,如下两条命令中任何一条为1,大概率表明这个题可以使用userfaultfd。而真实系统上,还要确认UFFD_USER_MODE_ONLY
没开。
1 | # 查看内核是否支持userfaultd,出现`CONFIG_USERFAULTFD=y`表示支持 |
感谢chatgpt😝
可以通过编译下面这段代码,确认能否使用userfaultfd。(出现[+] in usefaultfd handler, i will sleep 60s\n"
字符串打印,表明可以使用)
1 | // gcc test.c -lpthread -o test |
CTF利用模板
1 | void ErrExit(char* err_msg) |
使用方法:
1 | char *addr = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); |
setxattr系统调用
setxattr(2) — Linux manual page
Linux Kernel universal heap spray
setxattr
是一个linux系统调用,同时在libc中也有同名的封装函数。通过setxattr()
可以给系统中inode(文件,目录,符号链接)设置扩展属性(name:value对)。使用示例:
1 | char* value = "someting..." |
对应到内核调用路径:
内核setxattr()
函数中的主要处理过程如下,根据用户态传入的size申请对应大小(0<size<65535)的内存空间,并将用户态数据拷贝到该空间中,退出时会释放该内存空间。
1 | static long |
漏洞利用过程中有两种使用方法:
- 结合userfaultfd,跨页面拷贝时让进程停在
copy_from_user
处。对UAF堆,相比其他结构体,setxattr可覆盖前8字节内容 - 纯粹往堆上喷数据
题目1 - 强网杯2021 notebook
从强网杯 2021 线上赛题目 notebook 中浅析 userfaultfd 在 kernel pwn 中的利用
做这个题前,首先要了解读写锁的原理:
- 当写锁被取走时,所有取锁操作被阻塞
- 当读锁被取走时,取写锁的操作被阻塞
分析
题目附件:QWB2021-notebook.zip
先看notebook.ko
的逻辑:
- noteadd(0x100):为notebook[idx]->note申请一片size大小的空间
- notegift(0x64):将notebook处存放的note和size信息(256个字节)全部拷贝给用户态(信息泄露 - 泄露堆地址)
- notedel(0x200):free掉notebook[idx]->note对应的空间,在size不为0的情况下,清空bss段上的内容
- noteedit(0x300):修改notebook[idx]->note和size,逻辑有问题导致size变任意大小,并申请任意大小的堆。(这里存在条件竞争,可导致UAF)
- mynote_read:根据传入的idx,将notebook[idx]->note内容读出。有
_check_object_size()
检查,堆和大小匹配时才能正确读出,防止堆的越界读写。 - mynote_write:根据传入的idx,更新notebook[idx]->note的内容。也有
_check_object_size()
检查。
然后看一下锁的情况,noteadd()
和noteedit()
中都有读锁,notedel()
中有写锁,其他函数未使用锁。写锁设置后,读锁将被阻塞,所以notedel()
无法条件竞争。而noteadd()
和noteedit()
设置了读锁,却都对notebook[idx].size有写的操作,因此条件竞争可以从它们当中构造。
noteedit()
函数漏洞的关键点如下:
1 | v5 = ¬ebook[idx]; |
noteadd()
函数漏洞的关键点如下:
1 | v4 = v3; |
至此,分析的结果如下:
- noteadd一个内核堆块heap1
- 【新线程1】noteedit传入一个大size,使heap1被free,并通过userfaultfd使流程在copy_from_user()时停住。此时,heap1的地址还在notebook[index].note上,于是UAF就产生了。
- 【新线程2】noteadd将heap1对应的size重新改成原来的大小,并在copy_from_user()时停住。不然mynote_read和mynote_write无法正常执行。
- 在主线程中,就可以自由地对已经free掉的heap1进行自由读写操作了!
如下图所示:
接下来就看怎么利用这个UAF读写。
利用
利用思路:
- 最早noteadd时指定0x20大小
- 当UAF产生并能自由读写后,使用seq_operations结构体占用heap1。个人认为这个结构体比较好用,无需另外构造函数指针列表
- 先通过mynote_read泄露
seq_operations->start
函数指针 - 再通过mynote_write改写
seq_operations->start
函数指针,实现控制流劫持 - 控制流劫持后,利用内核ROP(notegift泄露堆地址)栈迁移到堆,继续ROP利用
这里列出四种get root shell的方式
modprobe_path - exp1
1)使用prepare()模板,做好准备工作(
/tmp/x
,/tmp/dummy
,/bin/umount
),fork()子进程2)子进程将modprobe_path改成
/tmp/x
3)父进程等待一段时间后,执行
/tmp/dummy
,回shell。4)再exit即可获得root shell
KPTI trampoline - exp2
1)执行commit_creds(prepare_cred(0))
2)利用swapgs_restore_regs_and_return_to_usermode()设置CR3,安全返回用户态(劫持到一系列pop后的第一个mov指令开始即可)
3)getshell被执行
signal handler - exp3
1)用户态注册signal(SIGSEGV,getshell)
2)执行commit_creds(prepare_cred(0))
3)swapgs和iretq返回用户态时触发SIGSEGV
4)getshell被执行
tty_struct + work_for_cpu_fn() - exp4
1)先构造一个0x3a8(kmalloc-1024)大小的UAF堆块heap1
2)通过
open("dev/ptmx",2);
取回heap1,于是我们就可以控制tty_struct的内容2)再构造一个任意大小(0x100)的堆块heap2,用来存放构造的tty_operations,更改ioctl函数指针
3)读取heap1的内容,更改0x18偏移处指向heap2。更改0x20(prepare_kernel_creds)和0x28(参数0)处的值,并保存0x30(ldisc_sem)处的值为old_value。最后将以上内容写回heap1。
4)调用ioctl,内核将执行prepare_kernel_creds(0)
5)读取heap1的内容,返回值pkc_ret在0x30处,更改0x20(commit_creds)和0x28(pkc_value)处的值,并更改0x30(ldisc_sem)处的值为old_value。最后将以上内容写回heap1。
6)调用ioctl,内核将执行commit_creds(pkc_ret)
7)用户态执行system(“/bin/sh”),即可获得root shell
leak heap cookie + modprobe_path - exp5
这个巧妙的利用方法来源:强网杯2021 Writeup by X1cT34m
利用方法的描述参考:从强网杯 Notebook 看内核条件竞争
1
2
3
4
5
6
7
8
9
10该方法不需要内核ROP,巧妙利用了slub的控制字节(freelist 单向链表,类似 fastbin)。
1. 分配两个 0x60 的块,分别为 chunk1, chunk2。
2. 通过 gift 读出 chunk1 和 chunk2 的地址。
3. free (chunk2) , free (chunk1),此时 freelist -> chunk1 -> chunk2
4. 再次分配 chunk1 和 chunk2,通过 gift 确保和上次分配的是同两个块。
5. 此时读出 chunk1 的前 8 字节,这 8 字节应为 cookie ^ chunk1_addr ^ chunk2_addr(详见 Kirin)。这样就能泄露出 cookie。
6. 重新开始,构造UAF,使chunk1被free,但堆地址还在notebook[0]上。
7. 向被 free 掉的 chunk1 中写入构造好的内容。此时我们写入 8 字节 cookie ^ chunk1_addr ^ notebook_addr-0x10 即可将 freelist 链改为 freelist -> chunk1 -> notebook_addr - 0x10。(但此时 freelist 链并不合法)
8. 由于 name 在 bss 上的位置正好在 notebook 的前面,所以可以将 note_addr - 0x10 的地方写为 cookie ^ notebook_addr - 0x10 ^ 0,这样 freelist 链就合法了。
9. 现在 notebook 可控,就能拿到任意地址读写的权力了。通过 notebook.ko 调用内核函数的地方泄露内核基地址,然后改写 modprobe_path 来提权。
exp1
利用modprobe_path改umount,exit退出系统时获得root shell
1 |
|
exp2
commit_creds(prepare_cred(0))执行完成后,ROP链执行swapgs_restore_regs_and_return_to_usermode(), 提前设置好返回用户态的rip,获得root shell
1 |
|
exp3
用户态注册signal handler, 获得root shell
1 |
|
exp4
利用work_for_cpu_fn()函数,劫持tty_struct->ops->ioctl,分两次执行commit_cred(prepare_kernel_creds(0)),最后在用户态执行system(“/bin/sh”)获得root shell
注意点:
只能用ioctl,不能用write
写tty_struct和file_operations要先将内容读出,改掉目标位置后,再将内容全部写回。
1 |
|
exp5
内核地址泄露:利用ko泄露(程序运行过程中,e8指令后四个字节是相对偏移量,被调用函数实际段偏移量 = 下一条指令地址 + 相对偏移量)带你弄懂 call 指令调用方式
任意地址读写:泄露堆地cookie,构造heap链:freelist -> chunk1 -> notebook_addr - 0x10
。
1 |
|
问题
问题1:/init的输出无法显示是什么原因?
为什么这个题,使用modprobe_path方法改了umount,但exit后不能获得root shell???因为init脚本中,没有下面这三行:
1
2
3exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console导致exit退出后,
/dev/console
的内容无法显示给我们。如下图,是在init脚本中增加以上三行代码的结果。Difference between /dev/console, /dev/tty, and /dev/tty0
找到根本原因后,一切就变得简单了。在我们改的umount文件中,
/bin/sh
之前增加这三行代码,就能达到获得root shell的目的啦!1
system("echo '#!/bin/sh\nrm /bin/umount\necho -e \"#!/bin/sh\\nexec 0</dev/console\\nexec 1>/dev/console\\nexec 2>/dev/console\\n/bin/sh\\n\" > /bin/umount\nchmod 777 /bin/umount' > /tmp/x");
问题2:在内核态执行
commit_creds(prepare_kernel_cred(0))
时,gadget不好找,怎么办?对于支持多核的内核中,有一个 work_for_cpu_fn() 函数,其反汇编后的代码如下
1
2
3
4
5
6
7
8
9__int64 __fastcall work_for_cpu_fn(__int64 a1)
{
__int64 result; // rax
_fentry__();
result = (*(__int64 (__fastcall **)(_QWORD))(a1 + 32))(*(_QWORD *)(a1 + 40));
*(_QWORD *)(a1 + 48) = result;
return result;
}只要控制参数a1(RDI)指向内存的内容,即可执行一个参数的任意函数(如
prepare_kernel_cred(0)
),并将返回值写入内存中。
题目2 - SECCON2020 kstack
从 SECCON2020 一道 kernel pwn 看 userfaultfd + setxattr “堆占位”技术
系统开启KASLR,SMEP,KPTI
1 | ➜ kstack cat start.sh |
分析
题目附件:kstack.tar.gz
全局变量head未加锁,在0x57ac001和0x57ac002两个分支中都有使用到它,所以存在条件竞争的可能。
利用
方法1 - exp1:
- 条件竞争泄露信息:如果利用userfaultfd让一个线程卡在copy_from_user,另一个线程执行copy_to_user分支,就能泄露堆上的信息
- 条件竞争产生UAF:copy_to_user线程执行完后,会free掉第一个线程申请的堆,于是产生了悬空指针。
- 结构体占用UAF堆块,完成控制流劫持:如果使用某个结构体占用刚刚释放的堆块,并在此时恢复copy_from_user线程的执行,让*arg覆盖结构体中的函数指针,就可以劫持控制流了。
方法2- exp2:
条件竞争泄露信息:同上
条件竞争构造double free:copy_from_user线程卡住,起第二个线程执行到copy_to_user处卡住,此时第三个线程也进入copy_to_user释放堆块,然后放行第二个线程,会再次释放堆块,于是产生double free
“setxattrr+usefaultfd” 改堆块指针,达到任意地址写:同libc
方法3 - exp3:
- 条件竞争泄露信息:同上
- 条件竞争构造double free:同上
- 结构体占用double free堆块,”setxattrr+usefaultfd” 改结构体函数指针,完成控制流劫持:某种程度上,可以看作方法1和方法2的结合。double free后,先malloc并用seq_operations占用UAF堆块,再malloc一次(还是那个UAF堆块)并使用”setxaddr+userfaultfd”改函数指针,最后通过操作seq_operations->start控制流劫持
exp1
具体做法如下:
- 信息泄露:
CMD_PUSH
分支中,申请的堆块为kmalloc-0x20大小。于是选择先通过open("/proc/self/stat",0);
(申请0x20堆块)和close()
(释放0x20堆块)在堆中喷上函数指针,再利用本题的漏洞,就能从copy_to_user()泄露内核函数地址(single_stop()
)给用户态。 - 控制流劫持:利用seq_operations结构体占用该堆块,并控制copy_from_user的arg地址处的值,那么就能控制
seq_operations->stop
函数指针。 - 提权:利用pt_regs执行ROP gadget写modprobe_path,完成提权。
1 |
|
效果如下:
exp2
通过double free 构造任意地址写,直接写modprobe_path
1 |
|
exp3
跟exp2的区别在如何malloc回double free的堆块,这里先用seq_operations结构体占住堆块,再用”setxattr+userfaultfd”覆盖seq_operations->start指针,完成控制流劫持。最后使用ptregs+ROP修改modprobe_path提权。
1 |
|