
Учебное пособие по CVE-2022-37969 с акцентом на методологию эксплуатации ядра, а не на внутренние причины CVE
Этот материал создан для прояснения общих аспектов эксплуатации Windows. В нём объясняются базовые концепции применительно к CVE-2022-37969. Конечный результат — рабочее PoC. Он не разъясняет каждый аспект CVE, но предоставляет переиспользуемые фрагменты кода и объясняет механизмы, которые встречаются во многих эксплойтах общего назначения.
Целевой пользователь — начинающий reverse-инженер, разработчик эксплойтов, который ищет рабочий исходный код proof of concept для тестирования и понимания основ Windows Internals. Материал служит отправной точкой для дальнейшего изучения.
Требования: базовые навыки отладки ядра, базовый reverse engineering, базовые знания Windows internals, навыки программирования на c/c++
Программа — это фрагмент кода, выполняющийся на машине. Как правило, программа получает данные (входные данные), выполняет вычисления с их использованием и генерирует данные (выходные данные). Большинство программ написаны людьми и поэтому содержат ошибки. Ошибка порождается исходным кодом, который был написан некорректно (программист хотел сделать с входными данными одно, а получившийся код дал иной результат, чем предполагалось). Большинство ошибок исправляются до выпуска продукта, но некоторые остаются. Это происходит потому, что существуют разные типы ошибок, и некоторые из них сложнее обнаружить, чем другие.
Windows — это компьютерная программа, написанная людьми, и поэтому в ней есть ошибки. Почему это важно? Потому что системы Windows могут запускать программы, которые работают с чувствительными данными, такими как банковские счета, базы данных здравоохранения и прочее. Некоторые ошибки могут быть использованы для незаконного получения доступа к ограниченным данным (это хороший вариант применения эксплойта).
Существует множество видов ошибок, некоторые полезны, некоторые нет. Как правило, ошибки порождаются входными данными программы, которые в сочетании с некорректно написанными строками кода приводят к некорректному выводу или поведению программы. Поиск таких входных данных — задача специалиста по безопасности (или хакера). Следующий шаг — оценить полученное некорректное поведение/вывод и ответить на вопрос: «Можно ли использовать это с пользой?». На этом этапе ошибки классифицируются по разным категориям. Например, ошибка может вызывать повреждение некоторых структур данных и приводить к перезагрузке целевого компьютера. Её полезность ограничена. Одна ошибка может привести к записи входных данных в область памяти, которая контролирует права доступа к ограниченным файлам. Такой тип ошибок более полезен.
Итак, из множества всех возможных ошибок хакер ищет подмножество, наиболее полезное для его целей. В общем виде проблема звучит так: «Могу ли я подать целевой программе специально сформированные входные данные так, чтобы не сломать систему, но при этом повысить свой уровень доступа и получить выгоду?»
После этого нетехнического введения можно сформулировать цель данного руководства: Можем ли мы найти программу Windows, которая принимает некорректные входные данные и, в результате ошибки разработчика, позволяет незаконно повысить наши привилегии с обычного пользователя до администратора?
Целевая программа: Windows CLFS (Common Log File System Driver)
Название эксплойта: CVE-2022-37969
Тип: Local Privilege Escalation
ЗАГРУЗКА УЯЗВИМОГО ISO: Скачать здесь
Адресное пространство Windows примерно разделено на пользовательское пространство (user-space, где выполняются обычные программы) и пространство ядра (kernel-space, где работают сама операционная система и программное обеспечение аппаратных компонентов — драйверы). Обычный пользователь не должен иметь доступ к пространству ядра, однако существуют механизмы, с помощью которых программы обычного пользователя могут обращаться к частям кода ядра (системные вызовы, процедуры драйверов). Зачем нам нужен доступ? Чтобы взаимодействовать с ОС безопасным и контролируемым образом, как это предусмотрено разработчиками ОС.
Некоторые драйверы используют входные данные, предоставленные пользователем, для работы со структурами данных в пространстве ядра. Если входные данные вызывают ошибку, ядро может быть повреждено. Один из таких случаев — Common Log File System Driver. Используя особые входные данные, мы можем заставить драйвер изменить структуры данных ядра, которые хранят уровень привилегий пользователя, и перезаписать обычного пользователя на администратора.
Что нужно изменить, чтобы повысить привилегии до администратора?
Начнём с конечной цели. Windows хранит внутри структуры данных ядра с именем _EPROCESS информацию о каждом процессе, выполняющемся в системе. Пример _EPROCESS
Одно из важных полей — struct _EX_FAST_REF Token. Это ещё одна структура данных, которая, в свою очередь, указывает на данные, относящиеся к уровню привилегий соответствующего процесса. На следующем рисунке процесс System имеет системный токен, а процесс Explorer — токен обычного пользователя.

Таким образом, чтобы повысить привилегии Explorer.exe, нам нужно скопировать значение из _EPROCESS-->Token процесса System в _EPROCESS-->Token процесса Explorer. Мы достигнем чего-то похожего, скопировав системный токен в токен нашей собственной программы и запустив командную строку из повышенного процесса (дочерние процессы наследуют токен родительского процесса).
Для выполнения этих действий нам нужны механизмы для:
Введение: природа Windows на протяжении многих лет: Как и в случае с обнаружением новых уязвимостей, Windows требовала исправлений для их устранения. Кроме того, с появлением новых технологий Windows требовала обновлений, чтобы оставаться конкурентоспособной. Одним из ключевых требований была обратная совместимость с предыдущими версиями. А иногда безопасность достигалась за счёт сокрытия деталей реализации. Структуры данных и определения функций удалялись из документации, но функциональность оставалась. Благодаря reverse engineering исследователи смогли использовать эти функции для различных целей.
Для поиска адреса _EPROCESS в ядре мы будем использовать недокументированную функцию: NtQuerySystemInformation (см. ссылку для параметров). Используя параметр SystemInformationClass, мы можем указать, какую информацию хотим получить. Мы получим общую информацию о процессах, указав значение SystemExtendedHandleInformation (#define SystemExtendedHandleInformation 0x40).
Особенность использования NtQuerySystemInformation заключается в том, что мы заранее не знаем длину возвращаемых данных, но у NtQuerySystemInformation есть механизм, который помогает. Если функция вызвана с массивом неправильного размера для требуемых данных, она возвращает ошибку и корректный размер данных, который следовало запросить. Это можно использовать для корректного чтения информации о процессах следующим образом:
NtQuerySystemInformation с фиктивным параметром SystemInformationLengthReturnLengthNtQuerySystemInformation с корректным значением SystemInformationLength, полученным на предыдущем шагеВозвращаемая структура данных имеет тип PSYSTEM_HANDLE_INFORMATION_EX. Это недокументированная структура данных (см. ссылку), которая ведёт к структуре SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, содержащей в поле Object адрес структуры данных _Eprocess в ядре для соответствующего процесса.
Таким образом, логика такова: перебрать все элементы PSYSTEM_HANDLE_INFORMATION_EX, сравнить поле UniqueProcessId из SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX с желаемым PID процесса и выбрать соответствующее поле Object, чтобы найти адрес его _Eprocess в ядре.
Фрагмент кода:

NtQuerySystemInformation объявлена как указатель на функцию, и её адрес динамически получается во время выполнения через loadlibrary и getprocaddress.
typedef NTSTATUS(WINAPI* ptr_NtQuerySystemInformation)(int, PVOID, ULONG, PULONG);ntdll=LoadLibrary(L"ntdll.dll");MyNtQuerySystemInformation = (ptr_NtQuerySystemInformation)GetProcAddress(ntdll, "NtQuerySystemInformation");Для чтения и записи токена нам придётся полагаться на уязвимости в clfsw32.sys и именованных каналах (NamedPipes).
Это чем-то похоже на «чёрный ящик», подробно описанный в других статьях, но здесь будет объяснён необходимый минимум для базового понимания процесса эксплуатации. Каналы (pipes) — это механизмы межпроцессного взаимодействия. Процессы могут передавать информацию друг другу с помощью каналов. Каналы представлены структурами данных ядра, некоторые поля которых могут заполняться из пользовательского пространства. Примером могут служить атрибуты канала (Pipe Attributes).
(Позже мы свяжем это с уязвимостью CLFS, чтобы получить произвольное чтение/запись из пользовательского пространства в пространстве ядра)
Для дальнейшего чтения обратитесь к fengshui-spraying-big-kids-pool. Упрощённое объяснение механизма выглядит так:
Ядро имеет 2 способа выделения памяти в зависимости от размера, который мы хотим выделить: small-pool для объектов размером <4KB + заголовок и big-pool для объектов размером >4KB + заголовок. Страницы big-pool важны, потому что их можно перечислить из пользовательского пространства. Это означает, что обычный пользователь может найти все начальные адреса ядра, которые содержат страницу Big-Pool.
Как? Каждая страница Big-Pool имеет поле с именем Tag (которое можно использовать для получения информации о типе хранящихся там данных). Все страницы Big-Pool в системе можно перечислить с помощью NtQuerySystemInformation, передав SystemBigPoolInformation в качестве значения параметра SystemInformationClass. Затем из всех страниц мы можем отфильтровать по Tag и получить адреса интересующих нас страниц Big-Pool. Например, CLFS использует страницы Big-Pool с тегом 'Clfs'. Мы можем получить адреса всех страниц в ядре, где размещаются объекты CLFS.

Возвращаясь к каналам, мы можем действовать аналогичным образом: выделить канал достаточно большого размера, чтобы использовался механизм Big-Pool, перечислить страницы big pool, найти специфический тег канала и отфильтровать эти страницы.
Таким образом, мы можем узнать в пользовательском пространстве, где именно ядро разместило наши каналы. А как насчёт контроля данных, которые попадают в ядро, и их чтения?
Для этого мы полагаемся на атрибуты канала (Pipe Attributes). Подобно тегу Big-Pool, атрибут канала — это массив, который может содержать информацию, описывающую канал (заполняется пользователем). Используя недокументированную функцию NtFsControlFile, мы можем произвольно читать и записывать в структуру данных атрибутов канала. Поскольку функция недокументирована, приведена только proof-of-concept-реализация, которая устанавливает вектор PipeAttribute и затем читает его. Единственное, что разрешено менять в примитивах чтения и записи, — это содержимое входного/выходного буфера и их размер.
Здесь мы выделяем канал, достаточно большой для использования страниц big-pool (0x2000), и устанавливаем входной и выходной буферы в контролируемое значение. Внимание: первые 2 байта входного значения должны быть 0x5a 0x00, чтобы всё работало.

Здесь мы читаем обратно то, что записали ранее в ядро, снова изменяя только выходной буфер.

И результат:

Вот содержимое страницы big-pool канала в ядре: Запросив страницы big-pool, мы нашли начало структуры данных канала в ядре. По адресу pipe_begin+0x20 находится указатель на наш входной буфер+0x2.

Когда мы вызываем функцию чтения атрибута канала, ОС делает следующее:
Почему это полезно? Представьте, что мы можем заменить указатель по адресу pipe_begin+0x20 на местоположение токена безопасности процесса System и вызвать чтение атрибута канала. Мы получим его значение в пользовательском пространстве. Замена выполняется с помощью уязвимости в CLFS.SYS.
Понимание этой техники крайне важно для понимания эксплойта.
Большинство эксплойтов по своей природе не детерминированы, а вероятностны. Даже если код, использующий уязвимость, корректен, эксплойт может не сработать. Приводя целевую программу в нестабильное состояние, разработчик эксплойта должен убедиться, что после выполнения эксплойта система не упадёт. Представьте программу, которая при эксплуатации даёт возможность читать по адресу, зависящему от значения переменной внутри программы.
Пример:
Допустим, target=0x1000000+ var_1&0xff+ var_2&0xff00.
Хакер не может контролировать target, var_1 или var_2.
Но эксплойт даёт возможность читать по адресу target.
Мы могли бы читать из любой точки между 0x1000000 и 0x100FFFF, что в некоторых случаях может быть полезно, а в некоторых — нет.
Пример 2:
Допустим, эксплойт даёт возможность прочитать QWORD по адресу, соответствующему определённому шаблону, и поместить содержимое в значение токена безопасности. Предположим, мы ранее получили значение токена безопасности для System.exe. Как мы можем использовать эксплойт для повышения привилегий?
read_addr=0x1000000+alfa&0xFFFF00
Мы не можем контролировать параметр alfa.
НЕ ПО ТЕМЕ, НО ОЧЕНЬ ВАЖНО: Ядро может обращаться к пользовательскому пространству, соответствующему процессу, код которого в данный момент выполняется в ядре.
С какого адреса мы можем читать? Ну, 0x1000000, 0x1000100 (alfa=1), 0x1000200 (alfa=2), ..., 0x1FFFF00 (alfa=ffff00).
Чтобы быть уверенным, что эксплойт сработает, программист должен сделать следующее:
memory=virtualalloc(dest=0x1000000,size=0x1000000,....)for (i=0x1000000;i<0x2000000;i+=0x100) ((QWORD*)i)[0]=system_token_value;Это и есть memory spraying. Манипуляция памятью по определённому шаблону, который соответствует всем возможным значениям вероятностного выражения, являющегося результатом эксплойта.
Требование для работы этого в нашем случае — возможность выделить память по адресу 0x1000000. Это ограничение, с которым нам также придётся работать в случае реального эксплойта.
После прояснения некоторых технических аспектов теперь нужно базовое понимание CLFS.Ссылка на Microsoft. CLFS используется для журналов приложений, журналов баз данных, транзакций и т.д.
Эксплойтам, как правило, требуется определённая раскладка памяти для работы. Форма раскладки диктуется значениями переменных внутри программы в точный момент эксплуатации.
Этот туториал не будет подробно объяснять уязвимый код или формат файловой системы журналов. Он даст базовое понимание процессов, которые порождают эксплойт.
Файлы журналов — это особый вид файлов, имеющих определённый формат и взаимодействующих с драйвером CLFS и DLL API. В этой системе также существует понятие контейнеров журнала (log containers). Контейнеры журнала — это тоже файлы журналов, но они связываются в памяти с основным файлом журнала (тем файлом журнала, к которому они добавляются).
Эта конструкция работает следующим образом:
Эта операция приводит к выделению новой области памяти внутри основного файла журнала и изменяет различные объекты внутри раскладки памяти основного файла журнала. Изменение раскладки памяти концептуально выглядело бы так:

Как и в случае с любой важной структурой данных/файлом, перед использованием драйвер CLFS выполняет проверки корректности формата файла журнала:
Второй пункт является отправной точкой уязвимости. Атакующий может изменить ранее созданный файл журнала, пересчитать его хэш и отредактировать поле хэша, чтобы драйвер прошёл проверку целостности. Изменения касаются длин заголовков файла. Тщательно подобранный набор значений позволяет пройти проверку диапазона/длины, которая в противном случае не прошла бы и вызвала ошибку. Таким образом, драйвер принимает поддельную длину как допустимую и продолжает выполнение кода как обычно. Поддельная длина используется для вычисления смещения внутри файла, по которому будет записан жёстко заданный адрес. Это даёт атакующему возможность контролировать адрес, по которому произойдёт предыдущая операция записи.
Эта уязвимость должна быть объединена с чтением/записью в ядре через каналы для завершения эксплойта. Визуальное подтверждение:

На предыдущем рисунке мы видим функцию AllocSymbol внутри CLFS.sys, которая отвечает за ложную проверку диапазона. Уязвимая проверка длины — та, которая возвращает код ошибки 0xC0000023.(BaseLogRecord + v9 + Size_1 + 0x1338 > v8 + *(0x68)) Входные данные этого условия частично контролируются атакующим. Можно подставить в формулу значение, которое заставит проверку пройти, хотя в обычных условиях она бы не прошла. Управляя v8 и v9, мы можем сделать истинностное значение условия ложным (FALSE), предотвратить возврат ошибки и привести к произвольному вычислению значения переменной v10, используя контролируемое атакующим значение v9.
V10 далее используется в memset со значением 0. Таким образом, атакующий получает возможность произвольно обнулять память. Это ещё не всё. Перед финальным возвратом *a3=v10. A3 — это параметр, передаваемый по адресу в функцию AllocSymbol. Вызывающий AllocSymbol, таким образом, получает значение V10 после возврата из AllocSymbol.
Вызывающим AllocSymbol является FindSymbol.

Внутри FindSymbol V33 — это параметр с именем a3 из AllocSymbol. Таким образом, v33 получает значение v10. Затем значение по адресу v33 устанавливается в константу (0xc1fdf006) и дополнительно префиксируется значением 0x30.

Итак, атакующий получает возможность записать константу в произвольное место памяти.
Почему это важно? Если мы можем перезаписать значение по умолчанию указателя на функцию константным адресом, доступным как из пользовательского пространства, так и из пространства ядра, и имеем гарантию, что соответствующий указатель будет вызван, мы получаем контроль над исполнением кода. Хотя это общая идея, существуют технические препятствия, которые будут объяснены далее.
CClfsContainer* pContainer;. Когда соответствующий контейнер освобождается, в родительском файле выполняются операции очистки, разыменовывается pContainer и значения [[pContainer]+0x18] и [[pContainer]+0x8] используются как указатели на функции.Таким образом, возможность управлять pContainer и выполнять специфические для контейнера API для добавления и удаления гарантирует перенаправление потока управления.
Где находится pContainer?
Структура файла CLFS не документирована, но существуют отдельные попытки реверс-инжиниринга структуры файла.
Общий вид структуры файла журнала:

И подробный вид базового блока:

Различия между структурой и представлением в памяти:
Файлы CLFS размещаются на страницах big-pool с тегом Clfs. Когда адрес журнала CLFS получается в пользовательском пространстве (тем же методом, который используется для получения адреса объекта pipe в пространстве ядра), ядро возвращает АДРЕС, С КОТОРОГО НАЧИНАЕТСЯ БАЗОВЫЙ БЛОК (по смещению 0x800 на предыдущем рисунке). По смещению 0xb98 мы видим структуру данных с именем regContainers. Это массив 32-битных значений. Каждое значение связано с одним контейнером и представляет собой смещение (начиная с 0x870), по которому внутренние структуры данных контейнера расположены в файле базового блока.
Компоновка памяти файла контейнера выглядит следующим образом:
CLFS_CONTAINER_CONTEXT structure
По смещению 0x18 внутри CLFS_CONTAINER_CONTEXT structure находится наша цель pContainer.
По-видимому, смещение до pContainer является постоянным, то есть первым элементом массива regContainers. И его значение равно 0x1468.
Разыменование и доступ к pContainer как к указателю на функцию — это суть эксплуатации. Это происходит внутри функции Remove container в драйвере CLFS.SYS. Таким образом, эксплойт сработает при удалении контейнера.
Доказательство:

Давайте рассмотрим шаги удаления контейнера, чтобы показать, что действительно указатель pContainer используется как указатель на функцию.
GetBaseLogRecord, который возвращает адрес базового блока в пространстве ядра + 0x70 (он перемещает указатель файла за заголовок).BaseRecord_1).a4 как v10.LODWORD(a4) = *( (_DWORD*)BaseLogRecord_1 + StartingIndex_1 + 0xCA); Если в файл добавлен один контейнер, то StartingIndex_1 равен нулю. Значение, возвращаемое GetBaseLogRecord, приводится к вектору DWORD (32 бита) и индексируется на 0xCA элементов. Обратите внимание, что (_DWORD*)BaseLogRecord_1 + 0 + 0xCA не просто добавляет значение 0xCA к BaseLogRecord_1. Из-за приведения это эквивалентно BaseLogRecord_1[0xCA]. Это отражено в соответствующем дизассемблированном коде: mov eax, [rdi+r12*4+328h], где rdi — база, r12 — StartingIndex_1 (обратите внимание, что он умножается на 4 — длину 32-битного элемента (DWORD)) и добавляется 0x328, что равно 0xCA*0x4.Значение a4 в итоге равно BaseBlock+0x70+0+328=(BaseBlock+0x398), что приводит нас к первому значению RgConrainers (0x398+0x800=0xB98).
pContainer.82 и 83 pContainer разыменовывается, к нему добавляются 0x18 и 0x8, и он интерпретируется как указатель на функцию и вызывается. (call cs:__guard_dispatch_icall_fptr на самом деле является вызовом инструкции jmp eax).Это доказывает, что, переписав значение pConainter, мы можем изменить поток управления драйвера и направить его на адреса, контролируемые атакующим.
Ранее объяснялось, что иногда необходимо заполнить участок памяти заранее заданным шаблоном значений, чтобы гарантировать успешное выполнение эксплойта. Это связано с тем, что хакер может контролировать лишь подмножество переменных, составляющих состояние программы на момент эксплуатации. В простом примере распыления памяти ранее мы использовали только значения, записанные в вектор, чтобы удовлетворить определенное условие.
В реальном случае условия более сложные. Нам нужно расположить файлы журналов в памяти ядра в определенном порядке с известным смещением между ними.
Прежде всего, давайте создадим множество файлов журналов в цикле и изучим, как ОС выделяет для них память. Эксперимент будет использовать следующий шаблон:
Следующий фрагмент кода делает это:

И результат:

Давайте упорядочим адреса:

Анализируя смещения между адресами, можно заметить псевдозакономерность в распределениях: есть непрерывные выделения, между которыми постоянное смещение. Например, от ffffd80faf444000 до ffffd80faf4ee000 смещение между двумя последовательными выделениями равно 0x11000.
Это предположение критически важно для работы эксплойта. На него будет опираться.
Еще одно предположение: возьмем несколько страниц, отстоящих друг от друга на 0x11000. Например: ffffd80faf444000,ffffd80faf455000,ffffd80faf466000,ffffd80faf477000,ffffd80faf488000,ffffd80faf499000. Все страницы соответствуют открытым файлам CLFS. Если мы закроем один файл, страница будет освобождена, и память по соответствующему адресу станет свободной.
В памяти это будет выглядеть так (скажем, мы закрываем файл, соответствующий странице по адресу ffffd80faf466000):
ffffd80faf444000,ffffd80faf455000,xxxxxxxxxxxxxxxx,ffffd80faf477000,ffffd80faf488000,
ЕСЛИ мы снова откроем файл, ОС с высокой степенью вероятности выделит страницу по тому же адресу (ffffd80faf466000), чтобы заполнить дыру и сделать память непрерывной. --> Это также критически важно для выполнения эксплойта.
Теперь представим схему стратегии, которую мы будем использовать, чтобы перезаписать указатель *pConainter адресом под нашим контролем в пользовательском пространстве.


