1. Overview

Field Value
Unpatched binary ci_unpatched.dll
Patched binary ci_patched.dll
Overall similarity 0.9457
Matched functions 2531
Changed functions 101
Identical functions 2430
Unmatched (unpatched) 0
Unmatched (patched) 0

Verdict: The patch adds bounded-iteration hardening to the ML-DSA (Module-Lattice Digital Signature Algorithm, FIPS 204 / CRYSTALS-Dilithium) rejection-sampling routines that ci.dll uses through its embedded SymCrypt post-quantum signature code. SymCryptMlDsaSampleInBall (sub_180029FE8) samples the challenge polynomial by repeatedly squeezing bytes from a SHAKE256 (Keccak) extendable-output stream and rejecting values until it accepts one; in the unpatched build the inner rejection loop has no iteration counter, which is the CWE-835 shape (a loop whose exit depends on sampled data). The same missing-cap pattern was present in the two sibling sampling routines SymCryptMlDsaRejNttPoly (sub_180029F0C) and SymCryptMlDsaRejBoundedPoly (sub_180029D6C). The patch adds a per-index iteration cap and an 0x8008 error return to each sampling routine, and adds error propagation in the callers so the new status is not discarded.

This is defense-in-depth, not a fix for an attacker-reachable denial of service. The bytes tested by each loop are SHAKE256 (Keccak) sponge output, not attacker bytes: the attacker controls only the seed (c~ from the signature, rho from the public key), and the sponge output behaves as a cryptographic random oracle whose bytes cannot be biased by choosing the seed. tau is a static per-parameter-set constant (39 / 49 / 60 for ML-DSA-44 / -65 / -87), read from the trusted context at arg1+0x18, not attacker input. For the challenge sampler the per-squeeze rejection probability is at most (tau-1)/256 <= 59/256 ~ 0.23, so reaching even the new 121-iteration cap at a single index has probability on the order of 0.23^121 ~ 10^-77; forcing the loop to run long would require brute-forcing on the order of 10^77 seeds, which is infeasible. The uncapped loop therefore terminates in a handful of squeezes in all reachable cases, and the cap guards only against a theoretically non-terminating or misbehaving sponge. The severity is accordingly Low (defense-in-depth), not High.


2. Vulnerability Summary

Finding #1 — Uncapped Challenge-Sampling Loop (SymCryptMlDsaSampleInBall)

Field Value
Severity Low (defense-in-depth)
Class Loop with data-dependent exit / unbounded rejection sampling (CWE-835), not attacker-reachable
Function SymCryptMlDsaSampleInBall (sub_180029FE8)
Entry point Signature (c~) processed during ML-DSA verification in Code Integrity; also the signing path

Root cause. SymCryptMlDsaSampleInBall (sub_180029FE8) builds the ML-DSA challenge polynomial c, a degree-255 polynomial with exactly tau nonzero coefficients of value ±1, placed by a Fisher-Yates-style shuffle. The seed for the shuffle is absorbed into a SHAKE256 sponge via SymCryptKeccakAppend (sub_18002B65C). For each index i running from 0x100 - tau up to 0xFF (where tau = *((uint8_t*)arg1 + 0x18)), the routine squeezes one byte j at a time from the SHAKE256 stream via SymCryptKeccakExtract (sub_18002B878) and rejects it while j > i. In the unpatched build the inner rejection loop has no iteration counter, which is the CWE-835 shape.

The patch inserts a counter (register edi) initialised to 0 and a cmp edi, 0x79 / jnb check before each squeeze. When the counter reaches 121 for a single index the function stores 0x8008 in r14d and returns it. The counter is reset at the top of each outer index iteration, so the cap is per-index (at most 121 squeezes per accepted coefficient).

