1. Overview

Field Value
Unpatched binary ci_unpatched.dll
Patched binary ci_patched.dll
Overall similarity 0.9579 (95.79%)
Matched functions 2566
Changed functions 83
Identical functions 2483
Unmatched (unpatched) 0
Unmatched (patched) 0

Verdict: Most of the changed functions are part of the statically-linked SymCrypt cryptographic library embedded in ci.dll, not the Authenticode/PE certificate-chain code. The patch hardens SymCrypt's FIPS 204 ML-DSA (Module-Lattice Digital Signature Algorithm, the post-quantum "Dilithium" scheme) sampling routines and the RSA private-key setup path: it adds an unchecked-return-value fix in SymCryptMlDsaExpandS, an RSA key consistency check (verify that the two prime factors multiply back to the modulus) in SymCryptRsakeyCalculatePrivateFields, and finite iteration bounds on two rejection-sampling loops (SymCryptMlDsaSampleInBall, SymCryptMlDsaRejBoundedPoly) that read bytes from a SHAKE/Keccak sponge. These are robustness/defensive hardening changes to key-generation and self-test crypto code; they are not PE signature-verification, certificate-chain, or ASN.1-parsing changes, and the SymCrypt error codes involved are 0x8008 (SYMCRYPT_EXTERNAL_FAILURE) and 0x800E (SYMCRYPT_INVALID_ARGUMENT).

In addition, the patch tightens the Code Integrity SI-policy "custom kernel signers" decision (Finding 5 below), which is NOT part of SymCrypt. SIPolicyAreCustomKernelSignersAllowed decides whether a Secure Kernel / driver signing evaluation may accept custom (non-Microsoft) kernel signers. The unpatched version scanned every loaded SI policy — including supplemental policies — and returned "allowed" if any of them set the enable bits in the policy field at +0x708. The patched version (1) refuses unless a new lock/enable byte byte_180048F08 has been explicitly set (via the new SIPolicySetCustomKernelSignersLockedEnabled, driven from a registry-backed init path) and (2) restricts the enabling policy to a base policy via SIPolicyIsBasePolicy, so a supplemental policy can no longer enable custom kernel signers. This is a real protection-mechanism-failure fix (CWE-693) on the kernel-mode driver-signature-enforcement trust boundary; the result feeds the g_CiDeveloperMode custom-kernel-signers bit (bit 0xD).


2. Vulnerability Summary

Finding 1: Unchecked Return Value in ML-DSA Secret-Vector Expansion

  • Severity: Low
  • Vulnerability class: CWE-252 / CWE-754 — unchecked return value
  • Affected function: SymCryptMlDsaExpandS (sub_180029484) (patched: SymCryptMlDsaExpandS (sub_18002651C))
  • Entry point: ML-DSA (FIPS 204) key generation / key import inside SymCrypt

Root cause: SymCryptMlDsaExpandS expands the ML-DSA secret vectors s1 and s2 from a seed by calling SymCryptMlDsaRejBoundedPoly (sub_18002A06C) once per polynomial. In the unpatched version each call is invoked as if it returned void — its return value is discarded — and the function always returns the result of SymCryptWipeAsm (sub_180011800), which is 0. If SymCryptMlDsaRejBoundedPoly fails (for example by hitting its sampling bound and returning 0x8008 SYMCRYPT_EXTERNAL_FAILURE, see Finding 4), that failure is lost and expansion reports success with an incompletely filled polynomial. The patched version captures the return value in a local (v9), breaks out of the loop on any non-zero status, and returns that status.

Call chain (SymCrypt-internal): 1. CiInitialize — CI subsystem initialization 2. SymCryptMlDsaSelftest (sub_180022048) — ML-DSA power-on self-test (fixed self-test vectors) 3. SymCryptMlDsakeySetValue (sub_1800215E0) — ML-DSA key import 4. SymCryptMlDsaKeyGenerateEx (sub_180020A14) — ML-DSA key generation (also updated to check the return of SymCryptMlDsaExpandS) 5. SymCryptMlDsaExpandS (sub_180029484) — secret-vector expansion loop (unchecked return) 6. SymCryptMlDsaRejBoundedPoly (sub_18002A06C) — per-polynomial rejection sampling (return value discarded)


Finding 2: Missing RSA Key Consistency Check (p·q == modulus)

  • Severity: Low
  • Vulnerability class: CWE-754 — missing validation of cryptographic key material
  • Affected function: SymCryptRsakeyCalculatePrivateFields (sub_180002D3C) (patched: SymCryptRsakeyCalculatePrivateFields (sub_18000E324))
  • Entry point: RSA key import / private-field computation inside SymCrypt

Root cause: SymCryptRsakeyCalculatePrivateFields derives the RSA private CRT fields from the supplied primes. The unpatched version never checks that the supplied prime factors are consistent with the modulus. The patched version adds a new 7th parameter (a7) and, at the end of the two-prime path, when bit 0x200 is NOT set in a7 and there are exactly 2 primes (v21 == 2), multiplies the two primes with SymCryptFdefIntMulMixedSize (sub_1800260E0) and compares the product to the stored modulus with SymCryptFdefIntIsEqual (sub_180025E1C). If they match it returns the running status; otherwise it falls through to return 32782 (0x800E, SYMCRYPT_INVALID_ARGUMENT). The unpatched version accepts an RSA key whose primes do not multiply back to its modulus.

Call chain (SymCrypt-internal): 1. CiInitialize 2. SymCryptRsakeySetValue (sub_18000E754) / SymCryptRsaSelftest (sub_18000E7C4) 3. SymCryptRsakeySetValueInternal (sub_18001DA98) — RSA key import routine 4. SymCryptRsakeyCalculatePrivateFields (sub_180002D3C) — private-field computation (no p·q==n check)


Finding 3: Unbounded Rejection-Sampling Loop in ML-DSA SampleInBall

  • Severity: Low
  • Vulnerability class: CWE-835 — loop with unreachable exit condition
  • Affected function: SymCryptMlDsaSampleInBall (sub_18002A2E8) (patched: SymCryptMlDsaSampleInBall (sub_1800273E0))
  • Entry point: ML-DSA (FIPS 204) signing / challenge-polynomial sampling inside SymCrypt

Root cause: SymCryptMlDsaSampleInBall samples the ML-DSA challenge polynomial c (a polynomial with exactly tau non-zero ±1 coefficients) using rejection sampling. Its inner loop squeezes one byte at a time from a SHAKE/Keccak sponge via SymCryptKeccakExtract (sub_18002BB78) and resamples while the byte exceeds the current index threshold (rbx_1). In the unpatched version this inner loop has no iteration limit. The patched version adds a counter with a limit of 0x79 (121) iterations, returning 0x8008 (SYMCRYPT_EXTERNAL_FAILURE) if exceeded. The byte source is the Keccak sponge, not attacker-supplied ASN.1.

Call chain (SymCrypt-internal): 1. CiInitialize 2. SymCryptMlDsaSelftest (sub_180022048) 3. SymCryptMlDsaSignEx (sub_180020C7C) 4. SymCryptMlDsaSampleInBall (sub_18002A2E8) — unbounded inner rejection loop


Finding 4: Unbounded Rejection-Sampling Loop in ML-DSA RejBoundedPoly

  • Severity: Low
  • Vulnerability class: CWE-835 — loop with unreachable exit condition
  • Affected function: SymCryptMlDsaRejBoundedPoly (sub_18002A06C) (patched: SymCryptMlDsaRejBoundedPoly (sub_180027128))
  • Entry point: ML-DSA (FIPS 204) key generation / secret-coefficient sampling inside SymCrypt

Root cause: SymCryptMlDsaRejBoundedPoly rejection-samples the coefficients of a secret polynomial in the range [-eta, eta] by squeezing bytes from a SHAKE/Keccak sponge via SymCryptKeccakExtract (sub_18002BB78) and mapping each nibble; nibbles out of range decode to a 0x80 skip marker that does not advance the output count. The outer loop stops when 0x100 (256) coefficients are produced, but the unpatched version has no independent limit on how many sponge bytes are consumed — a long run of skip-producing nibbles keeps the loop reading without filling the polynomial. The patched version adds a byte counter with a limit of 0x1E1 (481), returning 0x8008 (SYMCRYPT_EXTERNAL_FAILURE) if exceeded. The byte source is the Keccak sponge (fresh generated output), so there is no fixed input buffer to overread.

Call chain (SymCrypt-internal): 1. CiInitialize 2. SymCryptMlDsaSelftest (sub_180022048) 3. SymCryptMlDsakeySetValue (sub_1800215E0) 4. SymCryptMlDsaKeyGenerateEx (sub_180020A14) 5. SymCryptMlDsaExpandS (sub_180029484) 6. SymCryptMlDsaRejBoundedPoly (sub_18002A06C) — unbounded sampling loop


Finding 5: Custom Kernel Signers Enabled by Supplemental SI Policy Without Lock/Enable Gate

  • Severity: Medium
  • Vulnerability class: CWE-693 — protection mechanism failure (kernel-mode driver signature enforcement / custom kernel signers)
  • Affected function: SIPolicyAreCustomKernelSignersAllowed (unpatched: @ 0x1800706C4; patched: @ 0x1800764A8)
  • New helper functions in patched build: SIPolicyCheckCustomKernelSignersAllowed (@ 0x18007652C) and SIPolicySetCustomKernelSignersLockedEnabled (@ 0x180076558) — neither has a content match in the unpatched binary
  • Entry point: Code Integrity SI-policy initialization / driver-signing-level evaluation (SIPolicyInitSystemCipInitializeSiPolicy), where the result sets the custom-kernel-signers bit (bit 0xD) in g_CiDeveloperMode

Root cause: SIPolicyAreCustomKernelSignersAllowed answers "may this system accept custom (non-Microsoft) kernel signers?" by inspecting the enable flags at offset +0x708 of each loaded SI policy object.

The unpatched version walks the entire g_SiPolicyHandles array (0 .. g_NumberOfSiPolicies) and returns true as soon as ANY policy has the relevant bit set at +0x708 (test al, 18h when the caller flag is set, else test al, 4). There is no lock/enable gate and no distinction between a base policy and a supplemental policy — a supplemental policy could set the bit and enable custom kernel signers.

The patched version adds two independent restrictions: 1. Lock/enable gate. Before doing anything it compares the new byte cs:byte_180048F08 against zero; if it is unset the function immediately returns false. This byte is written only by the new SIPolicySetCustomKernelSignersLockedEnabled, which is called from a registry-backed initialization path (ZwQueryValueKey/ZwSetValueKey at ~0x180018E4F/0x180018E9F, then call SIPolicySetCustomKernelSignersLockedEnabled @ 0x180018EAD). The byte has zero references in the unpatched binary. 2. Base-policy restriction. Inside the per-policy loop it now calls the new SIPolicyIsBasePolicy on each handle and stops the scan when the policy is not a base policy, so a supplemental policy is no longer considered. (SIPolicyIsBasePolicy itself already exists in the unpatched binary at 0x1800713D8; what is new is that this decision function now consults it. The unpatched SIPolicyAreCustomKernelSignersAllowed never called it.) The per-policy flag test is delegated to the new SIPolicyCheckCustomKernelSignersAllowed, which re-checks the same lock byte.

Net effect: the patch is strictly stricter. It moves control of custom kernel signers from "any loaded SI policy, no lock" to "base policy only, and only after an explicit lock/enable state has been set." Because the decision feeds the kernel-mode driver-signature-enforcement path (custom-kernel-signers bit in g_CiDeveloperMode), the pre-patch behavior let a lower-trust supplemental policy relax which kernel drivers are accepted — a code-integrity trust-boundary weakening. This is not gated by any WIL/Feature_* staging flag, and the old permissive scan is not retained in the patched build.

Reachability: SIPolicyAreCustomKernelSignersAllowed is called during SI-policy initialization (unpatched @ 0x18001A4D8, patched @ 0x18001A7AC) where its return value is folded into g_CiDeveloperMode bit 0xD, and again from a second SI-policy evaluation site in the patched build (@ 0x18006BC0C). Practical abuse requires the ability to install/load a supplemental SI policy, which is itself a privileged operation, which is why this is rated Medium rather than High.

Call chain: 1. SIPolicyInitSystem — SI-policy subsystem init (called from the CI policy load path) 2. CipInitializeSiPolicy — per-policy initialization 3. SIPolicyAreCustomKernelSignersAllowed — custom-kernel-signers decision (result → g_CiDeveloperMode bit 0xD)


3. Pseudocode Diff

