ci.dll — Defense-in-depth iteration caps in ML-DSA rejection samplers (CWE-835) fixed
KB5077179
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):
- A caller presents an ML-DSA-signed PE binary or
.catcatalog to a verification path. SymCryptMlDsaVerify (sub_180020E24)dispatches toSymCryptMlDsaVerifyEx (sub_180020E68)(call at0x180020E4F).SymCryptMlDsaVerifyEx (sub_180020E68)invokesSymCryptMlDsaSampleInBall (sub_180029FE8)(call at0x180020FBB) with the context (arg1, holdingtauat+0x18), thec~seed (arg2), its length (arg3), and the output challenge polynomial buffer (arg4, 0x400 bytes / 256 int32 coefficients).- Inside
SymCryptMlDsaSampleInBall,SymCryptKeccakAppend (sub_18002B65C)absorbs the seed into a SHAKE256 sponge at[rsp+0x30]. - The inner loop at
0x18002A0A6–0x18002A0C2squeezes 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)callsSymCryptMlDsaRejBoundedPoly (sub_180029D6C)twice (for thes1ands2secret vectors) and discards the result.SymCryptMlDsaExpandA (sub_180028FE4)callsSymCryptMlDsaRejNttPoly (sub_180029F0C)in a nested loop (to expand the public matrixAfromrho) 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 onrhoand an index, rejecting values>= q. The unpatched inner loop has no counter; the patch adds acmp r14d, 0x12A(298) cap and returns0x8008(patchedsub_1800272EC, cap at0x180027369/ error at0x1800273AD).rhois 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 acmp esi, 0x1E1(481) cap and returns0x8008(patchedsub_180027128, cap at0x1800271A6/ error at0x1800272A9). Reached viaSymCryptMlDsaExpandS (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]istau, 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 receivesmov edi, r14d(counter init) /cmp edi, 0x79/jnb error_exitand aninc edi. In the unpatched binary this slot isxor 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.
- Reach the sampler. Present an ML-DSA-signed PE binary or
.catcatalog to a code path that invokes Windows Code Integrity (for exampleWinVerifyTrust, image-load signature verification, or PnP catalog verification), where the ML-DSA verify path recomputesc = SampleInBall(c~). - Seed layout (SampleInBall):
- The seed
c~is absorbed bySymCryptKeccakAppend (sub_18002B65C). - The first squeeze yields 8 sign bytes into
r15. ctx[0x18](tau) fixesi = 0x100 - tau.- Why the loop does not spin. The bytes tested at
0x18002A0C0are SHAKE256 sponge output. The attacker controls onlyc~, 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 of10^-77. There is no offline search that yields ac~forcing a long spin, because each candidate seed's output is unpredictable and the target probability is astronomically small. - Ordering / race. None.
- Observable effects:
- In normal operation the inner loop terminates within a few squeezes at each index; the outer loop runs
tautimes total. - No reachable input produces a kernel hang or DPC watchdog bugcheck from this loop.
- In the patched build the
0x8008path (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. tauis 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!SymCryptMlDsaSampleInBalllets you capture arg1 (rcx), arg2 (rdxseed ptr), arg3 (r8seed length), arg4 (r9output challenge poly) on entry.ci+0x2A0A6is 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(thecall) lets you watch each byte squeezed.ci+0x2A0C2is the rejectja; 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
- 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.
-
The kernel-side breakpoint at
ci!SymCryptMlDsaSampleInBallfires when CI's ML-DSA verify path samples the challenge. -
Single-step through
0x18002A0A6and count the rejectjaat0x18002A0C2per index; it fires only a few times beforeebxadvances.
Expected observation
- Normal termination:
ci+0x2A0A6fires a small number of times per index;ebxadvances once each accepted coefficient,tautimes total. No reachable input freezesebxor triggers a watchdog bugcheck. - Patched binary: the
cmp edi, 0x79cap and0x8008path are present at0x1800274A1/0x180027502but are not exercised by any reachable input; they engage only against a non-terminating or misbehaving sponge, after whichSymCryptMlDsaVerifyEx/SymCryptMlDsaExpandSpropagate 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
jareturns toxor r9d, r9dwith no counter in between; the patched build adds thecmp edi, 0x79 / jnb cleanupblock, and the two sibling samplers receive the analogous0x12Aand0x1E1caps, each returning0x8008. Direction is correct (patched is stricter). - The error-propagation changes in
SymCryptMlDsaExpandS,SymCryptMlDsaExpandA,SymCryptMlDsaVerifyEx, andSymCryptMlDsaSignExare companions to the caps: the new0x8008status 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.tauis 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 of10^-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, andc~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.