这周跟 Project SEKAI 一起打了 R3CTF 2026,做了三道 Crypto。
teRRibleRing
Description
Notice that there are 3 “R”s in the title :)
task.sage
from sage.stats.distributions.discrete_gaussian_integer import DiscreteGaussianDistributionIntegerSamplerfrom Crypto.Util.number import *from secrets import randbelowfrom secret import flag
p = 0x8000000bPR.<x> = PolynomialRing(Zmod(p))f = x^512 + 355853415*x^511 + ...
def F0(): D = DiscreteGaussianDistributionIntegerSampler(sigma=5.0) s = PR([D() for _ in range(512)]) e = PR([D() for _ in range(512)]) a = PR([randbelow(p) for _ in range(512)]) b = (a*s + e) % f return [a.list(), b.list()]
def F1(): a = PR([randbelow(p) for _ in range(512)]) b = PR([randbelow(p) for _ in range(512)]) return [a.list(), b.list()]
with open("samples.txt", "w") as s: s.write("output =" + str([F0() if i == "0" else F1() for i in bin(bytes_to_long(flag))[2:].zfill(len(flag)*8)]))Solution
这道题给了 344 个 degree 512 的 decisional RLWE 样本。其中 大约 ,而噪声的 std 只有 5。显然跑 344 个 1024 维的 LLL 是不现实的,所以只能是 f 有后门。
假如 ,那么我们可以将 投影到 ,从而在更小的维度解决这个 RLWE。但是这个投影相当于计算 ,而 的参数可能非常大,导致噪声也会被放大。因此这个方法只有在 的系数足够小的情况下才有用。
尝试分解 可以发现它有一堆小因子,但它们的系数都很大。所以我们先试着猜一些因子乘起来能得到一个系数较小的多项式。假如真的存在这么一个因子,我们可以推测出如下的性质:
- 这个因子的度数不能太大,不然 LLL 还是跑不完,这里我们假设度数不超过 128,对应的格维度是 256。
- 这个因子的系数不能太大。根据我们前一条假设度数不超过 128, 会差不多把 四次方,所以就算是十几的系数都会爆炸,必须得是非常小非常稀疏的才有戏。
现在我们希望从 的 75 个因子里面找出乘积很小的组合。显然枚举是不现实的,但是我们可以这么想:一个因子乘上一个多项式后系数很小的概率是很低的,所以我们可以先猜 有哪些因子,再用 LLL 反过来找这个乘上什么多项式后系数很小。这个和 LFSR 找一个稀疏的递推关系是类似的。
def find_short_poly(f, max_deg=128): coeffs = f.coefficients(sparse=False) coeffs = [ZZ(c) for c in coeffs] M = [] for i in range(max_deg - len(coeffs) + 1): row = [0] * i + coeffs + [0] * (max_deg - len(coeffs) - i) M.append(row) M = matrix(ZZ, M) M = block_matrix([[M], [identity_matrix(max_deg)*p]]) M = M.LLL(algorithm='flatter') return M[0]考虑到 大约 而系数只有个位数,我们可以认为对所有度数大于 12 的因子都不会出现伪阳。于是成功发现一个度数 16 的因子的倍数里面有 。
接下来就是把 RLWE 投影过来跑 LLL 了。这里直接简单用 Kannan Embedding 跑 cvp 即可。
这里 是 的系数矩阵,然后 取一个和噪声差不多大的数。但是 flatter 没法解出这个 SVP,所以我换到了 BLASter 这个库,支持更多的 BKZ,每个可以 6 秒跑完。最后花了 20 多分钟跑完 344 个 bit,然后重构出 flag 即可。
solve.sage
from Crypto.Util.number import *from tqdm import tqdm
p = 0x8000000bPR.<x> = PolynomialRing(Zmod(p))f = x^512 + 355853415*x^511 + ...
R.<x> = PolynomialRing(ZZ)r = x^127 - x^46 + x^19 - x^8 + 1
rp = r.change_ring(Zmod(p))
def lift_centered(poly): return R([c.lift_centered() for c in poly.list()])
def create_ideal_lattice(a, f): x = a.parent().gen() n = f.degree() M = [] for i in range(n): s = (a * x**i) % f row = lift_centered(s).list() M.append(row) return matrix(ZZ, M)
def flatter(M): from subprocess import check_output from re import findall z = "[[" + "]\n[".join(" ".join(map(str, row)) for row in M) + "]]" ret = check_output(["sage", "src/app.py", "-b42", "-t1", "-P2"], input=z.encode(), cwd="/home/sceleri/BLASter") return matrix(M.nrows(), M.ncols(), map(int, findall(b"-?\\d+", ret)))
a, b, s, e = F0()
def check(data): a, b = data a = PR(a) b = PR(b) ar = a % rp br = b % rp coef_variance = [0] * 127
for i in range(512): c = x ** i % r for j in range(127): coef_variance[j] += c[j] ** 2
coef_variance = [round(sqrt(c * 65536)) for c in coef_variance]
M = create_ideal_lattice(ar, rp)
target = matrix(ZZ, [0]*127+lift_centered(br).list())
n = M.nrows() M = block_matrix(ZZ, [[identity_matrix(ZZ, n), M], [zero_matrix(ZZ, n, n), p*identity_matrix(ZZ, n)]])
M = block_matrix(ZZ, [[M, 0], [target, 512]])
M = flatter(M)
v = M[0] cnt = 0 for c in v: if abs(c) < 1024: cnt += 1 if cnt > 200: return 0 else: return 1
output = []exec(open("samples.txt").read())
c = []
def pack_byte(ulist): ret = 0 for i in range(8): ret |= (ulist[i] << (7-i)) return ret
def decode_flag(c): out = b"" for i in range(0, len(c), 8): if i + 8 > len(c): break out += bytes([pack_byte(c[i:i+8])]) return out
from tqdm.contrib.concurrent import process_map
c = []for i in tqdm(range(len(output) // 8)): u = process_map(check, [output[i*8+j] for j in range(8)]) c.extend(u) print(c) print(decode_flag(c))rECp1cG
Description
Quiet steps, old notes:
challenge.py
from hashlib import sha256from random import SystemRandomfrom secrets import token_heximport signal
from secret import flag
p_bits = 1024k = 21d_bits = 451Delta = 1 << d_bitssolve_timeout = 888
rand = SystemRandom()
def is_prime(x): if x < 2: return False small = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97) for q in small: if x == q: return True if x % q == 0: return False
odd = x - 1 power = 0 while odd % 2 == 0: odd //= 2 power += 1
bases = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61) for base in bases: if base % x == 0: continue y = pow(base, odd, x) if y == 1 or y == x - 1: continue for _ in range(power - 1): y = pow(y, 2, x) if y == x - 1: break else: return False return True
def make_prime(bits): while True: x = rand.getrandbits(bits) x |= 1 << (bits - 1) x |= 3 if is_prime(x): return x
def add(P, Q, a, p): if P is None: return Q if Q is None: return P
x1, y1 = P x2, y2 = Q if x1 == x2 and (y1 + y2) % p == 0: return None
if P == Q: slope = (3 * x1 * x1 + a) * pow(2 * y1, -1, p) % p else: slope = (y2 - y1) * pow(x2 - x1, -1, p) % p
x3 = (slope * slope - x1 - x2) % p y3 = (slope * (x1 - x3) - y1) % p return x3, y3
def random_point(a, b, p): while True: x = rand.randrange(1, p) y2 = (x * x * x + a * x + b) % p if y2 != 0 and pow(y2, (p - 1) // 2, p) == 1: y = pow(y2, (p + 1) // 4, p) if rand.getrandbits(1): y = (-y) % p return x, y
def main(): key_tag = token_hex(16) p = make_prime(p_bits)
while True: a = rand.randrange(1, p) b = rand.randrange(1, p) if (4 * a * a * a + 27 * b * b) % p != 0: break
while True: g = random_point(a, b, p) u = random_point(a, b, p) points = [u] for _ in range(k - 1): u = add(u, g, a, p) if u is None: break points.append(u) if len(points) == k and len({P[0] for P in points}) == k: break
states = [] for x, _ in points: while True: shown = x - rand.randrange(-Delta, Delta + 1) if 0 <= shown < p: states.append(shown) break
size = (p.bit_length() + 7) // 8 nums = (a, b, g[0], g[1], points[0][0], points[0][1]) material = b"".join(int(x).to_bytes(size, "big") for x in nums) key = sha256(material + b"|" + key_tag.encode()).digest()
pad = b"" ctr = 0 while len(pad) < len(flag): pad += sha256(key + ctr.to_bytes(4, "big")).digest() ctr += 1 ct = bytes(x ^ y for x, y in zip(flag, pad)).hex()
print(f"p = {p}") print(f"a = {a}") print(f"b = {b}") print(f"Delta = {Delta}") print(f"G = {g}") print(f"states = {states}") print() print("# submit P0.x", flush=True)
signal.alarm(solve_timeout) try: answer = input().strip() except EOFError: return
if len(answer) > 4096: print("# wrong", flush=True) return
try: recovered_x = int(answer, 0) except ValueError: print("# wrong", flush=True) return
if recovered_x != points[0][0]: print("# wrong", flush=True) return
print("# ok", flush=True) print(f"key_tag = {key_tag!r}") print(f"ct = {ct!r}", flush=True)
if __name__ == "__main__": main()Solution
一眼看上去就是论文题,但是如果顺着题目里给的论文找就被阴了。这里我们需要找的是只给 MSB 的 Coppersmith 构造,然后应该搜索的是 ECHNP。在 Google Scholar 看 cite 可以找到 2026 年的最新工作1。然后就简单了,ocr 成 latex 交给 AI 去抄那一堆多项式构造即可,而且代入测试数据检查 是否为 0 就可以验证正确性。
实际跑的时候发现对于 451 bits 的 delta,LLL reduce 后的多项式大概会落在 的范围内,但是只要不是 0 就没法用 grobner 解。这个 Coppersmith 有 11 个变量,所以直接暴力猜测是不行的(除非你愿意跑 1000 次 408 维的 LLL),所以需要一点小优化就是 coppersmith base polys 里面有很多都包含了 ,所以正确做法是在设置 bounds 的时候把 的 bound 除以 8,这样就只要猜 8 次即可。
下面的代码是清理过的,同时把 GPT 写的 grobner 换成了 msolve。2
solve_clean.sage
from sage.all import *from hashlib import sha256from itertools import combinationsimport argparseimport astimport reimport shutilimport subprocessfrom pwn import context, remote, process
n = 10d = 2t = 1mid = n
def ec_add(P, Q, a, p): if P is None: return Q if Q is None: return P x1, y1 = P x2, y2 = Q if x1 == x2 and (y1 + y2) % p == 0: return None if P == Q: lam = (3 * x1 * x1 + a) * inverse_mod(2 * y1, p) % p else: lam = (y2 - y1) * inverse_mod(x2 - x1, p) % p x3 = (lam * lam - x1 - x2) % p y3 = (lam * (x1 - x3) - y1) % p return (ZZ(x3), ZZ(y3))
def ec_neg(P, p): if P is None: return None return (P[0], (-P[1]) % p)
def ec_mul(k, P, a, p): R = None Q = P k = ZZ(k) while k: if k & 1: R = ec_add(R, Q, a, p) Q = ec_add(Q, Q, a, p) k >>= 1 return R
def as_zz_data(data): out = dict(data) for k in ("p", "a", "b", "Delta"): out[k] = ZZ(out[k]) out["G"] = (ZZ(out["G"][0]), ZZ(out["G"][1])) out["states"] = [ZZ(x) for x in out["states"]] if "points" in out: out["points"] = [(ZZ(P[0]), ZZ(P[1])) for P in out["points"]] if "errors" in out: out["errors"] = [ZZ(e) for e in out["errors"]] return out
def signed_mod(x, m): x = ZZ(x) % m if x > m // 2: x -= m return ZZ(x)
class ECXHNPSolver: def __init__(self, data, check=False, verbose=True): self.data = as_zz_data(data) self.p = self.data["p"] self.a = self.data["a"] self.b = self.data["b"] self.Delta = self.data["Delta"] self.G = self.data["G"] self.states = self.data["states"] self.check = False self.verbose = verbose if len(self.states) < 2*n + 1: raise ValueError("need 21 states for n=10")
names_z = ["x"] + ["z%d" % i for i in range(1, n + 1)] names_y = ["x"] + ["y%d" % i for i in range(1, n + 1)] self.Rz = PolynomialRing(ZZ, names_z, order="degrevlex") self.Ry = PolynomialRing(ZZ, names_y, order="degrevlex") self.RQy = PolynomialRing(QQ, names_y, order="degrevlex") self.xz = self.Rz.gen(0) self.zs = [None] + [self.Rz.gen(i) for i in range(1, n + 1)] self.xy = self.Ry.gen(0) self.ys = [None] + [self.Ry.gen(i) for i in range(1, n + 1)]
self.Q = [None] + [ec_mul(i, self.G, self.a, self.p) for i in range(1, n + 1)] self.h0 = self.states[mid] self.A = [None] self.B = [None] self.C = [None] self.D = [None] self.H = [None] self.F = [None] self._L_cache = {} self._L0_triples = {} self._tilde_cache = {} self._over_cache = {} self._build_base_polys()
def log(self, s): if self.verbose: print(s, flush=True)
def _build_base_polys(self): x = self.xz for i in range(1, n + 1): xq, yq = self.Q[i] tilde_h = self.states[mid + i] + self.states[mid - i] Ai = self.h0 - xq Bi = -2 * (self.a + 3 * xq**2) Ci = Ai * Bi - 4 * yq**2 Di = tilde_h - 2 * xq self.A.append(ZZ(Ai)) self.B.append(ZZ(Bi)) self.C.append(ZZ(Ci)) self.D.append(ZZ(Di)) self.H.append((x + Ai)**2 * self.zs[i] + Bi*x + Ci) for i in range(1, n + 1): self.F.append(self.to_y(self.H[i]))
def _check_base_polys(self): for i in range(1, n + 1): self.assert_z(self.H[i], self.p, "H_%d" % i) self.assert_y(self.F[i], self.p, "F_%d" % i) self.log("[check] H_i/F_i roots mod p: ok")
def assert_z(self, poly, mod, label): val = ZZ(poly(*self.zroot)) if val % mod != 0: raise AssertionError("%s(root) mod %s != 0" % (label, mod))
def assert_y(self, poly, mod, label): val = ZZ(poly(*self.yroot)) if val % mod != 0: raise AssertionError("%s(root) mod %s != 0" % (label, mod))
def to_y(self, poly): args = [self.xy] + [self.ys[i] + self.D[i] for i in range(1, n + 1)] return self.Ry(poly(*args))
def reduce_poly(self, poly, mod, ring): terms = {} for exp, coeff in poly.dict().items(): c = ZZ(coeff) % mod if c: terms[exp] = c return ring(terms)
def H_vector(self, js): js = tuple(js) vec = [] x = self.xz for m, jm in enumerate(js): term = self.zs[jm] for r, jr in enumerate(js): if r != m: term *= self.H[jr] vec.append(term) for m, jm in enumerate(js): term = x * self.zs[jm] for r, jr in enumerate(js): if r != m: term *= self.H[jr] sub = self.Rz(0) for u, ju in enumerate(js): if u == m: continue part = self.Rz(self.B[ju]) for r, jr in enumerate(js): if r != u: part *= self.H[jr] sub += part vec.append(term - sub) return vec
def M_matrix(self, js, mod): x = self.xz rows = [] for shift in (0, 1): for m, jm in enumerate(js): poly = self.Rz(x**shift) for r, jr in enumerate(js): if r != m: poly *= (x + self.A[jr])**2 row = [ZZ(poly.monomial_coefficient(x**k)) % mod for k in range(2*len(js))] rows.append(row) return Matrix(Zmod(mod), rows)
def L(self, js, m): js = tuple(sorted(js)) key = (js, m) if key in self._L_cache: return self._L_cache[key] l = len(js) mod = self.p ** (l - 1) W = self.M_matrix(js, mod).inverse() hv = self.H_vector(js) poly = self.Rz(0) for c, h in zip(list(W[m]), hv): poly += ZZ(c) * h poly = self.reduce_poly(poly, mod, self.Rz) self._L_cache[key] = poly if self.check: self.assert_z(poly, mod, "L_%d%s" % (m, js)) return poly
def coeff_xz(self, poly, xpow, zset): exp = [0] * (n + 1) exp[0] = xpow for j in zset: exp[j] = 1 return ZZ(poly.monomial_coefficient(self.Rz.monomial(*exp)))
def overline_L0(self, js): js = tuple(sorted(js)) if js in self._over_cache: return self._over_cache[js] poly = self.L(js, 0) for pair in combinations(js, d): c = self.coeff_xz(poly, 2*d - 1, pair) if c: poly -= c * self.L(pair, 2*d - 1) poly = self.reduce_poly(poly, self.p, self.Rz) self._over_cache[js] = poly if self.check: self.assert_z(poly, self.p, "overline_L0%s" % (js,)) return poly
def ordered_triples(self): triples = list(combinations(range(1, n + 1), d + 1)) triples.sort(key=lambda c: tuple(1 if i in c else 0 for i in range(n, 0, -1)))
pairs = list(combinations(range(1, n + 1), d)) rows = [] for tri in triples: L0 = self.L(tri, 0) self._L0_triples[tri] = L0 rows.append([self.coeff_xz(L0, 2*d - 1, pair) % self.p for pair in pairs])
M = Matrix(GF(self.p), rows).transpose() pivots = list(M.pivots()) if len(pivots) < binomial(n, d): raise ValueError("could not find enough independent triples") pivots = pivots[:binomial(n, d)] pivot_set = set(pivots) ordered = [triples[i] for i in pivots] + [ triples[i] for i in range(len(triples)) if i not in pivot_set ] self.triples = ordered self.pairs = pairs
q = self.p**d Arows = [] for tri in self.triples[:binomial(n, d)]: L0 = self._L0_triples[tri] Arows.append([self.coeff_xz(L0, 2*d - 1, pair) % q for pair in pairs]) self.elim_A = Matrix(Zmod(q), Arows) _ = self.elim_A.inverse() self.log("[construct] selected %d invertible L0 triples for Construction II" % len(Arows))
def tilde_L(self, i0, v): key = (i0, v) if key in self._tilde_cache: return self._tilde_cache[key] tri = self.triples[v] q = self.p**d target = vector(Zmod(q), [ self.coeff_xz(self.L(tri, i0), 2*d - 1, pair) % q for pair in self.pairs ]) e = self.elim_A.transpose().solve_right(target) poly = self.L(tri, i0) for u in range(binomial(n, d)): eu = ZZ(e[u]) if eu: poly -= eu * self.L(self.triples[u], 0) poly = self.reduce_poly(poly, q, self.Rz) self._tilde_cache[key] = poly if self.check: self.assert_z(poly, q, "tilde_L_%d_%s" % (i0, tri)) return poly
def construct_lattice_polys(self): self.ordered_triples() x = self.xy polys = [] labels = [] p = self.p
for i0 in range(0, 2*d - 1): polys.append(p**d * x**i0) labels.append("I(%d)" % i0)
for j in range(1, n + 1): for i0 in range(0, 2): polys.append(p**d * x**i0 * self.ys[j]) labels.append("IIa(%d,%d)" % (i0, j)) polys.append(p**(d - 1) * self.F[j]) labels.append("IIb(2,%d)" % j)
for pair in combinations(range(1, n + 1), 2): for i0 in range(0, 2*d - 1): polys.append(p**(d - 2 + 1) * self.to_y(self.L(pair, i0))) labels.append("IIIa(%d,%s)" % (i0, pair))
cutoff = binomial(n, d) for v, tri in enumerate(self.triples): if v < cutoff: polys.append(p * self.to_y(self.overline_L0(tri))) labels.append("IVa(0,%s)" % (tri,)) else: polys.append(self.to_y(self.tilde_L(0, v))) labels.append("IVb(0,%s)" % (tri,)) for i0 in range(1, t + 1): for v, tri in enumerate(self.triples): polys.append(self.to_y(self.tilde_L(i0, v))) labels.append("IVb(%d,%s)" % (i0, tri))
expected_dim = (t + 1) * binomial(n, d + 1) + (2*d - 1) * sum( binomial(n, l) for l in range(d + 1) ) if len(polys) != expected_dim: raise AssertionError("dimension mismatch: got %d, expected %d" % (len(polys), expected_dim))
if self.check: for label, poly in zip(labels, polys): self.assert_y(poly, p**d, label) self.log("[check] all %d lattice polynomials vanish mod p^2: ok" % len(polys)) self.polys = polys return polys
def scale_poly_terms(self, poly, bounds): terms = {} for exp, coeff in poly.dict().items(): c = ZZ(coeff) for ei, bi in zip(exp, bounds): if ei: c *= bi**ei if c: terms[exp] = c return terms
def build_lattice(self, bounds): polys = self.construct_lattice_polys() term_rows = [self.scale_poly_terms(f, bounds) for f in polys] monoms = sorted(set().union(*[set(r.keys()) for r in term_rows])) idx = {m: i for i, m in enumerate(monoms)} M = Matrix(ZZ, len(term_rows), len(monoms)) for r, row in enumerate(term_rows): for exp, coeff in row.items(): M[r, idx[exp]] = coeff self.monoms = monoms self.bounds = bounds self.log("[lattice] matrix %d x %d" % (M.nrows(), M.ncols())) return M
def vector_to_poly(self, row): terms = {} for coeff, exp in zip(row, self.monoms): coeff = ZZ(coeff) if not coeff: continue scale = ZZ(1) for ei, bi in zip(exp, self.bounds): if ei: scale *= bi**ei if coeff % scale != 0: return None terms[exp] = coeff // scale return self.Ry(terms)
def reduced_polys(self, bounds, max_rows): M = self.build_lattice(bounds) self.log("[LLL] reducing...") B = self.reduce_matrix(M) self.log("[LLL] done") if max_rows is None or max_rows <= 0: row_limit = B.nrows() else: row_limit = min(max_rows, B.nrows()-1) out = [] decoded = 0 zero_over_zz = 0 for i in range(row_limit): f = self.vector_to_poly(B.row(i)) if f is None or f == 0: continue decoded += 1 if self.check: val = ZZ(f(*self.yroot)) if val == 0: zero_over_zz += 1 out.append(f) else: out.append(f) if self.check: self.log( "[LLL] ZZ root check: %d/%d decoded rows vanish (checked %d/%d rows)" % (zero_over_zz, decoded, row_limit, B.nrows()) ) self.log("[LLL] collected %d candidate integer polynomials" % len(out)) return out
def reduce_matrix(self, M): rows = [] for i in range(M.nrows()): rows.append("[" + " ".join(str(ZZ(x)) for x in M.row(i)) + "]") inp = "[" + "\n".join(rows) + "\n]\n" proc = subprocess.run( ["flatter"], input=inp, text=True, capture_output=True, check=True, timeout=840, ) parsed = [] for line in proc.stdout.splitlines(): line = line.strip() if not line or line == "]": continue line = line.lstrip("[").rstrip("]") if line: parsed.append([ZZ(x) for x in line.split()]) if len(parsed) == M.nrows(): self.log("[LLL] used flatter") return Matrix(ZZ, parsed)
def solve_root(self, bounds, max_rows): short_polys = self.reduced_polys(bounds, max_rows=max_rows) if len(short_polys) < n + 1: raise ValueError("not enough short polynomials")
ideal = self.RQy.ideal(short_polys) self.log("[root] computing Groebner basis...") import signal signal.alarm(120) root = ideal.variety(algorithm="msolve", proof=False) print(root) if not root: raise ValueError("variety returned no roots") return ZZ(root[0][self.RQy("x")])
def recover_p0_x(self, e0): x_mid = (self.h0 + e0) % self.p rhs = (x_mid**3 + self.a*x_mid + self.b) % self.p if kronecker(rhs, self.p) != 1: raise ValueError("recovered middle x is not on curve") y_mid = ZZ(power_mod(rhs, (self.p + 1) // 4, self.p)) candidates = [] tenG = ec_mul(mid, self.G, self.a, self.p) for y in (y_mid, (-y_mid) % self.p): Pmid = (x_mid, y) P0 = ec_add(Pmid, ec_neg(tenG, self.p), self.a, self.p) candidates.append(P0[0]) near = [x for x in candidates if abs(ZZ(x) - self.states[0]) <= self.Delta] if near: return ZZ(near[0]) return ZZ(candidates[0])
def parse_remote_data(blob): text = blob.decode(errors="replace")
def grab_int(name): m = re.search(r"^%s\s*=\s*([0-9]+)\s*$" % re.escape(name), text, re.M) if not m: raise ValueError("could not parse %s" % name) return ZZ(m.group(1))
def grab_literal(name): m = re.search(r"^%s\s*=\s*(.+)\s*$" % re.escape(name), text, re.M) if not m: raise ValueError("could not parse %s" % name) return ast.literal_eval(m.group(1))
return { "p": grab_int("p"), "a": grab_int("a"), "b": grab_int("b"), "Delta": grab_int("Delta"), "G": grab_literal("G"), "states": grab_literal("states"), }
def solve_data(data, max_rows): solver = ECXHNPSolver(data, check=False) bounds = [solver.Delta//8] + [solver.Delta] * n e0 = solver.solve_root(bounds, max_rows=max_rows) p0x = solver.recover_p0_x(e0) print("P0.x =", p0x) return p0x
def main(): context.log_level = "debug" io = process(["sage", "challenge.py"]) # io = remote("challenge.ctf2026.r3kapig.com", 30388) blob = io.recvuntil(b"# submit P0.x") data = parse_remote_data(blob) print("[remote] parsed p_bits=%d, states=%d" % (ZZ(data["p"]).bit_length(), len(data["states"]))) p0x = solve_data(data, max_rows=120) io.sendline(str(p0x).encode()) io.recvuntil(b"key_tag = ") key_tag = ast.literal_eval(io.recvline().strip().decode()) io.recvuntil(b"ct = ") ct = ast.literal_eval(io.recvline().strip().decode()) ct = bytes.fromhex(ct)
a = data["a"] b = data["b"] g = data["G"] p = data["p"] EC = EllipticCurve(GF(p), [a, b]) p0y = EC.lift_x(p0x).y()
def try_decrypt(a, b, g, p0x, p0y): nonlocal ct, key_tag size = (p.bit_length() + 7) // 8 nums = (a, b, g[0], g[1], p0x, p0y) # print(nums) material = b"".join(int(x).to_bytes(size, "big") for x in nums) key = sha256(material + b"|" + key_tag.encode()).digest() pad = b"" ctr = 0 while len(pad) < len(ct): pad += sha256(key + ctr.to_bytes(4, "big")).digest() ctr += 1 print(bytes(x ^^ y for x, y in zip(ct, pad)))
try_decrypt(a, b, g, p0x, p0y) try_decrypt(a, b, g, p0x, -p0y % p)
if __name__ == "__main__": main()Encrypted Activation
Description
The remote server uses a fixed key. The evaluation keys are provided in the attachments.
task.py
#!/usr/bin/env python3from __future__ import annotationsimport base64, json, os, random, sysimport fhe_core as fhefrom secret import FLAGimport signal
S = 4N_DIGITS = 5LUT_SIZE = S ** N_DIGITSSETUP_DIR = os.path.join(os.path.dirname(__file__), "setup")ROUNDS = 16
def _timeout(_signum, _frame): print("timeout") sys.stdout.flush() raise SystemExit(0)
def extract_radix(v: int, s: int, n: int): d = [] for _ in range(n): d.append(v % s) v //= s return d
def combine_radix(d, s: int): v = 0 for x in reversed(d): v = v * s + x return v
def write_setup(): os.makedirs(SETUP_DIR, exist_ok=True) print("[*] generating keys...") sk, bsk, ksk = fhe.keygen()
client_blob = fhe.serialize_client_key(sk) with open(os.path.join(SETUP_DIR, "client.bin"), "wb") as f: f.write(client_blob) print(f"[*] client.bin: {len(client_blob)/1e6:.1f} MB")
bsk_blob = fhe.serialize_bsk(bsk) with open(os.path.join(SETUP_DIR, "bsk.bin"), "wb") as f: f.write(bsk_blob) print(f"[*] bsk.bin: {len(bsk_blob)/1e6:.1f} MB")
ksk_blob = fhe.serialize_ksk(ksk) with open(os.path.join(SETUP_DIR, "ksk.bin"), "wb") as f: f.write(ksk_blob) print(f"[*] ksk.bin: {len(ksk_blob)/1e6:.1f} MB") print("[*] setup complete ->", SETUP_DIR) return
def load_setup(): client_path = os.path.join(SETUP_DIR, "client.bin") if not (os.path.exists(client_path)): write_setup() with open(client_path, "rb") as f: client_blob = f.read() sk = fhe.parse_client_key(client_blob) return sk
def main() -> int: signal.signal(signal.SIGALRM, _timeout) signal.alarm(120) sk = load_setup() lut = [int(token) for token in open("lut", "r").read().split()] if len(lut) != LUT_SIZE: raise ValueError(f"expected {LUT_SIZE} LUT entries, got {len(lut)}") print("=== Encrypted Activation-Layer Inference ===") print('''Each ciphertext carries one base-%d activation symbol (2 bits).Evaluate the published %d-bit activation table on the encryptedinput using the provided keys, and return the encrypted outputsymbols.''' % (S, (S.bit_length()-1) * N_DIGITS)) sys.stdout.flush()
for idx in range(ROUNDS): x = fhe.rng.randrange(LUT_SIZE) in_digits = extract_radix(x, S, N_DIGITS) seeded_inputs = [fhe.encrypt_ciphertext(sk, d) for d in in_digits] payload = { "round": idx + 1, "ciphertext": [(int.from_bytes(seed, 'big'), ct.b) for seed, ct in seeded_inputs], } print(json.dumps(payload)) sys.stdout.flush()
line = sys.stdin.readline() if not line: return 1 line = line.strip() parts = [t for t in line.replace(",", " ").split() if t] if len(parts) != N_DIGITS: print("wrong"); sys.stdout.flush(); return 0
try: out_cts = [fhe.parse_lwe_ciphertext(base64.b64decode(t, validate=True)) for t in parts] except Exception: print("wrong"); sys.stdout.flush(); return 0
bad = False for c in out_cts: if len(c.a) != fhe.N: bad = True; break zero_cnt = sum(1 for v in c.a if v == 0) if zero_cnt > 16: bad = True; break if len(set(c.a)) == 1: bad = True; break if bad: print("wrong"); sys.stdout.flush(); return 0
out_digits = [fhe.decrypt_ciphertext(sk, c) for c in out_cts] if any(d >= S for d in out_digits): print("wrong"); sys.stdout.flush(); return 0
y = combine_radix(out_digits, S) if y != lut[x]: print("wrong"); sys.stdout.flush(); return 0
print(FLAG) sys.stdout.flush() return 0
if __name__ == "__main__": raise SystemExit(main())Solution
WARNING这道题 95% 是 ai 做的,而且我不懂 TFHE,所以以下内容可能是错的,就当看个乐子。
TFHE 但是只给了加密解密,让 ai 先分析了 bsk 和 ksk,结论是和 TFHE 论文里的不同,任何非线性的计算只能使用 programmable bootstrapping 来实现。TFHE 原论文只给了 的实现,但这道题是 ,所以还得自己写。于是我就先让 ai 实现了一个 programmable bootstrapping 的实现,然后让 ai 优化了一下速度。比如让 ai 把 TFHE 原论文用的 nayuki 和 fftw 都试着接进来,但是 64 bits 的用 double fft 精度问题太大了。总之 fft 也没带来多少提升,最终大约 250 ms 一次 bootstrap。
然后是处理这个 LUT 的问题。每次 bootstrap 只能使用 mod 5 的 LUT 变换。从信息论的角度来说,这么干得 1000 次以上才能完成 1024 的 LUT,但题目设置了平均每个 7.5 秒的时限,所以加上多线程也就最多几百次的 bootstrap。
然后 ai 就开始发力了,说 TFHE 是把信息放在高位的(这个看解密就可以看出来),但是 bootstrap 的算法其实对这个 mod 5 没有感知,所以可以变成 mod 1024 的 ct 之后再 LUT(我内心:啊?),但很可惜的是测试炸了,原因是噪声太大了和 negacyclic 的问题。
但是换成 mod 32 之后成功率就还可以。于是就把 1024 的 LUT 分成 32 个 mod 32 的 LUT,最后再用 mask 合并成 5 个 mod 5 的输出。这个 mask 的实现也很神秘,这个 fhe 没有乘法,所以实际是把 mask 和 LUT 得到的 5 个 2 bit 数分别加了起来,相当于一个 8 slot 的 torus 再进行 bootstrap LUT。
最后大概 400 多次 bootstrap 就可以完成,但是每次的成功率大概是 70%,然后找了一台 128 核的服务器碰运气就过了。