Finding 1: SymCryptMlDsaExpandS (sub_180029484) — Ignored Return Value

// ===== UNPATCHED SymCryptMlDsaExpandS (sub_180029484) =====
// Return value of SymCryptMlDsaRejBoundedPoly is IGNORED:

    for ( ; v10 < v5; ++v10 )
    {
        v14 = v10;
        // -- VULNERABILITY: return value discarded (called as void) --
        SymCryptMlDsaRejBoundedPoly(a1, v13, a3, a4 + (v10 << 0xa) + 8);
    }
    // ...
    return SymCryptWipeAsm((__int64)v13, 0x42u);  // wipe cleanup — always returns 0

// ===== PATCHED SymCryptMlDsaExpandS (sub_18002651C) =====
// Return value is now CHECKED:

    while (1)
    {
        v15 = v10;
        v9 = SymCryptMlDsaRejBoundedPoly(a1, v14, a3, a4 + (v10 << 0xa) + 8);
        if (v9 != 0)                           // -- FIX: check for error --
            break;                             // -- FIX: stop on error --
        if (++v10 >= v5)
            goto LABEL_4;
    }
    // ...
    SymCryptWipeAsm((__int64)v14, 0x42u);
    return v9;  // returns actual error status

Finding 2: SymCryptRsakeyCalculatePrivateFields (sub_180002D3C) — Missing p·q==modulus Check

// ===== UNPATCHED SymCryptRsakeyCalculatePrivateFields (sub_180002D3C) =====
// After the private-field loops, function just returns v13.
// NO prime/modulus consistency check performed.

// ===== PATCHED SymCryptRsakeyCalculatePrivateFields (sub_18000E324) =====
// New consistency-check block ADDED (a7 is the new 7th parameter):

    if ((a7 & 0x200) != 0)                     // -- FIX: skip flag --
        return v16;
    if (v21 == 2)                              // -- FIX: exactly two primes --
    {
        // -- FIX: product of the two primes --
        SymCryptFdefIntMulMixedSize(*(a1 + 128) + 96, *(a1 + 136) + 96, a4);
        // -- FIX: compare product against stored modulus --
        if (SymCryptFdefIntIsEqual(a4, *(a1 + 120) + 96) != 0)
            return v16;                        // p*q == n : ok
    }
    return 32782;                              // 0x800E SYMCRYPT_INVALID_ARGUMENT

Finding 3: SymCryptMlDsaSampleInBall (sub_18002A2E8) — Unbounded Rejection Loop

// ===== UNPATCHED SymCryptMlDsaSampleInBall (sub_18002A2E8) =====
// Inner rejection loop has NO iteration limit:

        do
        {
            SymCryptKeccakExtract(v17, v16, 1);  // squeeze one SHAKE byte
            // -- VULNERABILITY: no counter, no limit --
        } while ((unsigned __int8)v16[0] > v10);  // resample while byte > threshold

// ===== PATCHED SymCryptMlDsaSampleInBall (sub_1800273E0) =====
// Counter with limit of 0x79 (121) iterations:

            do
            {
                if (v12 >= 0x79)               // -- FIX: iteration limit --
                {
                    v8 = 32776;                // -- FIX: 0x8008 SYMCRYPT_EXTERNAL_FAILURE --
                    goto LABEL_9;
                }
                SymCryptKeccakExtract(v17, v16, 1);
                ++v12;                          // -- FIX: increment counter --
            } while ((unsigned __int8)v16[0] > v10);

Finding 4: SymCryptMlDsaRejBoundedPoly (sub_18002A06C) — Unbounded Rejection Loop

// ===== UNPATCHED SymCryptMlDsaRejBoundedPoly (sub_18002A06C) =====
// No sponge-byte counter — only output coefficient count bounded:

    do
    {
        SymCryptKeccakExtract(v19, v18, 1);   // squeeze one SHAKE byte
        // ... map nibbles, out-of-range nibbles decode to 0x80 skip
        // -- VULNERABILITY: no limit on sponge bytes consumed --
    } while (v8 < 0x100);                       // only coefficient count bounded

// ===== PATCHED SymCryptMlDsaRejBoundedPoly (sub_180027128) =====
// Sponge-byte counter with limit of 0x1e1 (481):

    while (v9 < 0x1E1)                          // -- FIX: byte counter limit --
    {
        SymCryptKeccakExtract(v19, v18, 1);
        // ... map nibbles
        ++v9;                                   // -- FIX: increment counter --
        if (v8 >= 0x100)
            goto LABEL_24;
    }
    v7 = 32776;                                 // -- FIX: 0x8008 SYMCRYPT_EXTERNAL_FAILURE --

4. Assembly Analysis

Finding 1: SymCryptMlDsaExpandS (sub_180029484) — Ignored Return Value

Unpatched assembly (vulnerable):

; UNPATCHED SymCryptMlDsaExpandS @ 0x180029484
; s1 loop (head at loc_1800294E9): return value of SymCryptMlDsaRejBoundedPoly discarded
0000000180029505  call    SymCryptMlDsaRejBoundedPoly   ; sample one secret polynomial
000000018002950A  inc     di                            ; advance index; rax (status) never read
0000000180029510  cmp     eax, r14d
0000000180029513  jb      short loc_1800294E9           ; loop regardless of status
; ... s2 loop at loc_180029519 is identical (call at 0x180029539, status discarded) ...
0000000180029548  mov     edx, 42h
000000018002954D  lea     rcx, [rsp+0B8h+var_98]
0000000180029552  call    SymCryptWipeAsm               ; cleanup; sampling status is never returned
0000000180029575  retn

Patched assembly (fixed):

; PATCHED SymCryptMlDsaExpandS @ 0x18002651C
00000001800265A7  call    SymCryptMlDsaRejBoundedPoly   ; sample one secret polynomial
00000001800265AC  mov     edi, eax                      ; FIX: capture status
00000001800265AE  test    eax, eax
00000001800265B0  jnz     short loc_1800265FA           ; FIX: break out on error
00000001800265B2  inc     bx
00000001800265B8  cmp     ecx, r14d
00000001800265BB  jb      short loc_18002658B
; ... shared exit at loc_1800265FA ...
00000001800265FA  mov     edx, 42h
00000001800265FF  lea     rcx, [rsp+0B8h+var_98]
0000000180026604  call    SymCryptWipeAsm
0000000180026609  mov     eax, edi                      ; FIX: return captured status

Key change: The patched version adds mov edi, eax / test eax, eax / jnz loc_1800265FA immediately after each SymCryptMlDsaRejBoundedPoly call, and after the wipe it executes mov eax, edi, so the captured sampling status becomes the return value instead of the wipe result.


Finding 3: SymCryptMlDsaSampleInBall (sub_18002A2E8) — Unbounded Rejection Loop

Unpatched assembly (vulnerable — abbreviated, showing the critical loop):

; UNPATCHED SymCryptMlDsaSampleInBall @ 0x18002A2E8
; inner rejection loop (head loc_18002A3A6) — no iteration counter
000000018002A3A6  xor     r9d, r9d
000000018002A3A9  lea     rdx, [rsp+160h+var_140]   ; buffer for squeezed byte
000000018002A3AE  mov     r8, rsi
000000018002A3B1  lea     rcx, [rsp+160h+var_130]   ; Keccak sponge state
000000018002A3B6  call    SymCryptKeccakExtract     ; squeeze 1 byte
000000018002A3BB  movzx   eax, [rsp+160h+var_140]   ; load squeezed byte
000000018002A3C0  cmp     eax, ebx                  ; byte vs threshold (rbx_1)
000000018002A3C2  ja      short loc_18002A3A6       ; resample; no counter, can spin forever

Patched assembly (fixed):

; PATCHED SymCryptMlDsaSampleInBall @ 0x1800273E0
000000018002749B  mov     edi, r14d                 ; FIX: init iteration counter (r14d = 0)
000000018002749E  loc_18002749E:                    ; inner loop head
000000018002749E  cmp     edi, 79h                  ; FIX: iteration limit (121)
00000001800274A1  jnb     short loc_180027502       ; FIX: overflow -> error path
00000001800274A3  xor     r9d, r9d
00000001800274A6  lea     rdx, [rsp+160h+var_140]
00000001800274AB  lea     rcx, [rsp+160h+var_130]
00000001800274B0  lea     r8d, [r9+1]
00000001800274B4  call    SymCryptKeccakExtract     ; squeeze 1 byte
00000001800274B9  movzx   eax, [rsp+160h+var_140]
00000001800274BE  inc     edi                       ; FIX: advance counter
00000001800274C0  cmp     eax, ebx
00000001800274C2  ja      short loc_18002749E        ; resample (bounded by 0x79)
; ... error path:
0000000180027502  mov     r14d, 8008h               ; FIX: SYMCRYPT_EXTERNAL_FAILURE

Key change: The patched version uses edi as an iteration counter (initialised to 0), checks cmp edi, 79h at the loop head before each squeeze, and on overflow jumps to loc_180027502, which sets the return register to 0x8008 (SYMCRYPT_EXTERNAL_FAILURE).


Finding 4: SymCryptMlDsaRejBoundedPoly (sub_18002A06C) — Unbounded Rejection Loop

Unpatched assembly (vulnerable — showing the critical loop structure):

; UNPATCHED SymCryptMlDsaRejBoundedPoly @ 0x18002A06C
000000018002A0EB  loc_18002A0EB:                    ; main loop head
000000018002A0EB  xor     r9d, r9d
000000018002A0FC  call    SymCryptKeccakExtract     ; squeeze 1 byte
000000018002A101  mov     cl, [rsp+140h+var_11F]    ; load squeezed byte
000000018002A105  mov     al, [r14+16h]             ; eta selector (2 or 4)
; ... nibble decoding; out-of-range nibbles set the 0x80 skip marker ...
000000018002A1A1  cmp     dl, 80h
000000018002A1A4  jz      short loc_18002A1C6       ; skip marker — does NOT advance output
000000018002A1CC  jb      loc_18002A0EB             ; cmp edi,100h/jb — only output count bounded
; --- no counter on bytes squeezed from the sponge ---
000000018002A1D2  loc_18002A1D2:                    ; exit when output full (edi >= 0x100)

Patched assembly (fixed):

; PATCHED SymCryptMlDsaRejBoundedPoly @ 0x180027128
00000001800271A6  loc_1800271A6:                    ; main loop head
00000001800271A6  cmp     esi, 1E1h                 ; FIX: sponge-byte limit (481), checked before squeeze
00000001800271AC  jnb     loc_1800272A9             ; FIX: overflow -> error path
00000001800271C3  call    SymCryptKeccakExtract     ; squeeze 1 byte
; ... nibble decoding ...
000000018002724F  inc     esi                       ; FIX: advance sponge-byte counter
000000018002729B  cmp     ebx, 100h
00000001800272A1  jb      loc_1800271A6             ; loop back; re-checks the esi limit at the head
00000001800272A9  mov     edi, 8008h                ; FIX: SYMCRYPT_EXTERNAL_FAILURE

Key change: The patched version introduces esi as a sponge-byte counter, checked with cmp esi, 1E1h at the loop head (before each squeeze) with a limit of 0x1E1 (481). If the byte count reaches this limit before the output polynomial is full, control falls to loc_1800272A9, which returns error code 0x8008 (SYMCRYPT_EXTERNAL_FAILURE).


Finding 5: SIPolicyAreCustomKernelSignersAllowed — Custom Kernel Signers Gate

Unpatched assembly (permissive — scans every SI policy, no lock, no base-policy check):

; UNPATCHED SIPolicyAreCustomKernelSignersAllowed @ 0x1800706C4
00000001800706C4  xor     r8d, r8d                       ; result = 0
00000001800706C7  mov     r9d, r8d                       ; i = 0
00000001800706CA  cmp     r9d, cs:g_NumberOfSiPolicies    ; loop over ALL policies
00000001800706D1  jnb     short loc_1800706FB
00000001800706D3  mov     rax, cs:g_SiPolicyHandles
00000001800706DA  mov     edx, r9d
00000001800706DD  mov     rax, [rax+rdx*8]                ; handle = handles[i]
00000001800706E1  mov     eax, [rax+708h]                 ; enable flags at +0x708
00000001800706E7  test    cl, cl
00000001800706E9  jz      short loc_1800706EF
00000001800706EB  test    al, 18h                         ; no base-policy filter, no lock check
00000001800706ED  jmp     short loc_1800706F1
00000001800706EF  test    al, 4
00000001800706F1  jnz     short loc_1800706F8
00000001800706F3  inc     r9d                             ; next policy (incl. supplemental)
00000001800706F6  jmp     short loc_1800706CA
00000001800706F8  mov     r8b, 1                          ; ANY policy with bit set -> allowed
00000001800706FB  mov     al, r8b
00000001800706FE  retn

Patched assembly (hardened — lock gate + base-policy restriction + delegated flag test):

; PATCHED SIPolicyAreCustomKernelSignersAllowed @ 0x1800764A8
00000001800764B7  xor     r9d, r9d
00000001800764BA  mov     sil, cl
00000001800764BD  cmp     cs:byte_180048F08, r9b          ; FIX: lock/enable gate
00000001800764C4  jnz     short loc_1800764CA
00000001800764C6  xor     al, al                          ; FIX: lock unset -> return false
00000001800764C8  jmp     short loc_180076513
00000001800764CA  mov     ebx, cs:g_NumberOfSiPolicies
00000001800764D0  mov     r10d, r9d
00000001800764D3  test    ebx, ebx
00000001800764D5  jz      short loc_180076510
00000001800764D7  mov     r11, cs:g_SiPolicyHandles
00000001800764DE  mov     rdi, [r11]
00000001800764E1  mov     rcx, rdi
00000001800764E4  call    SIPolicyIsBasePolicy            ; FIX: base-policy filter
00000001800764E9  test    al, al
00000001800764EB  jz      short loc_180076510             ; FIX: not a base policy -> stop
00000001800764ED  mov     ecx, [rdi+708h]
00000001800764F3  mov     dl, sil
00000001800764F6  call    SIPolicyCheckCustomKernelSignersAllowed  ; FIX: delegated flag test
00000001800764FB  test    al, al
00000001800764FD  jnz     short loc_18007650D
000000018007650D  mov     r9b, 1
0000000180076510  mov     al, r9b

New helper SIPolicyCheckCustomKernelSignersAllowed (re-checks the same lock byte):

; PATCHED SIPolicyCheckCustomKernelSignersAllowed @ 0x18007652C
000000018007652C  cmp     cs:byte_180048F08, 0            ; FIX: lock/enable re-check
0000000180076533  jz      short loc_18007654C
0000000180076535  test    dl, dl
0000000180076537  jz      short loc_18007654C
0000000180076539  test    cl, 8                           ; flag test on the +0x708 value passed in ecx
000000018007653C  jnz     short loc_180076548
000000018007653E  shr     ecx, 4
0000000180076541  and     cl, 1
0000000180076544  mov     al, cl
0000000180076546  retn
0000000180076548  mov     al, 1
000000018007654A  retn
000000018007654C  xor     al, al                          ; lock unset / arg zero -> not allowed
000000018007654E  retn

New helper SIPolicySetCustomKernelSignersLockedEnabled (only writer of the lock byte):

; PATCHED SIPolicySetCustomKernelSignersLockedEnabled @ 0x180076558
0000000180076558  mov     cs:byte_180048F08, cl           ; sets the lock/enable state
000000018007655E  retn

Registry-backed writer of the lock byte (init path):

; PATCHED - init path that supplies the lock value
0000000180018E9F  call    cs:__imp_ZwSetValueKey          ; ntoskrnl
0000000180018EAB  mov     cl, bl
0000000180018EAD  call    SIPolicySetCustomKernelSignersLockedEnabled

Key change: The unpatched decision function has no lock gate and treats every SI policy (base or supplemental) equally — any policy with the enable bit at +0x708 yields "allowed." The patched function will not return "allowed" unless byte_180048F08 has been explicitly set (checked at 0x1800764BD and again inside SIPolicyCheckCustomKernelSignersAllowed @ 0x18007652C) and the enabling policy passes SIPolicyIsBasePolicy @ 0x1800764E4. byte_180048F08 has zero references in the unpatched binary.


5. Trigger Conditions

Finding 1: Unchecked Return in SymCryptMlDsaExpandS

  1. Reach the ML-DSA key path in the unpatched ci.dll — ML-DSA key generation or key import (SymCryptMlDsakeySetValue / SymCryptMlDsaKeyGenerateEx), or the ML-DSA power-on self-test.
  2. Cause SymCryptMlDsaRejBoundedPoly to fail for one of the secret polynomials (for example the sampling-bound overflow of Finding 4). Note the self-test path uses fixed vectors, so this is a robustness concern rather than an attacker-controlled input path.
  3. Observable effect: In the unpatched build the failing status is discarded; SymCryptMlDsaExpandS returns 0 and the caller proceeds with an incompletely sampled secret vector. In a debugger, SymCryptMlDsaRejBoundedPoly returns non-zero (0x8008) while SymCryptMlDsaExpandS returns 0. The patched build returns the error.

Finding 2: Missing p·q==modulus Check in SymCryptRsakeyCalculatePrivateFields

  1. Import an RSA key through SymCryptRsakeySetValueInternal with two prime factors that do not multiply back to the supplied modulus.
  2. Ensure the flags argument (a7) does NOT have bit 0x200 set (the bit that skips the new consistency check).
  3. Observable effect: The unpatched build derives private fields and returns success for the inconsistent key. The patched build multiplies the two primes and compares against the modulus (SymCryptFdefIntMulMixedSize + SymCryptFdefIntIsEqual); on mismatch it returns 32782 (0x800E, SYMCRYPT_INVALID_ARGUMENT).

Finding 3: Unbounded Rejection Loop in SymCryptMlDsaSampleInBall

  1. Reach ML-DSA signing (SymCryptMlDsaSignEx), which calls SymCryptMlDsaSampleInBall to sample the challenge polynomial c.
  2. Force the inner rejection loop to keep rejecting — the loop squeezes a byte from the Keccak sponge via SymCryptKeccakExtract and resamples while the byte exceeds the current index threshold (rbx_1, which starts at 0x100 - *(rsi+0x18) and increments per accepted coefficient). Because the byte source is the sponge, this requires a pathological or corrupted sponge state rather than external ASN.1 content.
  3. Observable effect (unpatched): the inner loop does not terminate; execution stays in loc_18002A3A60x18002A3C2, repeatedly calling SymCryptKeccakExtract at 0x18002A3B6. The patched build caps the loop at 0x79 (121) iterations and returns 0x8008 (SYMCRYPT_EXTERNAL_FAILURE).

Finding 4: Unbounded Rejection Loop in SymCryptMlDsaRejBoundedPoly

  1. Reach ML-DSA secret sampling (via SymCryptMlDsaExpandS), where SymCryptMlDsaRejBoundedPoly maps sponge bytes to coefficients. Out-of-range nibbles decode to a 0x80 skip marker that does not advance the coefficient count.
  2. For eta selector 4 (byte [r14+0x16] == 4): nibble values >= 9 produce the 0x80 skip.
  3. For eta selector 2 (byte [r14+0x16] == 2): nibble values >= 15 produce the 0x80 skip.
  4. The nibble-to-value formula is: ((nibble * 0xD) >> 6) * 5 - nibble + 2; out-of-range results become 0x80.
  5. Observable effect (unpatched): a long run of skip-producing sponge bytes keeps the loop reading without advancing the coefficient count (edi stays below 0x100). Because the bytes come from the Keccak sponge (fresh output), there is no fixed input buffer to overread — the concern is non-termination, not an OOB read. The patched build caps sponge reads at 0x1E1 (481) and returns 0x8008.

Finding 5: Custom Kernel Signers Gate in SIPolicyAreCustomKernelSignersAllowed

  1. Load a supplemental SI policy that sets the custom-kernel-signers enable bit in its policy object field at +0x708.
  2. Reach the CI SI-policy initialization / driver-signing evaluation path, where SIPolicyAreCustomKernelSignersAllowed is invoked (unpatched @ 0x18001A4D8) and its result is folded into g_CiDeveloperMode bit 0xD.
  3. Observable effect (unpatched): the function scans every loaded policy handle and returns true because the supplemental policy's +0x708 bit is set — there is no base-policy filter and no lock/enable gate, so custom kernel signers are enabled on the strength of a supplemental policy alone.
  4. Observable effect (patched): the function first checks byte_180048F08 (returns false if unset) and, inside the loop, skips/stops on any policy for which SIPolicyIsBasePolicy returns false, so the supplemental policy no longer enables custom kernel signers; only a base policy, and only after the lock/enable byte has been set through the registry-backed init path, can do so.

6. Exploit Primitive & Development Notes

Key-Validation / Error-Propagation Hardening (Findings 1 & 2)

Primitive: These are internal robustness fixes in SymCrypt's key-handling code, not an Authenticode/PE signature bypass. - Finding 1 ensures a failed secret-polynomial sampling status is propagated instead of being masked by an always-zero wipe return; without it, ML-DSA key material could be finalized in an incompletely sampled state. - Finding 2 adds an RSA key-consistency check so a key whose two primes do not multiply back to its modulus is rejected with SYMCRYPT_INVALID_ARGUMENT instead of silently accepted.

Reachability: These paths run during ML-DSA/RSA key generation, key import, and power-on self-tests inside ci.dll. The self-test paths operate on fixed vectors, and the key-import paths act on locally supplied key structures; there is no demonstrated path where remote or file-supplied signature data reaches these functions, so practical attacker impact is limited.

Non-Termination Hardening (Findings 3 & 4)

Primitive: Defensive iteration bounds on two ML-DSA rejection-sampling loops. Both loops draw bytes from a SHAKE/Keccak sponge; with a well-formed sponge they terminate with overwhelming probability, so the pre-patch risk is non-termination only under a pathological or corrupted sponge state rather than attacker-controlled input.

Notes: - No heap grooming or info leak is involved. - No OOB read (Finding 4): the byte source is fresh Keccak sponge output, not a bounded input buffer, so the concern is a hang, not memory disclosure.


7. Debugger PoC Playbook

Target: UNPATCHED ci.dll with WinDbg/KD attached (local kernel debugging while exercising ML-DSA/RSA key operations or self-tests).

Finding 1: Unchecked Return — SymCryptMlDsaExpandS (sub_180029484)

Breakpoints:

bp ci!sub_180029484
bp ci!sub_18002A06C
  • Why: SymCryptMlDsaExpandS (sub_180029484) is the secret-vector expansion loop that discards the sampling status. SymCryptMlDsaRejBoundedPoly (sub_18002A06C) is the per-polynomial sampler whose non-zero return is silently ignored.

What to inspect at each breakpoint: - At sub_180029484 entry: rcx = arg1 (ML-DSA key/state struct). Check byte [rcx+0x15] for the s1 polynomial count, byte [rcx+0x14] for the s2 count. - Inside the s1 loop at the call to sub_18002A06C (offset 0x180029505): - di = current polynomial index - r14d = s1 polynomial count (loop bound); ebp = s2 count - r9 (arg4) = per-polynomial output pointer = base + (index << 0xa) + 8 - At SymCryptMlDsaRejBoundedPoly (sub_18002A06C) return: - eax = status code. Non-zero means sampling failed — but the caller ignores it. - At offset 0x180029552: the call to SymCryptWipeAsm (sub_180011800) (secure wipe) — after it, the sampling status is not moved into eax, so it is discarded and never returned.

Key offsets: | Offset | Instruction | Significance | |--------|-------------|--------------| | 0x180029484 | function entry | Break here | | 0x180029505 | call SymCryptMlDsaRejBoundedPoly | s1-loop call — return value discarded | | 0x180029539 | call SymCryptMlDsaRejBoundedPoly | s2-loop call — also missing error check | | 0x180029552 | call SymCryptWipeAsm | Wipe cleanup; sampling status never reaches the return |

Trigger setup: 1. Reach the ML-DSA key-generation / key-import path in the unpatched ci.dll. 2. Force SymCryptMlDsaRejBoundedPoly to return non-zero for one polynomial (e.g. its sampling-bound overflow). 3. Observe that the failing status is dropped.

Expected observation: In the unpatched binary, SymCryptMlDsaRejBoundedPoly returns non-zero (0x8008 SYMCRYPT_EXTERNAL_FAILURE), but SymCryptMlDsaExpandS (sub_180029484) returns 0. No error propagates. The patched build returns the error.

Struct/offset notes: - arg1+0x14: s2 polynomial count - arg1+0x15: s1 polynomial count - arg1+0x80: not applicable (this is ML-DSA key expansion, not a certificate array) - Output stride: 0x400 bytes (1 << 10) per polynomial, accessed at (index << 0xa) + 8 + arg4


Finding 2: Missing p·q==modulus Check — SymCryptRsakeyCalculatePrivateFields (sub_180002D3C)

