Challenge Description
Here when adventuring, we like positive thinking, even when you can’t know the thoughts of others.
We’ll let you put other’s thoughts through something that gets you something positive!
ncat --ssl positive-thinking.chal.uiuc.tf 1337
The handout is a Dockerfile, an nsjail.cfg, and main.py.
Source Code Analysis
The server builds a TenSEAL CKKS context, encrypts a 50-bit secret under it, and hands you the public context plus the ciphertext:
POLY_MODULUS_DEGREE = 16384COEFF_MOD_BIT_SIZES = [60, 40, 40, 40, 40, 40, 40, 60]GLOBAL_SCALE = 2**40
SECRET_BITS = 50NORMALIZATION1 = 2**24NORMALIZATION2 = 2**25MAX_QUERIES = 100
secret = secrets.randbelow(2**SECRET_BITS)encrypted_secret = ts.ckks_vector(context, [secret])
public_context = context.copy()public_context.make_context_public()Then it loops 100 times. Each round takes a serialized ciphertext, evaluates the degree-8 Chebyshev polynomial on it, and leaks one bit — the sign of the result:
ciphertext = ts.ckks_vector_from(context, blob)
normalized = ciphertext * (1.0 / NORMALIZATION1) * (1.0 / NORMALIZATION2)
result = ( chebyshev8(normalized) .decrypt()[0])...print("Positive" if result > 0 else "Not positive")
guess = int(input("Secret: "))if guess == secret: print(FLAG)So the oracle is sign(T8(v / 2^49)) for any v I can produce homomorphically,
and I get one guess per round alongside it. Fifty bits, one hundred bits of
feedback — a binary search fits, if each query can be aimed.
The Vulnerability
Having the public context means I can evaluate on the ciphertext myself before
submitting it. If I can build v = m·secret − c for chosen m and c, then
x = (m·secret − c) / 2^49and the oracle tells me whether T8(x) is positive. T8 is even, so the sign of
T8 is a function of |x| alone. Its roots are at cos((2k+1)π/16), giving four
positive radii:
r1 = 0.19509 r2 = 0.55557 r3 = 0.83147 r4 = 0.98079and T8(x) > 0 exactly on
|x| < r1 or r2 < |x| < r3 or |x| > r4Keep the whole candidate range inside |x| <= r2 and the middle two bands never
come into play, so the oracle degrades to a clean membership test:
Positive <=> |m·secret − c| < r1 · 2^49which is “is the secret inside this interval”. Pick m and c so the interval
covers a little more than half the remaining candidates and each round halves
the search space.
The First Obstacle: No Plaintext Multiplication
Building m·secret looks like a one-liner, and it is not. Any plaintext
multiply from the client side kills the server’s evaluation:
tests = { "mult only": lambda: c0 * 0.5, "add only": lambda: c0 + 123.0, "mult then add": lambda: (c0 * 0.5) + 123.0, "add then mult": lambda: (c0 + 123.0) * 0.5,}mult only FAILED: ValueError('scale out of bounds')add only -> 0.9757967994716341mult then add FAILED: ValueError('scale out of bounds')add then mult FAILED: ValueError('scale out of bounds')double 3x -> 424279.9436124598The modulus chain has no room for an extra rescale before the server’s own two
divisions and the degree-8 polynomial. Only additions survive — and addition
is enough: ct + ct doubles the value at no level cost, so double-and-add builds
m·secret for any positive integer m, and plaintext constants can be added for
free at any point.
The Second Obstacle: The Constant Does Not Fit In A Double
At the end of the search m is around 2·10^14, so c ≈ m·secret ≈ 2^98. A
float64 at that magnitude has a granularity of 2^-52 · 2^98 ≈ 4·10^13, which is
a sizeable fraction of the 1.1·10^14 decision threshold. Handing the server one
big constant would smear the boundary.
The fix is to fold c into the doubling ladder. Walking the bits of m from the
top, each step doubles whatever constant has accumulated so far, so subtracting
a_R at the step with R doublings remaining contributes a_R · 2^R. Splitting
c into digits that are each exact integers below 2^53 makes every added
constant exactly representable:
def decompose(m, c): """split constant c across the double-and-add steps""" bits = bin(m)[2:]; K = len(bits) - 1 rem = c; a = {} for Rr in range(K, -1, -1): a[Rr] = float(int(rem / (2.0 ** Rr))); rem -= a[Rr] * (2.0 ** Rr) a[0] += rem cexact = sum(F(a[Rr]) * (2 ** Rr) for Rr in a) return bits, a, cexact
def build(m, c): bits, a, cx = decompose(m, c) K = len(bits) - 1 q = c0 + (-a[K]) for idx, b in enumerate(bits[1:], 1): Rr = K - idx q = q + q if b == '1': q = q + c0 q = q + (-a[Rr]) return q, cxdecompose also returns the exact rational value of the constant it actually
realized. That matters for the next part.
Tracking The Candidate Set Exactly
Early on, the candidate range spans the full 2^50 and no integer m >= 1 makes
it fit inside |x| <= r2, so the outer bands really are in play and a “positive”
answer means the secret lies in a union of up to five intervals. Rather than
special-case that, I kept the candidate set as a union of integer intervals
and applied the true predicate every round:
POS_X = [(-INF, -r4), (-r3, -r2), (-r1, r1), (r2, r3), (r4, INF)]
def pos_int_intervals(m, cexact, lo, hi): res = [] for x0, x1 in POS_X: a = lo if x0 == -INF else max(lo, math.ceil((cexact + F(x0) * U) / m)) b = hi if x1 == INF else min(hi, math.floor((cexact + F(x1) * U) / m)) if a <= b: res.append((a, b)) ...Everything runs through fractions.Fraction. Computing the interval endpoints in
float64 was the bug that cost me the most time — with c near 2^98, adding
r1·2^49 ≈ 10^14 to it is lost entirely in the rounding, so the solver’s model of
its own query drifted away from what the server was actually being asked, and the
search converged confidently onto the wrong value.
Query selection then just picks the (m, c) that splits the remaining set most
evenly, trying a spread of rank-based split points plus a scan over c at m = 1
for the opening rounds where no integer m gives a clean test:
def choose_query(C): lo = C[0][0]; hi = C[-1][1]; W = hi - lo + 1; n = size(C) cands = [] for q in [x / 40 for x in range(1, 40)]: p = nth(C, int(q * n)) d = (p - lo) / 2.0 if d <= 0: continue m = max(1, int(round(r1 * U / d))) cands.append((m, float(m) * (lo + p) / 2.0)) if r1 * U / (0.28 * W) < 1.0: s0 = lo - 1.05 * U; s1 = hi + 1.05 * U for k in range(801): cands.append((1, s0 + (s1 - s0) * k / 800)) ...Exploitation
Against a local replica the search closes in 50 queries with every decision correct. Remotely it took 49, including the kCTF proof of work:
q0 m=1 |C|=1125899906842624 Not positive guess=...q1 m=1 |C|=562949953421312 Positive guess=......q45 m=2496047447544 |C|=12 Positive guess=51385970989688q46 m=2678685065657 |C|=6 Positive guess=51385970989765q47 m=73217391794619 |C|=3 Not positive guess=51385970989768q48 m=219652175383856 |C|=2 Positive guess=51385970989769 -> uiuctf{s34rch1ng_th3_sp4c3_667f4c3d}CKKS noise never became a factor — the polynomial evaluation stayed accurate to
about 5·10^-8 at T8(0), and every decision boundary sat orders of magnitude
away from that.
Flag
uiuctf{s34rch1ng_th3_sp4c3_667f4c3d}