23 minutes
Dissecting the Windows Defender Driver - WdFilter (Part 1)
I’m back again! For the next couple (Or maybe more) posts I’ll be explaining how WdFilter works. I’ve always been very interested on how AVs work (Nowadays I would say EDRs though) and their development at kernel level. And since, unfortunately I don’t have access to the source code of any, my only chance is to reverse them (Or to write my own 😆). And of course what a better product to check than the one written by the company who developed the OS.
For those who don’t know, WdFilter is the main kernel component of Windows Defender. Roughly, this Driver works as a Minifilter from the load order group “FSFilter Anti-Virus”, this means that is attached to the File System stack (Actually, quite high - Big Altitude) and handles I/O operations in some Pre/Post callbacks. Not only that, this driver also implements other techniques to get information of what’s going on in the system. The goal of this series of post is to have a solid understanding on how this works under the hood.
A couple of remarks before moving forward. I’ll try to put together all the posts in a way that it makes sense, but since there are many components, flags and structures involved in many places some things may not be clear at first. Also since I’m still working on reversing the driver so I apologize in advance for not having all the structures fully reversed and same applies to flags I’ll try to post some header files on my Github and keep them updated :)
Initialization
For this research I’m looking at WdFilter version 4.18.1910.4, WdFilter gets updated a lot, thou changes are not huge so this research should be at least of some help for future versions :)
SHA256:
52D2A7A085896AC8A4FF92201FC87587EDF86B930825840974C6BC09BFB27E5B
So without further ado, let’s get into the DriverEntry. As we saw with WdBoot first steps are to check if running on SafeBootMode and initialize the WPP tracing. With this behind, we get into the allocation of the main structure, MpData. In the version of the driver we are studying it has a size of 0xCC0 bytes and is allocated in a NonPaged Pool with tag MPfd. Once we have the Pool allocated for the structure the code will proceed to call MpInitializeGlobals which will initialize some structures inside MpData (PagedLookasideLists, EResources, Timer among others) also this function will be in charge of computing a mask which determines the OS version running on the system, this can be seen in the following image – MpVerifyWindowsVersion receives the MajorVersion, MinorVersion, ServicePack and BuildNumber and end up calling RtlVerifyVersionInfo to verify if the running OS version is higher.

Also inside this function some pointers to function will be obtained, specifically inside MpGetSystemRoutines, this function will use MmGetSystemRoutineAddress and save the returned address into MpData. – The OsVersionMask field comes into play here, because some pointers will only be obtained in certain OS versions, for example FltRequestFileInfoOnCreateCompletion will only be retrieved if running Windows 10 build 17726 or higher – Going back to the initialization function, one last thing it will do is to create the following SIDs:
- MpServiceSID
- NriServiceSID
- TrustedInstallerSID
After this, the initialization of MpData is completed, even though there’s still plenty of members that will be filled in other functions, here you can see this Huge structure – Still missing A LOT of fields.
Next step is to setup the parameters/config of the the driver, this will be done inside MpLoadRegistryParameters. This function will setup a RTL_REGISTRY_QUERY_TABLE by iterating over an array of structures that I coined MP_CONFIG_PARAMS:
typedef struct _MP_CONFIG_PARAMS
{
PCWSTR Name;
PMP_CONFIG *pMpConfig;
INT64 DefaultData;
} MP_CONFIG_PARAMS, *PMP_CONFIG_PARAMSthe following image shows some entries of this array:

As you can see the second member of this structure is a pointer inside the structure MP_CONFIG, this address is the one that’s gonna be set as the EntryContext in the QueryTable. Finally, the function will call RtlQueryRegistryValuesEx with the registry path being HKLM\System\CurrentControlSet\Services\WdFilter\Parameters after this call has been made the values returned in the EntryContext will be check to see if they match some criteria, if they don’t match they will be set to their default value. The MP_CONFIG has the following definition:
With the MpConfig structure populated, some default values will be copied into MpData inside MpSetDefaultConfigs, then function MpSetBufferLimits will set the different limits both for Input and Output messages that will be used for the communication with the UserSpace process – MsMpEng.exe.
I will leave how this communication works for another post, since is a big part of the driver and I believe it deserves it’s own part. But basically the driver can receive different messages through a communication port and each of this message has his own data and size, and of course, each one executes a different operation.
Last thing regarding initialization of MpData is to initialize things related to thread boosting, this will be done inside MpInitializeBoostManager, for now is not relevant we will see more about this thread boosting in other posts.
From now on, the code will start initializing a lot of different structures, each one meant for something different, I’ll mention all of them but for this post I’ll focus only on some of them. First function is MpInitializeProcessTable, as the name implies this function will initialize a structure that will keep track of the process in the system, to do this it will allocate a pool of size 0x800 that will contain an array of LIST_ENTRY – Each list entry is of size 0x10 so we have 0x80 entries in the array – this LIST_ENTRY is actually a shifted pointer into the structure I named ProcessCtx that contains the information regarding a process. The definition of the process table looks something like this:
typedef struct _MP_PROCESS_TABLE
{
SHORT Magic; // Set to 0xDA13
SHORT Size; // Sizeof 0x1C0
ERESOURCE ProcessTableResource;
PAGED_LOOKASIDE_LIST ProcessCtxLookaside;
PAGED_LOOKASIDE_LIST ProcessCtxListLookaside;
LIST_ENTRY *__shifted(ProcessCtx,8) (*ProcessCtxArray)[0x80];
KEVENT ProcessTableEvent;
_DWORD BeingAccessed;
INT TrustedProcessCtxCounter;
INT UntrustedProcessCtxCounter;
INT Unk;
INT CreateThreadNotifyLock;
} MP_PROCESS_TABLE, *PMP_PROCESS_TABLE;After this, the DriverEntry will call MpInitBootSectorCache which will allocate a pool of size 0x64 and tag MPgb and save a pointer in MpData->pBootSectorCache – We’ll see more about the checks of the Boot sector in another post.
Then, based on the value saved on MpConfig.MaxCopyCacheSize another pool will be allocated and this time the pointer to the pool will be saved in the global variable MpCopyCacheData – The value of MaxCopyCacheSize cannot be higher than 0x200 and in order to allocate the pool this value is left shifted 6 times, so the max size would be 0x8000 – With this done, the next step is to initialize the following strucures and callbacks:
Process Exclusion structure, initialized inside
MpInitializeProcessExclusionswith a size of0x78, tagMPpsand saved in the globalMpProcessExclusion.Power setting callback, this is done inside
MpPowerStatusInitialize, which receives as parameter the address ofMpData->PowerSettingCbHandleand this function will usePoRegisterPowerSettingCallbackto set up a callback on the power settingGUID_LOW_POWER_EPOCHupon successful registration of the callback the Handle will be saved in the parameter – We will see the actual callback function in the end of this article.The transactional NTFS structure, which will be initialized inside
MpTxfInitializewith a size of0x140, tagMPtdand saved in a global I namedMpTxfData.Async worker thread alongside the Async structure, this will be initialized inside
MpAsyncInitializeand the structure will mainly keep two list entries of messages that are enqueued to be sent by the async worker thread. This thread is initialized inside this function too, and the functionMpAsyncpWorkerThreadis set as the StartRoutine of it.The registry data structure, which will be initialized inside
MpRegInitialize, of size0x500and tagMPrD. This is another big and important structure that will be used mainly in the RegistryCallback – We will get into this callback in the next post.Document rules structure, initialized inside
MpInitializeDocOpenRuleswith a size of0x100, tagMPdoand saved in the globalMpBmDocOpenRules– A bit further down we’ll see more about this quite interesting structure.Folder Guard structure, which is initialized inside
MpFgInitializeonly on systems running Windows 10 build 16000 or higher has a size of0x240, tagMPFgand saved in the globalMpFolderGuard. The structure will keep a pointer to a RTL_AVL_TABLE table and a RTL_GENERIC_TABLE and it will be used mainly to allow or revoke access to files/folders.Lastly, the drivers info structure, which is initialized inside
MpInitializeDriverInfo, this structure is tied to the ELAM driver, and is the one that will be used mainly on the function registered for the callback\Callback\WdEbNotificationCallback. When we get into how this function and this structure is used we will be able to intertwine what we saw in the post about the WdBoot with what WdFilter does with that data.
Reached this point we will find ourselves with a good amount of allocated pools and initialized structures:

The next step in the DriverEntry is to initialize both the minifilter, inside MpInitializeFltMgr, and the communication ports, inside MpCreateCommPorts. The former will choose a specific OperationRegistration for the FLT_REGISTRATION structure based on the configuration and the OsVersionMask, with this FilterRegistration it will register the minifilter (FltRegisterFilter). The latter will first set up a security descriptor using the MpServiceSID and this security descriptor will be used in the ObjectAttributes given as argument to FltCreateCommunicationPort. Four different ports will be created:
- MicrosoftMalwareProtectionControlPort (This is the only port that will registers a MessageNotifyCallback)
- MicrosoftMalwareProtectionPort
- MicrosoftMalwareProtectionVeryLowIoPort
- MicrosoftMalwareProtectionRemoteIoPort
From this point, roughly, the DriverEntry will register callbacks for the following events:
- Process Creation
- Image Load
- Thread Creation
- Image Verification
- Object Operations (ProcessType and DesktopObjectType)
- Registry Operations
Since the post is already quite long, for this part I will only focus on the first two
After setting the the Image Verification callback the driver will start filtering (FltStartFiltering) and after registering the last two callbacks the driver initialization would be done. Of course, if at any point any of the aforementioned steps fail the driver will cleanup everything.
MpSetProcessNotifyRoutine
The first callback registration we will dig into is the process creation, this callback is register inside MpSetProcessNotifyRoutine. First thing this function will do is check if PsSetCreateProcessNotifyRoutineEx2 is available (Windows 10 build 14980 - OsVersionMask & 0x80), in case it is then it will use this function to register the callback, if is not available then it will check PsSetCreateProcessNotifyRoutineEx lastly if this one isn’t available either then it will resort in PsSetCreateProcessNotifyRoutine. Once one of the callback routine has been registered, the code will then proceed to create two callback objects \Callback\WdProcessNotificationCallback and \Callback\WdNriNotificationCallback. For the latter, the code will also register a callback function – MpNriNotificationCallback
To get more information on this Callback Objects and others, make sure to check the research 0xcpu and I have been conducting on them https://github.com/0xcpu/ExecutiveCallbackObjects
MpCreateProcessNotifyRoutineEx - MpCreateProcessNotifyRoutine
In this section I will explain what does the callback routine registered for the process creation does. As can be seen on the section title, there can be two routines, the first one is registered by the ..Ex2 and ..Ex while the second one is registered by PsSetCreateProcessNotifyRoutine.
The difference between the
..Exand the..Ex2functions is basically that the latter allows to provide aPSCREATEPROCESSNOTIFYTYPEand even though this value can only be set toPsCreateProcessNotifySubsystemsmaybe in the future they will add more value for example one to get only notifications from the WSL subsystem. On the other hand, the difference from this two againstPsSetCreateProcessNotifyRoutineis that in the latter the register routine protoype isCREATE_PROCESS_NOTIFY_ROUTINEwhile for the other two the prototype is CREATE_PROCESS_NOTIFY_ROUTINE_EX
Both functions are pretty similar, moreover, they share a lot of the code. There’s only a couple of difference between them, the main differences being:
- MpCreateProcessNotifyRoutineEx can take advantage of having the structure
PS_CREATE_NOTIFY_INFO, for example if the flag FileOpenNameAvailable is set then it can retrieve the ImageFileName without the need of getting a handle to the process. - MpCreateProcessNotifyRoutineEx can deny the creation of the process setting the value CreationStatus to an error.
- The last difference is that function MpCreateProcessNotifyRoutineEx has also the ability to add processes to the boot process list entry, by calling
MpAddBootProcessEntry
Getting into the actual code, as I mentioned above, in case we don’t have the flag FileOpenNameAvailable or the case we don’t have PS_CREATE_NOTIFY_INFO the code will proceed to obtain a handle to the process (ZwOpenProcess) and with this handle it will call MpGetProcessNameByHandle, which basically calls ZwQueryInformationProcess with ProcessImageFileName as the ProcessInformationClass. Once the callback routine has the ImageFileName it will proceed to obtain the normalized name, to do this it will call the function MpGetImageNormalizedName, this function will mainly call FltGetFileNameInformationUnsafe with NameOptions FLT_FILE_NAME_NORMALIZED | FLT_FILE_NAME_QUERY_DEFAULT. Finally, the callback routine will end up calling MpHandleProcessNotification, which is the main function of this callback.
MpHandleProcessNotification
void __fastcall MpHandleProcessNotification(
_In_ PEPROCESS Process,
_In_ HANDLE ParentId,
_In_ HANDLE ProcessId,
_In_ BOOLEAN Create,
_In_ BOOLEAN IsTransacted,
_In_ PUNICODE_STRING ImageFileName,
_In_ PUNICODE_STRING CommandLine,
_Out_ PBYTE AccessDenied
);This function has two very clear code paths, which are defined by the Create flag. In the case where the process is being created the first, and probably one of the most important steps in the filter, is to create the ProcessContext structure. This is done inside MpCreateProcessContext
NTSTATUS __fastcall MpCreateProcessContext(
_In_ HANDLE ProcessId,
_In_ LONGLONG CreationTime,
_In_ PUNICODE_STRING FileNameAndCmdLine[2], // This is probably a struct with two UNICODE_STRING
_Out_ PProcessCtx *ProcessCtx
)this function will mainly allocate memory from the Lookaside MpProcessTable->ProcessCtxLookaside to hold one Process Context – Size 0xC0 - Tag MPpX – after the memory is allocated it will start filling the members of the Process Context structure, this structure looks something like this:
Once the Process Context (ProcessCtx from now on) has been retrieved or created, the function will proceed to see if a doc rule should be attached to this Process. This is done inside MpSetProcessDocOpenRule and there are two structures involved. One that keeps a list of all the documents rules and one for each rule.
The code will basically iterate the single list entry comparing the ImageFileName with the DocProcessName, if any of the rules matches, then that a pointer the MP_DOC_RULE structure will be saved in the ProcessCtx->pDocRule.
Next step is to check if the process which context has been created is csrss.exe – MpSetProcessPreScanHook – in case it is, a pointer to CsrssPreScanHook will be saved in ProcessCtx.pCsrssPreScanHook and the flag MpData->pCsrssHookData->HookSetFlag will be set. This is only done for the ProcessCtx of csrss.exe

