Skip to content

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_5 uses PKCS#1 v1.5, the same padding oracle weakness described above.
  • PyCryptodome (the maintained fork) keeps PKCS1_v1_5 only 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 where PKCS1v15 left 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 encrypt calls 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-256AndMGF1Padding transformation 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. cryptography raises the same ValueError for 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 calling padding.PKCS1v15() for "legacy" ciphertexts - the cryptography library will decrypt either padding scheme without complaint, so the padding-oracle path the OAEP switch was meant to close remains reachable.
  • Catching the cryptography library'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 pycrypto dependency with pycryptodome but leaving the code importing Crypto.Cipher.PKCS1_v1_5 unchanged - both packages install under the same top-level Crypto namespace and PyCryptodome keeps PKCS1_v1_5 available for legacy interoperability, so the library-maintenance problem is fixed but the padding scheme is not; the module name must also change to PKCS1_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