Created
March 14, 2026 08:24
-
-
Save n-WN/86d7ce992676f5869ce265b01bc451be to your computer and use it in GitHub Desktop.
AliCTF 2026 Final - RSA factorization via genus-2 Jacobian GCD leak
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import argparse | |
| import time | |
| from math import gcd, isqrt | |
| """Attack the special RSA where q = ((p - 1)^2 + 4x^2) / 8 and p = x^2 + 2y^2. | |
| The key observation behind the attack is that the explicit divisor | |
| D = (X^2 + 3X + 2, w(X + 1)), w^2 = -30 | |
| has order dividing 2q modulo the prime p generated by test.py, so [2n]D is | |
| the identity modulo p and a gcd-leak appears during Cantor arithmetic over Z/nZ. | |
| """ | |
| KNOWN_P = int( | |
| "46402297179264908470708471623273486751352579716103704797281944801460358524678" | |
| "42279018921962833172524978958523306002263641947191072311981468164777376340163" | |
| "09248109423490734066891943610586766143148559064501553780842031247144580440009" | |
| "38194474983786306069139448770032043723951601909376213368723549683354239163507" | |
| ) | |
| KNOWN_Q = int( | |
| "26914664793910201015089600129623785390294811149557238224416199450647622361540" | |
| "61458290133058210358061211802203736734517014228513818848859863320201028662594" | |
| "08636909405263960880006579031003383880970259284258277447905002584560808016151" | |
| "95599654382682078087103707560460633655990368809688841580499721704151332137274" | |
| "51938932866495598613452778552658798768008138970942831931260605440957375646498" | |
| "93934369707231143720201745545288105250504997847404937401018339096895509727111" | |
| "86962060225378214303250799105314406275478888857499845714130299510525486044637" | |
| "9794254941560890382535289658268169964153224826572350999211110389811740960617" | |
| ) | |
| KNOWN_R = int( | |
| "24388034157995205768053575087784566752894503365408073207022062557399294688638" | |
| "49871187552042732895106626518198027167886195098589690365603086574529209472200" | |
| "96949588170313491329321346278296852048084345607744818834743398321194013425847" | |
| "36079181920031140709129205873116058110573979367198811758488627834078439211237" | |
| "80070866680332283061603290014343206557668754257839454399733908519690656633727" | |
| "38942851736654217461175512542145828887781883388886484361840892076528161284480" | |
| "83334698430779030641077014553115044418997193362038721689196980116230439648408" | |
| "256003405800149003946832004060056815330449886786417953912090190244491671682413" | |
| ) | |
| KNOWN_N = int( | |
| "30458271324341532949475846017727469451149884244455456848418344623613451533435" | |
| "79307435382746063628125152906089520106401768106957001062097608543296273342600" | |
| "73494232932631354400410633128749140076171158983190907973593575053497854417048" | |
| "45070155389191120194965141592685269642219235223032729325019714071736443084339" | |
| "80477948134183047844592974766663701461571247677079016119150849583378377004941" | |
| "01778736872707088950191066536845315590172909690731773340519248676713609734650" | |
| "33186389749487953940534414613347699119330329207595957401343988936792722602584" | |
| "65824747012697595376626703975988882515595764957311907919027365749197002820887" | |
| "81656589748596542720171740310760955369300856300367678388501209155989865070239" | |
| "46443201683467311037291475027254181164572349694445942115684287264474876679889" | |
| "31325516339110486866743445852066095669998920438968216485232424791922223861252" | |
| "34242699387282215677275222361951085429715508244131809462847100374697050628398" | |
| "79709527194763646717676930379663192565578694000082841476832027375526271828242" | |
| "91133445659115219846651737385850400768144769901213446461201922926263027143565" | |
| "52210558900333779120514161371252950169057659365777899851550703510494190464197" | |
| "66493585403363271907314371735096265003807865198468046761716803922538443995949" | |
| "23125523614125873231662726372159350593472047446355770877880057916630068295202" | |
| "17920732447287933284467288077074320117165209921696169342156202641657329841597" | |
| "67065660140195565607999242430922731963562167060407187425962779655725641005967" | |
| "8199505555708003269487619386599472185153902440223444128863861928158832935247" | |
| ) | |
| class FactorFound(RuntimeError): | |
| def __init__(self, factor): | |
| super().__init__(f"non-invertible denominator leaked factor {factor}") | |
| self.factor = factor | |
| class QuadRing: | |
| __slots__ = ("n", "d", "zero", "one", "w") | |
| def __init__(self, n, d=30): | |
| self.n = n | |
| self.d = d | |
| self.zero = (0, 0) | |
| self.one = (1 % n, 0) | |
| self.w = (0, 1 % n) | |
| def coerce(self, value): | |
| if isinstance(value, tuple): | |
| return (value[0] % self.n, value[1] % self.n) | |
| return (value % self.n, 0) | |
| def is_zero(self, x): | |
| return x[0] % self.n == 0 and x[1] % self.n == 0 | |
| def is_one(self, x): | |
| return x[0] % self.n == 1 and x[1] % self.n == 0 | |
| def add(self, x, y): | |
| return ((x[0] + y[0]) % self.n, (x[1] + y[1]) % self.n) | |
| def sub(self, x, y): | |
| return ((x[0] - y[0]) % self.n, (x[1] - y[1]) % self.n) | |
| def neg(self, x): | |
| return ((-x[0]) % self.n, (-x[1]) % self.n) | |
| def mul(self, x, y): | |
| a0, a1 = x | |
| b0, b1 = y | |
| return ( | |
| (a0 * b0 - self.d * a1 * b1) % self.n, | |
| (a0 * b1 + a1 * b0) % self.n, | |
| ) | |
| def inv(self, x): | |
| a0, a1 = x | |
| norm = (a0 * a0 + self.d * a1 * a1) % self.n | |
| g = gcd(norm, self.n) | |
| if g != 1: | |
| if g == self.n: | |
| raise FactorFound(g) | |
| raise FactorFound(g) | |
| inv_norm = pow(norm, -1, self.n) | |
| return ((a0 * inv_norm) % self.n, (-a1 * inv_norm) % self.n) | |
| def div(self, x, y): | |
| return self.mul(x, self.inv(y)) | |
| def square(self, x): | |
| return self.mul(x, x) | |
| def format(self, x): | |
| a0, a1 = x | |
| if a1 == 0: | |
| return str(a0) | |
| if a0 == 0: | |
| return f"{a1}*w" | |
| return f"{a0} + {a1}*w" | |
| class Poly: | |
| __slots__ = ("ctx", "coeffs") | |
| def __init__(self, ctx, coeffs): | |
| self.ctx = ctx | |
| self.coeffs = [ctx.coerce(c) for c in coeffs] | |
| self._trim() | |
| def _trim(self): | |
| while len(self.coeffs) > 1 and self.ctx.is_zero(self.coeffs[-1]): | |
| self.coeffs.pop() | |
| @classmethod | |
| def zero(cls, ctx): | |
| return cls(ctx, [ctx.zero]) | |
| @classmethod | |
| def one(cls, ctx): | |
| return cls(ctx, [ctx.one]) | |
| def copy(self): | |
| return Poly(self.ctx, list(self.coeffs)) | |
| def degree(self): | |
| if self.is_zero(): | |
| return -1 | |
| return len(self.coeffs) - 1 | |
| def lc(self): | |
| return self.coeffs[-1] | |
| def is_zero(self): | |
| return len(self.coeffs) == 1 and self.ctx.is_zero(self.coeffs[0]) | |
| def is_one(self): | |
| return len(self.coeffs) == 1 and self.ctx.is_one(self.coeffs[0]) | |
| def __eq__(self, other): | |
| return self.coeffs == other.coeffs | |
| def __add__(self, other): | |
| size = max(len(self.coeffs), len(other.coeffs)) | |
| out = [] | |
| for i in range(size): | |
| a = self.coeffs[i] if i < len(self.coeffs) else self.ctx.zero | |
| b = other.coeffs[i] if i < len(other.coeffs) else self.ctx.zero | |
| out.append(self.ctx.add(a, b)) | |
| return Poly(self.ctx, out) | |
| def __sub__(self, other): | |
| size = max(len(self.coeffs), len(other.coeffs)) | |
| out = [] | |
| for i in range(size): | |
| a = self.coeffs[i] if i < len(self.coeffs) else self.ctx.zero | |
| b = other.coeffs[i] if i < len(other.coeffs) else self.ctx.zero | |
| out.append(self.ctx.sub(a, b)) | |
| return Poly(self.ctx, out) | |
| def __neg__(self): | |
| return Poly(self.ctx, [self.ctx.neg(c) for c in self.coeffs]) | |
| def scale(self, coeff): | |
| coeff = self.ctx.coerce(coeff) | |
| return Poly(self.ctx, [self.ctx.mul(c, coeff) for c in self.coeffs]) | |
| def shift(self, places): | |
| if self.is_zero(): | |
| return Poly.zero(self.ctx) | |
| return Poly(self.ctx, [self.ctx.zero] * places + self.coeffs) | |
| def __mul__(self, other): | |
| out = [self.ctx.zero] * (len(self.coeffs) + len(other.coeffs) - 1) | |
| for i, a in enumerate(self.coeffs): | |
| if self.ctx.is_zero(a): | |
| continue | |
| for j, b in enumerate(other.coeffs): | |
| if self.ctx.is_zero(b): | |
| continue | |
| out[i + j] = self.ctx.add(out[i + j], self.ctx.mul(a, b)) | |
| return Poly(self.ctx, out) | |
| def monic(self): | |
| if self.is_zero(): | |
| return self.copy() | |
| return self.scale(self.ctx.inv(self.lc())) | |
| def divmod(self, other): | |
| if other.is_zero(): | |
| raise ZeroDivisionError("polynomial division by zero") | |
| if self.degree() < other.degree(): | |
| return Poly.zero(self.ctx), self.copy() | |
| rem = self.copy() | |
| q_coeffs = [self.ctx.zero] * (self.degree() - other.degree() + 1) | |
| inv_lc = self.ctx.inv(other.lc()) | |
| while not rem.is_zero() and rem.degree() >= other.degree(): | |
| coeff = self.ctx.mul(rem.lc(), inv_lc) | |
| shift = rem.degree() - other.degree() | |
| q_coeffs[shift] = self.ctx.add(q_coeffs[shift], coeff) | |
| rem = rem - other.scale(coeff).shift(shift) | |
| return Poly(self.ctx, q_coeffs), rem | |
| def __floordiv__(self, other): | |
| q, r = self.divmod(other) | |
| if not r.is_zero(): | |
| raise ArithmeticError("polynomial division was not exact") | |
| return q | |
| def __mod__(self, other): | |
| _, r = self.divmod(other) | |
| return r | |
| def xgcd(self, other): | |
| r0, r1 = self.copy(), other.copy() | |
| s0, s1 = Poly.one(self.ctx), Poly.zero(self.ctx) | |
| t0, t1 = Poly.zero(self.ctx), Poly.one(self.ctx) | |
| while not r1.is_zero(): | |
| q, r2 = r0.divmod(r1) | |
| r0, r1 = r1, r2 | |
| s0, s1 = s1, s0 - q * s1 | |
| t0, t1 = t1, t0 - q * t1 | |
| if r0.is_zero(): | |
| return r0, s0, t0 | |
| inv_lc = self.ctx.inv(r0.lc()) | |
| return r0.scale(inv_lc), s0.scale(inv_lc), t0.scale(inv_lc) | |
| def __repr__(self): | |
| terms = [] | |
| for i, coeff in enumerate(self.coeffs): | |
| if self.ctx.is_zero(coeff): | |
| continue | |
| term = self.ctx.format(coeff) | |
| if i == 0: | |
| terms.append(term) | |
| elif i == 1: | |
| terms.append(f"({term})*x") | |
| else: | |
| terms.append(f"({term})*x^{i}") | |
| return " + ".join(terms) if terms else "0" | |
| class Divisor: | |
| __slots__ = ("ctx", "f", "u", "v") | |
| def __init__(self, ctx, f, u, v): | |
| self.ctx = ctx | |
| self.f = f | |
| self.u = u | |
| self.v = v | |
| @classmethod | |
| def zero(cls, ctx, f): | |
| return cls(ctx, f, Poly.one(ctx), Poly.zero(ctx)) | |
| def is_zero(self): | |
| return self.u.is_one() and self.v.is_zero() | |
| def __neg__(self): | |
| if self.is_zero(): | |
| return self | |
| return Divisor(self.ctx, self.f, self.u, -self.v) | |
| def __eq__(self, other): | |
| return self.u == other.u and self.v == other.v | |
| def _compose(self, other): | |
| a1, b1 = self.u, self.v | |
| a2, b2 = other.u, other.v | |
| if a1 == a2 and b1 == b2: | |
| d, _, h3 = a1.xgcd(b1.scale(2)) | |
| a = (a1 // d) | |
| a = a * a | |
| b = (b1 + h3 * ((self.f - (b1 * b1)) // d)) % a | |
| else: | |
| d0, _, h2 = a1.xgcd(a2) | |
| if d0.is_one(): | |
| a = a1 * a2 | |
| b = (b2 + h2 * a2 * (b1 - b2)) % a | |
| else: | |
| d, l, h3 = d0.xgcd(b1 + b2) | |
| a = (a1 * a2) // (d * d) | |
| b = ( | |
| b2 | |
| + l * h2 * (b1 - b2) * (a2 // d) | |
| + h3 * ((self.f - (b2 * b2)) // d) | |
| ) % a | |
| return a.monic(), b | |
| def _reduce(self, a, b): | |
| a2 = ((self.f - (b * b)) // a).monic() | |
| b2 = (-b) % a2 | |
| if a2.degree() > 2: | |
| return self._reduce(a2, b2) | |
| return a2, b2 | |
| def add(self, other): | |
| if self.is_zero(): | |
| return other | |
| if other.is_zero(): | |
| return self | |
| a, b = self._compose(other) | |
| if a.degree() > 2: | |
| a, b = self._reduce(a, b) | |
| return Divisor(self.ctx, self.f, a, b) | |
| def scalar_mul(self, k, progress=False): | |
| out = Divisor.zero(self.ctx, self.f) | |
| addend = self | |
| total_bits = k.bit_length() | |
| for bit_index in range(total_bits): | |
| if progress and bit_index and bit_index % 256 == 0: | |
| print(f"[+] processed {bit_index}/{total_bits} bits") | |
| if (k >> bit_index) & 1: | |
| out = out.add(addend) | |
| if bit_index + 1 != total_bits: | |
| addend = addend.add(addend) | |
| return out | |
| def __repr__(self): | |
| return f"({self.u}, {self.v})" | |
| def is_probable_prime(n): | |
| if n < 2: | |
| return False | |
| small_primes = ( | |
| 2, | |
| 3, | |
| 5, | |
| 7, | |
| 11, | |
| 13, | |
| 17, | |
| 19, | |
| 23, | |
| 29, | |
| 31, | |
| 37, | |
| 41, | |
| 43, | |
| 47, | |
| 53, | |
| 59, | |
| 61, | |
| 67, | |
| 71, | |
| ) | |
| for p in small_primes: | |
| if n == p: | |
| return True | |
| if n % p == 0: | |
| return False | |
| d = n - 1 | |
| s = 0 | |
| while d % 2 == 0: | |
| s += 1 | |
| d //= 2 | |
| for a in small_primes: | |
| if a >= n: | |
| continue | |
| x = pow(a, d, n) | |
| if x in (1, n - 1): | |
| continue | |
| for _ in range(s - 1): | |
| x = pow(x, 2, n) | |
| if x == n - 1: | |
| break | |
| else: | |
| return False | |
| return True | |
| def tonelli_shanks(a, p): | |
| a %= p | |
| if a == 0: | |
| return 0 | |
| if p == 2: | |
| return a | |
| if pow(a, (p - 1) // 2, p) != 1: | |
| raise ValueError("not a square") | |
| if p % 4 == 3: | |
| return pow(a, (p + 1) // 4, p) | |
| q = p - 1 | |
| s = 0 | |
| while q % 2 == 0: | |
| s += 1 | |
| q //= 2 | |
| z = 2 | |
| while pow(z, (p - 1) // 2, p) != p - 1: | |
| z += 1 | |
| m = s | |
| c = pow(z, q, p) | |
| t = pow(a, q, p) | |
| r = pow(a, (q + 1) // 2, p) | |
| while t != 1: | |
| i = 1 | |
| t2i = pow(t, 2, p) | |
| while t2i != 1: | |
| t2i = pow(t2i, 2, p) | |
| i += 1 | |
| if i == m: | |
| raise ValueError("tonelli failure") | |
| b = pow(c, 1 << (m - i - 1), p) | |
| m = i | |
| c = pow(b, 2, p) | |
| t = (t * c) % p | |
| r = (r * b) % p | |
| return r | |
| def cornacchia_x2_plus_2y2(p): | |
| z = tonelli_shanks(-2, p) | |
| for root in (z, p - z): | |
| a, b = p, root | |
| while b * b > p: | |
| a, b = b, a % b | |
| x = abs(b) | |
| t = p - x * x | |
| if t >= 0 and t % 2 == 0: | |
| y2 = t // 2 | |
| y = isqrt(y2) | |
| if y * y == y2: | |
| return x, y | |
| raise ValueError("no x^2 + 2y^2 representation found") | |
| def integer_cuberoot(n): | |
| if n < 0: | |
| raise ValueError("cube root only supports nonnegative integers") | |
| if n < 8: | |
| return 0 if n == 0 else 1 | |
| x = 1 << ((n.bit_length() + 2) // 3) | |
| while True: | |
| y = (2 * x + n // (x * x)) // 3 | |
| if y >= x: | |
| while (x + 1) ** 3 <= n: | |
| x += 1 | |
| while x ** 3 > n: | |
| x -= 1 | |
| return x | |
| x = y | |
| def build_curve_data(n): | |
| ctx = QuadRing(n) | |
| f = Poly(ctx, [0, -1, 0, 0, 0, 1]) | |
| u = Poly(ctx, [2, 3, 1]) | |
| v = Poly(ctx, [ctx.w, ctx.w]) | |
| return ctx, f, Divisor(ctx, f, u, v) | |
| def try_complete_from_p(n, candidate): | |
| if candidate <= 1 or n % candidate != 0: | |
| return None | |
| if candidate % 8 != 3 or not is_probable_prime(candidate): | |
| return None | |
| try: | |
| x, y = cornacchia_x2_plus_2y2(candidate) | |
| except ValueError: | |
| return None | |
| q = (((candidate - 1) * (candidate - 1)) + 4 * x * x) // 8 | |
| if q <= 1 or n % (candidate * q) != 0: | |
| return None | |
| r = n // (candidate * q) | |
| if not is_probable_prime(q) or not is_probable_prime(r): | |
| return None | |
| return candidate, q, r, x, y | |
| def try_complete_from_q(n, candidate_q): | |
| if candidate_q <= 1 or n % candidate_q != 0 or not is_probable_prime(candidate_q): | |
| return None | |
| root = isqrt(8 * candidate_q) | |
| for delta in range(-4, 5): | |
| candidate_p = root + delta | |
| result = try_complete_from_p(n, candidate_p) | |
| if result is not None and result[1] == candidate_q: | |
| return result | |
| return None | |
| def try_complete_from_pq_product(n, candidate_pq): | |
| if candidate_pq <= 1 or n % candidate_pq != 0: | |
| return None | |
| root = integer_cuberoot(8 * candidate_pq) | |
| for delta in range(-8, 9): | |
| candidate_p = root + delta | |
| result = try_complete_from_p(n, candidate_p) | |
| if result is not None and result[0] * result[1] == candidate_pq: | |
| return result | |
| return None | |
| def resolve_factor_leak(n, leaked_factor): | |
| candidates = [leaked_factor, n // leaked_factor] | |
| for candidate in candidates: | |
| result = try_complete_from_p(n, candidate) | |
| if result is not None: | |
| return result | |
| for candidate in candidates: | |
| result = try_complete_from_q(n, candidate) | |
| if result is not None: | |
| return result | |
| for candidate in candidates: | |
| result = try_complete_from_pq_product(n, candidate) | |
| if result is not None: | |
| return result | |
| return None | |
| def factor_special_rsa(n, progress=False): | |
| _, _, base = build_curve_data(n) | |
| scalar = 2 * n | |
| try: | |
| base.scalar_mul(scalar, progress=progress) | |
| except FactorFound as leak: | |
| g = leak.factor | |
| if progress: | |
| print(f"[+] leaked gcd = {g}") | |
| result = resolve_factor_leak(n, g) | |
| if result is not None: | |
| return result | |
| raise RuntimeError("got a nontrivial gcd, but could not turn it into p") | |
| raise RuntimeError("scalar multiplication finished without leaking a factor") | |
| def verify_known_instance(): | |
| x, y = cornacchia_x2_plus_2y2(KNOWN_P) | |
| q = (((KNOWN_P - 1) * (KNOWN_P - 1)) + 4 * x * x) // 8 | |
| print("p*q*r == n ?", KNOWN_P * KNOWN_Q * KNOWN_R == KNOWN_N) | |
| print("p = x^2 + 2y^2 ?", KNOWN_P == x * x + 2 * y * y) | |
| print("recovered q == known q ?", q == KNOWN_Q) | |
| print("n bits =", KNOWN_N.bit_length()) | |
| print("p bits =", KNOWN_P.bit_length()) | |
| print("q bits =", KNOWN_Q.bit_length()) | |
| print("r bits =", KNOWN_R.bit_length()) | |
| def run_small_demo(): | |
| p, q, r = 11, 17, 23 | |
| n = p * q * r | |
| print("[*] demo modulus =", n) | |
| got_p, got_q, got_r, x, y = factor_special_rsa(n, progress=False) | |
| print("[+] recovered p =", got_p) | |
| print("[+] recovered q =", got_q) | |
| print("[+] recovered r =", got_r) | |
| print("[+] x, y =", x, y) | |
| def parse_args(): | |
| parser = argparse.ArgumentParser(description="Attack the special RSA in test.py") | |
| parser.add_argument("--n", type=lambda s: int(s, 0), default=KNOWN_N) | |
| parser.add_argument("--check", action="store_true", help="verify the known instance") | |
| parser.add_argument("--demo", action="store_true", help="run a tiny toy example") | |
| parser.add_argument("--progress", action="store_true", help="print scalar-mul progress") | |
| return parser.parse_args() | |
| def main(): | |
| args = parse_args() | |
| if args.check: | |
| verify_known_instance() | |
| return | |
| if args.demo: | |
| run_small_demo() | |
| return | |
| start = time.time() | |
| p, q, r, x, y = factor_special_rsa(args.n, progress=args.progress) | |
| elapsed = time.time() - start | |
| print("p =", p) | |
| print("q =", q) | |
| print("r =", r) | |
| print("x =", x) | |
| print("y =", y) | |
| print("elapsed =", round(elapsed, 3), "seconds") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment