I was trying to patch g_CiOptions on a Windows 11 box when the write bounced. Not an access violation. Not a bugcheck. The BYOVD driver reported success, but the value did not change. I read it back and it was still 0x06. The hypervisor had silently eaten the write.
This is Kernel Data Protection. Microsoft used VBS to make certain kernel variables immutable after initialization, and g_CiOptions is one of them. The classic DSE bypass, zeroing that variable so CI.dll stops checking signatures, is dead on any machine with VBS enabled. Which, on Windows 11, is most of them.
I spent a few evenings working through an alternative. The kernel does not call CI.dll functions directly. It goes through SeCiCallbacks, an array of function pointers inside ntoskrnl.exe. And that array is not KDP-protected. You can replace the pointer to CiValidateImageHeader with a pointer to a kernel function that does nothing and returns success. Every signature check passes. Load your unsigned driver. Swap the pointer back. The whole window is a few milliseconds.
The classic bypass and why it broke
Driver Signature Enforcement boils down to a variable. g_CiOptions lives in CI.dll's .data section, and the kernel checks it when loading a driver. If the CODEINTEGRITY_OPTION_ENABLED bit is clear, signature validation is skipped. For years, the bypass was straightforward: get a kernel write primitive, set g_CiOptions to zero, call NtLoadDriver, set it back.
KDP changed the rules. When VBS is active, the hypervisor marks certain kernel data pages as read-only at the second-level address translation. The guest OS, running at VTL0, cannot modify them. Writes through BYOVD drivers go through the same guest-physical mapping and hit the same wall. The hypervisor does not care that your write came from Ring 0. It is enforcing at a layer below.
On my Win10 19041 test box (no VBS), g_CiOptions was 0x0e. On Win11 26100 with VBS, it was 0x06. Both had CODEINTEGRITY_OPTION_TESTSIGN set because kernel debug mode was active, but that is a different flag from the one that gates signature checks. The point is that on Win11, writing to g_CiOptions is a dead end.
The indirection
The kernel does not call CiValidateImageHeader by importing it directly. During boot, CI.dll populates an array of function pointers called SeCiCallbacks inside ntoskrnl's .data section. After that, every code integrity operation goes through this table.
The call chain for driver loading looks like this:
nt!MmLoadSystemImageEx
-> nt!MiObtainSectionForDriver
-> nt!MiCreateSectionForDriver
-> nt!MiCreateSystemSection
-> nt!MiCreateSection
-> nt!MiCreateImageOrDataSection
-> nt!MiCreateNewSection
-> nt!MiValidateSectionSigningPolicy
-> nt!MiValidateSectionCreate
-> SeCiCallbacks[3] // CiValidateImageHeader
That last call, SeCiCallbacks[3], is an indirect call through a pointer. Not an import. Not a hardcoded address. A slot in a writable array.
On Win10 (19041), SeCiCallbacks has 29 QWORDs. The first is a size field (0xe8, or 232 bytes), and the remaining 28 are function pointers into CI.dll. On Win11 (26100), the array grew to 34 QWORDs (size 0x110), with five new callbacks added at the end: CiHvciReportMmIncompatibility, CiCompareExistingSePool, CiSetCachedOriginClaim, CipIsDeveloperModeEnabled, and CiIsTrustedLaunchPolicyEnabled.
The target, CiValidateImageHeader, sits at offset +0x20 on both versions. That consistency was a nice surprise. I expected it to move.
The full table
Here is the complete SeCiCallbacks layout on Win10 19041. Win11 26100 is identical through offset +0xD8, with the five new entries appended after.
+0x00 0xe8 // size
+0x08 CI!CiSetFileCache
+0x10 CI!CiGetFileCache
+0x18 CI!CiQueryInformation
+0x20 CI!CiValidateImageHeader // swap target
+0x28 CI!CiValidateImageData
+0x30 CI!CiHashMemory
+0x38 CI!KappxIsPackageFile
+0x40 CI!CiCompareSigningLevels
+0x48 CI!CiValidateFileAsImageType
+0x50 CI!CiRegisterSigningInformation
+0x58 CI!CiUnregisterSigningInformation
+0x60 CI!CiInitializePolicy
+0x68 CI!CiReleaseContext
+0x70 NULL // separator
+0x78 CI!CiGetStrongImageReference
+0x80 CI!CiHvciSetImageBaseAddress
+0x88 CI!CipQueryPolicyInformation
+0x90 CI!CiValidateDynamicCodePages
+0x98 CI!CiQuerySecurityPolicy
+0xA0 CI!CiRevalidateImage
+0xA8 CI!CiSetInformation
+0xB0 CI!CiSetInformationProcess
+0xB8 CI!CiGetBuildExpiryTime
+0xC0 CI!CiCheckProcessDebugAccessPolicy
+0xC8 CI!CiGetCodeIntegrityOriginClaimForFileObject
+0xD0 CI!CiDeleteCodeIntegrityOriginClaimMembers
+0xD8 CI!CiDeleteCodeIntegrityOriginClaimForFileObject
There is a NULL at +0x70, splitting the table into two groups. I did not dig into whether this serves as a versioning marker or just a historical accident. Either way, the swap target at +0x20 is well above it.
Finding a no-op
The replacement pointer needs to satisfy two constraints: it must be a valid kernel address that the kernel will not fault on, and it must return STATUS_SUCCESS (zero) regardless of the arguments passed to it.
When CiValidateImageHeader returns zero through SeCiCallbacks, the kernel interprets that as "signature is valid, allow the driver." Any non-zero return blocks the load. So we need a function that always returns zero and does not care what arguments it receives.
ZwFlushInstructionCache fits perfectly. It is an Nt/Zw syscall stub in ntoskrnl.exe. On x64, instruction cache coherency is handled by hardware, so the function is a no-op. It accepts three arguments (process handle, base address, length), ignores all of them, and returns STATUS_SUCCESS. It lives inside ntoskrnl, so the address is always valid while the kernel is running.
I traced a normal boot to get a baseline for how often CiValidateImageHeader fires. It was called 634 times during boot. 551 of those returned STATUS_SUCCESS. 52 returned STATUS_BUFFER_TOO_SMALL. After the swap, every call to that slot returns zero. The 52 that used to return a buffer error will now report success, but those are not signature rejections. They are benign CI queries that retry with a larger buffer. During the brief swap window (milliseconds), the only thing I care about is that NtLoadDriver sees a zero return for the unsigned driver.
Resolving the addresses
Three addresses need to be resolved from usermode before the swap: the current value of CiValidateImageHeader in kernel space, the address of ZwFlushInstructionCache in kernel space, and the address of the SeCiCallbacks entry to patch.
For CiValidateImageHeader: enumerate loaded drivers with EnumDeviceDrivers and GetDeviceDriverBaseName to find CI.dll's kernel base. Grab CiValidateImageHeader's offset from PDB symbols (or a precomputed offset table keyed by build number). Add offset to base.
For ZwFlushInstructionCache: load ntoskrnl.exe into usermode with LoadLibrary. Call GetProcAddress("ZwFlushInstructionCache") on the loaded image. Compute the offset from the usermode base. Get ntoskrnl's kernel base from EnumDeviceDrivers. Add offset to kernel base.
For the SeCiCallbacks entry: the array's offset inside ntoskrnl comes from PDB symbols or a precomputed table. Read each 8-byte slot starting from SeCiCallbacks base using the BYOVD read primitive. Compare against the known CiValidateImageHeader address. On both Win10 and Win11, it was at +0x20. But scanning is safer than hardcoding, in case a future update reorders the table.
The swap
With all three addresses in hand, the actual attack is a single 8-byte write, one NtLoadDriver call, and another 8-byte write.
1. Load signed BYOVD driver (gdrv.sys) // passes CI normally
2. Read SeCiCallbacks, find CiValidateImageHeader at +0x20
3. Write SeCiCallbacks+0x20 = ZwFlushInstructionCache // SWAP
4. NtLoadDriver("unsigned.sys")
kernel calls SeCiCallbacks[3]
-> ZwFlushInstructionCache
-> returns 0
-> driver loads
5. Write SeCiCallbacks+0x20 = CiValidateImageHeader // RESTORE
The window between steps 3 and 5 is as short as you can make it. In my testing the swap, load, and restore completed in under 50 milliseconds. Any driver loaded by the OS during that window would also skip signature validation, but on a system that is not mid-boot, kernel driver loads are rare events.
The write is not atomic
Here is where I hit something unexpected, and it connects to the BYOVD post.
I set a hardware write watchpoint (ba w8) on SeCiCallbacks+0x20 to watch the swap happen. I expected one break, one clean 8-byte write. Instead, I got four breaks. The pointer was being written byte by byte.
Swap: CiValidateImageHeader (fffff80661d6b790)
-> ZwFlushInstructionCache (fffff806cfeabc30)
Original bytes (LE): 90 b7 d6 61 06 f8 ff ff
After byte 0: 30 b7 d6 61 = fffff80661d6b730
-> CI!CiValidateFileAsImageTypeLocked+0xd4
After byte 1: 30 bc d6 61 = fffff80661d6bc30
-> CI!CiValidateImageHeader+0x4a0
After byte 2: 30 bc ea 61 = fffff80661eabc30
-> Wdf01000!FxRequestBase::ValidateTarget+0x100
After byte 3: 30 bc ea cf = fffff806cfeabc30
-> nt!ZwFlushInstructionCache // swap complete
(Upper 4 bytes identical between both addresses. No writes needed.)
Each intermediate value resolves to a real kernel symbol. If the kernel had called SeCiCallbacks[3] after byte 0 but before byte 3, it would have jumped to CiValidateFileAsImageTypeLocked+0xd4. That is not a function entry point. The best case is a crash. The worst case is silent corruption.
The restore has the same problem, in reverse:
Restore: ZwFlushInstructionCache (fffff806cfeabc30)
-> CiValidateImageHeader (fffff80661d6b790)
After byte 0: 90 bc ea cf = fffff806cfeabc90
-> nt!ZwFlushVirtualMemory
After byte 1: 90 b7 ea cf = fffff806cfeab790
-> nt!ZwCreateTimer2
After byte 2: 90 b7 d6 cf = fffff806cfd6b790
-> nt!Amd64CheckCoreEventConstraints+0x6c
After byte 3: 90 b7 d6 61 = fffff80661d6b790
-> CI!CiValidateImageHeader // restore complete
gdrv.sys implements its write as a kernel-mode memcpy through MmMapIoSpace. It maps the physical page backing the target address into a non-cached virtual mapping, then copies byte by byte. There is no interlocked operation. No 8-byte aligned store. Just a byte loop.
I wrote about the atomicity gap in BYOVD primitives in the earlier post, focusing on RTCore64's two-IOCTL problem. gdrv has a different failure mode. It lands in one IOCTL, but the copy inside that IOCTL is not atomic at the byte level. The effect is similar: a brief window where a kernel pointer holds a half-written value. The window is much smaller than RTCore's (nanoseconds versus the full IOCTL round-trip), but it exists.
In practice, this did not cause a crash during any of my test runs. Driver loads during the swap window are rare, and the byte-write window within a single memcpy is tiny. But the risk is real, and it is worth knowing about if you are deploying this outside a lab.
KDP verification
To confirm the KDP boundary, I tested writes to both targets:
Target Location KDP Protected Patchable
─────────────────────────────────────────────────────────────────
CI!g_CiOptions CI.dll .data YES (Win11+VBS) NO
nt!SeCiCallbacks ntoskrnl .data NO YES
Writes to g_CiOptions on Win11 with VBS silently failed. The value read back unchanged. On Win10 without VBS, the same write succeeded, which confirmed the behavior difference is KDP, not something else.
Writes to SeCiCallbacks succeeded on both Win10 and Win11. The array lives in ntoskrnl's .data section, which is not enrolled in KDP. Microsoft protected the configuration variable but not the function pointer table that the kernel actually dispatches through.
The observer effect
All the testing up to this point was done with kernel debugging enabled. I needed bcdedit /debug on for hardware watchpoints, breakpoints, and call stack captures. That gave me the mechanics. What I did not have was proof that the bypass was necessary.
When the kernel debugger is attached, CI.dll relaxes its enforcement. An unsigned driver that would normally be rejected gets loaded anyway. The Code Integrity event log tells the story:
Event ID: 3005
Level: Warning
"Code integrity determined that the image hash of a file is not valid.
A driver is allowed to load because kernel mode debugger is attached
to the system."
Event 3005. Warning level. "Allowed to load." The debugger's presence told CI to let the unsigned driver through. My swap test proved the pointer replacement worked, the no-op returned zero, the call chain behaved correctly. But the driver would have loaded without the swap too.
To isolate the bypass from the debugger's effect, I booted the same Win11 VM with bcdedit /debug off and /testsigning off. No debugger. No test signing. Full enforcement.
Baseline first. I tried to load the same unsigned driver:
> sc start TestUnsigned
[SC] StartService FAILED 577:
Windows cannot verify the digital signature for this file.
Error 577. The driver was rejected. The Code Integrity log confirmed it:
Event ID: 3004
Level: Error
"Code integrity determined that the image hash of a file is not valid.
The file could not be verified because the file hash could not be
found on the system."
Event 3004 instead of 3005. Error instead of Warning. "Could not be verified" instead of "allowed to load." This is real enforcement.
Then I ran the full attack chain: loaded gdrv.sys, resolved addresses, swapped SeCiCallbacks+0x20 to ZwFlushInstructionCache, and called sc start TestUnsigned again. The driver loaded. Swapped the pointer back.
The earlier instrumented tests proved the mechanics. This test proved the bypass works against actual DSE enforcement on a system where unsigned drivers are genuinely blocked.
Anyone testing DSE bypasses will hit this same confound. The kernel debugger gives you visibility into what the bypass is doing, but it also disables the thing you are trying to bypass. You cannot simultaneously instrument and enforce. Run your instrumented tests first to verify the mechanics, then boot without the debugger to confirm the bypass is doing real work.
PatchGuard
The debugger confounded PatchGuard testing too, but in a different way. PatchGuard checks KdDebuggerEnabled at initialization and skips all context setup if the flag is set. With bcdedit /debug on, PatchGuard's timer DPCs are never registered. No checks run. Nothing catches the swap. Unlike DSE, I could not easily validate this one with debug off, because without the debugger there is no way to observe PatchGuard's behavior from the outside.
On a production machine without a debugger, PatchGuard might monitor SeCiCallbacks. I do not know whether it does. But even if it does, the timing strongly favors the attacker. PatchGuard checks typically run on 5-to-10-minute intervals. The swap window here is under 50 milliseconds. The pointer is back to its original value long before any check runs.
This is the fundamental weakness of periodic integrity checks against transient modifications. The swap is too fast to catch unless the check happens to land exactly during the window. And PatchGuard's intervals are deliberately randomized to make them harder to predict, which also means they are unlikely to fire during any specific sub-100ms window.
The call stack
For completeness, here is the write call stack captured on Win10. It was identical on Win11.
gdrv+0x28bd // physical memory write handler
gdrv+0x3066 // IOCTL dispatch
nt!IofCallDriver+0x55
nt!IopSynchronousServiceTail+0x361
nt!IopXxxControlFile+0xd0a
nt!NtDeviceIoControlFile+0x56
nt!KiSystemServiceCopyEnd+0x25
ntdll!NtDeviceIoControlFile+0x14
Nothing unusual here. Standard synchronous IOCTL path. The write happens at gdrv+0x28bd, inside the function that maps physical memory via MmMapIoSpace and copies the bytes.
What changed between versions
Structurally, the technique works the same on Win10 19041 and Win11 26100. The differences are cosmetic:
Win11 added five new callbacks at the end of SeCiCallbacks. The existing entries did not move. CiValidateImageHeader stayed at +0x20. The size field grew from 0xe8 to 0x110 to account for the new entries.
The only thing Win11 changed that mattered was KDP on g_CiOptions. That is what makes this technique necessary in the first place. On Win10 without VBS, you can still just patch g_CiOptions directly and skip the callback swap entirely.
So the technique is a Win11 bypass for a Win11 mitigation. It works on Win10 too, but on Win10 there is no reason to bother.
Microsoft closed the front door with KDP on g_CiOptions and left the side door open with SeCiCallbacks. The fix would be straightforward: enroll SeCiCallbacks in KDP the same way g_CiOptions is enrolled. I would not be surprised if a future Windows update does exactly that. Until then, the pointer swap works.
This was a fun one to trace through. The byte-by-byte write discovery was a bonus I was not expecting, and it reinforced what I wrote in the BYOVD post about never assuming your write primitive is atomic. Hopefully someone finds this useful.