На предыдущем рисунке start(aux1)+0x11000=start(A),start(A)+0x11000=start(B),start(B)+0x11000=start(aux2)
Мы будем использовать Logfile A, чтобы перезаписать Logfile B's *pcontainer pointer, и запустим эксплойт, закрыв Logfile B с помощью специального кода удаления после закрытия.
Добавим журнальный контейнер к Logfile B, чтобы выделить и обновить поля, сигнализирующие, что у B есть корректный файл контейнера. Мы можем добавить aux2 как контейнер к B.
Закроем LogfileA, чтобы мы могли редактировать его на диске. Важно выбрать A и B в середине максимальной последовательности файлов с интервалом 0x11000, чтобы создать в памяти дыру, которую ОС будет заполнять в приоритетном порядке. Если этого не произойдет, эксплойт провалится.
Пересчитаем хэш A, отредактируем его поле хэша для сохранения целостности и снова откроем A, надеясь, что ядро разместит его по тому же адресу; иначе эксплойт провалится.
Вызовем AddLogContainer на A с обычным файлом журнала, чтобы вызвать перезапись указателя *pContainer у B.
Удалим файл B, чтобы вызвался RemoveConainter и управление передалось на *pContainer файла B, который теперь поврежден и указывает на код, выделенный пользователем.
На следующем рисунке изображены описанные выше шаги.

