Table of Contents
Introduction #
리눅스 커널 가상 콘솔에서 데이터를 읽을 때 레이스 컨디션이 발생할 수 있고 이때 관련 구조체를 해제하는 명령어를 사용하여 use-after-free (UAF)를 트리거할 수 있다[1, 2].
다만, 가상 콘솔을 읽을 때 사용되는 파일을 사용하려면 루트와 tty 그룹이어야 한다. 즉, 일반 사용자는 이를 트리거하지 못한다[1].
본 글에서는 리눅스 커널 6.0 기준으로 이 취약점을 알아본다.
Rootcause Analysis #
이 취약점은 다음 표와 같이 vcs_read 함수와 vt_ioctl 함수를 동시에 실행할 수 있을 때 트리거 할 수 있다.
| No | Thread 1 | Thread 2 |
|---|---|---|
| 0 | vcs_read() | |
| vcs_vc() | ||
| console_unlock() [ In while loop ] | ||
| 1 | vt_ioctl() | |
| vt_disallocate_all() | ||
| tty_port_put() | ||
| kref_put() | ||
| tty_port_destructor() | ||
| vc_port_destruct() * port->ops->destruct == vc_port_destruct * | ||
| kfree(vc) | ||
| 2 | vcs_size() [ Next loop phase ] |
가상 콘솔은 /dev/vcs* 파일로 존재하며, 커널에서 이에 대한 연산을 수행할 때는 다음 구조체 변수를 통해서 진행한다 (/drivers/tty/vt/vc_screen.c에서 발췌)[3].
static const struct file_operations vcs_fops = {
.llseek = vcs_lseek,
.read = vcs_read,
.write = vcs_write,
.poll = vcs_poll,
.fasync = vcs_fasync,
.open = vcs_open,
.release = vcs_release,
};
이때 vcs_read 함수는 읽기 작업이 수행 중인 콘솔을 잠그고 vcs_vc 함수로부터 vc_data 구조체를 얻어 그 데이터를 읽는다 ({1}). 그런데 읽은 데이터를 사용자 버퍼로 복사할 때 잠시 잠금 해제한다 ({2}). 이때 다른 프로세스가 끼어들 수 있다. 즉, 레이스 컨디션이 가능하다. 커널은 vc_data 구조체와 vcs_read 함수는 다음과 같이 정의한다 (각각 /include/linux/console_struct.h와 /drivers/tty/vt/vc_screen.c에서 발췌)[3].
/*
* Example: vc_data of a console that was scrolled 3 lines down.
*
* Console buffer
* vc_screenbuf ---------> +----------------------+-.
* | initializing W | \
* | initializing X | |
* | initializing Y | > scroll-back area
* | initializing Z | |
* | | /
* vc_visible_origin ---> ^+----------------------+-:
* (changes by scroll) || Welcome to linux | \
* || | |
* vc_rows --->< | login: root | | visible on console
* || password: | > (vc_screenbuf_size is
* vc_origin -----------> || | | vc_size_row * vc_rows)
* (start when no scroll) || Last login: 12:28 | /
* v+----------------------+-:
* | Have a lot of fun... | \
* vc_pos -----------------|--------v | > scroll-front area
* | ~ # cat_ | /
* vc_scr_end -----------> +----------------------+-:
* (vc_origin + | | \ EMPTY, to be filled by
* vc_screenbuf_size) | | / vc_video_erase_char
* +----------------------+-'
* <---- 2 * vc_cols ----->
* <---- vc_size_row ----->
*
* Note that every character in the console buffer is accompanied with an
* attribute in the buffer right after the character. This is not depicted
* in the figure.
*/
struct vc_data {
struct tty_port port;
/* ... */
unsigned int vc_cols;
unsigned int vc_rows;
/* ... */
};
/* ------------------------------------------------------------*/
static ssize_t
vcs_read(struct file *file, char __user *buf, size_t count, loff_t *ppos)
{
struct inode *inode = file_inode(file);
struct vc_data *vc;
struct vcs_poll_data *poll;
unsigned int read;
ssize_t ret;
char *con_buf;
loff_t pos;
bool viewed, attr, uni_mode;
con_buf = (char *) __get_free_page(GFP_KERNEL);
if (!con_buf)
return -ENOMEM;
pos = *ppos;
/* Select the proper current console and verify
* sanity of the situation under the console lock.
*/
console_lock();
uni_mode = use_unicode(inode);
attr = use_attributes(inode);
ret = -ENXIO;
vc = vcs_vc(inode, &viewed); /* {1} */
if (!vc)
goto unlock_out;
/* ... */
read = 0;
ret = 0;
while (count) {
unsigned int this_round, skip = 0;
int size;
/* Check whether we are above size each round,
* as copy_to_user at the end of this loop
* could sleep.
*/
size = vcs_size(vc, attr, uni_mode);
if (size < 0) {
if (read)
break;
ret = size;
goto unlock_out;
}
if (pos >= size)
break;
if (count > size - pos)
count = size - pos;
/* ... */
/* Finally, release the console semaphore while we push
* all the data to userspace from our temporary buffer.
*
* AKPM: Even though it's a semaphore, we should drop it because
* the pagefault handling code may want to call printk().
*/
console_unlock(); /* {2} */
ret = copy_to_user(buf, con_buf + skip, this_round);
console_lock();
if (ret) {
read += this_round - ret;
ret = -EFAULT;
break;
}
buf += this_round;
pos += this_round;
read += this_round;
count -= this_round;
}
*ppos += read;
if (read)
ret = read;
unlock_out:
console_unlock();
free_page((unsigned long) con_buf);
return ret;
}
위 코드에서 vcs_vc 함수는 vc 구조체 타입의 전역 구조체 배열인 vc_cons에 접근하여 d 멤버 포인터 (vc_data 구조체에 대한 포인터)를 가져온다. 커널은 다음과 같이 이들을 구현한다 (각각 /include/linux/console_struct.h, /drivers/tty/vt/vt.c, /drivers/tty/vt/vc_screen.c에서 발췌)[3].
struct vc {
struct vc_data *d;
struct work_struct SAK_work;
/* might add scrmem, kbd at some time,
to have everything in one place */
};
struct vc vc_cons [MAX_NR_CONSOLES];
/* ----------------------------------------------------------------*/
/**
* vcs_vc -- return VC for @inode
* @inode: inode for which to return a VC
* @viewed: returns whether this console is currently foreground (viewed)
*
* Must be called with console_lock.
*/
static struct vc_data *vcs_vc(struct inode *inode, bool *viewed)
{
unsigned int currcons = console(inode);
WARN_CONSOLE_UNLOCKED();
if (currcons == 0) {
currcons = fg_console;
if (viewed)
*viewed = true;
} else {
currcons--;
if (viewed)
*viewed = false;
}
return vc_cons[currcons].d;
}
그런데 vcs_ioctl 함수를 보면 VT_DISALLOCATE 명령어를 통해 vc_data 구조체를 해제할 수 있다 ({3}). 이는 아래 콜 트레이스를 거쳐 진행된다[3].
/*
* We handle the console-specific ioctl's here. We allow the
* capability to modify any console, not just the fg_console.
*/
int vt_ioctl(struct tty_struct *tty,
unsigned int cmd, unsigned long arg)
{
struct vc_data *vc = tty->driver_data;
void __user *up = (void __user *)arg;
int i, perm;
int ret;
/*
* To have permissions to do most of the vt ioctls, we either have
* to be the owner of the tty, or have CAP_SYS_TTY_CONFIG.
*/
perm = 0;
if (current->signal->tty == tty || capable(CAP_SYS_TTY_CONFIG))
perm = 1;
ret = vt_k_ioctl(tty, cmd, arg, perm);
if (ret != -ENOIOCTLCMD)
return ret;
ret = vt_io_ioctl(vc, cmd, up, perm);
if (ret != -ENOIOCTLCMD)
return ret;
switch (cmd) {
/* ... */
/*
* Disallocate memory associated to VT (but leave VT1)
*/
case VT_DISALLOCATE:
if (arg > MAX_NR_CONSOLES)
return -ENXIO;
if (arg == 0) { /* {3} */
vt_disallocate_all();
break;
}
arg = array_index_nospec(arg - 1, MAX_NR_CONSOLES);
return vt_disallocate(arg);
/* ... */
default:
return -ENOIOCTLCMD;
}
return 0;
}
/* ----------------------------------------------------------------*/
vt_ioctl() /* cmd == VT_DISALLOCATE */
vt_disallcate_all()
tty_port_put()
kref_put()
tty_port_destructor()
vc_port_destruct() /* port->ops->destruct == vc_port_destruct */
kfree(vc) /* vc is vc_data structure */
앞서 vcs_read()의 반복문에서 unlock할 때 레이스 컨디션이 발생할 수 있다고 설명하였다. 이때 끼어드는 프로세스가 위와 같이 vc_data 구조체를 헤제하는 작업을 수행한다면, copy_to_user() 이후에 vcs_size()가 접근하는 메모리는 이미 해제된 메모리가 된다.
Patch #
vt_disallocate_all 함수에서 호출한 vc_deallocate 함수는 vc_cons 배열 원소의 데이터 멤버 포인터를 NULL로 초기화한다. 즉, 만약 상기의 레이스 컨디션으로 인해 vc_data 구조체가 free되었다면, vcs_vc 함수는 NULL을 리턴한다는 것이다. 이에 착안하여 패치는 vcs_read 함수 while 문에서 vcs_size 함수가 호출되기 전에 vcs_vc 함수를 호출하여 매번 해제 여부를 검사한다[4].
diff --git a/drivers/tty/vt/vc_screen.c b/drivers/tty/vt/vc_screen.c
index 1850bacdb5b0e1..f566eb1839dc50 100644
--- a/drivers/tty/vt/vc_screen.c
+++ b/drivers/tty/vt/vc_screen.c
@@ -386,10 +386,6 @@ vcs_read(struct file *file, char __user *buf, size_t count, loff_t *ppos)
uni_mode = use_unicode(inode);
attr = use_attributes(inode);
- ret = -ENXIO;
- vc = vcs_vc(inode, &viewed);
- if (!vc)
- goto unlock_out;
ret = -EINVAL;
if (pos < 0)
@@ -407,6 +403,11 @@ vcs_read(struct file *file, char __user *buf, size_t count, loff_t *ppos)
unsigned int this_round, skip = 0;
int size;
+ ret = -ENXIO;
+ vc = vcs_vc(inode, &viewed);
+ if (!vc)
+ goto unlock_out;
+
/* Check whether we are above size each round,
* as copy_to_user at the end of this loop
* could sleep.
그런데 여기서 궁금증이 드는 것은 위 패치가 vcs_read()에 대해서만 적용되는 것이었다는 점이다. vcs_write()는 다음과 같이 구현되어 있고, 그 구조가 vcs_read()와 크게 다르지 않기 때문이다 (/drivers/tty/vt/vc_screen.c에서 발췌)[3].
static ssize_t
vcs_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
struct inode *inode = file_inode(file);
struct vc_data *vc;
char *con_buf;
u16 *org0, *org;
unsigned int written;
int size;
ssize_t ret;
loff_t pos;
bool viewed, attr;
if (use_unicode(inode))
return -EOPNOTSUPP;
con_buf = (char *) __get_free_page(GFP_KERNEL);
if (!con_buf)
return -ENOMEM;
pos = *ppos;
/* Select the proper current console and verify
* sanity of the situation under the console lock.
*/
console_lock();
attr = use_attributes(inode);
ret = -ENXIO;
vc = vcs_vc(inode, &viewed);
if (!vc)
goto unlock_out;
size = vcs_size(vc, attr, false);
if (size < 0) {
ret = size;
goto unlock_out;
}
ret = -EINVAL;
if (pos < 0 || pos > size)
goto unlock_out;
if (count > size - pos)
count = size - pos;
written = 0;
while (count) {
unsigned int this_round = count;
if (this_round > CON_BUF_SIZE)
this_round = CON_BUF_SIZE;
/* Temporarily drop the console lock so that we can read
* in the write data from userspace safely.
*/
console_unlock();
ret = copy_from_user(con_buf, buf, this_round);
console_lock();
/* ... */
/* The vcs_size might have changed while we slept to grab
* the user buffer, so recheck.
* Return data written up to now on failure.
*/
size = vcs_size(vc, attr, false);
if (size < 0) {
if (written)
break;
ret = size;
goto unlock_out;
}
if (pos >= size)
break;
if (this_round > size - pos)
this_round = size - pos;
/* OK, now actually push the write to the console
* under the lock using the local kernel buffer.
*/
if (attr)
org = vcs_write_buf(vc, con_buf, pos, this_round,
viewed, &org0);
else
org = vcs_write_buf_noattr(vc, con_buf, pos, this_round,
viewed, &org0);
count -= this_round;
written += this_round;
buf += this_round;
pos += this_round;
if (org)
update_region(vc, (unsigned long)(org0), org - org0);
}
*ppos += written;
ret = written;
if (written)
vcs_scr_updated(vc);
unlock_out:
console_unlock();
free_page((unsigned long) con_buf);
return ret;
}
이는 CVE-2023-53747을 부여받아서 패치되었다[5].
References #
- "vcs(4) – Linux manual page." man7.org, Accessed: Mar. 07, 2026. [Online]. Available: https://man7.org/linux/man-pages/man4/vcs.4.html
- "CVE-2023-52973 Detail." nvd.nist.gov, Accessed: Mar. 07, 2026. [Online]. Available: https://nvd.nist.gov/vuln/detail/CVE-2023-52973
- Linux torvalds et al., "Linux kernel", (Version 6.0) [Source Code]. https://github.com/torvalds/linux
- George Kennedy, "vc_screen: move load of structure vc_data pointer in vcs_read() to avoid UAF." git.kernel.org, Accessed: Mar. 07, 2026. [Online]. Available: https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=226fae124b2dac217ea5436060d623ff3385bc34
- "CVE-2023-53747 Detail." nvd.nist.gov, Accessed: Mar. 07, 2026. [Online]. Available: https://nvd.nist.gov/vuln/detail/CVE-2023-53747