Author: Marek Wesołowski (WESMAR)
Date: 27 June 2026
Target: Windows 11 Pro 26H1, build 28000, fully patched
Subject: skci.dll version 10.0.28000.2336, public PDB available

PL: Analiza statyczna skci.dll — modułu Secure Kernel Code Integrity — z publicznym PDB na Windows 11 Pro 26H1. Dokumentuje łańcuch zaufania od firmware UEFI przez boot manager i OS loader do kontekstu securekernel, mapuje eksportowane API, identyfikuje siedem nieoczywistych zachowań, bitmapę uprawnień strony i format raportu atestacji. Na marginesie: inwentarz ESP i dwie kopie bootmgfw.efi z tą samą wersją ale różnym hashem. Bez bypassu, bez patcha, bez łańcucha exploitów.

EN: Static analysis of skci.dll — the Secure Kernel Code Integrity module — with public PDB on Windows 11 Pro 26H1. Documents the trust chain from UEFI firmware through boot manager and OS loader into the secure kernel context, maps the exported API, identifies seven non-obvious behaviours, the page-use permission bitmap, and the attestation report format. Side-finding: ESP inventory and two bootmgfw.efi copies with same version but different hash. No bypass, no patch, no exploit chain.


Abstract

This document records my static analysis of skci.dll on a live Windows 11 Pro 26H1 system, build 28000. The investigation began as a natural continuation of the ForceIO work: having established that WdFilter.sys filters file I/O in VTL0 and can be bypassed by routing through an overpowered signed driver, the next honest question was whether the kernel-level code-integrity machinery in VTL1 deserves the same quality of scrutiny. The answer, as always, is yes — and the methodology here differs from the VTL0 work because SKCI is more structurally interesting and carries public debug symbols.

I had a meaningful advantage that I want to acknowledge up front: the public PDB for skci.dll was available on the Microsoft symbol server. This is not common for VTL1 components. It means that most of the function names I cite here are their real names, not my analytical aliases. That changes the nature of the investigation from pure archaeology into something closer to reading annotated assembly — you still have to understand the semantics, but you skip the naming guesswork. I want to be honest about this because it affects how you should weigh the confidence in my findings.

The analysis produced seven findings that I consider non-obvious — things that surprised me or that I expect to surprise a reader who knows VTL0 Code Integrity well:

  1. SkciValidateDynamicCodePages returns 0x12C on success, not STATUS_SUCCESS (0). Any caller checking status == 0 gets a false negative.
  2. SkciQueryEnclavePackageID passes the authorization check and then returns 40 bytes of zeros. The actual enclave identities are computed separately and live elsewhere in the context.
  3. SkciQueryInformation accepts exactly two information classes (0x67 and 0xF1), with a rigid 8-byte handshake protocol for class 0x67. Everything else is STATUS_INVALID_INFO_CLASS.
  4. SkciBuildAttestationReport calls KeBugCheckEx(SECURITY_SYSTEM) when the attestation history count is zero. Zero history is an invariant violation, not an API error condition.
  5. CipForceImageRevalidation is two instructions: bts [g_CiPolicyState], 7; ret. This pass confirms SKCI as the producer of the revalidation-pending signal; the exact consumer was not proven in static analysis.
  6. SkciDetermineAuthorizedPageUse has an EKU fallback path for signing levels between 0x08 and 0x0C: the presence of EKU 1.3.6.1.4.1.311.76.8.1 can grant the WHQL_level bit without a full level upgrade.
  7. SkciSetCodeIntegrityPolicy mode 2 accepts exactly 5 bytes — a compact scenario state update under the CI policy lock.

The document also covers the ESP inventory, boot chain components, the image validation pipeline, the page-use flag bitmap, the attestation report format, and a diagnostic gap analysis. It is not a guide to bypassing HVCI. It is what I would want to read before writing defensive tooling or incident response procedures that interact with the CI attestation surface.


1. Motivation

The ForceIO write-up ended at the WdFilter.sys minifilter layer: we can route around it by speaking directly to an overpowered but legitimately signed driver. That closes one chapter and opens another. The minifilter is a VTL0 construct. The deeper question — whether a driver or image is authorized to execute at all — belongs to VTL1, and specifically to skci.dll.

I had been thinking about this for a while. The problem with most published HVCI/VBS research is that it operates at the level of "what happens when you try" rather than "why the machinery decides what it does." There are good theoretical write-ups, but almost nothing that walks through actual disassembly of the current enforcement path with current symbols. Part of the reason is that VTL1 components are harder to reach: you cannot single-step through securekernel.exe with a user-mode debugger, and full kernel debugging of the VTL1 world requires specialized setup. Static analysis with symbols is the practical alternative for a researcher who does not want to instrument firmware.

So I started there. The questions I brought to the binary:

  1. What is the exact sequence of decisions between an image being presented to SKCI and a page-use authorization being issued?
  2. Are there observable gaps in the diagnostic surface — places where the code makes a security-relevant decision without surfacing sufficient information to incident response tooling?
  3. What does the attestation report actually contain, and what are the correctness invariants the code depends on?
  4. Is there anything in the EFI/boot chain that a forensic baseline should record, beyond what sigcheck.exe -v reports?

I'm writing this in the first person and in chronological order, including the parts where I misread something or chased a dead end, because that is the form of record I want to leave behind. The failures are not embarrassments — they are evidence of what the code is designed to make non-obvious.


2. Test Environment

Component Path Version SHA-256 (first 8 bytes)
skci.dll C:\Windows\System32\skci.dll 10.0.28000.2336 615D8C124D2C31CC
securekernel.exe C:\Windows\System32\securekernel.exe 10.0.28000.2336
ntoskrnl.exe C:\Windows\System32\ntoskrnl.exe 10.0.28000.2336
winload.efi C:\Windows\System32\winload.efi 10.0.28000.2336
Boot Manager (System32) C:\Windows\Boot\EFI\bootmgfw.efi 10.0.28000.342 456DE3C04EA6A39B
Boot Manager (ESP/Microsoft) S:\EFI\Microsoft\Boot\bootmgfw.efi 10.0.28000.342 200D1E3A6A0DE342
Boot Manager (ESP/fallback) S:\EFI\boot\bootx64.efi 10.0.28000.342 200D1E3A6A0DE342
VBS SI policy C:\Windows\System32\CodeIntegrity\VbsSiPolicy.p7b
Driver SI policy C:\Windows\System32\CodeIntegrity\driversipolicy.p7b
Item Value
OS Windows 11 Pro 26H1, build 28000, fully patched
VBS Enabled ({current}.isolatedcontext = Yes)
Hyper-V launch type Auto
PDB path C:\Symbols (public Microsoft symbol server)
PDB available skci.pdb, bootmgfw.pdb — yes. winload.pdb — export symbols only
Primary tool CDB (Windows SDK 10.0.28000.2114, AMD64)
Constraint No VTL1 live dump. Static analysis only for skci.dll

3. Scope and Honest Constraints

Before the findings: what I cannot see from this analysis.

skci.dll runs inside securekernel.exe, which is the VTL1 kernel. A normal kernel debugger attached to the NT kernel does not give you a window into VTL1 execution. To single-step through SKCI at runtime you would need either a virtual machine with a VTL1-aware debugger, or a hypervisor trace. I have neither in this session. Everything I report is derived from:

  • Disassembly of skci.dll at rest, using cdb uf / cdb x with the public PDB loaded
  • Cross-referencing function names from the import table of securekernel.exe
  • Inferring runtime behavior from code paths, not from observed execution traces

The implications are:

  1. Values in global variables (g_CiOptions, g_CiDeveloperMode, g_CiScenarios, etc.) are their link-time defaults in my analysis. Runtime state may differ.
  2. "This function calls X" is established. "This function is called from Y" is partially established (via imports and cross-references), but the call graph is necessarily incomplete without a live trace.
  3. Where I state a hypothesis about runtime behavior, I will label it as such.

Where the PDB gives me a real name, I use it. Where I had to derive a name analytically, I will mark it in parentheses as (analytical).


4. The Trust Chain — From UEFI to skci.dll

skci.dll is not the first line of defense. It receives a world that has already gone through a sequence of trust-narrowing decisions. Understanding that sequence is necessary context for evaluating what SKCI can and cannot guarantee.

flowchart TD A[UEFI firmware] --> B[Secure Boot database\nPK / KEK / db / dbx] B --> C[bootmgfw.efi\n10.0.28000.342] C --> D[BmSecureBootInitializeMachinePolicy\nSecure Boot policy + revocation] D --> E[BlImgQueryCodeIntegrityBootOptions\nCI / HVCI boot options from BCD] E --> F[BlSecureBootPackageActivePolicyForKernel\npolicy package for kernel handoff] F --> G[winload.efi\n10.0.28000.2336] G --> H[BlImgRegisterCodeIntegrityCatalogs\ncatalog + revocation context] G --> I[BlVsmCheckSystemPolicy\nVSM policy gate] I --> J[Hypervisor / isolated context\nVTL1 activation] J --> K[securekernel.exe\n10.0.28000.2336] K --> L[SkciInitialize\nABI version 0x0A000012] L --> M[SkciCreateSecureImage\nimage context allocation] M --> N[SkciValidateImageData\npage data streaming] N --> O[SkciFinalizeSecureImageHash\nhash materialization] O --> P[SkciFinishImageValidation\npolicy + page-use convergence]

The table below shows what each stage inputs and outputs for the next stage:

Stage Key input Key output
Firmware PK/KEK/db/dbx, hardware Secure Boot state boot application accepted or rejected before execution
Boot Manager Secure Boot policy, BCD options, revocation lists packaged kernel policy blob, CI boot options
OS Loader CI options, SI policies, catalogs, HVCI options runtime policy and catalog state, VSM configuration
Secure Kernel VBS / isolated context, packaged policies VTL1 OS services, SKCI initialization
SKCI PE image bytes, page hashes, signing state image trust level, authorized page-use flags, attestation data

Each step is a monotonic reduction: it can only restrict what the next step is allowed to trust, never expand it. A compromised earlier stage invalidates all later guarantees.

4.1 Boot Manager phase

bootmgfw.efi has public symbols. The Secure Boot initialization sequence is visible in named routines:

Function Role
BmSecureBootInitializeMachinePolicy Top-level machine policy initialization. Queries Secure Boot state via SbIsEnabled2, checks test-root trust via SbIsTestRootTrusted, reads firmware revocation lists, sets accepted root-key masks
BmpSecureBootInitializeCurrentPolicy Loads current Secure Boot policy; handles the state where a policy is required but absent
BmSecureBootLoadSupplementalPoliciesFromAllPartitions Discovers supplemental policy material from storage
BlSecureBootPackageActivePolicyForKernel Converts policy state into a kernel-consumable blob via SbGetSizeOfKernelPolicyPackageForPolicySbGetKernelPolicyPackageForPolicyBlpPdSaveData
BlImgQueryCodeIntegrityBootOptions Reads CI/HVCI-related BCD boolean options 0x16000048, 0x16000049, 0x1600007e and active policy bits
BlImgValidateFileHash Validates a policy hash blob against image bytes using SymCryptEqual
BlImgLoadPEImageWithPolicyValidatedHash Validates policy-provided hash before using the image

The key insight from the boot manager phase: policy is packaged, not transmitted raw. The handoff from BlSecureBootPackageActivePolicyForKernel to the kernel is a compact blob, not a live database. This boot policy package establishes the initial trust baseline; later policy changes go through explicit SKCI policy refresh paths (see SkciSetCodeIntegrityPolicy, §15).

4.2 OS Loader phase

winload.efi does not have a matching full PDB on this host, but export symbols are rich enough:

Function Role
BlImgRegisterCodeIntegrityCatalogs Registers catalog directories and revocation-list context
BlSIPolicyCheckPolicyOnDevice Checks policy presence and applicability for this device
SIPolicyGetHvciOptions Extracts HVCI options from policy state
SIPolicyHashActiveCodeExecutionPolicies Hashes the active execution policy set
BlVsmCheckSystemPolicy VSM policy state gate before launch
BlSetVirtualizationLaunched Marks virtualization launch state

4.3 Secure Kernel import surface

securekernel.exe imports from skci.dll the full API surface documented in §6 — CDB shows 19 _imp_Skci* symbols in the import table, covering lifecycle, image validation, query, policy, catalog, cert, hotpatch, and attestation functions. The core image-validation loop drives four calls in sequence:

Import Role
SkciCreateSecureImage Secure image context creation from PE header mapping
SkciFinalizeSecureImageHash Final hash materialization
SkciFinishImageValidation Policy, page hash, and attestation convergence
SkciFreeImageContext Context teardown and memory release

skci.dll imports VTL1 OS services from securekernel.exe:

Service class Examples
Pool / memory SkAllocatePool, SkFreePool, SkAllocateNormalModePool, SkmmFreeSecureAllocation
Locking SkInitializePushLock, SkAcquirePushLockExclusive, SkAcquirePushLockShared, SkReleasePushLockExclusive, SkReleasePushLockShared
Critical region SkeEnterCriticalRegion, SkeLeaveCriticalRegion
Object manager SkobCreateObject, SkobCreateHandle, SkobDereferenceObject
Secure Boot query SeQuerySecureBootPolicyValue, SeQuerySecureBootPlatformManifest
Time / reference SkQuerySystemTime, ShvlGetReferenceTime
Hotpatch NtManageHotPatch

The architecture statement this import table makes is clean: securekernel.exe provides VTL1 OS services; skci.dll implements CI/SI policy, page hashing, attestation, and page-use authorization. Neither can function without the other.


5. ESP Audit Finding — Version Is Not Identity

While inventorying the test system, I found something worth documenting separately from the SKCI analysis proper.

5.1 Two bootmgfw.efi with the same version, different hashes

Path Size (bytes) Version SHA-256
C:\Windows\Boot\EFI\bootmgfw.efi 3 055 616 10.0.28000.342 456DE3C04EA6A39B03964181E23A725E9A27A1097D79D02355CBA0A061BD96C1
S:\EFI\Microsoft\Boot\bootmgfw.efi 3 055 456 10.0.28000.342 200D1E3A6A0DE342A5091654C0E62A434E38D467ADD78057B60A1FDBFC8EF101
S:\EFI\boot\bootx64.efi 3 055 456 10.0.28000.342 200D1E3A6A0DE342A5091654C0E62A434E38D467ADD78057B60A1FDBFC8EF101

All three carry valid Authenticode signatures. Both SHA-256 values in the ESP match each other (bootx64.efi is a copy of the ESP-side boot manager). The System32 copy is 160 bytes larger and has a different hash. Same PE version string. Same signed certificate subject. Different binaries.

I did not investigate the root cause. The gap might be a deployment artifact from a Windows Update that staged a new binary to System32 but not to the ESP, or vice versa. What matters for this document is the defensive lesson: a version string is not a content fingerprint. Incident response tooling that records bootmgfw.efi version 10.0.28000.342 as verified has not recorded enough. The minimum useful baseline is: path, size, SHA-256, certificate subject, certificate thumbprint, signing time. The bootmgfw.efi case is a clean demonstration of why.

5.2 Non-standard EFI assets

The ESP inventory (S:\) contained the following files that are not part of a standard Windows 11 installation:

File Size (bytes) SHA-256 Classification
S:\EFI\boot\HvciBypass.efi FFC60B2B21199E9A63585F724A52D4206C9060082492FB6C6220C60ECB8794FF Non-standard; name indicates HVCI context/secret
S:\EFI\boot\EfiGuardDxe.efi FD0F43E3D8DB8BF118072328CF27FD5AEB7AEF8C356A4177B2F0E8CE650C49A6 Known EFI bootkit framework, also in OpenCore distributions
S:\EFI\OC\Drivers\EfiGuardDxe.efi FD0F43E3D8DB8BF118072328CF27FD5AEB7AEF8C356A4177B2F0E8CE650C49A6 Same hash — duplicate
S:\EFI\boot\BlackLotus_2th_GEN.efi 1033F56887519F06D29A9B5688E890E4D4A9DF7A1DF8A6D290EAA3AF06687BB0 Requires separate classification/secret
S:\EFI\boot\UnderVolter.efi 5CED5112D8ED2998AD0CEA222661A1EFC14A977D9A285EFFEB9FBA23C97B4F87 Firmware boot entry points to this path
S:\EFI\boot\Loader.efi 5C991046E83A5E076F986A0BA3C6D133109E511CD34B5795C881D0D178005822 Custom loader

I am documenting these because the ESP is the first file system that the firmware reads after power-on. On this specific research machine their presence is known and intentional — this is a test system, not production. But the broader point stands: EDR and incident response tools have no standardized, supported channel for alerting on unexpected EFI executables, correlating them with BCD entries, or recording their hashes as part of boot chain telemetry. The ESP is essentially a blind spot in contemporary enterprise monitoring.

The recommendation is simple: Windows should ship a first-class, supported mechanism for:

  1. Enumerating EFI executables on the ESP with hash and signing status
  2. Correlating each file against active BCD entries and firmware boot entries
  3. Alerting when a new EFI executable appears or an existing one changes hash
  4. Distinguishing "signed by Microsoft" from "signed by Microsoft but not expected here"

None of those capabilities exist today in the standard Windows toolset. bcdedit /enum all shows boot entries. It does not show you what else lives on the ESP.


6. The SKCI Export Surface

skci.dll exports the following functions. This is the complete API surface that securekernel.exe (and potentially other components) consume:

Export Category Purpose
SkciInitialize Lifecycle Global CI/SI initialization. Version magic check, crypto init, policy load, catalog setup
SkciCreateSecureImage Image Allocate and populate image validation context from PE header mapping
SkciValidateImageData Image Stream image / page data into the pending validation context
SkciFinalizeSecureImageHash Image Finalize accumulated hash material for the image
SkciFinishImageValidation Image Policy, page hash, authorized-use, and attestation convergence point
SkciFreeImageContext Image Context teardown and memory release
SkciValidateDynamicCodePages Dynamic Page-hash validation for dynamically generated code (non-image-backed)
SkciDetermineAuthorizedPageUse Policy Derive page-use permission flags from signing trust chain
SkciQueryImageUniqueID Query Read unique image ID from validated context (ctx+0x148)
SkciQueryImageAuthorID Query Read author ID from validated context (ctx+0x168)
SkciQueryImageAttestationData Query Read attestation certificate thumbprint and program name
SkciQueryEnclavePackageID Query Read enclave package identifier (guarded by enclave trust bit)
SkciCalculateEnclaveIdentities Compute Compute unique ID and author ID for enclave context
SkciQueryInformation Query Query CI option summary (class 0x67) or serialized policies (class 0xF1)
SkciSetCodeIntegrityPolicy Policy Policy ingestion: deserialize (mode 0), revocation (mode 1), compact update (mode 2)
SkciCreateCodeCatalog Catalog Catalog context creation and registration
SkciDeleteCatalog Catalog Catalog teardown
SkciValidateCertChain Cert Certificate chain validation
SkciValidateAmeCertChain Cert AME (Azure Managed Environment) certificate chain validation
SkciMatchHotPatch Hotpatch Hotpatch identity matching against policy
SkciBuildAttestationReport Attestation Build policy-level attestation report from history
CipForceImageRevalidation Internal Set g_CiPolicyState bit 7 — confirmed producer; exact consumer not proven in this static pass

7. SkciInitialize — The ABI Contract

SkciInitialize is the entry point that securekernel.exe calls once, at VTL1 startup. The first thing it does is check a version magic:

skci!SkciInitialize:
    ; rcx = caller context block
    ; first field of that block = version DWORD
    cmp   dword ptr [rcx], 0A000012h
    jne   revision_mismatch

    ; ... initialization proceeds
    jmp   init_body

revision_mismatch:
    mov   eax, 0C0000059h    ; STATUS_REVISION_MISMATCH
    ret

If the caller does not present the exact magic 0x0A000012, initialization fails immediately with STATUS_REVISION_MISMATCH. No fallback, no version negotiation, no legacy path. The contract is intentionally rigid — the version must match the binary exactly. On a mismatched build, SKCI initialization would fail; whether the system then continues booting in a degraded state, halts, or BSODs depends on securekernel.exe's policy path for a failed SkciInitialize — that caller logic is outside the static scope of this binary.

The initialization sequence that follows:

flowchart TD A[SkciInitialize\ncaller passes 0x0A000012] --> B{version == 0x0A000012?} B -->|no| X[STATUS_REVISION_MISMATCH\n0xC0000059] B -->|yes| C[store g_CiOptions\nfrom boot context] C --> D[SkQuerySecureKernelInformation\nquery SK info needed for CI] D --> E[CiInitializePolicyFromPolicies\nload packaged boot policies] E --> F[g_CiDeveloperMode\nderive flight / test / weak-crypto flags] F --> G[HashpInitializeCrypto\nalgorithm table init] G --> H[CiInitializeParallelHashingContexts\nset up concurrent hash workers] H --> I[CiInitializeGlobalState\ninitialize push locks and state] I --> J[CiInitializeCatalogs\ncatalog registration] J --> K[CiUpdatePolicyHistory\nrecord initial policy in g_CiScenarios]

Step CiUpdatePolicyHistory is the first time g_CiScenarios+0x3B0 (the attestation history count) gets incremented. This matters for SkciBuildAttestationReport — see §12.


8. The Image Validation Pipeline

The main runtime loop that securekernel.exe drives through SKCI:

sequenceDiagram participant SK as securekernel.exe participant SKCI as skci.dll participant Crypto as CiHash / Page hash engine participant Policy as CI policy state SK->>SKCI: SkciCreateSecureImage(pe_header, size, flags) SKCI->>SKCI: CiValidateImageHeaderMapping SKCI->>SKCI: SkciCheckNtHeaderForCompliance SKCI->>SKCI: allocate 0x188-byte context SKCI->>Crypto: CiCalculateFirstPageHash SKCI-->>SK: image_context* SK->>SKCI: SkciValidateImageData(ctx, data_ptr, data_len) SKCI->>Crypto: accumulate page hashes SK->>SKCI: SkciFinalizeSecureImageHash(ctx) SKCI->>Crypto: finalize accumulated hash SK->>SKCI: SkciFinishImageValidation(ctx, signing_info, policy_info) SKCI->>Policy: CiValidateImagePages SKCI->>SKCI: SkciDetermineAuthorizedPageUse SKCI->>SKCI: SkciCalculateEnclaveIdentities SKCI->>SKCI: SkciCalculateImageAttestationData SKCI-->>SK: authorized_page_use_flags, status

8.1 The SKCI image context layout

SkciCreateSecureImage allocates a context of exactly 0x188 bytes. The analytically-derived layout from studying field accesses across multiple functions:

Offset Name (analytical) Size Notes
0x000 hash_algorithm 4 Hash algorithm ID used by the verifier
0x004 hash_length 4 Hash length in bytes
0x008 validated_page_hash_context 8 Pointer to finalized page-hash context
0x010 image_or_first_page_hash variable Hash of first page, or finalized image hash
0x0A0 image_context_lock 8 Push lock for concurrent access
0x0A8 page_hash_generation_context 8 Temporary context for ongoing hash generation
0x0E0 copied_image_buffer 8 Pointer to optional image copy
0x0E8 rebased_nt_headers 8 NT headers in the image copy
0x0F8 image_context_flags 4 Validation state flags
0x118 authorized_page_use_flags 4 Output of SkciDetermineAuthorizedPageUse. Gatekeeper for all Query* exports
0x11C attestation_thumbprint_algorithm 4 Set to 0x8004 when cert attestation present
0x120 attestation_certificate_thumbprint 20 Cert thumbprint bytes from SkciGetCertificateThumbprint
0x138 attestation_program_name 16 UNICODE_STRING from OPUS attribute, if present
0x148 image_unique_id 32 Computed by CiCalculateUniqueID
0x168 image_author_id 32 Computed by CiCalculateAuthorID

The field at 0x118authorized_page_use_flags — is the single most important output of the entire validation pipeline. It is checked by every SkciQuery* export before returning data:

; Pattern common to SkciQueryImageUniqueID, SkciQueryImageAuthorID, SkciQueryEnclavePackageID:
mov   eax, [rcx+118h]    ; load authorized_page_use_flags
test  al, 8              ; check bit 3 (enclave_trusted)
jne   authorized
mov   eax, 0C0000022h    ; STATUS_ACCESS_DENIED
ret

No authorization flag → no data. The gate is mandatory.

8.2 Page hash model

The page-hash record format uses a 36-byte (0x24) record:

record := uint32 FileOffset || uint8[32] SHA256PageHash

Validation reduces to:

for each requested_page:
    exists record R where R.FileOffset == page_file_offset
    and constant_time_compare(SHA256(page_bytes), R.SHA256PageHash) == equal
Invariant Meaning
Requested length must be 0x1000-aligned No half-pages in authorization decisions
Records are sorted by file offset Binary search is used for lookup (bsearch)
Each record covers exactly one 4KB page Granularity matches the page table
Overflow in record count computation → 0xC0000095 Integer overflow guarded explicitly
Missing record → STATUS_INVALID_IMAGE_HASH (0xC0000428) One known telemetry correlation point
Hash mismatch → STATUS_INVALID_IMAGE_HASH (0xC0000428) Same status for two different conditions

That last row is a minor diagnostic gap: a missing record and a mismatching hash both surface as 0xC0000428. An observer who sees this status code cannot immediately determine whether the image is unsigned (no record) or tampered (wrong hash). Distinguishing the two requires deeper inspection of the context state.


9. SkciDetermineAuthorizedPageUse — The Trust Decision Map

This is the function that converts signing trust information into page-use permission flags. It is the convergence point of everything that preceded it: Secure Boot policy, signing level, certificate chain, EKU table, and CI global options.

9.1 Input selection

The signing information arrives as an array of entries at [rdx+0x28], with a count at [rdx+0x20]. The function selects the highest-index valid entry:

eax = [rdx+0x20]           ; signing result count (0..4)
if eax != 0: eax = (eax-1) & 3
rsi = rdx+0x28 + eax*0xA0  ; select signing_info entry

Each entry is 0xA0 bytes. The selection of the last (highest-index) entry prefers the strongest available trust claim when multiple signing chains are present.

9.2 The page-use flag bitmap

The output is a 4-byte DWORD at ctx+0x118. Each bit encodes a specific trust grant:

Bit Hex Name (analytical) How it is set
0 0x01 IUM_execute CiIsSignatureTrustedForIum(rsi) returns true, OR input_flags & 5 non-zero, OR g_CiOptions bit 21 AND input bit 30
1 0x02 IUM_trusted CiIsSignatureTrustedForIum(rsi) confirmed (stronger IUM grant)
2 0x04 WHQL_level signing_level >= 0x0C OR EKU 1.3.6.1.4.1.311.76.8.1 present at level 0x08..0x0C
3 0x08 enclave_trusted CiIsSignatureTrustedForVsmEnclaves(rsi) returns true
4 0x10 whql_bio CiIsSignatureWhqlBio(rsi) returns true (WHQL Bio-specific)
5..9 0xE0..0x1E0 signing_level<<5 Raw signing level encoded in upper bits

The base value for these bits is established before any condition check: (signing_level & 0xF) << 5. This means the raw signing level is always recorded in the flags, regardless of which conditional grants succeed.

flowchart TD A[SkciDetermineAuthorizedPageUse] --> B[select highest-index signing_info entry] B --> C[base = signing_level AND 0xF SHL 5] C --> D{CiIsSignatureTrustedForIum?} D -->|yes| E[set bit 0 and bit 1\nIUM_execute and IUM_trusted] E --> F{CiIsSignatureWhqlBio?} F -->|yes| G[set bit 4\nwhql_bio] D -->|no| H{input_flags AND 5 nonzero?} H -->|yes| I[set bit 0\nIUM_execute fallback] H -->|no| J{g_CiOptions bit 21\nAND input bit 30?} J -->|yes| I G --> K{CiIsSignatureTrustedForVsmEnclaves?} I --> K K -->|yes| L[set bit 3\nenclave_trusted] K -->|no| M{bit 0 set?} L --> M M -->|yes| N{signing_level ge 0x0C?} N -->|yes| O[set bit 2\nWHQL_level] N -->|no| P{level 0x08 to 0x0B\nAND WHQL-Ext EKU present?} P -->|yes| O P -->|no| Q[bit 2 remains 0] O --> R[write authorized_page_use_flags to ctx+0x118] Q --> R

9.3 The EKU fallback path (Finding 6)

The most non-obvious branch in this function concerns signing levels between 0x08 and 0x0C. For an image with signing_level >= 0x0C (full WHQL level), bit 2 (WHQL_level) is set unconditionally when bit 0 is set. For levels 0x08 through 0x0B, there is a fallback: if the EKU 1.3.6.1.4.1.311.76.8.1 is present in the signing chain, bit 2 is still granted.

1.3.6.1.4.1.311.76.8.1 is szOID_WINDOWS_HARDWARE_DRIVER_EXTENDED_VERIFICATION — the Windows Hardware Quality Labs extended verification OID. Its presence is checked via MincryptIsEKUInPolicy. This means a driver signed at level 0x08 (WHQL submission level) can obtain WHQL_level trust if the extended verification EKU was issued as part of its certificate. The signing level alone does not tell you whether WHQL_level is set; you also need to check the EKU.


10. Finding 1: SkciValidateDynamicCodePages — Success Is 0x12C

This was the first thing that stopped me cold. The function is small enough to read in one pass:

skci!SkciValidateDynamicCodePages:
    sub   rsp, 48h
    mov   eax, 20h           ; hash_length = 0x20 (SHA-256)
    shr   r9, 5              ; page_hash_count = caller_byte_length >> 5
    ; [stack spills of arguments]
    mov   r8d, 800Eh         ; algorithm = 0x800E
    call  CiValidateFullImagePages
    test  eax, eax
    mov   ecx, 12Ch          ; preload 0x12C
    cmovns eax, ecx          ; if eax >= 0 (no sign bit): replace with 0x12C
    ret

cmovns is "conditional move if not signed" — move if the sign flag is clear. Since test eax, eax sets the sign flag when eax < 0 (negative, i.e. error status), the cmovns fires precisely when CiValidateFullImagePages returned a non-negative status, which in NTSTATUS terms means success or an informational code.

The success return value is 0x12C, not STATUS_SUCCESS (0x00000000).

NT_SUCCESS(0x12C) is TRUE — bit 31 is clear, so it passes the canonical success check. But status == 0 is false. Any caller that checks status == 0 as its success condition gets a false negative on a successfully validated dynamic code page set.

The algorithm code 0x800E is distinct from the image-backed page hash algorithm 0x800C. They are not interchangeable — the algorithm index routes to different hash-algorithm table entries inside CiValidateFullImagePages. Dynamic code pages and image-backed pages go through different validation paths even when both use SHA-256.

The r9 >> 5 conversion deserves a note: the input r9 is a byte count of the hash buffer, not a page count. Each hash record is 0x20 bytes (SHA-256 length). So byte_count >> 5 = byte_count / 32 = record count. The page count must match exactly or the validation fails before hashing begins.

What this means for callers

Caller correlation confirms securekernel.exe handles this correctly. In securekernel!SkmiValidateDynamicCodePages:

call    qword ptr [securekernel!_imp_SkciValidateDynamicCodePages]
test    eax, eax
js      error_path

The caller uses NT sign-bit testing, not equality-to-zero. 0x12C is non-negative, so js does not branch — the call is treated as success. This is a valid internal ABI contract. The risk is not a Microsoft caller bug; the risk is for analysts or reimplementations that assume success means status == 0 only. A correctly-written external consumer must check NT_SUCCESS(status) or compare against 0x12C explicitly. The public documentation for this function does not exist (it is not documented by Microsoft at the time of writing). Without the symbol information that the public PDB provides, a researcher reading the binary would see cmovns ecx, 12Ch and might spend time wondering whether 0x12C is an error variant rather than the primary success path.


11. Finding 2: SkciQueryEnclavePackageID — Authorized but Empty

skci!SkciQueryEnclavePackageID:
    mov   eax, [rcx+118h]       ; load authorized_page_use_flags from context
    test  al, 8                 ; bit 3 = enclave_trusted
    jne   authorized

    mov   eax, 0C0000022h       ; STATUS_ACCESS_DENIED
    ret

authorized:
    mov   byte ptr [rdx], 0     ; zero first output byte
    xorps xmm0, xmm0
    movups [r8], xmm0           ; zero bytes 0..15 of output buffer
    xor   eax, eax
    movups [r8+10h], xmm0       ; zero bytes 16..31
    mov   [r8+20h], rax         ; zero bytes 32..39   (total: 40 bytes)
    ; eax is already 0 = STATUS_SUCCESS
    ret

The authorization check is real: if ctx->authorized_page_use_flags & 0x08 is zero, the function returns STATUS_ACCESS_DENIED. The enclave trust bit must have been set by SkciDetermineAuthorizedPageUse — which requires CiIsSignatureTrustedForVsmEnclaves to have returned true — or the gate closes.

When the gate opens: 40 bytes of zeros, STATUS_SUCCESS.

No package ID is computed. No call to CiCalculateUniqueID or CiCalculateAuthorID here.

The actual enclave identities — unique ID and author ID — are computed by SkciCalculateEnclaveIdentities and stored at:

  • ctx+0x148: image_unique_id (32 bytes, via CiCalculateUniqueID)
  • ctx+0x168: image_author_id (32 bytes, via CiCalculateAuthorID)

These are read back by SkciQueryImageUniqueID and SkciQueryImageAuthorID, not by SkciQueryEnclavePackageID. The three query functions are separate exports that address different parts of the context.

Negative finding or stub?

Two interpretations:

  1. Stub not yet implemented. The function has the gate logic of a complete API but returns a zeroed buffer where a package identifier should be. This is consistent with an in-progress feature where the authorization model was built before the identity computation was wired up.

  2. Intentional. The package ID in the enclave sense may be a compound derived from policy state rather than a per-image computation. In that interpretation the caller gets STATUS_SUCCESS and zeros as a "not applicable in this context" signal, and the real enclave identification goes through SkciQueryImageUniqueID / SkciQueryImageAuthorID.

I cannot resolve this from static analysis alone. What I can state as fact: in build 10.0.28000.2336, the 40-byte output buffer is always zeroed when this function succeeds. A caller who reads from it gets nothing.


12. Finding 3: SkciQueryInformation — Two Classes, Rigid Protocol

skci!SkciQueryInformation:
    ; [prologue and argument save]
    SkeEnterCriticalRegion
    SkAcquirePushLockShared(g_CipPolicyLock)

    cmp   esi, 67h              ; class 0x67?
    je    class_67
    cmp   esi, 0F1h             ; class 0xF1?
    je    class_f1

    mov   ebx, 0C0000003h       ; STATUS_INVALID_INFO_CLASS
    jmp   exit

Only two branches. Everything else — any class value except 0x67 and 0xF1 — falls through to STATUS_INVALID_INFO_CLASS (0xC0000003). There is no default that logs the unknown class or routes to a stub. The lock is acquired, the class is checked, the lock is released, and the caller gets an error. The surface is intentionally minimal.

Class 0x67 — CI options summary

The protocol for class 0x67 is unusually rigid:

Preconditions (ALL must hold):
    input_buffer_length == 8        (exact, not >=)
    [input_buffer + 0x00] == 8      (caller must pre-fill magic DWORD)
    output_buffer_length == 8       (exact)

If any of these fail: STATUS_INFO_LENGTH_MISMATCH (0xC0000004).

When all pass, SKCI builds an 8-byte response:

Output bytes Source Meaning
[output+0x00] Echoed from input [input+0x00] = 8 Magic roundtrip (protocol confirmation)
[output+0x04] bit 0 g_CiOptions & 2 CI enforcement active
[output+0x04] bit 1 g_CiOptions & 2 AND g_CiOptions & 8 Both CI flags set
[output+0x04] bit 9 g_CiDeveloperMode & 0x40 Developer/flight mode
[output+0x04] bit 14 CipWhqlEnforcementEnabled() WHQL enforcement active
[output+0x04] bit 15 CipWhqlEnforcementEnabled() inner result WHQL enforcement sub-condition

g_CiOptions has many more bits internally. The API exposes only 5 derived flags. The pre-fill requirement ([input+0x00] == 8) acts as a simple protocol versioning token — a caller that does not know the protocol sends a wrong magic and gets STATUS_INFO_LENGTH_MISMATCH instead of garbage output.

Class 0xF1 — serialized policies

Standard query-size-then-copy pattern:

size = SIPolicyGetSerializedPoliciesSize()
if (input_buffer_length < size):
    *return_length = size
    return STATUS_INFO_LENGTH_MISMATCH
if (size != 0):
    SIPolicyGetSerializedPolicies(output_buffer, size)
*return_length = size
return STATUS_SUCCESS

No protocol magic, no exact-size requirement. The caller passes a buffer, gets the serialized policies or a required-size hint.

Caller path: IumpReadCodeIntegrity

Caller correlation shows SkciQueryInformation is not dead code. It is called from securekernel!IumpReadCodeIntegrity using a canonical two-pass sizing pattern:

Step Behavior
1 Call SkciQueryInformation with zero output length to query required size
2 Accept STATUS_INFO_LENGTH_MISMATCH as sizing response
3 For small outputs (class 0x67): use stack storage
4 For larger serialized policy data (class 0xF1): allocate pool
5 Call SkciQueryInformation again and copy output to caller

This places the two-class surface in a secure-service read path. The two classes are not arbitrary — class 0x67 is the small state summary, class 0xF1 is the heavier serialized policy channel for consumers that need the full policy set.

The diagnostic gap

What SkciQueryInformation does not expose:

Missing class What a defender would want
Page hash validation state Did the last image load pass or fail page hash coverage?
Signing level of last validated image What level was granted?
Catalog source Was the hash found in an embedded catalog, a file catalog, or generated page hashes?
Attestation report excerpt What does the current policy state attest to?
Image validation failure detail Why did a specific image fail — missing record, hash mismatch, policy denial, or unauthorized page use?

These are exactly the questions an incident responder wants to answer when investigating a code-integrity event. The current API provides a two-class aperture: a compressed CI options summary and serialized policies. That is sufficient for routine monitoring and policy audit. It is not sufficient for detailed root-cause investigation of a code-integrity failure.


13. Finding 4: SkciBuildAttestationReport — BugCheck on Empty History

This is the finding I find most interesting from a reliability engineering perspective.

skci!SkciBuildAttestationReport:
    cmp   cx, 1              ; version check
    je    version_ok
    mov   eax, 0C00000EFh   ; STATUS_NOT_IMPLEMENTED (version != 1)
    ret

version_ok:
    SkeEnterCriticalRegion
    SkAcquirePushLockShared(g_CipPolicyLock)

    mov   ecx, [g_CiScenarios+3B0h]   ; attestation history count
    cmp   ecx, 1
    jae   count_ok                    ; count >= 1: proceed

    ; count is 0. Check for non-zero anyway (unreachable for a DWORD):
    test  ecx, ecx
    jne   nonzero_path

    ; BugCheck:
    xor   r9d, r9d
    mov   qword ptr [rsp+20h], 0
    mov   edx, 74614943h              ; ASCII "CiAt" as little-endian immediate
    lea   ecx, [0+29h]               ; BugCheckCode = 0x29 (SECURITY_SYSTEM)
    mov   r8, 0FFFFFFFFC000000Dh     ; 0xC000000D = STATUS_INVALID_PARAMETER
    call  KeBugCheckEx
    int   3
BugCheck Parameter Value Meaning
Code 0x29 SECURITY_SYSTEM
P1 0x29 (same as code, by convention for SECURITY_SYSTEM)
P2 0x74614943 ASCII "CiAt" in little-endian — the CI attestation tag
P3 0xC000000D STATUS_INVALID_PARAMETER — the proximate cause
P4 0x0

Zero attestation history is not handled as an API error. It is treated as a kernel invariant violation. The reasoning is: if SkciBuildAttestationReport is being called, the system must have been initialized. SkciInitialize calls CiUpdatePolicyHistory as its last step. So if the history count is zero when SkciBuildAttestationReport is reached, either initialization did not complete, or the history counter was corrupted. Either way, continuing execution would produce an attestation report whose fundamental premise — "this policy state has a recorded history" — is false. A BSOD is the correct response.

The rest of the function

When count >= 1, the function builds the attestation report from g_CiScenarios:

Field accessed Offset in g_CiScenarios Content
State +0x390 State pointer
Reference time +0x398 ShvlGetReferenceTime result
Report pointer +0x3A0 Pointer to built report payload
Report size +0x3A8 Report payload size
History count +0x3B0 Count of recorded scenarios

Buffer-size query pattern (for callers who need the size before allocating):

if (buffer == NULL || buffer_size < required):
    neg   rsi
    sbb   eax, eax
    and   eax, 0xC0000023    ; STATUS_BUFFER_TOO_SMALL if buffer was non-NULL
    ret

The version field (cx == 1) exists because the attestation report format is versioned. A future version of the report structure would use a different version byte and would be handled by a separate code path (which does not yet exist in this build — version != 1 returns STATUS_NOT_IMPLEMENTED).

Attestation architecture: two layers

SKCI implements two distinct attestation layers that are often confused:

Layer Functions What it attests
Image-level SkciCalculateImageAttestationData, SkciQueryImageAttestationData Certificate thumbprint and OPUS program name for a specific validated image
Policy / state-level SkciBuildAttestationReport, CiGenerateAttestationReport, CipAddAttestationReportToHistory CI policy state, revocation list hash, blocklist update time, policy scenario history

CiGenerateAttestationReport builds the policy-level report with this header format:

Report offset Content Notes
0x00 1 Report version
0x04 0x3A Header size
0x08 0x50013 Type identifier (observed, no public name)
0x0C g_CiBlocklistUpdateTime Blocklist update timestamp
0x14 0x20 Hash length
0x18 0x0B Hash algorithm identifier (observed, no public name)
0x1A Revocation list attestation hash 32 bytes (or length matching the algorithm)
0x3A+ Serialized policy attestation payload SIPolicyGetSerializedPolicies output

Pool allocation tag for the report buffer: 0x74614943 — the same "CiAt" tag that appears in the BugCheck parameter.

Caller path: SkpBuildAuthenticatedRuntimeReports

Caller correlation shows SkciBuildAttestationReport is called from securekernel!SkpBuildAuthenticatedRuntimeReports, which builds a broader authenticated runtime report bundle:

Report bit Caller behavior
bit 0 Build NT drivers report
bit 1 Call SkciBuildAttestationReport(1, size, buffer, &size)

The caller handles buffer-sizing and aggregates report sizes across multiple report types. This means the SKCI policy attestation is not a standalone blob — it is one component of a wider authenticated runtime report that securekernel.exe composes. The bugcheck-on-empty-history invariant therefore protects a report that feeds into that larger bundle.


14. Finding 5: CipForceImageRevalidation — Two Instructions

skci!CipForceImageRevalidation:
    bts   dword ptr [skci!g_CiPolicyState], 7   ; atomically set bit 7 = 0x80
    ret

That is the complete function. One memory operation, one return. It sets bit 7 of g_CiPolicyState as a revalidation signal.

The bit is not read anywhere inside skci.dll — the static analysis confirms no internal consumer of bit 7. This pass confirms CipForceImageRevalidation as the producer of bit 7; the exact consumer in securekernel.exe was not proven in this static pass. The architectural implication is that SKCI raises the signal and the secure kernel is the expected consumer, but the precise reader and the action it takes (re-requesting validation, updating trust decisions) would require a live data watchpoint on g_CiPolicyState bit 7 during a policy refresh to confirm. That is a recommended next step if this signal's consumer semantics matter for defensive tooling.

When it is called

Inside CipRefreshPolicies (called by SkciSetCodeIntegrityPolicy mode 0):

test  dword ptr [rbp-68h], 1000000h   ; check incoming policy option bit 24
je    call_force                       ; bit 24 clear → always force revalidation
test  sil, sil                         ; bit 24 set → check secondary flag
jne   skip_force                       ; secondary flag set → skip
call_force:
    call CipForceImageRevalidation

So the revalidation signal fires unless the incoming policy explicitly carries bit 24 AND a secondary flag is non-zero. The default behavior — no special option — triggers revalidation on every policy push. This means every time a new WDAC policy is applied, any image that was loaded under the old policy has its trust status pending re-evaluation.

g_CiPolicyState bit map

From CipUpdateCiSettingsFromPolicies and related functions:

Bit Hex Operation Meaning
7 0x80 bts (set, CipForceImageRevalidation) Revalidation-pending signal — producer confirmed; consumer outside SKCI static scope
8 0x100 bts (set, CipUpdateCiSettingsFromPolicies) CI policy option
9 0x200 bts (set, CipUpdateCiSettingsFromPolicies) CI policy option
11 0x800 btr (clear, CipUpdateCiSettingsFromPolicies) CI policy option
12 0x1000 btr (clear, CipUpdateCiSettingsFromPolicies) CI policy option
22 0x400000 btr (clear, SkciFinishImageValidation) consumed via shr eax, 0x16 — controls hash / page-hash path
init 0x1800 (bits 11+12) Set at start of CipUpdateCiSettingsFromPolicies Default state before per-policy derivation

Bit 22 in SkciFinishImageValidation: shr [g_CiPolicyState], 0x16 makes bit 22 become bit 0 of the shifted result. That bit then controls a branch in the image validation path, selecting between the pure-hash and the page-hash validation strategies. The precise semantics require a live trace to confirm, but the observation establishes that g_CiPolicyState encodes not just policy state but also runtime validation strategy selection.


15. Finding 7: SkciSetCodeIntegrityPolicy — Mode Map

Mode (ecx) Size requirement Action
0 Any CiDeserializePolicyFileData then CipRefreshPolicies (full policy replace)
1 Non-zero buffer CiValidateAndSetRevocationList
2 Must be exactly 5 Write 4+1 bytes to g_CiScenarios+0x3B4/0x3B8 under exclusive lock
Anything else STATUS_NOT_SUPPORTED (0xC00000BB)

Mode 2 with size != 5 returns STATUS_INVALID_PARAMETER (0xC000000D). The 5-byte payload goes into two fields of g_CiScenarios in a single exclusive-lock section — making it a compact atomic scenario state update. The specific semantics of those 5 bytes (4+1) require correlating with the CI scenario enum, which is not fully reconstructed in this analysis.

Mode 0 — full policy refresh

The main policy refresh path (CipRefreshPolicies) is the most involved:

SkciSetCodeIntegrityPolicy(mode=0)
    → CiDeserializePolicyFileData
    → CipRefreshPolicies
        → SIPolicyStateAddPolicy / SIPolicyStateRemovePolicy
        → SIPolicyStateFinalize
        → SIPolicyStatePreparePolicyHashSHA256
        → CiUpdatePolicyHistory         ← increments g_CiScenarios+0x3B0
        → SIPolicyCommitState
        → update g_SiPolicyHash
        → CipUpdateCiSettingsFromPolicies
        → maybe CipForceImageRevalidation  ← sets g_CiPolicyState bit 7

SKCI treats policy change as a generation event: it finalizes a new state, hashes it, commits it, updates the attestation history, and may signal image revalidation. Policy is not applied piecemeal — it is replaced atomically.


16. Hotpatch Matching

SkciMatchHotPatch is the final identity comparator in a broader hotpatch match sequence driven by securekernel!SkmiMatchPatchImage. It is not the sole authorizer — SkmiMatchPatchImage performs record field checks and base machine metadata matching before reaching SKCI. SkciMatchHotPatch itself compares an identity hash from the caller against a blob field, using one of two algorithm-length combinations.

if alg == 0x8004 and len == 0x14:
    compare input_hash against hotpatch_blob + 0x20
elif alg == 0x800C and len == 0x20:
    compare input_hash against hotpatch_blob + 0x00
else:
    return false
Algorithm Length Blob offset Interpretation
0x8004 20 bytes +0x20 20-byte thumbprint-style identifier
0x800C 32 bytes +0x00 SHA-256 hash at the start of the blob

The helper CiHotpatchGetSequenceNumberFromHotpatchInfo requires the blob to be at least 0x0C bytes and reads the sequence number from +0x08. The helper SIPolicyDoesHotpatchExistForTarget uses NtManageHotPatch with class 0x0B and a 20-byte (0x14) output buffer.

The policy question for hotpatch is framed differently than for image loading. Rather than "is this image signed?", the question is "does the policy know this target, and does the patch identity match what the policy recorded?" The sequence number in the blob is read by CiHotpatchGetSequenceNumberFromHotpatchInfo; it likely participates in ordering or versioning, but the enforcement semantics require correlating with the caller in securekernel.exe, which is outside the static scope of this binary.


17. SkciInitialize — Version Magic and Initialization

One detail from SkciInitialize that belongs in the findings list even though it was the first thing I disassembled: the version magic 0x0A000012.

The check is:

cmp   dword ptr [rcx], 0A000012h
jne   revision_mismatch

This is a strict ABI contract between securekernel.exe and skci.dll. There is no tolerance, no negotiation. The 0x0A prefix is consistent with encoding a major version; the 0x000012 suffix appears to be the minor/patch component. If Microsoft ships a new skci.dll that uses a different magic, any securekernel.exe built against the old ABI will fail SKCI initialization. The final boot behavior then depends on the caller's policy path and fail-open/fail-closed decision for that boot configuration — which is why this check exists: it is better to fail a protected path explicitly than to operate in a partially initialized state that gives false assurance.


18. The Full Trust Decision Model

Synthesizing everything above, the authorization decision for a page can be framed as:

AllowedExecute(image, page, use) :=
    SecureBootState.valid                              [firmware gate]
    AND PolicyGeneration.current                       [SKCI policy state]
    AND ImageHeader.valid                              [SkciCheckNtHeaderForCompliance]
    AND CiValidateImageHeaderMapping.passed            [header mapping gate]
    AND PageHash(page).valid                           [page hash coverage]
    AND SigningLevel(image) >= RequiredLevel(use)      [signing level gate]
    AND AuthorizedPageUse(image, use) != Deny          [SkciDetermineAuthorizedPageUse]
    AND Policy.allows(image, use)                      [WDAC / SI policy check]

Each component is enforced at a different stage:

flowchart LR subgraph Firmware SB[Secure Boot\nvalidates bootmgfw.efi] end subgraph BootManager BM[bootmgfw.efi\npackages policy for kernel] end subgraph OSLoader WL[winload.efi\nVSM policy check\ncatalog registration] end subgraph SecureKernel SK[securekernel.exe\nVTL1 OS services] end subgraph SKCI CTX[SkciCreateSecureImage\nheader + page hash context] VAL[SkciFinishImageValidation\npage hash coverage] AUTH[SkciDetermineAuthorizedPageUse\nsigning level + EKU + policy bits] ATTEST[SkciCalculateImageAttestationData\ncert thumbprint + OPUS] end SB --> BM --> WL --> SK --> CTX --> VAL --> AUTH --> ATTEST

The mathematical framing is intentionally a defensive abstraction, not an implementation bypass map. Its value is in showing which inputs feed which decisions — useful for writing test cases, for building diagnostic tooling, and for understanding which layer a code-integrity failure implicates.


19. Status Code Diagnostic Map

A full table of NTSTATUS values observed in SKCI and their structural meaning:

Status Hex Source function(s) Meaning
STATUS_SUCCESS 0x00000000 Most functions Normal success
0x12C 0x0000012C SkciValidateDynamicCodePages Dynamic page success — not STATUS_SUCCESS
STATUS_INFO_LENGTH_MISMATCH 0xC0000004 SkciQueryInformation Buffer size wrong or magic mismatch
STATUS_INVALID_INFO_CLASS 0xC0000003 SkciQueryInformation Class not 0x67 or 0xF1
STATUS_INVALID_IMAGE_HASH 0xC0000428 Page hash validation Hash missing or mismatch
STATUS_INTEGER_OVERFLOW 0xC0000095 Context size arithmetic Record count × size overflow
STATUS_OBJECT_NAME_NOT_FOUND 0xC0000225 SkciQueryImageAttestationData ctx+0x11C == 0 — no attestation thumbprint
STATUS_ACCESS_DENIED 0xC0000022 SkciQueryEnclavePackageID enclave_trusted bit not set
STATUS_NOT_IMPLEMENTED 0xC00000EF SkciBuildAttestationReport Version != 1
STATUS_NOT_SUPPORTED 0xC00000BB SkciSetCodeIntegrityPolicy Unknown mode
STATUS_BUFFER_TOO_SMALL 0xC0000023 SkciBuildAttestationReport Buffer null or too small for report
STATUS_INVALID_PARAMETER 0xC000000D Multiple Various protocol violations
STATUS_REVISION_MISMATCH 0xC0000059 SkciInitialize ABI version != 0x0A000012
BSOD 0x29 SECURITY_SYSTEM SkciBuildAttestationReport g_CiScenarios+0x3B0 == 0 — invariant violation
0x80430006 0x80430006 Boot-phase VSM Secure Boot not active
0xC0450000 0xC0450000 Boot-phase VSM VSM not initialized
0xC0450001 0xC0450001 Boot-phase VSM No DMA protection from IOMMU
0xC0430001 0xC0430001 Secure Boot policy Rollback of protected data detected
0xC043000E 0xC043000E Boot Manager Required Secure Boot policy not found

Two observations from this table:

  1. STATUS_INVALID_IMAGE_HASH (0xC0000428) covers both "record not found" and "record found but hash mismatch". From the outside these look identical. Distinguishing them requires either a debugger or SKCI-level diagnostic telemetry that does not currently exist in the public API.

  2. The BSOD on zero attestation history is the only kernel-stop condition in the export surface. Everything else recovers gracefully. This is consistent with Microsoft's design intent: attestation integrity is a hard invariant; everything else is a soft policy decision that can be retried or reported.


20. What Is Missing From the Diagnostic Surface

The most practically useful output of this analysis, for a defender or an enterprise security architect:

Missing capability Current state Impact
Per-image CI failure detail STATUS_INVALID_IMAGE_HASH only; no sub-code Cannot determine whether failure is missing record, hash mismatch, policy deny, or unauthorized page use — requires kernel debugger
Signing level query Not exposed through any public API Cannot verify what signing level was granted to a specific loaded image without debugging the SKCI context
ESP baseline comparison No OS-supported mechanism Cannot alert on unexpected EFI executables or version-vs-hash mismatches without writing custom tooling
BCD-to-ESP correlation bcdedit /enum all shows boot entries; no cross-reference to what's on the partition Unknown EFI executables on ESP are invisible to standard audit tooling
Attestation clarity No distinction between "no attestation data" (cert absent) and "attestation failed" STATUS_OBJECT_NAME_NOT_FOUND is ambiguous
WDAC policy source SkciQueryInformation class 0xF1 returns serialized policies — correct, but large and unstructured for telemetry Enterprise SIEM cannot directly index policy IDs from this output
Dynamic code audit SkciValidateDynamicCodePages returns 0x12C; no event that says "dynamic code was allowed at this address" Difficult to audit dynamic code authorization from outside VTL1

21. Recommendations

These are addressed to Microsoft, to vendors building security tooling, and to enterprise security architects who own VBS/HVCI deployments.

