A Minifilter for Self-Defense

No amount of ObCallbacks or ETW monitoring matters if a cheat can delete the anti-cheat DLL off disk or overwrite the driver’s .sys file before the next reboot. The Windows minifilter framework provides a clean way to intercept file system operations in the kernel, making it possible to detect and report any attempt to tamper with Peregrine’s own files [1]. This post covers the minifilter component of Peregrine’s kernel driver: the INF registration, the callback structure, the protected file matching logic, and a rate-limiting mechanism that keeps the event stream sane.

Altitude 370030 in the FSFilter Activity Monitor Range

Minifilters attach to the file system stack at a specific altitude, a numeric string that determines ordering relative to other minifilters [2]. Microsoft maintains an altitude allocation registry, and each range corresponds to a load order group. Peregrine registers at altitude 370030, which falls in the FSFilter Activity Monitor range (360000 to 389999). Activity monitors observe I/O without modifying it, which is exactly what Peregrine needs: report file access events but do not block them.

The INF file declares the altitude, instance name, and load order group:

; From: PeregrineKernelComponent/PeregrineKernelComponent.inf
[Peregrine.Service]
DisplayName    = %ServiceName%
Description    = %ServiceDescription%
ServiceBinary  = %12%\%DriverName%.sys
ServiceType    = 2
StartType      = 3
ErrorControl   = 1
LoadOrderGroup = "FSFilter Activity Monitor"
AddReg         = Peregrine.AddRegistry

[Peregrine.AddRegistry]
HKR,"Instances","DefaultInstance",0x00000000,%DefaultInstance%
HKR,"Instances\"%DefaultInstance%,"Altitude",0x00000000,%Altitude%
HKR,"Instances\"%DefaultInstance%,"Flags",0x00010001,0
; From: PeregrineKernelComponent/PeregrineKernelComponent.inf
[Strings]
Altitude        = "370030"
DefaultInstance = "Peregrine Instance"

ServiceType = 2 is SERVICE_FILE_SYSTEM_DRIVER, and StartType = 3 is demand-start, meaning the driver loads when explicitly started via sc start, not at boot [3]. The Flags value of 0 means no automatic attachment flags; the minifilter attaches to all volumes via the MfInstanceSetup callback returning STATUS_SUCCESS unconditionally.

Two Pre-Operation Callbacks, No Post-Operations

The minifilter registers exactly two pre-operation callbacks:

// From: PeregrineKernelComponent/MiniFilter.c
static const FLT_OPERATION_REGISTRATION g_Callbacks[] = {
    { IRP_MJ_CREATE,          0, PreCreate,  NULL },
    { IRP_MJ_SET_INFORMATION, 0, PreSetInfo, NULL },
    { IRP_MJ_OPERATION_END }
};

IRP_MJ_CREATE fires on every file open. This is where Peregrine catches processes requesting write, delete, or overwrite access to protected files. IRP_MJ_SET_INFORMATION fires when a process sets file metadata; this is the IRP that carries rename and delete-on-close operations [4]. There are no post-operation callbacks (NULL in the fourth field) because only the pre-create information is needed. Whether the operation actually succeeded is irrelevant for detection purposes.

The FLT_REGISTRATION structure ties these callbacks to the filter lifecycle:

// From: PeregrineKernelComponent/MiniFilter.c
static const FLT_REGISTRATION g_FilterRegistration = {
    sizeof(FLT_REGISTRATION),
    FLT_REGISTRATION_VERSION,
    0,                          // Flags
    NULL,                       // Context registration
    g_Callbacks,
    (PFLT_FILTER_UNLOAD_CALLBACK)MfUnload,
    MfInstanceSetup,
    NULL, NULL, NULL,           // Instance query/teardown
    NULL, NULL, NULL            // Generate file name / normalize
};

Registration and startup happen in sequence: FltRegisterFilter first, then FltStartFiltering [5]. If filtering fails to start (perhaps the INF was not installed correctly), the driver logs the error but continues running. The minifilter is not critical to the other detection systems.

// From: PeregrineKernelComponent/MiniFilter.c
NTSTATUS MfInit(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);

    NTSTATUS status = FltRegisterFilter(DriverObject, &g_FilterRegistration, &g_FilterHandle);
    if (!NT_SUCCESS(status)) return status;

    status = FltStartFiltering(g_FilterHandle);
    if (!NT_SUCCESS(status)) {
        FltUnregisterFilter(g_FilterHandle);
        g_FilterHandle = NULL;
        return status;
    }
    return STATUS_SUCCESS;
}

Five Hardcoded Protected Filenames

The protected file check is a two-stage filter. First, the path must contain \peregrine\ as a directory component (case-insensitive). Then, the filename must match one of five hardcoded names:

// From: PeregrineKernelComponent/MiniFilter.c
static BOOLEAN IsProtectedFile(_In_ PCUNICODE_STRING Path)
{
    if (!ContainsI(Path, L"\\peregrine\\", 11)) return FALSE;

    if (EndsWithI(Path, L"peregrinekernelcomponent.sys", 27)) return TRUE;
    if (EndsWithI(Path, L"peregrinekernelcomponent.inf", 27)) return TRUE;
    if (EndsWithI(Path, L"peregrine64.dll", 15)) return TRUE;
    if (EndsWithI(Path, L"peregrine32.dll", 15)) return TRUE;
    if (EndsWithI(Path, L"peregrine-tauri.exe", 19)) return TRUE;

    return FALSE;
}

The ContainsI and EndsWithI helpers do manual case-insensitive matching one character at a time, without calling RtlCompareUnicodeString or allocating any pool memory. This matters because pre-operation callbacks can run at elevated IRQL on certain file system paths, and the hot path needs to be as lean as possible.

The directory check (\peregrine\) acts as a fast reject. The vast majority of file opens on the system will fail this check before any filename comparison runs, which keeps the per-I/O overhead near zero for unrelated files.

This is a hardcoded list. A production anti-cheat would likely want a configurable file list pushed from userland, but for Peregrine’s purposes this keeps things simple and avoids the complexity of synchronizing a dynamic list across IRQL boundaries.

Intercepting Dangerous Opens via IRP_MJ_CREATE

The PreCreate callback handles IRP_MJ_CREATE, which is the workhorse of the file system. Every CreateFile, DeleteFile, MoveFile, and file overwrite goes through it [6]. The key insight is that not every open matters. Only opens that request write-class access are relevant:

// From: PeregrineKernelComponent/MiniFilter.c
#define WRITE_ACCESS_FLAGS (FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | \
    FILE_WRITE_EA | FILE_APPEND_DATA | DELETE | WRITE_DAC | WRITE_OWNER)

static FLT_PREOP_CALLBACK_STATUS FLTAPI
PreCreate(
    _Inout_ PFLT_CALLBACK_DATA Data,
    _In_ PCFLT_RELATED_OBJECTS FltObjects,
    _Flt_CompletionContext_Outptr_ PVOID* CompletionContext)
{
    // ...
    if (IsOurProcess()) return FLT_PREOP_SUCCESS_NO_CALLBACK;

    ACCESS_MASK desired = Data->Iopb->Parameters.Create.SecurityContext->DesiredAccess;
    ULONG disposition   = (Data->Iopb->Parameters.Create.Options >> 24) & 0xFF;

    BOOLEAN isWrite = (desired & WRITE_ACCESS_FLAGS) != 0;
    BOOLEAN isCreateNew = (disposition == FILE_CREATE ||
                           disposition == FILE_OVERWRITE ||
                           disposition == FILE_OVERWRITE_IF ||
                           disposition == FILE_SUPERSEDE);

    if (!isWrite && !isCreateNew) return FLT_PREOP_SUCCESS_NO_CALLBACK;
    // ...
}

The WRITE_ACCESS_FLAGS mask covers the obvious ones (FILE_WRITE_DATA, DELETE, FILE_APPEND_DATA) but also WRITE_DAC and WRITE_OWNER, which would let an attacker change the file’s ACL and then write to it in a second operation. The disposition check catches file replacement operations that might not set explicit write flags but will overwrite file contents anyway [6].

The callback skips Peregrine’s own processes. IsOurProcess() checks whether the calling PID is in the driver’s protected PID set (the same set that the GUI registers itself into on connect). Without this exclusion, the GUI updating its own files would trigger false positives.

Only after the access check passes does the callback call FltGetFileNameInformation to resolve the full normalized path. This is the expensive part, involving an upcall to the file system to construct the name, and Peregrine avoids it entirely for read-only opens.

Catching Rename and Delete-on-Close via IRP_MJ_SET_INFORMATION

PreCreate handles most write scenarios, but there is a second path for file deletion and renaming: IRP_MJ_SET_INFORMATION with specific information classes. The PreSetInfo callback covers these:

// From: PeregrineKernelComponent/MiniFilter.c
static FLT_PREOP_CALLBACK_STATUS FLTAPI
PreSetInfo(
    _Inout_ PFLT_CALLBACK_DATA Data,
    _In_ PCFLT_RELATED_OBJECTS FltObjects,
    _Flt_CompletionContext_Outptr_ PVOID* CompletionContext)
{
    // ...
    if (IsOurProcess()) return FLT_PREOP_SUCCESS_NO_CALLBACK;

    ULONG infoClass = Data->Iopb->Parameters.SetFileInformation.FileInformationClass;
    BOOLEAN isDel = (infoClass == FileDispositionInformation ||
                     infoClass == FileDispositionInformationEx ||
                     infoClass == FileRenameInformation ||
                     infoClass == FileRenameInformationEx);
    if (!isDel) return FLT_PREOP_SUCCESS_NO_CALLBACK;
    // ...
}

This catches both flavors of each operation: the original FileDispositionInformation and the extended FileDispositionInformationEx (added in Windows 10 1709 for POSIX-style delete semantics), plus both FileRenameInformation variants [7]. A cheat that opens a file with DELETE access and then calls SetFileInformationByHandle with FileDispositionInfo would be caught here even if PreCreate did not fire with write flags.

