题目来源于WCTF 2018,shellphish出的一道比较简单的内核题目,苦于比赛时并不会内核,到今天才重新拿出来复现,附件及题目下载。
题目分析
题目模仿磁盘文件机制,可以自由申请、释放、读写堆块。提供了read、write、ioctl三个功能。
其堆块数据结构如下,其中isuse位是一个标志位,使用原子操作对其加减,每次操作前加一,操作后减一,当该位为0时,调用kfree释放。size为存放的是其大小,fd是下一个item的指针,后续是其实际内容:
1 | 00000000 struct_item struc ; (sizeof=0x20, mappedto_3) |
ioctl
在ioctl里实现了4个功能
add_item
可以看到,从copy_from_user可看出,其输入的参数是一个结构体,结构体有两项一个是size,一个是数据buf指针。然后程序会按照请求的size+0x18,kmalloc申请内存,并复制过去,将内存挂在单链表上,并将isuse位置1。
1 | signed __int64 __fastcall add_item(add_opt *a1) |
select_item
select_item函数也很简单,遍历查找第a2个内存块,然后对该块做get操作,放入(a1 + 200)位置,并对原来的堆块做puts操作。
1 | signed __int64 __fastcall select_item(__int64 a1, __int64 a2) |
可以看到get操作是一个原子性的加法操作
1 | void __fastcall get(volatile signed __int32 *a1) |
而put操作是一个原子性的减法,当减为0时,把这块free掉。
1 | __int64 __fastcall put(volatile signed __int32 *a1) |
remove_item
remove_item也是根据用户输入的a1,顺序查找链表,当找到后从单链表上摘除,并对其做一个put操作,并不是直接的free,这是为了防止用select_item选择时,将其放到a1+200中,而造成的UAF。
1 | signed __int64 __fastcall remove_item(__int64 a1) |
list_head
而最后list_head中,会将第一块内存中的数据返回给用户。注意此时在copy_to_user分别调用了get和put函数,标识该块正在被操作。
1 | unsigned __int64 __fastcall list_head(__int64 a1) |
write & read
write和read就比较简单了,都是判断内存块中的size是否小于用户请求的大小,如果否就返回给用户数据内容。
而读取的位置正是a1+200,也就明白了,之前select_item的功能是选择read和write函数所对应操作的内存块。
1 | unsigned __int64 __fastcall list_read(__int64 a1, __int64 a2, unsigned __int64 a3) |
1 | unsigned __int64 __fastcall list_write(__int64 a1, __int64 buf, unsigned __int64 size) |
漏洞分析及利用
感觉逻辑挺严谨得的,好像并没有什么问题。不过这是站在单线程的考虑,我们看一下启动项run.sh
1 |
|
发现是有两个内核、两个线程的,这样是满足内核竞争的条件的。让我们再来以多线程的角度来看看这道题。
好像发现了一点问题,在list_head函数中,put函数的操作直接是g_list->isuse,但是如果第一块不是之前打印的块呢?如果是一个新块,是否就错杀了好人?看一下是否可行
1 | unsigned __int64 __fastcall list_head(__int64 a1) |
可以发现&list_lock这个锁,在[2]处就释放了,而put操作在[3]处。
而add_item中仅要求能获得这个锁就可以插入了[4]、[5],如此看来是完全可以做到的,利用线程间add_item和list_head竞争,可以让第一个堆块变成一个已经释放的堆块(即在一个线程的执行在[2]、[3]之间时,恰好另一个进程进入[4])。
这样我们就通过竞争条件拿到了一个悬垂指针,而且悬垂指针的大小是任意控制的。
但是拿到这样一个指针并不好弄,针对于之前做过的题目,比如CISCN的babydriver里面的两种做法,第一种是利用申请和cred大小相同的块来UAF另一个线程的cred结构体,从而提权,但在此题行不通。
此题申请cred结构体用的是这个slab中的cred_jar内容:
1 | struct cred *prepare_creds(void) |
而kmalloc会根据size的大小,转换为kmalloc-xx中去申请堆块,虽然二者大小是相同的,但是存在完全的隔离。不同种类的slab是不可能交叉使用的。
而且,我想清楚这个问题以后,我重新调试了一下babydriver这题,惊讶的发现在/proc/slabinfo里居然没有cred_jar这个slab,而且192(cred大小0xa8对齐成 0xc0)的slab只有kmalloc-192、dentry、dma-kmalloc-192,而dentry是目录节点显然不是cred,这就很懵逼…
1 | / # cat /proc/slabinfo|grep jar |
在prepare_creds这个函数下断点,惊讶的发现cred的分配用的就是kmalloc-192,不禁怀疑人生,这内核咋编译的,还是有别的操作,所以降低了这题的难度,有了直接UAF cred的操作….
1 | 0xffffffff810a167d mov esi, 0x24000c0 |
然而,用劫持ptmx这种方法也不行,因为缺少地址泄露…
最后一种方法是利用本身的结构,如果我们可以找到一个函数申请到这个堆块,并把size位置的很大,也可以用来越界读写,由于这个堆块也是在映射区里,所以当cred结构体在高地址时,同样是可以覆写的,就类似于zer0fs那题。
不过,内核太大了,调kmalloc或者kmem_cache_alloc(kmalloc-xx)的太多,真的没办法一个一个试出来…
场面一度非常尴尬,调试这堆调用kmalloc函数花了我两天的时间…
卡了好久以后,发现一篇文章《Linux Kernel universal heap spray》,这篇文章竟然说的就是我遇到的这个问题,在竞争条件时如何找一个能精准控制的内容的函数。
我没有使用文章提出的终极解决方案,我用了第一种不是很完善的方案就是do_msgsnd函数,虽然前48字节不可控,但是结构体的第二个参数是指针,要知道内核的地址都是0xfffffffxxxxx之类的,原因大于我们需求的范围,从而可以爆破高地址的内存,如果存在一个cred的话,就可以提权了。
遇到最坑的事儿是,内核里启动了两个uid=1000的进程,一个qemu起的给用户交互这个,另外一个是子进程起EXP这个…
脚本几次都把父进程提权了,子进程提示提权失败… 也是绝望,不过也算是提取成功了吧…
还有就是memmem这个函数,emmmmm,真是略坑。
EXP
1 | #define _GNU_SOURCE /* See feature_test_macros(7) */ |
参考
https://cyseclabs.com/blog/linux-kernel-heap-spray
https://blog.csdn.net/vanbreaker/article/details/7694648