TL;DR
The server only provides and calls RSA.constrcut((n, e, d)), which tries to recover ; the challenge proceeds only if this recovery throws an exception. The recovery routine tests only bases By choosing for each a prime such that all small bases behave identically modulo and , the recovery always fails, and after 32 failures the flag is printed.
Analysis
Each round the server prints a 1024-bit prime , reads a prime , and computes
Then it calls RSA.construct((n,e,d)), If it succeeds, the program exits. If it raises, the server prints error! and continues to the next round. After 32 failures, it prints the flag.
When only are provided.
ktot = d*e - 1# ktot = t * 2^s (t odd)t = ktotwhile t % 2 == 0: t //= 2for a in range(2, 100, 2): # only a = 2,4,...,98 k = t while k < ktot: cand = pow(a, k, n) if cand != 1 and cand != n-1 and pow(cand, 2, n) == 1: p = gcd(cand + 1, n) # non-trivial sqrt(1) => factor ... k *= 2raise ValueError("Unable to compute factors p and q from exponent d.")So it searches for a non-trivial square root of 1 modulo among for each small base .
If but , then by CRT
In that case gcd(cand+1, n) reveals one prime factor immediately.
Therefore, to make the routine fail, we want all tested values to stay in the same sign cases
The routine only tries .
Every such factors over and odd primes (e.g. , ).
Define
If we make the relevant 2-adic residue behavior match for and for all odd primes dividing , then every tested behaves the same modulo and modulo .
We have Let be the odd part of .
Since always contains the odd parts of and , the values land in the 2-Sylow subgroup.
Multiplying by an extra odd factor (which depends on the random ) is an automorphism on that subgroup, so it does not change the same-sign vs split-sign phenomenon we care about.
Let .
Case A: (i.e., ) Then the 2-Sylow subgroup modulo has size , so matching quadratic characters is enough.
We search for a 1024-bit prime such that
This forces and, by quadratic reciprocity plus modulo every odd prime
also
By multiplicativity, every tested base has the same Legendre symbol modulo and , so the recovery loop never finds a split sign.
Case B: (i.e., ) Now higher 2-power residue information (quartic, etc.) matters.
work in Gaussian integers .
For we can write and in it splits as
Exploit
compute generate candidates using Case A or Case B filter by bit-length and primality locally check that RSA.construct((n,e,d)) fails send to the server
from Crypto.Math.Primality import *from Crypto.PublicKey import *
ODD_PRIMES_LE_47 = (3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47)
def v2(n: int) -> int: return (n & -n).bit_length() - 1
def legendre_symbol(a: int, p: int) -> int: x = pow(a, (p - 1) // 2, p) return -1 if x == p - 1 else x
def sqrt_minus_one_mod_p(p: int) -> int: if p % 4 != 1: raise ValueError("p must be 1 (mod 4)") while True: a = random.randrange(2, p - 1) if legendre_symbol(a, p) == -1: r = pow(a, (p - 1) // 4, p) if (r * r + 1) % p == 0: return r
def sum_of_two_squares_for_prime(p: int) -> tuple[int, int]: if p % 4 != 1: raise ValueError("Prime does not split in Z[i]") t = sqrt_minus_one_mod_p(p) r0, r1 = p, t while r1 * r1 > p: r0, r1 = r1, r0 % r1 x = r1 y2 = p - x * x y = isqrt(y2) if y * y != y2: raise RuntimeError("Cornacchia failed") return x, y
def rsa_construct_fails(p: int, q: int) -> bool: n = p * q phi = (p - 1) * (q - 1) e = 65537 if gcd(e, phi) != 1: # Extremely unlikely; pick another odd exponent. e = 3 if gcd(e, phi) != 1: return False d = pow(e, -1, phi) try: RSA.construct((n, e, d)) return False except Exception: return True
def build_L() -> int: L = 1 for r in ODD_PRIMES_LE_47: L *= r return L
L = build_L()
def find_q_for_p(p: int, min_bits: int = 1024) -> int: s = v2(p - 1)
if s == 1: modulus = 8 * L t_min = ((1 << (min_bits - 1)) - p + modulus - 1) // modulus attempts = 0 while True: attempts += 1 t = t_min + random.getrandbits(64) q = p + modulus * t if q.bit_length() < min_bits: continue if test_probable_prime(q) != PROBABLY_PRIME: continue if rsa_construct_fails(p, q): # print(f"[s=1] attempts={attempts} q_bits={q.bit_length()}", flush=True) return q
# s >= 2: work in Z[i] with q = X^2 + Y^2 and (X,Y) ≡ (x,y) mod (L, 2^{s+1}) x, y = sum_of_two_squares_for_prime(p) step = L * (1 << (s + 1)) # forces q ≡ p (mod 2^{s+1}) and (mod L)
# Pick k,l so X,Y end up around sqrt(2^min_bits) ≈ 2^(min_bits/2). half_bits = (min_bits + 1) // 2 k_bits = max(64, half_bits - step.bit_length() + 64)
attempts = 0 while True: attempts += 1 k = random.getrandbits(k_bits) l = random.getrandbits(k_bits) X = x + step * k Y = y + step * l q = X * X + Y * Y if q.bit_length() < min_bits: continue if test_probable_prime(q) != PROBABLY_PRIME: continue if rsa_construct_fails(p, q): return q
@dataclassclass BufferedSocket: sock: socket.socket buf: bytearray = field(default_factory=bytearray)
def recv_until(self, marker: bytes) -> bytes: while marker not in self.buf: chunk = self.sock.recv(4096) if not chunk: break self.buf.extend(chunk) idx = self.buf.find(marker) if idx == -1: data = bytes(self.buf) self.buf.clear() return data end = idx + len(marker) data = bytes(self.buf[:end]) del self.buf[:end] return data
def recv_all(self) -> bytes: out = bytearray(self.buf) self.buf.clear() while True: chunk = self.sock.recv(4096) if not chunk: break out.extend(chunk) return bytes(out)
def sendline(self, line: str) -> None: self.sock.sendall(line.encode() + b"\n")
P_RE = re.compile(rb"p = ([0-9]+)\nq: $")
def run_session(host: str, port: int, rounds: int, max_s: int) -> str | None: with socket.create_connection((host, port), timeout=600) as s: bs = BufferedSocket(s)
for i in range(rounds): chunk = bs.recv_until(b"q: ") m = P_RE.search(chunk) if not m: raise RuntimeError(f"Unexpected prompt: {chunk!r}") p = int(m.group(1)) s_p = v2(p - 1) print(f"[{i+1:02d}/{rounds:02d}] p_bits={p.bit_length()} s={s_p}", flush=True) if s_p > max_s: print(f" s={s_p} too large; restarting", flush=True) return None q = find_q_for_p(p) print(f" q_bits={q.bit_length()}", flush=True) bs.sendline(str(q))
tail = bs.recv_all() return tail.decode(errors="replace")
def main() -> None: host = os.environ.get("HOST", "yukari.seccon.games") port = int(os.environ.get("PORT", "15809")) rounds = int(os.environ.get("ROUNDS", "32")) max_s = int(os.environ.get("MAX_S", "5"))
for attempt in range(1, 21): print(f"[session {attempt}] max_s={max_s}", flush=True) out = run_session(host, port, rounds, max_s) if out is not None: print(out, end="") return
raise RuntimeError("Too many retries")
if __name__ == "__main__": main()