Part 1: Fs Minifilter Hooking
This post is the first part of series about hooking minifilter/miniport objects. During the course of this series I will explain how the management of these objects works, focusing where various callbacks reside in memory, The manner in which they are called by the manager driver, and how we might be able hook them without triggering PatchGuard.
The Filter Manager (FltMgr.sys) is a kernel component that allows other drivers to install callbacks that intercept file system operations. The filter manager is a replacement of the legacy file system filter driver model, and operates at the same level. Instead of consume the callback only by himself it sends callback to all consumers that registered to him.
Minifilter driver is commonly used in security components that have kernel driver (For example: AV, EDR or EPP). There are security components that use it to gather information about operations in file system and use it to make rules to detect unusual behavior, others use it for protects their files from evil that wants to damage their files. I want to show you how rootkit can tamper or filter these callbacks to the minifilter driver.
Before beginning to dig into the internals of the Filter Manager, which will help us understand where we can perform our hook, I recommend that you read a high level description of the Filter Manger here and look at the code for minifilter driver (E.g., You can see this sample of Microsoft’s code).
Filter Manager Internals
FltMgr uses a number of structs that simplify the development of a Minifilter. Most of the structs are undocumented and only accessed by an opaque pointer.
This diagram shows us the layers of the Filter Manager, each layer represented by the following structs:
FltMgr uses a concept called “frames” to enable minifilters to be placed before or after legacy filter drivers. FltMgr can add frame before, after or between legacy filter drivers (If there aren’t legacy filter drivers, FltMgr will use only one frame — “Frame 0”). From the perspective of a legacy filter driver, each frame represents a single legacy filter driver. Each frame contains a range of altitudes that are allowed for minifilters attached to it
In this example we can see that the range of altitudes of “Frame 0” is 0–409500.
Each minifilter is registered with a unique altitude that guides the filter manager to load it within the corresponding frame. For example:
In this example we can see that there is a legacy filter driver responsible for some backup functionality. To backing up files before they are encrypted or compressed, the minifilters that perform these tasks belong to “Frame 0” which is below the backup legacy filter driver. This allows the encryption and compression operations to be performed after the backup operations.
FLTP_FRAME is a private struct that is not used by minfilter writers.
It represents the attachment of FLTP_FRAME to a volume, i.e. each frame has a FLT_VOLUME for each volume (For example: In the above diagram “Frame 0” has four FLT_VOLUME structures). Let’s look at the volume list:
Take one of this FLT_VOLUME and see its fields:
Interesting fields in this struct:
- Frame — The frame that contains the volume.
- DeviceObject — Is the device object that associated with the volume.
- Callbacks — This field is a pointer to a struct that contains an array of callbacks. This is an important field, which we will elaborate upon further along.
As can be inferred from the name of the struct, it represents a minifilter driver. Each filter belongs to a FLTP_FRAME corresponding to its altitude.
The list of filters belonging to Frame 0 on a machine might look something like this:
In the above case, only one frame (“Frame 0”) is available and all the filters are loaded within it. We can observer the pointers, the altitude and the name of each filter belonging to the frame (One of the filters here is luafv, I recommend to read about it in the following blog).
Let’s take look at the FLT_FILTER struct of the “wcifs” driver:
We will focus at some of its fields
- Frame — The frame that the filter belongs to.
- DriverObject — The driver object that registered the filter.
- FilterUnload — The callback that is invoked when the filter unloads.
- Operations — A pointer to array of type FLT_OPERATION_REGISTRATION, the struct contains the operation callbacks of the minifilter. The structure taken from the struct FLT_REGISTRATION that is argument of the function FltRegisterFilter (This function responsible to register a new filter). Each cell in the array represent another major function (For example: 0x0 is IRP_MJ_CREATE). In FLT_OPERATION_REGISTRATION there is the pre operation callback that invoked before the operation and post operation callback that invoked after the operation complete.
The first five operations of the wcifs filter:
This struct represents the attachment of a filter to FLT_VOLUME. The maximum number of instances for a filter is the number of FLT_VOLUME instances (As you can see above, developers can control the number of instances registered. For example: the luafv filter has only one instance).
The instance list can be extracted by the commands “!fltkd.filters” or “!fltkd.volumes”, that showed above. Each command shows filter instances from another perspective, “!fltkd.filters” will show all instances registered under each filter and “!fltkd.volumes” show all instances that belong to each volume.
Let’s look at the FLT_INSTANCE structure:
There are some interesting fields in this struct:
- Volume — The volume that the instance belongs to.
- Filter — The filter that registered the instance.
- Name — The name of the instance (Not to be confused with the name of the filter).
- CallbackNodes — An array of type CALLBACK_NODE. This array contains all the callbacks that belonging to the instance. We can present the array with the following command:
Let’s look how the CALLBACK_NODE look like:
- PreOperation and PostOperation — Are the callbacks invoked before and after the operation.
- CallbackLinks —Is a dually linked list of CALLBACK_NODE structures that belong to FLT_INSTANCEs belonging to the same volume. All the CALLBACK_NODE structs have callbacks for the same MajorFunction. An example of such a list entry can be seen below:
With our new found knowledge of callbacks, we can now understand the “Callbacks” field of FLT_VOLUME. The field is of type CALLBACK_CTRL:
The first field in this struct (“OperationList”) is array of LIST_ENTRY, each cell in this array represent another major function (similar to the field CallbackNodes at FLT_INSTANCE). The lists in this array are the field CallbackLinks of CALLBACK_NODE that mentioned before.
Invoking Minifilter Callbacks
In order to implement our hook, we need to find where the callbacks are located in memory. To discover this we will explore how the callbacks are invoked. As mentioned before, there are two different callbacks — Pre and Post operation, so let’s check them separately:
Pre Operation Callback
To find the mechanism that invokes the callbacks, we’ll put a break point at the one of the callbacks in of luafv:
The call stack shows us the callback is called by a function named FltpPerformPreCallbacks. FltpPerformPreCallbacks takes a struct of type IRP_CALL_CTRL as an argument:
This struct is used in FltMgr to warp the pointers to callbacks that will be invoked and the information about the operation that will pass as parameters to the callbacks. This struct has some noteworthy fields:
- Volume — A Pointer to volume that contains all filter instances with a callback to be invoked.
- StartingCallbackNode — In order to avoid an infinite loop when a minifilter callback uses a filtered file system routine, FltMgr provides a set of functions that ensure a minifilter’s callback will not be invoked for its own file syetem operations (For example — FltCreateFile performs the same operation as ZwCreateFile, but receives an additional FLT_INSTANCE argument. If the argument is not equal to NULL it ensures that only the instances that under the instance in the argument will have a callback invoked for the operation. If the argument is NULL it will invoke the callbacks of all the instances that under the volume which mentioned in the IRP_CALL_CTRL). These functions rely on the field StartingCallbackNode. They insert the next CALLBACK_NODE of the specific operation taken from the FLT_INSTANCE which is passed as an argument (instanceArgument->CallbackNodes[majorFunction + 0x16].CallbackLinks.Flink). The function FltpPerformPreCallbacks checks if the field is not null and if it isn’t, it will invoke the callback in the field and all the other callbacks in CallbackLinks (As we can see upon further along).
- IrpCtrl — An internal FltMgr structure that warps the arguments of the callbacks, it is commonly used in FltMgr from the dispatch routines until the data is actually passed as arguments to the callbacks. Let’s look at the fields of this struct:
The fields marked in green are passed to the pre operation callback as arguments. We will discuss CompletionNodeStack when we will explore the post operation callback.
Let’s look how the function FltpPerformPreCallbacks work:
The field we should probably rely upon to contain the callbacks of the minifilter is CALLBACK_NODE. As we can see the field CALLBACK_NODE was taken from StartingCallbackNode or from the FLT_VOLUME. After that we can see this loop:
It iterates over the CallbackLinks, extracts each CALLBACK_NODE in turn, and invokes its pre operation callback:
Post Operation Callback
Post operation callbacks are invoked by the function FltpPerformPostCallbacks which take an IRP_CTRL structure as argument.
The function uses the struct COMPLETION_NODE:
COMPLETION_NODE is used to save data from the pre operation callback for post operation callback (For example: The field “Context” in the struct taken from argument of pre operation callback which allows minifilter developers to save data about an operation for the post operation callback).
Before the function FltpPerformPreCallbacks invokes the callback, the function constructs the struct COMPLETION_NODE and in particular takes the CALLBACK_NODE that was extracted at the beginning of the loop and insert it into the field CallbackNode in the struct COMPLETION_NODE.
Subsequently COMPLETION_NODE inserted into CompletionStack (A field of IRP_CTRL) and increments the field NextCompletion (Which is used as an index to the head of the completion stack).
The function FltpPerformPostCallbacks iterates over the CompletionStack and while NextCompletion (The index to the stack) is not zero it extracts the COMPLETION_NODE and get the CALLBACK_NODE from it:
After that it invokes the post operation callback:
How we can extract the list of callbacks?
Now that we know that the callbacks stored in the CALLBACK_NODE structure and that FltpPerformPreCallbacks and FltpPerformPostCallbacks invoke the callbacks from it. We have seen that the source of the CALLBACK_NODE can be traced to either FLT_VOLUME or FLT_INSTANCE, so we need to focus on these structures to obtain a pointer to the callbacks.
We can enumerate all instances of FLT_VOLUME (with FltEnumerateVolumes) and for each of them extract the list of CALLBACK_NODE (volume->Callbacks->OperationLists[index]), and iterate over list to find the callbacks that belong to the minifilter which we wish to hook.
If the pointer to CALLBACK_NODE in the field CallbackNodes is equal to the pointer to the CALLBACK_NODE in FLT_VOLUME, we can rely solely on FLT_INSTANCE and we will be able to enumerate all the instances that belong to the target filter and extract the callbacks in the field CallbacksNodes. Let’s check this hypothesis. The function that responsible to initialize a new FLT_INSTANCE is FltpInitInstance.
The function uses FltpSetCallbacksForInstance to initialize a CALLBACK_NODE array. For each CALLBACK_NODE it calls the function FltpInitializeCallbackNode which takes the PreOperation and PostOperation from FLT_FILTER and inserts it into the CALLBACK_NODE passed as an argument. At the end of the function it uses the FltpInsertCallback, which shows us the connection between the CALLBACK_NODE at volume and the CALLBACK_NODE at instance:
We can see that the our hypothesis is true. If CallbackLinks doesn’t exist in the CALLBACK_NODE of a particular instance, The function will take the LIST_ENTRY from the volume and will insert it into the field CallbackLinks. Afterwards it updates the LIST_ENTRY in the volume structure.
How To Hook a FS Minifilter?
The POC code can be found in my GitHub repo (The POC was tested at windows 7 32 bit version 6.1 and windows 10 64 bit version 1903).
First step: Get a pointer to the target FLT_FILTER
The function FltGetFilterFromName is a documented function that takes a name of a filter as an argument and returns the opaque pointer to FLT_FILTER:
Second step: Enumerate an Array of FLT_INSTANCE under the target FLT_FILTER
I use the function FltEnumerateInstances to find the FLT_INSTANCE structures:
Third step: Extract the CALLBACK_NODE array from FLT_INSTANCE
FLT_INSTANCE is an undocumented structure that changes from build to build. Because of that we need to find the offset to the field CallbackNodes in it, which is the array that contains the pointers to CALLBACK_NODE. There are a number of options to do that:
(1) Hardcoded offset — we can extract the offset from the appropriate symbols and set with it constant variables to use for each build. When initializing our driver, we can check the specific build that we run and choose the corresponding offset. This option is not good enough, because the hook will not be supported in future builds.
(2) FltpGetCallbackNodeForInstance — This is private function of FltMgr. The function takes a pointer to instance and an index to a specific CALLBACK_NODE in CallbackNodes as arguments. The function returns the pointer to CALLBACK_NODE by its index:
It seems like a wonderful function, we can enumerate all the CALLBACK_NODE at the instance and hook them. Unfortunately there are two problems with this option:
- The function is not exported. To find the pointer to this function, we can search for hardcoded opcodes before the call to this function and get the pointer from it, we can search the hash of the function or we can do an heuristic search of the function (For example: By checking the number of calls and the number of add operations). All these checks are not good enough because we get the same problem of option one (new builds are bound to break our assumptions).
- Besides returning the CALLBACK_NODE from the instance, the function FltpGetCallbackNodeForInstance also acquires rundown protection for the instance. The pointer for the rundown lock is located in the instance structure and is not exported (Like the field CallbackNodes). So failing release the rundown after we complete using the CALLBACK_NODE, the instance will be locked for rundown (something we would strongly wish to avoid).
(3) Register a callback — If we will have a pointer to a minifilter instance that exists in all builds and we can get a pointer to one of its callback, which will reliably exist on all systems. We could search for this pointer and get the offset of CallbackNodes from it. I didn’t find a filter and a callback like this, so I decided to register a new filter with my own set of callbacks. I chose this option, dynamically found the offset of CallbackNodes in the instance structure by using the callback functions of my driver as sentinel values. After finding the offset we can unregister the sentinel minifilter.
Fourth step: Hook the callback at CALLBACK_NODE
After finding the array of CALLBACK_NODE we can simply hook the PreOperation and the PostOperation of the major functions that we want to hook. I saved the original pointer at global variable to return the CALLBACK_NODE back to its original state when I unload the POC driver:
After performing the hook, I set a breakpoint at the original function of the filter, and we can see that our function now resides between FltPerformPreCallback and the original function:
Another POC that I done is to block the callbacks to PROCMON minifilter. So its minifilter will not get file system events after my driver loaded.
Thanks to Philip Tsukerman and to Liron Zuartz for the helping with problems in the research.