“I’ll ask your body”: SMBGhost pre-auth RCE – злоупотребление структурами прямого доступа к памяти



Дата публикации: 2020-04-27
Автор: Вольный перевод: @brom_samedi
Теги: ,

Источник: https://ricercasecurity.blogspot.com/2020/04/ill-ask-your-body-smbghost-pre-auth-rce.html

Введение

11 марта Microsoft выпустил отчет об уязвимости SMB Ghost – уязвимости целочисленного переполнения в программе распаковки сообщений SMBv3.1.1 драйвера ядра srv2.sys. SMB Ghost уже давно привлекает к себе внимание из-за возможности RCE (удаленного выполнения кода) и его “применимости” (ввиду распространенности пр. переводчика).

Несмотря на то, что было опубликовано много отчетов и демонстраций локального повышения привилегий (LPE), возможность RCE до сих пор не была продемонстрирована. Вероятно, это связано с тем, что удаленная эксплуатация уязвимостей ядра сильно отличается от локальной эксплуатации тем, что злоумышленник лишен возможности использовать полезные функции операционной системы, такие как создание процессов в пространстве пользователя, обращение к PEB (Process Environment Block) и выполнение системных вызовов. Вместе с мерами по обеспечению безопасности Windows 10 эти ограничения значительно осложняют RCE.

В этом докладе будет продемонстрирован способ достижения RCE, вопреки указанным выше ограничениям. Особенно интересной частью является обход рандомизации виртуальных адресов (или получение “примитива чтения”).

Источник проблемы и как получить произвольную запись

Как было указано в других отчетах, SMB Ghost – это уязвимость переполнения целых чисел, которая существует в srv2!Srv2DecompressData-процедуре, которая распаковывает сжатые пакеты запросов. Прежде чем переходить к тому, как это можно использовать, необходимо рассмотреть источник проблемы.

Ниже представлен упрощенный код srv2!Srv2DecompressData:

 

Как было отмечено в комментариях, в коде можно видеть первичное целочисленное переполнение в точке (A). Если обратить внимание на тот факт, что и compressHeader.originalCompressedSegSize, и compressHeader.offsetOrLength подконтрольны атакующему, то уязвимость становится очевидной. Кроме того, в точке (B) доступно переполнение буфера, при выставлении в compressHeader.originalCompressedSegSize большого значения (0xffffffff, например). Чтобы понять, что может быть перезаписано в результате данного переполнения, необходимо понять, что находится рядом.

srvnet!SrvNetAllocateBufferFromPool (вызывается в srvnet!SrvNetAllocateBuffer):

 

По какой-то причине буфер расположен над его заголовком. Это позволяет переписать SRVNET_BUFFER_HDR, используя переполнение буфера (B). Это важно для создания примитива записи, т.е. можно добиться произвольной записи в (C), если перезаписать pNetRawBuffer в (B). С более подробной информацией можно ознакомиться в the report and LPE PoC of ZecOps.

Может показаться, что если установить в compressHeader.originalCompressedSegSize искаженное значение, то проверка finalDecompressedSize != compressHeader.originalCompressedSegSize должна возвращать true и распаковка завершится неудачей, не достигнув (C).

Однако, как уже упоминалось в отчете ZecOps, по какой-то причине srvnet!SmbCompressionDecompress присваивает originalCompressedSegSize значение finalDecompressedSize после всего нескольких проверок. Следовательно, эта функция становится примитивом записи, и этого достаточно для LPE.

Предварительные знания, необходимые в последующих разделах: списки Lookaside и KUSER_SHARED_DATA

До сих пор обсуждался источник проблемы, которая уже была раскрыта во многих отчетах, и то, как получить примитив записи. Для получения примитива чтения необходимо использовать списки Lookaside и KUSER_SHARED_DATA.

Списки lookaside

Списки Lookaside – это механизм или API, предлагаемый в ядре Windows для кэширования структур данных, которым требуется частое выделение и освобождение памяти. Поскольку вызов ExAllocatePoolWithTag и ExFreePoolWithTag каждый раз занимает значительное время, драйверы ядра часто имеют список lookaside для своих собственных структур данных. Важным моментом является тот факт, что для структур, поддерживаемых списками lookaside, зачастую пропускается процесс инициализации и высвобождения. Поскольку элементы в списке lookaside должны были быть инициализированы раньше, в большинстве случаев нет необходимости инициализировать их снова, когда элемент извлекается из списка.

Как и ожидалось, именно это и происходит с SRVNET_BUFFER_HDR. По умолчанию srvnet!SrvNetAllocateBuffer подставляет SRVNET_BUFFER_HDR из lookaside списка (это поведение может быть изменено в регистре Windows) и большая часть инициализации пропускается при выделении памяти под заголовок из списка. Это значит, что можно сломать заголовок, добавить его в список, а затем извлечь его из списка в последующих запросах, сохраняя его сломанным. При получении примитива чтения следует полагаться на то, чтобы разделить процедуру произвольного чтения на два измененных запроса.

KUSER_SHARED_DATA

В последней версии Windows все виртуальные адреса рандомизированы, в том числе адреса стеков, куч, PTE и т. д. Одним из немногих исключений является KUSER_SHARED_DATA, который представляет собой структуру (и страницу), отображенную как в пространстве пользователей, так и в пространстве ядра. Его адрес: 0x7ffe0000 с правами r– в пространстве пользователя и 0xfffff78000000000 с правами rw- в пространстве ядра.

Поскольку примитив записи уже получен, с его помощью можно записать произвольные данные в отображение KUSER_SHARED_DATA, что крайне полезно при создании поддельных структур. Кроме того, туда может быть помещен шеллкод для обоих пространств (пользователя и ядра).

Рандомизация адресов и произвольное чтение

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

Шаг первый

Первая проблема заключается в том, что заголовок используется для пакетов запросов, а не ответов. Это означает, что добиться произвольного чтения так же легко, как и безрассудно перезаписать pNetRawBuffer или любой другой элемент; при простой перезаписи сервер либо промолчит, либо вернет нормальный ответ.

К счастью, srv2.sys предоставляет удобную функцию srv2!Srv2SetResponseBufferToReceiveBuffer:

Эта функция предположительно используется для эффективного повторного использования буферов, т. к. запросы и ответы имеют много общего в своих полезных нагрузках. В действительности srv2.sys не инициализирует буферы ответов, когда они подготовлены с помощью srv2!Srv2SetResponseBufferToReceiveBuffer. Таким образом, если вызвать эту функцию после взлома буфера запроса, то это позволит взломать буфер ответа.

Другим положительным моментом является то, что srv2!Srv2SetResponseBufferToReceiveBuffer вызывается в функции srv2!Smb2SetError, которая, в свою очередь, вызывается, когда srv2.sys хочет отправить сообщение об ошибке. Таким образом, взлом буфера ответа возможен с помощью правильно составленного запроса, который будет восприниматься сервером как валидный, но ошибочный.

Список дескрипторов памяти

Дальнейшее развитие атаки возможно с помощью MDL (Memory Descriptor Lists). В связи с тем, что tcpip.sys полагается на прямой доступ к памяти для передачи пакетов, драйверы поддерживают физические адреса буферов в MDL. Несмотря на то, что описание в Microsoft Docs не упоминает физический адрес, структуры MDL фактически содержат физические адреса, которым предшествуют 8 элементов:

 

В SRVNET_BUFFER_HDR pMDL1 и pMDL2 – указатели на структуру MDL, которая описывает хранящиеся в памяти данные, отправляемые клиенту через tcpip.sys.

Forging структуры MDL

Из вышеописанного вытекает следующий шаг – необходимо перезаписать указатель на MDL в заголовке ответа, чтобы получить утечку из физической памяти. Однако, в этом месте возникает новая проблема. Если перезаписать pMDL так же, как примитив записи, это вызовет сбой. Это произойдет по той причине, что pNonPagedPoolAddr находится между переполняемым буфером и pMDL1. Таким образом, если перезаписать pMDL1, то pNonPagedPoolAddr также перезапишется. Установка неверного адреса в pNonPagedPoolAddr рано или поздно вызовет SEGV в srvnet!SrvNetFreeBuffer, так как он вызывает ExFreePoolWithTag(header->pNonPagedPoolAddr, 0x3030534C).

Этого сбоя можно избежать, установив значение pNonPagedPoolAddr куда-то в KUSER_SHARED_DATA, но этот подход слишком сложен. Кроме того, этот подход осложнен тем, что ExAllocatePoolWithTag может вернуть адрес в KUSER_SHARED_DATA (и, возможно, вызвать сбой), даже если освобождение пройдет успешно.

Решение этой проблемы заключается в том, чтобы приравнять offsetOrLength к большому значению так, чтобы &newHeader->pNetRawBuffer[compressHeader.offsetOrLength] указывало непосредственно на адрес pMDL1. Это позволит избежать перезаписи pNonPagedPoolAddr, по крайней мере, при переполнении буфера в (B).

Однако, это еще не все. Далее следует рассмотреть переполнение буфера в (C). Как можно было заметить, memmove, в конечном счете, все равно будет перезаписывать pNonPagedPoolAddr, т. к. &newHeader→pNetRawBuffer[compressHeader.offsetOrLength-8] указывает на pNonPagedPoolAddr. Чтобы избежать этого, следует заставить srvnet!SmbCompressionDecompress завершиться с ошибкой. Это приведет к вызову SrvNetFreeBuffer(newHeader), но сломанный буфер останется доступен в списке lookaside, откуда он впоследствии может быть восстановлен.

Самый простой способ заставить srvnet!SmbCompressionDecompress завершиться ошибкой – с помощью некорректной LZNT1 нагрузки. Это потребует небольшого реверс инжиниринга nt!RtlDecompressBufferLZNT1. Даже если передать искаженную полезную нагрузку в nt!RtlDecompressBufferLZNT1, распаковка нагрузки будет продолжаться, пока не будет найден сломанный фрагмент, что позволит переписать pMDL и вызвать ошибку распаковки одновременно.