For Microsoft

  1. Document SkciValidateDynamicCodePages' success code. 0x12C as a success value is correct internally, but it is invisible to external callers without the PDB. At minimum, document it in the SKCI API documentation or in the Windows Internals series.

  2. Extend SkciQueryInformation with additional information classes. At minimum: signing level of last validated image, cause of last page hash failure (missing vs mismatch), policy ID that authorized the last image. These would allow WDAC incident response without requiring a kernel debugger.

  3. Ship a first-class ESP audit tool. A supported PowerShell cmdlet that enumerates EFI executables with SHA-256, signing status, and BCD correlation would eliminate a significant blind spot in the Windows security baseline.

  4. Clarify attestation semantics. SkciQueryImageAttestationData returning STATUS_OBJECT_NAME_NOT_FOUND when ctx+0x11C == 0 is semantically ambiguous. A dedicated "no attestation present" status code or a documented field that distinguishes "not applicable" from "failed" would improve incident diagnosis.

  5. Consider a log event for CipForceImageRevalidation. Currently there is no observable event when a policy push triggers image revalidation. Enterprise telemetry cannot see when previously-loaded images are queued for re-evaluation.

For security tooling vendors

  1. Record SHA-256, not just version, for all EFI executables in your baseline. The bootmgfw.efi finding in §5.1 is a direct demonstration that two files with the same version and valid signatures can differ in content.

  2. Treat STATUS_INVALID_IMAGE_HASH as requiring investigation, not just logging. The status is returned for both missing records and hash mismatches — two very different conditions from an incident response perspective.

  3. Add ESP monitoring to endpoint agents. If you monitor files in C:\Windows\System32, you should also monitor the EFI System Partition. The ESP is smaller and changes less frequently; anomaly detection is tractable.

For enterprise architects

  1. Use SkciQueryInformation class 0x67 as a sanity check, not a definitive audit. The 5 derived bits it returns confirm HVCI/WHQL state at a high level but do not replace a full policy review.

  2. Document your expected ESP contents. Run the inventory once on a known-good baseline, record SHA-256 for every EFI executable, and alert on deviations. The cost is low; the visibility improvement is large.


22. The Privileged Instrument Problem

I want to end with a structural observation about this kind of analysis.

The public PDB for skci.dll is the instrument that made this write-up possible at its current depth. Without it, I would have had to derive all function names analytically — a process that introduces naming ambiguity and inference gaps. With it, the findings I report here are grounded in Microsoft's own naming decisions, which are more reliable than my interpretations.

This is a fundamentally asymmetric situation. Microsoft publishes public PDBs for specific components and not others. The choice of which symbols to publish is a policy decision with real consequences for the security research ecosystem: public symbols lower the barrier for defensive analysis, but they also lower the barrier for offensive analysis. The SKCI symbols are here not because the module is deliberately open — it is not — but because debugging the VTL1 boot path requires symbolic resolution, and public debuggers are a legitimate tool.

I am not arguing that Microsoft should publish more symbols. I am arguing that the existence of public symbols for skci.dll does not make the module "known" in any meaningful sense. The symbols give you names. The semantics, the invariants, the non-obvious contracts — those require the kind of analysis recorded here. And as the SkciValidateDynamicCodePages finding shows: even with correct function names and correct disassembly, the non-obvious detail (0x12C as success, not 0x00) is invisible until you read the assembly closely enough to notice the cmovns.

The lesson I take from this: public symbols are a starting point, not a finish line. The research work is in understanding why the code is written the way it is — and in being honest about what that understanding does and does not cover.


Appendix A: Full SKCI Export List with Classification

Export Category Public PDB name Notes
SkciInitialize Lifecycle Yes ABI magic 0x0A000012
SkciCreateSecureImage Image Yes Allocates 0x188-byte context
SkciValidateImageData Image Yes Streaming input
SkciFinalizeSecureImageHash Image Yes Hash finalization
SkciFinishImageValidation Image Yes Policy + page-use convergence
SkciFreeImageContext Image Yes Teardown
SkciValidateDynamicCodePages Dynamic Yes Success = 0x12C, algorithm 0x800E
SkciDetermineAuthorizedPageUse Policy Yes Maps trust chain → page-use flags
SkciQueryImageUniqueID Query Yes Reads ctx+0x148, guarded by ctx+0x118 & 0x08
SkciQueryImageAuthorID Query Yes Reads ctx+0x168, guarded by ctx+0x118 & 0x08
SkciQueryImageAttestationData Query Yes STATUS_OBJECT_NAME_NOT_FOUND if ctx+0x11C == 0
SkciQueryEnclavePackageID Query Yes Authorized = all-zero output
SkciCalculateEnclaveIdentities Compute Yes Calls CiCalculateUniqueID and CiCalculateAuthorID
SkciQueryInformation Query Yes Only classes 0x67 and 0xF1
SkciSetCodeIntegrityPolicy Policy Yes Modes 0/1/2 only; mode 2 requires exactly 5 bytes
SkciCreateCodeCatalog Catalog Yes
SkciDeleteCatalog Catalog Yes
SkciValidateCertChain Cert Yes
SkciValidateAmeCertChain Cert Yes AME-specific chain
SkciMatchHotPatch Hotpatch Yes Algorithms 0x8004 / 0x800C
SkciBuildAttestationReport Attestation Yes BugCheck 0x29 on zero history
CipForceImageRevalidation Internal Yes Two instructions: bts [g_CiPolicyState], 7; ret

Appendix B: Key Global Variables

Variable Role
g_CiOptions CI enforcement mode bits. Class 0x67 exposes 5 derived bits
g_CiDeveloperMode Developer/flight/weak-crypto mode flags. Bit 0x40 exposed through class 0x67
g_CiPolicyState Runtime policy and revalidation state. Bit 7 = revalidation pending
g_CipPolicyLock Push lock protecting policy state reads/writes
g_CiScenarios Attestation scenario history array. +0x3B0 = history count
g_SiPolicyHash SHA-256 hash of the current active SI policy state
g_CiBlocklistUpdateTime Timestamp of last blocklist update, included in attestation report
g_EnclaveEncodedOid Encoded OID for enclave trust path in CiIsSignatureTrustedForVsmEnclaves

Appendix C: authorized_page_use_flags Reference

Bit Hex Analytical name Grant condition
0 0x01 IUM_execute IUM trust confirmed, or input_flags & 5, or CI global option + image flag
1 0x02 IUM_trusted CiIsSignatureTrustedForIum full confirmation
2 0x04 WHQL_level signing_level >= 0x0C OR EKU 1.3.6.1.4.1.311.76.8.1 at level 0x08..0x0B
3 0x08 enclave_trusted CiIsSignatureTrustedForVsmEnclaves returns true
4 0x10 whql_bio CiIsSignatureWhqlBio returns true
5–9 0x20..0x1E0 signing_level_field (signing_level & 0x0F) << 5 — always recorded
10+ (unobserved) Not seen in this build

Functions that gate on specific bits:

Bit tested Functions that gate on it
0x08 (enclave_trusted) SkciQueryImageUniqueID, SkciQueryImageAuthorID, SkciQueryEnclavePackageID
Any SkciDetermineAuthorizedPageUse (writer)


Appendix D: The Page Hash Engine in Detail

The page hash model is central to how SKCI enforces executable page authorization, so it deserves a deeper treatment than the high-level description in §8.2.

D.0 Why page hashes, not just file hashes?

A file hash covers the entire PE image at rest. A page hash covers each 4KB page individually. The distinction matters for kernel images because the kernel's executable section and its data section are mapped into different virtual ranges at different times. A file hash cannot detect a scenario where the file is correct but a specific page has been modified after mapping. Page hashes close this gap: each 4KB chunk of the executable is independently authenticated.

The tradeoff: page hash records are larger (one 36-byte record per page, vs one 32-byte hash per file) and require pre-computation or catalog storage. On a kernel image with a 512KB .text section, that is 128 page hash records just for that section. This is why SKCI's page hash context includes a binary search (bsearch) over records sorted by file offset — linear scan would be too slow for large images.

D.0.1 Two page-hash paths

SKCI handles two distinct scenarios for page hashes:

Path How records are obtained Typical user
Catalog / embedded signature PE Authenticode signature contains a page hash table, or a catalog file provides one System components, WHQL-signed drivers
Generated page hashes SKCI computes page hashes at load time from the PE sections JIT-compiled code, dynamically generated images

The generated-hash path is the SkciValidateDynamicCodePages path (§10). The catalog/signature path is the SkciFinishImageValidation path via CiValidateImagePages. Both paths ultimately validate a SHA-256 hash per 4KB page. They differ in where the expected hashes come from.

D.0.2 Binary search and the sort requirement

CiValidateImagePages uses binary search over the page-hash record array. This requires the array to be sorted by file offset. The sort happens during context creation:

CiCreateVerificationContextForImageGeneratedPageHashes:
    → allocate record array (n * 0x24 bytes)
    → for each section in section table (sorted by VirtualAddress):
        → for each 4KB page within section:
            → compute file offset
            → compute SHA-256(page_bytes)
            → store (file_offset, sha256) as 0x24-byte record
    → sort record array by file_offset (ascending)

The sort is a prerequisite for the validation loop. If records were stored in section order but sections are not contiguous in the file (which can happen), a naive linear scan would not find every page. The sort normalizes the storage.

D.0.3 Integer overflow protection

The record count computation explicitly guards against overflow:

; Somewhere in the context allocation path:
imul  rax, rcx, 24h      ; rax = record_count * sizeof(record)
jo    overflow_handler    ; jump if overflow flag set
; Continue with allocation...

overflow_handler:
    mov  eax, 0C0000095h  ; STATUS_INTEGER_OVERFLOW
    ret

STATUS_INTEGER_OVERFLOW (0xC0000095) means the caller supplied a page count so large that the record buffer size wrapped around in 64-bit arithmetic. This is a defense-in-depth check: a malformed image header with an impossibly large section count would cause this status before any allocation is attempted.


Appendix E: SkciQueryImageAttestationData — Attestation Data Retrieval

SkciQueryImageAttestationData reads the image-level attestation fields from the context and copies them to the caller's output buffer. It is separate from the policy-level attestation in SkciBuildAttestationReport.

The function follows a pattern used throughout the Query* exports:

SkciQueryImageAttestationData(context, output_buffer, output_size, return_size):
    if context->attestation_thumbprint_algorithm == 0:   // ctx+0x11C
        return STATUS_OBJECT_NAME_NOT_FOUND (0xC0000225)

    required_size = sizeof(thumbprint_struct) + sizeof(program_name_struct)
    if output_size < required_size:
        *return_size = required_size
        return STATUS_INFO_LENGTH_MISMATCH (0xC0000004)

    copy context+0x11C (algorithm) → output
    copy context+0x120..0x137 (thumbprint bytes) → output
    copy context+0x138 (program_name UNICODE_STRING, if present) → output
    *return_size = actual_copied_size
    return STATUS_SUCCESS

The check ctx+0x11C == 0 is the "no attestation data" condition. This field is set to 0x8004 by SkciCalculateImageAttestationData when a certificate thumbprint is found. If it remains 0 (the initial value), no attestation data was computed — either because the image has no Authenticode signature, or because the signature does not contain an OPUS attribute, or because the certificate thumbprint extraction failed.

STATUS_OBJECT_NAME_NOT_FOUND (0xC0000225) is the return code for "no data present." This is the right status semantically — the attestation data object does not exist for this image — but it is easy to misinterpret as a generic lookup failure. A dedicated status code like STATUS_NO_ATTESTATION_DATA would be clearer; this is one of the minor diagnostic gaps noted in §20.

The OPUS attribute

SkciCalculateImageAttestationData looks for an OPUS (Authenticode Program Name) attribute in the signer's authenticated attributes:

SkciCalculateImageAttestationData(context, signing_info):
    1. Clear ctx+0x11C through ctx+0x137 (zero attestation fields)
    2. Call SkciGetCertificateThumbprint(signing_info, hash_alg, thumbprint)
       - on success: set ctx+0x11C = 0x8004 (algorithm), ctx+0x120..0x137 = thumbprint
    3. Check for OPUS attribute in authenticated attributes
       - if OPUS present and not a "generic" program name:
           set ctx+0x138 = UNICODE_STRING pointing to OPUS program name

Step 1 — zero the fields first — is a correctness invariant: if either step 2 or step 3 fails partway, the context fields are in a deterministic "not present" state rather than whatever garbage the allocator left there.


Appendix F: Signing Levels Reference

SkciDetermineAuthorizedPageUse references signing level comparisons against 0x08 and 0x0C. For context, the known signing level constants used across ci.dll and skci.dll:

Level (hex) Level (dec) Name / Meaning
0x00 0 Unsigned
0x01 1 Unsigned (but scan authorised via policy)
0x02 2 Enterprise-signed (WDAC policy)
0x03 3 Custom-signed (developer mode)
0x04 4 Authenticode / catalog-signed
0x05 5 Microsoft Store app
0x06 6 Antimalware signed
0x07 7 Microsoft signed (non-OS critical)
0x08 8 WHQL submission level
0x09 9 Windows (standard)
0x0A 10 Windows TCB (Trusted Computer Base)
0x0B 11 Windows component
0x0C 12 WHQL (hardware-qualified level)
0x0D 13 Windows protected
0x0E 14 Windows system protected
0x0F 15 IUM (Isolated User Mode) trusted

The two SKCI thresholds:

  • 0x08 (WHQL submission): Lower bound for the EKU fallback. Levels 0x08..0x0B can gain WHQL_level trust via EKU 1.3.6.1.4.1.311.76.8.1.
  • 0x0C (WHQL): Unconditional WHQL_level grant when bit 0 (IUM_execute) is already set.

Levels below 0x08 cannot obtain WHQL_level regardless of EKU. This is a floor: a standard Authenticode signature (level 0x04) can never reach WHQL_level trust through any EKU path.

The IUM trust predicates (CiIsSignatureTrustedForIum, CiIsSignatureTrustedForVsmEnclaves) operate separately from signing level. An image can have a high signing level but still fail CiIsSignatureTrustedForIum if its certificate is not on the IUM trust list. Signing level and IUM trust are independent dimensions of authorization; the authorized_page_use_flags bitmap encodes both.


Appendix G: Comparing VTL0 ci.dll and VTL1 skci.dll

For readers who know the VTL0 Code Integrity stack and want to understand the relationship:

Feature VTL0 ci.dll VTL1 skci.dll
Location C:\Windows\System32\ci.dll C:\Windows\System32\skci.dll
Loader ntoskrnl.exe securekernel.exe
Can be patched by VTL0 kernel code? In principle (if HVCI off); no if HVCI on No — VTL1 memory is inaccessible from VTL0
Page hash implementation Similar record format, also SHA-256 Same record format (0x24 bytes)
Algorithm constants 0x800C for image pages 0x800C for image, 0x800E for dynamic
EKU path Present (same OIDs) Present (same OIDs, same resolution)
Attestation No policy-level attestation export SkciBuildAttestationReport
Policy update CiSetPolicy-style in-place SkciSetCodeIntegrityPolicy with generation
Hot module bypass risk Depends on HVCI state VTL boundary materially changes the attack surface; VTL0 patching assumptions no longer apply
Public PDB Yes (usually) Yes (this build)

The algorithms and data structures are deliberately consistent across VTL0 and VTL1. This makes sense: both implement the same fundamental operation (page hash validation and signing level evaluation). What VTL1 adds is that the enforcement cannot be weakened from VTL0, even by SYSTEM kernel code. The hypervisor enforces the VTL boundary; SKCI's authorization decisions are binding.

The implication for security research: a technique that works against ci.dll in a system without HVCI does not automatically work against skci.dll in a HVCI-enabled system. The code paths are similar; the trust boundary is not.


Appendix H: Deeper Dives — Selected Function Walkthroughs

H.1 SkciCreateSecureImage — Phase by Phase

The complete sequence of what SkciCreateSecureImage does, in order, derived from tracing the call graph in CDB:

Phase 1 — Header validation:

; After allocating the context block (0x188 bytes, pool tag 0x43495349 = "ISCI"):
call  CiValidateImageHeaderMapping
; verifies the supplied PE header pointer is within the image mapping
; and that the NT signature ("MZ" + PE\0\0) is present and correctly positioned.
; Failure: STATUS_INVALID_IMAGE_FORMAT

call  SkciCheckNtHeaderForCompliance
; additional checks on the optional header fields:
;   - SizeOfImage must be non-zero and page-aligned
;   - SectionAlignment must be page-aligned (>= 0x1000)
;   - NumberOfSections <= maximum allowed
;   - Subsystem must be in the allowed set for kernel images
; Failure: STATUS_INVALID_IMAGE_FORMAT or STATUS_INVALID_PARAMETER

Phase 2 — First page hash:

call  CiCalculateFirstPageHash
; computes SHA-256 over the first 0x1000 bytes of the image
; stores result at ctx+0x010
; the "first page" includes the DOS/MZ header, PE header, and optional header
; this hash is a quick identity anchor before full page coverage is computed

Phase 3 — Section table processing (conditional):

The function checks a flags argument to decide whether to generate page hashes or defer to the signed/catalog path:

if (caller_flags & USE_GENERATED_PAGE_HASHES):
    call CiCreateVerificationContextForImageGeneratedPageHashes
    ; allocates a verification context for generated (computed) page hashes
    ; iterates over the section table, sorts sections by file offset
    ; for each section: schedules SHA-256 computation over each 4KB page
    call CiAddSectionDataToVerificationContext
    ; accumulates section data into the verification context
else:
    ; defer: image uses signed catalog page hashes
    ; page hash records will come from the catalog/signature infrastructure
    ; validated later in SkciFinishImageValidation via CiValidateImagePages

The two paths produce different intermediate state but converge at SkciFinishImageValidation. The generated-page-hash path computes hashes eagerly; the catalog path validates lazily.

Phase 4 — Optional image copy:

; if caller requests a rebased/copied image:
call  RtlCopyMemory       ; copy header to ctx+0x0E0 region
; stores NT headers pointer at ctx+0x0E8
; this enables subsequent validation against a canonical copy,
; important for images that may be relocated at load time

H.2 CiIsSignatureTrustedForVsmEnclaves — The Enclave Trust Gate

This function controls whether enclave_trusted (bit 3 of authorized_page_use_flags) is set. It is more complex than the other trust predicates because enclave authorization layered on top of IUM authorization:

CiIsSignatureTrustedForVsmEnclaves(signing_info):
    1. Call CiIsSignatureTrustedForIum(signing_info)
       if false: return false
       // VSM enclave trust requires IUM trust as a prerequisite

    2. Check signing level via g_CipWhichLevelComparisons
       // signing level must satisfy the enclave-specific level requirement
       // the threshold is higher than for ordinary IUM trust

    3. Check EKU via MincryptIsEKUInPolicy
       // EKU lookup against the policy's allowed enclave EKU list

    4. Parse g_EnclaveEncodedOid via ASN.1 decoder
       // there is a dedicated OID/attribute path for enclave identification
       // CiIsEnclaveAttribute(cert_extension, oid) is the final gate

The four-step gate means a certificate can be trusted for IUM execution (bit 0/1) but not for VSM enclaves (bit 3) if either the signing level is insufficient or the enclave EKU/OID is absent. This is the correct layering: enclave trust is a subset of IUM trust, not a separate trust domain.

The g_EnclaveEncodedOid global contains the encoded form of the enclave attribute OID. It is populated during SkciInitialize (or CiInitializeGlobalState) and used as a reference for the ASN.1 comparison. I did not reconstruct the full OID string from the binary; it would require reading g_EnclaveEncodedOid from a live system or computing the DER encoding of a known enclave attribute OID.

H.3 CiGenerateAttestationReport — Full Format Reconstruction

The complete report structure, as reconstructed from CiGenerateAttestationReport and CipAddAttestationReportToHistory:

struct CiAttestationReport {
    uint8_t  version;              // 0x00: always 1
    uint8_t  reserved1[3];        // 0x01..0x03
    uint32_t header_size;          // 0x04: always 0x3A
    uint32_t type;                 // 0x08: 0x50013 (observed, no public name)
    uint32_t reserved2;            // 0x0C: padding
    uint64_t blocklist_update_time;// 0x10: g_CiBlocklistUpdateTime (8 bytes)
    uint8_t  hash_length;          // 0x18: 0x20 (SHA-256)
    uint8_t  hash_algorithm;       // 0x19: 0x0B (observed, no public name)
    uint8_t  revocation_list_hash[32]; // 0x1A: SHA-256 of current revocation list
    // [0x3A..] serialized policy attestation payload from SIPolicyGetSerializedPolicies
};

Pool tag 0x74614943 — little-endian ASCII "CiAt" — is used for the allocation. This is also the tag that appears in SkciBuildAttestationReport's BugCheck parameter P2.

The g_CiBlocklistUpdateTime field at offset 0x10 is particularly interesting for forensics: it records when the blocklist was last updated, not when the report was generated. An attestation report from a system that has been isolated from Windows Update for months will have an old blocklist_update_time even if the report was just generated. This is useful for detecting stale protection configurations.

The type field 0x50013 and the hash_algorithm byte 0x0B remain unresolved to public names. I am documenting them as observed literals rather than assigning analytical names, because any naming would be speculation.

H.4 SkciMatchHotPatch — Caller-Bound Identity Comparator

The full hotpatch authorization sequence belongs to securekernel!SkmiMatchPatchImage. SkciMatchHotPatch is its final step — a narrow identity comparator. The caller-side checks that precede it:

flowchart TD A[SkmiMatchPatchImage] --> B{record +0x60 == 0?} B -->|no| X[no match] B -->|yes| C[RtlFindHotPatchBaseMachine] C -->|null| X C --> D{base metadata matches\nat +0x20 / +0x24?} D -->|no| X D -->|yes| E{record hash pointer\nat +0x30 present?} E -->|no| M[match after metadata checks] E -->|yes| F[RtlGetHotPatchHashes] F -->|null| X F --> G[SkciMatchHotPatch] G -->|memcmp == 0| M G -->|memcmp != 0| X

SkciMatchHotPatch is called only when a non-null hash pointer exists. The metadata checks in steps 1–4 are securekernel work. SKCI is the hash oracle at the end of the chain, not the access-control decision maker.

The hotpatch matching design in SKCI embodies a specific philosophy about trust: the identity of a hotpatch is not "who signed it" but "does the CI policy know this target, and does the patch's identity match what the policy recorded." The signing chain matters for authorization, but the key mechanism is content-based identity.

skci!SkciMatchHotPatch:
    ; ecx = algorithm_id (DWORD)
    ; edx = hash_len (DWORD)
    ; r8  = input_hash (byte*)
    ; r9  = hotpatch_hash_blob*

    cmp  ecx, 8004h       ; 20-byte thumbprint-style path
    jne  try_800C

    cmp  edx, 14h         ; expected length: 20 bytes
    jne  no_match

    ; compare input_hash[0..19] with hotpatch_hash_blob + 0x20
    call skci!memcmp
    jmp  result

try_800C:
    cmp  ecx, 800Ch       ; 32-byte hash path
    jne  no_match
    cmp  edx, 20h         ; expected length: 32 bytes
    jne  no_match

    ; compare input_hash[0..31] with hotpatch_hash_blob + 0x00
    call skci!memcmp
    jmp  result

no_match:
    xor  eax, eax         ; return false
    ret
result:
    ; eax = comparison result (0 or 1)
    ret

The comparison uses skci!memcmp — a direct fixed-length byte compare. No constant-time property is established from this static trace; no timing-safe wrapper function appears in the disassembly.

The hotpatch blob format, partially reconstructed:

Blob offset Size Content
0x00 32 SHA-256 identity hash (read by algorithm 0x800C path)
0x08 4 Sequence number (read by CiHotpatchGetSequenceNumberFromHotpatchInfo)
0x20 20 Thumbprint-style hash (read by algorithm 0x8004 path)
0x0C+ Variable Additional fields not yet reconstructed

The minimum blob size enforced by CiHotpatchGetSequenceNumberFromHotpatchInfo is 0x0C — it reads a 4-byte sequence number from +0x08 and checks that the blob is large enough to contain it.

The NtManageHotPatch call in SIPolicyDoesHotpatchExistForTarget uses class 0x0B and a 20-byte output buffer. The class 0x0B for NtManageHotPatch is not documented, but its presence in SKCI's internal implementation suggests it is the "query if hotpatch exists for target" operation — a policy check separate from the identity matching.

The full hotpatch authorization flow:

SIPolicyDoesHotpatchExistForTarget(target_info)
    → NtManageHotPatch(class=0x0B, target_info, output_buf, 0x14)
    → if hotpatch exists in policy:
        → SkciMatchHotPatch(hotpatch_blob, algorithm, hash_len, identity_hash)
        → if match: authorize

H.5 SkciQueryInformation Class 0x67 — Protocol Dissection

The exact assembly for the class 0x67 protocol check deserves to be shown in full because the "pre-fill magic" requirement is non-obvious:

; esi = information_class = 0x67
class_67:
    ; [r12] = input_buffer_ptr, r13 = input_buffer_length
    ; [r14] = output_buffer_ptr, r15 = output_buffer_length
    ; [rbp-something] = return_length_ptr

    cmp   r13d, 8               ; input_buffer_length == 8 exactly?
    jne   length_mismatch

    cmp   r15d, 8               ; output_buffer_length == 8 exactly?
    jne   length_mismatch

    cmp   dword ptr [r12], 8    ; [input_buffer + 0] == 8 (magic pre-fill)?
    jne   length_mismatch

    ; All checks pass. Build the response DWORD at output+4:
    xor   ebx, ebx              ; response_bits = 0

    ; Build CI options bits:
    test  [g_CiOptions], 2      ; bit 1 of g_CiOptions?
    jz    skip_bit0
    or    ebx, 1                ; set response bit 0
    test  [g_CiOptions], 8      ; bit 3 of g_CiOptions?
    jz    skip_bit1
    or    ebx, 2                ; set response bit 1

skip_bit1:
skip_bit0:
    ; Developer mode bit:
    test  [g_CiDeveloperMode], 40h    ; bit 6 of g_CiDeveloperMode?
    jz    skip_bit9
    or    ebx, 200h                   ; set response bit 9

skip_bit9:
    ; WHQL enforcement bits:
    call  CipWhqlEnforcementEnabled
    test  al, al
    jz    skip_whql
    or    ebx, 4000h                  ; set response bit 14
    test  ah, ah
    jz    skip_whql
    or    ebx, 8000h                  ; set response bit 15

skip_whql:
    ; Write response:
    mov   dword ptr [r14], 8    ; output[0] = 8 (echo the magic)
    mov   [r14+4], ebx          ; output[4] = response bits

    ; Set return length:
    mov   dword ptr [return_length_ptr], 8
    xor   ebx, ebx              ; STATUS_SUCCESS
    jmp   exit

Two observations from this assembly:

  1. The magic echo. Output offset +0x00 receives the value 8 — the same magic the caller had to pre-fill in the input. This is a protocol handshake: the caller confirms it knows the protocol, the response confirms it was understood. If the caller pre-filled a different value, the check at cmp dword ptr [r12], 8 would have failed with STATUS_INFO_LENGTH_MISMATCH before any output was written.

  2. g_CiDeveloperMode & 0x40. This bit maps to what might be called "flight mode" or "test signing with weak crypto allowed" — the exact semantic is not named publicly. It is the bit that would indicate whether the system is in a development/test configuration with relaxed signing requirements. An enterprise system should have this bit clear; seeing it set in the class 0x67 output would be a finding.

H.6 Policy Lock Consistency in SkciQueryInformation

Both classes hold g_CipPolicyLock as a shared (read) lock for the duration of the query. The lock acquisition/release sequence:

SkeEnterCriticalRegion
SkAcquirePushLockShared(g_CipPolicyLock)
[execute class-specific code]
SkReleasePushLockShared(g_CipPolicyLock)
SkeLeaveCriticalRegion

This is the same lock held exclusively (SkAcquirePushLockExclusive) during SkciSetCodeIntegrityPolicy updates. The shared/exclusive protocol ensures that a policy query never observes a partially-written policy state — it either sees the complete old state or the complete new state, never a torn mix. This is a correct implementation of a read-write lock around policy state.

The SkeEnterCriticalRegion call disables kernel APC delivery for the duration of the lock hold, which prevents a kernel APC from interrupting between lock acquisition and release and causing a potential deadlock if the APC handler also tries to acquire the same lock.


Appendix I: Open Questions for Future Analysis

The findings above are bounded by static analysis. Several questions would require a live VTL1 trace to answer definitively:

Question What static analysis shows What live trace would add
Exact downstream meaning of 0x12C success from SkciValidateDynamicCodePages Code generates 0x12C; securekernel.exe accepts it with NT sign-bit semantics (test eax,eax; js) Whether telemetry or any later consumer distinguishes 0x12C from STATUS_SUCCESS
Runtime value of g_CiOptions on a live HVCI system Bits 1, 3, and 21 are referenced in the code Actual active bits on a production system
Consumer of g_CiPolicyState bit 7 in securekernel.exe Not read within skci.dll static scope Which securekernel function triggers revalidation on bit set
Full g_CiScenarios layout Offsets +0x390 through +0x3B0 reconstructed Complete structure including scenario array entries
SkciQueryEnclavePackageID payload on a real enclave load Returns 40 zero bytes in static analysis Whether a real enclave load produces non-zero output (if so, this analysis is wrong)
g_EnclaveEncodedOid content Exists as a global, used in ASN.1 comparison Decoded OID string
NtManageHotPatch class 0x0B semantics Called with 20-byte output buffer Exact output structure and what "hotpatch exists for target" means in policy terms
SkciSetCodeIntegrityPolicy mode 2 payload 4+1 bytes written to g_CiScenarios+0x3B4/0x3B8 What those 5 bytes encode in the CI scenario model

These are not gaps in the analysis — they are the natural boundary of what static analysis can reach without a live VTL1 debugger. Documenting them explicitly is part of being honest about the scope and confidence of what is reported here.


Appendix J: Why "Secure Kernel" and What VTL Means for This Analysis

For readers who are less familiar with the VBS/VTL architecture: a brief note on why skci.dll running in VTL1 matters for the analysis methodology.

Windows with Virtualization-Based Security (VBS) enabled runs in two Virtual Trust Levels:

VTL Name Runs Trust level
0 "Normal world" NT kernel (ntoskrnl.exe), user-mode processes, drivers Less trusted
1 "Secure world" Secure Kernel (securekernel.exe), SKCI (skci.dll) More trusted

The hypervisor enforces the boundary. VTL0 cannot read or write VTL1 memory. VTL0 kernel code — including any driver, even a kernel-mode driver running as SYSTEM — cannot call into skci.dll directly. All calls cross the VTL boundary through a controlled VMCALL interface.

For this analysis, the practical consequences are:

  1. Static analysis is possibleskci.dll is a standard PE image that CDB can disassemble with its public PDB.
  2. Live single-stepping is not possible — a user-attached debugger runs in VTL0 and cannot single-step VTL1 code.
  3. Memory inspection of VTL1 globals is not possible — reading g_CiOptions at runtime would require a VTL1-aware debugger or a hypervisor trace.
  4. The import/export surface is the primary interface evidence — what securekernel.exe imports from skci.dll is the ground truth for what capabilities are actually used.

This means the analysis in this document is necessarily "what would happen" rather than "what did happen." The code paths are real; the actual runtime values and timing of those paths on a live system are inferred.


Appendix K: Connecting to the ForceIO Research

The ForceIO write-up established that WdFilter.sys (a VTL0 minifilter) can be bypassed by routing file operations through a legitimately signed kernel driver that issues I/O below the filter. That bypass operates entirely in VTL0.

The question this research addresses is the natural follow-on: does the VTL1 code-integrity machinery (skci.dll) provide a backstop that VTL0 bypasses cannot circumvent?

The honest answer, based on this analysis: yes, for code execution; not directly relevant for file operations.

skci.dll controls whether a specific image is authorized to execute as kernel-mode code. It does not control file I/O. The ForceIO bypass is about file operations — specifically, moving and deleting files that WdFilter.sys would otherwise block. SKCI is not in that code path.

Where SKCI becomes relevant is in a more sophisticated scenario: if an attacker wants to load a kernel driver that has not been validated by the signing/policy infrastructure, HVCI (enforced by SKCI) prevents that driver's pages from becoming executable. The ForceIO approach works specifically because IObitUnlocker.sys is a legitimately signed driver — it passes the signing check, gets through SKCI validation, and executes normally. An unsigned driver or a driver with a revoked certificate would fail at SkciFinishImageValidation before any pages are authorized.

So the two pieces of research form a natural pair:

Layer Controlled by ForceIO relevant? This research relevant?
File I/O WdFilter.sys (VTL0 minifilter) Yes — ForceIO bypasses this No
Code execution authorization skci.dll (VTL1 SKCI) Partially — ForceIO uses SKCI-authorized driver Yes

The boundary is also the interesting part: BYOVD/BYOPD attacks work specifically because they stay within the VTL1 authorization model. The attacker uses a signed driver, which passes SKCI validation, which authorizes its pages for execution, which gives the attacker kernel-mode capabilities within VTL0. Closing this attack vector requires either revoking the driver (blocklist) or requiring a policy that permits only specific named drivers (WDAC application control). Neither of those is a change to skci.dll itself — they are policy changes enforced through skci.dll.


This analysis was performed on a personal research system running Windows 11 Pro 26H1, build 28000, using CDB with public PDB from the Microsoft symbol server. The findings are static and reflect the binary as shipped in June 2026. Runtime behavior in VTL1 may differ from what static analysis predicts. If you find errors or gaps, please reach out.