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:
SkciValidateDynamicCodePagesreturns0x12Con success, notSTATUS_SUCCESS (0). Any caller checkingstatus == 0gets a false negative.SkciQueryEnclavePackageIDpasses the authorization check and then returns 40 bytes of zeros. The actual enclave identities are computed separately and live elsewhere in the context.SkciQueryInformationaccepts exactly two information classes (0x67and0xF1), with a rigid 8-byte handshake protocol for class0x67. Everything else isSTATUS_INVALID_INFO_CLASS.SkciBuildAttestationReportcallsKeBugCheckEx(SECURITY_SYSTEM)when the attestation history count is zero. Zero history is an invariant violation, not an API error condition.CipForceImageRevalidationis 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.SkciDetermineAuthorizedPageUsehas an EKU fallback path for signing levels between0x08and0x0C: the presence of EKU1.3.6.1.4.1.311.76.8.1can grant theWHQL_levelbit without a full level upgrade.SkciSetCodeIntegrityPolicymode 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:
- What is the exact sequence of decisions between an image being presented to SKCI and a page-use authorization being issued?
- 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?
- What does the attestation report actually contain, and what are the correctness invariants the code depends on?
- Is there anything in the EFI/boot chain that a forensic baseline should record, beyond what
sigcheck.exe -vreports?
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.dllat rest, usingcdb uf/cdb xwith 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:
- Values in global variables (
g_CiOptions,g_CiDeveloperMode,g_CiScenarios, etc.) are their link-time defaults in my analysis. Runtime state may differ. - "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.
- 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.
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 SbGetSizeOfKernelPolicyPackageForPolicy → SbGetKernelPolicyPackageForPolicy → BlpPdSaveData |
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:
- Enumerating EFI executables on the ESP with hash and signing status
- Correlating each file against active BCD entries and firmware boot entries
- Alerting when a new EFI executable appears or an existing one changes hash
- 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:
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:
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 0x118 — authorized_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.
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, viaCiCalculateUniqueID)ctx+0x168:image_author_id(32 bytes, viaCiCalculateAuthorID)
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:
-
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.
-
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_SUCCESSand zeros as a "not applicable in this context" signal, and the real enclave identification goes throughSkciQueryImageUniqueID/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:
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:
-
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. -
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
-
Document
SkciValidateDynamicCodePages' success code.0x12Cas 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. -
Extend
SkciQueryInformationwith 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. -
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.
-
Clarify attestation semantics.
SkciQueryImageAttestationDatareturningSTATUS_OBJECT_NAME_NOT_FOUNDwhenctx+0x11C == 0is semantically ambiguous. A dedicated "no attestation present" status code or a documented field that distinguishes "not applicable" from "failed" would improve incident diagnosis. -
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
-
Record SHA-256, not just version, for all EFI executables in your baseline. The
bootmgfw.efifinding in §5.1 is a direct demonstration that two files with the same version and valid signatures can differ in content. -
Treat
STATUS_INVALID_IMAGE_HASHas 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. -
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
-
Use
SkciQueryInformationclass0x67as 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. -
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. Levels0x08..0x0Bcan gainWHQL_leveltrust via EKU1.3.6.1.4.1.311.76.8.1.0x0C(WHQL): UnconditionalWHQL_levelgrant 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:
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:
-
The magic echo. Output offset
+0x00receives the value8— 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 atcmp dword ptr [r12], 8would have failed withSTATUS_INFO_LENGTH_MISMATCHbefore any output was written. -
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 class0x67output 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:
- Static analysis is possible —
skci.dllis a standard PE image that CDB can disassemble with its public PDB. - Live single-stepping is not possible — a user-attached debugger runs in VTL0 and cannot single-step VTL1 code.
- Memory inspection of VTL1 globals is not possible — reading
g_CiOptionsat runtime would require a VTL1-aware debugger or a hypervisor trace. - The import/export surface is the primary interface evidence — what
securekernel.exeimports fromskci.dllis 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.