The last step before notifying the creation of the process is to check if the process matches some exceptions and set the ProcessCtx.ProcessFlags accordingly. To do this check there are three functions:
- MpSetProcessExempt
- MpSetProcessHardening
- MpSetProcessHardeningExclusion
The first one will iterate over the single list entry of the following structure – I know, there’s A LOT of structures.
// Sizeof 0x20
typedef struct _MP_PROCESS_EXCLUDED
{
SINGLE_LIST_ENTRY ExcludedProcessList;
UNICODE_STRING ProcessPath;
BYTE NoBackslashFlag;
BYTE WildcardPathFlag;
} MP_PROCESS_EXCLUDED, *PMP_PROCESS_EXCLUDED;and it will check FinalComponent of the ImageFileName is either prefix or equal to any of the ones from the list, in case it matches it will set the ProcessFlags by applying an OR with 0x1. The driver has the capabilitie to add Process/Paths to the MP_PROCESS_EXCLUDED list based on a message received from user space – MsMpEng.exe – Here we can see a list of process excluded using this criteria

There’s one special case in this check, when the process is MsMpEng in this case the ProcessFlags will be ORed with
0x9
The second check will first check if the FinalComponent matches mpcmdrun.exe or msmpeng.exe, in case it does using the previously created MpServiceSID it will check if the access token of the process matches that SID. If none of those process name match then it will check against nissrv.exe and NriServiceSID. If any of this situations is matched succesfully the ProcessFlags will be ORed with 0x10.
There’s another possible situation if we are running MpFilter instead of WdFilter, in this case process name will be compared agains msseces.exe and if it matches the ProcessFlag will be ORed against
0x80
The last check will first create, if needed, a list entry of hardened excluded process. The values for this list entry are hardcoded in WdFilter in an structure that keeps the name, a flag that indicates to what system does it applies and lastly the mask value that will be applied to the ProcessFlags

with those values the following structure will be filled
// Sizeof 0x20
typedef struct _MP_PROCESS_HARDENING_EXCLUDED
{
LIST_ENTRY ProcessExcludedList;
PUNICODE_STRING ProcessPath;
INT ProcessHardeningExcludedMask;
} MP_PROCESS_HARDENING_EXCLUDED, *PMP_PROCESS_HARDENING_EXCLUDED;once the structure is filled the procedure of the check is quite standard, the code compares the name and if it matches then it applies the ProcessHardeningExcludedFlag to the ProcessCtx.ProcessFlags. In the following image we can see the list of process in the MP_PROCESS_HARDENING_EXCLUDED of my system

One last detail regarding the process exclusion, is that a reference to both structures we just saw is kept in another structure – Yep, since there are not many structures already… there’s one more 😆.
// Sizeof 0x78
typedef struct _MP_PROCESS_EXCLUSION
{
ERESOURCE ProcessExclusionResource;
MP_PROCESS_EXCLUDED *ProcessExclusionList;
MP_PROCESS_HARDENING_EXCLUDED *ProcessHardenedExclusionList;
} MP_PROCESS_EXCLUSION, *PMP_PROCESS_EXCLUSION;After all this, the “default” ProcessCtx is ready and now is time to notify the callback \Callback\WdProcessNotificationCallback. Argument1 will contain the following structure
typdef struct _MP_PROCESS_CB_NOTIFY
{
HANDLE ProcessId;
HANDLE ParentId;
PUNICODE_STRING ImageFileName;
INT OperationType; // ProcessCreation = 1; ProcessTermination = 2; SetProcessInfo = 3
BYTE ProcessFlags;
} MP_PROCESS_CB_NOTIFY, *PMP_PROCESS_CB_NOTIFY;For the sake of brevity, I won’t explain more details about this. Please refer to the Github to learn more about this callback.
After notifying the callback we just need one last step to finish the ProcessNotification callback, this step is to send a message to the user space process listening to port ProtectionPortServerCookie.
Before getting into the function that creates and send the message, I’ll explain quickly the case when the flag Create is not set, which means the process is exiting. In this case the ProcessCtx will be obtained by the process Id, and with this ProcessCtx the structure MP_PROCESS_CB_NOTIFY will be populated and the callback notified. After this MpSendProcessMessage will be called to create and send the message.
One last detail is the call to MpCopyCacheProcessTerminate which will iterate over an array of MP_COPY_CACHE_ENTRY
typedef struct _MP_COPY_CACHE_ENTRY
{
DWORD Flags;
HANDLE ProcessId;
HANDLE ThreadId;
UNICODE_STRING FileName;
QWORD FileSize;
QWORD TimeStamp;
INT64 qword38;
} MP_COPY_CACHE_ENTRY, *PMP_COPY_CACHE_ENTRY;MpSendProcessMessage
NTSTATUS __fastcall MpSendProcessMessage(
_In_ BYTE CreateFlag,
_In_ PEPROCESS Process,
_In_ HANDLE ProcessId,
_In_ BOOLEAN IsTransacted,
_In_ HANDLE ParentId,
_In_ PAuxPidCreationTime ParentPidAndCreationTime,
_In_ PUNICODE_STRING ImageFileName,
_In_ PProcessCtx ProcessCtx,
_In_ PUNICODE_STRING CommandLine,
_Out_ PBYTE AccessDenied
)This function, and a lot of other functions we will see during this series of posts, handle the creation of a message with some specific data that will be sent to MsMpEng. This data can be send synchronously or asynchronously (Using the worker thread I mentioned above) – The message will be created differently, even though sometimes they use the async method but then send that message synchronously.
In the case of this function both message will be created using the asynchronous structure but if the parameter CreateFlag is 0x1 then the message will be sent synchronously (FltSendMessage), in case is 0x0 it will be enqueued and the worker thread will take care of it.
I’ll try to explain this as short and simple as possible. All async messages will be created with a function called MpAsyncCreateNotification. This function receives two parameters, first one is an outparam that will return a shifted pointer inside the allocated buffer that’s gonna be send as message, while the second parameter is the size to allocate.
So after that call we will end up with a buffer that needs to be filled with the specific data. And again, this buffer is shifted 8 bytes into the structure I named AsyncMessageData. This structure will look something like this
typedef struct _AsyncMessageData
{
INT Magic;
INT Size;
INT64 NotificationNumber;
DWORD SizeOfData;
INT RefCount;
INT TypeOfOperation;
union {
// This are the ones I have for now
ImageLoadAndProcessNotifyMessage ImageLoadAndProcessNotify;
TrustedOrUntrustedProcessMessage TrustedProcess;
ThreadNotifyMessage ThreadNotify;
CheckJournalMessage CheckJournal;
};
} AsyncMessageData, *PAsyncMessageData;As we can see, this struct contains a union where the specific data for each type of different message will start. In this case we will focus on the data regarding ProcessNotify, this structure looks something like this:
This would be the structure without a ImageFileName and without CommandLine, in case there is any of those or even both, the strings would be after this data and the members OffsetToImageFileName and OffsetToCommandLine would contain the relative offset to the start of each string (Relative from the start of the inner structure).
The structure AuxPidCreationTime you can see inside the struct is just an strucutre containing the PID as a ULONG and the CreationTime as ULONG64. If anyone knows an already defined structure with that data please let me know and I’ll change it.

Once the message is sent, in the case of using FltSendMessage, the function will proceed to check the status of the call and proceed to fill some fields of MpData accordingly
- FltSendMessageCount
- FltSendMessageError - In case it failed
- FltSendMessageStatusTimeout - In case the status was
STATUS_TIMEOUT
If everything went well, the code will check the ReplyBuffer (First byte should be 0x5D and Second Word should be 0x60, Size of the reply message). Among the things this reply buffer can contain is wheter the creation of the process is allowed or not (Byte 0x48)
And finally the last step before finishing is to set up the process info (Mainly with the information received from the ReplyBuffer) after doing that it will test the ProcessFlags – ProcessFlags & 0x20 || ProcessFlags & 0x18 – to add the process either to the Trusted or Untrusted process list. This is done inside MpSetTrustedProcess or MpSetUntrustedProcess respectively, but the post is already long enought so we will see those functions in the next part!
MpPowerStatusCallback
One last thing before finishing, I said before I was going to talk a bit about the power-setting callback routine registered during the initialization.
NTSTATUS MpPowerStatusCallback(
LPCGUID SettingGuid,
PVOID Value,
ULONG ValueLength,
PVOID Context
)
{
if (Value && Value == 4 && IsEqualGUID(SettingGuid, GUID_LOW_POWER_EPOCH)) {
if ( *(ULONG *) Value ) {
if ( *(ULONG *) Value == 1 ) {
MpData->LowPowerEpochOn = 1;
MpData->MachineUptime = 0;
}
} else {
MpData->MachineUptime = *(ULONG64 *) 0xFFFFF78000000014;
}
}
return STATUS_SUCCESS;
}Because of my lack of knowledge on Power Management plus the fact that there’s practically non-info on that GUID and the only thing related to Microsoft and low power epoch I managed to find is this strucutre: PEP_LOW_POWER_EPOCH in the documentation, but actually it doesn’t explain much, just that is used for a deprecated notification. I’m not comfortable saying anything in regard to this function, I just put it here and if somebody knows more about this Please reach out to me! I would love to hear more about this.
Conclusion
That’s gonna be all for this part, sorry for the Super long post I really tried to be as clear and concise as possible but there’s plenty of things going on in the driver, so things may seem a bit messy for now, but bear with me throughout all the posts things will start to make sense and I hope in the end we can glue everything together. As always, I hope you guys liked the post! We still have a long way ahead, this is just the tip of the iceberg, but slowly we’ll get to the end! In the next post I’ll be talking about the image load and thread creation callbacks, so I hope I’ll see you there!
If there’s any mistake or something not clear, please don’t hesitate to reach out to me on twitter @n4r1b
Bonus
This little windbg script let us print whatever data we want from all the ProcessCtx in the system. We just need the symbols of WdFilter and tweak the command !list however we like.
r @$t0 = poi(poi(WdFilter!MpProcessTable)+180); // Pointer to MpProcessTable->ProcessCtxArray
.for (r $t1 = 0; @$t1 != 0x80; r $t1 = @$t1+1) // Array size 0x80
{
r @$t2 = @$t0+10*@$t1; // Move pointer to next LIST_ENTRY
.if ( @$t2 == poi(@$t2) ) { // Check if our pointer value is the same as Blink
.continue
}
.else { // We walk the LIST_ENTRY and print whatever
// member we want from ProcessCtx in this case
// ProcessCtx.ProcessId and ProcessCtx.ProcessCmdLine
!list -t nt!_LIST_ENTRY.Flink -x "dd @$extret+10 L1; dS /c100 poi(@$extret+20)" -a "L1" poi(@$t2)
}
}
If you run the above script you should see something like this:

WdFilter MiniFilter Windows Defender Microsoft Security
WdFilter MiniFilter Windows Defender Microsoft Security
4793 Words
2020-01-29 00:00 +0000 (Last updated: 2020-04-18 20:44 +0000)
ade56b9 @ 2020-04-18