CVE-2022-22715 — Windows Dirty Pipe (npfs.sys LFH→VS Overflow, AppContainer Escape)

Last updated: 2026-04-11
Severity: High (CVSS 7.8)
Component: npfs.sys (Named Pipe File System driver)
Bug Class: Integer Overflow → Integer Underflow → Kernel Paged Pool OOB Write
Privilege Escalation: AppContainer → Kernel arbitrary R/W → PreviousMode → SYSTEM
Patch: February 2022 Patch Tuesday
Related: Pool Internals, Integer Overflows, Heap Grooming, Wnf Internals, Primitives
Tags: integer-overflow, oob-write, kernel-mode, pool, uaf, aaw, aar, smep

Vulnerability Summary

CVE-2022-22715 (“Windows Dirty Pipe”) is a kernel integer overflow in npfs!NpTranslateContainerLocalAlias. When an AppContainer process opens a named pipe with a “LOCAL\” prefix containing a leading backslash, all size-calculation variables are USHORT. A sufficiently long pipe name overflows the allocation size to zero. The subsequent MaximumLength -= 2 then underflows to 0xFFFE. RtlUnicodeStringPrintf copies 0xFFFE bytes from attacker-controlled data into a tiny paged pool allocation, producing a massive OOB write.

Used by k0shl of Cyber Kunlun to escape the Adobe Reader AppContainer sandbox in TianfuCup 2021. Published after vendor patch in February 2022.


Root Cause

Trigger Conditions

  1. The calling process must be an AppContainer or restricted token process (enforced by SeQueryInformationToken checks)
  2. Named pipe path must begin with \Device\NamedPipe\LOCAL\ (matches RtlPrefixUnicodeString check)
  3. The first character after “LOCAL\” must be \ (sets ifslash = 1)
  4. The pipe name must be long enough to overflow the USHORT total size to exactly 0

Integer Overflow Path (NpTranslateContainerLocalAlias)

// All variables are USHORT:
v21 = prefixlength + namedpipenamelength + 0x14;  // [1] can wrap to small value
v26.MaximumLength = v21;

if (ifslash) {                  // [2] if leading '\' was present:
    v21 += 2;                   // total += 2 → wraps to 0 if v21 was 0xFFFE
    v26.MaximumLength = v21;    //             OR sets MaximumLength to 0
}

PoolWithTag = ExAllocatePoolWithTag(PagedPool, v21, 'nFpN');  // [3] allocates 0 bytes → tiny alloc

if (ifslash) {
    v26.Buffer = PoolWithTag + 1;
    v26.MaximumLength -= 2;     // [4] 0 - 2 = 0xFFFE (USHORT underflow)
}

RtlUnicodeStringPrintf(         // [5] copies 0xFFFE bytes into tiny pool → OOB write
    &v26,                       //     content = formatted pipe name (attacker-controlled)
    L"Sessions\\%ld\\AppContainerNamedObjects\\%wZ\\%wZ\\%wZ",
    session_id, &sid_str, &container_str, &pipename);

Critical requirement: total size must overflow to exactly 0 (not a small non-zero value), because the MaximumLength -= 2 needs to produce 0xFFFE. If total is non-zero (e.g., 4), then MaximumLength -= 2 → 2, and RtlUnicodeStringPrintf only copies 2 bytes — not exploitable.

Vulnerable pool object: ExAllocatePoolWithTag(PagedPool, 0, 'NpFn') — allocates the smallest paged pool chunk (0x20 bytes in Segment Heap LFH).


Exploit Strategy

Core Challenge

  • The OOB write size is fixed at 0xFFFE bytes — over 16 pages
  • Cannot predict which slot in the LFH bucket the vulnerable 0x20 allocation lands in
  • Cannot control which part of the 0xFFFE-byte write hits which object

Key Insight: Cross-Subsegment Layout (LFH adjacent to VS)

Different-type subsegments (LFH and VS) can reside in the same backend segment. This means a 0x20 LFH subsegment and a VS subsegment can be physically adjacent on the same memory page boundary. The OOB write from the LFH region naturally flows into the VS region.

Target layout (identical pattern to CVE-2021-31969 / cldflt.sys):

[0x20 LFH subsegment: spray objects (NtRegisterThreadTerminatePort)]
[VS subsegment: _WNF_STATE_DATA (manager) + _TOKEN (worker)]

The OOB write doesn’t need to land precisely — it corrupts through all LFH entries in the bucket and into the VS subsegment beyond. This corrupts _WNF_STATE_DATA.DataSize in the VS region to 0x1000, creating a 0x1000-byte limited read/write primitive.


Stage 1 — Pool Layout Preparation

Spray Primitives

0x20 LFH fillNtRegisterThreadTerminatePort:

// Allocates a 0x20 paged pool object (tag: PsTp) for each call
// Controlled: create a thread, call NtRegisterThreadTerminatePort(lpc_port) N times
// Free: terminate the thread (automatically frees all NtRegisterThreadTerminatePort allocs)

_WNF_NAME_INSTANCES pre-spray: When creating _WNF_STATE_DATA objects, a companion _WNF_NAME_INSTANCES object is also created (size 0xD0). This would create additional LFH segments and disrupt layout. Pre-spray solution:

// Pre-allocate 0x4000 WNF objects of size 0xD0, then free them all → 0xD0 pool holes
for (i = 0; i < 0x4000; i++) AllocateWnfObject(0xD0, &gStateName[i]);
for (i = 0; i < 0x4000; i++) NtDeleteWnfStateName(&gStateName[i]);
// Now WNF_NAME_INSTANCES will fill these holes and not create new LFH segments

Layout Steps

  1. Fill 0x20 LFH bucket with NtRegisterThreadTerminatePort sprays → bucket full → backend allocates new segment
  2. New 0x20 LFH subsegment created in that segment
  3. Spray _TOKEN + _WNF_STATE_DATA objects → VS subsegment created in the same segment, adjacent to the LFH subsegment
  4. Trigger vulnerability → OOB write corrupts through LFH and into VS → corrupts _WNF_STATE_DATA.DataSize = 0x1000
WinDbg verification:
!pool ffffb0880d69e000  → PsTp 0x20 objects (LFH spray)
!pool ffffb0880d6a2000  → VS subsegment header + Toke (0x880) + Wnf (0x580)

Stage 2 — Identify Manager/Worker Objects

After the OOB write corrupts an unknown number of _WNF_STATE_DATA objects, finding the correct ones requires a scanning technique:

Manager object identification (corrupted _WNF_STATE_DATA):

  • Call NtQueryWnfStateData with length=0x1000 on each sprayed state name
  • Uncorrupted: returns STATUS_BUFFER_TOO_SMALL (0xC0000023) — DataSize is still small
  • Corrupted: returns data — DataSize was set to 0x1000 → reads 0x1000 bytes including OOB content
  • The last corrupted page is the manager object (its OOB read reaches the next normal page with the TOKEN)

Worker object identification (adjacent _TOKEN):

  • At spray time, query NtQueryInformationToken(TokenStatistics) → get TokenId (LUID) for each TOKEN handle → store in array
  • After OOB read, the returned data contains the TOKEN header including the LUID at a known offset
  • Match the LUID against the stored array → identify the specific TOKEN handle = worker object

Stage 3 — Arbitrary Read/Write Primitives

Once manager (_WNF_STATE_DATA) and worker (_TOKEN) objects are identified:

Read primitive (via NtQueryInformationToken(TokenBnoIsolation)):

  • Corrupt _TOKEN.BnoIsolationHandlesEntry.IsolationPrefix.Buffer → arbitrary address via NtUpdateWnfStateData
  • Call NtQueryInformationToken(TokenHandle, TokenBnoIsolation, ...) → kernel does memmove(output, TOKEN->BnoIsolation...Buffer, MaximumLength) → reads from arbitrary address to user-mode

Write primitive (via NtSetInformationToken(TokenDefaultDacl)):

  • Corrupt _TOKEN.DynamicPart pointer → arbitrary address via NtUpdateWnfStateData
  • Call NtSetInformationToken(TokenHandle, TokenDefaultDacl, pACL, size) → kernel SepAppendDefaultDacl does memmove(TOKEN->DynamicPart + offset, usercontrolled, size) → writes to arbitrary address

PreviousMode flip → unlimited NtReadVirtualMemory / NtWriteVirtualMemory (avoids needing to re-corrupt on every operation):

  • Write ETHREAD.PreviousMode = 0 (kernel mode) → all subsequent NT calls treated as kernel-mode
  • Use NtReadVirtualMemory / NtWriteVirtualMemory for all subsequent read/write

Stage 4 — Privilege Escalation and Fix-Up

Privilege Escalation

  • With PreviousMode=0 and NtReadVirtualMemory/NtWriteVirtualMemory: walk kernel structures, token steal, or inject into privileged process

Critical Fix-Up (Stability)

Problem 1 — Corrupted _TOKEN ObjectHeader: Corruption overwrites ObjectType byte in _OBJECT_HEADER. When any process (e.g., Process Explorer) traverses handle tables and dereferences the corrupted object, system crashes.

Fix: recompute the correct ObjectType using the object header cookie:

// ObjectType = cookie ^ (pool_addr >> 8 & 0xFF) ^ TypeIndex
BYTE cookie = ReadKernel(ntBase + OBJHEADERCOOKIE);
BYTE addrbyte = (pPoolAddress >> 8) & 0xFF;
BYTE correctedType = cookie ^ addrbyte ^ TokenTypeIndex;
WriteKernel(tokenPoolAddr + 0x88, correctedType);  // InfoMask
WriteKernel(tokenPoolAddr + 0x48, correctedType);  // TypeIndex
// Repeat for every corrupted TOKEN (each at a different pool address → different addrbyte)

Problem 2 — Corrupted VS Pool RBTree: The OOB write corrupts not just the object content but also the VS subsegment’s Red-Black Tree metadata used by the backend allocator to manage VS chunks. Corrupted nodes cause BSOD when the allocator traverses the tree (on any subsequent allocation/free).

Fix: walk the RBTree from the root, find the corrupted node, remove it:

// VS pool manager address calculation:
pHpMgr = (globalHeap ^ pPoolChunkAddr ^ pPoolChunkValue ^ 0xA2E64EADA2E64EAD) - 0x100 + 0x290;
// Walk RBTree: left/right children at +0x0/+0x8 of each node
// When corrupted node found (address matches): overwrite with fake node, update parent pointer

Deleting the node causes memory leak (any VS chunks under it become orphaned) but prevents the crash.

Final Step

  • Adobe Reader renderer runs in a Job with restricted CreateProcess — cannot directly spawn a new process
  • Solution: inject shellcode into the browser process (parent, not in Job), write a file to C:\ to demonstrate escape

Patch

Microsoft fixed the vulnerability in February 2022 by changing the size calculation to use int instead of USHORT and adding an explicit bounds check:

// After patch: use int type, check against USHORT max
int totalSize = prefixlength + namedpipenamelength + 0x14;
if (totalSize > MAXUSHORT) return STATUS_INVALID_PARAMETER;

Key Primitives Used

PrimitiveSource
OOB write (0xFFFE bytes)npfs.sys integer overflow → underflow in NpTranslateContainerLocalAlias
LFH fill primitiveNtRegisterThreadTerminatePort → 0x20 paged pool (tag: PsTp)
Manager object_WNF_STATE_DATA (DataSize corrupt → 0x1000 limited R/W)
Worker object_TOKEN (DynamicPart → write; BnoIsolation.Buffer → read)
Manager/worker identificationNtQueryWnfStateData error scan + LUID-based TOKEN matching
PreviousMode flipWrite ETHREAD.PreviousMode = 0
Unlimited kernel R/WNtReadVirtualMemory / NtWriteVirtualMemory with PreviousMode=0
Fix-up: ObjectHeaderCookie-XOR ObjectType recalculation
Fix-up: VS RBTreeRBTree traversal + corrupt node deletion

Exploit Relevance

This exploit established several important patterns for paged pool exploitation:

  • LFH→VS cross-subsegment technique (independently discovered; also seen in CVE-2021-31969)
  • _WNF_STATE_DATA + _TOKEN as the canonical manager/worker object pair for arbitrary R/W
  • NtRegisterThreadTerminatePort as a clean 0x20 LFH spray primitive
  • VS RBTree repair as a required cleanup step after large OOB writes
  • _WNF_NAME_INSTANCES pre-spray (0xD0) to prevent LFH segment pollution during WNF spray

References

  • k0shl (Cyber Kunlun), “Break me out of sandbox in old pipe — CVE-2022-22715 Windows Dirty Pipe”, whereisk0shl.top
  • Used at TianfuCup 2021 for Adobe Reader AppContainer escape
  • CVE-2021-31969 (cldflt.sys) — same LFH→VS cross-subsegment technique, independently developed by Alex Birnberg (SSD Security Research)