Filesystem Minifilter Drivers

Chapter 6

  • Using filesystem minifilter drivers, or minifilters for short, endpoint security products can learn about the files being created, modified, written to, and deleted.

  • Minifilters might, of course, monitor the native Windows filesystem (NTFS) which is implemented in ntfs.sys. However, they might also monitor other important filesystems, including named pipes, a bidirectional inter-process communication mechanism implemented in npfs.sys, and mailslots, a unidirectional inter-process communication mechanism implemented in msfs.sys.

Legacy Filters and the Filter Manager

  • Before Microsoft introduced minifilters, EDR developers would write legacy filter drivers to monitor filesystem operations. These drivers would sit on the filesystem stack, directly inline of user-mode calls destined for the filesystem.

The legacy filter driver architecture
  • These drivers were very difficult to develop and support. Below are seven problems that developers faced-

    1. Confusing Filter Layering - In cases when there is more than one legacy filter installed on the system, the architecture defines no order for how these drivers should be placed on the filesystem stack. This prevents the driver developer from knowing when the system will load their driver in relation to the others.

    2. A Lack of Dynamic Loading and Unloading - Legacy filter drivers can’t be inserted into a specific location on the device stack and can only be loaded at the top of the stack. Additionally, legacy filters can’t be unloaded easily and typically require a full system reboot to unload.

    3. Tricky Filesystem-Stack Attachment and Detachment - The mechanics of how the filesystem stack attaches and detaches devices are extremely complicated, and developers must have a substantial amount of arcane knowledge to ensure that their driver can appropriately handle odd edge cases.

    4. Indiscriminate IRP Processing - Legacy filter drivers are responsible for processing all Interrupt Request Packets (IRPs) sent to the device stack, regardless of whether they are interested in the IRPs or not.

    5. Challenges with Fast I/O Data Operations - Windows supports a mechanism for working with cached files, called Fast I/O, that provides an alternative to its standard packet-based I/O model. It relies on a dispatch table implemented in the legacy drivers. Each driver processes Fast I/O requests and passes them down the stack to the next driver. If a single driver in the stack lacks a dispatch table, it disables Fast I/O processing for the entire device stack.

    6. An Inability to Monitor Non-data Fast I/O Operations - In Windows, filesystems are deeply integrated into other system components, such as the memory manager. These non-data requests always bypass the device stack, making it hard for a legacy filter driver to collect information about them.

    7. Issues with Handling Recursion - Filesystems make heavy use of recursion, so filters in the filesystem stack must support it as well. However, due to the way that Windows manages I/O operations, this is easier said than done. Because each request passes through the entire device stack, a driver could easily deadlock or exhaust its resources if it handles recursion poorly.

  • To address some of these limitations, Microsoft introduced the filter manager model. The filter manager (fltmgr.sys) is a driver that ships with Windows and exposes functionality commonly used by filter drivers when intercepting filesystem operations. To leverage this functionality, developers can write minifilters.

  • Minifilters are substantially easier to develop than their legacy counterparts, and EDRs can manage them more easily by dynamically loading and unloading them on a running system. The ability to access functionality exposed by the filter manager makes for less complex drivers, allowing for easier maintenance.

Minifilter Architecture

The filter manager and minifilter architecture
  • In a legacy architecture, filesystem drivers would filter I/O requests directly, while in a minifilter architecture, the filter manager handles this task before passing information about the requests to the minifilters loaded on the system. This means that minifilters are only indirectly attached to the filesystem stack. Also, they register with the filter manager for the specific operations they’re interested in, removing the need for them to handle all I/O requests.

  • When a supported operation occurs, the filter manager first calls the correlated pre-operation callback function in each of the loaded minifilters. Once a minifilter completes its pre-operation routine, it passes control back to the filter manager, which calls the next callback function in the subsequent driver. When all drivers have completed their pre-operation callbacks, the request travels to the filesystem driver, which processes the operation.

  • After receiving the I/O request for completion, the filter manager invokes the post-operation callback functions in the minifilters in reverse order. Once the post-operation callbacks complete, control is transferred back to the I/O manager, which eventually passes control back to the caller application.

  • Each minifilter has an altitude, which is a number that identifies its location in the minifilter stack and determines when the system will load that minifilter. Ideally, Microsoft assigns altitudes to the minifilters of production applications, and these values are specified in the drivers’ registry keys, under Altitude. Microsoft sorts altitudes into load-order groups, which are shown below:

Microsoft’s Minifilter Load-Order Groups
  • Most EDR vendors register their minifilters in the FSFilter Anti-Virus or FSFilter Activity Monitor group. Microsoft publishes a list of registered altitudes, as well as their associated filenames and publishers. While an administrator can change a minifilter’s altitude, the system can load only one minifilter at a single altitude at one time.

Altitudes of Popular EDRs

Writing An Minifilter

  • The first, and most important, of these actions is registration, which the DriverEntry() function performs by calling fltmgr!FltRegisterFilter(). This function adds the minifilter to the list of registered minifilter drivers on the host and provides the filter manager with information about the minifilter.

NTSTATUS FLTAPI FltRegisterFilter(
        [in] PDRIVER_OBJECT Driver,
        [in] const FLT_REGISTRATION *Registration,
        [out] PFLT_FILTER *RetFilter
);
  • The Registration parameter is a pointer to an FLT_REGISTRATION structure which houses all important information about a minifilter.

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 first two members of this structure set the structure size, which is always sizeof(FLT_REGISTRATION), and the structure revision level, which is always FLT_REGISTRATION_VERSION.

  • The next member is flags, which is a bitmask that may be zero or a combination of any of the following three values:

    1. FLTFL_REGISTRATION_DO_NOT_SUPPORT_SERVICE_STOP (1) - The minifilter won’t be unloaded in the event of a service stop request.

    2. FLTFL_REGISTRATION_SUPPORT_NPFS_MSFS (2) - The minifilter supports named pipe and mailslot requests.

    3. FLTFL_REGISTRATION_SUPPORT_DAX_VOLUME (4) - The minifilter supports attaching to a Direct Access (DAX) volume.

  • Next is the context registration which will be either an array of FLT_CONTEXT_REGISTRATION structures or null. These contexts allow a minifilter to associate related objects and preserve state across I/O operations.

FLT_OPERATION_REGISTRATION Structure

  • Next is the operation registration array. This is a variable length array of FLT_OPERATION _REGISTRATION structures.

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 first parameter indicates which major function the minifilter is interested in processing. These are constants defined in wdm.h. Some noteworthy functions are shown below:

Major Functions and Their Purposes
  • The next member of the structure specifies the flags. This bitmask describes when the callback functions should be invoked for cached I/O or paging I/O operations. There are four supported flags, all of which are prefixed with FLTFL_OPERATION_REGISTRATION_ :

    1. SKIP_PAGING_IO indicates whether a callback should be invoked for IRP-based read or write paging I/O operations.

    2. SKIP_CACHED_IO flag is used to prevent the invocation of callbacks on fast I/O-based read or write cached I/O operations.

    3. SKIP_NON_DASD_IO is used for requests issued on a Direct Access Storage Device (DASD) volume handle.

    4. SKIP_NON_CACHED_NON _PAGING_IO prevents callback invocation on read or write I/O operations that are not cached or paging operations.

  • The next two members of the FLT_OPERATION_REGISTRATION structure define the pre-operation or post-operation callbacks to be invoked when each of the target major functions occurs on the system. Pre-operation callbacks are passed via a pointer to an FLT_PRE_OPERATION_CALLBACK structure, and post-operation routines are specified as a pointer to an FLT_POST_OPERATION _CALLBACK structure.

  • Pre-operation callback functions receive a pointer to the callback data for the operation and some opaque pointers for the objects related to the current I/O request, and they return an FLT_PREOP_CALLBACK_STATUS return code.

PFLT_PRE_OPERATION_CALLBACK PfltPreOperationCallback;

FLT_PREOP_CALLBACK_STATUS PfltPreOperationCallback(
         [in, out] PFLT_CALLBACK_DATA Data,
         [in] PCFLT_RELATED_OBJECTS FltObjects,
         [out] PVOID *CompletionContext)
{...}
  • The FLT_CALLBACK_DATA structure is used by both the filter manager and the minifilter to process I/O operations. It's important members include:

    1. Flags - A bitmask that describes the I/O operation. When the filter manager initializes the data structure, it sets a flag to indicate what type of I/O operation it represents: either fast I/O, filter, or IRP operations. The filter manager may also set flags indicating whether a minifilter generated or reissued the operation, whether it came from the non-paged pool, and whether the operation completed.

    2. Thread - A pointer to the thread that initiated the I/O request. This is useful for identifying the application performing the operation.

    3. Iopb - The I/O parameter block that contains information about IRP-based operations; the major function code; special flags related to the operation; a pointer to the file object that is the target of the operation; and an FLT_PARAMETERS structure containing the parameters unique to the specific I/O operation specified by the major or minor function code member of the structure.

    4. IoStatus - A structure that contains the completion status of the I/O operation set by the filter manager.

    5. TagData - A pointer to an FLT_TAG_DATA_BUFFER structure containing information about reparse points, such as in the case of NTFS hard links or junctions.

    6. RequestorMode - A value indicating whether the request came from user mode or kernel mode

  • The second parameter passed, a pointer to an FLT_RELATED_OBJECTS structure, contains opaque pointers to the object associated with the operation, including the volume, minifilter instance, and file object.

  • The last parameter, CompletionContext, contains an optional context pointer that will be passed to the correlated post-operation callback if the minifilter returns FLT_PREOP _SUCCESS_WITH _CALLBACK or FLT_PREOP_SYNCHRONIZE.

  • On completion, this routine must return a FLT_PREOP_CALLBACK_STATUS value which is one of:

    1. FLT_PREOP_SUCCESS_WITH_CALLBACK (0) - Return the I/O operation to the filter manager for processing and instruct it to call the minifilter’s post-operation callback during completion.

    2. FLT_PREOP_SUCCESS_NO_CALLBACK (1) - Return the I/O operation to the filter manager for processing and instruct it not to call the minifilter’s post-operation callback during completion.

    3. FLT_PREOP_PENDING (2) - Pend the I/O operation and do not process it further until the minifilter calls fltmgr!FltCompletePendedPreOperation().

    4. FLT_PREOP_DISALLOW_FASTIO (3) - Block the fast I/O path in the operation. This code instructs the filter manager not to pass the operation to any other minifilters below the current one in the stack and to only call the post-operation callbacks of those drivers at higher altitudes.

    5. FLT_PREOP_COMPLETE (4) - Instruct the filter manager not to send the request to minifilters below the current driver in the stack and to only call the post-operation callbacks of those minifilters above it in the driver stack.

    6. FLT_PREOP_SYNCHRONIZE (5) - Pass the request back to the filter manager but don’t complete it. This code ensures that the minifilter’s post-operation callback is called at IRQL ≤ APC_LEVEL in the context of the original thread.

    7. FLT_PREOP_DISALLOW_FSFILTER_IO (6) - Disallow a fast QueryOpen operation and force the operation down the slower path, causing the I/O manager to process the request using an open, query, or close operation on the file.

  • The filter manager invokes the post-operation callbacks of all minifilters for the request type, beginning with the lowest altitude.

PFLT_POST_OPERATION_CALLBACK PfltPostOperationCallback;

FLT_POSTOP_CALLBACK_STATUS PfltPostOperationCallback(
         [in, out] PFLT_CALLBACK_DATA Data,
         [in] PCFLT_RELATED_OBJECTS FltObjects,
         [in, optional] PVOID CompletionContext,
         [in] FLT_POST_OPERATION_FLAGS Flags)
{...}
  • It is similar to the pre-operation callback except the Flag parameter and the return type. The only documented flag that it can pass is FLTFL_POST_OPERATION_DRAINING, which indicates that the minifilter is in the process of unloading. The various return values are:

    1. FLT_POSTOP_FINISHED_PROCESSING (0) - the minifilter has completed its post-operation callback routine and is passing control back to the filter manager to continue processing it.

    2. FLT_POSTOP_MORE_PROCESSING_REQUIRED (1) - The minifilter has posted the IRP-based I/O operation to a work queue and halted completion of the request until the work item completes, and it calls fltmgr!FltCompletePendedPostOperation().

    3. FLT_POSTOP_DISALLOW_FSFILTER_IO (2) - The minifilter is disallowing a fast QueryOpen operation and forcing the operation down the slower path.

  • These callbacks are invoked in an arbitrary thread unless the pre-operation callback passes the FLT_PREOP_SYNCHRONIZE flag, preventing the system from attributing the operation to the requesting application.

  • Post-operation callbacks are invoked at IRQL ≤ DISPATCH_LEVEL. This means that certain operations are restricted, including accessing most synchronization primitives, calling kernel APIs that require an IRQL ≤ DISPATCH_LEVEL, and accessing paged memory. One workaround to these limitations involves delaying the execution of the post-operation callback via the use of fltmgr!FltDoCompletionProcessingWhenSafe(), but it has its own challenges

  • The array of these structures passed in the OperationRegistration member of FLT_REGISTRATION may look like:

const FLT_OPERATION_REGISTRATION Callbacks[] = {
         {IRP_MJ_CREATE, 0, MyPreCreate, MyPostCreate},
         {IRP_MJ_READ, 0, MyPreRead, NULL},
         {IRP_MJ_WRITE, 0, MyPreWrite, NULL},
         {IRP_MJ_OPERATION_END}
};

Defining Optional Callbacks

  • The last section in the FLT_REGISTRATION structure contains the optional callbacks. The first three callbacks, FilterUnloadCallback, InstanceSetupCallback, and InstanceQueryTeardownCallback, may all technically be null, but this will impose some restrictions on the minifilter and system behavior.

  • The rest of the callbacks in this section of the structure relate to various functionality provided by the minifilter. These include things such as the interception of filename requests (GenerateFileNameCallback) and filename normalization (NormalizeNameComponentCallback).

  • In general, only the first three semi-optional callbacks are registered, and the rest are rarely used.

Activating The Minifilter

  • After all callback routines have been set, a pointer to the created FLT_REGISTRATION structure is passed as the second parameter to fltmgr!FltRegisterFilter().

  • Upon completion of this function, an opaque filter pointer (PFLT_FILTER) is returned to the caller in the RetFilter parameter. This pointer uniquely identifies the minifilter and remains static as long as the driver is loaded on the system. This pointer is typically preserved as a global variable.

  • When the minifilter is ready to start processing events, it passes the PFLT_FILTER pointer to fltmgr!FltStartFilter(). This notifies the filter manager that the driver is ready to attach to filesystem volumes and start filtering I/O requests.

  • After this function returns, the minifilter will be considered active and sit inline of all relevant filesystem operations. The callbacks registered in the FLT_REGISTRATION structure will be invoked for their associated major functions.

  • Whenever the minifilter is ready to unload itself, it passes the PFLT_FILTER pointer to fltmgr!FltUnregisterFilter() to remove any contexts that the minifilter has set on files, volumes, and other components and calls the registered InstanceTeardownStartCallback and InstanceTeardownCompleteCallback functions.

Managing A Minifilter

  • Compared to working with other drivers, the process of installing, loading, and unloading a minifilter requires special consideration. This is because minifilters have specific requirements related to the setting of registry values. To make it easier, install minifilters through a setup information (INF) file.

  • The ClassGuid entry in the Version section of the INF file is a GUID that corresponds to the desired load-order group. The altitude can be set to the name of a variable (for example, %MyAltitude%) defined in the Strings section of the INF file. Lastly, the ServiceType entry under the ServiceInstall section is always set to SERVICE_FILE_SYSTEM_DRIVER (2).

  • Below shows what this looks like in the registry keys for WdFilter, Defender’s minifilter driver.

PS > Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\WdFilter\" | Select *
-Exclude PS* | fl

DependOnService : {FltMgr}
Description : @%ProgramFiles%\Windows Defender\MpAsDesc.dll,-340
DisplayName : @%ProgramFiles%\Windows Defender\MpAsDesc.dll,-330
ErrorControl : 1
Group : FSFilter Anti-Virus
ImagePath : system32\drivers\wd\WdFilter.sys
Start : 0
SupportedFeatures : 7
Type : 2

PS > Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\WdFilter\Instances\
WdFilter Instance" | Select * -Exclude PS* | fl

Altitude : 328010
Flags : 0
  • The Start key dictates when the minifilter will be loaded. The service can be started and stopped using the Service Control Manager APIs, as well as through a client such as sc.exe.

  • In addition, we can manage minifilters with the filter manager library, FltLib, which is leveraged by the fltmc.exe utility included by default on Windows. This setup also includes setting the altitude of the minifilter, which for WdFilter is 328010.

Detecting Tradecraft With Minifilters

File Detections

  • While attackers tend to avoid dropping tooling to disk now-a-days, some tools and APIs still require on-disk files as part of their workflow (For example - dbghelp!MiniDumpWriteDump() which requires that the caller pass in a handle to a file for the dump to be written to).

  • Additionally, the attacker has no control over the format of the data being written to the file, allowing a minifilter to coordinate with a scanner to detect a memory-dump file without using function hooking.

  • Some defenders use these concepts to implement filesystem canaries. These are files created in key locations that users should seldom, if ever, interact with. If an application other than a backup agent or the EDR requests a handle to a canary file, the minifilter can take immediate action, including crashing the system. Filesystem canaries provide strong anti-ransomware control, as ransomware tends to indiscriminately encrypt all files on the host.

Named Pipe Detections

  • Many command-and-control agents, like Cobalt Strike’s Beacon, make use of named pipes for tasking, I/O, and linking. Other offensive techniques, such as those that use token impersonation for privilege escalation, revolve around the creation of a named pipe.

  • In both cases, a minifilter monitoring IRP_MJ_CREATE_NAMED_PIPE requests would be able to detect the attacker’s behavior, in much the same way as those that detect file creation via IRP_MJ_CREATE.

  • Minifilters commonly look for the creation of anomalously named pipes, or those originating from atypical processes. This is useful because many tools used by adversaries rely on the use of named pipes, so an attacker who wants to blend in should pick pipe and host process names that are typical in the environment.

  • When a Beacon process creates the “mojo” named pipe, the minifilter can check that its current context matches the information in the pipe name. Pseudocode to do that is shown below:

DetectMojoMismatch(string mojoPipeName)
{
         pid = GetCurrentProcessId();
         tid = GetCurrentThreadId();
         if (!mojoPipeName.beginsWith("mojo. " + pid + "." + tid + "."))
         {
            // Bad Mojo pipe found
         }
}
  • Not every command inside Beacon will create a named pipe. There are certain functions that will create an anonymous pipe (as in, a pipe without a name), such as execute-assembly. These types of pipes have limited operational viability, as their name can’t be referenced and code can interact with them through an open handle only. What they lose in functionality, however, they gain in evasiveness.

Evading Minifilters

