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 SIPolicyAreCustomKernelSignersAllowed so that the custom-kernel-signer allowance folded into g_CiDeveloperMode bit 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 (unpatched 0x180073964 / patched 0x1800764A8), plus the newly-split helper SIPolicyCheckCustomKernelSignersAllowed (patched 0x18007652C).
  • 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)

  1. 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.
  2. 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.
  3. 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 (unpatched 0x18002A17C / patched 0x180027128), SymCryptMlDsaRejNttPoly (unpatched 0x18002A31C / patched 0x1800272EC), SymCryptMlDsaSampleInBall (unpatched 0x18002A3F8 / patched 0x1800273E0).
  • 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 code 0x8008 (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 SymCryptKeccakExtract reading 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 (for RejNttPoly, 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:

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

  2. HashpVerifyMldsaSignature (0x180087408) calls SymCryptMlDsaVerify (0x180021234). SymCryptMlDsaVerifyEx (0x180021278) decodes the signature and calls SymCryptMlDsaSampleInBall (0x1800213CB) to derive the challenge from the signature-supplied hash; the public key decode path (SymCryptMlDsaPkDecode -> SymCryptMlDsaExpandA) reaches SymCryptMlDsaRejNttPoly. 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.dll as 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 the RejNttPoly return in its nested loop and exits on error.
  • SymCryptMlDsaExpandS (0x180029594 -> 0x18002651C): checks the RejBoundedPoly return and exits on error.
  • SymCryptMlDsaPkDecode (0x180029950 -> 0x1800268FC): checks the ExpandA return before proceeding.
  • SymCryptMlDsaSignEx (0x180020D8C -> 0x180021394): adds an overall iteration bound and propagates decoder/mask errors (sign path).
  • SymCryptMlDsaVerifyEx (0x180021278 -> 0x180021890): propagates the SampleInBall return.

8. Changed Functions — Full Triage

  • SymCryptMlDsaRejBoundedPoly (unpatched 0x18002A17C / patched 0x180027128, 0.9187 sim): Added fixed iteration cap 0x1E1 (481) to the byte/nibble rejection loop, returning 0x8008 on exhaustion. Sampled values come from the SHAKE sponge; reached only via the sign/keygen path (ExpandS). Library hardening, not attacker-reachable.
  • SymCryptMlDsaRejNttPoly (unpatched 0x18002A31C / patched 0x1800272EC, 0.5277 sim): Added fixed iteration cap 0x12A (298) to the 3-byte rejection loop, returning 0x8008. Sponge-driven. Library hardening.
  • SymCryptMlDsaSampleInBall (unpatched 0x18002A3F8 / patched 0x1800273E0, 0.7630 sim): Added fixed iteration cap 0x79 (121) to the inner rejection loop, returning 0x8008. Sponge-driven. Library hardening.
  • SymCryptMlDsaExpandA (unpatched 0x1800293F4 / patched 0x180026374, 0.4969 sim): Propagates the RejNttPoly error out of its nested loops. Caller-side plumbing for the above.
  • SymCryptMlDsaExpandS (unpatched 0x180029594 / patched 0x18002651C, 0.7159 sim): Propagates the RejBoundedPoly error.
  • SymCryptMlDsaPkDecode (unpatched 0x180029950 / patched 0x1800268FC, 0.9432 sim): Checks the ExpandA return before continuing.
  • SymCryptMlDsaSignEx (unpatched 0x180020D8C / patched 0x180021394, 0.5245 sim): Adds an overall iteration bound plus a NULL check and error propagation on the sign path.
  • SymCryptMlDsaVerifyEx (unpatched 0x180021278 / patched 0x180021890, 0.9432 sim): Propagates the SampleInBall error.

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 0x8008 return, 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 (SymCryptKeccakAppend then SymCryptKeccakExtract) is explicit in both builds.
  • Caveats: The functions are genuine post-quantum ML-DSA (Dilithium) rejection-sampling routines statically linked from SymCrypt (0x7FE001 is the ML-DSA prime q; the 256-entry outputs are degree-256 ring elements). They are reachable from HashpVerifyMldsaSignature with 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.