An Exhibition of A Hunger Artist 1nzag (graypanda) Security researcher

CVE-2022-0995 Review - Part 1

OverView

해당 취약점은 리눅스의 watch_queue 에 특정 filter를 적용할 때 발생하는 취약점이다.

What is watch queue?

watch_queueGeneral notification mechanism에서 사용하는 개념이다.

General notification mechanism 은 리눅스 시스템 내에 이벤트 및 변경사항을 모니터링 하기 위해 만들어진 매커니즘이다. 특정 시스템 이벤트에 실시간으로 반응하는 어플리케이션이 해당 매커니즘을 이용한다.

대표적으로 해당 매커니즘을 이용하는 어플리케이션으로는 watchdog 이 있겠다.

General notification mechanism 은 pipe 의 형태로 구현된다. pipe 내부의 버퍼는 커널에서 생성된 메시지를 보관한다. 그리고 해당 메시지는 read() 를 통해 읽는 것이 가능하다.

pipe 의 소유자는 커널에게 어떤 소스를 볼 것인지 요청 할 수 있다. 그럼으로써 요청한 소스만 파이프에 메시지를 보내게 된다.

또한 pipe 의 소유자는 filter를 이용하여 특정 소스나 이벤트에 대한 메시지를 필터링 할 수 있다.

General notification mechanism

커널로 부터 메시지를 받는 pipe는 다음과 같이 생성할 수 있다.

pipe2(fds, O_IOC_WATCH_QUEUE);

다음으로 해당 파이프에 ioctl로 watch 할 메시지 설정을 할 수 있다. 아래 코드는 256바이트의 메시지를 수용할 수 있게 하는 설정을 하는 코드이다.

ioctl(fds[1], IOC_WATCH_QUEUE_SET_SIZE, 256);

파이프에 특정 필터를 설정하는 코드는 다음과 같다.

ioctl(fds[0], IOC_WATCH_QUEUE_SET_FILTER, filter) 

Vulnerability

이제 직접 General notification mechanism 에서 filter를 추가할 때 어떻게 취약점이 유발되는지 코드를 통해 살펴보자

내가 분석한 코드는 5.13.18 버전의 리눅스 커널이다.

먼저 pipe2를 통해 파이프를 생성하면 어떻게 ioctl이 이루어지는 지 살펴보자.

  • fs/pipe.c
static int do_pipe2(int __user *fildes, int flags)
{
	struct file *files[2];
	int fd[2];
	int error;

	error = __do_pipe_flags(fd, files, flags);
	if (!error) {
		if (unlikely(copy_to_user(fildes, fd, sizeof(fd)))) {
			fput(files[0]);
			fput(files[1]);
			put_unused_fd(fd[0]);
			put_unused_fd(fd[1]);
			error = -EFAULT;
		} else {
			fd_install(fd[0], files[0]);
			fd_install(fd[1], files[1]);
		}
	}
	return error;
}

pipe2 syscall 을 호출하면 do_pipe2() 함수가 호출되고, 해당 함수는 __do_pipe_flags() 함수를 호출한다.

  • fs/pipe.c
static int __do_pipe_flags(int *fd, struct file **files, int flags)
{
	int error;
	int fdw, fdr;

	if (flags & ~(O_CLOEXEC | O_NONBLOCK | O_DIRECT | O_NOTIFICATION_PIPE))
		return -EINVAL;

	error = create_pipe_files(files, flags);
	if (error)
		return error;

	error = get_unused_fd_flags(flags);
	if (error < 0)
		goto err_read_pipe;
	fdr = error;

	error = get_unused_fd_flags(flags);
	if (error < 0)
		goto err_fdr;
	fdw = error;

	audit_fd_pair(fdr, fdw);
	fd[0] = fdr;
	fd[1] = fdw;
	return 0;

 err_fdr:
	put_unused_fd(fdr);
 err_read_pipe:
	fput(files[0]);
	fput(files[1]);
	return error;
}

/* ... */

int create_pipe_files(struct file **res, int flags)
{
	struct inode *inode = get_pipe_inode();
	struct file *f;
	int error;

	if (!inode)
		return -ENFILE;

	if (flags & O_NOTIFICATION_PIPE) {
		error = watch_queue_init(inode->i_pipe);
		if (error) {
			free_pipe_info(inode->i_pipe);
			iput(inode);
			return error;
		}
	}

	f = alloc_file_pseudo(inode, pipe_mnt, "",
				O_WRONLY | (flags & (O_NONBLOCK | O_DIRECT)),
				&pipefifo_fops);
	if (IS_ERR(f)) {
		free_pipe_info(inode->i_pipe);
		iput(inode);
		return PTR_ERR(f);
	}

	f->private_data = inode->i_pipe;

	res[0] = alloc_file_clone(f, O_RDONLY | (flags & O_NONBLOCK),
				  &pipefifo_fops);
	if (IS_ERR(res[0])) {
		put_pipe_info(inode, inode->i_pipe);
		fput(f);
		return PTR_ERR(res[0]);
	}
	res[0]->private_data = inode->i_pipe;
	res[1] = f;
	stream_open(inode, res[0]);
	stream_open(inode, res[1]);
	return 0;
}

/* ... */

int create_pipe_files(struct file **res, int flags)
{
	struct inode *inode = get_pipe_inode();
	struct file *f;
	int error;

	if (!inode)
		return -ENFILE;

	if (flags & O_NOTIFICATION_PIPE) {
		error = watch_queue_init(inode->i_pipe);
		if (error) {
			free_pipe_info(inode->i_pipe);
			iput(inode);
			return error;
		}
	}

	f = alloc_file_pseudo(inode, pipe_mnt, "",
				O_WRONLY | (flags & (O_NONBLOCK | O_DIRECT)),
				&pipefifo_fops);
	if (IS_ERR(f)) {
		free_pipe_info(inode->i_pipe);
		iput(inode);
		return PTR_ERR(f);
	}

	f->private_data = inode->i_pipe;

	res[0] = alloc_file_clone(f, O_RDONLY | (flags & O_NONBLOCK),
				  &pipefifo_fops);
	if (IS_ERR(res[0])) {
		put_pipe_info(inode, inode->i_pipe);
		fput(f);
		return PTR_ERR(res[0]);
	}
	res[0]->private_data = inode->i_pipe;
	res[1] = f;
	stream_open(inode, res[0]);
	stream_open(inode, res[1]);
	return 0;
}

__do_pipe_flags() 함수는 create_pipe_files() 함수를 호출하고, create_pipe_files() 함수는 get_pipe_inode() 함수를 호출한다. get_pipe_inode() 함수는 해당 inode 의 file_operations 구조체를 pipefifo_fops 로 할당한다.

  • fs/pipe.c
const struct file_operations pipefifo_fops = {
	.open		= fifo_open,
	.llseek		= no_llseek,
	.read_iter	= pipe_read,
	.write_iter	= pipe_write,
	.poll		= pipe_poll,
	.unlocked_ioctl	= pipe_ioctl,
	.release	= pipe_release,
	.fasync		= pipe_fasync,
	.splice_write	= iter_file_splice_write,
};

pipefifo_fopsunlocked_ioctl 필드는 pipe_ioctl() 함수인 것을 알 수 있다.

즉, pipe2() 함수를 통하여 생성된 fd 에 ioctl을 요청하면, pipe_ioctl() 함수가 해당 요청을 핸들링 하는 것을 알 수 있다.

그럼 ioctl(fds[0], IOC_WATCH_QUEUE_SET_FILTER, filter) 을 통해 watch queue 에 필터를 설정했을 때 어떤 루틴을 거치는지 보자.

먼저 다음은 pipe_ioctl() 함수의 코드이다.

  • fs/pipe.c
static long pipe_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
	struct pipe_inode_info *pipe = filp->private_data;
	int count, head, tail, mask;

	switch (cmd) {
	case FIONREAD:
		__pipe_lock(pipe);
		count = 0;
		head = pipe->head;
		tail = pipe->tail;
		mask = pipe->ring_size - 1;

		while (tail != head) {
			count += pipe->bufs[tail & mask].len;
			tail++;
		}
		__pipe_unlock(pipe);

		return put_user(count, (int __user *)arg);

#ifdef CONFIG_WATCH_QUEUE
	case IOC_WATCH_QUEUE_SET_SIZE: {
		int ret;
		__pipe_lock(pipe);
		ret = watch_queue_set_size(pipe, arg);
		__pipe_unlock(pipe);
		return ret;
	}

	case IOC_WATCH_QUEUE_SET_FILTER:
		return watch_queue_set_filter(
			pipe, (struct watch_notification_filter __user *)arg);
#endif

	default:
		return -ENOIOCTLCMD;
	}
}

IOC_WATCH_QUEUE_SET_FILTER 명령을 입력했을 떄, watch_queue_set_filter() 함수를 호출하는 것을 알 수 있다.

  • kernel/watch_queue.c
/*
 * Set the filter on a watch queue.
 */
long watch_queue_set_filter(struct pipe_inode_info *pipe,
			    struct watch_notification_filter __user *_filter)
{
	struct watch_notification_type_filter *tf;
	struct watch_notification_filter filter;
	struct watch_type_filter *q;
	struct watch_filter *wfilter;
	struct watch_queue *wqueue = pipe->watch_queue;
	int ret, nr_filter = 0, i;

	if (!wqueue)
		return -ENODEV;

	if (!_filter) {
		/* Remove the old filter */
		wfilter = NULL;
		goto set;
	}

	/* Grab the user's filter specification */
	if (copy_from_user(&filter, _filter, sizeof(filter)) != 0)
		return -EFAULT;
	if (filter.nr_filters == 0 ||
	    filter.nr_filters > 16 ||
	    filter.__reserved != 0)
		return -EINVAL;

	tf = memdup_user(_filter->filters, filter.nr_filters * sizeof(*tf));
	if (IS_ERR(tf))
		return PTR_ERR(tf);

	ret = -EINVAL;
	for (i = 0; i < filter.nr_filters; i++) {
		if ((tf[i].info_filter & ~tf[i].info_mask) ||
		    tf[i].info_mask & WATCH_INFO_LENGTH)
			goto err_filter;
		/* Ignore any unknown types */
		if (tf[i].type >= sizeof(wfilter->type_filter) * 8)
			continue;
		nr_filter++;
	}

	/* Now we need to build the internal filter from only the relevant
	 * user-specified filters.
	 */
	ret = -ENOMEM;
	wfilter = kzalloc(struct_size(wfilter, filters, nr_filter), GFP_KERNEL);
	if (!wfilter)
		goto err_filter;
	wfilter->nr_filters = nr_filter;

	q = wfilter->filters;
	for (i = 0; i < filter.nr_filters; i++) {
		if (tf[i].type >= sizeof(wfilter->type_filter) * BITS_PER_LONG)
			continue;

		q->type			= tf[i].type;
		q->info_filter		= tf[i].info_filter;
		q->info_mask		= tf[i].info_mask;
		q->subtype_filter[0]	= tf[i].subtype_filter[0];
		__set_bit(q->type, wfilter->type_filter);
		q++;
	}

	kfree(tf);
set:
	pipe_lock(pipe);
	wfilter = rcu_replace_pointer(wqueue->filter, wfilter,
				      lockdep_is_held(&pipe->mutex));
	pipe_unlock(pipe);
	if (wfilter)
		kfree_rcu(wfilter, rcu);
	return 0;

err_filter:
	kfree(tf);
	return ret;
}

먼저 watch_queue_set_filter() 함수는 인자로 받은 _filterfilter 에 복사한다. 그 후 memdump_user() 함수를 이용해 filter.nr_filters * sizeof(*tf) 만큼 메모리를 할당하여, 그 곳에 _filter->filters 를 복사한다.

그 후 각 tf 마다 type 필드의 사이즈를 검사하여 sizeof(wfilter->type_filter) * 8 만큼의 사이즈를 넘지 않는 포인터의 개수만 카운트 한다. 이때 wfilter->type_filter의 사이즈는 0x10 이므로 허용하는 크기는 0x10 * 8 = 0x80 이다.

그 후 filterfilters 라는 가변길이의 필드를 nr_filter만큼 할당하고, wfilternr_filter 필드를 기존에 셋던 nr_filter로 초기화 한다.

wfilters를 초기화 한 후에는 q라는 wfilterfilters의 맴버 포인터를 초기화 하고, filter를 다시 검사한다.

이번에는 sizeof(wfilter -> type_filter) * BITS_PER_LONG 보다 크기가 작은 맴버에 대해서만 그 아래 연산을 허용한다. 이 때 64비트에서 BITS_PER_LONG의 크기는 64 이므로 연산을 허용하는 크기는 64 * 0x10 = 0x400 이다.

참고를 위해 아래 각 변수에 대한 구조체 정의를 가져왔다.

  • kernel/watch_queue.h
/*
 * Notification filtering rules (IOC_WATCH_QUEUE_SET_FILTER).
 */
struct watch_notification_type_filter {
	__u32	type;			/* Type to apply filter to */
	__u32	info_filter;		/* Filter on watch_notification::info */
	__u32	info_mask;		/* Mask of relevant bits in info_filter */
	__u32	subtype_filter[8];	/* Bitmask of subtypes to filter on */
};

struct watch_notification_filter {
	__u32	nr_filters;		/* Number of filters */
	__u32	__reserved;		/* Must be 0 */
	struct watch_notification_type_filter filters[];
};

struct watch_type_filter {
	enum watch_notification_type type;
	__u32		subtype_filter[1];	/* Bitmask of subtypes to filter on */
	__u32		info_filter;		/* Filter on watch_notification::info */
	__u32		info_mask;		/* Mask of relevant bits in info_filter */
};

struct watch_filter {
	union {
		struct rcu_head	rcu;
		unsigned long	type_filter[2];	/* Bitmask of accepted types */
	};
	u32			nr_filters;	/* Number of filters */
	struct watch_type_filter filters[];
};

즉, 먼저 0x80 이하 크기의 데이터를 검사하고 이에 대해서는 데이터 공간 할당을, 두번째 검사에서 0x400 이하 크기의 데이터를 검사 한 후 이에 대해서는 데이터 쓰기 작업을 수행한다.

뭔가 이상하지 않은가? 처음에는 0x80 크기보다 작은 데이터만 선별한 후 할당을 하고, 그 뒤에는 0x400 까지 크기의 데이터까지 데이터에 간섭하는 것을 허용하고 있다.

즉, 0x80 이상 0x400 이하 크기의 사이즈를 보내면, 데이터는 할당하지 않은 채 해당 인덱스 부분에서 데이터 쓰기를 하는 것을 볼 수 있다! 즉, OOB 취약점이 유발된다.

OOB가 나는 곳은 하나 더 있다.

그 아래 루틴에서 사용하는 __set_bit() 함수를 살펴보자.

  • arch/alpha/include/asm/bitops.h
/*
 * WARNING: non atomic version.
 */
static inline void
__set_bit(unsigned long nr, volatile void * addr)
{
	int *m = ((int *) addr) + (nr >> 5);

	*m |= 1 << (nr & 31);
}

addr + 4 * (nr >> 5) 만큼의 주소 부분에 데이터를 쓰는 것을 알 수 있다. 기존에 0x80 미만의 사이즈라면 0x80 » 5 * 4 = 최대 16byte 만큼 뒤에 쓰게 된다. 16byte의 사이즈는 q의 자료형인 24 바이트를 넘지 않는다. 하지만, 그 이상의 사이즈를 주게 되면 OOB 가 가능하게 된다.

따라서 filter에 사이즈를 0x80 초과 0x400 미만으로 설정해 준다면 힙 영역에서의 OOB를 노릴 수 있다!

Conclusion

이번 취약점은 개발자의 단순 실수가 맞나 싶을정도로 뭔가 단순한 취약점으로 보였다. 익스플로잇 코드를 보니까 힙 스프레이 기법을 사용하는 것 같은데 다음 파트에서 힙 스프레이를 이용한 익스플로잇을 공부하게 되어 기대가 된다.

실제 PoC 및 익스플로잇 코드는 다음 파트에 작성해놓았다.

References