Skip to content

CWE-780: Use of RSA Algorithm without OAEP

Overview

Using RSA encryption without OAEP (Optimal Asymmetric Encryption Padding) leaves the ciphertext open to padding oracle attacks and message malleability. OAEP adds randomness and an integrity check to the padded block. This typically appears when code calls an RSA encrypt function without specifying padding, or explicitly selects PKCS#1 v1.5 padding.

OWASP Classification

A04:2025 - Cryptographic Failures

Risk

High: PKCS#1 v1.5 padding exposes the decrypting side to Bleichenbacher's attack, and textbook RSA - no padding at all - makes encryption deterministic, so the same plaintext always produces the same ciphertext. Either way, the data the RSA operation protects is no longer confidential.

Remediation Steps

Core Principle: Use RSA-OAEP for RSA encryption; never use raw RSA or PKCS#1 v1.5 padding.

Trace the Data Path

  • Source: Any code path that encrypts data with an RSA public key (session keys, stored secrets, data sent to a partner system)
  • Sink: The RSA encrypt/decrypt call
  • Data Flow / Missing Controls: Look for a missing or explicit non-OAEP padding argument, a "default" RSA transformation, or a deprecated crypto library call

Use RSA with OAEP Padding (Primary Defense)

  • Always specify OAEP padding explicitly; never rely on a library's default
  • Use OAEP with SHA-256 or stronger (not SHA-1)
  • Specify MGF1 (Mask Generation Function) with a matching hash

OAEP takes two digests - one for the message hash and one for MGF1 - and several APIs leave one or both on a default. Java's Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding") leaves MGF1 on SHA-1 unless an OAEPParameterSpec says otherwise, and Node's crypto.publicEncrypt() applies OAEP by default but with oaepHash at SHA-1. Neither call site shows the SHA-1 that results, and both produce ciphertext that a correctly configured peer cannot decrypt. Confirm the value the library actually used rather than the one the call site names, and see the language pages for how.

Naming both digests keeps the ciphertext readable outside the runtime that wrote it.

Use Hybrid Encryption for Large Data

RSA is limited to small payloads (roughly 318 bytes for a 3072-bit key with SHA-256 OAEP):

  • Generate a random symmetric key (AES-256)
  • Encrypt the data with an authenticated symmetric cipher (AES-GCM or ChaCha20-Poly1305)
  • Encrypt only the symmetric key with RSA-OAEP
  • Store the encrypted key alongside the ciphertext

Choose Appropriate Key Sizes

  • 3072 bits for new keys - NIST rates 2048-bit at 112-bit strength and accepts it only through 2030; 3072-bit gives 128-bit and stays valid beyond
  • The 2031 disallowance in NIST SP 800-57 Part 1 Table 4 covers applying protection (generating keys, encrypting, signing). Decrypting or verifying data already protected with a 2048-bit key remains permitted as legacy use, so existing ciphertext does not have to be re-encrypted to comply
  • Larger keys cost more CPU time but do not change the padding requirement

Use PSS for Signatures, Not OAEP

OAEP is for encryption only. For RSA signatures, use PSS (Probabilistic Signature Scheme) padding with a strong hash such as SHA-256.

Verify the Fix

Re-scanning proves the weak padding constant is gone. It cannot tell you the new ciphertext is readable, so the round trip is the assertion that matters here - an OAEP call can name a strong algorithm, encrypt without error, and produce ciphertext that never decrypts because the two sides disagree about a parameter the ciphertext does not carry.

  • Decrypt what you encrypted and assert the plaintext comes back byte for byte, not merely that no exception was raised
  • If a different system decrypts this data, run the assertion against that system rather than a second process of your own - the OAEP digest, the MGF1 digest and the label are all agreements between the two, and none of them is transmitted
  • Encrypt the largest payload the system actually sends and confirm it still fits; OAEP's ceiling is lower than PKCS#1 v1.5's
  • Encrypt the same plaintext twice and confirm the ciphertexts differ (OAEP is probabilistic)
  • Modify a valid ciphertext byte-by-byte and confirm decryption fails uniformly (no distinguishable error or timing signal)
  • Attempt to decrypt an OAEP ciphertext using legacy PKCS#1 v1.5 padding and confirm it fails

Common Vulnerable Patterns

Padding left to the library default

// VULNERABLE - default padding, usually PKCS#1 v1.5
cipher = rsa_cipher(padding: DEFAULT)
encrypted = cipher.encrypt(message, public_key)
// Attack: attacker observes decryption success/failure or timing and
// runs Bleichenbacher's adaptive attack to recover the plaintext

Why this is vulnerable: the encryption is correct and round-trips perfectly, so nothing in testing distinguishes this from the secure version. The weakness is not in the ciphertext but in what the decrypting side gives away. Bleichenbacher's attack needs only a way to tell "the padding parsed" from "it did not", and an error message, a status code, a log line, or a measurable difference in response time all supply one. Given that distinguisher, an attacker submits modified ciphertexts and recovers the plaintext of a message they never had the key for.

Two consequences follow. The vulnerable component is the service that decrypts, not the code that encrypts, so a finding on this line is really a question about the decryption endpoint's behaviour. And the call that is wrong is often the one that says nothing: Java's Cipher.getInstance("RSA") and node-forge's publicKey.encrypt() both select PKCS#1 v1.5 when no padding is named, so the code contains no word a reviewer can object to.

The default is not the same in every ecosystem, so an assumption carried over from another language is often wrong. Node's crypto.publicEncrypt() applies OAEP when no padding is given - what it leaves at SHA-1 is the digest, not the padding - and Python's cryptography has no default at all, refusing to encrypt unless the padding, both digests and the label are named. Look up the default for the library in front of you, and confirm it by decrypting the output with each candidate rather than by reading the call site.

Textbook RSA with no padding

// VULNERABLE - textbook RSA, no padding at all
encrypted = pow(message, e, n)
// Result: deterministic and malleable; same plaintext always
// produces the same ciphertext, and ciphertext bits can be manipulated
// to produce predictable plaintext changes

Why this is vulnerable: without padding the operation is a plain function of the message, so encrypting the same value twice produces the same ciphertext both times. That alone breaks confidentiality for any message drawn from a small set, and it needs no attack on RSA at all: the public key is public, so an attacker encrypts each candidate - every PIN, every account number, yes and no - and compares. The mathematics is untouched and the plaintext is recovered anyway.

The second property is malleability. An attacker who cannot read the ciphertext can still modify it so that the plaintext changes in a predictable, chosen way, because multiplying the ciphertext by a suitable factor multiplies the decrypted message. Anything that acts on the decrypted value - an amount, a quantity, an identifier - can therefore be altered in transit even though it stays encrypted throughout. Padding is what supplies the randomness that fixes the first problem and the structure check that fixes the second; it is not an optional wrapper around the interesting part.

Secure Patterns

// SECURE - explicit OAEP with a strong hash
cipher = rsa_cipher(padding: OAEP, hash: SHA256, mgf: MGF1(SHA256))
encrypted = cipher.encrypt(message, public_key)

// SECURE - hybrid encryption for data larger than the RSA limit
symmetric_key = random_bytes(32)               // AES-256
data_ciphertext = aes_gcm_encrypt(data, symmetric_key)
key_ciphertext = cipher.encrypt(symmetric_key, public_key)   // RSA-OAEP
store(key_ciphertext, data_ciphertext)

Why this works: OAEP's randomized padding makes encryption probabilistic, so identical plaintexts never produce identical ciphertexts, and any modification to the padded structure makes decryption fail outright. OAEP does not leak a distinguishable "padding valid/invalid" signal, so the queries Bleichenbacher's attack depends on are not available. Hybrid encryption keeps RSA-OAEP to wrapping a short symmetric key, so RSA's payload ceiling stops constraining how much data you can protect.

Common Pitfalls

  • Keeping a legacy PKCS#1 v1.5 fallback for "compatibility": switching new encryption calls to OAEP but leaving an old decrypt path (or an older client) that still accepts PKCS#1 v1.5 - if the service still decrypts either format, an attacker can send padding-oracle probes down the legacy path and recover plaintext exactly as before, regardless of what the new code path does.
  • Splitting large payloads into multiple RSA-encrypted chunks instead of hybrid encryption: encrypting data that exceeds the OAEP size limit by breaking it into several RSA blocks rather than wrapping a symmetric key. Each block is still probabilistically encrypted, but the fixed block size and per-block boundaries leak structural information (block count, repeated blocks) that hybrid encryption with a stream/AEAD cipher does not, and it is far slower and easier to implement incorrectly than the standard AES-GCM + RSA-OAEP pattern.
  • Returning distinguishable errors from OAEP decryption: OAEP closes the classic Bleichenbacher oracle, but a decrypt function that surfaces different exceptions, log messages, or timing for "OAEP padding check failed" versus "hash mismatch" versus "downstream parsing failed" can reopen a related oracle (Manger's attack). Decryption failures need to be handled as a single uniform error, not a chain of specific ones.
  • Reusing one RSA key pair for both encryption and signing: using the same key for OAEP encryption and PSS/PKCS#1 signing mixes two different security assumptions on one key, and some historical padding-confusion attacks exploit exactly this overlap. Generate separate key pairs for encryption and signing.

Migration Considerations

Changing RSA padding makes existing RSA-encrypted data unreadable, because OAEP and PKCS#1 v1.5 are not interchangeable.

What Breaks

  • Session keys or stored secrets encrypted with the old padding become inaccessible
  • Any external system that encrypts data for you using the old padding must be updated in lockstep
  • The maximum plaintext shrinks. OAEP overhead is 2 * digest_length + 2 bytes against PKCS#1 v1.5's 11, so on a 2048-bit key OAEP-SHA-256 leaves 190 bytes where PKCS#1 v1.5 left 245, and on a 3072-bit key 318 against 373 - measured identically on .NET 10, Go 1.25, Java 26, Node 24.3 and Python cryptography 50.0. A payload between the two limits encrypts before the change and fails after it, at runtime, on the records that happen to be long. Check the largest value the system actually encrypts before shipping the padding change, and move to hybrid encryption if it is anywhere near the new ceiling

Migration Approach

Prefer a dual-padding rollout over a big-bang cutover:

  1. Deploy code that tries OAEP first, then falls back to the legacy padding only for decryption
  2. Always encrypt new data with OAEP
  3. Batch re-encrypt existing stored data where feasible
  4. Track which records have been migrated (a version flag alongside the ciphertext works well)
  5. Remove legacy padding support once all data and dependent systems have migrated

Step 1 is not available everywhere. Node has withdrawn PKCS#1 v1.5 private decryption from its crypto module entirely - on Node 24.3 crypto.privateDecrypt rejects RSA_PKCS1_PADDING outright and there is no flag to restore it - so the dual-read period has to run on a third-party library or a separate service there. Confirm the legacy half is still callable on the runtime you are on before planning around it.

If a dual-read period is not feasible, schedule a maintenance window: decrypt all data with the old padding, re-encrypt with OAEP, and deploy the new code as one coordinated change with a tested rollback plan.

Rollback Procedures

Keep the legacy decrypt path available until migration is verified complete, keep a pre-migration backup, and monitor decryption error rates during rollout so a failed migration can be reverted before it affects production traffic.

Language-Specific Guidance

  • C#/.NET - RSA.Create() with RSAEncryptionPadding.OaepSHA256, migrating off RSACryptoServiceProvider
  • Go - crypto/rsa with EncryptOAEP/DecryptOAEP and SHA-256
  • Java - Cipher with RSA/ECB/OAEPPadding and an explicit OAEPParameterSpec, because the named transformation leaves MGF1 on SHA-1
  • JavaScript/Node.js - crypto.publicEncrypt with RSA_PKCS1_OAEP_PADDING and an explicit oaepHash, which defaults to SHA-1
  • Python - cryptography library padding.OAEP with SHA-256

Additional Resources