Skip to content

CSRF cookie: legacy-AES fallback is skipped ~1 in 256 times because a wrong-mode decrypt doesn't always throw #3361

Description

@bpamiri

Found while diffing compat-matrix legs for #3351. Filed separately because it is unrelated to that work, and because the flaky spec is the visible symptom of a real product defect.

The defect

vendor/wheels/controller/csrf.cfc::$decryptCsrfCookieValue() reads a CSRF cookie by trying the configured algorithm first and falling back to the legacy bare AES (ECB) only from its catch block:

try {
    local.state.decrypted = Decrypt(value, key, application.wheels.csrfCookieEncryptionAlgorithm, encoding);
} catch (any e) {
    if (application.wheels.csrfCookieEncryptionAlgorithm != "AES") {
        try {
            local.state.decrypted = Decrypt(value, key, "AES", encoding);
        } catch (any legacyDecryptError) {}
    }
}

That treats "did not throw" as "decrypted correctly", which is not true for a wrong-mode decrypt.

Under AES/CBC/PKCS5Padding, decrypting a ciphertext that was actually written with bare-AES/ECB throws only when the trailing plaintext bytes fail PKCS5 padding validation. They pass by chance roughly 1 time in 256. When that happens Decrypt() returns garbage instead of erroring, the legacy fallback never runs, and the cookie is treated as corrupted.

AES/GCM/NoPadding is authenticated, so it reliably throws — this only affects engines that fall back to CBC. events/init/security.cfm probes GCM at startup and falls back to CBC when the engine can't run it through Encrypt()/Decrypt(), which is the documented Lucee case.

Impact

A user still holding a legacy ECB-encrypted cookie has a ~1-in-256 chance per request of it reading as corrupted.

It fails closedcsrf.cfc:232 checks IsJSON() and returns "" — so this is a reliability wart confined to the legacy-cookie migration window, not a security hole. Worth stating plainly rather than inflating.

Measurement

The spec CsrfCookieCipherSpec › still reads cookies encrypted with the legacy bare AES (ECB) algorithm exercises exactly this path, and its payload contains a CreateUUID(), so the ciphertext — and the coin flip — differs every run.

Across 326 non-empty compat-matrix legs collected on 2026-08-04 it failed exactly once, on lucee6 / sqlite:

Failed | still reads cookies encrypted with the legacy bare AES (ECB) algorithm
       | Expected [false] to be true

1/326 = 0.31% observed, against ~0.39% theoretical for random PKCS5 padding validity. The mechanism and the rate agree.

Suggested fix

Validate the first decrypt's result before accepting it, rather than relying on it having thrown. This cookie's plaintext is always the JSON written by $generateCookieAuthenticityToken() (SerializeJSON({sessionId, authenticityToken})), so a non-JSON result means it was decrypted with the wrong algorithm and the legacy attempt should still run.

That closes the window and makes the spec deterministic as a side effect, rather than needing a separate test-only patch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions