RSA Is Simple. Deploying RSA Correctly Almost Never Is.
The capstone to our RSA series. The math checks out. Shipping it as written gets you breached. This is the nineteen-year-old attack that proves it, and what belongs in production instead.
If you've followed the RSA from scratch series through key generation, proper prime handling, and digital signatures, you understand something most developers who use RSA every day have never bothered to learn: why it works. You've built the key generation. You've done the modular exponentiation by hand. You know what λ(n) is and why e is usually 65537.
None of that math is wrong. All of it is dangerously incomplete, and the gap between "the math works" and "this is safe to expose to the internet" is where a nineteen-year-old attack keeps finding new victims, one TLS stack at a time. This article is the piece I promised would come after the series proper: not more math, but the part where the math meets a hostile network and loses, repeatedly, for reasons that have nothing to do with arithmetic.
Textbook RSA Was Never Meant to Face the Internet
Every implementation in the series so far does what cryptographers call "textbook RSA." You take a message, treat it as a number, raise it to the power e mod n. Clean. Deterministic. Exactly what the math says it should be.
Deterministic is the problem. If encrypting the number 5 under a given public key always produces the exact same ciphertext, an attacker who suspects your plaintext is one of a small set of possibilities, a yes/no vote, a dollar amount, a status code, can encrypt every candidate themselves and compare the output against what they intercepted. That's a matching exercise, not a factoring problem, and it works precisely because you never told the algorithm to behave differently on two separate occasions.
Textbook RSA also has a nastier property: it's malleable. Encrypt two messages, multiply the ciphertexts together, and you get a valid encryption of the product of the two original messages, without ever touching the private key. An attacker who can influence the ciphertext your server receives can manipulate the plaintext your server decrypts, blind, without knowing what either value is.
Padding exists to kill both of these properties. A padding scheme takes your plaintext, mixes in randomness and structure before encryption, and gives the decrypting side a way to check that what came out the other side is well-formed. Done right, it removes the determinism and breaks the malleability. Done wrong, and this is the part the series never had room to cover, it opens a door that's arguably worse than not padding at all, because now the server is willing to tell an attacker, one bit at a time, whether a guess was close.
The Attack That Refuses to Die
In 1998, Daniel Bleichenbacher published an attack against the PKCS#1 v1.5 padding scheme used in SSL at the time. The idea: send the server a chosen ciphertext, and watch how it responds. If the padding on the decrypted result looks structurally valid, the server proceeds one way. If the padding looks malformed, it fails differently, maybe a different error message, maybe a different response time, maybe a connection reset instead of a handshake alert. That difference in behavior is an oracle. It doesn't hand the attacker the plaintext directly. It hands them a single bit of information about a guess, and thousands of carefully chosen guesses later, that bit-by-bit leakage reconstructs the entire message. Researchers at the time called it the million-message attack, because that's roughly how many chosen ciphertexts it took to fully compromise a session.
The fix seemed obvious: make every failure look the same. Don't tell the attacker why decryption failed. Every implementation since 1998 has tried to do exactly that.
In December 2017, Hanno Böck, Juraj Somorovsky, and Craig Young showed that trying isn't the same as succeeding. Their research, published as ROBOT, Return of Bleichenbacher's Oracle Threat, found that major TLS stacks, including F5's, Citrix's, and Cisco's, still leaked distinguishable error behavior nearly twenty years after the original attack was public knowledge. Some servers sent different TLS alert codes. Others behaved differently under a shortened handshake flow that skipped the expected ChangeCipherSpec message. A few leaked the difference through nothing more than response timing. The F5 Labs writeup of their own exposure is worth reading end to end if you want to see how an engineering team with real resources still got caught by a mistake this old. F5's specific instance was tracked as CVE-2017-6168.
Nineteen years is not long enough for a padding oracle to become someone else's problem. It's exactly long enough for the next team to inherit the same code and never read the changelog.
The researchers demonstrated the practical stakes by using a vulnerable server's own private key operations against it, well enough to forge a valid signature the server itself would never have knowingly produced. That's the part worth sitting with. This wasn't a theoretical weakness confined to a lab. It reached production infrastructure belonging to some of the largest sites on the internet, sitting there, quietly exploitable, because "different error handling paths" felt like an implementation detail to whoever wrote them in the first place.
What the Oracle Looks Like in Code
I'm not going to hand you working attack tooling. That already exists, is well documented, and doesn't need another copy. What's worth seeing is the shape of the mistake, because it's more mundane than the name "nineteen-year-old cryptographic attack" makes it sound.
This is the kind of code that creates an oracle, written generically rather than pulled from any specific product:
def decrypt_and_unpad(ciphertext, private_key):
plaintext = rsa_decrypt(ciphertext, private_key)
if plaintext[0:2] != b'\x00\x02':
raise ValueError("Invalid padding: bad header bytes")
padding_end = plaintext.find(b'\x00', 2)
if padding_end == -1:
raise ValueError("Invalid padding: no separator found")
return plaintext[padding_end + 1:]
Nothing here looks reckless. It reads like careful, defensive code. That's exactly the trap. Two different exceptions, with two different messages, thrown at two different points in the function. If those messages, or even the timing of which check failed first, ever reach a place an attacker can observe, whether that's an error response, a log line reflected back through some other channel, or a measurable difference in how long the function took before it gave up, you've built an oracle. Not on purpose. Only by writing normal, readable error handling for a function that has no business being allowed to fail informatively.
The mitigation is less satisfying than it should be, because it asks you to make your code worse at being helpful:
def decrypt_and_unpad(ciphertext, private_key):
plaintext = rsa_decrypt(ciphertext, private_key)
valid_header = plaintext[0:2] == b'\x00\x02'
padding_end = plaintext.find(b'\x00', 2)
valid_separator = padding_end != -1
is_valid = valid_header and valid_separator
result = plaintext[padding_end + 1:] if valid_separator else b''
if not is_valid:
raise ValueError("Decryption failed")
return result
One error message. One code path, evaluated in constant time regardless of which specific check failed, so an attacker measuring response time learns nothing about where the padding went wrong. It's less friendly to debug. That's the point. A padding oracle protects itself by refusing to be helpful to anyone, including the developer who has to support it at 3 AM with nothing but "decryption failed" in the logs.
This is also, not coincidentally, exactly why the field moved toward OAEP for encryption and PSS for signatures instead of continuing to patch PKCS#1 v1.5 forever. Both schemes were designed from the start with padding validation that doesn't leak through timing or error variance the way naive v1.5 checks do. If you're implementing RSA today rather than studying how it works, reach for a library that's already made these decisions for you. OpenSSL and libsodium have had two decades of the entire security research community trying to break their padding implementations. Your hand-rolled version has had you, on a Tuesday, for however long it took to write it.
Signatures Have Their Own Version of This Problem
Everything above covers encryption. Signatures fail differently, and the series already touched the fix without naming the attack it prevents.
Raw RSA signing, without hashing the message first, inherits the same multiplicative property that makes textbook encryption malleable. If signing is nothing more than m^d mod n, then an attacker who can get you to sign two chosen messages can derive a valid signature over the product of those messages, without you ever having signed that specific value. That's an existential forgery: a signature over content you never agreed to, constructed entirely from signatures over content you did agree to.
Hashing the message before signing, which our digital signatures piece already does, breaks this specific attack, because the hash function's output doesn't preserve the multiplicative relationship between inputs. But hashing alone isn't the full modern answer either. PKCS#1 v1.5 signature padding has had its own history of implementation bugs, most famously cases where sloppy padding verification let an attacker forge a signature by constructing a value that merely looked correct to a parser that wasn't checking carefully enough for trailing garbage after the expected structure. PSS, the padding scheme built specifically for signatures, adds randomization and a more rigorous verification structure that closes that specific class of parser mistake.
The practical guidance, for anyone shipping this rather than studying it: hash before you sign, use PSS over PKCS#1 v1.5 where your protocol allows it, and verify the entire structure rather than checking for a prefix and calling it done.
What Belongs in Production
Everything the series built exists to teach the math, and it did that job well. None of it, run as written, belongs anywhere near real user data. This is the honest list of what changes between "I understand RSA" and "I can deploy RSA":
- Use a vetted library, always. OpenSSL, libsodium, your language's standard cryptography module. Not because you couldn't implement OAEP or PSS correctly with enough time. Because a library with a decade of public scrutiny has already survived attacks you haven't thought of yet.
- Prefer forward-secret key exchange over raw RSA key transport. The TLS handshake piece in this series shows RSA doing key transport directly. Modern TLS configurations favor ECDHE specifically so that a compromised private key years from now can't retroactively decrypt traffic captured today. RSA can still sign the handshake to prove identity. It doesn't need to be the thing carrying the session key.
- Size your keys for the threat model you have, not the one from 2010. 2048-bit RSA is the current practical floor. 3072 or 4096 buys headroom against improvements in factoring, at a real computational cost you should measure before assuming it's free.
- Watch what's coming, even if you're not touching it yet. Cryptographically relevant quantum computing is still a "when," not an imminent "now," but the migration planning already underway across the industry, which we've covered in big tech's diverging PQC readiness timelines and the more speculative threats to systems like Bitcoin's signature scheme, exists because RSA and elliptic curve cryptography share the same underlying mathematical assumption that a sufficiently capable quantum computer would break. That's a separate, longer piece on its own. File it under "not urgent, not ignorable."
The Series Was Never About Shipping RSA
If you came away from the earlier parts of this series thinking you now had production-ready encryption code, that's on me for not saying this sooner: you had a working demonstration of why RSA is mathematically sound, which is a different kind of achievement than having something safe to expose to strangers on the internet. The gap between those two things isn't a footnote. It's nineteen years of a single attack class finding new victims, one confident engineering team at a time, because "we know the math" and "we're safe" turned out to be two separate claims wearing the same acronym.
Understand the math. Then hand the implementation to a library that's already been attacked by people smarter and more patient than either of us, and let their scar tissue do the work yours hasn't earned yet.