Skip to content
KitploitKITPLOIT
ИнструментыБлог
Отправить
ИнструментыБлог
Отправить

Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!

Kitploit — это каталог инструментов для хакинга, кибербезопасности и пентестинга. Находите последние обновления проектов для поиска уязвимостей, анализа систем, автоматизации тестирования и усиления вашей безопасности.

··Ленты·Контакты·Конфиденциальность·© 2026 Kitploit

Каталог инструментов

Категории

Все категории
Loading categories
CVE-2022-37969PoC — Учебное пособие по CVE-2022-37969 с акцентом на методологию эксплуатации ядра, а не на внутренние причины CVE | Kitploit
Инструменты/GitHubGitHub/emilc3978/cve-2022-37969poc
Повышение привилегийКриминалистика памятиАнализ уязвимостейЭксплуатацияОбратная инженерияОбучение и ОбразованиеЭксплуатация Бинарных Файлов
GitHubemilc3978/cve-2022-37969poc

CVE-2022-37969PoC

Учебное пособие по CVE-2022-37969 с акцентом на методологию эксплуатации ядра, а не на внутренние причины CVE

Репозиторий
229 месяцев назадЕщё не проверено

Популярное

Смотреть все →

Откройте для себя самые используемые инструменты нашего сообщества.

Изучить все инструменты

Просмотрите нашу коллекцию инструментов

Смотреть все инструменты →
Поделиться

Содержание

Общее введение

Этот материал создан для прояснения общих аспектов эксплуатации 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

Адресное пространство Windows примерно разделено на пользовательское пространство (user-space, где выполняются обычные программы) и пространство ядра (kernel-space, где работают сама операционная система и программное обеспечение аппаратных компонентов — драйверы). Обычный пользователь не должен иметь доступ к пространству ядра, однако существуют механизмы, с помощью которых программы обычного пользователя могут обращаться к частям кода ядра (системные вызовы, процедуры драйверов). Зачем нам нужен доступ? Чтобы взаимодействовать с ОС безопасным и контролируемым образом, как это предусмотрено разработчиками ОС.

Некоторые драйверы используют входные данные, предоставленные пользователем, для работы со структурами данных в пространстве ядра. Если входные данные вызывают ошибку, ядро может быть повреждено. Один из таких случаев — Common Log File System Driver. Используя особые входные данные, мы можем заставить драйвер изменить структуры данных ядра, которые хранят уровень привилегий пользователя, и перезаписать обычного пользователя на администратора.

Что нужно изменить, чтобы повысить привилегии до администратора?

Начнём с конечной цели. Windows хранит внутри структуры данных ядра с именем _EPROCESS информацию о каждом процессе, выполняющемся в системе. Пример _EPROCESS

Одно из важных полей — struct _EX_FAST_REF Token. Это ещё одна структура данных, которая, в свою очередь, указывает на данные, относящиеся к уровню привилегий соответствующего процесса. На следующем рисунке процесс System имеет системный токен, а процесс Explorer — токен обычного пользователя.

Tokens

Таким образом, чтобы повысить привилегии Explorer.exe, нам нужно скопировать значение из _EPROCESS-->Token процесса System в _EPROCESS-->Token процесса Explorer. Мы достигнем чего-то похожего, скопировав системный токен в токен нашей собственной программы и запустив командную строку из повышенного процесса (дочерние процессы наследуют токен родительского процесса).

Для выполнения этих действий нам нужны механизмы для:

  1. Получения адреса структуры данных _EPROCESS в ядре
  2. Чтения значения поля Token для процесса System
  3. Получения адреса структуры данных _EPROCESS для Explorer
  4. Записи значения токена System по смещению Token в структуре _EPROCESS процесса Explorer

Поиск структуры данных _EPROCESS для целевого процесса по PID

Введение: природа Windows на протяжении многих лет: Как и в случае с обнаружением новых уязвимостей, Windows требовала исправлений для их устранения. Кроме того, с появлением новых технологий Windows требовала обновлений, чтобы оставаться конкурентоспособной. Одним из ключевых требований была обратная совместимость с предыдущими версиями. А иногда безопасность достигалась за счёт сокрытия деталей реализации. Структуры данных и определения функций удалялись из документации, но функциональность оставалась. Благодаря reverse engineering исследователи смогли использовать эти функции для различных целей.

Для поиска адреса _EPROCESS в ядре мы будем использовать недокументированную функцию: NtQuerySystemInformation (см. ссылку для параметров). Используя параметр SystemInformationClass, мы можем указать, какую информацию хотим получить. Мы получим общую информацию о процессах, указав значение SystemExtendedHandleInformation (#define SystemExtendedHandleInformation 0x40).

Особенность использования NtQuerySystemInformation заключается в том, что мы заранее не знаем длину возвращаемых данных, но у NtQuerySystemInformation есть механизм, который помогает. Если функция вызвана с массивом неправильного размера для требуемых данных, она возвращает ошибку и корректный размер данных, который следовало запросить. Это можно использовать для корректного чтения информации о процессах следующим образом:

  1. Вызовите NtQuerySystemInformation с фиктивным параметром SystemInformationLength
  2. Прочитайте возвращённое значение параметра ReturnLength
  3. Снова вызовите NtQuerySystemInformation с корректным значением 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 в ядре.

Фрагмент кода:

proc_1 proc_2

NtQuerySystemInformation объявлена как указатель на функцию, и её адрес динамически получается во время выполнения через loadlibrary и getprocaddress.

  1. typedef NTSTATUS(WINAPI* ptr_NtQuerySystemInformation)(int, PVOID, ULONG, PULONG);
  2. ntdll=LoadLibrary(L"ntdll.dll");
  3. 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.

proc_1

Возвращаясь к каналам, мы можем действовать аналогичным образом: выделить канал достаточно большого размера, чтобы использовался механизм Big-Pool, перечислить страницы big pool, найти специфический тег канала и отфильтровать эти страницы.

Таким образом, мы можем узнать в пользовательском пространстве, где именно ядро разместило наши каналы. А как насчёт контроля данных, которые попадают в ядро, и их чтения?

Для этого мы полагаемся на атрибуты канала (Pipe Attributes). Подобно тегу Big-Pool, атрибут канала — это массив, который может содержать информацию, описывающую канал (заполняется пользователем). Используя недокументированную функцию NtFsControlFile, мы можем произвольно читать и записывать в структуру данных атрибутов канала. Поскольку функция недокументирована, приведена только proof-of-concept-реализация, которая устанавливает вектор PipeAttribute и затем читает его. Единственное, что разрешено менять в примитивах чтения и записи, — это содержимое входного/выходного буфера и их размер.

Пример записи и чтения атрибута канала в ядре:

Здесь мы выделяем канал, достаточно большой для использования страниц big-pool (0x2000), и устанавливаем входной и выходной буферы в контролируемое значение. Внимание: первые 2 байта входного значения должны быть 0x5a 0x00, чтобы всё работало.

pipewr

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

peprd

И результат:

piperes

Как выглядит страница big-pool канала в памяти и почему предыдущая операция полезна для нас?

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

pipe_kern

Когда мы вызываем функцию чтения атрибута канала, ОС делает следующее:

  1. Определяет местоположение канала в ядре
  2. Добавляет 0x20, разыменовывает указатель и выгружает содержимое из пространства ядра в наш пользовательский буфер.

Почему это полезно? Представьте, что мы можем заменить указатель по адресу pipe_begin+0x20 на местоположение токена безопасности процесса System и вызвать чтение атрибута канала. Мы получим его значение в пользовательском пространстве. Замена выполняется с помощью уязвимости в CLFS.SYS.

Memory spraying (распыление памяти)

Понимание этой техники крайне важно для понимания эксплойта.

Большинство эксплойтов по своей природе не детерминированы, а вероятностны. Даже если код, использующий уязвимость, корректен, эксплойт может не сработать. Приводя целевую программу в нестабильное состояние, разработчик эксплойта должен убедиться, что после выполнения эксплойта система не упадёт. Представьте программу, которая при эксплуатации даёт возможность читать по адресу, зависящему от значения переменной внутри программы.

Пример:

Допустим, 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).

Чтобы быть уверенным, что эксплойт сработает, программист должен сделать следующее:

  1. Выделить в пользовательском пространстве участок памяти размером примерно 0x1000000: memory=virtualalloc(dest=0x1000000,size=0x1000000,....)
  2. for (i=0x1000000;i<0x2000000;i+=0x100) ((QWORD*)i)[0]=system_token_value;
  3. Код из пункта 2 заполняет память значением системного токена для любого возможного значения alfa. Таким образом, гарантируется, что независимо от значения параметра alfa, системный токен будет присутствовать по адресу.

Это и есть memory spraying. Манипуляция памятью по определённому шаблону, который соответствует всем возможным значениям вероятностного выражения, являющегося результатом эксплойта.

Требование для работы этого в нашем случае — возможность выделить память по адресу 0x1000000. Это ограничение, с которым нам также придётся работать в случае реального эксплойта.

Common Log File System

После прояснения некоторых технических аспектов теперь нужно базовое понимание CLFS.Ссылка на Microsoft. CLFS используется для журналов приложений, журналов баз данных, транзакций и т.д.

Эксплойтам, как правило, требуется определённая раскладка памяти для работы. Форма раскладки диктуется значениями переменных внутри программы в точный момент эксплуатации.

Этот туториал не будет подробно объяснять уязвимый код или формат файловой системы журналов. Он даст базовое понимание процессов, которые порождают эксплойт.

Файлы журналов — это особый вид файлов, имеющих определённый формат и взаимодействующих с драйвером CLFS и DLL API. В этой системе также существует понятие контейнеров журнала (log containers). Контейнеры журнала — это тоже файлы журналов, но они связываются в памяти с основным файлом журнала (тем файлом журнала, к которому они добавляются).

Эта конструкция работает следующим образом:

  1. Создайте или откройте основной файл журнала
  2. Создайте или откройте вторичные файлы журнала
  3. Используйте API AddLogContainer, чтобы добавить вторичные файлы журнала как контейнеры к основному файлу журнала

Эта операция приводит к выделению новой области памяти внутри основного файла журнала и изменяет различные объекты внутри раскладки памяти основного файла журнала. Изменение раскладки памяти концептуально выглядело бы так:

containet_concept

Высокоуровневый обзор механизма эксплойта

Как и в случае с любой важной структурой данных/файлом, перед использованием драйвер CLFS выполняет проверки корректности формата файла журнала:

  1. Защита от подделки (целостность): хэш файла хранится по константному смещению в файле. Чтобы успешно открыть файл, драйвер вычисляет хэш файла и сверяет его с сохранённым значением. Если значения не совпадают, это означает, что файл был изменён после создания, и генерируется ошибка.
  2. Проверки диапазонов и структуры файла: проверяются различные заголовки и длины, чтобы убедиться, что формат файла соблюдён.

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

Эта уязвимость должна быть объединена с чтением/записью в ядре через каналы для завершения эксплойта. Визуальное подтверждение:

range-check

На предыдущем рисунке мы видим функцию 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.

find-symbol

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

constant-fix

Итак, атакующий получает возможность записать константу в произвольное место памяти.

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

Порча указателя CLFS, или какое место перезаписать, чтобы перехватить поток выполненияРанее мы говорили о контейнерах CLFS. Когда контейнер добавляется в файл, в родительском файле заполняется некоторая структура. В структуре есть указатель с именем CClfsContainer* pContainer;. Когда соответствующий контейнер освобождается, в родительском файле выполняются операции очистки, разыменовывается pContainer и значения [[pContainer]+0x18] и [[pContainer]+0x8] используются как указатели на функции.

Таким образом, возможность управлять pContainer и выполнять специфические для контейнера API для добавления и удаления гарантирует перенаправление потока управления.

Где находится pContainer?

Структура файла CLFS не документирована, но существуют отдельные попытки реверс-инжиниринга структуры файла.

Общий вид структуры файла журнала:

bird_eye_file_Structure

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

detailed_base_blok

Различия между структурой и представлением в памяти:

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

Компоновка памяти файла контейнера выглядит следующим образом:

  1. CLFS_CONTAINER_CONTEXT structure

container

По смещению 0x18 внутри CLFS_CONTAINER_CONTEXT structure находится наша цель pContainer.

По-видимому, смещение до pContainer является постоянным, то есть первым элементом массива regContainers. И его значение равно 0x1468.

Доказательство того, что pContainer разыменовывается и используется как указатель на функцию:

Разыменование и доступ к pContainer как к указателю на функцию — это суть эксплуатации. Это происходит внутри функции Remove container в драйвере CLFS.SYS. Таким образом, эксплойт сработает при удалении контейнера.

Доказательство:

container_removal

Давайте рассмотрим шаги удаления контейнера, чтобы показать, что действительно указатель pContainer используется как указатель на функцию.

  1. В строке 22 происходит вызов API GetBaseLogRecord, который возвращает адрес базового блока в пространстве ядра + 0x70 (он перемещает указатель файла за заголовок).
  2. В строке 23 инициализируется копия результата (BaseRecord_1).
  3. В строке 41 инициализируется еще одна копия переменной a4 как v10.
  4. В строке 50: интересная конструкция индексации вектора: 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).

  1. В строке 76 v13 получается из v10 (копии a4) через приведение к вектору QWORD (размер элемента 8) и индексацию на 3 позиции. Обратите внимание в строке 16 декомпиляции, что a4 имеет тип _CLFS_CONTAINER_CONTEXT. Таким образом, v10 — это третье поле структуры, которое соответствует pContainer.
  2. В строках 82 и 83 pContainer разыменовывается, к нему добавляются 0x18 и 0x8, и он интерпретируется как указатель на функцию и вызывается. (call cs:__guard_dispatch_icall_fptr на самом деле является вызовом инструкции jmp eax).

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

Распыление памяти ядра с помощью объектов

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

В реальном случае условия более сложные. Нам нужно расположить файлы журналов в памяти ядра в определенном порядке с известным смещением между ними.

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

  1. Определить файлы CLFS, присутствующие в системе по умолчанию
  2. Выделить новый файл CLFS
  3. Определить адрес базового блока нового файла в пространстве ядра
  4. Повторить для ~50 файлов
  5. Изучить адреса, по которым были выделены файлы

Следующий фрагмент кода делает это:

page_study

И результат:

result_addresses

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

ordered_addresses

Анализируя смещения между адресами, можно заметить псевдозакономерность в распределениях: есть непрерывные выделения, между которыми постоянное смещение. Например, от ffffd80faf444000 до ffffd80faf4ee000 смещение между двумя последовательными выделениями равно 0x11000.

Это предположение критически важно для работы эксплойта. На него будет опираться.

Еще одно предположение: возьмем несколько страниц, отстоящих друг от друга на 0x11000. Например: ffffd80faf444000,ffffd80faf455000,ffffd80faf466000,ffffd80faf477000,ffffd80faf488000,ffffd80faf499000. Все страницы соответствуют открытым файлам CLFS. Если мы закроем один файл, страница будет освобождена, и память по соответствующему адресу станет свободной. В памяти это будет выглядеть так (скажем, мы закрываем файл, соответствующий странице по адресу ffffd80faf466000): ffffd80faf444000,ffffd80faf455000,xxxxxxxxxxxxxxxx,ffffd80faf477000,ffffd80faf488000,

ЕСЛИ мы снова откроем файл, ОС с высокой степенью вероятности выделит страницу по тому же адресу (ffffd80faf466000), чтобы заполнить дыру и сделать память непрерывной. --> Это также критически важно для выполнения эксплойта.

Схема эксплойта на данный момент

Теперь представим схему стратегии, которую мы будем использовать, чтобы перезаписать указатель *pConainter адресом под нашим контролем в пользовательском пространстве.

Схема эксплойта

  1. Выделяем множество файлов clfs, что приводит к такой компоновке памяти:

first_allocation

  1. Находим последовательность (в идеале максимальную) файлов, отстоящих друг от друга на 0x11000.

second_Allocation

На предыдущем рисунке start(aux1)+0x11000=start(A),start(A)+0x11000=start(B),start(B)+0x11000=start(aux2)

  1. Мы будем использовать Logfile A, чтобы перезаписать Logfile B's *pcontainer pointer, и запустим эксплойт, закрыв Logfile B с помощью специального кода удаления после закрытия.

  2. Добавим журнальный контейнер к Logfile B, чтобы выделить и обновить поля, сигнализирующие, что у B есть корректный файл контейнера. Мы можем добавить aux2 как контейнер к B.

  3. Закроем LogfileA, чтобы мы могли редактировать его на диске. Важно выбрать A и B в середине максимальной последовательности файлов с интервалом 0x11000, чтобы создать в памяти дыру, которую ОС будет заполнять в приоритетном порядке. Если этого не произойдет, эксплойт провалится.

  4. Пересчитаем хэш A, отредактируем его поле хэша для сохранения целостности и снова откроем A, надеясь, что ядро разместит его по тому же адресу; иначе эксплойт провалится.

  5. Вызовем AddLogContainer на A с обычным файлом журнала, чтобы вызвать перезапись указателя *pContainer у B.

  6. Удалим файл B, чтобы вызвался RemoveConainter и управление передалось на *pContainer файла B, который теперь поврежден и указывает на код, выделенный пользователем.

На следующем рисунке изображены описанные выше шаги.

steps_first

КОД — Получение корректной компоновки памяти

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

allocate_clfs

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

find_tw_files

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

malform_first_file_o

Редактирование заголовков A и пересчет его хэша

Этот шаг требует дополнительного объяснения, поскольку нам нужно вычислить некоторые точные значения, которые при срабатывании эксплуатируемого кода в 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 категории полей:

  1. Поля, чтобы принудительно привести условие IF в AllocSymbol к значению FALSE
  2. Поле, соответствующее v9, для вычисления точного расстояния до *pContainer файла B

Поля из категории 1 не будут детализироваться. V9 соответствует полю по смещению 0x1b98 на диске внутри файла A. Больше не будет деталей о том, почему эти поля вызывают срабатывание эксплойта; читателю предоставляется возможность изучить это самостоятельно, если интересно.

Вот значения, которые необходимо изменить:

disk_a_modification

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

Обратите внимание, что v10 также будет использоваться для обнуления (memset с 00) области памяти длиной 0xa0.

first_memset

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

overwrite_constant

Обратите внимание на константное значение вида 0x30c1fdf006X0.

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

Представление файла журнала на диске несколько отличается от представления в памяти. Мы изменяем только содержимое base block, длина которого составляет 0x7a00 и который начинается на диске со смещения файла 0x800. Алгоритм хэширования, используемый для хэширования базового блока, — CRC32. Поле, содержащее значение хэша, также хранится внутри базового блока по смещению 0x80c.

Процедура пересчета хэша:

  1. Обнулить старое значение CRC32 по адресу 0x80c
  2. Отредактировать поля в соответствии с заданными значениями и смещениями
  3. Вычислить CRC32 для базового блока, начиная с 0x800, длиной 0x7a00.
  4. Заменить обнуленное значение новым значением CRC32

Полный код, отвечающий за запуск эксплойта:

Чтобы перенаправить выполнение кода в пользовательское пространство, нам нужно:

  1. Получить компоновку памяти файлов CLFS A и B (уже объяснено)
  2. Закрыть A, отредактировать его на диске с поврежденными заголовками и снова открыть (объяснено)
  3. Добавить контейнер журнала к B (в коде назван second) для инициализации структур контейнера. Контейнер должен быть обычным нормальным файлом журнала (одним из 50 выделенных ранее)
  4. Подготовить пользовательскую память, которая будет представлять измененный путь выполнения (БУДЕТ ОБЪЯСНЕНО ДАЛЕЕ)
  5. Добавить контейнер журнала к A (в коде назван first), используя обычный файл журнала в качестве контейнера. Добавление контейнера к поврежденной версии A вызовет перезапись значения *pConainter файла B (second) константой вида 0x30c1fdf006X0.
  6. Установить для B (second) автоматическое удаление после закрытия с помощью NtSetInformationFile. Критически важно использовать этот API, потому что простое закрытие его дескриптора не вызовет API RemoveContainer.

