В янва­ре 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 янва­ря, пос­ле того как закон­чил про­тотип экс­пло­ита, я отпра­вил информа­цию об уяз­вимос­тях и исправ­ление (патч) по адре­су 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 через cve.mitre.org и от­пра­вил пись­мо в спи­сок рас­сылки 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. Это очень огра­ничен­ный при­митив экс­плу­ата­ции, я пре­одо­лел боль­шие труд­ности, что­бы прев­ратить его в пол­ный кон­троль над сис­темой. Далее я рас­ска­жу, как раз­работал про­тотип экс­пло­ита, в хро­ноло­гичес­ком поряд­ке.

info

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

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

Продолжение доступно только участникам

Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

Вариант 2. Открой один материал

Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    12 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии