CVE-2021-31956 — NTFS NtQueryEaFile Heap Overflow → WNF LPE
Last updated: 2026-04-11
Severity: High
Component: ntfs.sys (NtfsQueryEaUserEaList)
Bug Class: Integer Underflow → Heap Overflow (Paged Pool)
Privilege Escalation: User → SYSTEM
Exploit Status: ITW — PuzzleMaker APT (April 2021), exploited alongside CVE-2021-31955
Patch: June 2021 Patch Tuesday (KB5003637)
Related: Wnf Internals, Cve 2021 31955, Heap Grooming, Primitives
Tags:integer-overflow,heap-overflow,kernel-mode,pool,aaw,aar,token-steal
Vulnerability Summary
NtfsQueryEaUserEaList in ntfs.sys iterates over NTFS extended attribute (EA) blocks, copies them into a caller-supplied output buffer, and applies 32-bit alignment padding between entries. A check verifies ea_block_size <= out_buf_length - padding, but when out_buf_length == 0 and padding == 2, the subtraction underflows (DWORD wraps: 0 - 2 = 0xFFFFFFFE), making any ea_block_size appear to pass the check. The result: an attacker-controlled memmove of controlled size and content off the end of a controlled paged pool allocation. This is the first publicly documented exploitation of WNF structures as kernel pool spray objects.
Root Cause Analysis
// NtfsQueryEaUserEaList simplified:
for (cur_ea = ea_list; ; cur_ea = next_ea) {
out_buf_pos = out_buf + padding + occupied_length;
ea_block_size = ea_block->DataLength + ea_block->NameLength + 9;
if (ea_block_size <= out_buf_length - padding) // ← integer underflow here
{
memmove(out_buf_pos, ea_block, ea_block_size); // heap overflow
}
out_buf_length -= ea_block_size + padding;
padding = ((ea_block_size + 3) & 0xFFFFFFFC) - ea_block_size; // 32-bit align
}
Trigger sequence:
- EA1:
EaNameLength=5, EaValueLength=4→ea_block_size=18,out_buf_length=18,padding=0- Check:
18 <= 18 - 0→ True → copy occurs;out_buf_length = 0,padding = 2
- Check:
- EA2: any larger
ea_block_size(e.g., 137)- Check:
137 <= 0 - 2→137 <= 0xFFFFFFFE→ True (underflow) → overflow occurs
- Check:
Attacker control:
- Size of vulnerable allocation: set
LengthinNtQueryEaFileto equal the first EA block size - Size of overflow: controlled by
EaValueLengthof second EA block - Content of overflow: controlled by EA value bytes
Pool tag: NtFE (ntfs.sys EA allocation). Allocation falls in paged pool, size configurable.
Triggering
// Setup EA on file:
NtSetEaFile(hFile, &iosb, &eaBuffer, eaBufferSize);
// EA1: small size (= desired allocation size - 0x10 pool header)
// EA2: large size (overflow content + size)
// EA1->NextEntryOffset = sizeof(EA1) + padding, EA2->NextEntryOffset = 0
// Trigger:
NtQueryEaFile(hFile, &iosb, outBuf, firstEaBlockSize, FALSE, eaList, listLen, NULL, TRUE);
The allocation size matches the output buffer Length parameter — full attacker control over pool chunk size (and therefore which bucket/subsegment it lands in).
WNF as Exploitation Primitive
This is the first public use of WNF structures for kernel pool grooming. Key insight from Alex Plaskett (NCC Group): WNF allocations go to paged pool and are precisely size-controlled from userland.
WNF_STATE_DATA as Controlled Paged Pool Allocation
// NtUpdateWnfStateData calls ExpWnfWriteStateData which calls:
ExAllocatePoolWithQuotaTag(PagedPool, (unsigned int)(dataLen + 16), 'WNF ');
// Allocation = 0x10 pool header + 0x10 _WNF_STATE_DATA header + dataLen bytes
// Pool tag: 'Wnf '
To allocate exactly N bytes in paged pool:
NtCreateWnfStateName(&state, WnfTemporaryStateName, WnfDataScopeMachine, FALSE, 0, 0x1000, psd);
NtUpdateWnfStateData(&state, buf, N - 0x10, 0, 0, 0, 0);
// Resulting pool chunk = N + 0x10 (pool header)
Target Layout
_WNF_STATE_DATA: data_len=0xA0 → chunk 0xC0 (0xA0 + 0x10 header + 0x10 pool hdr)
_WNF_NAME_INSTANCE: 0xA8 + 0x10 pool header = 0xB8, rounds to 0xC0
NtFE victim: output_len=0xB0 → chunk 0xC0
All three objects are 0xC0 bytes — same LFH bucket. Both WNF objects and the NTFS overflow victim share the same paged pool segment.
Stage 1: Spray + Overflow (0x10-byte overflow)
Spray many _WNF_STATE_DATA + _WNF_NAME_INSTANCE objects (both 0xC0) then trigger the overflow. Limit overflow to 0x10 bytes past the pool header to minimize damage to neighboring objects:
- If next chunk is
_WNF_NAME_INSTANCE: corruptsHeader + RunRef(+0x0, +0x8) - If next chunk is
_WNF_STATE_DATA: corruptsHeader + AllocatedSize + DataSize + ChangeStamp(+0x0, +0x4, +0x8, +0xC)
The WNF_STATE_DATA corruption is exploitable — enlarging DataSize gives an OOB read/write primitive.
Stage 2: Detect + Exploit Corrupted WNF_STATE_DATA
// Detect: scan all state names for one with unexpectedly large DataSize
NtQueryWnfStateData(&state, NULL, NULL, &stamp, buf, &size);
// Identifies corrupted WNF_STATE_DATA
// Scan memory content for WNF_NAME_INSTANCE header pattern: \x03\x09\xa8
// _WNF_NODE_HEADER.TypeCode=0x903, ByteSize=0xA8 → stored as 3 bytes: 0x03 0x09 0xA8
// Read CreatorProcess from WNF_NAME_INSTANCE+0x98 → EPROCESS address (no CVE-2021-31955 needed)
// Read StateData pointer from +0x58, ScopeInstance from +0x30, StateName from +0x28
Stage 3: KTHREAD Leak + PreviousMode Overwrite
// Overwrite _WNF_NAME_INSTANCE.StateData to point near _KPROCESS.ThreadListHead
// _EPROCESS+0x0 = _KPROCESS, ThreadListHead at _KPROCESS+0x30
// Point StateData to KPROCESS+0x220 (_KPROCESS.Process member area):
// AllocatedSize = upper 32 bits of KPROCESS pointer (always sane kernel addr high word)
// DataSize = UserAffinity (can be influenced via SetProcessAffinityMask)
// Read 3 bytes from KTHREAD+0x232 = PreviousMode (confirmed as 0x01 = UserMode)
// Write 0x00 to PreviousMode → kernel mode context for this thread
EPROCESS.Pcb (=KPROCESS).ThreadListHead.Flink → KTHREAD.ThreadListEntry
KTHREAD = Flink - 0x2F8 (ThreadListEntry offset)
PreviousMode = KTHREAD + 0x232
Post-Exploitation
With PreviousMode=0:
NtWriteVirtualMemoryandNtReadVirtualMemoryaccept kernel addresses- Walk
EPROCESS.ActiveProcessLinksto find SYSTEM process (PID=4) - Read
SYSTEM.Token, write toCurrentProcess.Token
Must restore PreviousMode to 1 before spawning new process — otherwise NtCreateUserProcess crashes at PspLocateInPEManifest.
Cleanup (for Real-World Reliability)
Three post-exploitation cleanups required:
Restore _WNF_NAME_INSTANCE.StateData pointer: walk
_WNF_SCOPE_INSTANCE.NameSetAVL tree using arbitrary read, find target by StateName value, restore pointer.Restore corrupted RunRef fields: walk
_EPROCESS.WnfContext._WNF_PROCESS_CONTEXT.TemporaryNamesListHead, detect non-0x0000000000A80903headers, restore Header and RunRef to 0.Restore PreviousMode: use
NtWriteVirtualMemoryto set back to 1 before spawning shell.
NTOSKRNL Base Address (Bonus Technique)
Without a KASLR leak: put a thread in wait state (NtWaitForSingleObject), walk that thread’s kernel stack using arbitrary read, find nt!KiSystemServiceCopyEnd return address, calculate NTOSKRNL base from fixed offset. Used in KernelForge too.
PuzzleMaker In-the-Wild Exploitation
- Date: April 14-15, 2021 (targeted attacks, multiple companies)
- Chain: Chrome 0-day (CVE-2021-21224, V8 Typer bug) → sandbox escape via CVE-2021-31955 + CVE-2021-31956
- CVE-2021-31955: Information disclosure in
NtQuerySystemInformation(SystemSuperfetchInformation)classSuperfetchPrivSourceQuery— leaked EPROCESS addresses for running processes. Not strictly needed (CreatorProcess available via WNF), but used in the wild. - PreviousMode technique: used to inject a malware module into SYSTEM process
- Malware chain: Stager → Dropper → WmiPrvMon.exe (service) → wmimon.dll (remote shell)
- C&C:
media-seoengine[.]com
Key Primitives Used
| Primitive | Technique |
|---|---|
| Controlled paged pool allocation | NtUpdateWnfStateData → ExAllocatePoolWithQuotaTag(PagedPool, len+16, 'WNF ') |
| Relative OOB read/write | WNF_STATE_DATA DataSize corruption → unbounded NtQueryWnfStateData |
| EPROCESS leak | _WNF_NAME_INSTANCE.CreatorProcess (+0x98) — no separate KASLR bypass needed |
| KTHREAD location | EPROCESS.Pcb.ThreadListHead.Flink - 0x2F8 |
| PreviousMode null | WNF relative write → target KPROCESS+0x220 area; write 0 to PreviousMode |
| Full AAR/AAW | NtReadVirtualMemory/NtWriteVirtualMemory with PreviousMode=0 |
| Token steal | Walk ActiveProcessLinks, copy SYSTEM Token pointer |
Mitigations Bypassed
- KASLR: Defeated via
_WNF_NAME_INSTANCE.CreatorProcess— EPROCESS leaks from WNF objects we already control in the same pool region - Pool safe unlinking / Segment Heap: WNF_STATE_DATA DataSize corruption doesn’t touch headers — bypasses header integrity checks
- ASLR for NTOSKRNL: Optional — kernel stack walk on waiting thread gives NTOSKRNL base
Patch Analysis
Patch adds: after copying EA blocks, validates that ea_block_size does not exceed remaining buffer, accounting for the underflow case. Specifically, the underflow check ea_block_size <= out_buf_length - padding is replaced with proper bounds checking that prevents negative out_buf_length.
References
- Alex Plaskett (NCC Group), “CVE-2021-31956 Exploiting the Windows Kernel (NTFS with WNF) Parts 1+2”, 2021-07/08
- Kaspersky GReAT (Costin Raiu), “PuzzleMaker attacks with Chrome zero-day exploit chain”, securelist.com, 2021-06-08
- Yan ZiShuang (360): independent exploitation writeup confirming no CVE-2021-31955 needed, blog.360.net
- Corentin Bayet + Paul Fariello, “Scoop the Windows 10 pool!”, SSTIC 2020 — foundation for paged pool exploitation
