Porting a Windows Kernel data-only exploit technique to 24H2

How we got a paged-pool exploit technique working under Windows 11 24H2

Porting a Windows Kernel data-only exploit technique to 24H2

This research was part of our presentation at BlackHat Asia 2025. We will present our general exploitation strategy on how we used an integer overflow turned out-of-bounds write (CVE-2024-36336) inside the paged pool to escalate our privileges on the latest Windows 11 version 24H2. In this blogpost we will focus on the exploitation strategy not on the bugs themselves. The bugs could be the content of a later blogpost… Maybe…

TL;DR

We largely followed the method described by k0shl in his writeup about CVE-2022-22715 Windows Dirty Pipe. An amazing blog post read it!. With Windows 11 version 24H2 the bug around previousMode which was often used for better read/write primitives was fixed. As the write primitive which k0shl used had side effects, we had to replace it with something else. We used a reference counter at offset 0x18 of _TOKEN->BnoIsolationHandlesEntry to gain an arbitrary increment primitive when we duplicate a _TOKEN object. The increment primitive only works if the value to increment is within the range 0x1 - 0x7ffffffffffffffff otherwise, you get to enjoy a bluescreen. This primitive was then used to enable the SeDebug privilege in the _TOKEN object of the current _EPROCESS and thus escalating our privileges. As a little bonus when you close the duplicated _TOKEN objects your privileges will be automatically dropped again.

Intro

So lets start at the beginning. Our exploitation strategy looks something like this:

  1. Create a predictable pool layout aka Fengshui
  2. Overwrite a _WNF_STATE_DATA object
  3. Put a _TOKEN object behind our _WNF_DATA object
  4. Establish arbitrary read
  5. Use arbitrary increment
  6. ???
  7. Profit

WTF is WNF?

Before we start with our fengshui we have to talk about _WNF_STATE_DATA objects. This will be the object we will primarly use for our fengshui. Think about it like a swiss army knife for paged pool exploitation. Lets have a look at the object:

struct _WNF_STATE_DATA
{
    struct _WNF_NODE_HEADER Header;             //0x0
    ULONG AllocatedSize;                        //0x4
    ULONG DataSize;                             //0x8
    ULONG ChangeStamp;                          //0xc
    UCHAR UserData[0];                          //Up to 0x1000
}; 

As seen by the last member of this struct the _WNF_STATE_DATA has a dynamic size. We can write up to 0x1000 bytes of user controlled data. This means we know exactly how big our _WNF_STATE_DATA object will be. Additonally AllocatedSize and DataSize will be set to the size of our UserData. With an overflow we can now overwrite AllocatedSize and DataSize to an value bigger than the actual UserData. Now we can use the APIs NtQueryWnfStateData and NtUpdateWnfStateData to linearly read and write out of bounds of our _WNF_STATE_DATA allocation.

More specifically if you overwrite AllocatedSize you can write out of bounds up to the specified value or to a maximum of 0x1000. Fun fact you can overwrite AllocatedSize with a size bigger than 0x1000 and you will still be able to write up to 0x1000 bytes.

DataSize on the other hand, controls how much we can read. Here we are not limited to 0x1000 bytes but you always need to read the entire buffer. So if you overwrite this field with the value 0xffffff you have to read all 0xffffff bytes. If you read a page which is not paged in, your system will crash. Don’t use huge numbers here. You can also use this property to your advantage. You can query all sprayed _WNF_STATE_DATA objects with an buffer of similar size as the original one and the one which returns STATUS_BUFFER_TOO_SMALL is the object where AllocatedSize got corrupted.

We can create a _WNF_STATE_DATA object with a call to NtCreateWnfStateName and NtUpdateWnfStateData. As detailed in k0shls blog in the section Demonstrate how I use WNF API with a accessible SD.

Fengshui

Enough nerding about _WNF_STATE_DATA it is time to make our paged pool layout predictable. There are a few sources on how the windows kernel allocator works like this or this. I mainly used the second source.

The hardest part about the fengshui was choosing the size and which allocator we want to use. We choose the Variable Size allocator. Our fengshui has three different objects.

  1. The object which will trigger the overflow. See our BH presentation. It has an dynamic size.
  2. _WNF_STATE_DATA <3
  3. A _TOKEN object

