CVE-2022-0995 Review - Part 1
OverView
해당 취약점은 리눅스의 watch_queue
에 특정 filter
를 적용할 때 발생하는 취약점이다.
What is watch queue?
watch_queue
는 General notification mechanism에서 사용하는 개념이다.
General notification mechanism 은 리눅스 시스템 내에 이벤트 및 변경사항을 모니터링 하기 위해 만들어진 매커니즘이다. 특정 시스템 이벤트에 실시간으로 반응하는 어플리케이션이 해당 매커니즘을 이용한다.
대표적으로 해당 매커니즘을 이용하는 어플리케이션으로는 watchdog 이 있겠다.
General notification mechanism 은 pipe 의 형태로 구현된다. pipe 내부의 버퍼는 커널에서 생성된 메시지를 보관한다. 그리고 해당 메시지는 read()
를 통해 읽는 것이 가능하다.
pipe 의 소유자는 커널에게 어떤 소스를 볼 것인지 요청 할 수 있다. 그럼으로써 요청한 소스만 파이프에 메시지를 보내게 된다.
또한 pipe 의 소유자는 filter
를 이용하여 특정 소스나 이벤트에 대한 메시지를 필터링 할 수 있다.
커널로 부터 메시지를 받는 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_fops
의 unlocked_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()
함수는 인자로 받은 _filter
를 filter
에 복사한다. 그 후 memdump_user()
함수를 이용해 filter.nr_filters * sizeof(*tf)
만큼 메모리를 할당하여, 그 곳에 _filter->filters
를 복사한다.
그 후 각 tf
마다 type
필드의 사이즈를 검사하여 sizeof(wfilter->type_filter) * 8
만큼의 사이즈를 넘지 않는 포인터의 개수만 카운트 한다. 이때 wfilter->type_filter
의 사이즈는 0x10 이므로 허용하는 크기는 0x10 * 8 = 0x80 이다.
그 후 filter
의 filters
라는 가변길이의 필드를 nr_filter
만큼 할당하고, wfilter
의 nr_filter
필드를 기존에 셋던 nr_filter
로 초기화 한다.
wfilters
를 초기화 한 후에는 q
라는 wfilter
의 filters
의 맴버 포인터를 초기화 하고, 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 및 익스플로잇 코드는 다음 파트에 작성해놓았다.