Hastad's broadcast attack exploits the fact that with a small public exponent e=3, three encryptions of the same plaintext under different moduli can be combined. Since 0 < m < min(ni), it follows that m³ < n₁·n₂·n₃, so the CRT solution is the exact integer M = m³ (not just a residue class). Taking an exact integer cube root recovers m.
Hastad's broadcast attack exploits the fact that with a small public exponent e=3, three encryptions of the same plaintext under different moduli can be combined. Since 0 < m < min(n_i), it follows that m³ < n₁·n₂·n₃, so the CRT solution is the exact integer M = m³ (not just a residue class). Taking an exact integer cube root recovers m.
c₁ = m³ mod n₁ c₂ = m³ mod n₂ c₃ = m³ mod n₃
└──────────────┬──────────────┘
▼
M = CRT(c₁,c₂,c₃ ; n₁,n₂,n₃) ≡ m³ (mod n₁·n₂·n₃), and M = m³ exactly
▼
m = iroot3(M) ← exact integer cube root (Newton, no floats)
Three pieces: (1) extended Euclid/modular inverse, (2) CRT via the standard pairwise combination, (3) an exact integer cube root using integer Newton's method x ← (2x + n//x²)//3 with a power-of-two initial bound plus a final correction loop — guaranteed exact, never touches float.
# --- exact integer cube root (no floating point) ---
def iroot3(n: int) -> int:
if n < 0:
raise ValueError("iroot3 requires n >= 0")
if n == 0:
return 0
x = 1 << ((n.bit_length() + 2) // 3) # initial upper bound
while True: # integer Newton iteration
y = (2 * x + n // (x * x)) // 3
if y >= x:
break
x = y
while x * x * x > n: # exact floor correction
x -= 1
while (x + 1) * (x + 1) * (x + 1) <= n:
x += 1
return x
# --- CRT (pairwise coprime moduli) ---
def egcd(a, b):
if b == 0:
return a, 1, 0
g, x1, y1 = egcd(b, a % b)
return g, y1, x1 - (a // b) * y1
def modinv(a, m):
g, x, _ = egcd(a, m)
if g != 1:
raise ValueError(f"{a} not invertible mod {m}")
return x % m
def crt(remainders, moduli) -> int:
N = 1
for n in moduli:
N *= n
result = 0
for r, n in zip(remainders, moduli):
Ni = N // n
result += r * Ni * modinv(Ni, n) # Σ rᵢ·Nᵢ·(Nᵢ⁻¹ mod nᵢ) mod N
return result % N
# --- the attack ---
def hastad_broadcast(ciphers, moduli, e: int = 3) -> int:
assert len(ciphers) == len(moduli) >= e # need ≥ e broadcasts
M = crt(ciphers, moduli) # M == m**e exactly (m**e < Πnᵢ)
if e == 3:
return iroot3(M)
# generic exact integer e-th root (binary search) if e != 3
lo, hi = 0, 1 << ((M.bit_length() + e - 1) // e)
while lo < hi:
mid = (lo + hi + 1) // 2
if mid ** e <= M: lo = mid
else: hi = mid - 1
return lo
Usage (self-contained demo generates real RSA keys with e=3 and recovers the ASCII message):
import random
msg = b"Attack at dawn!"
m = int.from_bytes(msg, "big")
n1, _ = generate_keypair(256, 3) # Miller–Rabin primes, gcd(e, φ(n))==1
n2, _ = generate_keypair(256, 3)
n3, _ = generate_keypair(256, 3)
assert m < min(n1, n2, n3)
c1, c2, c3 = pow(m, 3, n1), pow(m, 3, n2), pow(m, 3, n3)
recovered = hastad_broadcast([c1, c2, c3], [n1, n2, n3], e=3)
assert recovered == m
print(recovered.to_bytes((recovered.bit_length() + 7) // 8, "big")) # b'Attack at dawn!'
Files written: ~/hastad/hastad_attack.py (solution + demo) and ~/hastad/test_hastad.py (test suite).
Verification performed (Python 3.11-compatible; ran under 3.14, no float or mpmath anywhere in the attack path):
python3 test_hastad.py → Ran 21 tests ... OK:iroot3 exhaustively checked against an independent binary-search reference for every n in [0, 200_000), plus 300 random 1–2048-bit exact cubes, k³−1, k³+1, a 3000-bit non-cube, and the float-rounding-tricky value 10³⁰+1 (all satisfy r³ ≤ n < (r+1)³).x≡2 mod 3, ≡3 mod 5, ≡2 mod 7 → 23), 100 random 3-modulus systems, and modinv identity a·a⁻¹ ≡ 1.b"A", b"\x01"), a long 16-byte message, mixed key sizes (128/192/256, 128/256/512), 20 randomized runs with random bit-size combos and random messages.m re-encrypted under all three keys reproduces the original cᵢ exactly.m = min(nᵢ) − 7 (largest allowed message) recovered correctly; precondition m³ < n₁n₂n₃ asserted.b'Attack at dawn!' byte-for-byte.Edge cases handled:
- m = 0, m = 1 boundaries for the cube root; negative input rejected.
- Non-cube CRT results (e.g., m³+1-type truncation) — floor root never overestimates.
- Shared/identical moduli fail loudly in CRT (not pairwise coprime).
- Fewer than e ciphers rejected with a clear error.
- Generic e ≠ 3 path kept exact via integer binary search; verified to agree with iroot3 for e = 3.
Test count: 21 unit tests, plus 30 randomized independent cross-checks — all passing.
{"problem_class":"python-rsa-hastad-broadcast-attack","model":"deepseek-v4-flash","result":"passed","tests":21}
Solved by Pi Agent (deepseek-v4-flash).
Hastad's broadcast attack exploits the fact that with a small public exponent e=3, three encryptions of the same plaintext under different moduli can be combined. Since 0 < m < min(n_i), it follows that m³ < n₁·n₂·n₃, so the CRT solution is the exact integer M = m³ (not just a residue class). Taking an exact integer cube root recovers m.
c₁ = m³ mod n₁ c₂ = m³ mod n₂ c₃ = m³ mod n₃
└──────────────┬──────────────┘
▼
M = CRT(c₁,c₂,c₃ ; n₁,n₂,n₃) ≡ m³ (mod n₁·n₂·n₃), and M = m³ exactly
▼
m = iroot3(M) ← exact integer cube root (Newton, no floats)
Three pieces: (1) extended Euclid/modular inverse, (2) CRT via the standard pairwise combination, (3) an exact integer cube root using integer Newton's method x ← (2x + n//x²)//3 with a power-of-two initial bound plus a final correction loop — guaranteed exact, never touches float.
# --- exact integer cube root (no floating point) ---
def iroot3(n: int) -> int:
if n < 0:
raise ValueError("iroot3 requires n >= 0")
if n == 0:
return 0
x = 1 << ((n.bit_length() + 2) // 3) # initial upper bound
while True: # integer Newton iteration
y = (2 * x + n // (x * x)) // 3
if y >= x:
break
x = y
while x * x * x > n: # exact floor correction
x -= 1
while (x + 1) * (x + 1) * (x + 1) <= n:
x += 1
return x
# --- CRT (pairwise coprime moduli) ---
def egcd(a, b):
if b == 0:
return a, 1, 0
g, x1, y1 = egcd(b, a % b)
return g, y1, x1 - (a // b) * y1
def modinv(a, m):
g, x, _ = egcd(a, m)
if g != 1:
raise ValueError(f"{a} not invertible mod {m}")
return x % m
def crt(remainders, moduli) -> int:
N = 1
for n in moduli:
N *= n
result = 0
for r, n in zip(remainders, moduli):
Ni = N // n
result += r * Ni * modinv(Ni, n) # Σ rᵢ·Nᵢ·(Nᵢ⁻¹ mod nᵢ) mod N
return result % N
# --- the attack ---
def hastad_broadcast(ciphers, moduli, e: int = 3) -> int:
assert len(ciphers) == len(moduli) >= e # need ≥ e broadcasts
M = crt(ciphers, moduli) # M == m**e exactly (m**e < Πnᵢ)
if e == 3:
return iroot3(M)
# generic exact integer e-th root (binary search) if e != 3
lo, hi = 0, 1 << ((M.bit_length() + e - 1) // e)
while lo < hi:
mid = (lo + hi + 1) // 2
if mid ** e <= M: lo = mid
else: hi = mid - 1
return lo
Usage (self-contained demo generates real RSA keys with e=3 and recovers the ASCII message):
import random
msg = b"Attack at dawn!"
m = int.from_bytes(msg, "big")
n1, _ = generate_keypair(256, 3) # Miller–Rabin primes, gcd(e, φ(n))==1
n2, _ = generate_keypair(256, 3)
n3, _ = generate_keypair(256, 3)
assert m < min(n1, n2, n3)
c1, c2, c3 = pow(m, 3, n1), pow(m, 3, n2), pow(m, 3, n3)
recovered = hastad_broadcast([c1, c2, c3], [n1, n2, n3], e=3)
assert recovered == m
print(recovered.to_bytes((recovered.bit_length() + 7) // 8, "big")) # b'Attack at dawn!'
Files written: ~/hastad/hastad_attack.py (solution + demo) and ~/hastad/test_hastad.py (test suite).
Verification performed (Python 3.11-compatible; ran under 3.14, no float or mpmath anywhere in the attack path):
python3 test_hastad.py → Ran 21 tests ... OK:iroot3 exhaustively checked against an independent binary-search reference for every n in [0, 200_000), plus 300 random 1–2048-bit exact cubes, k³−1, k³+1, a 3000-bit non-cube, and the float-rounding-tricky value 10³⁰+1 (all satisfy r³ ≤ n < (r+1)³).x≡2 mod 3, ≡3 mod 5, ≡2 mod 7 → 23), 100 random 3-modulus systems, and modinv identity a·a⁻¹ ≡ 1.b"A", b"\x01"), a long 16-byte message, mixed key sizes (128/192/256, 128/256/512), 20 randomized runs with random bit-size combos and random messages.m re-encrypted under all three keys reproduces the original cᵢ exactly.m = min(nᵢ) − 7 (largest allowed message) recovered correctly; precondition m³ < n₁n₂n₃ asserted.b'Attack at dawn!' byte-for-byte.Edge cases handled:
- m = 0, m = 1 boundaries for the cube root; negative input rejected.
- Non-cube CRT results (e.g., m³+1-type truncation) — floor root never overestimates.
- Shared/identical moduli fail loudly in CRT (not pairwise coprime).
- Fewer than e ciphers rejected with a clear error.
- Generic e ≠ 3 path kept exact via integer binary search; verified to agree with iroot3 for e = 3.
Test count: 21 unit tests, plus 30 randomized independent cross-checks — all passing.
{"problem_class":"python-rsa-hastad-broadcast-attack","model":"deepseek-v4-flash","result":"passed","tests":21}
Solved by Pi Agent (deepseek-v4-flash).