Эта статья является продолжением моей серии трюков по эксплуатации Windows. Здесь описывается трюк с эксплуатацией, который я пытался разработать в течение многих лет, преуспев (в основном, подробнее об этом позже) в последних версиях Windows 10. Этот трюк, позволяющий перехватить доступ к виртуальной памяти, получить обратную связь, когда это происходит, и отложить доступ на неопределенное время. В блоге будут рассмотрены некоторые предпосылки того, почему этот метод полезен, обзор исследований, которые я провел, чтобы найти этот трюк, а также обзор типов уязвимостей, с которыми он может использоваться.
Background
Когда вам понадобится такой трюк с эксплуатацией? Хороший пример типов уязвимостей безопасности, которые могут быть полезны, можно найти в основополагающем исследовании Bochspwn (https://storage.googleapis.com/pub-tools-public-publication-data/pdf/42189.pdf), проведенном Матеушем Юрчиком и Гинваэлем Кулвинд. Исследование показало способ автоматизации обнаружения двойных выборок из памяти в ядре Windows.
Если вы не читали статью, двойная выборка - это тип уязвимости Time-of-Check Time-of-Use (TOCTOU), когда код считывает значение из памяти, например длину буфера, и проверяет, находится ли значение в пределах границы, а затем повторно считывает значение из памяти перед использованием. При замене значения в памяти между первой и второй выборками проверка обходится, что может привести к проблемам безопасности, таким как повышение привилегий или раскрытие информации. Ниже приведен простой пример двойной выборки, взятый из исходной статьи.
Этот код копирует буфер из адреса управляемого пользовательского режима в буфер стека фиксированного размера. Буфер начинается со значения размера DWORD, которое указывает общий размер буфера. Повреждение памяти может произойти, если значение размера, на которое указывает lpInputBuffer, изменяется между первым чтением значения размера для сравнения с размером буфера (1) и вторым чтением размера при копировании в буфер (2). Например, если в первый раз будет считано значение 100, а во второй - 400, тогда код пройдет проверку размера, поскольку 100 меньше 256, но затем скопирует 400 байтов в этот буфер, повредив стек.
Как только такая уязвимость была обнаружена, Матеуш и Гинваэль должны были экспулатировать ее. Как они достигли эксплуатации, подробно описано в разделе 4. Все идентифицированные методы эксплойтов были вероятностными. Для эксплуатации обычно требовалось два потока, участвующих в гонке друг с другом, с одним чтением и одним записью. Вероятностный характер успеха обусловлен вероятностью того, что между первым чтением из области памяти и вторым чтением записывающий поток устанавливает новое значение, которое использует уязвимость.
Чтобы расширить окно TOCTOU, многие из описанных методов злоупотребляют поведением виртуальной памяти в Windows. Процесс в Windows обычно может получить доступ к большой области виртуальной памяти размером до 8 Т. Этот размер, вероятно, будет значительно больше, чем физическая память в системе, особенно с учетом ограничения на процесс, а не на систему. Поэтому, чтобы поддерживать иллюзию такого большого адресного пространства памяти, ядро использует подкачку памяти по запросу.
Когда в процессе выделяется память, таблицы страниц ЦП настраиваются так, чтобы указывать на наличие области памяти, но помечаются как недопустимые. На этом этапе область виртуальной памяти была выделена, но ее поддерживающей физической памяти нет. Когда процесс пытается получить доступ к этой области памяти, ЦП генерирует исключение, обычно называемое отказом страницы, которое обрабатывается ядром.
Ядро может найти адрес памяти, к которому обращались, чтобы вызвать отказ страницы, и попытаться исправить адрес. Способ устранения страничной ошибки зависит от типа доступа к памяти. Простой пример: если память была выделена, но еще не использована, ядро получит страницу физической памяти, инициализирует ее нулями, а затем скорректирует таблицы страниц, чтобы отобразить эту новую страницу физической памяти по адресу сбоя. Как только ошибка страницы будет исправлена, сбойный поток может быть перезапущен с помощью инструкции, обращающейся к памяти, и теперь доступ к памяти должен быть успешным, как если бы он всегда присутствовал.
Более сложный сценарий - если страница является частью файла с отображением памяти. В этом случае ядру потребуется запросить считывание данных страницы с диска, прежде чем оно сможет устранить ошибку страницы. Это может занять довольно много времени, по крайней мере, для дисков, поэтому может потребоваться приостановка сбойного потока, пока он ожидает чтения страницы. После того, как страница была прочитана, память может быть восстановлена, исходный поток может быть возобновлен, а поток перезапущен с помощью инструкции, вызвавшей ошибку.
Конечным результатом является то, что обработка ошибки страницы может занять значительное количество времени по сравнению с собственной скоростью процессора. Однако злоупотребление этим поведением виртуальной памяти только расширяет окно TOCTOU, оно не позволяет точно рассчитать время для обмена значениями в памяти. В результате методы эксплуатации по-прежнему имели ограничения. Например, использование уязвимостей на машине с одним ядром ЦП было очень медленным, а иногда и невозможным, поскольку оно полагалось на одновременное чтение и запись потоков.
Идеальным примитивом эксплойта был бы такой, в котором окно эксплойта можно сделать произвольно большим, чтобы выиграть гонку стало тривиально. Принимая во внимание предыдущий опыт и знания существующих классов ошибок, моим идеальным примитивом был бы тот, который соответствует набору критериев:
- Работает при установке Windows 10 20H2 по умолчанию.
- Дает четкий сигнал при чтении или записи памяти.
- Работает, когда доступ к памяти осуществляется как из пользовательского режима, так и из режима ядра.
- Позволяет отложить доступ к памяти на неопределенный срок.
- Данные в доступной памяти произвольны.
- Примитив может быть настроен из ряда уровней привилегий.
- Может поймать несколько раз за один и тот же эксплойт.
Хотя соответствие всем этим критериям было бы идеальным, нет никакой гарантии, что мы выполним все или любые из них. Если мы встретим только некоторые из них, диапазон уязвимостей эксплуатации может быть ограничен. Давайте начнем с краткого обзора существующей работы, которая может дать нам представление о том, как продолжить поиск примитива.
Существующая работа
Поговорив с Матеушем и попытавшись найти любую последующую работу, кажется, что есть немного новой работы помимо оригинальной статьи Bochspwn по использованию этих типов проблем TOCTOU. По крайней мере, это верно для эксплуатации в Windows, однако новые методы были разработаны для других платформ, в частности для Linux (https://static.sched.com/hosted_files/lsseu2019/04/LSSEU2019 - Exploiting race conditions on Linux.pdf). Оба эти метода основаны на поведении виртуальной памяти, которое я описал ранее.
Первый метод в Linux использует дескриптор файла Userfault (userfaultfd) (https://man7.org/linux/man-pages/man2/userfaultfd.2.html) для получения уведомлений, когда в процессе возникают ошибки страниц. При включенном userfaultfd вторичный поток в процессе может читать уведомление и обрабатывать отказ страницы в пользовательском режиме. Обработка ошибки может заключаться в отображении памяти в соответствующем месте или изменении защиты страницы. Ключ в том, что сбойный поток приостановлен до тех пор, пока сбой страницы не будет обработан другим потоком. Следовательно, если функция ядра обращается к памяти, запрос будет задерживаться до завершения. Это позволяет использовать примитив, в котором доступ к памяти может быть отложен на неопределенное время, а также иметь сигнал синхронизации для доступа.
Использование userfaultfd также позволяет различать ошибки чтения и записи, поскольку страница памяти может быть защищена от записи. Использование userfaultdd работает для доступа внутри процесса, например, из ядра, но не очень полезно, если код, обращающийся к памяти, находится в другом процессе. Чтобы решить эту проблему, вы можете использовать файловую систему FUSE, как показал Янн Хорн в предыдущем сообщении блога Project Zero. Файловая система FUSE полностью реализована в пользовательском режиме, но любые запросы файла проходят через API виртуальной файловой системы ядра Linux. Поскольку доступ к файлу осуществляется так, как если бы он был реализован файловой системой в ядре, можно отобразить этот файл в память с помощью mmap. Когда сбой страницы происходит в области памяти, поддерживаемой FUSE, будет сделан запрос к демону файловой системы пользовательского режима, который может задержать запрос чтения или записи на неопределенное время.
Удаленные файловые системы
Насколько я могу судить, нет ничего эквивалентного Linux userfaultd в Windows. Одна особенность, которая привлекла мое внимание, - это memory write watches. Но они, похоже, просто позволяют приложению запрашивать, была ли произведена запись в память с момента последней проверки, и не позволяют захватить записи в память.
Если мы не можем просто отлавливать ошибки страниц в виртуальной памяти, как насчет проецирования файла с файловой системой пользовательского режима, такой как FUSE? К сожалению, в Windows 10 нет встроенного драйвера FUSE (пока?), но это не означает, что нет механизма для реализации файловой системы в пользовательском режиме. Есть некоторые попытки создать настоящий FUSE в Windows, например проект WinFsp, но я ожидаю, что шансы их установки в реальной системе будут исчезающе малы.
Первой моей мыслью было попытаться использовать клиентов с несколькими поставщиками UNC (MUP). Когда вы обращаетесь к файлу через UNC-путь, например \\server\share\file.bin, это будет обрабатываться драйвером MUP в ядре, который передаст его одному из зарегистрированных клиентских драйверов. Что касается ядра, то открытый файл является обычным файлом (с некоторыми оговорками), что обычно означает, что файл может быть отображен в памяти. Однако любые запросы содержимого этого файла не будут обрабатываться напрямую, а вместо этого будут обрабатываться сервером по сетевому протоколу. В идеале мы должны иметь возможность реализовать собственный сервер, обрабатывать запросы на чтение или запись в сопоставленый файл, что позволит нам обнаруживать или задерживать запрос, чтобы мы могли использовать любой TOCTOU. В следующей таблице содержатся только указанные мной драйверы Microsoft MUP. В таблице указано, в каких версиях Windows 10 поддерживается драйвер и включено ли что-то по умолчанию.
Хотя MUP был разработан для удаленных файловых систем, фактически удаленный сервер файловой системы не требуется. SMB, WebDAV и NFS - это протоколы на основе IP, которые могут быть перенаправлены на localhost. P9 использует локальный сокет Unix, который в любом случае нельзя удалить удаленно. Клиент служб терминалов отправляет запросы доступа к файлам обратно в клиентскую систему по протоколу RDP. Для всех этих протоколов мы можем реализовать сервер с разной степенью усилий и посмотреть, сможем ли мы обнаруживать и задерживать чтение и запись в отображаемые файлы.
Я решил сосредоточиться только на двух протоколах: SMB и WebDAV. Это были единственные два протокола, которые включены по умолчанию и просты в использовании. Хотя клиент удаленного рабочего стола теоретически установлен по умолчанию, сервер RDP обычно не включен по умолчанию. Кроме того, настройка сеанса RDP сложна и может потребовать действительных учетных данных для аутентификации, поэтому я отказался от него.
Блок сообщений сервера
SMB почти такой же старый, как сама Windows, он был представлен в Lan Manager 1.0 еще в 1987 году. Последний протокол SMB версии 3.1 имеет лишь небольшое сходство с той исходной версией, которая потеряла свои корни NetBIOS для соединения TCP/IP. Его происхождение означает, что это лучшая интеграция из всех сетевых файловых систем, а API-интерфейсы MUP разработаны с учетом потребностей SMB.
Я решил провести простой тест поведения сопоставления файла по SMB. Это довольно просто, поскольку вы можете получить доступ к SMB на том же компьютере через localhost. Сначала я создал файл размером 1 ГБ на локальном диске, поскольку, если SMB поддерживает кэширование файловых данных, вряд ли удастся прочитать что-то настолько большое за один раз. Затем я запустил Wireshark и отслеживал петлевой интерфейс для захвата трафика SMB, как показано ниже.
Затем я написал быстрый сценарий на PowerShell, который отобразит файл в память, а затем прочитает несколько байтов из памяти с несколькими разными смещениями.
Он просто считывает 4 байта со смещения, 0, 256МБ, 512МБ и 768МБ. Возвращаясь к Wireshark, я отфильтровал вывод только для запросов чтения SMBv2, используя фильтр отображения smb2.cmd == 8, и можно было наблюдать следующие четыре пакета.
Это соответствует точным смещениям памяти, к которым мы обращались в сценарии, хотя длина всегда составляет 32 КБ, а не 4, которые мы запрашивали. Обратите внимание, что это не типичная для Windows степень детализации выделения памяти в 64 КБ, которую можно было бы ожидать. В своем тестировании я никогда не видел ничего, кроме запрашиваемых 32 КБ.
Все байты, которые мы протестировали, выровнены по блоку 32 КБ, что, если бы байты не были выровнены, например, если мы получили доступ к 4 байтам с адреса 512 МБ минус 2? Изменение скрипта на добавление следующего позволяет нам проверить поведение:
В Wireshark мы видим следующие запросы на чтение.
Доступ по-прежнему находится на границе 32 КБ, однако, поскольку запрос охватывает два блока, ядро извлекло из файла предыдущие 32 КБ данных, а затем следующие 32 КБ. Вы могли подумать, что все имеет смысл, однако такое поведение оказалось случайностью тестирования.
Это обзорная диаграмма схемы чтения памяти. В центре находится набор полей, представляющих читаемые страницы размером 4 КиБ. Все поля находятся в одной большой области, которая является большой страницей. Над полями находятся стрелки, которые показывают, что из основания блока 4 КиБ будет выполнено чтение 32 КБ в файл, который может удовлетворить чтения с других страниц 4 КиБ. Последнее поле показывает, что последние 32 КБ большого размера страницы всегда будут считываться как одна страница, независимо от того, где в поле происходит чтение.
На приведенной выше диаграмме показана структура обработки чтения сопоставленного файла. Когда адрес считывается, ядро запрашивает 32 КБ от ближайшей границы страницы 4 КБ, а не от границы 32 КБ. Однако есть вторичная структура сверху, основанная на поддерживаемом размере больших страниц. Если чтение находится где-нибудь в пределах 32 КБ от конца большой страницы, смещение чтения всегда для последних 32 КБ.
Например, в моей системе большой размер страницы (при запросе с использованием API GetLargePageMinimum) составляет 2 МБ. Поэтому, если вы начнете со смещения 512 МБ, между 512 и 514 - 32 КБ, ядро будет читать 32 КБ от смещения, усеченного до ближайшей границы 4 КБ. Между 514–32 КБ и 514 МБ при чтении всегда будет запрашиваться смещение 514–32 КБ, чтобы 32 КБ не пересекали границу большой страницы.
Это позволяет читать на границах 4 КБ, однако объем считываемых данных по-прежнему составляет 32 КБ. Это означает, что при обращении к одной странице размером 4 КиБ ядро заполнит текущую страницу и 7 следующих страниц. Есть ли способ заполнить только одну нативную страницу? Основываясь на комментарии Матеуша, я протестировал возврат короткого чтения. Если сервер SMB возвращает меньше байтов, чем было запрошено при чтении, то вместо отказа он заполняет только страницы, охваченные чтением. Возвращая эти короткие чтения, мы можем уменьшить степень детализации ловушки до нативного размера страницы, за исключением последних 32 КБ большой страницы. Если запрос на чтение короче нативного размера страницы, остальная часть страницы обнуляется.
А как насчет письма? Давайте снова изменим скрипт на вызов WriteBytes, а не ReadBytes, например:
Вы увидите запрос на запись в файл в Wireshark, подобный следующему:
Однако если копнуть немного глубже, то можно заметить, что запись происходит только после закрытия файла, а не в ответ на вызов WriteBytes. В этом есть смысл, нет простого способа определить, когда произошла запись, чтобы принудительно сбросить страницу обратно в файловую систему. Даже если бы существовал способ сброса на сетевой сервер для каждой записи, это оказало бы огромное влияние на производительность.
Однако еще не все потеряно, прежде чем память станет безопасной для записи, она должна быть заполнена содержимым файла. Поэтому, если вы посмотрите перед записью, вы увидите соответствующий запрос на чтение для области 32 КБ, которая охватывает место записи, синхронное с чтением. Вы можете обнаружить запись через соответствующее чтение, но не можете отличить чтение от записи на уровне протокола.
Все это тестирование показывает, если у нас есть контроль над сервером, мы можем обнаружить доступ к памяти для сопоставленного файла. Можем ли мы отложить доступ? Я написал простой SMB-сервер на .NET 5, используя SMBLibrary Тала Алони. Я реализовал сервер с пользовательским обработчиком файловой системы и добавил код в путь чтения, который задерживается на 10 секунд, когда смещение файла превышает 512 МБ.
Данные, возвращаемые операцией чтения, могут быть произвольными, вам просто нужно заполнить соответствующие байтовые буферы при чтении. Чтобы проверить время доступа, я заключил запросы на чтение из памяти в вызов Measure-Command, чтобы рассчитать время доступа к памяти.
Для сравнения времени доступа выполняется запрос на чтение к расположению на 4 байта ниже границы 512 МБ, а затем к границе 512 МБ. Сделав два запроса, мы сможем увидеть, отличаются ли результаты при чтении. Результаты были следующими:
Первый доступ для менее 512 МБ занимает около секунды, это связано с тем, что запрос по-прежнему необходимо отправить на сервер, а сервер написан на .NET, который может иметь медленное время запуска для запуска нового кода. Второй запрос занимает значительно меньше 1 секунды, память теперь кэшируется локально, поэтому никаких запросов не требуется.
Для обращений выше 512 МБ первый запрос занимает около 10 секунд, что коррелирует с добавленной задержкой. Второй запрос занимает меньше секунды, потому что страница теперь кэшируется локально. Это именно то, чего мы ожидали, и доказывает, что мы можем задержаться хотя бы на 10 секунд. Фактически вы можете отложить запрос как минимум на 60 секунд, прежде чем соединение будет принудительно сброшено. Это основано на тайм-ауте сеанса для клиента SMB. Вы можете запросить тайм-аут клиента SMB, используя следующую команду в PowerShell:
Несколько замечаний о поведении SMB-клиента по результатам тестирования. Сначала кажется, что клиент или диспетчер кеша Windows могут кэшировать удаленный файл. Если вы запрашиваете определенный доступ при открытии файла, например GENERIC_READ | GENERIC_WRITE для желаемого доступа, тогда кеширование включено. Это означает, что запросы на чтение не поступают на сервер, если они ранее были кэшированы локально. Однако если вы укажете MAXIMUM_ALLOWED для желаемого доступа, кеширование, похоже, не произойдет. Во-вторых, иногда части файла предварительно кэшируются, например, первые и последние 32 КБ файла. Я не выяснил, в чем причина, как ни странно, это происходит чаще с нативным кодом, чем с кодом .NET, так что, возможно, Защитник Windows заглядывает в память или, возможно, Superfetch. В общем, пока вы храните доступ к памяти где-то в середине большого файла, вы должны быть в безопасности.
Если вы запускали пример кода, вы могли заметить проблему, запуск примера сервера локально завершается ошибкой со следующей ошибкой:
System.Net.Sockets.SocketException (10013): An attempt was made to access a socket in a way forbidden by its access permissions.
По умолчанию в Windows 10 включен SMB-сервер. Это берет на себя порты TCP и делает их эксклюзивными, поэтому привязка к ним от обычного пользователя невозможна. Можно отключить локальный сервер SMB, но для этого потребуются права администратора. Тем не менее, стоило проверить, будет ли подход SMB-сервера работать, даже если нам придется связываться с удаленным сервером.
Я провел небольшое исследование, которые можно было бы использовать, чтобы заставить встроенный SMB-сервер работать в наших целях. Например, я попытался использовать тот факт, что вы можете установить Opportunistic блокировку, которая блокирует чтение файлов. Я использовал этот трюк, чтобы эксплуатировать уязвимость TOCTOU в драйвере LUAFV. К сожалению, сервер SMB обнаруживает, что файл уже заблокирован, и ждет прерывания OpLock, прежде чем разрешить доступ к файлу.
Для тестирования вы можете отключить службу LanmanServer и соответствующие ей драйверы. Если вы хотите использовать это в произвольной системе, вам почти наверняка потребуется подключиться к удаленному серверу. Я выпустил здесь пример кода сервера, который можно изменить, хотя это всего лишь демонстратор. Он обеспечивает детализацию чтения исходного размера страницы, который предполагается равным 4 КиБ. Код сервера должен работать в Linux, но начиная с версии 1.4.3 библиотеки SMBLibrary в NuGet есть ошибка, которая приводит к сбою сервера при запуске. В репозитории github есть исправление, но на момент написания не было обновленного пакета.
Насколько хорошо злоупотребление клиентом SMB соответствует нашим критериям, изложенным ранее? Я вычеркнул все, что мы встречали ранее.
- Работает при установке Windows 10 20H2 по умолчанию.
- Дает четкий сигнал при чтении или записи памяти.
- Работает, когда доступ к памяти осуществляется как из пользовательского режима, так и из режима ядра.
- Позволяет отложить доступ к памяти на неопределенный срок.
- Данные в доступной памяти произвольны.
- Примитив может быть настроен из ряда уровней привилегий.
- Может поймать несколько раз за один и тот же эксплойт.
Использование клиента SMB действительно соответствует большинству наших критериев. Я проверил, что не имеет значения, получает ли код ядра или пользовательского режима доступ к памяти, которую он все еще будет перехватывать. Самая большая проблема заключается в том, что это трудно использовать из изолированного приложения, где это, возможно, было бы наиболее полезно. Это связано с тем, что MUP по умолчанию ограничивает доступ к удаленным файловым системам для процессов с ограниченным и низким IL, а для песочниц AppContainer требуются определенные возможности, которые вряд ли будут предоставлены большинству приложений. Нельзя сказать, что это совершенно невозможно, но сделать это будет сложно.
Хотя наш трюк на самом деле не задерживает чтение памяти на неопределенный срок, для наших целей ограничение в 60 секунд на основе тайм-аута сеанса SMB будет достаточным для большинства уязвимостей. Кроме того, после активации ловушки вы не можете заставить диспетчер памяти запрашивать ту же страницу с сервера. Я пробовал играть с флагами кэширования памяти и прямым вводом-выводом, но, по крайней мере, для файлов через SMB ничего не работало. Однако вы можете указать свой собственный базовый адрес при сопоставлении файла, чтобы вы могли сопоставить разные смещения в файле с одним и тем же виртуальным адресом, отключив оригинал и сопоставив в новой копии. Это позволит вам использовать один и тот же адрес несколько раз.
WebDAV
Как насчет WebDAV, поскольку локально использовать SMB непросто? По умолчанию TCP-порт 80 не используется в Windows 10, поэтому мы можем запустить собственный веб-сервер для связи. Также, в отличие от Linux, не требуется иметь права администратора для привязки к TCP-портам ниже 1024. Даже если это не так, клиент WebDAV поддерживает синтаксис для указания TCP-порта сервера. Например, если вы используете путь \\localhost@8080\ share, тогда HTTP-соединение WebDAV будет выполнено через порт 8080.
Однако предоставляет ли клиент WebDAV правильные примитивы чтения и записи, позволяющие нам перехватить доступ к памяти? Я написал простой сервер WebDAV, используя библиотеку NWebDav для обслуживания локальных файлов. Запустив сценарий, но указав сервер WebDAV на порту 8080, чтобы открыть файл размером 1 ГБ, я сразу же столкнулся с проблемой:
Get-NtFile : (0xC0000904) - The file size exceeds the limit allowed and cannot be saved.
Просто открыть файл не удается с кодом ошибки STATUS_FILE_TOO_LARGE. Причину этого можно найти в одной из многих статей базы знаний Microsoft, таких как эта. По умолчанию установлено ограничение в 50 МБ (то есть в десятичных мегабайтах) для любого файла, доступ к которому осуществляется через общий ресурс WebDAV, поскольку раньше можно было вызвать отказ в обслуживании, обманом заставив систему Windows загрузить файл произвольно большого размера.
Причина, по которой существует такое поведение ограничения размера, заключается в том, что WebDAV не подходит для этой атаки. Если вы измените размер файла до менее 50 МБ, вы обнаружите, что клиент WebDAV полностью переносит файл на локальный диск, прежде чем вернуться из вызова для открытия файла. Затем этот файл отображается в памяти как локальный файл. Сервер WebDAV никогда не получает запрос GET или PUT для синхронного чтения/записи в отображение памяти, поэтому нет механизма для обнаружения или перехвата определенных запросов к памяти.
API для наложения на файловую систему
Злоупотребление SMB-клиентом действительно работает, но его нельзя использовать локально при установке по умолчанию. Я решил, что нужно искать другой подход. Когда я просматривал драйверы фильтров Windows, я заметил, что некоторые из драйверов предоставляют механизм для наложения другой файловой системы поверх существующей. Я пролистал MSDN, чтобы найти документацию по API, чтобы посмотреть, подойдет ли что-нибудь. Три, на которые я смотрел, показаны в таблице ниже.
Безусловно, наиболее интересной из них является Проектируемая Файловая Система. Она была разработана Microsoft, чтобы предоставить виртуальную файловую систему для GIT. Она позволяет "проецировать" файлы в каталог на диске, и содержимое этих файлов толь ко "регидратируется" до полного файла по запросу. Теоретически это звучит идеально, поскольку до тех пор, пока содержимое файла заполняется по частям, мы могли бы добавить задержки при получении обратного вызова PRJ_GET_FILE_DATA_CB.
Однако базовая реализация, основанная на образце кода Microsoft ProjectedFileSystem, всегда будет повторно гидратировать весь файл во время открытия файла, подобно WebDAV. Возможно, я пропустил вариант потоковой передачи содержимого вместо того, чтобы заполнить его за один раз, но я не смог найти его сразу. В любом случае Проецируемая Файловая Система не устанавливается по умолчанию, что делает ее менее полезной.
WOF на самом деле не позволяет вам реализовать собственную семантику файловой системы. Вместо этого он позволяет накладывать файлы либо из вторичного файла образа Windows (WIM), либо из сжатых на том же томе. Это действительно не дает нам контроля, который мы ищем, возможно, вам удастся заставить что-то работать, но, похоже, это требует больших усилий.
Остается API Cloud Files. Он используется OneDrive для обеспечения локальной файловой системы в Интернете, но задокументирован и может использоваться для реализации любого наложения файловой системы, которое вам нравится. API работает очень похоже на проектируемую файловую систему и концепцией гидратации файла по запросу. Содержимое файлов не обязательно должно поступать из какой-либо онлайн-службы, такой как OneDrive, все это может быть получено локально. Важно отметить, что после некоторого базового тестирования он поддерживает потоковую передачу содержимого файла на основе того, что читается, и вы можете отложить запросы данных файла, и поток чтения будет блокироваться до тех пор, пока чтение не будет удовлетворено. Это можно включить, указав политику гидратации CF_HYDRATION_POLICY_PRIMARY со значением CF_HYDRATION_POLICY_PARTIAL при настройке базового корня синхронизации. Это позволяет Cloud File API гидратировать только те части файла, к которым был осуществлен доступ.
Это казалось идеальным, пока я не протестировал скрипт сопоставления файлов PowerShell, где он не работал, у моего поставщика облачных файлов всегда требовалось предоставить весь файл. При проверке драйвера Cloud Filter, когда получен запрос на сопоставление файла-заполнителя, обработчик IRP_MJ_ACQUIRE_FOR_SECTION_SYNCHRONIZATION всегда полностью регидрирует файл перед завершением. Если файл не гидратирован полностью, вызов NtCreateSection никогда не возвращается, что предотвращает отображение файла в памяти.
Я собирался вернуться к исследованиям фильтров, пока не понял, что могу объединить loopback клиента SMB с API Cloud Filter. Я уже знал, что клиент SMB на самом деле не отображает файл, даже локально, вместо этого он будет читать его по запросу через протокол SMB. И я также знал, что Cloud Filter API позволит потоковую передачу частей файла по запросу, пока файл не отображается в памяти. Окончательная настройка показана на следующей диаграмме:
Чтобы использовать примитив, мы сначала настраиваем нашего собственного облачного провайдера, регистрируя корневой каталог синхронизации с помощью API CfRegisterSyncRoot, настраивая его с политикой частичной гидратации. Затем в каталоге можно создать заполнитель размером 1 ГБ с помощью CfCreatePlaceholder. На данный момент у файла нет содержимого на диске. Если мы теперь откроем и сопоставим файл заполнителя через loopback клиент SMB, файл не будет немедленно регидратирован.
Любой доступ к памяти в сопоставлении приведет к тому, что клиент SMB сделает запрос на блок размером 32 КБ, который будет передан нашему облачному провайдеру пользовательского режима, который мы можем обнаружить и задержать при необходимости. Само собой разумеется, что содержимое файла также может быть произвольным. На основании тестирования не похоже, что вы можете уменьшить степень детализации чтения до исходного размера страницы, как при реализации настраиваемого SMB-сервера, однако вы все равно можете делать запросы на границах собственного размера страницы в пределах ограничения большого размера страницы. Можно было бы изменить размер файла, чтобы заставить SMB-сервер выполнять короткие чтения, но это поведение не проверялось. Пример реализации облачного провайдера доступен здесь. (https://bugs.chromium.org/p/project-zero/issues/detail?id=2142)
Примеры использования
Теперь у нас есть трюк эксплуатации, который позволяет нам захватывать и задерживать чтение и запись виртуальной памяти. Большой вопрос в том, улучшит ли это эксплуатацию уязвимостей, таких как двойная выборка? Ответ зависит от реальной уязвимости. Небольшое примечание: когда я использую слово страница, я имею в виду единицу памяти, которая вызовет запрос к серверу SMB, например 32 КБ, а не собственный размер страницы, например 4 КБ.
Давайте рассмотрим пример, приведенный в начале этого сообщения в блоге. Эта уязвимость дважды считывает значение из одного и того же адреса памяти, lpInputPtr. Сначала для сравнения, затем для копирования размера. Проблема эксплуатации - одно из ограничений метода - ловушка памяти . Как только ловушка сработает для чтения размера для сравнения, вы можете отложить его на неопределенное время. Однако, как только вы предоставите запрошенную страницу памяти и возобновите сбойный поток, он не будет запускаться при втором чтении, он просто будет считан из памяти, как если бы он всегда был там.
Вы можете спросить, можно ли переназначить страницу памяти при обнаружении первого чтения? К сожалению, это не работает. Когда поток возобновляется, он перезапускается по команде, вызвавшей сбой, и снова выполняет чтение, поэтому произойдет следующее:
Как видно из диаграммы, вы попадаете в бесконечный цикл, поскольку переназначаете новую страницу, которая вызывает еще одну ошибку страницы до бесконечности. Если вы не выполните шаг [3], операция будет завершена, и между возобновлением потока, чтением теперь действующей памяти для сравнения размеров и вторым чтением будет промежуток времени. Однако в этом примере временное окно, вероятно, будет состоять из пары инструкций, поэтому использование нашего трюка эксплуатации не лучше существующих вероятностных подходов. Тем не менее, одним из преимуществ является то, что вы знаете, когда происходит чтение, что позволяет вам более точно нацеливать окно брутфорса.
Этот пример - наихудший случай, что, если бы между чтениями было больше времени? Другой пример из статьи Bochspwn показан ниже:
Присутствует такое же поведение двойной выборки, но отличается то, что значение передается другой функции, в данном случае ExAllocatePool, которая выделяет память ядра. В зависимости от текущей конфигурации памяти или размера запрошенного распределения между [1] и [2] может быть значительная временная задержка. Есть ли способ выиграть гонку?
Ну, не то чтобы я знал, но мы можем эксплуатировать одно поведение, чтобы попытаться немного синхронизировать потоки чтения и записи. Напомним, что для записи на неразрешенную страницу содержимое страницы необходимо сначала прочитать с сервера. Следовательно, для поддержания согласованности любой поток, записывающий на неразрешенную страницу, должен генерировать ошибку страницы и ждать той же блокировки, что и другой поток, который просто читает со страницы, как показано на следующей диаграмме:
Синхронизируя потоки чтения и записи, вы даете себе разумный шанс вызвать запись в течение временного окна для эксплуатации. Это все еще вероятностный подход, он зависит от планировщика. Например, возможно, что поток записи пробуждается перед потоком чтения, что приведет к тому, что указатель всегда будет принимать окончательное значение. Или поток чтения может выполняться до завершения до того, как поток записи когда-либо будет запланирован для запуска, так что значение никогда не изменится. Возможно, есть некоторая магия планировщика, такая как использование нескольких потоков чтения или записи или выбор соответствующих приоритетов, которые вы могли бы использовать, чтобы гарантировать упорядочение чтения и записи. Я был бы удивлен, если бы что-то было надежным в нескольких системах Windows 10. Мне был бы очень интересен любой, у кого есть лучшие идеи о том, как повысить надежность этого.
Один из подходов, который может вас заинтересовать, - это невыровненный доступ, например, разделение значения на две отдельные страницы. С точки зрения микроархитектуры вполне вероятно, что чтение будет разделено на две части, сначала касаясь одной страницы, затем другой. Однако помните, как работает ошибка страницы: она генерирует исключение, которое вызывает выполнение обработчика в ядре. На этом этапе любая работа, уже проделанная инструкцией, будет удалена, пока ядро обрабатывает ошибку страницы. Когда поток возобновляется, он перезапускает сбойную инструкцию, которая повторно выполняет соответствующие микрооперации для чтения с невыровненного адреса. Если компилятор не сгенерировал две загрузки для невыровненного доступа (что может произойти на некоторых архитектурах), то я не знаю способа перезапустить инструкцию доступа к памяти на этом этапе.
Все это кажется немного мрачным с точки зрения полезности трюка эксплуатации. Дело в том, что существует столько различных типов уязвимости, сколько рыб в море. Например, если мы изменим исходный пример следующим образом:
Теперь проверка гарантирует, что размер буфера достаточно велик, а второй DWORD в буфере не установлен на 2. Второе поле может представлять тип буфера, а тип 2 не подходит для этого запроса. Если вы проверите вывод компилятора для этого кода, например, на Godbolt, разница в собственном коде составляет 2 или 3 инструкции. Казалось бы, это существенно не улучшит шансы на победу в гонке TOCTOU при использовании наивного вероятностного подхода. Но с нашим трюком эксплуатации мы теперь можем создать детерминированный эксплойт.
На диаграмме выше показано, как можно получить этот детерминированный эксплойт. Мы можем разместить поле "Размер" на странице, отличной от остальной части входного буфера, хотя буфер по-прежнему является непрерывным в виртуальной памяти. Первая страница (N-1) уже должна быть загружена в память и содержать поле размера, которое меньше размера LocalBuffer. Мы можем позволить считыванию размера [1] завершиться нормально.
Затем код будет читать поле Тип, которое находится на странице N [2]. Эта страница в настоящее время не находится в памяти, поэтому при обращении к ней произойдет ошибка страницы [3]. Это требует, чтобы ядро считало содержимое файла, кооторое мы можем обнаружить и задержать. Когда чтение обнаружено, у нас есть столько времени, сколько нам нужно изменить поле Size, чтобы оно содержало значение больше, чем размер LocalBuffer [4]. Наконец, мы завершаем чтение, которое перезапустит поток обратно в инструкции чтения поля Type [5]. Код может продолжаться и теперь будет считывать слишком большое поле размера и вызывать повреждение памяти.
Ключевой вывод заключается в том, что если между точками двойной выборки код касается любой памяти пользовательского режима, находящейся под вашим контролем, а не той, которую выбирают дважды, должно быть возможно преобразовать это в детерминированный эксплойт. Не имеет значения, имеет ли целевая система только один процессор, какой алгоритм планирования используется в ядре, сколько инструкций находится между точками двойной выборки или какой сейчас день недели и так далее, он должен "просто работать".
Следующее сообщение в блоге об эксплуатации с двойной выборкой дает некоторые цифры по уязвимости (https://j00ru.vexillium.org/2013/06...ndition-exploitation-on-x86-further-thoughts/). В примерах, показанных до сих пор, когда выбрано правильное временное окно, шанс успеха может достигать 100% через некоторое количество секунд. Однако, как показано здесь, мы можем получить 100% надежность для некоторых классов одной и той же ошибки, но в лучшем случае это не улучшение, а детерминированность.
Все примеры только демонстрируют эксплуатацию того, что в блоге называется арифметическими гонками. В блоге также упоминается второй класс ошибок, бинарные гонки, которые труднее использовать и никогда не достигают 100% успеха. Давайте посмотрим на пример в блоге и посмотрим, сработает ли наш трюк с эксплуатацией.
На первый взгляд, это не сильно отличается от предыдущих примеров, однако в этом случае изменяется указатель места назначения, а не размер. API ядра ProbeForWrite, который проверяет, что указатель находится как по адресу пользовательского режима, так и в памяти, доступной для записи. Это часто используемая идиома для проверки того, что указатель, указанный пользователем, не указывает на память ядра.
Если значение указателя изменяется между [1] и [2] с адреса пользовательского режима на адрес режима ядра, в этом примере будет перезаписана память ядра. Такое поведение сложнее использовать с помощью вероятностного эксплойта, поскольку существует только два допустимых значения указателя: адрес пользовательского режима или адрес режима ядра. Если вы выполняете перебор значения указателя, то можно закончить тем, что обе выборки читают указатель пользовательского режима, даже если он может измениться на указатель ядра между выборками.
К счастью, из-за вызова ProbeForWrite это тривиально эксплуатировать, если вы можете перехватить доступ к пользовательской памяти, как показано на следующей диаграмме:
Согласно диаграмме, выполняется первое чтение из UserPointer [1], а полученное значение указателя передается в ProbeForWrite. API ProbeForWrite сначала проверяет, находится ли указатель в адресном пространстве пользовательского режима, а затем проверяет каждую страницу памяти до размера параметра длины [2]. Если страница недействительна или недоступна для записи, то будет сгенерировано исключение и перехвачено блоком __except примера. Это дает нам возможность использовать уязвимости, мы можем использовать трюк эксплуатации на одной из проверяемых страниц пользовательского режима, что заставит ProbeForWrite сгенерировать ошибку страницы, которую мы можем перехватить [3]. Однако, поскольку проверяемый адрес не совпадает с адресом, в котором хранится указатель, мы можем изменить его, чтобы он содержал адрес режима ядра, пока запрос перехватывается [4].
В результате мы можем детерминированно выиграть гонку.
Конечно, я сосредоточился на двойной выборке ядра, поскольку именно это изначально побудило меня искать такое поведение. Есть много сценариев, в которых это можно использовать для облегчения эксплуатации приложений пользовательского режима. Самый очевидный - когда служба разделяет память с приложением с более низкими привилегиями. Примером такого рода проблем была двойная выборка в маршалере DfMarshal COM (https://bugs.chromium.org/p/project-zero/issues/detail?id=1648). Маршалер COM разделял раздел памяти между процессами, поэтому можно было предоставить раздел, который использовал бы наш трюк. В конце концов, в этом трюке не было необходимости, поскольку логика уязвимого кода позволила мне создать бесконечный цикл для расширения окна двойной выборки. Однако, если бы этого не было, мы могли бы использовать этот трюк для обнаружения и задержки, когда код находится в точке, где можно переключить дескриптор.
Другое более тонкое использование - это когда привилегированный процесс считывает память из менее привилегированного процесса. Это может быть явное использование API, таких как ReadProcessMemory, или косвенное, например, запрос командной строки процесса с помощью NtQueryInformationProcess будет считывать ячейки памяти, находящиеся под нашим контролем.
При использовании этого трюка эксплуатации следует помнить, что ее можно использовать, чтобы открыть окно и выиграть гонку на время. В этом случае это похоже на мою предыдущую работу по oplocks, но вместо доступа к памяти. На самом деле доступ к памяти может быть случайным для уязвимого кода, это не обязательно должна быть двойная выборка из памяти или даже уязвимость TOCTOU. Например, вы можете попытаться выиграть гонку между двумя путями к файлам с символическими ссылками. Пока уязвимый код может быть использован для проверки адреса пользовательского режима, который мы контролируем, вы можете использовать его в качестве сигнала синхронизации и для расширения окна эксплуатации.
Выводы
Я описал трюк с использованием SMB и Cloud File API, который может помочь в демонстрации использования определенных типов приложений и уязвимостей ядра. Возможно, есть и другие способы достижения аналогичного результата с помощью API, на которые я не обращал внимания, но пока это лучший подход, который я придумал. Это позволяет вам перехватить чтение из памяти пользовательского режима, определить, когда происходит доступ, и задержать чтение как минимум на 60 секунд. Примеры кода для реализации уловок SMB и Cloud File API доступны здесь (https://bugs.chromium.org/p/project-zero/issues/detail?id=2142).
Прежде чем мы закончим, стоит еще раз напомнить о некоторых ограничениях этого трюка с эксплуатацией.
- Нельзя использовать в песочнице, только с правами обычного пользователя.
- Допускается только один снимок для любой страницы, отображаемой из файла. Если что-то еще (например, AV) пытается прочитать эту страницу или файл, то ловушка может сработать раньше.
- Невозможно определить точное местоположение считываемого материала, степень детализации не превышает 4 КБ. Для локального доступа через Cloud File API всегда будут заполняться следующие 7 страниц, а также часть прочитанных 32 КБ. При доступе к настраиваемому серверу SMB размер чтения может быть уменьшен до 4 КиБ. Это предотвратит эксплуатацию определенных ошибок, которые требуют точного отлова только на небольшой площади внутри более крупной конструкции.
- Может обнаруживать записи только косвенно, не может специально отлавливать запись.
С практической точки зрения представленный здесь трюк не значительно улучшает процент выигрышей для традиционных двойных выборок ядра, описанных в статье Bochspwn. На практике для большинства этих классов уязвимостей вы, вероятно, захотите использовать вероятностный подход, во всяком случае из-за его простоты реализации. Однако трюк применим к другим классам ошибок, где ловушка памяти используется в качестве детерминированного сигнала синхронизации, дополняющего уязвимость.
Одноразовый характер трюка также делает бесполезным эксплуатацию простых путей кода с двойной выборкой. Также более сложный код, который может читать и писать по адресу памяти более одного раза, прежде чем вы перейдете к уязвимому коду, что может затруднить управление ловушками.
Источник: https://googleprojectzero.blogspot.com/2021/01/windows-exploitation-tricks-trapping.html
Автор перевода: yashechka
Переведено специально для https://xss.pro
Background
Когда вам понадобится такой трюк с эксплуатацией? Хороший пример типов уязвимостей безопасности, которые могут быть полезны, можно найти в основополагающем исследовании Bochspwn (https://storage.googleapis.com/pub-tools-public-publication-data/pdf/42189.pdf), проведенном Матеушем Юрчиком и Гинваэлем Кулвинд. Исследование показало способ автоматизации обнаружения двойных выборок из памяти в ядре Windows.
Если вы не читали статью, двойная выборка - это тип уязвимости Time-of-Check Time-of-Use (TOCTOU), когда код считывает значение из памяти, например длину буфера, и проверяет, находится ли значение в пределах границы, а затем повторно считывает значение из памяти перед использованием. При замене значения в памяти между первой и второй выборками проверка обходится, что может привести к проблемам безопасности, таким как повышение привилегий или раскрытие информации. Ниже приведен простой пример двойной выборки, взятый из исходной статьи.
Этот код копирует буфер из адреса управляемого пользовательского режима в буфер стека фиксированного размера. Буфер начинается со значения размера DWORD, которое указывает общий размер буфера. Повреждение памяти может произойти, если значение размера, на которое указывает lpInputBuffer, изменяется между первым чтением значения размера для сравнения с размером буфера (1) и вторым чтением размера при копировании в буфер (2). Например, если в первый раз будет считано значение 100, а во второй - 400, тогда код пройдет проверку размера, поскольку 100 меньше 256, но затем скопирует 400 байтов в этот буфер, повредив стек.
Как только такая уязвимость была обнаружена, Матеуш и Гинваэль должны были экспулатировать ее. Как они достигли эксплуатации, подробно описано в разделе 4. Все идентифицированные методы эксплойтов были вероятностными. Для эксплуатации обычно требовалось два потока, участвующих в гонке друг с другом, с одним чтением и одним записью. Вероятностный характер успеха обусловлен вероятностью того, что между первым чтением из области памяти и вторым чтением записывающий поток устанавливает новое значение, которое использует уязвимость.
Чтобы расширить окно TOCTOU, многие из описанных методов злоупотребляют поведением виртуальной памяти в Windows. Процесс в Windows обычно может получить доступ к большой области виртуальной памяти размером до 8 Т. Этот размер, вероятно, будет значительно больше, чем физическая память в системе, особенно с учетом ограничения на процесс, а не на систему. Поэтому, чтобы поддерживать иллюзию такого большого адресного пространства памяти, ядро использует подкачку памяти по запросу.
Когда в процессе выделяется память, таблицы страниц ЦП настраиваются так, чтобы указывать на наличие области памяти, но помечаются как недопустимые. На этом этапе область виртуальной памяти была выделена, но ее поддерживающей физической памяти нет. Когда процесс пытается получить доступ к этой области памяти, ЦП генерирует исключение, обычно называемое отказом страницы, которое обрабатывается ядром.
Ядро может найти адрес памяти, к которому обращались, чтобы вызвать отказ страницы, и попытаться исправить адрес. Способ устранения страничной ошибки зависит от типа доступа к памяти. Простой пример: если память была выделена, но еще не использована, ядро получит страницу физической памяти, инициализирует ее нулями, а затем скорректирует таблицы страниц, чтобы отобразить эту новую страницу физической памяти по адресу сбоя. Как только ошибка страницы будет исправлена, сбойный поток может быть перезапущен с помощью инструкции, обращающейся к памяти, и теперь доступ к памяти должен быть успешным, как если бы он всегда присутствовал.
Более сложный сценарий - если страница является частью файла с отображением памяти. В этом случае ядру потребуется запросить считывание данных страницы с диска, прежде чем оно сможет устранить ошибку страницы. Это может занять довольно много времени, по крайней мере, для дисков, поэтому может потребоваться приостановка сбойного потока, пока он ожидает чтения страницы. После того, как страница была прочитана, память может быть восстановлена, исходный поток может быть возобновлен, а поток перезапущен с помощью инструкции, вызвавшей ошибку.
Конечным результатом является то, что обработка ошибки страницы может занять значительное количество времени по сравнению с собственной скоростью процессора. Однако злоупотребление этим поведением виртуальной памяти только расширяет окно TOCTOU, оно не позволяет точно рассчитать время для обмена значениями в памяти. В результате методы эксплуатации по-прежнему имели ограничения. Например, использование уязвимостей на машине с одним ядром ЦП было очень медленным, а иногда и невозможным, поскольку оно полагалось на одновременное чтение и запись потоков.
Идеальным примитивом эксплойта был бы такой, в котором окно эксплойта можно сделать произвольно большим, чтобы выиграть гонку стало тривиально. Принимая во внимание предыдущий опыт и знания существующих классов ошибок, моим идеальным примитивом был бы тот, который соответствует набору критериев:
- Работает при установке Windows 10 20H2 по умолчанию.
- Дает четкий сигнал при чтении или записи памяти.
- Работает, когда доступ к памяти осуществляется как из пользовательского режима, так и из режима ядра.
- Позволяет отложить доступ к памяти на неопределенный срок.
- Данные в доступной памяти произвольны.
- Примитив может быть настроен из ряда уровней привилегий.
- Может поймать несколько раз за один и тот же эксплойт.
Хотя соответствие всем этим критериям было бы идеальным, нет никакой гарантии, что мы выполним все или любые из них. Если мы встретим только некоторые из них, диапазон уязвимостей эксплуатации может быть ограничен. Давайте начнем с краткого обзора существующей работы, которая может дать нам представление о том, как продолжить поиск примитива.
Существующая работа
Поговорив с Матеушем и попытавшись найти любую последующую работу, кажется, что есть немного новой работы помимо оригинальной статьи Bochspwn по использованию этих типов проблем TOCTOU. По крайней мере, это верно для эксплуатации в Windows, однако новые методы были разработаны для других платформ, в частности для Linux (https://static.sched.com/hosted_files/lsseu2019/04/LSSEU2019 - Exploiting race conditions on Linux.pdf). Оба эти метода основаны на поведении виртуальной памяти, которое я описал ранее.
Первый метод в Linux использует дескриптор файла Userfault (userfaultfd) (https://man7.org/linux/man-pages/man2/userfaultfd.2.html) для получения уведомлений, когда в процессе возникают ошибки страниц. При включенном userfaultfd вторичный поток в процессе может читать уведомление и обрабатывать отказ страницы в пользовательском режиме. Обработка ошибки может заключаться в отображении памяти в соответствующем месте или изменении защиты страницы. Ключ в том, что сбойный поток приостановлен до тех пор, пока сбой страницы не будет обработан другим потоком. Следовательно, если функция ядра обращается к памяти, запрос будет задерживаться до завершения. Это позволяет использовать примитив, в котором доступ к памяти может быть отложен на неопределенное время, а также иметь сигнал синхронизации для доступа.
Использование userfaultfd также позволяет различать ошибки чтения и записи, поскольку страница памяти может быть защищена от записи. Использование userfaultdd работает для доступа внутри процесса, например, из ядра, но не очень полезно, если код, обращающийся к памяти, находится в другом процессе. Чтобы решить эту проблему, вы можете использовать файловую систему FUSE, как показал Янн Хорн в предыдущем сообщении блога Project Zero. Файловая система FUSE полностью реализована в пользовательском режиме, но любые запросы файла проходят через API виртуальной файловой системы ядра Linux. Поскольку доступ к файлу осуществляется так, как если бы он был реализован файловой системой в ядре, можно отобразить этот файл в память с помощью mmap. Когда сбой страницы происходит в области памяти, поддерживаемой FUSE, будет сделан запрос к демону файловой системы пользовательского режима, который может задержать запрос чтения или записи на неопределенное время.
Удаленные файловые системы
Насколько я могу судить, нет ничего эквивалентного Linux userfaultd в Windows. Одна особенность, которая привлекла мое внимание, - это memory write watches. Но они, похоже, просто позволяют приложению запрашивать, была ли произведена запись в память с момента последней проверки, и не позволяют захватить записи в память.
Если мы не можем просто отлавливать ошибки страниц в виртуальной памяти, как насчет проецирования файла с файловой системой пользовательского режима, такой как FUSE? К сожалению, в Windows 10 нет встроенного драйвера FUSE (пока?), но это не означает, что нет механизма для реализации файловой системы в пользовательском режиме. Есть некоторые попытки создать настоящий FUSE в Windows, например проект WinFsp, но я ожидаю, что шансы их установки в реальной системе будут исчезающе малы.
Первой моей мыслью было попытаться использовать клиентов с несколькими поставщиками UNC (MUP). Когда вы обращаетесь к файлу через UNC-путь, например \\server\share\file.bin, это будет обрабатываться драйвером MUP в ядре, который передаст его одному из зарегистрированных клиентских драйверов. Что касается ядра, то открытый файл является обычным файлом (с некоторыми оговорками), что обычно означает, что файл может быть отображен в памяти. Однако любые запросы содержимого этого файла не будут обрабатываться напрямую, а вместо этого будут обрабатываться сервером по сетевому протоколу. В идеале мы должны иметь возможность реализовать собственный сервер, обрабатывать запросы на чтение или запись в сопоставленый файл, что позволит нам обнаруживать или задерживать запрос, чтобы мы могли использовать любой TOCTOU. В следующей таблице содержатся только указанные мной драйверы Microsoft MUP. В таблице указано, в каких версиях Windows 10 поддерживается драйвер и включено ли что-то по умолчанию.
Хотя MUP был разработан для удаленных файловых систем, фактически удаленный сервер файловой системы не требуется. SMB, WebDAV и NFS - это протоколы на основе IP, которые могут быть перенаправлены на localhost. P9 использует локальный сокет Unix, который в любом случае нельзя удалить удаленно. Клиент служб терминалов отправляет запросы доступа к файлам обратно в клиентскую систему по протоколу RDP. Для всех этих протоколов мы можем реализовать сервер с разной степенью усилий и посмотреть, сможем ли мы обнаруживать и задерживать чтение и запись в отображаемые файлы.
Я решил сосредоточиться только на двух протоколах: SMB и WebDAV. Это были единственные два протокола, которые включены по умолчанию и просты в использовании. Хотя клиент удаленного рабочего стола теоретически установлен по умолчанию, сервер RDP обычно не включен по умолчанию. Кроме того, настройка сеанса RDP сложна и может потребовать действительных учетных данных для аутентификации, поэтому я отказался от него.
Блок сообщений сервера
SMB почти такой же старый, как сама Windows, он был представлен в Lan Manager 1.0 еще в 1987 году. Последний протокол SMB версии 3.1 имеет лишь небольшое сходство с той исходной версией, которая потеряла свои корни NetBIOS для соединения TCP/IP. Его происхождение означает, что это лучшая интеграция из всех сетевых файловых систем, а API-интерфейсы MUP разработаны с учетом потребностей SMB.
Я решил провести простой тест поведения сопоставления файла по SMB. Это довольно просто, поскольку вы можете получить доступ к SMB на том же компьютере через localhost. Сначала я создал файл размером 1 ГБ на локальном диске, поскольку, если SMB поддерживает кэширование файловых данных, вряд ли удастся прочитать что-то настолько большое за один раз. Затем я запустил Wireshark и отслеживал петлевой интерфейс для захвата трафика SMB, как показано ниже.
Затем я написал быстрый сценарий на PowerShell, который отобразит файл в память, а затем прочитает несколько байтов из памяти с несколькими разными смещениями.
Он просто считывает 4 байта со смещения, 0, 256МБ, 512МБ и 768МБ. Возвращаясь к Wireshark, я отфильтровал вывод только для запросов чтения SMBv2, используя фильтр отображения smb2.cmd == 8, и можно было наблюдать следующие четыре пакета.
Это соответствует точным смещениям памяти, к которым мы обращались в сценарии, хотя длина всегда составляет 32 КБ, а не 4, которые мы запрашивали. Обратите внимание, что это не типичная для Windows степень детализации выделения памяти в 64 КБ, которую можно было бы ожидать. В своем тестировании я никогда не видел ничего, кроме запрашиваемых 32 КБ.
Все байты, которые мы протестировали, выровнены по блоку 32 КБ, что, если бы байты не были выровнены, например, если мы получили доступ к 4 байтам с адреса 512 МБ минус 2? Изменение скрипта на добавление следующего позволяет нам проверить поведение:
В Wireshark мы видим следующие запросы на чтение.
Доступ по-прежнему находится на границе 32 КБ, однако, поскольку запрос охватывает два блока, ядро извлекло из файла предыдущие 32 КБ данных, а затем следующие 32 КБ. Вы могли подумать, что все имеет смысл, однако такое поведение оказалось случайностью тестирования.
Это обзорная диаграмма схемы чтения памяти. В центре находится набор полей, представляющих читаемые страницы размером 4 КиБ. Все поля находятся в одной большой области, которая является большой страницей. Над полями находятся стрелки, которые показывают, что из основания блока 4 КиБ будет выполнено чтение 32 КБ в файл, который может удовлетворить чтения с других страниц 4 КиБ. Последнее поле показывает, что последние 32 КБ большого размера страницы всегда будут считываться как одна страница, независимо от того, где в поле происходит чтение.
На приведенной выше диаграмме показана структура обработки чтения сопоставленного файла. Когда адрес считывается, ядро запрашивает 32 КБ от ближайшей границы страницы 4 КБ, а не от границы 32 КБ. Однако есть вторичная структура сверху, основанная на поддерживаемом размере больших страниц. Если чтение находится где-нибудь в пределах 32 КБ от конца большой страницы, смещение чтения всегда для последних 32 КБ.
Например, в моей системе большой размер страницы (при запросе с использованием API GetLargePageMinimum) составляет 2 МБ. Поэтому, если вы начнете со смещения 512 МБ, между 512 и 514 - 32 КБ, ядро будет читать 32 КБ от смещения, усеченного до ближайшей границы 4 КБ. Между 514–32 КБ и 514 МБ при чтении всегда будет запрашиваться смещение 514–32 КБ, чтобы 32 КБ не пересекали границу большой страницы.
Это позволяет читать на границах 4 КБ, однако объем считываемых данных по-прежнему составляет 32 КБ. Это означает, что при обращении к одной странице размером 4 КиБ ядро заполнит текущую страницу и 7 следующих страниц. Есть ли способ заполнить только одну нативную страницу? Основываясь на комментарии Матеуша, я протестировал возврат короткого чтения. Если сервер SMB возвращает меньше байтов, чем было запрошено при чтении, то вместо отказа он заполняет только страницы, охваченные чтением. Возвращая эти короткие чтения, мы можем уменьшить степень детализации ловушки до нативного размера страницы, за исключением последних 32 КБ большой страницы. Если запрос на чтение короче нативного размера страницы, остальная часть страницы обнуляется.
А как насчет письма? Давайте снова изменим скрипт на вызов WriteBytes, а не ReadBytes, например:
Вы увидите запрос на запись в файл в Wireshark, подобный следующему:
Однако если копнуть немного глубже, то можно заметить, что запись происходит только после закрытия файла, а не в ответ на вызов WriteBytes. В этом есть смысл, нет простого способа определить, когда произошла запись, чтобы принудительно сбросить страницу обратно в файловую систему. Даже если бы существовал способ сброса на сетевой сервер для каждой записи, это оказало бы огромное влияние на производительность.
Однако еще не все потеряно, прежде чем память станет безопасной для записи, она должна быть заполнена содержимым файла. Поэтому, если вы посмотрите перед записью, вы увидите соответствующий запрос на чтение для области 32 КБ, которая охватывает место записи, синхронное с чтением. Вы можете обнаружить запись через соответствующее чтение, но не можете отличить чтение от записи на уровне протокола.
Все это тестирование показывает, если у нас есть контроль над сервером, мы можем обнаружить доступ к памяти для сопоставленного файла. Можем ли мы отложить доступ? Я написал простой SMB-сервер на .NET 5, используя SMBLibrary Тала Алони. Я реализовал сервер с пользовательским обработчиком файловой системы и добавил код в путь чтения, который задерживается на 10 секунд, когда смещение файла превышает 512 МБ.
Данные, возвращаемые операцией чтения, могут быть произвольными, вам просто нужно заполнить соответствующие байтовые буферы при чтении. Чтобы проверить время доступа, я заключил запросы на чтение из памяти в вызов Measure-Command, чтобы рассчитать время доступа к памяти.
Для сравнения времени доступа выполняется запрос на чтение к расположению на 4 байта ниже границы 512 МБ, а затем к границе 512 МБ. Сделав два запроса, мы сможем увидеть, отличаются ли результаты при чтении. Результаты были следующими:
Первый доступ для менее 512 МБ занимает около секунды, это связано с тем, что запрос по-прежнему необходимо отправить на сервер, а сервер написан на .NET, который может иметь медленное время запуска для запуска нового кода. Второй запрос занимает значительно меньше 1 секунды, память теперь кэшируется локально, поэтому никаких запросов не требуется.
Для обращений выше 512 МБ первый запрос занимает около 10 секунд, что коррелирует с добавленной задержкой. Второй запрос занимает меньше секунды, потому что страница теперь кэшируется локально. Это именно то, чего мы ожидали, и доказывает, что мы можем задержаться хотя бы на 10 секунд. Фактически вы можете отложить запрос как минимум на 60 секунд, прежде чем соединение будет принудительно сброшено. Это основано на тайм-ауте сеанса для клиента SMB. Вы можете запросить тайм-аут клиента SMB, используя следующую команду в PowerShell:
Несколько замечаний о поведении SMB-клиента по результатам тестирования. Сначала кажется, что клиент или диспетчер кеша Windows могут кэшировать удаленный файл. Если вы запрашиваете определенный доступ при открытии файла, например GENERIC_READ | GENERIC_WRITE для желаемого доступа, тогда кеширование включено. Это означает, что запросы на чтение не поступают на сервер, если они ранее были кэшированы локально. Однако если вы укажете MAXIMUM_ALLOWED для желаемого доступа, кеширование, похоже, не произойдет. Во-вторых, иногда части файла предварительно кэшируются, например, первые и последние 32 КБ файла. Я не выяснил, в чем причина, как ни странно, это происходит чаще с нативным кодом, чем с кодом .NET, так что, возможно, Защитник Windows заглядывает в память или, возможно, Superfetch. В общем, пока вы храните доступ к памяти где-то в середине большого файла, вы должны быть в безопасности.
Если вы запускали пример кода, вы могли заметить проблему, запуск примера сервера локально завершается ошибкой со следующей ошибкой:
System.Net.Sockets.SocketException (10013): An attempt was made to access a socket in a way forbidden by its access permissions.
По умолчанию в Windows 10 включен SMB-сервер. Это берет на себя порты TCP и делает их эксклюзивными, поэтому привязка к ним от обычного пользователя невозможна. Можно отключить локальный сервер SMB, но для этого потребуются права администратора. Тем не менее, стоило проверить, будет ли подход SMB-сервера работать, даже если нам придется связываться с удаленным сервером.
Я провел небольшое исследование, которые можно было бы использовать, чтобы заставить встроенный SMB-сервер работать в наших целях. Например, я попытался использовать тот факт, что вы можете установить Opportunistic блокировку, которая блокирует чтение файлов. Я использовал этот трюк, чтобы эксплуатировать уязвимость TOCTOU в драйвере LUAFV. К сожалению, сервер SMB обнаруживает, что файл уже заблокирован, и ждет прерывания OpLock, прежде чем разрешить доступ к файлу.
Для тестирования вы можете отключить службу LanmanServer и соответствующие ей драйверы. Если вы хотите использовать это в произвольной системе, вам почти наверняка потребуется подключиться к удаленному серверу. Я выпустил здесь пример кода сервера, который можно изменить, хотя это всего лишь демонстратор. Он обеспечивает детализацию чтения исходного размера страницы, который предполагается равным 4 КиБ. Код сервера должен работать в Linux, но начиная с версии 1.4.3 библиотеки SMBLibrary в NuGet есть ошибка, которая приводит к сбою сервера при запуске. В репозитории github есть исправление, но на момент написания не было обновленного пакета.
Насколько хорошо злоупотребление клиентом SMB соответствует нашим критериям, изложенным ранее? Я вычеркнул все, что мы встречали ранее.
- Дает четкий сигнал при чтении или записи памяти.
- Работает, когда доступ к памяти осуществляется как из пользовательского режима, так и из режима ядра.
- Позволяет отложить доступ к памяти на неопределенный срок.
- Данные в доступной памяти произвольны.
- Примитив может быть настроен из ряда уровней привилегий.
- Может поймать несколько раз за один и тот же эксплойт.
Использование клиента SMB действительно соответствует большинству наших критериев. Я проверил, что не имеет значения, получает ли код ядра или пользовательского режима доступ к памяти, которую он все еще будет перехватывать. Самая большая проблема заключается в том, что это трудно использовать из изолированного приложения, где это, возможно, было бы наиболее полезно. Это связано с тем, что MUP по умолчанию ограничивает доступ к удаленным файловым системам для процессов с ограниченным и низким IL, а для песочниц AppContainer требуются определенные возможности, которые вряд ли будут предоставлены большинству приложений. Нельзя сказать, что это совершенно невозможно, но сделать это будет сложно.
Хотя наш трюк на самом деле не задерживает чтение памяти на неопределенный срок, для наших целей ограничение в 60 секунд на основе тайм-аута сеанса SMB будет достаточным для большинства уязвимостей. Кроме того, после активации ловушки вы не можете заставить диспетчер памяти запрашивать ту же страницу с сервера. Я пробовал играть с флагами кэширования памяти и прямым вводом-выводом, но, по крайней мере, для файлов через SMB ничего не работало. Однако вы можете указать свой собственный базовый адрес при сопоставлении файла, чтобы вы могли сопоставить разные смещения в файле с одним и тем же виртуальным адресом, отключив оригинал и сопоставив в новой копии. Это позволит вам использовать один и тот же адрес несколько раз.
WebDAV
Как насчет WebDAV, поскольку локально использовать SMB непросто? По умолчанию TCP-порт 80 не используется в Windows 10, поэтому мы можем запустить собственный веб-сервер для связи. Также, в отличие от Linux, не требуется иметь права администратора для привязки к TCP-портам ниже 1024. Даже если это не так, клиент WebDAV поддерживает синтаксис для указания TCP-порта сервера. Например, если вы используете путь \\localhost@8080\ share, тогда HTTP-соединение WebDAV будет выполнено через порт 8080.
Однако предоставляет ли клиент WebDAV правильные примитивы чтения и записи, позволяющие нам перехватить доступ к памяти? Я написал простой сервер WebDAV, используя библиотеку NWebDav для обслуживания локальных файлов. Запустив сценарий, но указав сервер WebDAV на порту 8080, чтобы открыть файл размером 1 ГБ, я сразу же столкнулся с проблемой:
Get-NtFile : (0xC0000904) - The file size exceeds the limit allowed and cannot be saved.
Просто открыть файл не удается с кодом ошибки STATUS_FILE_TOO_LARGE. Причину этого можно найти в одной из многих статей базы знаний Microsoft, таких как эта. По умолчанию установлено ограничение в 50 МБ (то есть в десятичных мегабайтах) для любого файла, доступ к которому осуществляется через общий ресурс WebDAV, поскольку раньше можно было вызвать отказ в обслуживании, обманом заставив систему Windows загрузить файл произвольно большого размера.
Причина, по которой существует такое поведение ограничения размера, заключается в том, что WebDAV не подходит для этой атаки. Если вы измените размер файла до менее 50 МБ, вы обнаружите, что клиент WebDAV полностью переносит файл на локальный диск, прежде чем вернуться из вызова для открытия файла. Затем этот файл отображается в памяти как локальный файл. Сервер WebDAV никогда не получает запрос GET или PUT для синхронного чтения/записи в отображение памяти, поэтому нет механизма для обнаружения или перехвата определенных запросов к памяти.
API для наложения на файловую систему
Злоупотребление SMB-клиентом действительно работает, но его нельзя использовать локально при установке по умолчанию. Я решил, что нужно искать другой подход. Когда я просматривал драйверы фильтров Windows, я заметил, что некоторые из драйверов предоставляют механизм для наложения другой файловой системы поверх существующей. Я пролистал MSDN, чтобы найти документацию по API, чтобы посмотреть, подойдет ли что-нибудь. Три, на которые я смотрел, показаны в таблице ниже.
Безусловно, наиболее интересной из них является Проектируемая Файловая Система. Она была разработана Microsoft, чтобы предоставить виртуальную файловую систему для GIT. Она позволяет "проецировать" файлы в каталог на диске, и содержимое этих файлов толь ко "регидратируется" до полного файла по запросу. Теоретически это звучит идеально, поскольку до тех пор, пока содержимое файла заполняется по частям, мы могли бы добавить задержки при получении обратного вызова PRJ_GET_FILE_DATA_CB.
Однако базовая реализация, основанная на образце кода Microsoft ProjectedFileSystem, всегда будет повторно гидратировать весь файл во время открытия файла, подобно WebDAV. Возможно, я пропустил вариант потоковой передачи содержимого вместо того, чтобы заполнить его за один раз, но я не смог найти его сразу. В любом случае Проецируемая Файловая Система не устанавливается по умолчанию, что делает ее менее полезной.
WOF на самом деле не позволяет вам реализовать собственную семантику файловой системы. Вместо этого он позволяет накладывать файлы либо из вторичного файла образа Windows (WIM), либо из сжатых на том же томе. Это действительно не дает нам контроля, который мы ищем, возможно, вам удастся заставить что-то работать, но, похоже, это требует больших усилий.
Остается API Cloud Files. Он используется OneDrive для обеспечения локальной файловой системы в Интернете, но задокументирован и может использоваться для реализации любого наложения файловой системы, которое вам нравится. API работает очень похоже на проектируемую файловую систему и концепцией гидратации файла по запросу. Содержимое файлов не обязательно должно поступать из какой-либо онлайн-службы, такой как OneDrive, все это может быть получено локально. Важно отметить, что после некоторого базового тестирования он поддерживает потоковую передачу содержимого файла на основе того, что читается, и вы можете отложить запросы данных файла, и поток чтения будет блокироваться до тех пор, пока чтение не будет удовлетворено. Это можно включить, указав политику гидратации CF_HYDRATION_POLICY_PRIMARY со значением CF_HYDRATION_POLICY_PARTIAL при настройке базового корня синхронизации. Это позволяет Cloud File API гидратировать только те части файла, к которым был осуществлен доступ.
Это казалось идеальным, пока я не протестировал скрипт сопоставления файлов PowerShell, где он не работал, у моего поставщика облачных файлов всегда требовалось предоставить весь файл. При проверке драйвера Cloud Filter, когда получен запрос на сопоставление файла-заполнителя, обработчик IRP_MJ_ACQUIRE_FOR_SECTION_SYNCHRONIZATION всегда полностью регидрирует файл перед завершением. Если файл не гидратирован полностью, вызов NtCreateSection никогда не возвращается, что предотвращает отображение файла в памяти.
Я собирался вернуться к исследованиям фильтров, пока не понял, что могу объединить loopback клиента SMB с API Cloud Filter. Я уже знал, что клиент SMB на самом деле не отображает файл, даже локально, вместо этого он будет читать его по запросу через протокол SMB. И я также знал, что Cloud Filter API позволит потоковую передачу частей файла по запросу, пока файл не отображается в памяти. Окончательная настройка показана на следующей диаграмме:
Чтобы использовать примитив, мы сначала настраиваем нашего собственного облачного провайдера, регистрируя корневой каталог синхронизации с помощью API CfRegisterSyncRoot, настраивая его с политикой частичной гидратации. Затем в каталоге можно создать заполнитель размером 1 ГБ с помощью CfCreatePlaceholder. На данный момент у файла нет содержимого на диске. Если мы теперь откроем и сопоставим файл заполнителя через loopback клиент SMB, файл не будет немедленно регидратирован.
Любой доступ к памяти в сопоставлении приведет к тому, что клиент SMB сделает запрос на блок размером 32 КБ, который будет передан нашему облачному провайдеру пользовательского режима, который мы можем обнаружить и задержать при необходимости. Само собой разумеется, что содержимое файла также может быть произвольным. На основании тестирования не похоже, что вы можете уменьшить степень детализации чтения до исходного размера страницы, как при реализации настраиваемого SMB-сервера, однако вы все равно можете делать запросы на границах собственного размера страницы в пределах ограничения большого размера страницы. Можно было бы изменить размер файла, чтобы заставить SMB-сервер выполнять короткие чтения, но это поведение не проверялось. Пример реализации облачного провайдера доступен здесь. (https://bugs.chromium.org/p/project-zero/issues/detail?id=2142)
Примеры использования
Теперь у нас есть трюк эксплуатации, который позволяет нам захватывать и задерживать чтение и запись виртуальной памяти. Большой вопрос в том, улучшит ли это эксплуатацию уязвимостей, таких как двойная выборка? Ответ зависит от реальной уязвимости. Небольшое примечание: когда я использую слово страница, я имею в виду единицу памяти, которая вызовет запрос к серверу SMB, например 32 КБ, а не собственный размер страницы, например 4 КБ.
Давайте рассмотрим пример, приведенный в начале этого сообщения в блоге. Эта уязвимость дважды считывает значение из одного и того же адреса памяти, lpInputPtr. Сначала для сравнения, затем для копирования размера. Проблема эксплуатации - одно из ограничений метода - ловушка памяти . Как только ловушка сработает для чтения размера для сравнения, вы можете отложить его на неопределенное время. Однако, как только вы предоставите запрошенную страницу памяти и возобновите сбойный поток, он не будет запускаться при втором чтении, он просто будет считан из памяти, как если бы он всегда был там.
Вы можете спросить, можно ли переназначить страницу памяти при обнаружении первого чтения? К сожалению, это не работает. Когда поток возобновляется, он перезапускается по команде, вызвавшей сбой, и снова выполняет чтение, поэтому произойдет следующее:
Как видно из диаграммы, вы попадаете в бесконечный цикл, поскольку переназначаете новую страницу, которая вызывает еще одну ошибку страницы до бесконечности. Если вы не выполните шаг [3], операция будет завершена, и между возобновлением потока, чтением теперь действующей памяти для сравнения размеров и вторым чтением будет промежуток времени. Однако в этом примере временное окно, вероятно, будет состоять из пары инструкций, поэтому использование нашего трюка эксплуатации не лучше существующих вероятностных подходов. Тем не менее, одним из преимуществ является то, что вы знаете, когда происходит чтение, что позволяет вам более точно нацеливать окно брутфорса.
Этот пример - наихудший случай, что, если бы между чтениями было больше времени? Другой пример из статьи Bochspwn показан ниже:
Присутствует такое же поведение двойной выборки, но отличается то, что значение передается другой функции, в данном случае ExAllocatePool, которая выделяет память ядра. В зависимости от текущей конфигурации памяти или размера запрошенного распределения между [1] и [2] может быть значительная временная задержка. Есть ли способ выиграть гонку?
Ну, не то чтобы я знал, но мы можем эксплуатировать одно поведение, чтобы попытаться немного синхронизировать потоки чтения и записи. Напомним, что для записи на неразрешенную страницу содержимое страницы необходимо сначала прочитать с сервера. Следовательно, для поддержания согласованности любой поток, записывающий на неразрешенную страницу, должен генерировать ошибку страницы и ждать той же блокировки, что и другой поток, который просто читает со страницы, как показано на следующей диаграмме:
Синхронизируя потоки чтения и записи, вы даете себе разумный шанс вызвать запись в течение временного окна для эксплуатации. Это все еще вероятностный подход, он зависит от планировщика. Например, возможно, что поток записи пробуждается перед потоком чтения, что приведет к тому, что указатель всегда будет принимать окончательное значение. Или поток чтения может выполняться до завершения до того, как поток записи когда-либо будет запланирован для запуска, так что значение никогда не изменится. Возможно, есть некоторая магия планировщика, такая как использование нескольких потоков чтения или записи или выбор соответствующих приоритетов, которые вы могли бы использовать, чтобы гарантировать упорядочение чтения и записи. Я был бы удивлен, если бы что-то было надежным в нескольких системах Windows 10. Мне был бы очень интересен любой, у кого есть лучшие идеи о том, как повысить надежность этого.
Один из подходов, который может вас заинтересовать, - это невыровненный доступ, например, разделение значения на две отдельные страницы. С точки зрения микроархитектуры вполне вероятно, что чтение будет разделено на две части, сначала касаясь одной страницы, затем другой. Однако помните, как работает ошибка страницы: она генерирует исключение, которое вызывает выполнение обработчика в ядре. На этом этапе любая работа, уже проделанная инструкцией, будет удалена, пока ядро обрабатывает ошибку страницы. Когда поток возобновляется, он перезапускает сбойную инструкцию, которая повторно выполняет соответствующие микрооперации для чтения с невыровненного адреса. Если компилятор не сгенерировал две загрузки для невыровненного доступа (что может произойти на некоторых архитектурах), то я не знаю способа перезапустить инструкцию доступа к памяти на этом этапе.
Все это кажется немного мрачным с точки зрения полезности трюка эксплуатации. Дело в том, что существует столько различных типов уязвимости, сколько рыб в море. Например, если мы изменим исходный пример следующим образом:
Теперь проверка гарантирует, что размер буфера достаточно велик, а второй DWORD в буфере не установлен на 2. Второе поле может представлять тип буфера, а тип 2 не подходит для этого запроса. Если вы проверите вывод компилятора для этого кода, например, на Godbolt, разница в собственном коде составляет 2 или 3 инструкции. Казалось бы, это существенно не улучшит шансы на победу в гонке TOCTOU при использовании наивного вероятностного подхода. Но с нашим трюком эксплуатации мы теперь можем создать детерминированный эксплойт.
На диаграмме выше показано, как можно получить этот детерминированный эксплойт. Мы можем разместить поле "Размер" на странице, отличной от остальной части входного буфера, хотя буфер по-прежнему является непрерывным в виртуальной памяти. Первая страница (N-1) уже должна быть загружена в память и содержать поле размера, которое меньше размера LocalBuffer. Мы можем позволить считыванию размера [1] завершиться нормально.
Затем код будет читать поле Тип, которое находится на странице N [2]. Эта страница в настоящее время не находится в памяти, поэтому при обращении к ней произойдет ошибка страницы [3]. Это требует, чтобы ядро считало содержимое файла, кооторое мы можем обнаружить и задержать. Когда чтение обнаружено, у нас есть столько времени, сколько нам нужно изменить поле Size, чтобы оно содержало значение больше, чем размер LocalBuffer [4]. Наконец, мы завершаем чтение, которое перезапустит поток обратно в инструкции чтения поля Type [5]. Код может продолжаться и теперь будет считывать слишком большое поле размера и вызывать повреждение памяти.
Ключевой вывод заключается в том, что если между точками двойной выборки код касается любой памяти пользовательского режима, находящейся под вашим контролем, а не той, которую выбирают дважды, должно быть возможно преобразовать это в детерминированный эксплойт. Не имеет значения, имеет ли целевая система только один процессор, какой алгоритм планирования используется в ядре, сколько инструкций находится между точками двойной выборки или какой сейчас день недели и так далее, он должен "просто работать".
Следующее сообщение в блоге об эксплуатации с двойной выборкой дает некоторые цифры по уязвимости (https://j00ru.vexillium.org/2013/06...ndition-exploitation-on-x86-further-thoughts/). В примерах, показанных до сих пор, когда выбрано правильное временное окно, шанс успеха может достигать 100% через некоторое количество секунд. Однако, как показано здесь, мы можем получить 100% надежность для некоторых классов одной и той же ошибки, но в лучшем случае это не улучшение, а детерминированность.
Все примеры только демонстрируют эксплуатацию того, что в блоге называется арифметическими гонками. В блоге также упоминается второй класс ошибок, бинарные гонки, которые труднее использовать и никогда не достигают 100% успеха. Давайте посмотрим на пример в блоге и посмотрим, сработает ли наш трюк с эксплуатацией.
На первый взгляд, это не сильно отличается от предыдущих примеров, однако в этом случае изменяется указатель места назначения, а не размер. API ядра ProbeForWrite, который проверяет, что указатель находится как по адресу пользовательского режима, так и в памяти, доступной для записи. Это часто используемая идиома для проверки того, что указатель, указанный пользователем, не указывает на память ядра.
Если значение указателя изменяется между [1] и [2] с адреса пользовательского режима на адрес режима ядра, в этом примере будет перезаписана память ядра. Такое поведение сложнее использовать с помощью вероятностного эксплойта, поскольку существует только два допустимых значения указателя: адрес пользовательского режима или адрес режима ядра. Если вы выполняете перебор значения указателя, то можно закончить тем, что обе выборки читают указатель пользовательского режима, даже если он может измениться на указатель ядра между выборками.
К счастью, из-за вызова ProbeForWrite это тривиально эксплуатировать, если вы можете перехватить доступ к пользовательской памяти, как показано на следующей диаграмме:
Согласно диаграмме, выполняется первое чтение из UserPointer [1], а полученное значение указателя передается в ProbeForWrite. API ProbeForWrite сначала проверяет, находится ли указатель в адресном пространстве пользовательского режима, а затем проверяет каждую страницу памяти до размера параметра длины [2]. Если страница недействительна или недоступна для записи, то будет сгенерировано исключение и перехвачено блоком __except примера. Это дает нам возможность использовать уязвимости, мы можем использовать трюк эксплуатации на одной из проверяемых страниц пользовательского режима, что заставит ProbeForWrite сгенерировать ошибку страницы, которую мы можем перехватить [3]. Однако, поскольку проверяемый адрес не совпадает с адресом, в котором хранится указатель, мы можем изменить его, чтобы он содержал адрес режима ядра, пока запрос перехватывается [4].
В результате мы можем детерминированно выиграть гонку.
Конечно, я сосредоточился на двойной выборке ядра, поскольку именно это изначально побудило меня искать такое поведение. Есть много сценариев, в которых это можно использовать для облегчения эксплуатации приложений пользовательского режима. Самый очевидный - когда служба разделяет память с приложением с более низкими привилегиями. Примером такого рода проблем была двойная выборка в маршалере DfMarshal COM (https://bugs.chromium.org/p/project-zero/issues/detail?id=1648). Маршалер COM разделял раздел памяти между процессами, поэтому можно было предоставить раздел, который использовал бы наш трюк. В конце концов, в этом трюке не было необходимости, поскольку логика уязвимого кода позволила мне создать бесконечный цикл для расширения окна двойной выборки. Однако, если бы этого не было, мы могли бы использовать этот трюк для обнаружения и задержки, когда код находится в точке, где можно переключить дескриптор.
Другое более тонкое использование - это когда привилегированный процесс считывает память из менее привилегированного процесса. Это может быть явное использование API, таких как ReadProcessMemory, или косвенное, например, запрос командной строки процесса с помощью NtQueryInformationProcess будет считывать ячейки памяти, находящиеся под нашим контролем.
При использовании этого трюка эксплуатации следует помнить, что ее можно использовать, чтобы открыть окно и выиграть гонку на время. В этом случае это похоже на мою предыдущую работу по oplocks, но вместо доступа к памяти. На самом деле доступ к памяти может быть случайным для уязвимого кода, это не обязательно должна быть двойная выборка из памяти или даже уязвимость TOCTOU. Например, вы можете попытаться выиграть гонку между двумя путями к файлам с символическими ссылками. Пока уязвимый код может быть использован для проверки адреса пользовательского режима, который мы контролируем, вы можете использовать его в качестве сигнала синхронизации и для расширения окна эксплуатации.
Выводы
Я описал трюк с использованием SMB и Cloud File API, который может помочь в демонстрации использования определенных типов приложений и уязвимостей ядра. Возможно, есть и другие способы достижения аналогичного результата с помощью API, на которые я не обращал внимания, но пока это лучший подход, который я придумал. Это позволяет вам перехватить чтение из памяти пользовательского режима, определить, когда происходит доступ, и задержать чтение как минимум на 60 секунд. Примеры кода для реализации уловок SMB и Cloud File API доступны здесь (https://bugs.chromium.org/p/project-zero/issues/detail?id=2142).
Прежде чем мы закончим, стоит еще раз напомнить о некоторых ограничениях этого трюка с эксплуатацией.
- Нельзя использовать в песочнице, только с правами обычного пользователя.
- Допускается только один снимок для любой страницы, отображаемой из файла. Если что-то еще (например, AV) пытается прочитать эту страницу или файл, то ловушка может сработать раньше.
- Невозможно определить точное местоположение считываемого материала, степень детализации не превышает 4 КБ. Для локального доступа через Cloud File API всегда будут заполняться следующие 7 страниц, а также часть прочитанных 32 КБ. При доступе к настраиваемому серверу SMB размер чтения может быть уменьшен до 4 КиБ. Это предотвратит эксплуатацию определенных ошибок, которые требуют точного отлова только на небольшой площади внутри более крупной конструкции.
- Может обнаруживать записи только косвенно, не может специально отлавливать запись.
С практической точки зрения представленный здесь трюк не значительно улучшает процент выигрышей для традиционных двойных выборок ядра, описанных в статье Bochspwn. На практике для большинства этих классов уязвимостей вы, вероятно, захотите использовать вероятностный подход, во всяком случае из-за его простоты реализации. Однако трюк применим к другим классам ошибок, где ловушка памяти используется в качестве детерминированного сигнала синхронизации, дополняющего уязвимость.
Одноразовый характер трюка также делает бесполезным эксплуатацию простых путей кода с двойной выборкой. Также более сложный код, который может читать и писать по адресу памяти более одного раза, прежде чем вы перейдете к уязвимому коду, что может затруднить управление ловушками.
Источник: https://googleprojectzero.blogspot.com/2021/01/windows-exploitation-tricks-trapping.html
Автор перевода: yashechka
Переведено специально для https://xss.pro
Последнее редактирование: