RSA Attacks
A comprehensive catalogue of RSA attacks for CTF crypto: small e, Hastad, common modulus, Wiener, Boneh-Durfee, Franklin-Reiter, partial key exposure, parity oracle, Fermat, batch GCD, Pollard p-1, Coppersmith, CRT faults, and signature forgery.
RSA Attacks
RSA itself is sound; what breaks in CTFs is the way it is used. Reused randomness, tiny exponents, structured plaintexts, leaked bits, or special-shaped primes each open a distinct mathematical door. This page is a working catalogue: how to recognise each weakness, the math behind it, and a runnable script.
If you have not yet internalised key generation, , and the encryption/decryption relations, read RSA Basics first.
Notation: is the modulus, the public exponent (usually ), the private exponent, the plaintext, the ciphertext, and a message hash.
Which Attack? Decision Table
Read off what the challenge gives you and what looks abnormal.
| Symptom / given data | Likely attack | Section |
|---|---|---|
| (or tiny), one ciphertext, m^e < n | Cube root / no-padding | Small |
| Same , same small , different moduli | Hastad broadcast | Hastad |
| Same , same , two coprime exponents | Common modulus | Common modulus |
| Huge (close to ), implies small | Wiener | Wiener |
| Small but d > n^{0.25} (up to ) | Boneh-Durfee | Boneh-Durfee |
| Two related plaintexts , same | Franklin-Reiter | Franklin-Reiter |
| You leak high/low bits of or of | Partial key exposure / Coppersmith | Partial key |
| Oracle reveals parity / range of | LSB / parity oracle | Parity oracle |
| small (primes close) | Fermat | Fermat |
| Many public keys from one source | Batch GCD (shared factor) | Batch GCD |
| has only small prime factors | Pollard | Pollard p-1 |
| Known prefix/suffix of , small | Coppersmith stereotyped | Coppersmith |
| CRT signing with a glitch / wrong value | CRT fault (Bellcore) | CRT fault |
| signatures, no full-domain hash | Bleichenbacher forgery | Forgery |
| small or in a public database | Just factor it | Tooling |
Tooling and First Moves
Before any clever attack, try to factor outright.
Many CTF moduli (or their factors) are already in the public database at factordb.com.
from factordb.factordb import FactorDB
f = FactorDB(n)
f.connect()
print(f.get_factor_list()) # [] or fully/partially factoredFactoring a real 2048-bit RSA modulus is infeasible. If a CTF expects you to factor a large , the primes have special structure (close, smooth, shared) and one of the targeted attacks below applies.
Small Public Exponent (No Padding)
When is tiny (classically ) and the plaintext is short, encryption may not wrap around the modulus: if m^e < n, then over the integers and you recover with an integer -th root.
c = m^e \pmod n \quad\text{but}\quad m^e < n \;\Longrightarrow\; m = \sqrt[e]{c}from gmpy2 import iroot
from Crypto.Util.number import long_to_bytes
c = ... # ciphertext
e = 3
m, exact = iroot(c, e)
if exact:
print(long_to_bytes(int(m)))
else:
print("Not a perfect e-th root: m^e wrapped the modulus or padding is present")If it is not a perfect root, exceeded . You can still recover if it wrapped by only a small factor : try for until the root is exact.
Hastad Broadcast Attack
If the same message is sent to (or more) recipients with the same small exponent and pairwise-coprime moduli , the Chinese Remainder Theorem reconstructs modulo . Because m^e < N, that combined value is over the integers, and a single -th root finishes the job.
from sympy.ntheory.modular import crt
from gmpy2 import iroot
from Crypto.Util.number import long_to_bytes
e = 3
mods = [n1, n2, n3]
cts = [c1, c2, c3]
M, _ = crt(mods, cts) # M = m^e mod (n1*n2*n3)
m, exact = iroot(int(M), e)
assert exact
print(long_to_bytes(int(m)))If each recipient applies a different linear padding (e.g. ), plain CRT fails. Hastad's full theorem handles this with Coppersmith on the combined polynomial - see the Coppersmith section.
Common Modulus Attack
Two ciphertexts of the same plaintext under the same modulus but different, coprime exponents leak regardless of how large the exponents are. By Bezout, find with ; then
One of is negative, so invert the corresponding ciphertext.
from math import gcd
from Crypto.Util.number import long_to_bytes
n, e1, e2, c1, c2 = ...
assert gcd(e1, e2) == 1
g, s, t = __import__("sympy").gcdex(e1, e2) # s*e1 + t*e2 = 1
s, t = int(s), int(t)
if s < 0:
c1 = pow(c1, -1, n); s = -s
if t < 0:
c2 = pow(c2, -1, n); t = -t
m = (pow(c1, s, n) * pow(c2, t, n)) % n
print(long_to_bytes(m))Wiener's Attack (Small )
If the private exponent is small, d < \tfrac{1}{3} n^{1/4}, then appears as a denominator in the continued-fraction expansion of . Each convergent is a candidate; the right one yields an integer that recovers .
from Crypto.Util.number import long_to_bytes
def wiener(e, n):
# continued fraction of e/n
cf, a, b = [], e, n
while b:
cf.append(a // b); a, b = b, a % b
# convergents
num0, num1, den0, den1 = 0, 1, 1, 0
for q in cf:
num0, num1 = num1, q * num1 + num0
den0, den1 = den1, q * den1 + den0
k, d = num0, den1
if k == 0:
continue
if (e * d - 1) % k:
continue
phi = (e * d - 1) // k
# solve x^2 - (n - phi + 1)x + n = 0 for integer roots p, q
s = n - phi + 1
disc = s * s - 4 * n
if disc >= 0:
t = __import__("gmpy2").isqrt(disc)
if t * t == disc and (s + t) % 2 == 0:
return d
return None
d = wiener(e, n)
print("d =", d)The library owiener (pip install owiener) does the same in one call. For the boundary case , Wiener fails and you need Boneh-Durfee.
Boneh-Durfee (Small , Lattice)
Boneh and Durfee extended the small- break up to
d < n^{\,0.292}(the tight bound \delta < 1 - \tfrac{1}{\sqrt 2}), using Coppersmith's lattice method. The key relation with becomes a small-root problem for the bivariate modular polynomial
where the small root encodes and . See the original Eurocrypt 1999 paper and the lattice attacks page.
# SageMath - uses the well-known boneh_durfee.sage helper
# https://github.com/mimoo/RSA-and-LLL-attacks
#
# Set the parameters and the lattice tuning knobs:
# delta : target bound, d < n^delta (try 0.26 -> 0.292)
# m, t : lattice dimension / shifts (raise if it fails, slower)
load("boneh_durfee.sage")
n = ...
e = ...
delta = 0.28
m = 4 # lattice parameter
t = int((1 - 2 * delta) * m)
X = int(floor(n ** delta)) # bound on k
Y = int(floor(2 * n ** 0.5)) # bound on p+q
d = boneh_durfee(n, e, delta, m, t, X, Y)
print("recovered d =", d)Boneh-Durfee is finicky: if it returns nothing, increase (and recompute ), or nudge delta upward toward the true value. Wiener first - it is instant when it applies.
Franklin-Reiter Related Message
If two plaintexts satisfy a known linear relation and are encrypted under the same with small , then is a common root of
Their polynomial is (with overwhelming probability) linear, , revealing the message.
# SageMath
def franklin_reiter(n, e, c1, c2, a, b):
P.<x> = PolynomialRing(Zmod(n))
g1 = x^e - c1
g2 = (a * x + b)^e - c2
# monic gcd over Z/nZ
g = g1
h = g2
while h != 0:
g, h = h, g % h
g = g.monic()
return Integer(-g[0]) # root is the constant term of (x - m1)
m1 = franklin_reiter(n, e, c1, c2, a, b)
print(bytes.fromhex(hex(int(m1))[2:]))The closely related Coppersmith short-pad attack handles the case where the relation is unknown but small (random short padding differs between two encryptions of the same message); it combines a Franklin-Reiter step with a Coppersmith resultant - see below.
Partial Key Exposure
Leaking only part of the private key is often enough.
- Low bits of : Given roughly the bottom bits of and a small , Boneh-Durfee-Frankel recovers the rest. With small you can also solve style relations.
- High or low bits of : If you know about half the bits of a prime, Coppersmith factors . Write with \lvert x_0\rvert < n^{1/4} and find the small root of .
# SageMath - factor n given the high bits of p (Coppersmith)
n = ...
p_high = ... # the known most-significant bits of p, low bits zeroed
unknown_bits = ... # number of unknown low bits of p
P.<x> = PolynomialRing(Zmod(n))
f = p_high + x
f = f.monic()
# small_roots needs: beta ~ 0.5 (root mod a divisor of size n^beta),
# X bounds the unknown part.
roots = f.small_roots(X=2**unknown_bits, beta=0.4)
if roots:
p = int(p_high + roots[0])
print("p =", p, " divides n:", n % p == 0)This is the practical core of Coppersmith for factoring; the lattice page explains the bound X < n^{\beta^2/\deg} that governs how many bits you need.
LSB / Parity Oracle
If a service decrypts attacker-chosen ciphertexts and leaks just the parity (or just whether the result is in the upper/lower half), you can binary-search the plaintext. Multiplying by encrypts ; the oracle's answer tells you whether wrapped the modulus, which is one bit of per query.
Each query halves the interval containing ; after queries you have exactly.
from fractions import Fraction
from Crypto.Util.number import long_to_bytes
def lsb_oracle(c):
"""Return parity (0/1) of the decryption of c. Replace with the real oracle."""
...
n, e, c = ...
two_e = pow(2, e, n)
lo, hi = Fraction(0), Fraction(n)
ct = c
for _ in range(n.bit_length()):
ct = (ct * two_e) % n
mid = (lo + hi) / 2
if lsb_oracle(ct) == 1: # 2m wrapped -> m was in upper half
lo = mid
else:
hi = mid
print(long_to_bytes(int(hi)))Use rational arithmetic (Fraction) to avoid the off-by-one rounding bugs that plague integer-only implementations near the final bit.
Fermat Factorization (Close Primes)
If and are close, with and small . Iterate until is a perfect square.
from gmpy2 import isqrt, is_square
def fermat(n):
a = isqrt(n) + 1
while True:
b2 = a * a - n
if is_square(b2):
b = isqrt(b2)
return int(a - b), int(a + b)
a += 1
p, q = fermat(n)
print("p =", p, "q =", q)This converges in a handful of steps only when . Wider gaps need a sieving variant or a different attack.
Shared Factor / Batch GCD
If many RSA keys are generated with a weak RNG, two moduli may share a prime. Then factors both instantly. Across a large key set, the bGCD / "Mining your Ps and Qs" product-tree algorithm finds all shared factors in near-linear time.
from math import gcd
from Crypto.Util.number import long_to_bytes
# pairwise GCD for a small batch
def pairwise_shared(moduli):
found = {}
for i in range(len(moduli)):
for j in range(i + 1, len(moduli)):
g = gcd(moduli[i], moduli[j])
if g != 1:
found[i] = g; found[j] = g
return found
shared = pairwise_shared([n1, n2, n3])
# Given a shared prime p for modulus n with exponent e:
# q = n // p; d = pow(e, -1, (p-1)*(q-1)); m = pow(c, d, n)For thousands of keys, use a product/remainder tree (the batch-gcd tool) instead of pairwise GCDs.
Smooth (Pollard)
If factors into only small primes (it is -smooth), Pollard's method finds . Compute where ; then .
from math import gcd
from Crypto.Util.number import isPrime
import sympy
def pollard_p_minus_1(n, B=10**6):
a = 2
for prime in sympy.primerange(2, B):
pe = prime
while pe * prime <= B:
pe *= prime
a = pow(a, pe, n)
g = gcd(a - 1, n)
if 1 < g < n:
return g
return None
p = pollard_p_minus_1(n)
print("p =", p)The analogous Williams method targets smooth . CTF challenges that "roll their own primes" (e.g. ) are textbook targets.
Coppersmith: Stereotyped and Short-Pad
Coppersmith's method finds small roots of a polynomial modulo a divisor of . Two classic RSA applications:
Stereotyped messages
The plaintext is mostly known with a small unknown chunk: , with small and small. The root of is recoverable when \lvert x_0\rvert < n^{1/e}.
Short-pad (Coppersmith + Franklin-Reiter)
The same message is encrypted twice with short random pads. The difference of pads is a small root of a resultant polynomial in two variables; a Franklin-Reiter gcd then recovers .
# SageMath - stereotyped message with e = 3
n = ...
e = 3
c = ...
m_known = ... # known part, unknown low bits zeroed
unknown_bits = ... # size of the unknown chunk
P.<x> = PolynomialRing(Zmod(n))
f = (m_known + x)^e - c
f = f.monic()
roots = f.small_roots(X=2**unknown_bits, beta=1.0, epsilon=1/30)
print("recovered low part:", roots)For non-built-in cases (bivariate, custom shifts) use the well-known defund/coppersmith helper. The mechanics of small_roots, the lattice it builds, and the achievable bounds are covered on the lattice attacks page.
CRT Fault Attack (Bellcore)
RSA signing is often sped up with the CRT: compute and , then recombine. If a hardware/software fault corrupts exactly one half (say is wrong, correct), the faulty signature satisfies but . Therefore
One faulty signature factors the modulus.
from math import gcd
# m : the signed message (already encoded), e, n public
# s_faulty : a signature produced with a fault injected in one CRT branch
p = gcd(pow(s_faulty, e, n) - m, n)
q = n // p
print("p =", p, "q =", q)The defence (verify the signature, or use Shamir's trick before releasing it) is exactly what a vulnerable CTF service forgets to do.
Bleichenbacher Signature Forgery
PKCS#1 v1.5 signatures with are forgeable when the verifier parses the padding loosely (does not check that the hash sits flush against the modulus, i.e. accepts trailing garbage). The signature block is
If the verifier ignores the bytes after the hash, you can craft a number whose perfect cube reproduces a valid-looking prefix, with arbitrary low bytes - and a cube root is easy with . No private key required.
from gmpy2 import iroot, mpz
import hashlib
# Build the "left-aligned" block: header + ASN.1 + H(m), then garbage to fill n.
def forge_e3(msg, modulus_bits, asn1_prefix):
h = hashlib.sha256(msg).digest()
block = b"\x00\x01\xff\xff\xff\x00" + asn1_prefix + h
# left-align in the modulus, pad the rest with zero bits we are free to choose
block += b"\x00" * ((modulus_bits // 8) - len(block))
target = int.from_bytes(block, "big")
# take the ceil cube root; the cube's high bytes match our crafted prefix
root, _ = iroot(mpz(target), 3)
forged = int(root) + 1
return forged # forged^3 verifies under a sloppy parser
# Real exploits tune the trailing bytes so the cube's prefix matches exactly;
# see Hal Finney's "Bleichenbacher's RSA signature forgery based on implementation error".See Hal Finney's classic write-up for the byte-exact construction.
Checklist
- Tried
FactorDBand a quickfactor(n)/ ECM pass - tiny and message short? cube root, then try
- Same across moduli? Hastad + CRT
- Same , two coprime exponents? common modulus (Bezout)
- huge / small? Wiener, then Boneh-Durfee up to
- Known linear relation between two messages? Franklin-Reiter gcd
- Leaked bits of or ? Coppersmith small-roots
- Parity/range oracle available? binary-search with
- small? Fermat
- Many keys from one source? batch GCD for shared primes
- smooth? Pollard (and try )
- Known plaintext structure? Coppersmith stereotyped / short-pad
- CRT signing service that can glitch? Bellcore fault, one signature
- PKCS#1 v1.5 verifier that is loose? Bleichenbacher forgery
See Also
- RSA Basics - key generation, the math, and the embedded quick-attack notes.
- Lattice Attacks - LLL and Coppersmith mechanics underpinning Boneh-Durfee, partial-key, and stereotyped attacks.
- ECC Attacks - the elliptic-curve counterpart catalogue.
- Diffie-Hellman Attacks - discrete-log weaknesses in the other major asymmetric family.
- Asymmetric Cryptography - subsection index and quick selectors.
Last updated on