Category: Cryptography
Flag: CodeVinci{p0lyn0m14l_rs4_l34ks_r00t5_lm4o}
Challenge Description
it isnt a CTF without RSA
Analysis
The provided script looked like a normal RSA setup at first, but the key clue was that it also published ten polynomial points with an added random noise term and a matching RSA-encrypted version of that noise. Pulling the crucial lines out of galatical.py made the weakness obvious: the noise was only 16 bits, and the script output both y_noisy = y + r (mod n) and r_enc = r^e mod n.
python extract_core_lines.py13: NOISE_BITS = 1688: y = poly_eval_mod(coeffs, x, n)89: r = secrets.randbelow(1 << NOISE_BITS)90: y_noisy = (y + r) % n91: r_enc = pow(r, E, n)94: f_m = poly_eval_mod(coeffs, m, n)98: c1 = pow(m, E, n)99: c2 = pow(f_m, E, n)Then I checked the published parameters in output.txt to confirm the shape of the instance (e = 17, degree 9 polynomial, and 16-bit noise). Because r < 2^16, r^17 is far smaller than a 2048-bit modulus, so r_enc = r^17 mod n is just the exact integer power with no wraparound. That means each r can be recovered by exact integer 17th root, which instantly removes the noise from every point.
python -c "from pathlib import Path; t=Path('output.txt').read_text(); print('\n'.join([line for line in t.splitlines() if line.startswith(('n =','e =','deg =','noise_bits =','c1 =','c2 ='))]))"n = 21795931982959810520942863253123932350006550281673221741324389746441489588991691784228362252631862821241876624167634299253224012371185010989980972276294863120930187293561186408413794501225854649333475546641202394219469315718499032427794894336784303091789112740065030697184580712820317698061315065029994965000331675762255319964208064800895206524399505312735194878932683089429730306737687182410525781623616904466313063331314814981761501664490445059865564935765420577375894805622643041732346768897425640062528994058910747278648497266970687062720587080127756482814143522566541416793580032910516069598797363246158893559203e = 17deg = 9noise_bits = 16c1 = 17069480607381333340837059621017041514461037312661575749074463467593711378468501917601145213696262157081974590275851364318126946432567619623442558856499278049000634859710386939661034781830101841558737028294620534431460584889198939890743582295134418218234863044625794900789252930749138452915364022716617829366398541818209985678600543910238332239757640924205524654998000872497132900300803106929276168403073803078292990833759803062978594282481693044544928540953522758918780829701611071981841179667387496533021549067145744230245674295353326670788674422134235059172059397648763177319296047417510386887263201663685604459270c2 = 22017604496222855387400089488783882102774559652391746781635976591924702436082247234343029133767824949950873390768790867821809530150727619420302922605868959289862308167072315580390032286804422072742556634741359609108164215568204513995809834871745948638995210622023605193809961374191659533045113161205072671662931939694847470718069118230862749096690727993074315524642611506095071814885193074966029279617628571492730244236406415232861297020381115027941652762550708861288283690502892471616548245255984354646123110490438594329667864637941033723511769203613511959066641052710958145498270486098531267360662949728464981997From there the solve is neat: recover every r_i by exact root, compute clean y_i, interpolate the degree-9 polynomial f(x) over Z_n, and use a Franklin-Reiter-style common-root trick with P(x)=x^17-c1 and Q(x)=f(x)^17-c2. Their gcd yields a linear factor containing m, which decodes directly to the flag. This one felt very clean once the tiny-noise leak clicked.

Solution
# !/usr/bin/env sageimport refrom math import gcdfrom gmpy2 import iroot
def parse_output(path): vals = {} points = {} with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or "=" not in line: continue k, v = [x.strip() for x in line.split("=", 1)] if k[0] in ("x", "y", "r") and k[1:].isdigit(): idx = int(k[1:]) points.setdefault(idx, {})[k[0]] = Integer(v) else: vals[k] = Integer(v) return vals, points
def interpolate_mod_n(points, n): Zn = Zmod(n) R = PolynomialRing(Zn, "X") X = R.gen()
f = R(0) m = len(points) for i in range(m): xi, yi = points[i] num = R(1) den = Zn(1) for j in range(m): if i == j: continue xj, _ = points[j] num *= (X - xj) den *= (xi - xj)
den_int = int(den) g = gcd(den_int, n) if g != 1: raise ValueError(f"Non-invertible interpolation denominator; gcd={g}") f += yi * num * den.inverse_of_unit() return f
def gcd_over_zn(a, b, n): R = a.parent() while b != 0: lc = b.leading_coefficient() g = gcd(int(lc), n) if g != 1: return None, g b = b * lc.inverse_of_unit() a, b = b, a % b
if a == 0: return R(0), None
lc = a.leading_coefficient() g = gcd(int(lc), n) if g != 1: return None, g a = a * lc.inverse_of_unit() return a, None
def int_to_bytes(x): x = int(x) if x == 0: return b"\x00" return x.to_bytes((x.bit_length() + 7) // 8, "big")
def main(): vals, raw_points = parse_output("output.txt") n = int(vals["n"]) e = int(vals["e"]) c1 = int(vals["c1"]) c2 = int(vals["c2"])
points = [] for idx in sorted(raw_points): x = int(raw_points[idx]["x"]) y_noisy = int(raw_points[idx]["y"]) r_enc = int(raw_points[idx]["r"])
r, exact = iroot(r_enc, e) if not exact: raise ValueError(f"r{idx} root is not exact") r = int(r) y = (y_noisy - r) % n points.append((x, y))
f = interpolate_mod_n([(Zmod(n)(x), Zmod(n)(y)) for x, y in points], n)
R = f.parent() X = R.gen() P = X**e - Zmod(n)(c1) Q = f**e - Zmod(n)(c2)
g, nontrivial = gcd_over_zn(P, Q, n)
m = None if g is not None and g.degree() == 1: b = g[0] m = int((-b) % n) elif nontrivial is not None and 1 < nontrivial < n: p = int(nontrivial) q = n // p phi = (p - 1) * (q - 1) d = inverse_mod(e, phi) m = int(pow(c1, int(d), n)) else: gg = P.gcd(Q) if gg.degree() == 1: m = int((-gg[0] / gg[1]) % n)
if m is None: raise RuntimeError("Failed to recover m")
msg = int_to_bytes(m) print("m_bytes:", msg)
try: s = msg.decode() except Exception: s = msg.decode(errors="ignore")
print("decoded:", s) mm = re.search(r"CodeVinci\{[^}]+\}", s) if mm: print("FLAG:", mm.group(0))
if __name__ == "__main__": main()sage solve_galatical.sagem_bytes: b'CodeVinci{p0lyn0m14l_rs4_l34ks_r00t5_lm4o}'decoded: CodeVinci{p0lyn0m14l_rs4_l34ks_r00t5_lm4o}FLAG: CodeVinci{p0lyn0m14l_rs4_l34ks_r00t5_lm4o}