CVE-2021-3492

CVE
Heap
Analysis

라온화이트햇 핵심연구팀 조진호

CVE-2021-3492

Summary

Pwn2Own Vancouver 2021에서 Ubuntu Shiftfs driver발생한 double free 취약점. KASLR, SMAP를 우회하여 LPE에 성공했다. Groovy(20.10)와 Focal(20.04) 데스크탑, 서버 모두 해당한다.

shiftfs Driver

취약점은 파일시스템 드라이버인 shiftfs에서 터진다. 이 드라이버의 기능은 파일시스템을 마운트하고 디렉토리에 UID, GID를 마운트 한 소유자의 것으로 바꿔주는 기능을 한다.

static struct file_system_type shiftfs_type = {
	.owner		= THIS_MODULE,
	.name		= "shiftfs",
	.mount		= shiftfs_mount,
	.kill_sb	= kill_anon_super,
	.fs_flags	= FS_USERNS_MOUNT,
};

간략하게 기능을 보자면 fs/shiftfs.c파일에 모든 코드들이 있어 보기 편하다. 먼저 마운트 하게 되면 shiftfs_mount함수가 실행된다.

static struct dentry *shiftfs_mount(struct file_system_type *fs_type,
				    int flags, const char *dev_name, void *data)
{
	struct shiftfs_data d = { data, dev_name };

	return mount_nodev(fs_type, flags, &d, shiftfs_fill_super);
}

그리고 shared memory에 매핑하는 커널 함수인 mount_nodev를 이용해 마운트 한다. shiftfs_fill_super는 리눅스 파일시스템의 루트와 같은 superblock을 파라미터로 가져와 새 가상 파일 시스템을 구현하는 함수로 드라이버에서 구현해야하는 함수이다.

static int shiftfs_fill_super(struct super_block *sb, void *raw_data,
			      int silent)
{
	...

	inode = new_inode(sb);
	if (!inode) {
		err = -ENOMEM;
		goto out_put_path;
	}
	shiftfs_fill_inode(inode, dentry->d_inode->i_ino, S_IFDIR, 0, dentry);

그리고 superblock뒤의 데이터를 읽어서 리눅스에서 사용할 수 있도록 inode로 만든다.

static void shiftfs_fill_inode(struct inode *inode, unsigned long ino,
			       umode_t mode, dev_t dev, struct dentry *dentry)
{
	struct inode *loweri;

	inode->i_ino = ino;
	inode->i_flags |= S_NOCMTIME;

	mode &= S_IFMT;
	inode->i_mode = mode;
	switch (mode & S_IFMT) {
	case S_IFDIR:
		inode->i_op = &shiftfs_dir_inode_operations;
		inode->i_fop = &shiftfs_dir_operations;
		break;
	case S_IFLNK:
		inode->i_op = &shiftfs_symlink_inode_operations;
		break;
	case S_IFREG:
		inode->i_op = &shiftfs_file_inode_operations;
		inode->i_fop = &shiftfs_file_operations;
		inode->i_mapping->a_ops = &shiftfs_aops;
		break;
	default:
		inode->i_op = &shiftfs_special_inode_operations;
		init_special_inode(inode, mode, dev);
		break;
	}

	if (!dentry)
		return;

	loweri = dentry->d_inode;
	if (!loweri->i_op->get_link)
		inode->i_opflags |= IOP_NOFOLLOW;

	shiftfs_copyattr(loweri, inode);
	shiftfs_copyflags(loweri, inode);
	set_nlink(inode, loweri->i_nlink);
}

디렉토리, 심볼릭 링크, 파일 등 타입별로 inode를 만든다.

const struct file_operations shiftfs_file_operations = {
	.open			= shiftfs_open,
	.release		= shiftfs_release,
	.llseek			= shiftfs_file_llseek,
	.read_iter		= shiftfs_read_iter,
	.write_iter		= shiftfs_write_iter,
	.fsync			= shiftfs_fsync,
	.mmap			= shiftfs_mmap,
	.fallocate		= shiftfs_fallocate,
	.fadvise		= shiftfs_fadvise,
	.unlocked_ioctl		= shiftfs_ioctl,
	.compat_ioctl		= shiftfs_compat_ioctl,
	.copy_file_range	= shiftfs_copy_file_range,
	.remap_file_range	= shiftfs_remap_file_range,
};

const struct file_operations shiftfs_dir_operations = {
	.open			= shiftfs_dir_open,
	.release		= shiftfs_dir_release,
	.compat_ioctl		= shiftfs_compat_ioctl,
	.fsync			= shiftfs_fsync,
	.iterate_shared		= shiftfs_iterate_shared,
	.llseek			= shiftfs_dir_llseek,
	.read			= generic_read_dir,
	.unlocked_ioctl		= shiftfs_ioctl,
};

여기서 타입별로 세팅하는 i_op는 ctf문제들에 주로 나오는 디바이스 드라이버들의 fops처럼 사용한다. 여기에 익숙한 ioctl도 있다.

static long shiftfs_ioctl(struct file *file, unsigned int cmd,
			  unsigned long arg)
{
	switch (cmd) {
	case FS_IOC_GETVERSION:
		/* fall through */
	case FS_IOC_GETFLAGS:
		/* fall through */
	case FS_IOC_SETFLAGS:
		break;
	default:
		if (!in_ioctl_whitelist(cmd, arg) ||
		    !shiftfs_passthrough_ioctls(file->f_path.dentry->d_sb->s_fs_info))
			return -ENOTTY;
	}

	return shiftfs_real_ioctl(file, cmd, arg);
}

디렉토리와 파일 모두 같은 shiftfs_real_ioctl함수를 사용한다.

static bool in_ioctl_whitelist(int flag, unsigned long arg)
{
	void __user *argp = (void __user *)arg;
	u64 flags = 0;

	switch (flag) {
	case BTRFS_IOC_FS_INFO:
		return true;
	case BTRFS_IOC_SNAP_CREATE:
		return true;
	case BTRFS_IOC_SNAP_CREATE_V2:
		return true;
	case BTRFS_IOC_SUBVOL_CREATE:
		return true;
	case BTRFS_IOC_SUBVOL_CREATE_V2:
		return true;
	case BTRFS_IOC_SUBVOL_GETFLAGS:
		return true;
	case BTRFS_IOC_SUBVOL_SETFLAGS:
		if (copy_from_user(&flags, argp, sizeof(flags)))
			return false;

		if (flags & ~BTRFS_SUBVOL_RDONLY)
			return false;

		return true;
	case BTRFS_IOC_SNAP_DESTROY:
		return true;
	}

	return false;
}

사용하기 전에 in_ioctl_whitelist함수에서 cmd를 체크한다. 그리고 shiftfs_real_ioctl를 사용한다.

Vulnerability

먼저 바로위의 in_ioctl_whitelist에서 필터링된 cmd를 거치고 shiftfs_real_ioctl로 들어간다.

static long shiftfs_real_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
    int newfd = -EBADF;
    long err = 0, ret = 0;
    void __user *argp = (void __user *)arg;
    struct btrfs_ioctl_vol_args *btrfs_v1 = NULL;
    struct btrfs_ioctl_vol_args_v2 *btrfs_v2 = NULL;

    ret = shiftfs_btrfs_ioctl_fd_replace(cmd, argp, &btrfs_v1, &btrfs_v2, &newfd);
    if (ret < 0)
        return ret;

    // here wrapper to vfs_ioctl()
    // ...

    err = shiftfs_btrfs_ioctl_fd_restore(cmd, newfd, argp, btrfs_v1, btrfs_v2);
    if (!ret)
        ret = err;

    return ret;
}

그리고 필터링 된 cmd를 파라미터로 shiftfs_btrfs_ioctl_fd_replace함수로 들어가게 된다. 그리고 shiftfs_btrfs_ioctl_fd_restore함수를 마지막에 호출한다.

static int shiftfs_btrfs_ioctl_fd_replace(int cmd, void __user *arg,
					  struct btrfs_ioctl_vol_args **b1,
					  struct btrfs_ioctl_vol_args_v2 **b2,
					  int *newfd)
{
	struct btrfs_ioctl_vol_args *v1 = NULL;
	...

	if (cmd == BTRFS_IOC_SNAP_CREATE) {
		v1 = memdup_user(arg, sizeof(*v1));
		if (IS_ERR(v1))
			return PTR_ERR(v1);
		oldfd = v1->fd;
	} else {

	...

	if (cmd == BTRFS_IOC_SNAP_CREATE) {
		v1->fd = *newfd;
		ret = copy_to_user(arg, v1, sizeof(*v1));
		v1->fd = oldfd;
	} else {

	...

	if (ret)
		shiftfs_btrfs_ioctl_fd_restore(cmd, *newfd, arg, v1, v2);

	return ret;
}

cmdBTRFS_IOC_SNAP_CREATE일때 유저스페이스의 구조체를 커널에 복사한다. 이 작업은 btrfs_ioctl_vol_args에 포함된 fd를 맨 아래 파일 시스템의 inodefd로 바꾸는 것이다.

그리고 fd를 복사하는데 copy_to_user를 사용하는데 여기서 copy_to_user의 리턴값은 복사하고 남은 byte수이다.

Description
Copy data from kernel space to user space. Caller must check the specified block with access_ok before calling this function.

Returns number of bytes that could not be copied. On success, this will be zero.

성공할 경우에 0을 리턴하고 나머지 복사되지 않은 바이트를 출력. 그러므로 남은 데이터가 있으면 shiftfs_btrfs_ioctl_fd_restore를 호출한다.

static int shiftfs_btrfs_ioctl_fd_restore(int cmd, int fd, void __user *arg,
					  struct btrfs_ioctl_vol_args *v1,
					  struct btrfs_ioctl_vol_args_v2 *v2)
{
	int ret;

	if (!is_btrfs_snap_ioctl(cmd))
		return 0;

	if (cmd == BTRFS_IOC_SNAP_CREATE)
		ret = copy_to_user(arg, v1, sizeof(*v1));
	else
		ret = copy_to_user(arg, v2, sizeof(*v2));

	__close_fd(current->files, fd);
	kfree(v1);
	kfree(v2);

	return ret;
}

그리고 shiftfs_real_ioctl에서 호출하는 shiftfs_btrfs_ioctl_fd_restore함수를 여기서도 마지막에 호출한다. 이 함수는 커널에서 다시 유저의 영역으로 복사해준다.

#define BTRFS_PATH_NAME_MAX 4087
struct btrfs_ioctl_vol_args {
    __s64 fd;
    char name[BTRFS_PATH_NAME_MAX + 1];
};

복사하는 크기는 한 페이지고 4096 byte다.

여기서 가정은 shiftfs_btrfs_ioctl_fd_restore함수를 두번 실행하게 되는 경우이다. 만약 이 함수를 동시에 두개 실행하게 된다면 같은 fd가 닫힌다.(__close_fd(current->files, fd);) 그리고 같은 구조체를 두번 free하게 된다. (kfree(v1); kfree(v2);)

v1 = memdup_user(arg, sizeof(*v1));

v1 또는 v2의 할당은 shiftfs_btrfs_ioctl_fd_replace에서 memdup_user함수로 한다.

/**
 * memdup_user - duplicate memory region from user space
 *
 * @src: source address in user space
 * @len: number of bytes to copy
 *
 * Return: an ERR_PTR() on failure.  Result is physically
 * contiguous, to be freed by kfree().
 */
void *memdup_user(const void __user *src, size_t len)
{
	void *p;

	p = kmalloc_track_caller(len, GFP_USER | __GFP_NOWARN);
	if (!p)
		return ERR_PTR(-ENOMEM);

	if (copy_from_user(p, src, len)) {
		kfree(p);
		return ERR_PTR(-EFAULT);
	}

	return p;
}
EXPORT_SYMBOL(memdup_user);

이 커널함수 memdup_userkmallocGFP_USER플래그로 할당하는 함수이다. GFP_KERNEL과 같은 slabs을 사용한다.

/assets/2021-08-01/3492_1.png

만약에 shiftfs_btrfs_ioctl_fd_replace에서 copy_to_user가 실패하여 ret변수에 copy_to_user의 리턴값으로 나머지 값이 들어가서 shiftfs_btrfs_ioctl_fd_replace에서 shiftfs_btrfs_ioctl_fd_restore로 들어가게 된다면 이런 그림이 된다. v1 또는 v2값을 두번 해제하게 된다. 또한 할당한 것을 해제 안하는 것도 가능하다 (예를 들면 스프레이에 사용하는)

Exploit

double free버그는 두개의 해제 사이에 들어갈 할당과 컨트롤 할 수 있는 힙 청크가 있어야한다. 이런 경우에 userfaultfd를 이용해 사이에서 사용자가 할당하는 방법을 커널익스에서 많이 사용한다.

userfaultfd로 첫번째 할당이 끝난 뒤 free된 청크에 같은 사이즈(4096 byte)로 컨트롤 할 수 있는 커널 힙 청크를 할당하고 실행하면 두번째 free에서 내가 할당한 객체를 free시킬 것이다. 이 방법으로 익스를 진행한다.

/assets/2021-08-01/3492_2.png

익스플로잇 코드에서는 스레드를 이용해 스레드1에서는 copy_to_user에서 userfaultfd로 세팅한 메모리를 지정하고 해당 메모리로 커널이 접근하게 한다. 스레드2에서는 그 에러를 처리하며 힙 청크를 할당한다.

이제 어떤 컨트롤 가능한 힙 청크를 할당할지 정해야한다. 익스플로잇에서는 sysctl이라는 커널 파라미터를 변경하기 위한 기능을 이용해 aar/aaw를 만들었다.

static struct devinet_sysctl_table {
	struct ctl_table_header *sysctl_header;
	struct ctl_table devinet_vars[__IPV4_DEVCONF_MAX];
} devinet_sysctl = {
	.devinet_vars = {
		DEVINET_SYSCTL_COMPLEX_ENTRY(FORWARDING, "forwarding",
					     devinet_sysctl_forward),
		DEVINET_SYSCTL_RO_ENTRY(MC_FORWARDING, "mc_forwarding"),
		DEVINET_SYSCTL_RW_ENTRY(BC_FORWARDING, "bc_forwarding"),
    ...

net/ipv4/devinet.cdevinet_sysctl_table이라는 구조체가 있다.

static int __devinet_sysctl_register(struct net *net, char *dev_name,
						int ifindex, struct ipv4_devconf *p)
	{
		int i;
		struct devinet_sysctl_table *t;
		char path[sizeof("net/ipv4/conf/") + IFNAMSIZ];

		t = kmemdup(&devinet_sysctl, sizeof(*t), GFP_KERNEL); // <--- Allocate

net/ipv4/conf에 등록되는 sysctl이다. 여기에서 힙 할당을 하게된다.

bash-1912    [002] ....  5515.306551: kmalloc: call_site=__devinet_sysctl_register+0x47/0x110 ptr=00000000552c19f5 bytes_req=2120 bytes_alloc=4096 gfp_flags=GFP_KERNEL
bash-1912    [002] ....  5515.306595: kmalloc: call_site=__devinet_sysctl_register+0x47/0x110 ptr=000000007dd47f0d bytes_req=2120 bytes_alloc=4096 gfp_flags=GFP_KERNEL
bash-1912    [002] ....  5515.306742: kmalloc: call_site=mr_table_alloc+0x42/0x100 ptr=00000000598ab799 bytes_req=3608 bytes_alloc=4096 gfp_flags=GFP_KERNEL|__GFP_ZERO
bash-1912    [002] ....  5515.306766: kmalloc: call_site=ipv4_mib_init_net+0xf4/0x1a0 ptr=00000000d0d71277 bytes_req=4096 bytes_alloc=4096 gfp_flags=GFP_KERNEL|__GFP_ZERO
bash-1912    [002] ....  5515.306811: kmalloc: call_site=__register_sysctl_table+0x50/0x1e0 ptr=000000001ae330cc bytes_req=3216 bytes_alloc=4096 gfp_flags=GFP_KERNEL|__GFP_ZERO
bash-1912    [002] ....  5515.306922: kmalloc: call_site=ipv6_init_mibs+0xb2/0x110 ptr=00000000dd7d83c1 bytes_req=4096 bytes_alloc=4096 gfp_flags=GFP_KERNEL|__GFP_ZERO

할당되는 이 구조체는 네트워크 네임스페이스를 생성하면 할당한다. 이 구조체에 있는 ctl_table구조체에 포인터가 있는데 이를 이용해서 익스를 진행한다.

struct ctl_table {
        const char  *              procname;             /*     0   0x8 */
        void *                     data;                 /*   0x8   0x8 */
        int                        maxlen;               /*  0x10   0x4 */
        umode_t                    mode;                 /*  0x14   0x2 */
        struct ctl_table *         child;                /*  0x18   0x8 */
        proc_handler *             proc_handler;         /*  0x20   0x8 */
        struct ctl_table_poll *    poll;                 /*  0x28   0x8 */
        void *                     extra1;               /*  0x30   0x8 */
        void *                     extra2;               /*  0x38   0x8 */
};

위구조체(devinet_sysctl_table)의 ctl_table 구조체 안을 보면 여기서 proc_handler는 함수 포인터인데 이를 이용해 익스에 사용했다.

void collect_leak()
{
	u64 *pu64 = (u64*)(gdata.alloc+1);

	...

	gdata.devinet_sysctl_forward_ptr = pu64[5];
	gdata.table_header_addr = pu64[0];
}

먼저 릭 부분이다. 릭은 위에서 설명한 userfaultfd에서 받은 free된 메모리를 그냥 읽으면 devinet_sysctl_table 구조체가 할당되어 있다.

static struct devinet_sysctl_table {
	struct ctl_table_header *sysctl_header;
	struct ctl_table devinet_vars[__IPV4_DEVCONF_MAX];
} devinet_sysctl = {
	.devinet_vars = {
		DEVINET_SYSCTL_COMPLEX_ENTRY(FORWARDING, "forwarding",
					     devinet_sysctl_forward),

pu64[0]sysctl_header의 포인터이고 pu64[5]ctl_table배열의 첫번째 인자의 proc_handler 함수 포인터인 devinet_sysctl_forward이다.

이 leak된 구조체를 이용하면 proc_handler가 함수 포인터이기 때문에 pc도 바꿀 수 있다. 익스에서는 aar/aaw를 이 구조체를 이용해 만들었다. sysctl의 함수중 proc_doulongvec_minmax함수를 이용해서 만들었다.

/**
 * proc_doulongvec_minmax - read a vector of long integers with min/max values
 * @table: the sysctl table
 * @write: %TRUE if this is a write to the sysctl file
 * @buffer: the user buffer
 * @lenp: the size of the user buffer
 * @ppos: file position
 *
 * Reads/writes up to table->maxlen/sizeof(unsigned long) unsigned long
 * values from/to the user buffer, treated as an ASCII string.
 *
 * This routine will ensure the values are within the range specified by
 * table->extra1 (min) and table->extra2 (max).
 *
 * Returns 0 on success.
 */

먼저 이 함수의 기능은 ctf_table구조체의 extra1(min), extra2(max)를 이용해 ctf_table->data에서 값을 읽거나 쓰는 기능이다.

sysctlproc_handler를 이 함수로 바꿔버리면 사용할 수 있다. aar/aaw를 만드는데 두개의 sysctl을 사용한다. 첫번째 sysctl로는 두번째 sysctlctl_table->data를 덮어써 주소를 세팅하고 읽고 쓰는 방법이다.

이 과정에서 global sysctl에 aar/aaw에 사용하는 ctl_table패치하는 과정이 따로 있는데 설명은 없지만 권한때문에 하는 것 같다.

여기까지 완료되면 aar/aaw와 pc제어가 완료된다. 익스는 commit_cred(prepare_kernel_cred(0));를 하던지 현재 프로세스의 task_structcred를 덮던지 원하는 방식대로 사용 가능하다.

Patch

@@ -1491,8 +1495,18 @@ static int shiftfs_btrfs_ioctl_fd_replace(int cmd, void __user *arg,
 		v2->fd = oldfd;
 	}
 
-	if (ret)
+	if (!ret) {
+		*b1 = v1;
+		*b2 = v2;
+	} else {
 		shiftfs_btrfs_ioctl_fd_restore(cmd, *newfd, arg, v1, v2);
+	}
+
+	return ret;
+
+err_free:
+	kfree(v1);
+	kfree(v2);
 
-	return ret;
+	return ret ? -EFAULT: 0;
 }

패치내용중 주요 내용은 copy_from_user의 결과를 제대로 처리하도록 패치하였다.

Referenece

https://www.synacktiv.com/publications/exploitation-of-a-double-free-vulnerability-in-ubuntu-shiftfs-driver-cve-2021-3492.html

https://github.com/synacktiv/CVE-2021-3492/blob/master/exploit/main.c