When a protected file is matched, the callback classifies the operation as either "rename" or "delete" and sends a JSON event to the GUI through the same circular buffer used by all other kernel events:

// From: PeregrineKernelComponent/MiniFilter.c
const CHAR* opName = (infoClass == FileRenameInformation ||
                      infoClass == FileRenameInformationEx) ? "rename" : "delete";

CHAR json[COMS_MAX_MESSAGE_SIZE];
ANSI_STRING ansi = { 0 };
if (NT_SUCCESS(RtlUnicodeStringToAnsiString(&ansi, &nameInfo->Name, TRUE))) {
    RtlStringCchPrintfA(json, ARRAYSIZE(json),
        "{ \"event\": \"file_access\", \"pid\": %lu, \"path\": \"%s\", \"op\": \"%s\" }",
        (ULONG)(ULONG_PTR)callerPid, ansi.Buffer, opName);
    ComsSendToUser(json, (ULONG)strlen(json));
    RtlFreeAnsiString(&ansi);
}

Single-PID Rate Limiting with a 2-Second Window

A process hammering a protected file (say, a cheat loader retrying a delete in a tight loop) would flood the circular buffer and drown out other, more interesting kernel events. Peregrine uses a simple per-PID throttle with a 2-second window:

// From: PeregrineKernelComponent/MiniFilter.c
static LARGE_INTEGER g_LastBlockReport = { 0 };
static HANDLE        g_LastBlockPid    = NULL;
#define BLOCK_REPORT_INTERVAL_MS 2000

static BOOLEAN ShouldReport(HANDLE Pid)
{
    LARGE_INTEGER now;
    KeQuerySystemTime(&now);
    LONGLONG diffMs = (now.QuadPart - g_LastBlockReport.QuadPart) / 10000;
    if (g_LastBlockPid == Pid && diffMs < BLOCK_REPORT_INTERVAL_MS)
        return FALSE;
    g_LastBlockReport = now;
    g_LastBlockPid    = Pid;
    return TRUE;
}

This is intentionally coarse. It only tracks one PID at a time, not a full per-PID map. If two different processes alternate writes to protected files faster than 2 seconds apart, both will be reported because each one changes g_LastBlockPid. The optimization targets the common case: a single process retrying the same operation in a loop. A more precise implementation would need a hash table or lookaside list keyed on PID, but the additional complexity and pool allocations are not worth it for what is essentially a “please be quiet” heuristic.

Observation Only, No Blocking

One thing Peregrine’s minifilter deliberately does not do: it never returns FLT_PREOP_COMPLETE with STATUS_ACCESS_DENIED. Both callbacks always return FLT_PREOP_SUCCESS_NO_CALLBACK, letting the I/O proceed to the underlying file system. The minifilter is purely an observation layer. It reports what it sees and lets the GUI (or a human operator) decide what to do about it.

This is a conscious trade-off. A blocking minifilter that denies write access to protected files would be stronger self-defense, but it also risks breaking system operations (antivirus scanners, Windows Update, WinSxS servicing) if the protected path check has false positives. For an educational project, observing and reporting is the safer default.

Limitations

The protected file list is static; adding or removing files requires recompiling the driver. The rate limiter tracks only a single PID, so coordinated multi-process attacks would not be throttled effectively. The minifilter also does not cover memory-mapped writes: a process that maps a protected file into its address space and writes through the mapping would bypass these callbacks because the actual I/O goes through the memory manager, not the file system stack [8].

This post was generated by an LLM based on code from Peregrine Anti-Cheat. All code snippets are from the actual repository. Claims about Windows internals are sourced from Microsoft documentation.

References

[1] Microsoft, “File System Minifilter Drivers”, learn.microsoft.com/en-us/windows-hardware/drivers/ifs/file-system-minifilter-drivers

[2] Microsoft, “Minifilter Altitude Request”, learn.microsoft.com/en-us/windows-hardware/drivers/ifs/minifilter-altitude-request

[3] Microsoft, “SERVICE_FILE_SYSTEM_DRIVER”, learn.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-service_status

[4] Microsoft, “IRP_MJ_SET_INFORMATION (IFS)”, learn.microsoft.com/en-us/windows-hardware/drivers/ifs/flt-parameters-for-irp-mj-set-information

[5] Microsoft, “FltRegisterFilter function”, learn.microsoft.com/en-us/windows-hardware/drivers/ddi/fltkernel/nf-fltkernel-fltregisterfilter

[6] Microsoft, “IRP_MJ_CREATE (IFS)”, learn.microsoft.com/en-us/windows-hardware/drivers/ifs/irp-mj-create

[7] Microsoft, “FileDispositionInformationEx”, learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-_file_disposition_information_ex

[8] Microsoft, “File-Backed and Page-File-Backed Sections”, learn.microsoft.com/en-us/windows-hardware/drivers/kernel/section-objects-and-views