Breakpoints:

bp ci!sub_180002D3C
bp ci!sub_18001DA98
  • Why: SymCryptRsakeyCalculatePrivateFields (sub_180002D3C) is the function missing the prime/modulus consistency check. SymCryptRsakeySetValueInternal (sub_18001DA98) is the caller that (in the patched version) supplies the new a7 parameter.

What to inspect: - At sub_180002D3C entry: - arg1 (rcx) = RSA key struct - [arg1+0x1C] = number of prime factors (*(a1+28), should be 2 for the checked path) - [arg1+0x18] = CRT exponent count - [arg1+0x78]/[arg1+0x80]/[arg1+0x88] = modulus / prime[0] / prime[1] object pointers (the integer field is at +0x60) - In the patched version, observe the new a7 parameter and check bit 0x200. - After the private-field loops, observe that no p·q==n check occurs in the unpatched version.

Key offsets: | Offset | Instruction | Significance | |--------|-------------|--------------| | 0x180002D3C | function entry | Break here | | ~0x180002E71 | private-field loop | New check inserted after this in patched | | ~0x180002F05 | CRT inner loop | Consistency check follows after this in patched |

Trigger setup: 1. Import an RSA key via SymCryptRsakeySetValueInternal with two primes that do not multiply to the modulus. 2. Ensure the flags argument (a7) does not have bit 0x200 set. 3. Compute private fields.

Expected observation: Unpatched: inconsistent key accepted (returns success). Patched: SymCryptFdefIntIsEqual (sub_180025E1C) reports non-equal, function returns 32782 (0x800E, SYMCRYPT_INVALID_ARGUMENT).


Finding 3: Unbounded Rejection Loop — SymCryptMlDsaSampleInBall (sub_18002A2E8)

Breakpoints:

bp ci!sub_18002A2E8
  • Why: This function contains the unbounded inner rejection loop for challenge-polynomial sampling.

What to inspect: - ebx (rbx_1) = current index threshold, starts at 0x100 - *(rsi+0x18) and increments by 1 per accepted coefficient. - byte [rsp+0x20] (var_148[0]) = squeezed byte, compared against ebx. If always > ebx, the inner loop does not terminate. - rsi+0x18 = tau field from the ML-DSA parameter structure. - r14 = output buffer pointer (arg4) where the challenge polynomial is written.

Key offsets: | Offset | Instruction | Significance | |--------|-------------|--------------| | 0x18002A398 | lea rdi, [r14+rbx*4] | Outer loop — sets up output pointer | | 0x18002A3A6 | inner loop head (xor r9d, r9d) | Resample target | | 0x18002A3B6 | call SymCryptKeccakExtract | Squeeze 1 byte | | 0x18002A3C2 | ja short loc_18002A3A6 | UNBOUNDED — no counter check | | Patched 0x18002749E | cmp edi, 79h / jnb | Limit check added |

Trigger setup: 1. Reach SymCryptMlDsaSampleInBall via ML-DSA signing (SymCryptMlDsaSignEx). 2. Requires a pathological/corrupted Keccak sponge state so the byte stays above the threshold (the sponge is the byte source, not external ASN.1). 3. Observe the non-terminating inner loop. 4. Warning: if reachable in a running kernel this freezes the thread; use a VM with forced reset.

Expected observation: Execution stuck looping at loc_18002A3A60x18002A3C2, calling SymCryptKeccakExtract at 0x18002A3B6. Call stack: ci!SymCryptMlDsaSampleInBallci!SymCryptMlDsaSignEx (sub_180020C7C). Patched build exits with 0x8008.


Finding 4: Unbounded Rejection Loop — SymCryptMlDsaRejBoundedPoly (sub_18002A06C)

Breakpoints:

bp ci!sub_18002A06C
  • Why: This function samples bounded coefficients from a Keccak sponge with an unbounded byte-read loop.

What to inspect: - edi (unpatched) / ebx (patched) = accepted coefficient counter, bounded by 0x100. - byte [r14+0x16] = eta selector (2 or 4) — determines the nibble range. - byte [rsp+0x21] = current squeezed sponge byte. - r14 (arg1/rcx) = Keccak/state context pointer. - r9 (arg4) = output buffer for sampled coefficients (0x100 entries max).

Key offsets: | Offset | Instruction | Significance | |--------|-------------|--------------| | 0x18002A0EB | main loop head (xor r9d, r9d) | Loop start | | 0x18002A0FC | call SymCryptKeccakExtract | Squeeze 1 byte | | 0x18002A11A | imul r8d, eax, 0Dh ... | Nibble-to-coefficient conversion (eta 2) | | 0x18002A140 | mov r8d, ebx; sub r8b, dl | Nibble-to-coefficient conversion (eta 4) | | 0x18002A1A1 | cmp dl, 80h; jz | Skip check — does not advance output | | 0x18002A1CC | cmp edi, 100h; jb loc_18002A0EB | Loop-back — no sponge-byte counter | | 0x18002A1D2 | exit when output full | edi >= 0x100 | | Patched 0x1800271A6 | cmp esi, 1E1h; jnb | Sponge-byte limit at loop head | | Patched 0x1800272A9 | mov edi, 8008h | Error path (SYMCRYPT_EXTERNAL_FAILURE) |

Trigger setup: 1. Requires a sponge state producing a long run of skip-decoding nibbles (>= 9 for eta 4, >= 15 for eta 2). 2. Reach the function via ML-DSA secret sampling. 3. Observe the coefficient counter not advancing.

Expected observation: Execution stuck in the loop at 0x18002A0EB0x18002A1D2, with edi not advancing while SymCryptKeccakExtract is called repeatedly. No OOB read (bytes come from the sponge, not a bounded buffer). Patched build exits with 0x8008 after 0x1E1 bytes.


8. Changed Functions — Full Triage

Security-Relevant Changes

