Skip to content

CWE-780: Use of RSA Algorithm without OAEP - C# / .NET

Overview

RSA encryption in .NET uses one of two padding schemes: PKCS#1 v1.5 and OAEP (Optimal Asymmetric Encryption Padding). PKCS#1 v1.5 padding, used when RSACryptoServiceProvider.Encrypt(data, fOAEP: false) is called, is vulnerable to Bleichenbacher's adaptive chosen-ciphertext attack, which allows an attacker who can observe the server's response to decryption attempts (a "padding oracle") to decrypt RSA-encrypted messages without the private key.

OAEP padding adds randomization and a hash-based message structure that eliminates the padding oracle vulnerability. In .NET, RSA.Create() with RSAEncryptionPadding.OaepSHA256 selects OAEP over SHA-256. Passing fOAEP: true to the legacy RSACryptoServiceProvider also selects OAEP, but always over SHA-1 - on .NET 10 its ciphertext decrypts under OaepSHA1 and no other digest - so it closes the padding oracle without getting the code off SHA-1.

Primary Defence: Replace RSACryptoServiceProvider.Encrypt(data, false) with RSA.Create() and rsa.Encrypt(data, RSAEncryptionPadding.OaepSHA256). For payloads larger than ~318 bytes (the maximum for RSA-OAEP with a 3072-bit key), use hybrid encryption: encrypt data with AES-GCM and encrypt only the AES key with RSA-OAEP.

Common Vulnerable Patterns

RSACryptoServiceProvider with PKCS#1 v1.5

using System.Security.Cryptography;

// VULNERABLE - fOAEP = false uses PKCS#1 v1.5 padding
public static byte[] EncryptVulnerable(byte[] data, RSACryptoServiceProvider rsa)
{
    return rsa.Encrypt(data, fOAEP: false); // PKCS#1 v1.5 - vulnerable to Bleichenbacher
}

public static byte[] DecryptVulnerable(byte[] ciphertext, RSACryptoServiceProvider rsa)
{
    return rsa.Decrypt(ciphertext, fOAEP: false); // Must match - also vulnerable
}

Why this is vulnerable:

  • PKCS#1 v1.5 includes a padding structure that the RSA decryption operation verifies. If the server returns different responses for valid vs. invalid padding (a timing difference or a difference in the error it returns), an attacker can perform Bleichenbacher's attack and decrypt ciphertexts with as few as a million queries.

OaepSHA1 (Deprecated Hash)

// VULNERABLE - SHA-1 is deprecated; use SHA-256 or stronger
var rsa = RSA.Create(3072);
byte[] ciphertext = rsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA1);

Why this is vulnerable:

  • RSAEncryptionPadding.OaepSHA1 uses SHA-1 as the hash function in the OAEP mask generation. While this does not directly enable Bleichenbacher's attack, SHA-1 is cryptographically weak and its use is discouraged by NIST. Prefer OaepSHA256 or stronger.

Direct RSA Encryption of Large Payloads

// VULNERABLE - Direct RSA Encryption of Large Payloads
// PROBLEMATIC - RSA can only encrypt up to key_size_bytes - 66 bytes with OaepSHA256
// (2 * 32-byte digest + 2; the familiar 42 is the SHA-1 figure, 2 * 20 + 2).
// For a 3072-bit key: max 318 bytes. Larger data causes a CryptographicException.
var rsa = RSA.Create(3072);
byte[] bigPayload = Encoding.UTF8.GetBytes(largeStringOver318Chars);
byte[] ciphertext = rsa.Encrypt(bigPayload, RSAEncryptionPadding.OaepSHA256); // throws!

Why this is vulnerable/problematic:

  • RSA is not designed for bulk encryption. Attempting to encrypt large data directly throws a CryptographicException. Applications that encounter this often fall back to weaker padding or split the data unsafely. Hybrid encryption is the correct approach.

Secure Patterns

RSA.Create() with OAEP-SHA256

using System.Security.Cryptography;

// SECURE - modern API with explicit OAEP-SHA256 padding
public static byte[] Encrypt(byte[] plaintext, RSA publicKey)
{
    // Max plaintext: key_size_in_bytes - 66 bytes (for OaepSHA256)
    // For 3072-bit key: 384 - 66 = 318 bytes max
    return publicKey.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
}

public static byte[] Decrypt(byte[] ciphertext, RSA privateKey)
{
    return privateKey.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
}

Why this works:

  • RSAEncryptionPadding.OaepSHA256 uses OAEP with SHA-256 as the hash and MGF1 mask generation function. OAEP's randomized padding ensures identical plaintexts produce different ciphertexts, and its structure prevents padding oracle attacks.

Hybrid Encryption for Large Payloads

using System.Security.Cryptography;

// SECURE - hybrid encryption - RSA-OAEP encrypts the AES key; AES-GCM encrypts the data
public static HybridCiphertext HybridEncrypt(byte[] plaintext, RSA publicKey)
{
    // Generate a fresh random AES-256 key for this message
    byte[] aesKey = RandomNumberGenerator.GetBytes(32); // 256-bit
    byte[] nonce  = RandomNumberGenerator.GetBytes(12); // 96-bit GCM nonce

    byte[] ciphertext = new byte[plaintext.Length];
    byte[] tag = new byte[16]; // 128-bit authentication tag

    using var aesGcm = new AesGcm(aesKey, tagSizeInBytes: 16);
    aesGcm.Encrypt(nonce, plaintext, ciphertext, tag);

    // Encrypt the AES key with RSA-OAEP (32 bytes is well within the 318-byte limit)
    byte[] encryptedKey = publicKey.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256);

    return new HybridCiphertext(encryptedKey, nonce, ciphertext, tag);
}

public static byte[] HybridDecrypt(HybridCiphertext envelope, RSA privateKey)
{
    byte[] aesKey = privateKey.Decrypt(envelope.EncryptedKey, RSAEncryptionPadding.OaepSHA256);
    byte[] plaintext = new byte[envelope.Ciphertext.Length];

    using var aesGcm = new AesGcm(aesKey, tagSizeInBytes: 16);
    aesGcm.Decrypt(envelope.Nonce, envelope.Ciphertext, envelope.Tag, plaintext);

    return plaintext;
}

public record HybridCiphertext(byte[] EncryptedKey, byte[] Nonce, byte[] Ciphertext, byte[] Tag);

Why this works:

  • RSA-OAEP encrypts only the 32-byte AES key (well within the 318-byte limit). AES-256-GCM then encrypts the actual data of any size. AES-GCM provides both confidentiality (encryption) and integrity (authentication tag), so a tampered ciphertext fails to decrypt instead of returning altered plaintext.
  • A fresh random AES key is generated per message, so even if one message's key is compromised, others remain secure.

Migrating from RSACryptoServiceProvider

// BEFORE (vulnerable):
using var rsaLegacy = new RSACryptoServiceProvider(2048);
byte[] legacyCiphertext = rsaLegacy.Encrypt(plaintext, fOAEP: false); // PKCS#1 v1.5

// AFTER (secure): carry the existing key over, change only the padding.
// ImportParameters preserves the key material, so certificates and published
// public keys stay valid. Moving 2048 -> 3072 is a separate, later migration that
// does require new key material. NIST accepts 2048 (112-bit) only through 2030,
// so generate new keys at 3072; do not conflate that with this padding fix.
using var rsa = RSA.Create();
rsa.ImportParameters(rsaLegacy.ExportParameters(includePrivateParameters: true));
byte[] ciphertext = rsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256); // OAEP

// The old ciphertext is not readable with the new padding - keep this path
// alive until the backfill completes.
byte[] recoveredLegacy = rsa.Decrypt(legacyCiphertext, RSAEncryptionPadding.Pkcs1);
byte[] recovered = rsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);