ntsetinfofile

  1. Закрыть B и вызвать перенаправление.

Пользовательское распыление памяти и компоновка

Существуют некоторые условия, накладываемые на пользовательскую память, которая является целью перенаправления потока выполнения через *pContainer. Мы не можем просто начать выполнять инструкции из пользовательского пространства, когда программа находится в режиме ядра.

Цель этого раздела — утечка значения SystemToken в пользовательском пространстве без вызывания системного сбоя (BSOD).

Как можно прочитать данные из адреса в ядре и сохранить результат в пользовательском пространстве? Краткое напоминание о разделе про Pipe в руководстве:

second_pipes

Здесь мы:

  1. Выделили pipe (объект ядра) достаточно большого размера, чтобы он был размещен внутри BigPool
  2. Записали некоторую информацию в секцию атрибутов pipe (строку алфавита BCDEFBBBB)
  3. Получили адрес объекта pipe в пользовательском пространстве, используя поле Tag страниц BigPool
  4. Изучили структуру pipe в ядре с помощью Windbg.
  5. Прочитали данные обратно из ядра в пользовательском пространстве с помощью NtFsControlFile PipeReadAttribute

Идея, связывающая объект Pipe с нашей целью получения значения системного токена, состоит в том, чтобы использовать NtFsControlFile PipeReadAttribute для чтения с адреса системного токена вместо начала буфера, содержащего информацию, которую мы передали ядру через PipeWrite Attribute.

Это означает, что мы должны изменить значение указателя по адресу PIPE_BEGIN+=0x20, чтобы он содержал адрес, по которому расположен системный токен.

Этот адрес известен, он был получен на более ранних этапах руководства, когда мы находили и разбирали структуру EPROCESS.

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

Для этого мы используем перенаправление кода, полученное через эксплуатацию функции AddLogConainer в CLFS. Можно подумать, что достаточно написать шеллкод, который выполняет прямую замену, но (хотя это не проверялось) это, вероятно, не сработает. Это связано с тем, что драйвер работает в контексте ядра и выполняет код из области пользовательского процесса.

Чтобы обойти это ограничение, мы должны найти ROP-гаджеты ядра, которые выполняют запись произвольного значения. То есть найти фрагменты кода ядра, которые находятся в конце функции и заканчиваются инструкцией ret, и предоставить их адреса в качестве целей для перенаправления. Таким образом, код по-прежнему выполняется ядром.

Это основная идея, но ограничения возникают из-за того, как драйвер CLFS вызывает код внутри *pConainer:

mem_spray_user

Чтобы создать пригодный шаблон памяти, мы должны изучить, как функция 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, будут вычислены позже).

Это алгоритм генерации шаблона распыления.

algo_spray_first_stage

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

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

sprayed_mem_constant

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

sprayed_mem_ROP

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

debugger_redir

Обратите внимание на значение регистра RDI и указатель инструкций в отладчике. RDI после одного разыменования равен 0x5000000 и сохраняется в RAX. [RAX+0x18] — это второй ROP, а ниже [RAX+0x8] будет адресом первого ROP.

Кернел ROP-гаджеты: что это и зачем они нужны

На этом этапе у нас есть следующие части эксплойта, которые мы должны связать вместе:

  1. Адрес системного токена
  2. Перенаправление кода с использованием эксплойта CLFS
  3. Pipe с объектом атрибута

Задача на данный момент — связать перенаправление кода с фрагментом кода, который перезапишет указатель Pipe на буфер атрибутов адресом системного токена и использует Pipe read attribute для получения информации обратно в пользовательское пространство.Как мы уже говорили, код, на который перенаправляется выполнение, также должен находиться в ядре. Более того, в нашем распоряжении есть только 2 функции. В данном руководстве не будет рассматриваться, как были найдены эти две конкретные функции, но, предположительно, существует список часто используемых ROP-кандидатов.

Мы изучим 2 функции:

  1. SeSetAccessStateGenericMapping в ntoskrnl.exe Вызывается второй ([rax+0x8])
  2. ClfsEarlierLsn в CLFS.SYS Вызывается первой ([rax+0x18])

Анализ ClfsEarlierLSn:

ealier

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

Анализ SeSetAccessStateGenericMapping:

sesetAccess

Этот фрагмент нужно подробно разобрать построчно.

Входные данные: при входе в эту функцию:

  1. Значение RAX не имеет значения, поскольку оно перезаписывается в первой строке функции
  2. RCX — это значение RDI, то есть константа 0x30C1FDF006X0
  3. Значение [RCX+0x48] имеет вид 0x30C1FDF00YX8, всегда выровнено по 0x8 и не конфликтует с 0x30C1FDF006X0, которое выровнено по 0 и содержит 0x5000000

Выполнение кода функции:

  1. Инструкция mov rax, [rcx+48h] выполнит разыменование по адресу 0x30C1FDF00YX8 и переместит значение в RAX
  2. Инструкция movups xmm0, xmmword ptr [rdx] переместит 16 байт из RDX в регистр XMM0. Помните: после ClfsEarlierLSn в RDX находится 0xFFFFFFFF
  3. Инструкция movdqu xmmword ptr [rax+8], xmm0 переместит значение XMMO в [RAX+0x8]

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

mem_move

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

Вот как это реализовано в реальном коде:

mem_pattern

Ещё один аспект, который не был объяснён: как получить адреса ROP-функций ядра в пользовательском пространстве?

Здесь используется трюк. Сначала вы можете получить базовый адрес любого модуля ядра с помощью NtQuerySystemInformation со следующими параметрами: NtQuerySystemInformation(SystemModuleInformation, (HANDLE)ModuleInfo, ModuleInfoSize, &retlen)

Затем вы фильтруете по значению ModuleInfo->Modules[i].Name, чтобы найти нужную библиотеку. Так вы получаете базовый адрес в ядре.

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

Мы загружаем модуль в пользовательское пространство (вы можете это сделать) с помощью LoadLibrary и получаем адрес экспортируемой функции через Getprocaddress. Вычисляем дельту между адресом экспорта и базовым адресом в пользовательском пространстве и прибавляем её к базовому адресу ядра (найденному с помощью NtQuerySystemInformation). Таким образом, находясь в пользовательском пространстве, мы получаем адрес экспортируемой функции, загруженной в пространстве ядра.

Чтение значения системного токена

Как только указатель на атрибут повреждён и настроен так, чтобы указывать на расположение системного токена, вызов MyNtFsControlFile с правильными параметрами должен прочитать данные по этому адресу и раскрыть значение системного токена в пользовательском пространстве.

read_token

Что дальше?

Имея корректное значение системного токена, для завершения эксплойта мы должны записать его вместо токена нашего собственного процесса. То есть перезаписать токен процесса, который выполняет эксплойт, значением системного токена.

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

Шаги для перезаписи собственного значения токена:

  1. Определить, где в ядре хранится наш собственный токен (аналогично тому, как в первый раз мы определили адрес системного токена).
  2. Снова выполнить распыление системной памяти и распыление объектов с помощью файлов CLFS.
  3. В распылённой пользовательской памяти задайте содержимое буфера таким образом, чтобы DESTINATION_ADDRESS указывал на адрес нашего собственного системного токена, а адрес источника (по адресу 0xFFFFFFFF) содержал значение системного токена, полученное при первом запуске эксплойта.
  4. Запустить эксплойт CLFS второй раз.
  5. Запустить cmd.exe и проверить привилегии.

Поскольку во второй раз нам не нужно чтение из ядра, pipe в этот раз нам не понадобится.

Поскольку эти шаги не требуют дополнительных знаний, руководство можно закончить здесь финальным доказательством — cmd.exe с привилегиями nt authority\system

final_proof

Краткое содержание

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

Скачать инструмент
ffffd80faf499000