$ Attacks that never touch the private key
Textbook RSA (rsa/core.py) has no ciphertext integrity check -- no MAC, no AEAD. Each demo below breaks that in a different, real way using this project's actual encrypt/decrypt code, and each is backed by a passing test in backend/tests/test_security_demo.py and tests/test_rsa.py.
0. Generate a keypair to attack
1. Multiplicative malleability
RSA encryption is a homomorphism: (m^e)(s^e) ≡ (m·s)^e (mod N). An attacker who intercepts c = m^e mod N can multiply in any blinding factor s of their choosing -- without ever seeing m or d -- and the victim's decryption comes back as m·s mod N.
Generate a keypair above first.
run the attack above to see this animate with real numbers
The actual attack code
s_pow_e = pow(req.blind_factor, req.e, req.n)c_tampered = (c * s_pow_e) % req.n
2. Block substitution (splicing)
A multi-block message is encrypted one block at a time with no chaining -- like ECB mode. An attacker who intercepts the ciphertext can encrypt a block of their own choosing (with only the public key) and splice it in place of a genuine one. The victim decrypts the whole thing without any error.
Generate a keypair above first.
splice a forged block above to see which one changes and why
The actual attack code
forged_ciphertext_block = encrypt_int(int.from_bytes(forged_bytes_padded, "big"), pub)
3. Case study: the PKCS7 padding bug this project actually shipped
An early version of rsa/core.py's padding check trusted the last decrypted byte as the pad length without validating it -- two real, silent-wrong-output bugs followed on exactly the kind of corrupted ciphertext these demos produce.
# before (vulnerable)
def _pkcs7_unpad(data, block_size):
pad_len = data[-1]
return data[:-pad_len] # pad_len=0 -> data[:-0] == data[:0] == b"" (Python quirk!)
# pad_len > len(data) -> silently over-truncates, no error
# after (fixed, the code this site actually runs)
def _pkcs7_unpad(data, block_size):
if not data or not (1 <= data[-1] <= block_size):
raise ValueError("invalid PKCS7 padding")
pad_len = data[-1]
if data[-pad_len:] != bytes([pad_len]) * pad_len:
raise ValueError("invalid PKCS7 padding")
return data[:-pad_len]Try it live: encrypt a message, flip the lowest bit of the last ciphertext block, and decrypt the result through the real, currently-deployed code path below.
Generate a keypair above first.
encrypt & corrupt a message above to watch which path it takes
4. The parity oracle attack: full recovery, zero private key
RSA is multiplicatively homomorphic, so an attacker who can learn just the parity (one bit) of the decrypted plaintext for a chosen ciphertext -- from a timing gap, a distinct error page, anything that collapses to one bit -- can binary-search out the entire message, using only the public key, in ceil(log2(N)) queries. No d. No brute force. No guessing.
Generate a keypair above first.
The actual attack code
for i in range(1, bits_needed + 1):current_ciphertext = (current_ciphertext * two_e) % nbit = oracle(current_ciphertext)if bit not in (0, 1):raise ValueError(f"oracle must return 0 or 1, got {bit!r}")mid = (lo + hi) / 2if bit == 0:hi = midelse:lo = mid
5. Wiener's attack: a classical break of the whole private key
No quantum computer, no oracle, no chosen ciphertexts -- just number theory. If a key was ever generated with an abnormally small private exponent d (roughly d < N^0.25 / 3), the continued-fraction expansion of e/N leaks d outright -- and from d, the full factorization p, q.
The actual attack code
def wiener_attack(n: int, e: int) -> WienerResult:convergents = continued_fraction_convergents(e, n)for i, frac in enumerate(convergents):k, d = frac.numerator, frac.denominatorif k == 0 or d == 0 or (e * d - 1) % k != 0:continuephi_candidate = (e * d - 1) // ks = n - phi_candidate + 1 # p + q, if this convergent is the right onediscriminant = s * s - 4 * nif discriminant < 0:continuesqrt_disc = math.isqrt(discriminant)if sqrt_disc * sqrt_disc != discriminant:continuep, q = (s + sqrt_disc) // 2, (s - sqrt_disc) // 2if p > 1 and q > 1 and p * q == n:return WienerResult(True, d, p, q, i + 1, len(convergents))return WienerResult(False, None, None, None, len(convergents), len(convergents))
6. Fault injection: one glitched bit factors a completely normal key
Real-world RSA signing almost always uses the CRT speedup (~4x faster than plain modular exponentiation): sign separately mod p and mod q, then recombine. No weak key, no small exponent, nothing unusual required -- if a single physical fault (a voltage glitch, a laser pulse, a stray cosmic ray, all documented against real smart cards and TPMs) corrupts just one of those two branches, the resulting signature leaks a full factor of n via one gcd computation.
The actual attack code
def crt_fault_attack(n: int, e: int, message: int, faulty_signature: int) -> CrtFaultResult:candidate = (pow(faulty_signature, e, n) - message) % nfactor = math.gcd(candidate, n)if factor in (0, 1, n):return CrtFaultResult(succeeded=False, recovered_p=None, recovered_q=None)other = n // factorp, q = sorted((factor, other))return CrtFaultResult(succeeded=True, recovered_p=p, recovered_q=q)