Treasury Audit
by Pavel Knespl (capyplivl)
RSA-2048 done correctly (random PKCS#1 v1.5 padding, e = 65537), but a memory dump leaks the top 574 bits of the 1024-bit prime p. Knowing more than half of the high bits of a factor makes the rest recoverable by univariate Coppersmith. The unknown low 450 bits of p are a small root of f(x) = p_high + x modulo the secret factor p, so a Howgrave-Graham lattice reduced with LLL pulls them out. Sage is not required; the lattice and reduction are done in pure Python (mpmath).
Solve
- Read
challenge.txt:n,e = 65537,p_high(p with its low 450 bits zeroed), and ciphertextc. With 574 known high bits the unknown part is2^450 < n^0.22, comfortably inside then^0.25Coppersmith limit for a degree-1 root modulo a half-size factor. - Build the Howgrave-Graham lattice for the monic polynomial
f(x) = p_high + x:g_i(x) = n^(h-i) * f(x)^ifori = 0..hh_i(x) = x^i * f(x)^hfori = 1..tScale columnkbyX^k(X = 2^450). Withh = t = 4(dimension 9) the bound is met.
- LLL-reduce. The shortest reduced vector is a polynomial that vanishes at the true small root over the integers, not just mod p. Find its integer root, set
p = p_high + x, and confirmn % p == 0. - Decrypt:
q = n // p,d = e^-1 mod (p-1)(q-1),m = c^d mod n. The plaintext is a PKCS#1 v1.5 block; the flag is plain ASCII inside it.
Full working solver (pure Python, no Sage; saved as solve.py next to challenge.txt):
#!/usr/bin/env python3
import re
import mpmath
from Crypto.Util.number import inverse, long_to_bytes
mpmath.mp.dps = 1500
n = 20617455663957563943267326918617398723675048338700713508388960337975656074956983954064553970232006105633822724545049106949889041967134068405950797243962890380652813070549392311724315694576720318394577417831762411162063790168382307801789490753058437672849231668739056316117671801790905154610394734059247857646044105297843204679863793846337246723259252782851848506544277634589232584330688766380641104405598348156924547283668145466744363813519320248440414734847004384358617700598907088226681244793505417553863937933376546436886127459797975723513300591424081072414325387861807381952526820669185389031630281315242655170717
e = 65537
p_high = 135821285288920085946400642493014096417179798771949765976749043914308535043229221291771123359432312700587822143272544010115732019245443039378869303523804196896440047381409730337025746802127495444758067618759291612503761938664380636654095888464531365956441579793480169836657177615766339596852373964556817399808
c = 15399453910127061789675963240803320036190619586685886168849952792176844266710864840696934360866933747435426651819430663651239409026723624056591682946786774471942295921105659579581045823901601924759841105403281205054712833860982070982224894217765296106283067730766592746637151191786740864644672613168298236545616220412521561818761283896777842924383090123845548849139149118912752576502103168975646962571588150298263392656503798048473829431960775446201697629078858470141849733084816666011136002572028379569782708766506079759764753618433774520470069158597532689469756907803181662085381061072253761074271076342865689340509
UNKNOWN_BITS = 450
def lll(B, delta=0.99):
B = [row[:] for row in B]
n = len(B)
width = len(B[0])
mp = mpmath
G = [[mp.mpf(sum(a * b for a, b in zip(B[i], B[j]))) for j in range(n)] for i in range(n)]
mu = [[mp.mpf(0)] * n for _ in range(n)]
Bnorm = [mp.mpf(0)] * n
def recompute_row(i):
for j in range(i):
s = G[i][j] - sum(mu[j][k] * mu[i][k] * Bnorm[k] for k in range(j))
mu[i][j] = s / Bnorm[j]
Bnorm[i] = G[i][i] - sum(mu[i][k] ** 2 * Bnorm[k] for k in range(i))
for i in range(n):
recompute_row(i)
k = 1
while k < n:
for j in range(k - 1, -1, -1):
if abs(mu[k][j]) > 0.5:
q = int(mp.nint(mu[k][j]))
if q != 0:
B[k] = [B[k][i] - q * B[j][i] for i in range(width)]
for i in range(n):
if i != k:
G[k][i] = mp.mpf(sum(a * b for a, b in zip(B[k], B[i])))
G[i][k] = G[k][i]
G[k][k] = mp.mpf(sum(a * a for a in B[k]))
recompute_row(k)
if Bnorm[k] >= (delta - mu[k][k - 1] ** 2) * Bnorm[k - 1]:
k += 1
else:
B[k], B[k - 1] = B[k - 1], B[k]
G[k], G[k - 1] = G[k - 1], G[k]
for r in range(n):
G[r][k], G[r][k - 1] = G[r][k - 1], G[r][k]
recompute_row(k - 1)
recompute_row(k)
for i in range(k + 1, n):
recompute_row(i)
k = max(k - 1, 1)
return B
def polymul(p, q):
r = [0] * (len(p) + len(q) - 1)
for i, pi in enumerate(p):
for j, qj in enumerate(q):
r[i + j] += pi * qj
return r
def coppersmith_known_high(N, a, X, h, t):
f = [a, 1]
fpows = [[1]]
for _ in range(h + t):
fpows.append(polymul(fpows[-1], f))
polys = []
for i in range(h + 1):
polys.append([(N ** (h - i)) * coef for coef in fpows[i]])
for i in range(1, t + 1):
polys.append([0] * i + fpows[h][:])
deg = max(len(p) for p in polys)
M = []
for p in polys:
row = [0] * deg
for k, coef in enumerate(p):
row[k] = coef * (X ** k)
M.append(row)
red = lll(M)
for row in red:
coeffs = [row[k] // (X ** k) for k in range(deg)]
while len(coeffs) > 1 and coeffs[-1] == 0:
coeffs.pop()
if len(coeffs) <= 1:
continue
try:
roots = mpmath.polyroots([mpmath.mpf(c) for c in coeffs[::-1]], maxsteps=500, extraprec=600)
except Exception:
continue
for r in roots:
if abs(mpmath.im(r)) > 1e-6:
continue
ri = int(mpmath.nint(mpmath.re(r)))
for cand_x in (ri - 1, ri, ri + 1):
cand = a + cand_x
if 1 < cand < N and N % cand == 0:
return cand
return None
p = coppersmith_known_high(n, p_high, 1 << UNKNOWN_BITS, h=4, t=4)
assert p is not None and n % p == 0, "Coppersmith failed to recover p"
q = n // p
d = inverse(e, (p - 1) * (q - 1))
m = pow(c, d, n)
mb = long_to_bytes(m)
print("recovered p bit length:", p.bit_length())
flag = re.search(rb"SVIUSCG\{[^}]*\}", mb)
print("flag =", flag.group(0).decode() if flag else mb)
$ python3 solve.py
recovered p bit length: 1024
flag = SVIUSCG{c0pp3rsm1th_wh1sp3rs_4cr0ss_th3_l4tt1c3}
The recovered p is a genuine 1024-bit prime and n % p == 0, confirming the factorisation. Runtime is about half a minute.
Flag: SVIUSCG{c0pp3rsm1th_wh1sp3rs_4cr0ss_th3_l4tt1c3}