! Educational demonstration only. Keys, moduli, and RSA sizes here are intentionally tiny so classical/quantum attacks finish in seconds — today's classical hardware cannot run Shor's algorithm against real production RSA keys. See Security & Limitations.
Shor's Lab

$ 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.

Every attack here needs only the public key and an intercepted ciphertext -- none of them touch the private key d. That's the point: this is exactly what a network eavesdropper could do.

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.

7
m (secret)
c
intercepted
×
s^e
s = 3
c′
tampered

run the attack above to see this animate with real numbers

The actual attack code

backend/app/routers/security_demo.py:118-119
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

backend/app/routers/security_demo.py:216-216
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

attacker/parity_oracle.py:64-73
for i in range(1, bits_needed + 1):
current_ciphertext = (current_ciphertext * two_e) % n
bit = oracle(current_ciphertext)
if bit not in (0, 1):
raise ValueError(f"oracle must return 0 or 1, got {bit!r}")
mid = (lo + hi) / 2
if bit == 0:
hi = mid
else:
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

attacker/wiener.py:37-54
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.denominator
if k == 0 or d == 0 or (e * d - 1) % k != 0:
continue
phi_candidate = (e * d - 1) // k
s = n - phi_candidate + 1 # p + q, if this convergent is the right one
discriminant = s * s - 4 * n
if discriminant < 0:
continue
sqrt_disc = math.isqrt(discriminant)
if sqrt_disc * sqrt_disc != discriminant:
continue
p, q = (s + sqrt_disc) // 2, (s - sqrt_disc) // 2
if 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

attacker/crt_fault.py:100-107
def crt_fault_attack(n: int, e: int, message: int, faulty_signature: int) -> CrtFaultResult:
candidate = (pow(faulty_signature, e, n) - message) % n
factor = math.gcd(candidate, n)
if factor in (0, 1, n):
return CrtFaultResult(succeeded=False, recovered_p=None, recovered_q=None)
other = n // factor
p, q = sorted((factor, other))
return CrtFaultResult(succeeded=True, recovered_p=p, recovered_q=q)
Go deeper: Security & Limitations