Logo
Overview
[SECCON CTF 14] yukari writeup

[SECCON CTF 14] yukari writeup

December 14, 2025
5 min read

✨ 크립토 공짜 다운로드 ✨

TL;DR

The server only provides n=pqn = pq and calls RSA.constrcut((n, e, d)), which tries to recover p,qp, q; the challenge proceeds only if this recovery throws an exception. The recovery routine tests only bases a=2,4,...,98a = 2,4,...,98 By choosing for each pp a prime qq such that all small bases behave identically modulo pp and qq, the recovery always fails, and after 32 failures the flag is printed.

Analysis

Each round the server prints a 1024-bit prime pp, reads a prime qq, and computes

n=pq,e64-bit prime,de1(mod(p1)(q1))n=pq,\quad e\leftarrow \text{64-bit prime},\quad d\equiv e^{-1}\pmod{(p-1)(q-1)}

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 (n,e,d)(n,e,d) are provided.

ktot = d*e - 1
# ktot = t * 2^s (t odd)
t = ktot
while t % 2 == 0:
t //= 2
for 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 *= 2
raise ValueError("Unable to compute factors p and q from exponent d.")

So it searches for a non-trivial square root of 1 modulo nn among at,a2t,a4t,a^t, a^{2t}, a^{4t}, \dots for each small base aa.

If cand21(modn)\text{cand}^2\equiv 1\pmod{n} but cand≢±1(modn)\text{cand}\not\equiv \pm 1\pmod{n}, then by CRT

cand1(modp)\text{cand}\equiv 1\pmod{p}

cand1(modq)\text{cand}\equiv -1\pmod{q}

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

cand±1(modp)\text{cand}\equiv \pm 1\pmod{p}

cand±1(modq)\text{cand}\equiv \pm 1\pmod{q}

The routine only tries a=2,4,,98a=2,4,\dots,98.

Every such aa factors over 22 and odd primes 47\le 47 (e.g. 94=24794=2\cdot 47, 98=27298=2\cdot 7^2).

Define

L=r47, r odd primerL = \prod_{r\le 47,\ r\ \text{odd prime}} r If we make the relevant 2-adic residue behavior match for 22 and for all odd primes dividing LL, then every tested aa behaves the same modulo pp and modulo qq.

We have ktot=ed1=hφ(n)=h(p1)(q1)k_{\text{tot}} = ed-1 = h\,\varphi(n)=h(p-1)(q-1) Let tt be the odd part of ktotk_{\text{tot}}.

Since tt always contains the odd parts of (p1)(p-1) and (q1)(q-1), the values ata^t land in the 2-Sylow subgroup.

Multiplying tt by an extra odd factor (which depends on the random ee) is an automorphism on that subgroup, so it does not change the same-sign vs split-sign phenomenon we care about.

Let s=v2(p1)s=v_2(p-1).

Case A: s=1s=1 (i.e., p3(mod4)p\equiv 3\pmod{4}) Then the 2-Sylow subgroup modulo pp has size 22, so matching quadratic characters is enough.

We search for a 1024-bit prime qq such that qp(mod8L)q \equiv p \pmod{8L}

This forces (2p)=(2q)\left(\frac{2}{p}\right)=\left(\frac{2}{q}\right) and, by quadratic reciprocity plus qpq\equiv p modulo every odd prime r47r\le 47

also (rp)=(rq)\left(\frac{r}{p}\right)=\left(\frac{r}{q}\right)

By multiplicativity, every tested base aa has the same Legendre symbol modulo pp and qq, so the recovery loop never finds a split sign.

Case B: s2s\ge 2 (i.e., p1(mod4)p\equiv 1\pmod{4}) Now higher 2-power residue information (quartic, etc.) matters.

work in Gaussian integers Z[i]\mathbb{Z}[i].

For p1(mod4)p\equiv 1\pmod{4} we can write p=x2+y2,p=x^2+y^2, and in Z[i]\mathbb{Z}[i] it splits as p=(x+iy)(xiy)p=(x+iy)(x-iy)

Exploit

compute s=v2(p1)s=v_2(p-1) generate candidates qq using Case A or Case B filter by bit-length and primality locally check that RSA.construct((n,e,d)) fails send qq 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
@dataclass
class 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()

flag