Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
В январе 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.
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 января, после того как закончил прототип эксплоита, я отправил информацию об уязвимостях и исправление (патч) по адресу [email protected]
, то есть выполнил процедуру ответственного разглашения (responsible disclosure). Мне оперативно ответили Линус Торвальдс и Грег Кроа‑Хартман, и мы договорились о следующем порядке действий.
linux-distros
о том, что данное исправление важно для безопасности системы.
[email protected]
, когда производители дистрибутивов позволят это сделать.
На самом деле первый пункт довольно спорный. Линус решил принять мой патч сразу, без эмбарго на разглашение (disclosure embargo), потому что «этот патч не сильно отличается от патчей, которые мы принимаем каждый день» (the patch doesn’t look all that different from the kinds of patches we do every day). Я подчинился, но предложил отправить патч открыто. Это важно, потому что иначе каждый может отследить исправления безопасности, если отфильтрует коммиты, которые не обсуждались в публичном списке рассылки. Недавно эта техника была рассмотрена в одной исследовательской работе.
2 февраля вторая версия моего патча была принята в ветку netdev/net.git
и оттуда попала в ветку Линуса. 4 февраля Грег применил мое исправление в стабильных ветках ядра, которые были подвержены уязвимостям. Сразу после этого я уведомил [email protected]
, что исправленные уязвимости можно эксплуатировать для локального повышения привилегий в системе. Я спросил, сколько потребуется времени, прежде чем я сделаю публичное разглашение информации об уязвимостях. Но я получил неожиданный ответ:
То есть меня попросили немедленно раскрыть информацию о найденных и исправленных уязвимостях в публичном списке рассылки oss-security
. Странно. Как бы то ни было, я запросил идентификатор CVE через cve.mitre.org и отправил письмо в список рассылки [email protected]
.
Возникает вопрос: насколько эта практика немедленного принятия патча в ванильное ядро совместима с работой организаций в 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. Это очень ограниченный примитив эксплуатации, я преодолел большие трудности, чтобы превратить его в полный контроль над системой. Далее я расскажу, как разработал прототип эксплоита, в хронологическом порядке.
info
Эти иллюстрации я сделал из фотографий экспонатов Государственного Эрмитажа. Замечательный музей!
Первым делом я начал работать над стабильной техникой heap spraying. Ее суть в том, что эксплоит должен выполнить такие действия в пользовательском пространстве, которые заставят ядро выделить новый объект на месте освобожденной 64-байтовой структуры virtio_vsock_sock
. В этом случае ошибочная запись после освобождения virtio_vsock_sock
испортит четыре байта в этом новом объекте, что может быть полезно для развития атаки.
Источник: xakep.ru