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:
- Create a predictable pool layout aka Fengshui
- Overwrite a
_WNF_STATE_DATA
object - Put a
_TOKEN
object behind our_WNF_DATA
object - Establish arbitrary read
- Use arbitrary increment
- ???
- 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.
- The object which will trigger the overflow. See our BH presentation. It has an dynamic size.
_WNF_STATE_DATA
<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:
- Spray enough
_WNF_STATE_DATA
to fill up all existing holes to get the allocations continous and enabledynamic lookaside
for this allocation size. - Free one
_WNF_STATE_DATA
- Trigger the allocation of our vulnerable object and overflow
AllocatedSize
and parts ofDataSize
inside a_WNF_STATE_DATA
- Use the
_WNF_STATE_DATA
object to overwriteAllocatedSize
andDataSize
of the next_WNF_STATE_DATA
with better values - Replace the not corrupted
_WNF_STATE_DATA
objects with_TOKEN
objects - Enjoy the full control over a
_TOKEN
object
Visualized it looks something like this:

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.

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:
- Search the page containing the
_TOKEN->SessionObject
of our current Token for an allocation with the tagAlIn
- Read the pointer at offset 0x38 as it contains an IoCompletion object
- Search the page containing the IoCompletion object
for an allocation with the tag
EtwR
. - Read the pointer at offset 0x30 as it contains an
_EPROCESS
object - 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.

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!

Arbitrary increment with BnoIsolationHandlesEntry
So lets check it out in Binary Ninja:

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:

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:

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:
- We point
token->BnoIsolationHandlesEntry
(from the token we r/w into) to our process tokens_TOKEN.Privileges.Present
- 0x18 +privs.offset
- Duplicate the controlled token for
privs.amount
- Point
token->BnoIsolationHandlesEntry
(from the token we r/w into) to our process tokens_TOKEN.Privileges.Enabled
- 0x18 +privs.offset
- Duplicate the controlled token for
privs.amount
- 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 :)