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
- The calling process must be an AppContainer or restricted token process (enforced by
SeQueryInformationTokenchecks) - Named pipe path must begin with
\Device\NamedPipe\LOCAL\(matchesRtlPrefixUnicodeStringcheck) - The first character after “LOCAL\” must be
\(setsifslash = 1) - 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
0xFFFEbytes — 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 fill — NtRegisterThreadTerminatePort:
// 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
- Fill 0x20 LFH bucket with
NtRegisterThreadTerminatePortsprays → bucket full → backend allocates new segment - New 0x20 LFH subsegment created in that segment
- Spray
_TOKEN+_WNF_STATE_DATAobjects → VS subsegment created in the same segment, adjacent to the LFH subsegment - 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
NtQueryWnfStateDatawith length=0x1000 on each sprayed state name - Uncorrupted: returns
STATUS_BUFFER_TOO_SMALL(0xC0000023) —DataSizeis still small - Corrupted: returns data —
DataSizewas 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)→ getTokenId(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 viaNtUpdateWnfStateData - Call
NtQueryInformationToken(TokenHandle, TokenBnoIsolation, ...)→ kernel doesmemmove(output, TOKEN->BnoIsolation...Buffer, MaximumLength)→ reads from arbitrary address to user-mode
Write primitive (via NtSetInformationToken(TokenDefaultDacl)):
- Corrupt
_TOKEN.DynamicPartpointer → arbitrary address viaNtUpdateWnfStateData - Call
NtSetInformationToken(TokenHandle, TokenDefaultDacl, pACL, size)→ kernelSepAppendDefaultDacldoesmemmove(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
| Primitive | Source |
|---|---|
| OOB write (0xFFFE bytes) | npfs.sys integer overflow → underflow in NpTranslateContainerLocalAlias |
| LFH fill primitive | NtRegisterThreadTerminatePort → 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 identification | NtQueryWnfStateData error scan + LUID-based TOKEN matching |
| PreviousMode flip | Write ETHREAD.PreviousMode = 0 |
| Unlimited kernel R/W | NtReadVirtualMemory / NtWriteVirtualMemory with PreviousMode=0 |
| Fix-up: ObjectHeader | Cookie-XOR ObjectType recalculation |
| Fix-up: VS RBTree | RBTree 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)
