Skip to content

Fix CTR mode keystream reuse with short IV (fixes #508)#537

Open
alanturing881 wants to merge 1 commit into
brix:developfrom
alanturing881:fix/ctr-short-iv-keystream-reuse
Open

Fix CTR mode keystream reuse with short IV (fixes #508)#537
alanturing881 wants to merge 1 commit into
brix:developfrom
alanturing881:fix/ctr-short-iv-keystream-reuse

Conversation

@alanturing881

Copy link
Copy Markdown

Summary

Fixes #508CryptoJS.mode.CTR reuses the same keystream for the first
two blocks of ciphertext whenever the supplied IV/nonce is shorter than
the cipher's block size (e.g. a 12-byte/96-bit nonce for AES's 16-byte
block, a very common choice matching typical AEAD-style nonce sizes).

Root Cause

processBlock() copies the counter directly from iv.slice(0) with no
length check. For a 12-byte IV (a 3-word array for AES), counter[3]
(counter[blockSize - 1]) doesn't exist — reading it is undefined.
Block 1's keystream generation implicitly treats that missing word as 0
(via JS bitwise coercion inside encryptBlock). The subsequent increment,
counter[blockSize - 1] = (counter[blockSize - 1] + 1) | 0, evaluates
(undefined + 1) | 0 = NaN | 0 = 0 — i.e. it does not actually
increment anything, it just makes the previously-implicit 0 explicit.
Block 2 then reads that same explicit 0 and produces an identical
keystream to block 1
. Increments proceed correctly from block 3 onward.

This is a critical CTR-mode "two-time pad" failure: given
C1 = P1 XOR KS and C2 = P2 XOR KS with the same KS, an observer can
compute C1 XOR C2 = P1 XOR P2 with no knowledge of the key, and recover
either plaintext block given any partial knowledge of the other (crib
dragging).

Verified CryptoJS.mode.CTRGladman is not affected — it increments
the counter's first word (counter[0]), which always exists regardless
of IV length, rather than the last word.

Fix

Zero-pad the counter array up to blockSize immediately after copying the
IV, so counter[blockSize - 1] is always a defined 0 rather than
undefined on the first block:

if (iv) {
    counter = this._counter = iv.slice(0);

    for (var i = counter.length; i < blockSize; i++) {
        counter[i] = 0;
    }

    this._iv = undefined;
}

This is exactly the "pad the nonce to 16 bytes" workaround already
independently found by users in #508's comment thread — this PR applies
it inside the library so callers don't need to remember to do it
themselves.

Verification

  • Reproduced AES CTR Does not increment the nonce when iv is less than 16 bytes #508's own PoC: encrypting with a 12-byte nonce now produces
    ciphertext byte-for-byte identical to encrypting with that same
    nonce manually zero-padded to 16 bytes (previously, blocks past the
    first diverged due to the keystream reuse).
  • test/mode-ctr-test.js's existing assertions (full 16-byte IV,
    unrelated to the padding path added here) still pass unchanged.
  • CryptoJS.mode.CTRGladman confirmed unaffected by the same short-IV
    test, before and after this change.

Test plan

…323)

CryptoJS.mode.CTR.processBlock() derives the counter from
this._iv.slice(0) without padding it to the cipher's block size. When
a caller passes a short IV (e.g. a standard 12-byte/96-bit nonce
instead of a full 16-byte counter block, per NIST SP 800-38A's
recommended nonce+counter construction), counter[blockSize - 1] is
`undefined` after the first block. `(undefined + 1) | 0` evaluates to
0 in JS - coincidentally the same value implicitly used (via bitwise
coercion of the missing array slot) as that word during the first
block's keystream generation. So blocks 1 and 2 are encrypted with an
*identical* keystream before the counter starts incrementing
correctly from block 3 onward - a critical two-time-pad-style
keystream reuse that lets an attacker recover the XOR of the first
two plaintext blocks from their ciphertexts.

Already publicly reported and root-caused in
brix#508 with a working PoC; this
commit provides the fix. CTR-Gladman mode (mode-ctr-gladman.js) was
checked and is NOT affected - it increments the counter's low-order
word (index 0) first, which always exists regardless of IV length.

Fix: zero-pad the counter array up to blockSize immediately after
copying the IV, so counter[blockSize - 1] is always a defined 0
(matching the well-known IV-padding workaround from the issue
thread) rather than undefined. Verified against the official
test/mode-ctr-test.js vectors (full 16-byte IV, unaffected/unchanged)
and issue brix#508's own reproduction case (12-byte nonce now produces
ciphertext identical to a manually zero-padded 16-byte IV).

Co-Authored-By: iaohkut <thb2601@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AES CTR Does not increment the nonce when iv is less than 16 bytes

2 participants