cng.sys — statically-linked SymCrypt ML-DSA rejection-sampling bounded-iteration hardening
KB5083768
1. Overview
| Field | Value |
|---|---|
| Unpatched binary | cng_unpatched.sys |
| Patched binary | cng_patched.sys |
| Overall similarity | 0.9625 |
| Matched functions | 1552 |
| Changed functions | 85 |
| Identical functions | 1467 |
| Unmatched (either direction) | 0 |
Verdict: The diff is a rebuild of the statically-linked SymCrypt ML-DSA (FIPS 204 / CRYSTALS-Dilithium) code. The rejection-sampling primitives that draw coefficients from a SHAKE sponge — SymCryptMlDsaRejNttPoly, SymCryptMlDsaRejBoundedPoly and SymCryptMlDsaSampleInBall — gained a bounded-iteration cap and now return an error status (0x8008) if that cap is ever exceeded. That new status is propagated up through SymCryptMlDsaExpandA / SymCryptMlDsaExpandS, and the callers SymCryptMlDsaKeyGenerateEx and SymCryptMlDsaVerifyEx now test it. In the unpatched build these primitives have no failure path at all: they loop over the (effectively infinite) SHAKE output until the required number of coefficients is collected, then return with no meaningful status word. The condition the patched build newly checks for cannot arise in the unpatched build, and the sampling input is sponge output under static per-parameter-set tables, not an attacker-controlled primitive. There is no demonstrable, reachable security impact — this is robustness / bounded-runtime hardening in the crypto library, not a vulnerability fix.
2. Change Summary
Finding #1 — ML-DSA key generation: new bounded-iteration status plumbed into SymCryptMlDsaKeyGenerateEx
| Attribute | Value |
|---|---|
| Severity | None (no security-relevant change) |
| Classification | Library rebuild / defensive robustness (statically-linked SymCrypt) |
| Affected function | SymCryptMlDsaKeyGenerateEx (unpatched @ 0x1800894B8, patched @ 0x18008C2F0) |
| Entry point | ML-DSA key-generate path (BCryptGenerateKeyPair / BCryptFinalizeKeyPair / BCryptImportKeyPair) |
What changed. The unpatched SymCryptMlDsaKeyGenerateEx (@ 0x1800894B8) calls SymCryptMlDsaExpandA (@ 0x180095DD8) (expands the public matrix A from the rho seed) and SymCryptMlDsaExpandS (@ 0x180095F78) (expands the secret vectors s1, s2) and does not test a return value from either. The patched SymCryptMlDsaKeyGenerateEx (@ 0x18008C2F0) inserts test eax,eax / jnz cleanup after each of the two calls.
Why this is not a security fix. In the unpatched build, SymCryptMlDsaExpandA and SymCryptMlDsaExpandS — and the rejection-sampling helpers they call, SymCryptMlDsaRejNttPoly (@ 0x180096CEC) and SymCryptMlDsaRejBoundedPoly (@ 0x180096B4C) — have no failure path. They draw bytes from a SHAKE sponge and loop until a full poly (256 coefficients) is accepted, then return without ever setting a status in eax (the value the caller "ignores" is undefined, not an error code). There is no early return that would leave the intermediate vectors partially filled, so the downstream steps (SymCryptMlDsaVectorNTT (@ 0x180097AEC), SymCryptMlDsakeyComputeT (@ 0x180097D24), SymCryptMlDsaPkEncode (@ 0x180096584)) always run on fully-computed state. The patched build changes the sampling helpers to enforce a maximum number of draws and to return 0x8008 if that cap is exceeded, and threads that status up to the caller. The check the patch adds guards against a condition that the unpatched code cannot produce.
Reachability of the new failure. The cap fires only if the SHAKE stream yields far more rejections than expected. For SymCryptMlDsaRejNttPoly a 3-byte draw is rejected when its 23-bit value is >= 0x7FE001 (q = 8380417); that happens with probability ≈ 0.098% per draw, so collecting 256 coefficients needs ≈ 256 draws on average against a cap of 298. Producing 42+ rejections inside 298 draws is astronomically improbable, and the sampling input is derived from a SHAKE sponge over static per-parameter-set tables, so it is not a value an attacker can steer toward the reject region. Even if the cap were somehow reached, the only effect is a clean error return with no key produced — no corrupted-state use, no memory-safety fault, no key-integrity break.
Finding #2 — ML-DSA signature verification: same status plumbed into SymCryptMlDsaVerifyEx
| Attribute | Value |
|---|---|
| Severity | None (no security-relevant change) |
| Classification | Library rebuild / refactor + defensive robustness (statically-linked SymCrypt) |
| Affected function | SymCryptMlDsaVerifyEx (unpatched @ 0x180089D9C, patched @ 0x18008CBF8) |
What changed. The unpatched SymCryptMlDsaVerifyEx (@ 0x180089D9C) decodes the incoming signature inline (SymCryptMlDsaVectorDecode @ 0x180089EE4, SymCryptMlDsaHintBitUnpack @ 0x180089F0B) and, after the bounds check SymCryptMlDsaVectorInfinityNorm (@ 0x180089F1D), calls the challenge-sampling step SymCryptMlDsaSampleInBall (@ 0x180089F4F) and proceeds to SymCryptMlDsaVectorNTT (@ 0x180089F57) without testing a return value. The patched SymCryptMlDsaVerifyEx (@ 0x18008CBF8) consolidates the inline decode into a single SymCryptMlDsaSigDecode (@ 0x18008CD1F) call whose return is checked (jnz to cleanup at 0x18008CD28), keeps the same SymCryptMlDsaVectorInfinityNorm bounds check (@ 0x18008CD31), and adds a test eax,eax / jnz after SymCryptMlDsaSampleInBall (@ 0x18008CD62) (jnz to cleanup at 0x18008CD6B).
Why this is not a security fix. This is the verification-side face of the same library change. In the unpatched build SymCryptMlDsaSampleInBall (@ 0x180096DC8) loops over the SHAKE sponge until it places all challenge coefficients and returns with no status word, so it cannot fail; the added check therefore guards a condition the unpatched code cannot produce. The signature-decode consolidation is a refactor (SymCryptMlDsaSigDecode wraps the same decode steps), and the coefficient bounds check SymCryptMlDsaVectorInfinityNorm is present in both builds. The verification decision logic is unchanged: an invalid signature is rejected in both builds. Nothing here weakens or newly strengthens acceptance/rejection of signatures.
3. Pseudocode Diff
Finding #1 — SymCryptMlDsaKeyGenerateEx: unpatched @ 0x1800894B8 vs patched @ 0x18008C2F0
// ====== UNPATCHED: SymCryptMlDsaKeyGenerateEx @ 0x1800894B8 ======
// ExpandA / ExpandS have no failure return in this build; eax is not a status word.
SymCryptMlDsaExpandA(rsi + 0x56, r8 = *(rsi + 0xb8)); // no failure path in callee
rax = *(rsi + 0xd0);
SymCryptMlDsaExpandS(*(rsi + 0x8), rsp+0x40,
*(rsi + 0xc8), rax); // no failure path in callee
SymCryptMlDsaVectorNTT(*(rsi + 0xc8)); // always reached
SymCryptMlDsaVectorNTT(*(rsi + 0xd0));
SymCryptMlDsakeyComputeT(/* args from rsi+0xb8..0xd8 */); // t = A*s1 + s2
SymCryptMlDsaVectorNTT(*(rsi + 0xd8));
SymCryptMlDsaVectorNTT(*(rsi + 0xc0));
SymCryptMlDsaPkEncode(rsi, *(rbp+0x128), *(r14+0x30)); // encode public key
// ====== PATCHED: SymCryptMlDsaKeyGenerateEx @ 0x18008C2F0 ======
// ExpandA / ExpandS now propagate the new bounded-iteration status; caller tests it.
ebx = SymCryptMlDsaExpandA(...); // callee now returns 0x8008 if the draw cap is hit
if (ebx != 0) goto cleanup; // *** ADDED: test eax,eax / jnz (0x18008C40F/0x18008C411) ***
ebx = SymCryptMlDsaExpandS(...);
if (ebx != 0) goto cleanup; // *** ADDED: test eax,eax / jnz (0x18008C43A/0x18008C43C) ***
SymCryptMlDsaVectorNTT(...);
SymCryptMlDsakeyComputeT(...);
SymCryptMlDsaPkEncode(...);
The two added test eax,eax / jnz sequences are the entire behavioral delta in this function; the rest is call-target relocation from the recompilation. The guarded status only becomes non-zero because the callees changed (see Section 5), and that non-zero condition is not reachable in the unpatched build.
4. Assembly Evidence
SymCryptMlDsaKeyGenerateEx — unpatched, @ 0x1800894B8 (no return-value test around the expansions)
00000001800895C5 mov r8, [rsi+0B8h] ; key material pointer
00000001800895CC lea rcx, [rsi+56h] ; arg for SymCryptMlDsaExpandA
00000001800895D0 call SymCryptMlDsaExpandA ; eax not tested (callee has no failure path)
00000001800895D5 mov rax, [rsi+0D0h]
00000001800895DC lea rdx, [rsp+0C8h+var_88]
00000001800895E1 mov r9, [rsi+0C8h]
00000001800895E8 mov rcx, [rsi+8]
00000001800895EC mov [rsp+0C8h+var_A8], rax
00000001800895F1 call SymCryptMlDsaExpandS ; eax not tested (callee has no failure path)
00000001800895F6 mov rcx, [rsi+0C8h]
00000001800895FD call SymCryptMlDsaVectorNTT
0000000180089602 mov rcx, [rsi+0D0h]
0000000180089609 call SymCryptMlDsaVectorNTT
...
0000000180089654 call SymCryptMlDsakeyComputeT ; t = A*s1 + s2
...
000000018008967F call SymCryptMlDsaPkEncode ; encode public key
0000000180089684 mov ebx, eax ; PkEncode return IS captured/tested in both builds
0000000180089686 test eax, eax
0000000180089688 jnz short loc_1800896A4
SymCryptMlDsaKeyGenerateEx — patched, @ 0x18008C2F0 (return-value tests added around the expansions)
000000018008C3FD mov r8, [rsi+0B8h]
000000018008C404 lea rcx, [rsi+56h]
000000018008C408 call SymCryptMlDsaExpandA
000000018008C40D mov ebx, eax ; *** ADDED: capture status ***
000000018008C40F test eax, eax ; *** ADDED guard ***
000000018008C411 jnz loc_18008C4F0 ; skip keygen pipeline on non-zero
000000018008C417 mov rax, [rsi+0D0h]
000000018008C41E lea rdx, [rsp+0C8h+var_88]
000000018008C423 mov r9, [rsi+0C8h]
000000018008C42A mov rcx, [rsi+8]
000000018008C42E mov [rsp+0C8h+var_A8], rax
000000018008C433 call SymCryptMlDsaExpandS
000000018008C438 mov ebx, eax ; *** ADDED: capture status ***
000000018008C43A test eax, eax ; *** ADDED guard ***
000000018008C43C jnz loc_18008C4F0 ; skip keygen pipeline on non-zero
000000018008C442 mov rcx, [rsi+0C8h]
000000018008C449 call SymCryptMlDsaVectorNTT
The two mov ebx,eax / test eax,eax / jnz sequences at 0x18008C40D–0x18008C411 and 0x18008C438–0x18008C43C are the only logical additions in the function.
SymCryptMlDsaVerifyEx — SampleInBall call site (Finding #2)
; ---- unpatched @ 0x180089D9C ----
0000000180089F4F call SymCryptMlDsaSampleInBall ; eax not tested
0000000180089F54 mov rcx, r14
0000000180089F57 call SymCryptMlDsaVectorNTT ; proceeds unconditionally
; ---- patched @ 0x18008CBF8 ----
000000018008CD62 call SymCryptMlDsaSampleInBall
000000018008CD67 mov ebx, eax ; *** ADDED: capture status ***
000000018008CD69 test eax, eax ; *** ADDED guard ***
000000018008CD6B jnz loc_18008CFD0 ; skip on non-zero
000000018008CD71 mov rcx, rdi
000000018008CD74 call SymCryptMlDsaVectorNTT
5. Root of the Change — the rejection-sampling helpers gained an iteration cap
The reason the callees now return a status is that the SymCrypt rejection-sampling loops were bounded in the patched build. This is the substantive part of the diff.
SymCryptMlDsaRejNttPoly — unpatched @ 0x180096CEC (unbounded loop, no return status)
0000000180096D6E ... ; loop head
0000000180096D7F call SymCryptKeccakExtract ; draw 3 bytes from the sponge
0000000180096D84 and byte ptr [rsp+...+2], 7Fh
0000000180096D89 mov eax, [rsp+130h+var_110]
0000000180096D8D cmp eax, 7FE001h ; q = 8380417
0000000180096D92 jnb short loc_180096D6E ; reject -> draw again (no cap)
0000000180096D94 mov [rdi], eax ; accept
0000000180096D96 add rdi, 4
0000000180096D9A sub rbx, 1 ; rbx counts 256 -> 0
0000000180096D9E jnz short loc_180096D6E
0000000180096DA0 ... ; epilogue: eax is never set to a status
0000000180096DC0 retn
SymCryptMlDsaRejNttPoly — patched @ 0x18009AA84 (bounded loop, returns 0x8008 on cap)
000000018009AB01 cmp r14d, 12Ah ; *** ADDED cap: 298 draws ***
000000018009AB08 jnb short loc_18009AB45 ; exceeded -> error
...
000000018009AB1B call SymCryptKeccakExtract
000000018009AB25 inc r14d ; *** ADDED: count draws ***
000000018009AB28 mov eax, [rsp+140h+var_120]
000000018009AB2C cmp eax, 7FE001h
000000018009AB31 jnb short loc_18009AB01
000000018009AB33 mov [rsi], eax
000000018009AB35 inc ebx
000000018009AB37 add rsi, 4
000000018009AB3B cmp ebx, 100h
000000018009AB41 jb short loc_18009AB01
000000018009AB43 jmp short loc_18009AB4A
000000018009AB45 mov edi, 8008h ; *** ADDED: error status ***
000000018009AB4A mov eax, edi ; return status in eax
The same edit appears in SymCryptMlDsaRejBoundedPoly (patched @ 0x18009A8C0: cap cmp esi, 1E1h = 481, returns 0x8008) and in SymCryptMlDsaSampleInBall (patched @ 0x18009AB78: cap cmp edi, 79h = 121). Their unpatched counterparts (@ 0x180096B4C, @ 0x180096DC8) loop without a cap and return no status word. SymCryptMlDsaExpandA (patched @ 0x180099B14) and SymCryptMlDsaExpandS (patched @ 0x180099CBC) were updated to capture and propagate that status (mov e?x, eax / test / jnz after the SymCryptMlDsaRejNttPoly / SymCryptMlDsaRejBoundedPoly calls), whereas the unpatched SymCryptMlDsaExpandA (@ 0x180095DD8) and SymCryptMlDsaExpandS (@ 0x180095F78) fall through to their epilogue without setting eax.
6. Impact Assessment
No exploit primitive. The report's guarding change does not correspond to a reachable fault:
- No corrupted-state use. In the unpatched build the expansions cannot return early, so the intermediate vectors at
rsi+0xc8,rsi+0xd0,rsi+0xd8are always fully populated beforeSymCryptMlDsakeyComputeTandSymCryptMlDsaPkEncoderead them. There is no partial-fill or dangling-pointer condition to dereference. - No key-integrity break. The unpatched sampling produces the same coefficients as the patched sampling; the only difference is that the patched code stops after a fixed number of draws. For any input that both builds accept, the generated key pair is identical.
- Not attacker-controllable. The new failure depends on an extreme rejection rate in a SHAKE-derived byte stream over static per-parameter-set tables. The rejection probability per draw for
SymCryptMlDsaRejNttPolyis ≈ 0.098% (values>= 0x7FE001out of2^23); reaching the 298-draw cap requires 42+ rejections where ≈ 0.25 are expected, which is not achievable by steering a sponge whose output is pseudorandom. - Failure is fail-closed anyway. Should the cap ever be hit in the patched build, the effect is a clean error return (
0x8008) with no key or verification result produced — the safe direction.
Direction check. The direction is not reversed: the patched build is the stricter one (it adds the checks). But the stricter behavior only matters for a condition that the unpatched build never enters, so the delta carries no reachable security consequence.
7. How to Confirm Against the Binaries
The observations below can be reproduced statically from the two disassemblies; there is no runtime fault to trigger.
- Unpatched
SymCryptMlDsaKeyGenerateEx (@ 0x1800894B8): notest eax,eaxbetween theSymCryptMlDsaExpandAcall at0x1800895D0and theSymCryptMlDsaExpandScall at0x1800895F1, nor between that call andSymCryptMlDsakeyComputeTat0x180089654. TheSymCryptMlDsaPkEncodereturn at0x180089684is captured in both builds and is unrelated to the expansions. - Patched
SymCryptMlDsaKeyGenerateEx (@ 0x18008C2F0):mov ebx,eax / test eax,eax / jnzat0x18008C40D–0x18008C411(afterSymCryptMlDsaExpandA) and0x18008C438–0x18008C43C(afterSymCryptMlDsaExpandS). - Unpatched
SymCryptMlDsaRejNttPoly (@ 0x180096CEC): the rejection loop at0x180096D6Ehas no iteration counter and the epilogue at0x180096DA0–0x180096DC0never writes a status toeax. - Patched
SymCryptMlDsaRejNttPoly (@ 0x18009AA84): the capcmp r14d, 12Ah / jnbat0x18009AB01, theinc r14dat0x18009AB25, and the error pathmov edi, 8008h / mov eax, ediat0x18009AB45–0x18009AB4A. SymCryptMlDsaVerifyEx: unpatchedSymCryptMlDsaSampleInBallcall at0x180089F4Fis followed directly bySymCryptMlDsaVectorNTTat0x180089F57with no test; patched addsmov ebx,eax / test eax,eax / jnzat0x18008CD67–0x18008CD6B.
Key offsets in the ML-DSA key context (rsi) referenced by SymCryptMlDsaKeyGenerateEx
| Offset | Meaning |
|---|---|
+0x08 |
argument passed to SymCryptMlDsaExpandS |
+0x56 |
rho-seed argument passed to SymCryptMlDsaExpandA |
+0xb8 |
key material pointer (r8 at the first expansion call) |
+0xc0 |
vector slot — copied to [rsp+0x20] before SymCryptMlDsakeyComputeT |
+0xc8 |
vector slot — NTT input and rdx to SymCryptMlDsakeyComputeT |
+0xd0 |
vector slot — r8 to SymCryptMlDsakeyComputeT, NTT-transformed |
+0xd8 |
vector slot — r9 to SymCryptMlDsakeyComputeT, NTT-transformed |
8. Changed Functions — Full Triage
| Function | Similarity | Change type | Note |
|---|---|---|---|
SymCryptMlDsaKeyGenerateEx (@ 0x1800894B8 -> 0x18008C2F0) |
0.8329 | Library robustness | Two test eax,eax / jnz guards added after SymCryptMlDsaExpandA and SymCryptMlDsaExpandS. The guarded status is only ever non-zero because the sampling helpers gained an iteration cap; that cap is not reachable in the unpatched build. No reachable security impact. |
SymCryptMlDsaVerifyEx (@ 0x180089D9C -> 0x18008CBF8) |
0.9221 | Library robustness / refactor | Inline decode consolidated into SymCryptMlDsaSigDecode; return-value check added after SymCryptMlDsaSampleInBall. Same library change as above; verification decision logic unchanged. |
SymCryptMlDsaRejNttPoly / SymCryptMlDsaRejBoundedPoly / SymCryptMlDsaSampleInBall |
— | Library robustness | Rejection-sampling loops bounded; now return 0x8008 on cap. Source of the new status word. Not attacker-reachable (sponge output, static params). |
MSCryptGetProperty (@ 0x180021240) |
0.8428 | Behavioral | BCryptGetProperty dispatch restructured; new BCRYPT_FIPS_SERVICE_INDICATOR property (feature, not a fix). Data-table references shifted due to recompilation. |
SymCryptDlkeySetValue (@ 0x18008C900) |
0.873 | Behavioral | 268-instruction function; changes are call-target address shifts from recompilation. Core logic preserved. |
SymCryptRsakeySetValueInternal (@ 0x1800465F0) |
0.9562 | Behavioral | 606-instruction function; only minor callee-address drift. No security-relevant logic change. |
The remaining changed functions fall into the same two buckets: (a) ML-DSA functions touched by the rejection-sampling-cap plumbing, and (b) call-target relocations and register-allocation shifts produced by recompilation. None contain a security-relevant logic delta.
9. Unmatched Functions
There are no added or removed functions (unmatched_unpatched: 0, unmatched_patched: 0). The patch is an in-place recompilation of existing routines — consistent with a crypto-library refresh rather than a targeted vulnerability fix.
10. Confidence & Caveats
Confidence: High that this is not a security-relevant change.
- The added
test eax,eax / jnzguards inSymCryptMlDsaKeyGenerateExandSymCryptMlDsaVerifyExare real, but the status they test only becomes non-zero becauseSymCryptMlDsaRejNttPoly/SymCryptMlDsaRejBoundedPoly/SymCryptMlDsaSampleInBallwere given an iteration cap. The unpatched versions of those helpers have no cap and no status return, so the guarded condition cannot occur in the unpatched build. - The sampling input is SHAKE-sponge output over static per-parameter-set tables; the cap-exceeded condition is not an attacker-steerable primitive, and if it ever fired the result is a fail-closed error return.
- The verification decision logic (
SymCryptMlDsaVectorInfinityNormbounds check, hint-bit unpack, decode) is present and equivalent in both builds; signatures accepted/rejected identically.
Caveats:
- The exact numeric caps (298 / 481 / 121 draws) and the
0x8008status are read directly from the patched disassembly; they reflect a specific SymCrypt revision and are quoted only as evidence of the change, not as a tunable security boundary. - This assessment addresses the ML-DSA rejection-sampling delta and the recompilation churn in the remaining changed functions. No other function in the triage list shows a security-relevant logic change.