Why this works: The vulnerability is the fOAEP: false argument, not the class. That boolean selects PKCS#1 v1.5 padding, which is deterministic in structure and leaks whether a decryption produced a well-formed block - the signal Bleichenbacher's adaptive chosen-ciphertext attack uses to recover plaintext one query at a time without ever learning the private key. RSAEncryptionPadding.OaepSHA256 adds randomised padding with an integrity check, so a tampered ciphertext fails indistinguishably and yields the attacker nothing to iterate on.

Moving to RSA.Create() matters for a second reason: it is the platform-agnostic factory, while RSACryptoServiceProvider is the Windows CSP-backed implementation. EncryptValue/DecryptValue on it are obsolete as SYSLIB0048 and throw NotSupportedException (verified on .NET 10); Encrypt(byte[], bool) and Decrypt(byte[], bool) still compile without a warning there, so the fOAEP: false call site is a finding the compiler will not raise for you. Code that stays on this class will not behave consistently off Windows.

The ImportParameters step is what makes this a migration rather than a re-key. The key material is unchanged - only the padding scheme changes - so existing public keys stay valid and certificates do not need reissuing. Ciphertext is the part that does not carry over: anything already encrypted with PKCS#1 v1.5 must still be decrypted with RSAEncryptionPadding.Pkcs1 until it has been re-encrypted, so keep the old decryption path alive until the backfill completes.

Testing

  • Round trip: Decrypt(Encrypt(plaintext, OaepSHA256), OaepSHA256) returns a byte array equal to plaintext. Assert on the bytes; an encrypt-only test passes whether or not anything can read the result back. Do the same through HybridEncrypt/HybridDecrypt - it is the path where a dropped nonce or tag produces ciphertext nothing can open.
  • Boundary input: on a 3072-bit key, 318 bytes encrypts under OaepSHA256 and 319 throws CryptographicException. Assert the largest payload the system actually sends is under the limit: OaepSHA256 leaves 318 bytes where Pkcs1 left 373, and on a 2048-bit key 190 where Pkcs1 left 245 (measured on .NET 10). 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.
  • Negative case: a ciphertext produced with OaepSHA256 throws CryptographicException when decrypted with RSAEncryptionPadding.Pkcs1 rather than returning a shorter or garbled array.
  • Uniform failures: a tampered ciphertext, one produced for a different key, and a truncated one all reach the caller as the same generic error and status code. CryptographicException.Message reaching a response body or a caller-visible log is what makes an oracle usable.
  • Re-run the scanner that reported the finding and confirm it no longer triggers.

Common Pitfalls

  • Calling rsa.Encrypt(data, true) as the "quick fix" for the boolean overload without migrating to RSA.Create()/RSAEncryptionPadding.OaepSHA256 - fOAEP: true on the legacy RSACryptoServiceProvider always uses SHA-1 for the OAEP hash (it has no parameter to change it), so the padding-oracle risk is closed but the weak-hash issue is not.
  • Wrapping rsa.Decrypt() in a try/catch that logs or returns the underlying CryptographicException message - even with OAEP, exposing whether a failure came from padding validation versus a downstream step can leak information; catch and return a single generic decryption failure instead.
  • Encrypting a payload just over the ~318-byte OAEP-SHA256 limit by switching to a larger key size (3072/4096-bit) instead of hybrid encryption - this only raises the ceiling and reintroduces the same problem for the next slightly-larger payload; use AES-GCM + RSA-OAEP regardless of key size once payloads are variable-length.
  • Importing an RSACryptoServiceProvider key into RSA.Create() via ImportParameters but leaving old call sites that still construct RSACryptoServiceProvider directly elsewhere in the codebase - the key material is portable, but each remaining fOAEP: false call site is still independently vulnerable.

Additional Resources