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:

  1. EA1: EaNameLength=5, EaValueLength=4ea_block_size=18, out_buf_length=18, padding=0
    • Check: 18 <= 18 - 0 → True → copy occurs; out_buf_length = 0, padding = 2
  2. EA2: any larger ea_block_size (e.g., 137)
    • Check: 137 <= 0 - 2137 <= 0xFFFFFFFE → True (underflow) → overflow occurs

Attacker control:

  • Size of vulnerable allocation: set Length in NtQueryEaFile to equal the first EA block size
  • Size of overflow: controlled by EaValueLength of 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: corrupts Header + RunRef (+0x0, +0x8)
  • If next chunk is _WNF_STATE_DATA: corrupts Header + 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:

  • NtWriteVirtualMemory and NtReadVirtualMemory accept kernel addresses
  • Walk EPROCESS.ActiveProcessLinks to find SYSTEM process (PID=4)
  • Read SYSTEM.Token, write to CurrentProcess.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:

  1. Restore _WNF_NAME_INSTANCE.StateData pointer: walk _WNF_SCOPE_INSTANCE.NameSet AVL tree using arbitrary read, find target by StateName value, restore pointer.

  2. Restore corrupted RunRef fields: walk _EPROCESS.WnfContext._WNF_PROCESS_CONTEXT.TemporaryNamesListHead, detect non-0x0000000000A80903 headers, restore Header and RunRef to 0.

  3. Restore PreviousMode: use NtWriteVirtualMemory to 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) class SuperfetchPrivSourceQuery — 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

PrimitiveTechnique
Controlled paged pool allocationNtUpdateWnfStateDataExAllocatePoolWithQuotaTag(PagedPool, len+16, 'WNF ')
Relative OOB read/writeWNF_STATE_DATA DataSize corruption → unbounded NtQueryWnfStateData
EPROCESS leak_WNF_NAME_INSTANCE.CreatorProcess (+0x98) — no separate KASLR bypass needed
KTHREAD locationEPROCESS.Pcb.ThreadListHead.Flink - 0x2F8
PreviousMode nullWNF relative write → target KPROCESS+0x220 area; write 0 to PreviousMode
Full AAR/AAWNtReadVirtualMemory/NtWriteVirtualMemory with PreviousMode=0
Token stealWalk 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