Why this is defense-in-depth, not an attacker-reachable DoS. The bytes the loop tests are SHAKE256 (Keccak) sponge output, not attacker bytes. SymCryptKeccakExtract regenerates stream bytes on demand by permuting the sponge state; there is no attacker-supplied input buffer being walked past, so there is no memory over-read. The attacker controls only the seed c~, and SHAKE256 output behaves as a cryptographic random oracle whose bytes cannot be steered by choosing the seed. tau is a static per-parameter-set constant (39 / 49 / 60 for ML-DSA-44 / -65 / -87) read from the trusted context, not attacker input. Acceptance probability at index i is (i+1)/256; the hardest (smallest) index is i = 0x100 - tau, giving a per-squeeze rejection probability of at most (tau-1)/256 <= 59/256 ~ 0.23. Reaching even the new 121-iteration cap at a single index therefore has probability on the order of 0.23^121 ~ 10^-77 (and ~10^-88 / ~10^-100 for tau = 49 / 39). An attacker cannot bias the sponge to raise this; forcing the loop to run long would require brute-forcing on the order of 10^77 seeds, which is infeasible. In every reachable case the loop terminates in a handful of squeezes, so the uncapped loop is not a usable denial of service. The cap guards only against a theoretically non-terminating or misbehaving sponge.

Call chain (reachable path where the loop runs):

  1. A caller presents an ML-DSA-signed PE binary or .cat catalog to a verification path.
  2. SymCryptMlDsaVerify (sub_180020E24) dispatches to SymCryptMlDsaVerifyEx (sub_180020E68) (call at 0x180020E4F).
  3. SymCryptMlDsaVerifyEx (sub_180020E68) invokes SymCryptMlDsaSampleInBall (sub_180029FE8) (call at 0x180020FBB) with the context (arg1, holding tau at +0x18), the c~ seed (arg2), its length (arg3), and the output challenge polynomial buffer (arg4, 0x400 bytes / 256 int32 coefficients).
  4. Inside SymCryptMlDsaSampleInBall, SymCryptKeccakAppend (sub_18002B65C) absorbs the seed into a SHAKE256 sponge at [rsp+0x30].
  5. The inner loop at 0x18002A0A6–0x18002A0C2 squeezes bytes with no counter; per the probability above it terminates quickly in all reachable cases.

The same routine is also reached from SymCryptMlDsaSignEx (sub_18002097C) on the signing side (call at 0x180020C38).

Finding #2 — Dropped Error from Rejection-Sampling Sub-Step

Field Value
Severity Low (defense-in-depth)
Class Unchecked return value (CWE-252), companion to Finding #1
Function SymCryptMlDsaExpandS (sub_180029184) and SymCryptMlDsaExpandA (sub_180028FE4)
Entry point Same ML-DSA verification / key-derivation paths as Finding #1

Root cause. The vector-expansion helpers call the per-polynomial rejection samplers in a loop and, in the unpatched build, ignore their return values:

  • SymCryptMlDsaExpandS (sub_180029184) calls SymCryptMlDsaRejBoundedPoly (sub_180029D6C) twice (for the s1 and s2 secret vectors) and discards the result.
  • SymCryptMlDsaExpandA (sub_180028FE4) calls SymCryptMlDsaRejNttPoly (sub_180029F0C) in a nested loop (to expand the public matrix A from rho) and discards the result.

