1589 words
8 minutes
R3CTF 2026 Writeup

这周跟 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 DiscreteGaussianDistributionIntegerSampler
from Crypto.Util.number import *
from secrets import randbelow
from secret import flag
p = 0x8000000b
PR.<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 样本。其中 pp 大约 2312^{31},而噪声的 std 只有 5。显然跑 344 个 1024 维的 LLL 是不现实的,所以只能是 f 有后门。

假如 f=ghf=g\cdot h,那么我们可以将 Zp[x]/(f)\mathbb{Z}_p[x]/(f) 投影到 Zp[x]/(g)\mathbb{Z}_p[x]/(g),从而在更小的维度解决这个 RLWE。但是这个投影相当于计算 amodga\mod g,而 gg 的参数可能非常大,导致噪声也会被放大。因此这个方法只有在 gg 的系数足够小的情况下才有用。

尝试分解 ff 可以发现它有一堆小因子,但它们的系数都很大。所以我们先试着猜一些因子乘起来能得到一个系数较小的多项式。假如真的存在这么一个因子,我们可以推测出如下的性质:

  1. 这个因子的度数不能太大,不然 LLL 还是跑不完,这里我们假设度数不超过 128,对应的格维度是 256。
  2. 这个因子的系数不能太大。根据我们前一条假设度数不超过 128,x512modgx^{512}\mod g 会差不多把 gg 四次方,所以就算是十几的系数都会爆炸,必须得是非常小非常稀疏的才有戏。

现在我们希望从 ff 的 75 个因子里面找出乘积很小的组合。显然枚举是不现实的,但是我们可以这么想:一个因子乘上一个多项式后系数很小的概率是很低的,所以我们可以先猜 gg 有哪些因子,再用 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]

考虑到 pp 大约 2312^{31} 而系数只有个位数,我们可以认为对所有度数大于 12 的因子都不会出现伪阳。于是成功发现一个度数 16 的因子的倍数里面有 x127x46+x19x8+1x^{127} - x^{46} + x^{19} - x^8 + 1

接下来就是把 RLWE 投影过来跑 LLL 了。这里直接简单用 Kannan Embedding 跑 cvp 即可。

(e0b0IA00pI)\begin{pmatrix} e & 0 & b \\ 0 & I & A\\ 0 & 0 & pI \end{pmatrix}

这里 AAaa 的系数矩阵,然后 ee 取一个和噪声差不多大的数。但是 flatter 没法解出这个 SVP,所以我换到了 BLASter 这个库,支持更多的 BKZ,每个可以 6 秒跑完。最后花了 20 多分钟跑完 344 个 bit,然后重构出 flag 即可。

solve.sage
from Crypto.Util.number import *
from tqdm import tqdm
p = 0x8000000b
PR.<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:

https://eprint.iacr.org/2007/099.pdf

challenge.py
from hashlib import sha256
from random import SystemRandom
from secrets import token_hex
import signal
from secret import flag
p_bits = 1024
k = 21
d_bits = 451
Delta = 1 << d_bits
solve_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#

一眼看上去就是论文题,但是如果顺着题目里给的论文找就被阴了。这里我们需要找的是只给 xix_i MSB 的 Coppersmith 构造,然后应该搜索的是 ECHNP。在 Google Scholar 看 cite 可以找到 2026 年的最新工作1。然后就简单了,ocr 成 latex 交给 AI 去抄那一堆多项式构造即可,而且代入测试数据检查 modp2\mkern{-20mu}\mod p^2 是否为 0 就可以验证正确性。

实际跑的时候发现对于 451 bits 的 delta,LLL reduce 后的多项式大概会落在 3p23p2-3p^2\sim 3p^2 的范围内,但是只要不是 0 就没法用 grobner 解。这个 Coppersmith 有 11 个变量,所以直接暴力猜测是不行的(除非你愿意跑 1000 次 408 维的 LLL),所以需要一点小优化就是 coppersmith base polys 里面有很多都包含了 xx,所以正确做法是在设置 bounds 的时候把 xx 的 bound 除以 8,这样就只要猜 8 次即可。

下面的代码是清理过的,同时把 GPT 写的 grobner 换成了 msolve。2

solve_clean.sage
from sage.all import *
from hashlib import sha256
from itertools import combinations
import argparse
import ast
import re
import shutil
import subprocess
from pwn import context, remote, process
n = 10
d = 2
t = 1
mid = 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 python3
from __future__ import annotations
import base64, json, os, random, sys
import fhe_core as fhe
from secret import FLAG
import signal
S = 4
N_DIGITS = 5
LUT_SIZE = S ** N_DIGITS
SETUP_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 encrypted
input using the provided keys, and return the encrypted output
symbols.''' % (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 原论文只给了 2322^{32} 的实现,但这道题是 2642^{64},所以还得自己写。于是我就先让 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 核的服务器碰运气就过了。

Footnotes#

  1. Xu, J., Sarkar, S., Wang, H., & Hu, L. (2026). New Results on Elliptic Curve Hidden Number Problem for ECDH Key Exchange: J. Xu et al. Journal of Cryptology, 39(2), 20.

  2. 但还是用了 500 行,感觉除了没有用 sage 的 EC 之外都没法变短。如果没有 ai 在 ctf 的时候写 500 行是绝对的折磨,只能说感谢 ai。