Many RSA Libraries Leave the Bleichenbacher Oracle Open
Companion to An Ok/Err Result Is a Bleichenbacher Oracle. It showed that one library’s Ok/Err decrypt result is a deterministic Bleichenbacher oracle. That Ok/Err reveals the bit: was the PKCS#1 v1.5 padding valid? This post surveys which other implementations leak it.
That bit leaks more than one way, and has since Bleichenbacher’s 1998 paper: in the response itself (behavioral, as ROBOT re-found across TLS servers), or through a side channel the code failed to close, by timing (Marvin) or by cache (9 Lives). This post is about the behavioral channel, where the bit is in the result, no statistics required. Implicit rejection closes that channel: on bad padding, return a deterministic synthetic message instead of an error, so there is no Ok/Err to read (CFRG draft, Alicja Kario). It is a stopgap, since the fix is to stop using RSA v1.5 encryption. A “closed” verdict means that success/error bit is gone; a “removed” verdict means a library dropped v1.5 decryption.
Verdicts are runtime-confirmed. Where a library exposes a v1.5 decrypt API, that API was run on a valid ciphertext and on garbage ciphertexts to see whether valid-vs-invalid is distinguishable. Two of the 27 expose no general-purpose decrypt API: BearSSL and s2n-tls (no callable decrypt entry point, so their own depad routines were run from source).
Tally: 10 closed (5 by design, 5 OpenSSL-backed wrappers †), 12 open, 1 masked, 3 removed, 1 token-dependent.
Scope. No timing claims: timing-safety (Marvin) is not measured here. “Open at the API” ≠ exploitable: it takes the consuming protocol surfacing the error and acting on it. Each row cites a public reference (Discussion column): a survey of the known 1998 oracle, not a disclosure of new vulnerabilities.
| Implementation | Lang | Oracle | Mechanism / maintainer stance | Discussion |
|---|---|---|---|---|
| .NET runtime | C# | open | Microsoft’s build disables OpenSSL IR to keep Decrypt throwing (as on Windows/CNG); Red Hat’s downstream build re-enables it (closed) | #95157 · #97866 |
| BearSSL | C | closed | only v1.5 API is br_rsa_ssl_decrypt: constant-time random-PMS swap, no app-facing Ok/Err. Runtime-confirmed: even a conforming non-PMS block folds to the same 0 outcome (there is no general-purpose v1.5 decrypt API to leak a bit) | bearssl_rsa.h |
| BoringSSL | C | open | returns -1 on bad pad (runtime-confirmed, BoringSSL git 7883578: garbage → -1 + PKCS_DECODING_ERROR); no library IR by design. Its own API doc warns the v1.5 decrypt scheme “is vulnerable to a chosen-ciphertext attack” that “may give the attacker control over your private key”, citing Bleichenbacher (Crypto ‘98) | rsa.h API doc |
| BouncyCastle | Java | open | default decodeBlock throws InvalidCipherTextException (the JCE Cipher surfaces it as BadBlockException); an opt-in fallback constructor (PKCS1Encoding(cipher, fallback)) hides validity instead, as the TLS layer uses for ROBOT (CVE-2017-13098 was that fix); runtime-confirmed (bcprov 1.78.1) | CVE-2017-13098 |
| cjose | C | masked | JWE RSA1_5 returns the identical CJOSE_ERR_CRYPTO for both a conforming-but-wrong-CEK block and a non-conforming block, so cjose_jwe_decrypt exposes no clean padding bit; the RSA-layer oracle exists but is indistinguishable through the public API | src/jwe.c |
| Crypt-OpenSSL-RSA | Perl | removed | v1.5 decryption removed (0.35 croaks “Marvin attack”, CVE-2024-2467); ≤0.34 was open. Runtime-confirmed (0.41) | POD security note |
Go crypto/rsa | Go | open (deprecated) | DecryptPKCS1v15’s godoc warns that an attacker who can “learn whether each instance returned an error … can decrypt and forge signatures as if they had the private key.” Deprecated whole-API in Go 1.26 (incl. DecryptPKCS1v15SessionKey) but code-unchanged, so it still returns the bit. runtime-confirmed (go 1.26.3) | #75302 |
| jsrsasign | JS | removed | v1.5 decrypt removed in v11 (“for Marvin attack”); ≤v10 was open | 11.0.0 |
| libgcrypt | C | open | no implicit rejection. The linked Marvin report flagged that closing the channel needs IR; libgcrypt shipped only the constant-time unpad fix (CVE-2024-2236), so _gcry_rsa_pkcs1_decode_for_enc still errors on bad pad: the Ok/Err bit is observable, just not via timing. runtime-confirmed (1.12.2): valid → Ok, garbage → GPG_ERR_ENCODING_PROBLEM | gcrypt-devel (Mar 2024) |
| LibreSSL | C | open | raw RSA_private_decrypt errors on bad pad: no implicit rejection (the fix OpenSSL added in 3.2). The man page documents it as a Bleichenbacher oracle and points to OAEP. | RSA_private_decrypt(3) |
| M2Crypto | Python | closed † | OpenSSL wrapper; runtime-confirmed (0.48.0 / OpenSSL 3.5.4): bad pad → synthetic message, no None/exception | 84c53958 |
| Mbed TLS | C | open | default v1.5 decrypt is the PSA primitive now (psa_asymmetric_decrypt; legacy rsa.h removed in 4.x), returns PSA_ERROR_INVALID_PADDING; runtime-confirmed (4.1.0): garbage → -150. 4.x also removed the TLS RSA key exchange (#8170), so the general decrypt is the only v1.5 path left | #8459 · #8170 |
| node-rsa | JS | open | throws; v1.5 is non-default (OAEP default); maintainer recommends OAEP; runtime-confirmed (node-rsa 2.0.0) | v2.0.0 security notes |
| Node.js (built-in) | C++/JS | removed | the Feb-2024 security release disabled RSA_PKCS1_PADDING for crypto.privateDecrypt (Marvin mitigation); only --security-revert restores it, so v1.5 private decrypt is off by default | node@54cd268 · Feb-2024 release |
| NSS | C | closed | RSA_DecryptBlock is implicit-rejection-only (synthetic message, always succeeds); added in NSS 3.61, the (timing-titled) bug 1651411 fix / CVE-2023-4421 | bug 1651411 |
| opencryptoki | C | closed | implicit rejection (CVE-2024-0914); runtime-confirmed (v3.26 soft token): bad pad → CKR_OK + synthetic message | #737 |
| OpenSSL | C | closed | implicit rejection, default for RSA_PKCS1_PADDING since 3.2: returns a pseudo-random message, not an error | #13421 · #13817 · EVP_PKEY_decrypt(3) |
| pkcs11-provider | C | token-dep | no v1.5 validation of its own: inherits the backend token’s CK_RV; closed with NSS softoken, open with a strict HSM | asymmetric_cipher.c |
| pyca/cryptography | Python | closed † | implicit rejection via the bundled OpenSSL ≥3.2 (v42 wheels, Jan 2024); earlier versions open | 42.0.0 changelog |
| PyCryptodome | Python | open | never raises on bad padding: decrypt(ct, sentinel, expected_pt_len=0) returns the caller-supplied sentinel, so the validity bit shows up by value or length, not Ok/Err (only a wrong-length ciphertext raises, and that length is public). Maintainer recommends OAEP; runtime-confirmed (3.23.0) | #481 · docs |
| python-rsa | Python | open (archived) | decrypt() raises on bad pad; its one public Bleichenbacher report (CVE-2020-25658) is the timing channel, not behavioral. Repo archived; the maintainer says a pure-Python library can’t be made timing-safe, and to fork it. runtime-confirmed (4.9.1) | #165 · README |
Ruby openssl | Ruby | closed † | OpenSSL::PKey::RSA#private_decrypt binding; runtime-confirmed both backends: OpenSSL 3.6.2 → synthetic (closed), LibreSSL 3.3.6 → RSAError (open). Maintainer points to OpenSSL 3.2’s IR, not ruby/openssl’s | #732 |
Rust openssl | Rust | closed † | Rsa::private_decrypt → RSA_private_decrypt; runtime-confirmed (openssl 0.10 / OpenSSL 3.6.2): garbage → synthetic (closed). Its Bleichenbacher CVE (CVE-2024-3296) is timing, not behavioral | #2171 · Rsa::private_decrypt |
RustCrypto rsa | Rust | open | returns Err on bad pad; an implicit-rejection API (PR #680) is unmerged | #626 · #680 |
| s2n-tls | C | closed | unpad-or-dont, always returns success with random output, CBMC-proven (runtime-confirmed via depad functions vendored from source; s2n has no standalone decrypt CLI) | PR #916 |
| wolfSSL | C | open | fixed the TLS static-RSA path (ROBOT) and made the unpad constant-time for Marvin (CVE-2023-6935, explicitly covering RSA decrypt outside TLS); maintainer recommends disabling static RSA. The general v1.5 decrypt still returns a distinguishable error, so the behavioral bit stays open; runtime-confirmed (5.9.1): garbage → -131 (RSA_BUFFER_E) | CVE-2017-13099 · CVE-2023-6935 |
| xmlsec | C | closed † | rsa-1_5 calls EVP_PKEY_decrypt (RSA_PKCS1_PADDING) without disabling IR, so on OpenSSL ≥3.2 it inherits IR; downstream AES-CBC then masks any residual RSA bit. runtime-confirmed closed (xmlsec 1.2.41 / OpenSSL 3.5.4) | #289 · kt_rsa.c |
† Backend-dependent. These wrap OpenSSL and are closed only because the host links OpenSSL ≥3.2 (implicit rejection); on OpenSSL <3.2 or LibreSSL the same code is open.
What the table shows
- Self-contained implementations are open by default. These do their own v1.5 unpadding and return the bit: BoringSSL, BouncyCastle, Go, libgcrypt, LibreSSL, Mbed TLS, node-rsa, PyCryptodome, python-rsa, RustCrypto, wolfSSL.
- Implicit rejection is concentrated in OpenSSL ≥3.2. Five of the ten “closed” rows are OpenSSL-backed wrappers (†). The by-design closures use one of two mechanisms: implicit rejection, a derived synthetic message (OpenSSL ≥3.2 itself, NSS, and opencryptoki), or a constant-time random swap (BearSSL, s2n). For a † row the verdict is the linked
libcrypto’s, not the library’s. - A random swap closes it only if the value never escapes. IR’s synthetic message is derived from key and ciphertext, so replaying a ciphertext learns nothing. A fresh-random swap would leak under replay: the same response means the padding was valid, a different one means it was not. BearSSL and s2n swap safely only because the premaster never escapes: it feeds key derivation and the handshake fails identically whether real or random, which Klíma, Pokorný, Rosa proved defeats the attack. A general decrypt API returning a fresh value would need deterministic IR. PyCryptodome is that case: its
sentinelescapes to the caller, so it stays open unless the caller makes it indistinguishable. - Constant-time ≠ closed. wolfSSL, BoringSSL, node-rsa harden the timing of the unpad; the
Ok/Errbit still leaks. - Remove and deprecate are not the same outcome. Removing the v1.5 decrypt ends the oracle: jsrsasign (v11) and Crypt-OpenSSL-RSA (0.35) did. Deprecating it does not: Go 1.26 deprecated its whole v1.5 encryption API (#75302), yet
DecryptPKCS1v15is code-unchanged and still returns the bit; removing it would break the Go 1 compatibility promise. Either way, the CFRG draft and NIST SP 800-131A with SP 800-56B Rev. 2 disallow v1.5 key transport.
Timeline: implicit rejection in .NET
How one library ended up open in one build and closed in another.
- Background. OpenSSL adds implicit rejection for PKCS#1 v1.5 (#13817), shipped in 3.2.
- 2023-11-22. A .NET crypto test fails:
RSA.Decryptno longer errors on bad padding (#95115). Cause: OpenSSL’s implicit rejection. - 2023-11-24. .NET merges a one-line opt-out to disable it and keep
Decryptthrowing, the documented, Windows-consistent behavior (#95157:EVP_PKEY_CTX_ctrl_str(ctx, "rsa_pkcs1_implicit_rejection", "0")). - 2023-12-06. The author of the OpenSSL change objects on the PR: “This will make all users of the RSA PKCS#1v1.5 decryption API vulnerable!” (comment). A Red Hat engineer concurs: “this re-enables a Bleichenbacher timing oracle attack … I don’t think this should be merged” (comment).
- 2024-01-13. The .NET security team proceeds, after consulting Red Hat (statement): "…what we believe an appropriate default security stance is for the product. […] we speak only for our own framework. We don’t speak for other languages and frameworks, who are of course free to pursue a different strategy."
- 2024-02-02. Red Hat takes that different strategy: it reverts the opt-out in its own .NET packages (“Revert ‘Disable implicit rejection for RSA PKCS#1’”), citing the Marvin attack. On a Fedora build the opt-out is gone: the
rsa_pkcs1_implicit_rejectionstring is absent from the shippedlibSystem.Security.Cryptography.Native.OpenSsl.so.
Outcome. Microsoft’s .NET throws on bad padding (open); Red Hat’s .NET returns a synthetic message (closed).
Bleichenbacher’s 1998 paper made it straightforward to recover plaintext from a 4096-bit RSA ciphertext or forge a signature, using only the Ok/Err bit: no key, no timing.