Luckily the only object where we don’t control the size is the _TOKEN object. Interestingly the _TOKEN object is not a fixed allocation but the size can vary depending on what is stored inside the _TOKEN->DynamicPart. I think it is some DACL stuff but I’m not 100% sure. For us our _TOKEN objects had a size around 0x7c0. So we adjusted our _WNF_STATE_DATA and our vulnerable object to the same size.

Then our strategy was the following:

  1. Spray enough _WNF_STATE_DATA to fill up all existing holes to get the allocations continous and enable dynamic lookaside for this allocation size.
  2. Free one _WNF_STATE_DATA
  3. Trigger the allocation of our vulnerable object and overflow AllocatedSize and parts of DataSize inside a _WNF_STATE_DATA
  4. Use the _WNF_STATE_DATA object to overwrite AllocatedSize and DataSize of the next _WNF_STATE_DATA with better values
  5. Replace the not corrupted _WNF_STATE_DATA objects with _TOKEN objects
  6. Enjoy the full control over a _TOKEN object

Visualized it looks something like this:

Photo of the fengshui

Fengshui visualized, AS: Allocated Size and DS: Data Size

Establish arbitrary read

After a reliable linear read/write primitive was established we needed arbitrary read. To establish an arbitrary read we can modify the _TOKEN->BnoIsolationHandlesEntry and point it to a controlled userspace address, see once again k0shls blog. At this userspace address we can now forge a _SEP_CACHED_HANDLES_ENTRY. The EntryDescriptor.IsolationPrefix.Buffer at offset 0x30 can be pointed to the kernel address we want to read. And EntryDescriptor.IsolationPrefix.MaximumLength at offset 0x28 defines how much bytes will be read.

Photo illustrating the arbitrary read setup

Simple illustration of the arbitrary read setup

After we setup our _SEP_CACHED_HANDLES_ENTRY in userspace we can simply call NtQueryInformationToken with a handle to our corrupted token and set the TokenInformationClass to TokenBnoIsolation. At offset 0x10 of the returning buffer you will find your data. So make the input buffer 0x10 bytes bigger than EntryDescriptor.IsolationPrefix.MaximumLength.

What should we read?

Chen Le Qi from Starlabs documented in a blogpost from 2023 how we can find the _EPROCESS structure of our current process from the _TOKEN object we control. It still works in 24H2!

I summarize this strategy shortly:

  1. Search the page containing the _TOKEN->SessionObject of our current Token for an allocation with the tag AlIn
  2. Read the pointer at offset 0x38 as it contains an IoCompletion object
  3. Search the page containing the IoCompletion object for an allocation with the tag EtwR.
  4. Read the pointer at offset 0x30 as it contains an _EPROCESS object
  5. Iterate over the ActiveProcessLinks linked list of the _EPROCESS to find our own _EPROCESS object by comparing the UniqueProcessId field to our current process id.

Why we need _EPROCESS?

Because the _EPROCESS has again a pointer to the currently used _TOKEN which contains the privileges held of our current process. If we can change the fields _TOKEN.Privileges.Present and _TOKEN.Privileges.Enabled in the _TOKEN of our current process, we can enable powerful SE_Privileges. Those privileges are allowing us to bypass a lot of security checks. For example with SeDebugPrivilege we can obtain a handle with full rights to the winlogon.exe process and spawn a subprocess with it.

What if I am an _EPROCESS hater?

That is a bit wierd, but we don’t judge. Luckily for you Angelboy presented at Hexxacon 2024 a different way to escalate your privileges. You have to locate the symbol nt!SeDebugPrivilege and later increment the value there to the value of a privilege you are currently holding. So instead of _EPROCESS you have to locate the nt base address. Check his blogpost for more details. He was also an inspiration for using an arbitrary increment as a primitive later.

Arbitrary increment

RIP previousMode

So Microsoft killed the previousMode technique with 24H2.

Photo of an upset child congratulating

My reaction to this amazing achievement...

K0shls technique for arbitrary write described in his blog had some sideeffects. It messed up values surrounding the value we wrote. This was not a problem when we simply could set the previousMode to 0 and then use NtReadVirtualMemory and NtWriteVirtualMemory to clean up the stuff we messed up. But we couldn’t get it working anymore :(

(Mis)using reference counters

As the awesome previousMode trick was gone, we had to find a different way to enable SeDebugPrivilege for our process. As we only need to flip a bit inside the fields _TOKEN.Privileges.Present and _TOKEN.Privileges.Enabled we realized an stable arbitrary increment would suffice. After checking the _TOKEN object we realized that our good old friend _TOKEN->BnoIsolationHandlesEntry has the following type:

struct _SEP_CACHED_HANDLES_ENTRY
{
    struct _RTL_DYNAMIC_HASH_TABLE_ENTRY HashEntry;                //0x0
    LONGLONG ReferenceCount;                                       //0x18 <-- looking promising
    struct _SEP_CACHED_HANDLES_ENTRY_DESCRIPTOR EntryDescriptor;   //0x20
    ULONG HandleCount;                                             //0x38
    VOID** Handles;                                                //0x40
}; 

At offset 0x18 there is an ReferenceCount counter which could be abused as an increment primitive. Because if we could duplicate a _TOKEN and only the field ReferenceCount gets changed we would be back in the game!

Photo illustrating the arbitrary increment

Arbitrary increment with BnoIsolationHandlesEntry

So lets check it out in Binary Ninja:

Photo of the increment

Increment during SepDuplicateToken

So if we duplicate a _TOKEN and _TOKEN->BnoIsolationHandlesEntry is set and the _TOKEN->BnoIsolationHandlesEntry.ReferenceCount is not 0 and less than 0x7ffffffffffffffff, only the _TOKEN->BnoIsolationHandlesEntry.ReferenceCount is changed. Perfect!

We also should verify the decrement:

Photo of the decrement

Decrement when a _TOKEN handle is closed

As long as the value doesn’t get decremented to 0, which would try to free the _SEP_CACHED_HANDLES_ENTRY, we should be fine.

Good life!

If we look at the Present and Enabled fields of an process token running in low integrity:

Photo of the privilege struct in a low integrity process

The privilege struct of an low integrity process

The values are between 0 and 0x7ffffffffffffffff. Perfect!

As a bonus when we don’t need SeDebugPrivilege anymore, we can simply close the handles to our duplicated tokens and it will drop our privileges again.

Calculations

But how many times do we need to duplicate our modified _TOKEN and where do we have to point _TOKEN->BnoIsolationHandlesEntry.ReferenceCount to? Lets first have a closer look at SE_Privileges. We created a struct with some of the most important SE_Privileges:

typedef enum _PRIVS
{
	unk = 0x0,
	unk2 = 0x1,
	SeCreateTokenPrivilege = 0x2,
	SeAssignPrimaryTokenPrivilege = 0x3,
	SeLockMemoryPrivilege = 0x4,
	SeIncreaseQuotaPrivilege = 0x5,
	SeUnsolicitedInputPrivilege = 0x6,
	SeTcbPrivilege = 0x7,
	SeSecurityPrivilege = 0x8,
	SeTakeOwnershipPrivilege = 0x9,
	SeLoadDriverPrivilege = 0xa,
	SeSystemProfilePrivilege = 0xb,
	SeSystemtimePrivilege = 0xc,
	SeProfileSingleProcessPrivilege = 0xd,
	SeIncreaseBasePriorityPrivilege = 0xe,
	SeCreatePagefilePrivilege = 0xf,
	SeCreatePermanentPrivilege = 0x10,
	SeBackupPrivilege = 0x11,
	SeRestorePrivilege = 0x12,
	SeShutdownPrivilege = 0x13,
	SeDebugPrivilege = 0x14,
	SeAuditPrivilege = 0x15,
	SeSystemEnvironmentPrivilege = 0x16,
	SeChangeNotifyPrivilege = 0x17,
	SeRemoteShutdownPrivilege = 0x18,
	SeUndockPrivilege = 0x19,
	SeSyncAgentPrivilege = 0x1a,
	SeEnableDelegationPrivilege = 0x1b,
	SeManageVolumePrivilege = 0x1c,
	SeImpersonatePrivilege = 0x1d,
	SeCreateGlobalPrivilege = 0x1e,
	SeTrustedCredManAccessPrivilege = 0x1f,
	SeRelabelPrivilege = 0x20,
	SeIncreaseWorkingSetPrivilege = 0x21,
	SeTimeZonePrivilege = 0x22,
	SeCreateSymbolicLinkPrivilege = 0x23,
	SeDelegateSessionUserImpersonatePrivilege  = 0x24
} PRIV;

The value inside PRIV is the bit offset which must be 1 inside the 8 byte fields of Present and Enabled so the privilege can be used by the process.

There is only one slight problem, we have a byte precision increment and we need to flip a bit. The first thing we need to know is which byte we need to increment. We called this offset. We can calculate this by dividing the PRIV by 8 with an integer division.

Now we need to calculate how many times we need to increment our target byte. For that we just check the last byte of our privilege and see which bit we need to flip. We named this property amount:

typedef struct _PrivOffsets {
	uint16_t amount;
	uint16_t offset;
	} PrivOffsets;

consteval PrivOffsets CalcPrivIncrement(PRIV priv) {
	PrivOffsets offset = { 0 };
	offset.offset = priv / 8;
	offset.amount = 1 <<  ((priv & 0x7));
	return offset;
}

Please note: I accidentally included a screenshot of the wrong offset calculation in my BH presentation. Shame on me. I fixed it here. Sorry about that!

Usage

Now lets put our new primitive to use:

  1. We point token->BnoIsolationHandlesEntry (from the token we r/w into) to our process tokens _TOKEN.Privileges.Present - 0x18 + privs.offset
  2. Duplicate the controlled token for privs.amount
  3. Point token->BnoIsolationHandlesEntry (from the token we r/w into) to our process tokens _TOKEN.Privileges.Enabled - 0x18 + privs.offset
  4. Duplicate the controlled token for privs.amount
  5. Enjoy your SE_Privileges

My code looks something like this:

HANDLE* buf_pres = (HANDLE*)calloc(privs.amount, sizeof(HANDLE));
if (buf_pres == NULL) {
	return NULL;
}
HANDLE* buf_en = (HANDLE*)calloc(privs.amount, sizeof(HANDLE));
	if (buf_en == NULL) {
		return NULL;
}

// use arbitrary increment on present (+0x40) -0x18 for the offset of the ref counter in BnoIsolationHandlesEntry
token->BnoIsolationHandlesEntry = (void*)(process_token_addr + offsetof(_TOKEN, Privileges) + offsetof(_SEP_TOKEN_PRIVILEGES, Present) + privs.offset - 0x18);
NTSTATUS set_res = lNtUpdateWnfStateData(state, curr_wnf_buf, buf_size, &TypeID, NULL, NULL, NULL);
for (int i = 0; i < privs.amount; i++) {
	bool bool_result = DuplicateToken(manipulated_token, SecurityAnonymous, &buf_pres[i]);
	if (!bool_result) {
		printf("Duplicate token failed pres\n");
	}
}
// use arbitrary increment on enabled (+0x48) -0x18 for the offset of the ref counter in BnoIsolationHandlesEntry
token->BnoIsolationHandlesEntry = (void*)(process_token_addr + offsetof(_TOKEN, Privileges) + offsetof(_SEP_TOKEN_PRIVILEGES, Enabled) + privs.offset - 0x18);
set_res = lNtUpdateWnfStateData(state, curr_wnf_buf, buf_size, &TypeID, NULL, NULL, NULL);
for (int i = 0; i < privs.amount; i++) {
	bool bool_result = DuplicateToken(manipulated_token, SecurityAnonymous, &buf_en[i]);
	if (!bool_result) {
		printf("Duplicate token failed pres\n");
	}
}
// set the BnoIsolationHandlesEntry to NULL so we can close the modified token without crashing
token->BnoIsolationHandlesEntry = NULL;
set_res = lNtUpdateWnfStateData(state, curr_wnf_buf, buf_size, &TypeID, NULL, NULL, NULL);

When you finished enjoying your SE_Privileges, you can simply close the handles and the privilege will be gone again:

BOOL res = true;
for (int i = 0; i < privs.amount; i++) {
	res = CloseHandle(buf_en[i]);
	res = CloseHandle(buf_pres[i]);
}
free(buf_en);
free(buf_pres);

Closing thoughts

So this technique is nothing revolutionary. We just ported an data-only attack technique to 24H2 with a little trick abusing reference counters. I’m far from an expert in Windows Kernel exploitation. I just wanted to share the reference counter trick and document the entire exploitation strategy. I hope somebody maybe find it useful.

One last shoutout as I pulled most structs inside this blogpost from the Vergilius Project website. A cool website if you want to quickly check a struct inside the windows kernel.

And if you find any errors please tell me and I will fix them :)

Cookies
essential