Function Similarity Change Type Note
SymCryptMlDsaExpandS (sub_180029484)(sub_18002651C) 0.716 Fix Return value from SymCryptMlDsaRejBoundedPoly now checked; previously discarded and function always returned 0. CWE-252/CWE-754.
SymCryptRsakeyCalculatePrivateFields (sub_180002D3C)(sub_18000E324) 0.745 Fix New a7 parameter + p·q==modulus consistency check for 2-prime RSA keys. CWE-754 key validation.
SymCryptMlDsaSampleInBall (sub_18002A2E8)(sub_1800273E0) 0.763 Fix Added iteration limit (0x79) to unbounded challenge-sampling rejection loop. CWE-835.
SymCryptMlDsaRejBoundedPoly (sub_18002A06C)(sub_180027128) 0.919 Fix Added sponge-byte counter (0x1E1) to unbounded secret-coefficient rejection loop. CWE-835.
SymCryptMlDsaSignEx (sub_180020C7C)(sub_180021394) 0.525 Fix Added main-loop iteration bound (s * 0x32E, mov ecx, 32Eh at 0x180021561) and error propagation on the SymCryptMlDsaSampleInBall call, returning 0x8008 on overflow. Same non-termination-hardening class as Findings 3/4.
SymCryptMlDsaKeyGenerateEx (sub_180020A14) 0.831 Fix Added missing error checking on return value of SymCryptMlDsaExpandS (sub_180029484 / sub_18002651C). Compounds Finding 1.
SymCryptMlDsaExpandA (sub_1800292E4) 0.497 Fix Added error return checking on SymCryptMlDsaRejNttPoly (sub_18002A20C) calls; previously continued loop regardless of errors.
SymCryptMlDsaPkDecode (sub_180029840) 0.943 Fix Added error checking on SymCryptMlDsaExpandA (sub_180026374) return before using it as a size parameter to SymCryptKeccakExtract (sub_18002C284). Prevents incorrect size calculation.
SIPolicyAreCustomKernelSignersAllowed (@0x1800706C4@0x1800764A8) Fix Finding 5 (not SymCrypt). Custom-kernel-signers decision now gated on new lock byte byte_180048F08 and restricted to base policies via SIPolicyIsBasePolicy; unpatched accepted the enable bit at +0x708 from any policy, including supplemental. New helpers SIPolicyCheckCustomKernelSignersAllowed (@0x18007652C) and SIPolicySetCustomKernelSignersLockedEnabled (@0x180076558) have no unpatched counterpart. CWE-693.

Behavioral Changes (Not Directly Exploitable)

Function Similarity Change Type Note
CipProcessSIPolicyLogs (sub_1800DDF24) 0.802 Behavioral Changed struct stride from 0xA0 to 0x28; added new condition at end checking arg1[0x2FC] & 0x40000 with arg1[0x26A]/arg1[0x26B]. Likely related to SI policy log reporting.
wil_details_EvaluateFeatureDependencies_ReevaluateCachedFeatureEnabledState (sub_1800576AC) 0.754 Feature-flag WIL feature-flag/dependency evaluation: flag check tightened from bit 6 only to require both bits 10+11 (0xC00, mov ecx, 0C00h at 0x18005965D); clears bit 10 on failure. Feature-staging logic, not a security fix.
wil_details_GetCurrentFeatureEnabledState (sub_180019264) 0.748 Feature-flag WIL feature-flag evaluation restructured with the 0xC00 bit check; added call to wil_details_AreDependenciesEnabled (sub_180019064). Feature-staging logic, not a security fix.
wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState (sub_180019100) 0.973 Feature-flag Now always sets bit 18 (0x40000) in the WIL feature-state result. Minor flag change.

Cosmetic / Register Reallocation

  • SymCryptFdefDecideModulusType (sub_18000E99C) (0.970): Minor register reallocation and variable renaming. No behavioral change.
  • The remaining ~70 changed functions (not individually listed in the diff output) are presumed to be similar cosmetic/register changes or minor control-flow reordering due to the overall structural changes in the security-relevant functions.

9. Unmatched Functions

The name-based diff reported no unmatched functions (unmatched_unpatched: 0, unmatched_patched: 0), but a content review of Finding 5 shows two small helper functions in the patched build — SIPolicyCheckCustomKernelSignersAllowed (@0x18007652C) and SIPolicySetCustomKernelSignersLockedEnabled (@0x180076558) — that have no content match in the unpatched binary; these are the new custom-kernel-signers lock/enable helpers. No existing sanitizer functions were removed.


10. Confidence & Caveats

Overall confidence: HIGH that these are the four code changes described; LOW on real-world exploitability, since all four sit in SymCrypt key-generation / self-test crypto code with no demonstrated attacker-controlled input path.

Rationale: - The pseudocode and assembly for all four findings were confirmed against both binaries: the return-value check (Finding 1), the p·q==modulus check (Finding 2), and the two rejection-loop iteration bounds (Findings 3, 4) are present in the patched build and absent in the unpatched build. - All affected functions carry real SymCrypt/WIL symbols at the cited addresses; none of them are Authenticode certificate-chain, PE-load, or ASN.1 signature-parsing routines. - The call chains reflect the SymCrypt-internal caller/callee relationships (ML-DSA key generation, key import, signing, self-test; RSA key import).

Assumptions made: - The module is Windows ci.dll (Code Integrity), which statically links SymCrypt; the changed functions are SymCrypt (ML-DSA/RSA/Keccak) and WIL feature-flag routines. - The SymCrypt error codes are 0x8008 (SYMCRYPT_EXTERNAL_FAILURE, decimal 32776) and 0x800E (SYMCRYPT_INVALID_ARGUMENT, decimal 32782); earlier CRYPT_E/TRUST_E interpretations do not apply. - The struct offsets are taken from the decompilation and may differ slightly by build version. - For Finding 4 there is no OOB read: the byte source is a Keccak sponge, not a bounded input buffer, so the only pre-patch effect is potential non-termination.

What to verify before treating any of these as attacker-reachable: 1. Confirm reachability: Determine whether any caller feeds attacker-controlled data into ML-DSA/RSA key material; the self-test paths use fixed vectors. 2. Confirm struct offsets: Dump the ML-DSA/RSA key struct at arg1 to verify the polynomial/prime counts and pointers. 3. Finding 1/3/4: Confirm the rejection loops can be driven to their bounds only under a corrupted sponge/incomplete-sampling state, not by external signature bytes. 4. Finding 2: Verify the new a7 parameter (7th argument) and the value the caller SymCryptRsakeySetValueInternal (sub_18001DA98) assigns to it (bit 0x200 skips the check). 5. Nibble mapping (Finding 4): Validate the formula ((nibble * 0xD) >> 6) * 5 - nibble + 2 and the 0x80 skip mapping via tracing.