Unloading

  • The first technique is to completely unload the minifilter. While you’ll need administrator access to do this (specifically, the SeLoadDriverPrivilege token privilege), it’s the most surefire way to evade the minifilter. After all, if the driver is no longer on the stack, it can’t capture events.

  • Unloading the minifilter can be as simple as calling fltmc.exe unload, but if the vendor has put a lot of effort into hiding the presence of their minifilter, it might require complex custom tooling.

  • To complicate this process for attackers, the minifilter sometimes uses a random service name to conceal its presence on the system. In the case of Sysmon, an administrator can implement this approach during installation by passing the -d flag to the installer and specifying a new name.

  • However, an attacker can abuse another feature of production minifilters to locate the driver and unload it: their altitudes. Because Microsoft reserves specific altitudes for certain vendors, an attacker can learn these values and then simply walk the registry or use fltlib!FilterFindNext() to locate any driver with the altitude in question.

  • Defenders could further thwart attackers by modifying the minifilter’s altitude. This isn’t recommended in production applications, however, because another application might already be using the chosen value. Also, the altitude affects the minifilter’s position in the stack, so choosing too low a value could have unintended implications for the efficacy of the tool.

  • Starting in Windows 10, both the vendor and Microsoft must sign a production driver before it can be loaded onto the system, and because these signatures are meant to identify the drivers, they include information about the vendor that signed them. This information is often enough to tip an adversary off to the presence of the target minifilter.

  • There are no particularly great ways to hide a minifilter on the system. This doesn’t mean, however, that these obfuscations aren’t worthwhile. An attacker might lack the tooling or knowledge to counter the obfuscations, providing time for the EDR’s sensors to detect their activity.

Prevention

  • To prevent filesystem operations from ever passing through an EDR’s minifilter, attackers can register their own minifilter and use it to force the completion of I/O operations. An example:

FLT_PREOP_CALLBACK_STATUS EvilPreWriteCallback(
        [in, out] PFLT_CALLBACK_DATA Data,
        [in] PCFLT_RELATED_OBJECTS FltObjects,
        [out] PVOID *CompletionContext)
{
         --snip--
         if (IsThisMyEvilProcess(PsGetCurrentProcessId())
         {
                  --snip--
                  Data->IoStatus.Status = STATUS_SUCCESS;
                  return FLT_PREOP_COMPLETE
          }
 --snip--
}
  • One of the possible values, FLT_PREOP_COMPLETE, tells the filter manager that the current minifilter is in the process of completing the request, so the request shouldn’t be passed to any minifilters below the current altitude. If a minifilter returns this value, it must set the NTSTATUS value in the Status member of the I/O status block to the operation’s final status.

  • Antivirus engines whose minifilters communicate with user-mode scanning engines commonly use this functionality to determine whether malicious content is being written to a file. If the scanner indicates to the minifilter that the content is malicious, the minifilter completes the request and returns a failure status, such as STATUS_VIRUS_INFECTED, to the caller.

  • But attackers can abuse this feature of minifilters to prevent the security agent from ever intercepting their filesystem operations. The attacker first inserts their malicious minifilter at an altitude higher than the minifilter belonging to the EDR. Inside the malicious minifilter’s pre-operation callback would exist logic to complete the I/O requests coming from the adversary’s processes in user mode, preventing them from being passed down the stack to the EDR.

Interference

  • Interference is built around the fact that a minifilter can alter members of FLT_CALLBACK_DATA structure passed to its callbacks on a request. An attacker can modify any members of this structure except the RequestorMode and Thread members. This includes the file pointer in the FLT_IO_PARAMETER_BLOCK structure’s TargetFileObject member.

  • The only requirement of the malicious minifilter is that it calls fltmgr!FltSetCallbackDataDirty(), which indicates that the callback data structure has been modified when it is passing the request to minifilters lower in the stack.

  • An adversary can abuse this behavior by inserting itself anywhere above it in the stack, modifying the data tied to the request and passing control back to the filter manager. A minifilter that receives the modified request may evaluate whether FLTFL_CALLBACK_DATA_DIRTY, which is set by fltmgr!FltSetCallbackDataDirty(), is present and act accordingly, but the data will still be modified.

Last updated