CWE-780: Use of RSA Algorithm without OAEP - Python
Overview
Using RSA encryption without OAEP (Optimal Asymmetric Encryption Padding) enables padding oracle attacks and message malleability. In Python, this typically occurs when using the deprecated Crypto/PyCrypto library or when the cryptography library is called with PKCS1v15 padding instead of OAEP.
Primary Defence: Use the cryptography library with padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None) for RSA encryption/decryption, avoid the unmaintained PyCrypto package and PKCS1v15 padding entirely, and use hybrid encryption (RSA-OAEP for a key + AES-GCM for data) for payloads larger than the RSA size limit.
Common Vulnerable Patterns
Using PKCS1v15 Padding
# VULNERABLE - Explicitly using PKCS1v15 instead of OAEP
from cryptography.hazmat.primitives.asymmetric import rsa, padding
private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
public_key = private_key.public_key()
message = b"Confidential data"
ciphertext = public_key.encrypt(
message,
padding.PKCS1v15() # Vulnerable padding!
)
# Attack: PKCS#1 v1.5 padding enables Bleichenbacher's attack - a peer that
# reveals whether the padding parsed hands the attacker a decision oracle.
Why this is vulnerable:
padding.PKCS1v15()reintroduces PKCS#1 v1.5 padding oracles.- Padding-valid vs. decryption-error signals (exceptions, timing) can leak an oracle to an attacker who can trigger many decryption attempts.
Using the Deprecated PyCrypto Library
# VULNERABLE - PyCrypto is unmaintained since 2013, and this selects PKCS#1 v1.5
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
key = RSA.generate(3072)
cipher = PKCS1_v1_5.new(key.publickey())
encrypted = cipher.encrypt(b"Secret message")
Why this is vulnerable:
- PyCrypto has had no security patches since 2013 and should not be used in new or existing code.
PKCS1_v1_5uses PKCS#1 v1.5, the same padding oracle weakness described above.- PyCryptodome (the maintained fork) keeps
PKCS1_v1_5only for legacy interoperability and documents it as insecure for new use.
Using a Weak Hash with OAEP
# VULNERABLE - SHA-1 is deprecated
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
ciphertext = public_key.encrypt(
b"Message",
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA1()), # Weak!
algorithm=hashes.SHA1(), # Deprecated!
label=None
)
)
Why this is vulnerable: OAEP is the correct scheme here; the digest is the finding. The 2017 collisions are not what makes it one - OAEP's proof treats the hash as a random oracle rather than resting on collision resistance - but standards prohibit SHA-1 for new systems, so it has to move.
Secure Patterns
Using the cryptography Library with OAEP and SHA-256
# SECURE - Modern cryptography library with OAEP padding and SHA-256
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
# Generate RSA key pair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=3072, # NIST rates 2048-bit at 112-bit strength and accepts it only through 2030; 3072-bit gives 128-bit and stays valid beyond
)
public_key = private_key.public_key()
message = b"Sensitive data to encrypt"
ciphertext = public_key.encrypt(
message,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
plaintext = private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
assert plaintext == message
Why this works: OAEP per RFC 8017 makes encryption probabilistic, blocking pattern analysis across ciphertexts. MGF1(SHA-256) spreads randomness across the padded message, and SHA-256 is the digest current standards still allow. label=None is the standard, interoperable default.
padding.OAEP has no defaults to get wrong - mgf, algorithm and label are all required positional-or-keyword arguments and padding.OAEP() raises TypeError (verified on cryptography 50.0). That makes Python the ecosystem where the two OAEP digests are hardest to leave mismatched, which is worth knowing when the peer is a Java service: Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding") without an OAEPParameterSpec runs MGF1 on SHA-1, and this code will reject its ciphertext. See the Java page for that side of it.
Using PyCryptodome with OAEP (Alternative to cryptography)
# SECURE - PyCryptodome (maintained fork, not PyCrypto) with OAEP
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP # Use OAEP, not PKCS1_v1_5
from Crypto.Hash import SHA256
key = RSA.generate(3072)
cipher = PKCS1_OAEP.new(key, hashAlgo=SHA256)
message = b"Confidential message"
ciphertext = cipher.encrypt(message)
plaintext = cipher.decrypt(ciphertext)
assert plaintext == message
Why this works: PKCS1_OAEP adds randomness, making RSA semantically secure, and hashAlgo=SHA256 is the argument doing the work: PKCS1_OAEP.new(key) without it uses SHA-1 for both the OAEP digest and MGF1 (measured on pycryptodome 3.23), so the ciphertext is unreadable by any peer configured for OAEP-SHA-256 and the weakness the finding named is only half closed. Install with pip install pycryptodome, not pycrypto - the package names differ and PyCryptodome is the actively maintained project.
Hybrid Encryption for Large Data
# SECURE - Hybrid encryption: RSA-OAEP for the key, AES-GCM for the data
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives import hashes
import os
OAEP_SHA256 = dict(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
)
def hybrid_encrypt(public_key, plaintext):
aes_key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(aes_key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
encrypted_key = public_key.encrypt(aes_key, padding.OAEP(**OAEP_SHA256))
return {'encrypted_key': encrypted_key, 'nonce': nonce, 'ciphertext': ciphertext}
def hybrid_decrypt(private_key, envelope):
aes_key = private_key.decrypt(envelope['encrypted_key'], padding.OAEP(**OAEP_SHA256))
return AESGCM(aes_key).decrypt(envelope['nonce'], envelope['ciphertext'], None)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
large_data = b"Very large confidential document..." * 1000
envelope = hybrid_encrypt(private_key.public_key(), large_data)
assert hybrid_decrypt(private_key, envelope) == large_data
Why this works: RSA's size limits (318 bytes for a 3072-bit key with SHA-256 OAEP) make direct encryption impractical for large data. AES-GCM handles bulk encryption efficiently and adds integrity protection. RSA-OAEP wraps only the short AES key, so the scheme is not bound by RSA's payload limit. The nonce has to travel with the ciphertext - it is not secret, but without it the recovered AES key decrypts nothing, and that failure surfaces at decrypt time rather than at encrypt time. Building the OAEP parameters once and reusing them on both sides is what keeps the two digests from drifting apart between the wrap and the unwrap.
Testing
- Round trip:
private_key.decrypt(public_key.encrypt(msg, oaep), oaep) == msg. Assert on the returned bytes rather than on the absence of an exception - an encrypt-only test passes with parameters no decryptor can match. - Boundary input: on a 3072-bit key, 318 bytes encrypts under OAEP-SHA-256 and 319 raises
ValueError: Encryption failed(measured on cryptography 50.0). Assert the largest payload the system actually sends is under the limit: OAEP-SHA-256 leaves 318 bytes wherePKCS1v15left 373, and on a 2048-bit key 190 where it left 245, so a record that encrypted before the padding change may not after it. - Probabilistic output: two
encryptcalls on the same plaintext produce different ciphertexts. Equal output means OAEP is not being applied. - Peer interoperability: if another service decrypts this ciphertext, assert against that service rather than against a second Python process. The parameters are not carried in the ciphertext, and a Java peer using the bare
OAEPWithSHA-256AndMGF1Paddingtransformation runs MGF1 on SHA-1 and will fail on every message. - Uniform failures: a tampered ciphertext, one encrypted for a different key, and a truncated one all reach the caller as the same generic error.
cryptographyraises the sameValueErrorfor all of them; the risk is application code that re-raises them differently.
Common Pitfalls
- Migrating encryption call sites to
padding.OAEP(...)but leaving a decrypt function (or a different service that shares the key) still callingpadding.PKCS1v15()for "legacy" ciphertexts - thecryptographylibrary will decrypt either padding scheme without complaint, so the padding-oracle path the OAEP switch was meant to close remains reachable. - Catching the
cryptographylibrary's decrypt exception and re-raising it with a message that varies by cause (e.g., distinguishing a padding failure from a length failure) -private_key.decrypt()is designed to fail uniformly for any OAEP validation problem; adding differentiated error handling on top can reintroduce a Manger-style oracle. - Replacing the unmaintained
pycryptodependency withpycryptodomebut leaving the code importingCrypto.Cipher.PKCS1_v1_5unchanged - both packages install under the same top-levelCryptonamespace and PyCryptodome keepsPKCS1_v1_5available for legacy interoperability, so the library-maintenance problem is fixed but the padding scheme is not; the module name must also change toPKCS1_OAEP. - Encrypting oversized payloads by base64-encoding or gzip-compressing them first and hoping the result fits under the OAEP size limit, instead of switching to hybrid encryption - this just moves the failure point to a larger input and does not address RSA's unsuitability for bulk data.
Additional Resources
- Cryptography Library Documentation
- CWE-780: Use of RSA Algorithm without OAEP
- NIST SP 800-131A Rev 2: Transitioning the Use of Cryptographic Algorithms and Key Lengths - RSA-2048 provides 112-bit security strength, which is currently acceptable for applying protection
- NIST SP 800-57 Part 1 Rev 5: Recommendation for Key Management - Table 4 is the source for the 2030 date: 112-bit strength is Acceptable for applying protection (generating keys, encrypting, signing) through 2030 and Disallowed from 2031, while processing already-protected data stays permitted as legacy use
- OWASP Cryptographic Storage Cheat Sheet
- RFC 8017: PKCS #1 v2.2