Сначала мы выделяем 50 файлов CLFS. После каждого выделения мы запрашиваем все страницы CLFS в памяти ядра и составляем список всех адресов, выделенных для каждого файла. Затем мы находим 2 файла, отстоящих друг от друга на 0x11000 (A и B на схеме) (и сохраняем их в переменные first, second).

После определения адреса ищем два файла на расстоянии 0x11000: A->first , B->second.

Затем закрываем A (first), редактируем его на диске (повреждаем его заголовки) и снова открываем.

Этот шаг требует дополнительного объяснения, поскольку нам нужно вычислить некоторые точные значения, которые при срабатывании эксплуатируемого кода в AllocSymbol приведут к перезаписи указателя *pConainter файла B.
Из более раннего анализа функций AllocSymbol и FindSymbol мы знаем, что должны изменить файл A таким образом, чтобы логическое значение условия IF стало FALSE и чтобы внедрить в переменную v9 значение, которое приведет к записи в расположение указателя *pContainer файла B.
Сначала каким должно быть значение v9?
AllocSymbol вычисляет v10 (целевой адрес записи) как: v10=BaseLogRecord + v9 + 0x1338. Это вычисляется в контексте адресного пространства A. Таким образом, BaseLogRecord — это: адрес страницы ядра A + 0x70. v9 контролируется атакующим, а 0x1338 — константа.
Где находится *pContainer файла B относительно адреса его страницы ядра?
Это было подробно описано ранее: от адреса страницы ядра B нужно добавить 0x398, чтобы попасть в вектор regContainter файла B, и проиндексировать первый элемент, чтобы получить смещение до первой структуры CONTEINER_CONTEXT. Как уже говорилось, для первого контейнера индекс был определен как 0x1468 (считая от B BaseBlock +0x70).
Итак, расположение *pContainer файла B — это B's Kernel_Address + 0x70 + 0x1468 + 0x18 (third element in CONTEINER_CONTEXT structure)
B's Kernel_Address=A's Kernel Address+0x11000 (потому что мы так сконструировали память)
A's BaseRecordAddress=A's Kernel Address+0x70
V10=A's Kernel Address+0x70+v9+0x1338
v10 должно перезаписать *pContainer файла B.
V10 должно быть: v10=B's Kernel_Address + 0x70 + 0x1468 + 0x18 (third element in CONTEINER_CONTEXT structure)
Подставляя B's Kernel_Address: v10=A's Kernel Address+0x110000+x70 + 0x1468 + 0x18
Сокращая v10: A's Kernel Address+0x70+v9+0x1338=A's Kernel Address+0x11000+x70 + 0x1468 + 0x18
Решая относительно v9 = 0x11000+0x70+0x1468+0x18-0x1338-0x70=0x11148
Какие поля в A нужно перезаписать? --> Есть 2 категории полей:
IF в AllocSymbol к значению FALSEПоля из категории 1 не будут детализироваться. V9 соответствует полю по смещению 0x1b98 на диске внутри файла A. Больше не будет деталей о том, почему эти поля вызывают срабатывание эксплойта; читателю предоставляется возможность изучить это самостоятельно, если интересно.
Вот значения, которые необходимо изменить:

Обратите внимание, что (little endian) используется значение 0x11149 вместо 0x11148. Это связано с тем, что нам нужно контролировать старшие октеты в *pContainer. Младший значащий октет будет иметь форму X0 (10 или 20 или 30...). Мы можем сделать распыление памяти по каждому из значений, чтобы учесть это условие.
Обратите внимание, что v10 также будет использоваться для обнуления (memset с 00) области памяти длиной 0xa0.

Затем по тому же адресу происходит перезапись константой.

Обратите внимание на константное значение вида 0x30c1fdf006X0.
Это происходит, когда мы добавляем контейнер журнала к файлу A, но мы не рассмотрели процесс пересчета хэша после изменения полей A.
Представление файла журнала на диске несколько отличается от представления в памяти. Мы изменяем только содержимое base block, длина которого составляет 0x7a00 и который начинается на диске со смещения файла 0x800. Алгоритм хэширования, используемый для хэширования базового блока, — CRC32. Поле, содержащее значение хэша, также хранится внутри базового блока по смещению 0x80c.
Процедура пересчета хэша:
0x80c0x800, длиной 0x7a00.Чтобы перенаправить выполнение кода в пользовательское пространство, нам нужно:
*pConainter файла B (second) константой вида 0x30c1fdf006X0.RemoveContainer.
Существуют некоторые условия, накладываемые на пользовательскую память, которая является целью перенаправления потока выполнения через *pContainer. Мы не можем просто начать выполнять инструкции из пользовательского пространства, когда программа находится в режиме ядра.
Цель этого раздела — утечка значения SystemToken в пользовательском пространстве без вызывания системного сбоя (BSOD).
Как можно прочитать данные из адреса в ядре и сохранить результат в пользовательском пространстве? Краткое напоминание о разделе про Pipe в руководстве:

Здесь мы:
Идея, связывающая объект Pipe с нашей целью получения значения системного токена, состоит в том, чтобы использовать NtFsControlFile PipeReadAttribute для чтения с адреса системного токена вместо начала буфера, содержащего информацию, которую мы передали ядру через PipeWrite Attribute.
Это означает, что мы должны изменить значение указателя по адресу PIPE_BEGIN+=0x20, чтобы он содержал адрес, по которому расположен системный токен.
Этот адрес известен, он был получен на более ранних этапах руководства, когда мы находили и разбирали структуру EPROCESS.
Здесь нам нужно найти механизм, который позволит нам записывать в структуру pipe в фиксированное место.
Для этого мы используем перенаправление кода, полученное через эксплуатацию функции AddLogConainer в CLFS. Можно подумать, что достаточно написать шеллкод, который выполняет прямую замену, но (хотя это не проверялось) это, вероятно, не сработает. Это связано с тем, что драйвер работает в контексте ядра и выполняет код из области пользовательского процесса.
Чтобы обойти это ограничение, мы должны найти ROP-гаджеты ядра, которые выполняют запись произвольного значения. То есть найти фрагменты кода ядра, которые находятся в конце функции и заканчиваются инструкцией ret, и предоставить их адреса в качестве целей для перенаправления. Таким образом, код по-прежнему выполняется ядром.
Это основная идея, но ограничения возникают из-за того, как драйвер CLFS вызывает код внутри *pConainer:

Чтобы создать пригодный шаблон памяти, мы должны изучить, как функция RemoveContiner обращается к коду *pContainer.
На предыдущем рисунке мы выделили фрагменты кода, которые разыменовывают *pContainer и используют его как указатель на функцию.
RDI — это константа, которая была записана внутрь *pcontainer путем эксплуатации AllocSymbol. Как видите, константа не полностью фиксирована: она меняется в первом байте (форма 6X0, где X — что угодно).
mov rax, [rdi] разыменовывает значение константы. Чтобы не вызвать недопустимое чтение (и BSOD), мы должны убедиться, что rdi содержит указатель на допустимый адрес. Для этого мы должны распылить пользовательскую память от 0x30C1FDF00000 как минимум до 0x30C1FDF006FF. Это делается путем выделения участка памяти с помощью VirtualAlloc. Конечно, если система по какой-либо причине не может выделить память, эксплойт провалится.
LPVOID dirtySpray = VirtualAlloc((LPVOID)0x000030c1fdf006d0, 0x1000000, 0x3000, 0x4);
Затем для каждого возможного значения X мы сохраняем по адресам от 0x30C1FDF000X0 до 0x30C1FDF00FX0 другое значение, соответствующее допустимой пользовательской памяти, выбирая произвольное значение, скажем 0x5000000. Таким образом мы гарантируем, что rax будет равен 0x5000000 для любого rdi вида 0x30C1FDF006X0.
После этого шага мы видим еще две операции разыменования: mov rax, [rax+0x18] и mov rax ,[rax+0x8].
Чтобы гарантировать согласованность памяти по этим адресам, мы должны сохранить по адресам 0x5000008 и 0x5000018 указатели на функции ядра (ROP1 и ROP2, будут вычислены позже).
Это алгоритм генерации шаблона распыления.

Примечание: шаблон будет развиваться, когда мы введем ROP-гаджеты, потому что у них также есть параметры, которые будут учитываться, но это минимум на данный момент для предотвращения недопустимого обращения к памяти.
Так выглядит первый этап распыленной памяти в отладчике (обратите внимание, где расположено значение 0x5000000 --> выравнивание по X0):

Вот как выглядит память по адресу 0x5000000 (ROP-гаджеты, которые будут описаны далее).

И процесс перенаправления кода в отладчике ядра:

Обратите внимание на значение регистра RDI и указатель инструкций в отладчике. RDI после одного разыменования равен 0x5000000 и сохраняется в RAX. [RAX+0x18] — это второй ROP, а ниже [RAX+0x8] будет адресом первого ROP.
На этом этапе у нас есть следующие части эксплойта, которые мы должны связать вместе:
Задача на данный момент — связать перенаправление кода с фрагментом кода, который перезапишет указатель Pipe на буфер атрибутов адресом системного токена и использует Pipe read attribute для получения информации обратно в пользовательское пространство.Как мы уже говорили, код, на который перенаправляется выполнение, также должен находиться в ядре. Более того, в нашем распоряжении есть только 2 функции. В данном руководстве не будет рассматриваться, как были найдены эти две конкретные функции, но, предположительно, существует список часто используемых ROP-кандидатов.
Мы изучим 2 функции:
SeSetAccessStateGenericMapping в ntoskrnl.exe Вызывается второй ([rax+0x8])ClfsEarlierLsn в CLFS.SYS Вызывается первой ([rax+0x18])Анализ ClfsEarlierLSn:

Единственная роль этой функции при вызове с некорректными параметрами — установить EDX в 0xFFFFFFFF и вернуться. Зачем это нужно, станет ясно при анализе второго ROP.
Анализ SeSetAccessStateGenericMapping:

Этот фрагмент нужно подробно разобрать построчно.
Входные данные: при входе в эту функцию:
0x30C1FDF006X0[RCX+0x48] имеет вид 0x30C1FDF00YX8, всегда выровнено по 0x8 и не конфликтует с 0x30C1FDF006X0, которое выровнено по 0 и содержит 0x5000000Выполнение кода функции:
mov rax, [rcx+48h] выполнит разыменование по адресу 0x30C1FDF00YX8 и переместит значение в RAXmovups xmm0, xmmword ptr [rdx] переместит 16 байт из RDX в регистр XMM0. Помните: после ClfsEarlierLSn в RDX находится 0xFFFFFFFFmovdqu xmmword ptr [rax+8], xmm0 переместит значение XMMO в [RAX+0x8]По сути, это чтение из адреса и сохранение внутри ядра. Это можно представить следующим образом:

Итак, чтобы перезаписать указатель буфера атрибутов pipe указателем на системный токен, мы должны разместить 16 октетов по адресу 0xFFFFFFFF и сохранить там адрес системного токена. Затем мы должны многократно распылять память по адресу 0x30C1FDF00YX8 со значением адреса назначения минус 0x8. Это значение смещения до буфера атрибутов pipe минус 0x8. То есть PIPE_KERNEL_PAGE_ADDRESS+0x20-0x8.
Вот как это реализовано в реальном коде:

Ещё один аспект, который не был объяснён: как получить адреса ROP-функций ядра в пользовательском пространстве?
Здесь используется трюк. Сначала вы можете получить базовый адрес любого модуля ядра с помощью NtQuerySystemInformation со следующими параметрами: NtQuerySystemInformation(SystemModuleInformation, (HANDLE)ModuleInfo, ModuleInfoSize, &retlen)
Затем вы фильтруете по значению ModuleInfo->Modules[i].Name, чтобы найти нужную библиотеку. Так вы получаете базовый адрес в ядре.
Затем мы используем тот факт, что смещение между базовым адресом и адресом экспортируемой функции постоянно независимо от адресного пространства, в которое загружен исполняемый файл.
Мы загружаем модуль в пользовательское пространство (вы можете это сделать) с помощью LoadLibrary и получаем адрес экспортируемой функции через Getprocaddress. Вычисляем дельту между адресом экспорта и базовым адресом в пользовательском пространстве и прибавляем её к базовому адресу ядра (найденному с помощью NtQuerySystemInformation). Таким образом, находясь в пользовательском пространстве, мы получаем адрес экспортируемой функции, загруженной в пространстве ядра.
Как только указатель на атрибут повреждён и настроен так, чтобы указывать на расположение системного токена, вызов MyNtFsControlFile с правильными параметрами должен прочитать данные по этому адресу и раскрыть значение системного токена в пользовательском пространстве.

Имея корректное значение системного токена, для завершения эксплойта мы должны записать его вместо токена нашего собственного процесса. То есть перезаписать токен процесса, который выполняет эксплойт, значением системного токена.
Шаги, необходимые для этого изменения, не включают новых техник или методологий, а опираются на повторное использование предыдущего кода. Алгоритм перезаписи собственного значения токена подразумевает операцию записи в ядре по определённому адресу. Это достигается с помощью ROP-функций SeSetAccessStateGenericMapping и ClfsEarlierLSn. Это означает, что НАМ НУЖНО ЗАПУСТИТЬ ЭКСПЛОЙТ CLFS ВТОРОЙ РАЗ. Всё верно: мы должны выполнить выделение контейнера и распыление памяти повторно и надеяться, что ОС не упадёт.
Шаги для перезаписи собственного значения токена:
Поскольку во второй раз нам не нужно чтение из ядра, pipe в этот раз нам не понадобится.
Поскольку эти шаги не требуют дополнительных знаний, руководство можно закончить здесь финальным доказательством — cmd.exe с привилегиями nt authority\system

Основное внимание в этом руководстве уделялось не внутреннему устройству самого CVE, а скорее доступному для пользователя описанию процесса создания примера повышения привилегий в Windows. Многие эксплойты используют одни и те же методы и строительные блоки, такие как распыление памяти или работа с определёнными структурами данных Windows. И в большинстве случаев существует большой разрыв между теоретическими знаниями о внутреннем устройстве Windows и умением эффективно написать алгоритм, который использует уязвимость.
ffffd80faf499000