На этом этапе становится понятно, что чтобы получить примитив чтения, необходимо подделать структуру MDL в KUSER_SHARED_DATA, используя примитив записи, а затем установить pMDL на адрес подделанной структуры.

Победа над PML4 рандомизацией

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

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

Примечательная особенность механизма подкачки в Windows заключается в том, что он реализуется с помощью ссылки на самого себя. Это позволяет использовать PML4 в качестве PDP (Page Directory Pointer), PD (Page Directory) и PT (Page Table). Этот механизм позволяет легко вычислить виртуальный адрес PTE, соответствующий определенному виртуальному адресу, поскольку все виртуальные адреса PTE сразу же фиксируются, как только задается индекс ссылки на самого себя. Доклад ”Core Security on the Windows paging mechanism” позволит изучить этот вопрос подробнее.

Согласно статье, в то время как изменение PTE является распространенным методом создания удобного для злоумышленников пространства памяти для эксплойтов ядра, в Anniversary Update Windows 10 уже были предприняты меры по борьбе с этим вектором. Это не значит, что механизм ссылок на самого себя перестал использоваться, просто теперь индекс ссылки на самого себя в PML4 рандомизирован, что в свою очередь рандомизирует виртуальные адреса PML4 и PTE.

В отличие от виртуального адреса PML4 физический адрес рандомизирован непреднамеренно. Память под PML4 выделяется в ArchpAllocateAndInitializePageTables, реализованных в BIOS/UEFI. Реверс инжиниринг bootmgr.exe и bootmgfw.efi подтвердил отсутствие механизма рандомизации адресов, но это не значит, что для PML4 физический адрес задан жестко. Было проведено исследование на qemu, VMWare, VirtualBox и ThinkPad, которое показало, что физический адрес PML4 соответствует 0x1aa000 в случае с BIOS, и 0x1ad000 в случае с UEFI. Вполне возможно, что в каких-то ситуациях адрес будет отличаться, однако можно предположить, что в подавляющем количестве ситуаций он будет именно таким.

(https://1.bp.blogspot.com/-872FzmO8k7A/Xpq8g2yMFQI/AAAAAAAAA-Y/nCdWONOvfL009XN9XmLWdz3YNzV-regPQCLcBGAsYHQ/s1600/PML4_Allocation_BIOS.png)

(https://1.bp.blogspot.com/-dIAdUfRMO_w/Xpq8iw__mEI/AAAAAAAAA-c/trtfmyDxwJg45lV3DpUWadzdT0QY2sMOgCLcBGAsYHQ/s1600/PML4_Allocation_UEFI.png)

Таким образом, PML4 может быть сдамплено с помощью физического примитива чтения. Возможность чтения физических страниц как MMU позволяет также читать PDPE, PDE и PTE. Это открывает возможность по переводу виртуальных адресов в физические и, как следствие, использовать примитив физического чтения для чтения виртуальных адресов (после их перевода в соответствующий физический адрес).

Получение указателя инструкции и обход CFG

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

Первая. Поскольку произвольное чтение из виртуальных адресов уже достигнуто, один из возможных вариантов получения адреса указателя инструкции – это поиск полезных адресов в “мусоре” в куче ядра. Например, рассмотрим ситуацию, когда pNetRawBuffer изначально имеет значение X. Во-первых, pNetRawBuffer перезаписывается так, чтобы он указывал на что-то другое (скажем, адрес Y). Затем последующие операции в srv2.sys будут ссылаться на Y. Это оставляет X неинициализированным.

Т.к. MDL (который имеет физический адрес X) указывает, где происходит утечка, данные неинициализированного места памяти X можно будет увидеть в куче ядра. После получения из него действительного адреса можно снова использовать примитив виртуального чтения для извлечения дополнительных адресов, пока не будет найден указатель функции для перезаписи.

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

Вторая. Вместо этого, можно искать физические страницы как PML4 в HAL куче. В отличие от физического адреса PML4 физический адрес кучи HAL может варьироваться в зависимости от систем. Презентация  Alex Ionescu включает в себя подробное объяснение этого феномена. В то время как физический адрес кучи HAL меняется в зависимости от окружающей среды, грубый поиск этой страницы не так уж и сложен. Проверка физических адресов в нескольких средах показала, что адрес был не более 0x10f000. Кроме того, можно легко проверить, действительно ли просочившаяся страница находится в куче HAL. Необходимо найти HalpInterruptController, который содержит ряд указателей на функции HAL. Сравнивая просочившиеся адреса со смещениями этих функций, можно точно выполнить проверку, хотя этот метод зависит от версий Windows 10 и требует регистрации всех возможных комбинаций смещений (именно этот способ использовал автор оригинальной статьи).

Самая универсальная. Согласно этой презентации получить физический адрес PML4 и некоторые другие полезные виртуальные адреса на большинстве систем можно, читая физический адрес 0x1000. Это позволит создать более быстрый и универсальный эксплоит.