N00bzCTF 2023 — Writeup

This is a simple RSA crypto challenge that becomes instantly solvable because the same plaintext (the flag) is encrypted many times using a small public exponent.


Source

from Crypto.Util.number import *
import time

flag = bytes_to_long(b'n00bz{***********************}')
e = 17

p = getPrime(1024)
q = getPrime(1024)
n = p*q

ct = pow(flag,e,n)
time.sleep(0.5)

print(f'{e = }')
print(f'{ct = }')
print(f'{n = }')

What matters:

  • Public exponent: $e = 17$
  • Each run generates fresh primes $p,q$ → fresh modulus $n = pq$
  • Same message $m=\text{flag}$ is encrypted each time:

$$ c_i \equiv m^{17} \pmod{n_i} $$

What’s broken (the vulnerability)

This is exactly the setup for Håstad’s Broadcast Attack:

  • same plaintext $m$
  • same small exponent $e$
  • different moduli $n_i$ (typically pairwise coprime)
  • enough samples: at least $e$ ciphertexts

If you collect $e$ ciphertext/modulus pairs, you can reconstruct $m^e$ as a normal integer (not just modulo something), then take the exact $e$-th root to recover $m$.

Attack explained (CRT -> integer root)

Step 1 — Collect $e$ encryptions

Run the challenge 17 times to obtain:

$$ (c_1,n_1), (c_2,n_2), \dots, (c_{17},n_{17}) $$

with:

$$ c_i \equiv m^{17} \pmod{n_i} $$

Step 2 — CRT recombination

Because the $n_i$ are (with overwhelming probability) pairwise coprime, CRT gives a unique value $M$ modulo:

$$ N=\prod_{i=1}^{17} n_i $$

such that:

$$ M \equiv c_i \pmod{n_i} \quad \forall i $$

Since each $c_i \equiv m^{17} \pmod{n_i}$, CRT implies:

$$ M \equiv m^{17} \pmod{N} $$

Step 3 — The crucial inequality

If the message is small enough that:

$$ m^{17} < N $$

then the congruence collapses into equality:

$$ M = m^{17} $$

So you can recover:

$$ m = \sqrt[17]{M} $$

as an exact integer root.

That’s the whole attack.


Solution

This is your original approach, cleaned:

  • runs the challenge 17 times
  • stores $(c_i, n_i)$
  • CRT combines them
  • takes exact 17th root
  • converts to bytes

Note that in the real solution script, it was against a remote connection however I just switched it to the original chall.py for demonstration purpose.

#!/usr/bin/env python3
from pwn import process
from sage.all import crt
from Crypto.Util.number import long_to_bytes
from gmpy2 import iroot

E = 17
cts = []
mods = []

for _ in range(E):
    io = process(["python3", "../src/chall.py"])

    io.recvuntil(b"ct = ")
    c = int(io.recvline().strip())

    io.recvuntil(b"n = ")
    n = int(io.recvline().strip())

    cts.append(c)
    mods.append(n)
    io.close()

# CRT: find M such that M ≡ c_i (mod n_i) for all i
M = int(crt(cts, mods))

# exact integer 17th root
m, exact = iroot(M, E)
assert exact, "Root not exact: not enough samples, padding present, or m^e >= product(n_i)."

flag = long_to_bytes(int(m))
print(flag.decode(errors="replace"))

Notes / sanity checks

  • With random 1024-bit primes, different moduli are almost always coprime.
  • The time.sleep(0.5) is just fluff; it doesn’t help security.
  • This fails in real RSA because padding (e.g., OAEP) ensures the plaintext differs each time, so the “same $m$” requirement breaks.

Summary

This challenge is a textbook demonstration of why textbook RSA is not used with small public exponents + repeated plaintexts across different moduli… This is what enables Hastads broadcast attack