CVE-2023-52973: Race Condition Use After Free In Linux Kernel Virtual Console Screen

· omacs's blog


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 #

  1. "vcs(4) &#x2013; Linux manual page." man7.org, Accessed: Mar. 07, 2026. [Online]. Available: https://man7.org/linux/man-pages/man4/vcs.4.html
  2. "CVE-2023-52973 Detail." nvd.nist.gov, Accessed: Mar. 07, 2026. [Online]. Available: https://nvd.nist.gov/vuln/detail/CVE-2023-52973
  3. Linux torvalds et al., "Linux kernel", (Version 6.0) [Source Code]. https://github.com/torvalds/linux
  4. 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
  5. "CVE-2023-53747 Detail." nvd.nist.gov, Accessed: Mar. 07, 2026. [Online]. Available: https://nvd.nist.gov/vuln/detail/CVE-2023-53747
last updated: