ci.dll — Security-relevant change: custom-kernel-signer trust-decision hardening (CWE-863), plus SymCrypt ML-DSA termination hardening
KB5095051
1. Overview
- Unpatched Binary:
ci_unpatched.dll - Patched Binary:
ci_patched.dll - Overall Similarity: 0.9803 (98.03%)
- Diff Statistics: 2585 matched functions, 51 changed functions, 2534 identical functions, 0 unmatched functions (both directions).
- Verdict: The patch contains one security-relevant trust-decision hardening plus a body of statically-linked SymCrypt ML-DSA (Dilithium) termination hardening. The security-relevant change (see section 1a) rewrites
SIPolicyAreCustomKernelSignersAllowedso that the custom-kernel-signer allowance folded intog_CiDeveloperModebit 0xD can no longer be granted by any loaded SI policy: the patched build gates the whole decision behind a registry-backed lock byte and restricts the flag check to the base policy only, closing a policy-confusion path in which a supplemental (non-base) policy could enable unsigned/self-signed kernel-mode signers. The SymCrypt ML-DSA change adds fixed upper iteration bounds to three rejection-sampling loops (SymCryptMlDsaRejBoundedPoly,SymCryptMlDsaRejNttPoly,SymCryptMlDsaSampleInBall) and propagates the resulting error code through the ML-DSA callers; those loops read from a freshly-initialized SHAKE/Keccak sponge, not from raw attacker bytes, so the bounds are a defensive termination guarantee (library hardening), not a fix for an attacker-reachable denial of service.
1a. Security-Relevant Finding — Custom Kernel Signer Policy-Confusion Hardening
- Function:
SIPolicyAreCustomKernelSignersAllowed(unpatched0x180073964/ patched0x1800764A8), plus the newly-split helperSIPolicyCheckCustomKernelSignersAllowed(patched0x18007652C). - Severity: Medium.
- CWE: CWE-863 (Incorrect Authorization). The unpatched build makes the custom-kernel-signer trust decision using the wrong authority set (any loaded SI policy, base or supplemental) and with no lock gate; the patched build corrects the check.
- Direction: Correct — the patched build is strictly stricter. It can only return TRUE (custom kernel signers allowed) in a subset of the cases the unpatched build accepts.
What the function decides
Both call sites fold this function's boolean result directly into g_CiDeveloperMode bit 0xD, the global flag Code Integrity consults when deciding whether unsigned / self-signed (custom-kernel-signer, test-signing style) kernel-mode drivers are treated as allowed.
Unpatched call site 0x18001A5F0:
000000018001A5F0 call SIPolicyAreCustomKernelSignersAllowed
000000018001A5F5 and eax, edi
000000018001A5F7 btr r10d, 0Dh ; clear g_CiDeveloperMode bit 0xD
000000018001A5FC shl eax, 0Dh
000000018001A5FF or eax, r10d
000000018001A602 mov cs:g_CiDeveloperMode, eax ; result -> bit 0xD
Patched call site 0x18001A7AC folds the result into g_CiDeveloperMode bit 0xD the same way (shl eax,0Dh / btr ebx,0Dh / or eax,ebx / mov cs:g_CiDeveloperMode, eax at 0x18001A7BA-0x18001A7C7). A second patched call site exists at 0x18006BC0C.
Unpatched logic — OR across ALL policies, no gate (0x180073964)
The function walks every loaded SI policy (g_SiPolicyHandles[0 .. g_NumberOfSiPolicies)) and returns TRUE if ANY policy's flags field (+0x708) has the relevant bit set. There is no lock check and no base-policy restriction:
000000018007396A cmp r9d, cs:g_NumberOfSiPolicies ; loop over ALL policies
0000000180073971 jnb short loc_18007399B
0000000180073973 mov rax, cs:g_SiPolicyHandles
000000018007397D mov rax, [rax+rdx*8] ; handle[i]
0000000180073981 mov eax, [rax+708h] ; policy flags
0000000180073987 test cl, cl ; caller mode byte
0000000180073989 jz short loc_18007398F
000000018007398B test al, 18h ; mode!=0: bits 0x8|0x10
000000018007398D jmp short loc_180073991
000000018007398F test al, 4 ; mode==0: bit 0x4
0000000180073991 jnz short loc_180073998
0000000180073993 inc r9d ; not set -> keep scanning
0000000180073996 jmp short loc_18007396A
0000000180073998 mov r8b, 1 ; ANY policy sets it -> TRUE
000000018007399E retn
Patched logic — registry lock gate + base-policy-only (0x1800764A8)
- New global gate: if the registry-backed lock byte
byte_180048F08("CustomKernelSignerLockedEnabled") is 0, the function returns FALSE unconditionally and never inspects any policy flags. - The scan aborts the moment it reaches a policy that is not a base policy (
SIPolicyIsBasePolicy), so a supplemental policy cannot contribute to the decision. - The per-policy check is delegated to
SIPolicyCheckCustomKernelSignersAllowed, which re-checks the lock byte and a mode argument before honoring the flag bits.
00000001800764BD cmp cs:byte_180048F08, r9b ; r9b = 0: lock byte set?
00000001800764C4 jnz short loc_1800764CA
00000001800764C6 xor al, al ; lock clear -> return FALSE
00000001800764C8 jmp short loc_180076513
00000001800764CA mov ebx, cs:g_NumberOfSiPolicies
00000001800764D7 mov r11, cs:g_SiPolicyHandles
00000001800764DE mov rdi, [r11] ; handle[i]
00000001800764E1 mov rcx, rdi
00000001800764E4 call SIPolicyIsBasePolicy
00000001800764E9 test al, al
00000001800764EB jz short loc_180076510 ; not base -> abort scan (FALSE)
00000001800764ED mov ecx, [rdi+708h] ; base policy flags
00000001800764F3 mov dl, sil ; mode byte
00000001800764F6 call SIPolicyCheckCustomKernelSignersAllowed
The helper re-checks the same lock byte and the mode argument:
; SIPolicyCheckCustomKernelSignersAllowed @ 0x18007652C
000000018007652C cmp cs:byte_180048F08, 0 ; lock byte again
0000000180076533 jz short loc_18007654C ; clear -> FALSE
0000000180076535 test dl, dl ; mode argument
0000000180076537 jz short loc_18007654C ; unset -> FALSE
0000000180076539 test cl, 8 ; flag bit 0x8
000000018007653C jnz short loc_180076548 ; -> TRUE
000000018007653E shr ecx, 4
0000000180076541 and cl, 1 ; else flag bit 0x10
0000000180076544 mov al, cl
0000000180076546 retn
000000018007654C xor al, al ; FALSE
000000018007654E retn
The lock byte is populated from the registry by CipUpdateCustomKernelSignerEnabledState (0x180018DE8), which reads the CustomKernelSignerLockedEnabled value via ZwQueryValueKey (with a ZwSetValueKey create-fallback) and stores it through SIPolicySetCustomKernelSignersLockedEnabled (0x180076558, mov cs:byte_180048F08, cl). Neither byte_180048F08 nor CipUpdateCustomKernelSignerEnabledState exists in the unpatched build.
Why this is a real omitted fix, not feature staging
The unpatched function has no fallback: its permissive "OR across all policies" loop is fully replaced, not retained behind a feature flag. The old code let a supplemental (less-trusted) SI policy that merely sets the flag bit at +0x708 drive g_CiDeveloperMode bit 0xD to TRUE, overriding the base policy's trust decision on whether unsigned/self-signed kernel drivers are allowed. Because SI policies (including supplemental ones) are loaded through documented SIPolicy update mechanisms, a supplemental policy is administrator/attacker-reachable input to this decision rather than a hardcoded constant. The patch closes the confusion by (a) requiring an explicit registry-controlled lock and (b) consulting the base policy only. This is a trust-decision hardening (an incorrect-authorization fix), not WIL feature staging, telemetry, relocation, or API modernization.
2. Change Summary
- Severity: None (defense-in-depth library hardening).
- Change Class: Bounded-iteration guard added to cryptographic rejection sampling in statically-linked SymCrypt.
- Affected Functions:
SymCryptMlDsaRejBoundedPoly(unpatched0x18002A17C/ patched0x180027128),SymCryptMlDsaRejNttPoly(unpatched0x18002A31C/ patched0x1800272EC),SymCryptMlDsaSampleInBall(unpatched0x18002A3F8/ patched0x1800273E0). - What changed: Each function previously looped until it had produced the required number of polynomial coefficients, re-reading the sponge whenever a sampled value was rejected (out of range, or a sentinel). The patched functions add a fixed iteration counter (
0x1E1= 481,0x12A= 298,0x79= 121 respectively). When the counter is reached the function returns error code0x8008(32776) instead of continuing. The ML-DSA callers were updated to observe and propagate that return code. - Why this is not an attacker-reachable defect: In all three functions the loop input is produced by
SymCryptKeccakExtractreading from a sponge that is initialized inside the same function and absorbed (SymCryptKeccakAppend) from a seed. The bytes fed to the range/sentinel checks are pseudorandom SHAKE output. An attacker who supplies a malformed public key or signature controls only the sponge seed (the ML-DSA public seed rho, or the challenge hash c-tilde), never the sponge output. Rejection sampling on a SHAKE stream terminates with probability 1, and the per-sample rejection probability is tiny (forRejNttPoly, values in[0x7FE001, 0x800000)are rejected: 8191 of 8388608 possible 23-bit values, about 0.098%). Exceeding the natural loop length by the margin these caps allow would require finding a seed whose SHAKE expansion yields hundreds of consecutive rejections, i.e. breaking SHAKE. The caps therefore only convert an astronomically improbable event into a graceful error; they do not close a reachable hang. - Direction: Correct. The patched build is stricter (adds the bound); the unbounded form is in the unpatched build.
3. Pseudocode Diff
Decompiler output for SymCryptMlDsaRejNttPoly in both builds. Note SymCryptKeccakAppend(..., a1, 34) seeds the sponge and SymCryptKeccakExtract reads its output; a1 is the seed, not the sampled bytes.
// UNPATCHED SymCryptMlDsaRejNttPoly @ 0x18002A31C
SymCryptKeccakAppend(v8, a1, 34); // absorb seed
v7 = 0;
for ( i = 256; i != 0; --i ) // 256 coefficients required
{
do
{
SymCryptKeccakExtract(v8, &v7, 3); // read 3 sponge bytes
BYTE2(v7) &= ~0x80u; // mask to 23 bits
result = v7;
}
while ( v7 >= 0x7FE001 ); // reject out-of-range, re-read
*a3++ = v7; // accept in-range coefficient
}
return result;
// PATCHED SymCryptMlDsaRejNttPoly @ 0x1800272EC
SymCryptKeccakAppend(v10, a1, 34);
v9 = 0;
v7 = 0; // accepted-coefficient count
while ( v6 < 0x12A ) // FIX: hard cap of 298 sponge reads
{
SymCryptKeccakExtract(v10, &v9, 3);
BYTE2(v9) &= ~0x80u;
++v6; // FIX: unconditional read counter
if ( v9 < 0x7FE001 )
{
*a3 = v9;
++v7;
++a3;
if ( v7 >= 0x100 )
return v5; // normal completion (256 stored)
}
}
return 32776; // FIX: 0x8008 on cap exhaustion
The same shape applies to SymCryptMlDsaRejBoundedPoly (nibble decode, sentinel 0x80 skips the write) and SymCryptMlDsaSampleInBall (byte compared to a running threshold). In every case the rejected value comes from the sponge, not from the input buffer.
4. Assembly Analysis
SymCryptMlDsaRejNttPoly — unpatched loop (0x18002A31C)
Reads 3 sponge bytes, masks the high bit, and re-reads without any counter when the value is out of range. The only exit is rbx (remaining coefficients) reaching 0.
000000018002A399 mov ebx, 100h ; 256 coefficients to place
000000018002A39E xor r9d, r9d
000000018002A3A1 lea rdx, [rsp+130h+var_110]
000000018002A3A6 lea rcx, [rsp+130h+var_100]
000000018002A3AB lea r8d, [r9+3]
000000018002A3AF call SymCryptKeccakExtract ; read 3 sponge bytes
000000018002A3B4 and byte ptr [rsp+130h+var_110+2], 7Fh
000000018002A3B9 mov eax, [rsp+130h+var_110]
000000018002A3BD cmp eax, 7FE001h
000000018002A3C2 jnb short loc_18002A39E ; reject: re-read, no counter
000000018002A3C4 mov [rdi], eax
000000018002A3C6 add rdi, 4
000000018002A3CA sub rbx, 1
000000018002A3CE jnz short loc_18002A39E ; loop until 256 placed
SymCryptMlDsaRejNttPoly — patched loop (0x1800272EC)
r14d counts every sponge read and caps at 0x12A; on exhaustion the function returns 0x8008.
0000000180027363 mov [rsp+140h+var_120], edi
0000000180027367 mov ebx, edi
0000000180027369 cmp r14d, 12Ah ; iteration cap (298)
0000000180027370 jnb short loc_1800273AD ; exhausted -> error
0000000180027383 call SymCryptKeccakExtract
0000000180027388 and byte ptr [rsp+140h+var_120+2], 7Fh
000000018002738D inc r14d ; count every read
0000000180027390 mov eax, [rsp+140h+var_120]
0000000180027394 cmp eax, 7FE001h
0000000180027399 jnb short loc_180027369 ; reject: back to cap check
000000018002739B mov [rsi], eax
000000018002739D inc ebx
000000018002739F add rsi, 4
00000001800273A3 cmp ebx, 100h
00000001800273A9 jb short loc_180027369
00000001800273AD mov edi, 8008h ; loc_1800273AD: error code
SymCryptMlDsaRejBoundedPoly (0x18002A17C -> 0x180027128)
Unpatched loop-back 000000018002A2DC jb loc_18002A1FB has no counter; a sampled nibble equal to 0x80 (000000018002A292 cmp r8b, 80h / 000000018002A2B4 cmp dl, 80h) skips the store without advancing edi. Patched adds 00000001800271A6 cmp esi, 1E1h / 00000001800271AC jnb loc_1800272A9 and sets 00000001800272A9 mov edi, 8008h.
SymCryptMlDsaSampleInBall (0x18002A3F8 -> 0x1800273E0)
Unpatched inner loop 000000018002A4D2 ja loc_18002A4B6 re-reads a byte until it is <= the running threshold ebx, with no counter. Patched adds 000000018002749E cmp edi, 79h / 00000001800274A1 jnb loc_180027502 with 0000000180027502 mov r14d, 8008h.
All sampled values in these three functions originate from SymCryptKeccakExtract on a sponge seeded earlier in the same function; none originate directly from the caller's input buffer.
5. Reachability and Trigger Analysis
The three functions are reachable from two contexts in ci.dll:
-
SymCryptMlDsaSelftest(0x180022158) runs the SymCrypt FIPS self-test on hardcoded known-answer vectors (rgbMlDsaKeyPrivateSeed,rgbMlDsaSelfTestSignature,rgbMlDsaSelfTestMessage). This input is a compile-time constant, not attacker-controlled. -
HashpVerifyMldsaSignature(0x180087408) callsSymCryptMlDsaVerify(0x180021234).SymCryptMlDsaVerifyEx(0x180021278) decodes the signature and callsSymCryptMlDsaSampleInBall(0x1800213CB) to derive the challenge from the signature-supplied hash; the public key decode path (SymCryptMlDsaPkDecode->SymCryptMlDsaExpandA) reachesSymCryptMlDsaRejNttPoly. Here an attacker who supplies a malformed public key or signature controls the sponge seed.
SymCryptMlDsaRejBoundedPoly is reached only through SymCryptMlDsaExpandS, which runs on the key-generation / signing / private-key-decode path, not on the signature-verification path. It is not reachable from a caller-supplied public key or signature during image verification.
In every reachable case the attacker controls the sponge seed but not the sponge output. Because rejection sampling over a SHAKE stream terminates almost surely well below these caps, there is no input that drives any of the loops to their bound without a preimage-style break of SHAKE. No attacker-reachable trigger exists.
6. Impact Assessment
- No exploit primitive. The change does not correspond to a reachable hang, memory-safety issue, or logic bypass. The unbounded form terminates on all realistic sponge output; the patched form only guarantees termination on pathological output that no attacker can produce.
- No policy or signature-validation change. The accept/reject range checks (
0x7FE001= the ML-DSA prime q = 2^23 - 2^13 + 1), the coefficient counts (256), and the surrounding verification logic are identical in both builds. Only the loop bound and the error-return plumbing were added. - Nature of the change. This is upstream SymCrypt robustness hardening (guaranteeing bounded execution of rejection sampling) delivered to
ci.dllas part of the statically-linked SymCrypt rebuild.
7. Error-Propagation Changes (same origin)
The following callers were updated so that the new 0x8008 return code is observed rather than ignored. These are the caller-side half of the same library hardening and carry no independent security impact, because the underlying 0x8008 condition is not attacker-reachable:
SymCryptMlDsaExpandA(0x1800293F4->0x180026374): checks theRejNttPolyreturn in its nested loop and exits on error.SymCryptMlDsaExpandS(0x180029594->0x18002651C): checks theRejBoundedPolyreturn and exits on error.SymCryptMlDsaPkDecode(0x180029950->0x1800268FC): checks theExpandAreturn before proceeding.SymCryptMlDsaSignEx(0x180020D8C->0x180021394): adds an overall iteration bound and propagates decoder/mask errors (sign path).SymCryptMlDsaVerifyEx(0x180021278->0x180021890): propagates theSampleInBallreturn.
8. Changed Functions — Full Triage
SymCryptMlDsaRejBoundedPoly(unpatched0x18002A17C/ patched0x180027128, 0.9187 sim): Added fixed iteration cap0x1E1(481) to the byte/nibble rejection loop, returning0x8008on exhaustion. Sampled values come from the SHAKE sponge; reached only via the sign/keygen path (ExpandS). Library hardening, not attacker-reachable.SymCryptMlDsaRejNttPoly(unpatched0x18002A31C/ patched0x1800272EC, 0.5277 sim): Added fixed iteration cap0x12A(298) to the 3-byte rejection loop, returning0x8008. Sponge-driven. Library hardening.SymCryptMlDsaSampleInBall(unpatched0x18002A3F8/ patched0x1800273E0, 0.7630 sim): Added fixed iteration cap0x79(121) to the inner rejection loop, returning0x8008. Sponge-driven. Library hardening.SymCryptMlDsaExpandA(unpatched0x1800293F4/ patched0x180026374, 0.4969 sim): Propagates theRejNttPolyerror out of its nested loops. Caller-side plumbing for the above.SymCryptMlDsaExpandS(unpatched0x180029594/ patched0x18002651C, 0.7159 sim): Propagates theRejBoundedPolyerror.SymCryptMlDsaPkDecode(unpatched0x180029950/ patched0x1800268FC, 0.9432 sim): Checks theExpandAreturn before continuing.SymCryptMlDsaSignEx(unpatched0x180020D8C/ patched0x180021394, 0.5245 sim): Adds an overall iteration bound plus a NULL check and error propagation on the sign path.SymCryptMlDsaVerifyEx(unpatched0x180021278/ patched0x180021890, 0.9432 sim): Propagates theSampleInBallerror.
The remaining changed functions are register reallocations and function relocations caused by the size shift, plus telemetry/error-code adjustments (e.g. DbgPrint value 0x2718 -> 0x273e). The CustomKernelSignerLockedEnabled cluster is not merely staged feature work: its core function SIPolicyAreCustomKernelSignersAllowed (0x180073964 -> 0x1800764A8) is a genuine trust-decision hardening and is written up as a security-relevant finding in section 1a. The supporting members of that cluster (SIPolicyCheckCustomKernelSignersAllowed 0x18007652C, the registry handler CipUpdateCustomKernelSignerEnabledState 0x180018DE8, SIPolicySetCustomKernelSignersLockedEnabled 0x180076558, and the logging/telemetry call sites at 0x1800638fc and 0x1800e2478) implement the lock byte and its consumers that the finding depends on. The register/relocation and DbgPrint entries are not security-relevant to this diff.
9. Unmatched Functions
- Removed: 0
- Added: 0
10. Confidence & Caveats
- Confidence: High. The added iteration counters, the
0x8008return, and the caller-side checks are unambiguous in both the disassembly and the decompilation, and match by function content across the relocation. The sponge-seeded data flow (SymCryptKeccakAppendthenSymCryptKeccakExtract) is explicit in both builds. - Caveats: The functions are genuine post-quantum ML-DSA (Dilithium) rejection-sampling routines statically linked from SymCrypt (
0x7FE001is the ML-DSA prime q; the 256-entry outputs are degree-256 ring elements). They are reachable fromHashpVerifyMldsaSignaturewith an attacker-controlled seed, but not with attacker-controlled sponge output, which is what a hang would require. The change is therefore classified as library termination hardening with no attacker-reachable security impact.