Once the samplers themselves are given the new iteration cap (see Finding #3), they can return 0x8008. The unpatched callers would keep iterating and return success regardless, so the new error would be silently lost. The patch makes both callers capture the sampler return value, break out of their loops on any non-zero status, and return that status. The return type of each caller was widened to carry the error code.

This is a companion to the same hardening: it only has an observable effect if a sampler's new cap fires, which (per Finding #1) does not happen for any reachable input. It is defense-in-depth, not the fix for a reachable unchecked-return bug.

Finding #3 — Same Missing Cap in the Sibling Samplers

Field Value
Severity Low (defense-in-depth)
Class Loop with data-dependent exit / unbounded rejection sampling (CWE-835), not attacker-reachable
Function SymCryptMlDsaRejNttPoly (sub_180029F0C), SymCryptMlDsaRejBoundedPoly (sub_180029D6C)
Entry point rho (public key) and key material feeding SHAKE256 rejection sampling

Root cause. The two other ML-DSA rejection samplers share the identical shape and receive the identical hardening:

  • SymCryptMlDsaRejNttPoly (sub_180029F0C) samples a uniform NTT-domain polynomial (coefficients in [0, q)) by squeezing 3-byte groups from a SHAKE256 stream keyed on rho and an index, rejecting values >= q. The unpatched inner loop has no counter; the patch adds a cmp r14d, 0x12A (298) cap and returns 0x8008 (patched sub_1800272EC, cap at 0x180027369 / error at 0x1800273AD). rho is part of the public key; as with the challenge seed, it drives a SHAKE256 stream whose output the attacker cannot steer.
  • SymCryptMlDsaRejBoundedPoly (sub_180029D6C) samples a polynomial with coefficients in [-eta, eta] by squeezing bytes from a SHAKE256 stream and rejecting out-of-range nibbles. The unpatched inner loop has no counter; the patch adds a cmp esi, 0x1E1 (481) cap and returns 0x8008 (patched sub_180027128, cap at 0x1800271A6 / error at 0x1800272A9). Reached via SymCryptMlDsaExpandS (sub_180029184) on the key-derivation / signing side.

Each cap is a generous bound on the number of squeeze attempts the algorithm can plausibly need at that stage. As with Finding #1, the tested bytes are SHAKE256 sponge output the attacker cannot bias, so exceeding the cap has negligible probability for any reachable input; the caps are defense-in-depth against a misbehaving sponge, not the fix for a reachable loop.


3. Pseudocode Diff

SymCryptMlDsaSampleInBall (sub_180029FE8) — Challenge Sampler

// === UNPATCHED (ci_unpatched.dll @ 0x180029FE8) ===
int SymCryptMlDsaSampleInBall(ctx* arg1, char* arg2, int64_t arg3, uint32_t* arg4) {
    memset(arg4, 0, 0x400);                          // zero challenge poly (256 int32)
    memset(sponge, 0, 0xf0);                         // zero SHAKE256 sponge state
    SymCryptWipeAsm(sponge, 0xc8);                   // init sponge
    SymCryptKeccakAppend(sponge, arg2, arg3, /*..*/);// absorb c~ seed

    SymCryptKeccakExtract(sponge, &local_qword, 8, 0);// squeeze 8-byte sign bits
    int i = 0x100 - *((uint8_t*)arg1 + 0x18);        // start index = 256 - tau
    uint64_t signs = local_qword;                    // sign bit reservoir

    if (i < 0x100) {
        uint32_t* out = arg4 + i;
        do {
            // === UNCAPPED INNER LOOP (CWE-835 shape) ===
            // NO iteration counter, NO upper bound
            do {
                j[0] = 0;
                SymCryptKeccakExtract(sponge, &j, 1, 0);  // squeeze 1 byte from sponge
            } while (j[0] > i);                            // reject; terminates fast for sponge output

            uint8_t  jv     = j[0];
            uint64_t sbit   = signs & 1;
            signs >>= 1;
            *out = arg4[jv];                                // Fisher-Yates swap
            arg4[jv] = /* ±1 with sign from sbit */;
            out += 1;
            i   += 1;
        } while (i < 0x100);
    }
    /* cleanup */
    return 0;
}

// === PATCHED (ci_patched.dll @ 0x1800273E0) ===
... same setup ...
    if (i < 0x100) {
        uint32_t* out    = arg4 + i;
        uint32_t  ctr    = 0;                                 // NEW counter (edi)
        do {
            do {
                if (ctr >= 0x79) {                            // NEW: cap @ 121
                    status = 0x8008;                          // NEW error code
                    goto cleanup;
                }
                j[0] = 0;
                SymCryptKeccakExtract(sponge, &j, 1, 0);
                ctr += 1;                                     // NEW increment
            } while (j[0] > i);
            ...
        } while (i < 0x100);
    }
    return 0;
cleanup:
    return status;

SymCryptMlDsaExpandS (sub_180029184) / SymCryptMlDsaExpandA (sub_180028FE4) — Caller Error Propagation

// === UNPATCHED (ExpandS) ===
for (v = 0; v < k1; v++) SymCryptMlDsaRejBoundedPoly(...);  // return value DISCARDED
for (v = 0; v < k2; v++) SymCryptMlDsaRejBoundedPoly(...);  // return value DISCARDED
return;

// === PATCHED (ExpandS) ===
for (v = 0; v < k1; v++) { err = SymCryptMlDsaRejBoundedPoly(...); if (err) goto done; }
for (v = 0; v < k2; v++) { err = SymCryptMlDsaRejBoundedPoly(...); if (err) goto done; }
done:
return err;                                                 // propagate

// === UNPATCHED (ExpandA) ===
for (...) SymCryptMlDsaRejNttPoly(...);                     // return value DISCARDED
return;

// === PATCHED (ExpandA) ===
for (...) { err = SymCryptMlDsaRejNttPoly(...); if (err) goto done; }
done:
return err;                                                 // propagate

4. Assembly Analysis

SymCryptMlDsaSampleInBall (sub_180029FE8) — Uncapped Reject Loop (UNPATCHED)

0x180029fe8 : push    rbp
0x180029fea : push    rbx
0x180029feb : push    rsi
0x180029fec : push    rdi
0x180029fed : push    r14
0x180029fef : push    r15
0x180029ff1 : lea     rbp, [rsp-0x38]
0x180029ff6 : sub     rsp, 0x138
0x180029ffd : mov     rax, qword [rel 0x180046280]   ; __security_cookie
0x18002a004 : xor     rax, rsp
0x18002a007 : mov     qword [rbp+0x28], rax          ; save cookie
0x18002a00b : mov     rdi, r8                         ; rdi = arg3 (seed length)
0x18002a00e : mov     rbx, rdx                        ; rbx = arg2 (c~ seed ptr)
0x18002a011 : mov     rsi, rcx                        ; rsi = arg1 (context)
0x18002a014 : xor     edx, edx
0x18002a016 : mov     r8d, 0x400                      ; 1024 bytes = 256 int32
0x18002a01c : mov     rcx, r9
0x18002a01f : mov     r14, r9                         ; r14 = arg4 (challenge poly)
0x18002a022 : call    0x18002c640                     ; memset(poly, 0, 0x400)
0x18002a027 : xor     edx, edx
0x18002a029 : lea     rcx, [rsp+0x30]
0x18002a02e : mov     r8d, 0xf0
0x18002a034 : call    0x18002c640                     ; memset(sponge, 0, 0xF0)
0x18002a039 : mov     edx, 0xc8
0x18002a03e : mov     dword [rbp-0x8], 0x88
0x18002a045 : lea     rcx, [rsp+0x30]
0x18002a04a : mov     byte [rbp], 0x1f
0x18002a04e : call    0x180011840                     ; SymCryptWipeAsm: init sponge
0x18002a053 : mov     r8, rdi                          ; arg3 seed length
0x18002a056 : mov     dword [rbp-0x4], 0x0
0x18002a05d : mov     rdx, rbx                         ; arg2 seed
0x18002a060 : mov     byte [rbp+0x1], 0x0
0x18002a064 : lea     rcx, [rsp+0x30]
0x18002a069 : call    0x18002b65c                      ; SymCryptKeccakAppend: absorb seed
0x18002a06e : xor     r9d, r9d
0x18002a071 : lea     rdx, [rbp+0x20]
0x18002a075 : lea     rcx, [rsp+0x30]
0x18002a07a : lea     r8d, [r9+0x8]
0x18002a07e : call    0x18002b878                      ; SymCryptKeccakExtract: 8 sign bytes
0x18002a083 : movzx   eax, byte [rsi+0x18]             ; ctx.tau
0x18002a087 : mov     ecx, 0x100
0x18002a08c : mov     r15, qword [rbp+0x20]            ; r15 = sign bit reservoir
0x18002a090 : mov     ebx, ecx                         ; i = 0x100
0x18002a092 : sub     ebx, eax                         ; i = 0x100 - tau
0x18002a094 : cmp     ebx, ecx
0x18002a096 : jnb     0x18002a100                      ; if i >= 0x100, skip loop
; === UNCAPPED LOOP REGION =======================================
0x18002a098 : lea     rdi, [r14+rbx*4]                 ; rdi -> poly[i]
0x18002a09c : mov     esi, 0x1
; --- INNER LOOP (MISSING ITERATION COUNTER) ---
0x18002a0a1 : mov     byte [rsp+0x20], 0x0             ; clear scratch byte
0x18002a0a6 : xor     r9d, r9d                         ; <-- PATCH INSERTS cmp/jnb HERE
0x18002a0a9 : lea     rdx, [rsp+0x20]
0x18002a0ae : mov     r8, rsi                          ; r8 = 1
0x18002a0b1 : lea     rcx, [rsp+0x30]
0x18002a0b6 : call    0x18002b878                      ; SymCryptKeccakExtract(sponge,&j,1,0)
0x18002a0bb : movzx   eax, byte [rsp+0x20]
0x18002a0c0 : cmp     eax, ebx                         ; byte j vs. index i
0x18002a0c2 : ja      0x18002a0a6                      ; *** UNCAPPED REJECT BACKWARD JUMP ***
; --- END INNER LOOP ---
0x18002a0c4 : movzx   r8d, byte [rsp+0x20]             ; jv = accepted byte
0x18002a0ca : mov     rcx, r15
0x18002a0cd : and     rcx, rsi                         ; rcx = next sign bit
0x18002a0d0 : shr     r15, 0x1
0x18002a0d3 : neg     rcx
0x18002a0d6 : add     ebx, esi                         ; i += 1
0x18002a0d8 : shr     rcx, 0x20
0x18002a0dc : mov     eax, dword [r14+r8*4]            ; read poly[jv]
0x18002a0e0 : mov     dword [rdi], eax                 ; poly[i] = poly[jv]  (swap)
0x18002a0e2 : mov     eax, ecx
0x18002a0e4 : not     eax
0x18002a0e6 : and     ecx, 0x7fe000
0x18002a0ec : and     eax, esi
0x18002a0ee : add     rdi, 0x4                         ; advance output ptr
0x18002a0f2 : or      eax, ecx
0x18002a0f4 : mov     dword [r14+r8*4], eax            ; poly[jv] = ±1 with sign
0x18002a0f8 : cmp     ebx, 0x100
0x18002a0fe : jb      0x18002a0a1                      ; outer loop continue
; === END UNCAPPED LOOP REGION ===================================
0x18002a100 : mov     edx, 0xf0
0x18002a105 : lea     rcx, [rsp+0x30]
0x18002a10a : call    0x180011840                      ; SymCryptWipeAsm: wipe sponge
0x18002a10f : mov     rcx, qword [rbp+0x28]
0x18002a113 : xor     rcx, rsp
0x18002a116 : call    0x18002c1f0                      ; __security_check_cookie
0x18002a11b : add     rsp, 0x138
0x18002a122 : pop     r15
0x18002a124 : pop     r14
0x18002a126 : pop     rdi
0x18002a127 : pop     rsi
0x18002a128 : pop     rbx
0x18002a129 : pop     rbp
0x18002a12a : retn

Annotations

  • 0x18002a092: i = 0x100 - ctx[0x18]ctx[0x18] is tau, the number of nonzero challenge coefficients (39 / 49 / 60 for ML-DSA-44 / -65 / -87). This fixes how many indices the outer loop covers.
  • 0x18002a0a6: in the patched build this address receives mov edi, r14d (counter init) / cmp edi, 0x79 / jnb error_exit and an inc edi. In the unpatched binary this slot is xor r9d, r9d — no counter, no comparison.
  • 0x18002a0b6: SHAKE256 squeeze call (SymCryptKeccakExtract). In practice invoked only a few times per index.
  • 0x18002a0c0 / 0x18002a0c2: the reject comparison + backward jump. Uncapped in the unpatched build, but bounded in practice by the sponge output distribution.

Patched Equivalent (SymCryptMlDsaSampleInBall @ 0x1800273E0, delta only)

0x18002749b : mov     edi, r14d             ; counter = 0
0x18002749e : cmp     edi, 0x79             ; NEW: cap @ 121
0x1800274a1 : jnb     0x180027502           ; -> set r14d = 0x8008, goto cleanup
0x1800274a3 : xor     r9d, r9d              ; (original code resumes)
...
0x1800274b4 : call    0x18002c284           ; SymCryptKeccakExtract (patched address)
0x1800274b9 : movzx   eax, byte [rsp+...]
0x1800274be : inc     edi                   ; NEW increment
0x1800274c0 : cmp     eax, ebx
0x1800274c2 : ja      0x18002749e           ; reject -> re-check cap first
...
0x180027502 : mov     r14d, 0x8008          ; NEW error status
0x180027508 : ... cleanup / return eax=r14d

5. Trigger Conditions

The uncapped loop runs whenever ML-DSA verification (or signing) samples the challenge, so reaching the code is routine; what is not reachable is a stream that makes the loop run long enough to matter.

  1. Reach the sampler. Present an ML-DSA-signed PE binary or .cat catalog to a code path that invokes Windows Code Integrity (for example WinVerifyTrust, image-load signature verification, or PnP catalog verification), where the ML-DSA verify path recomputes c = SampleInBall(c~).
  2. Seed layout (SampleInBall):
  3. The seed c~ is absorbed by SymCryptKeccakAppend (sub_18002B65C).
  4. The first squeeze yields 8 sign bytes into r15.
  5. ctx[0x18] (tau) fixes i = 0x100 - tau.
  6. Why the loop does not spin. The bytes tested at 0x18002A0C0 are SHAKE256 sponge output. The attacker controls only c~, and cannot steer the sponge output; it behaves as a random oracle. The per-squeeze rejection probability is at most (tau-1)/256 <= 0.23, so the probability of even 121 consecutive rejections (the new cap) at the hardest index is on the order of 10^-77. There is no offline search that yields a c~ forcing a long spin, because each candidate seed's output is unpredictable and the target probability is astronomically small.
  7. Ordering / race. None.
  8. Observable effects:
  9. In normal operation the inner loop terminates within a few squeezes at each index; the outer loop runs tau times total.
  10. No reachable input produces a kernel hang or DPC watchdog bugcheck from this loop.
  11. In the patched build the 0x8008 path (cap reached) is not exercised by any reachable input; it is reserved for a non-terminating or misbehaving sponge.

6. Exploit Primitive & Development Notes

Primitive(s): None demonstrable. The uncapped loop is a CWE-835-shaped rejection sampler, but there is no attacker-reachable input that makes it run long:

  • The rejection samplers squeeze from a Keccak/SHAKE256 sponge, so there is no attacker input buffer to walk past and no memory over-read.
  • The tested bytes are sponge output, not attacker bytes; the attacker controls only the seed and cannot bias the output. Forcing even the 121-iteration cap at the hardest index has probability on the order of 10^-77, so no offline search produces a spinning seed.
  • tau is a static per-parameter-set constant, not attacker input, and a smaller parameter set only lowers the rejection probability further.

From primitive → full exploit:

  • DoS path: Not reachable. There is no feasible way to make the loop exceed a handful of squeezes per index.
  • Code-exec path: Not applicable. There is no memory corruption.

Nature of the change: defense-in-depth. The patch bounds each rejection sampler against a theoretically non-terminating or misbehaving sponge and propagates the resulting 0x8008 status, consistent with hardening the whole ML-DSA sampler family.


7. Debugger PoC Playbook

Target: ci_unpatched.dll loaded in ntoskrnl's CI module (typically mapped as ci.dll in the kernel address space). Use WinDbg / KD in local-kernel or host-target mode.

Breakpoints

bp ci!SymCryptMlDsaSampleInBall   ; entry to the challenge sampler
bp ci+0x29FE8                     ; equivalent if private symbols unavailable
bp ci+0x2A0A6                     ; start of the uncapped inner loop
bp ci+0x2A0B6                     ; the SHAKE256 squeeze call site (next insn: 0x18002A0BB)
bp ci+0x2A0C2                     ; the reject backward jump
bp ci!SymCryptKeccakExtract       ; the byte-squeeze primitive called by the loop

Why these matter:

  • ci!SymCryptMlDsaSampleInBall lets you capture arg1 (rcx), arg2 (rdx seed ptr), arg3 (r8 seed length), arg4 (r9 output challenge poly) on entry.
  • ci+0x2A0A6 is the inner-loop head — break here with a hit-count condition to measure how many squeezes each index takes (a handful for any real input).
  • ci+0x2A0B6 (the call) lets you watch each byte squeezed.
  • ci+0x2A0C2 is the reject ja; count how often it is taken per index to confirm it does not run long.

What to inspect at each breakpoint

Breakpoint Inspect
SymCryptMlDsaSampleInBall entry rcx = arg1 ctx; byte ptr [rcx+0x18] = tau → drives i. rdx = attacker c~ seed (dump with db rdx L100). r8 = seed length. r9 = output challenge poly (dd r9 L100).
ci+0x2A092 eax = tau; ebx = resulting i. Confirm i < 0x100 so the loop is entered.
ci+0x2A0A6 (loop head) ebx = i (advances once per accepted coefficient). rdi = current output ptr. byte ptr [rsp+0x20] = last byte squeezed.
ci+0x2A0B6 (call) rcx = SHAKE256 sponge at [rsp+0x30]. rdx = scratch byte at [rsp+0x20]. r8 = 1.
ci+0x2A0C2 (jump) Compare eax (byte just squeezed) vs ebx. If eax > ebx, the byte is rejected and re-squeezed; in practice this happens only a few times per index.
ci!SymCryptKeccakExtract Each squeeze re-permutes the sponge and returns a fresh byte; the byte source is the sponge, not an attacker buffer, so there is no over-read.

Useful watchpoints:

ba w4 <addr_of_ebx_spill>    ; track i advancement (advances once per accepted coefficient)
ba r1 <addr_of_scratch_byte> ; track each byte squeezed

Key instructions / offsets

Offset Meaning
0x18002A083 loads ctx[0x18]tau
0x18002A092 computes i = 0x100 - tau
0x18002A096 bounds check i >= 0x100 → skip loop
0x18002A0A6 inner-loop head — patched build inserts cmp edi, 0x79 here
0x18002A0B6 call SymCryptKeccakExtract — SHAKE256 byte squeeze
0x18002A0C0 cmp eax, ebx — reject test
0x18002A0C2 ja 0x18002A0A6 — reject backward jump (uncapped in unpatched)
0x18002A0FE outer loop continuation cmp ebx, 0x100

Trigger setup

  1. From a user-mode test harness on the same machine, open an ML-DSA-signed PE/catalog with WinVerifyTrust:
WINTRUST_FILE_INFO fi = { sizeof(fi), L"C:\\signed.bin" };
WINTRUST_DATA wtd = { sizeof(wtd), .dwUIChoice = WTD_UI_NONE,
                      .fdwRevocationChecks = WTD_REVOKE_NONE,
                      .dwUnionChoice = WTD_CHOICE_FILE,
                      .pFile = &fi };
WinVerifyTrust(HWND_INVALID, WINTRUST_ACTION_GENERIC_VERIFY_V2, &wtd);

Alternatively, map the file as an image or trigger driver/catalog verification via SetupVerifyInfFile.

  1. The kernel-side breakpoint at ci!SymCryptMlDsaSampleInBall fires when CI's ML-DSA verify path samples the challenge.

  2. Single-step through 0x18002A0A6 and count the reject ja at 0x18002A0C2 per index; it fires only a few times before ebx advances.

Expected observation

  • Normal termination: ci+0x2A0A6 fires a small number of times per index; ebx advances once each accepted coefficient, tau times total. No reachable input freezes ebx or triggers a watchdog bugcheck.
  • Patched binary: the cmp edi, 0x79 cap and 0x8008 path are present at 0x1800274A1 / 0x180027502 but are not exercised by any reachable input; they engage only against a non-terminating or misbehaving sponge, after which SymCryptMlDsaVerifyEx / SymCryptMlDsaExpandS propagate the error up the chain.

Struct / offset notes

ci!SymCryptMlDsaSampleInBall arguments (Win64 ABI):
  rcx (arg1)  : ML-DSA context (also held in rsi inside)
                [arg1 + 0x18] = uint8_t  tau (nonzero-coefficient count)
  rdx (arg2)  : c~ seed pointer (absorbed by SymCryptKeccakAppend)
  r8  (arg3)  : c~ seed length
  r9  (arg4)  : output challenge polynomial, 0x400 bytes / 256 int32 coefficients

Stack layout (frame base after prologue):
  [rsp + 0x20] : scratch byte buffer for each squeeze
  [rsp + 0x30] : SHAKE256 sponge state, 0xF0 bytes (init by SymCryptWipeAsm / SymCryptKeccakAppend)
  [rbp + 0x20] : 8-byte sign-bit reservoir squeezed from the stream
  [rbp + 0x28] : __security_cookie xor rsp

Verify path (SymCryptMlDsaVerify sub_180020E24 -> SymCryptMlDsaVerifyEx sub_180020E68):
  the c~ seed and its length are taken from the attacker-supplied signature blob
  and passed to arg2/arg3 of SymCryptMlDsaSampleInBall verbatim.

8. Changed Functions — Full Triage

Function Sim Change Note
SymCryptMlDsaSampleInBall (sub_180029FE8) 0.763 Hardening Adds per-index iteration cap (>= 0x79) and error return 0x8008 in the challenge-sampling reject loop. Defense-in-depth (not attacker-reachable; see Finding #1).
SymCryptMlDsaRejNttPoly (sub_180029F0C) changed Hardening Adds iteration cap (>= 0x12A) and error 0x8008 to the uniform-A rejection loop keyed on rho. Same defense-in-depth pattern.
SymCryptMlDsaRejBoundedPoly (sub_180029D6C) changed Hardening Adds iteration cap (>= 0x1E1) and error 0x8008 to the [-eta,eta] rejection loop. Same defense-in-depth pattern.
SymCryptMlDsaExpandS (sub_180029184) 0.716 Hardening Checks the return value of SymCryptMlDsaRejBoundedPoly (sub_180029D6C) and propagates the error from both s1/s2 loops. Companion to the caps above.
SymCryptMlDsaExpandA (sub_180028FE4) changed Hardening Checks the return value of SymCryptMlDsaRejNttPoly (sub_180029F0C) and propagates the error from the matrix-expansion loop. Companion to the caps above.
SymCryptMlDsaVerifyEx (sub_180020E68) 0.497 Hardening Now checks the return value of SymCryptMlDsaSampleInBall (sub_180029FE8) and aborts verification on the 0x8008 error instead of continuing.
SymCryptMlDsaSignEx (sub_18002097C) 0.524 Hardening Signing counterpart; now checks the SymCryptMlDsaSampleInBall (sub_180029FE8) return (r15d = 0x8008 path) and aborts.
CipApplySiPolicyEx (sub_1800DC2D4) 0.795 Behavioral 603-instruction CI policy evaluator; adds sub_180018ED8 feature gate, switches offsets between 0x930 and 0x9A0 for policy objects, revises runFullTrust capability check. Not exploit-relevant but indicates a policy revision.
CiInstrumentSignatureFailuresForWNC (sub_180060828) 0.823 Behavioral CI policy verification logging; retargeted helpers, new feature-gated branch.
CiValidateImageHeader (sub_18005B880) 0.134 Behavioral Refactor from 1273 lines down to 55; logic moved into sub_18001975C, sub_180019704, sub_18006F7EC. No security impact apparent.

The remaining changed entries (not enumerated individually) are dominated by:

  • Compiler/register-allocation drift induced by the patched routines above (cascading into call graphs).
  • Helper renames in CI logging paths (sub_1800958EC → sub_18009B13C, etc.).
  • Routine CFG / EH-table regeneration from build-to-build.

None of these remaining changes appear to introduce or remove bounds checks, sanitizers, or capability gates.


9. Unmatched Functions

Removed Added
(none) (none)

The patch is a pure in-place modification — no helper was deleted and no new sanitizer was added as a separate function. All fixes live inside the existing ML-DSA sampling and expansion routines.


10. Confidence & Caveats

Confidence: High that the change is real and correctly characterized as defense-in-depth; High that it is not an attacker-reachable DoS.

  • The change is unambiguous in the assembly: in the unpatched build a reject ja returns to xor r9d, r9d with no counter in between; the patched build adds the cmp edi, 0x79 / jnb cleanup block, and the two sibling samplers receive the analogous 0x12A and 0x1E1 caps, each returning 0x8008. Direction is correct (patched is stricter).
  • The error-propagation changes in SymCryptMlDsaExpandS, SymCryptMlDsaExpandA, SymCryptMlDsaVerifyEx, and SymCryptMlDsaSignEx are companions to the caps: the new 0x8008 status is now checked by every caller.
  • The reachability that would make this a denial of service does not hold. The loops test SHAKE256 sponge output, not attacker bytes; the attacker controls only the seed (c~ / rho) and cannot bias the output. tau is a static per-parameter-set constant. The per-squeeze rejection probability is at most ~0.23, so reaching even the new cap at a single index has probability on the order of 10^-77; no feasible offline search produces a seed that makes the loop run long. The caps are therefore defense-in-depth against a non-terminating or misbehaving sponge, and the cap values are generous upper bounds rather than a boundary an attacker can push a stream across.

Verification notes:

  • The call chain SymCryptMlDsaVerify (sub_180020E24) → SymCryptMlDsaVerifyEx (sub_180020E68, call at 0x180020E4F) → SymCryptMlDsaSampleInBall (sub_180029FE8, call at 0x180020FBB) is present, and c~ from the signature does drive the seed. What is absent is any way to turn that into a long-running loop.
  • Compare patched vs. unpatched side-by-side: the only security-relevant deltas are the three sampler caps and the four caller error-propagation changes; all are consistent with a single defense-in-depth hardening pass over the ML-DSA sampler family.