$ cat writeup.md…
$ cat writeup.md…
Securinets CTF Quals 2026
Task: Given arrays x, y with y[i] = a * x[i]^2, recover the hidden coefficient a each round using only an integer-array calculator whose reads are capped to [0,255]. Solution: combine a float-ULP residue reduction (read any value mod 256) with x^62 * y_i = a mod 256 for odd x_i and a first-odd-index product selector.
Yet another Calculator,the interaction is the same as the old one but there's something more to it
The service (nc calculator-crypto.securinets.tn 4874) hands us two integer arrays x and y with the relation
y[i] = a * x[i]^2 (a random 8-bit coefficient)
and exposes a small arithmetic engine that operates on those arrays. We may read exactly one cell per round, but the read is capped to the range [0, 255]. After the read, the server asks for the coefficient a itself, and a wrong answer closes the socket. There are 16 rounds per connection, and both arrays plus a are regenerated every round, so all 16 rounds must be played on the same connection to get the flag.
This is the sequel to "Old Calculator", where the relation was affine (y = a*x + b) and the leak was a straightforward IEEE-754 pack/unpack. Here the map is quadratic and the hidden quantity is the coefficient, not a cell — which kills the direct float-leak approach used before.
One JSON object per line. Reverse engineering by fuzzing hundreds of operation names showed that only five operations exist:
| operation | meaning |
|---|---|
add | dst = src1[i1] + src2[i2] |
mult | dst = src1[i1] * src2[i2] |
add_const | dst = src[i] + C |
mult_const | dst = src[i] * C |
submit | read one cell, then ask for a |
source is "x" or "y" (a two-character "xy" is also accepted). Constants must be integers in [-255, 255]; integral floats such as 1.0 / 2.0 are accepted and produce float cell values, but fractional floats are rejected (Constant must be an integer) and out-of-range constants give Constant must be 8-bit.
submit {"source":"x","index":i} prints the cell value and then waits for {"a": <number>}. The read is the whole game:
0 <= value <= 255 → the exact value is printed (ints and floats).value > 255 (int) → Value at ... is N bits, which cannot be represented as 8-bit: (only the bit length leaks).value > 255 (float) → 'float' object cannot be interpreted as an integer.value < 0 → Value at ... is negative, ....So the cap is enforced by value, not by bit width: any value outside [0, 255] is unreadable, which is exactly what breaks the naive float pack from the prequel (floats above 255 can no longer be read out).
a is impossibleEvery expression the engine can build is an integer polynomial in the cells x[], y[]. Substituting the challenge relation y_i = a * x_i^2, any monomial becomes
a^(Σ p) * Π x_i^(2 p_i + q_i)
An expression that is free of x and y variables is therefore necessarily constant — it can never isolate the parameter a. No polynomial click-sequence returns a. The only remaining channel is the side channel of what the server is willing to print: the exact value when it is small, and the bit length when it is large. The intended path is a residue leak through the read cap.
Doubles have 53 bits of mantissa. Choose
K = 2^60 + 2^40
For any value B in [2^60, 2^61), the ULP of its double representation is 256. So float(B) is B rounded to a multiple of 256 (relative to the 2^60 grid). Therefore, for a cell value V:
Bf = float(V + K)
R = Bf - K # R ≡ V (mod 256), and R differs from V by less than 256
D = V - R # centered residue, D ≡ V (mod 256) and D ∈ [-128, 128]
Then D + 128 ∈ [0, 255] and is fully readable, giving us V mod 256 in a single submit.
Two crucial implementation details:
R = Bf - K as a float subtraction first, and only afterwards V - R. In CPython, int + float converts the int to a float; writing V - (Bf - K) as one expression or mixing the operands naively destroys the low bits and yields D = 0. Keeping magnitudes below 2^53 keeps the intermediate exact.|V| comfortably below 2^53; otherwise the individual addends are not exactly representable.Building K inside the engine: set a scratch cell to 0 (mult_const 0), then add_const 1, then multiply by 2 sixty times to get 2^60; do the same for 2^40 in another cell; add them to get K; multiply by -1 to get -K.
a mod 256 from the relationWe want a = y_i * x_i^{-2} (mod 256). The multiplicative group (Z/256Z)^* has exponent λ(256) = 64 (Carmichael), i.e. every odd x_i satisfies x_i^64 ≡ 1 (mod 256). Hence for odd x_i:
x_i^62 ≡ x_i^(-2) (mod 256)
y_i * x_i^62 = a * x_i^2 * x_i^62 = a * x_i^64 ≡ a (mod 256)
For even x_i, x_i^62 ≡ 0 (mod 256), so the product is 0 and carries no information (the map x -> x^2 is not invertible modulo 256).
So the value
V_i = red(y_i * x_i^62) (mod 256, centered)
equals a when x_i is odd and 0 when x_i is even.
x_i^62 is computed with the engine's own mult by repeated squaring (62 = 32 + 16 + 8 + 4 + 2), applying the residue reduction after every multiplication so all intermediate magnitudes stay far below 2^53.
We cannot branch in the engine, so we fold the selection into arithmetic using the mod-256 indicator
n_j = x_j^64 mod 256 ∈ {0, 1}
(1 exactly when x_j is odd, 0 when even — again by λ(256) = 64). Define the running "all previous were even" flag
S_0 = 1, S_i = S_{i-1} * (1 - n_i) (mod 256)
S_{i-1} is 1 iff every index before i was even, and 0 otherwise. The accumulated sum
P = Σ_i V_i * S_{i-1} (mod 256)
therefore contains exactly the term of the first odd index, and equals a whenever at least one of the first N indices is odd. With N = 14 the failure probability is 2^-14 (all fourteen x_i even), handled by simply reconnecting. Note that S must be initialized explicitly to 1 (mult_const 0 then add_const 1); an uninitialized cell silently corrupts the whole product chain.
Center P and send P + 128 through the residue read, recover
a = (printed_value - 128) mod 256
and answer {"a": a}. Repeat for all 16 rounds on the same connection. Since a mod 256 with a an 8-bit coefficient is exactly a, this is always the correct value.
The script below builds, for each round, a fixed prologue that constructs K, -K, a 0, and a 1, then for each of the first N = 14 indices emits the repeated-squaring chain for x_i^62, the x_i^64 indicator, the term y_i * x_i^62 * S, the accumulation into P, and the update S *= (1 - n_i). Finally it reduces P mod 256, adds 128, and submits the scratch cell. Scratch cells are kept in the high indices of x (construction) and y (accumulators) so they never collide with the data cells used by the challenge arrays.
#!/usr/bin/env python3 """New Calculator (Securinets CTF Quals 2026) — solution-only exploit. Per round: recover `a mod 256` as y_i * x_i^62 (mod 256) for the FIRST ODD x_i, selected by S_{i-1} = prod_{j<i} (1 - x_j^64 mod 256). Uses the float-ULP residue primitive to read any value modulo 256 in a single submit. """ import json, socket, time, re HOST, PORT = "calculator-crypto.securinets.tn", 4874 # scratch layout K1, NEGK = 90, 92 # x[90] = K, x[92] = -K T1, T2, T3, T4 = 83, 84, 85, 86 # temporaries for the residue reduction PRODX = 87 # product scratch # y accumulators: y60..y66 = powers/indicator, y67 = S, y68 = P, y69..y71 = scratch N = 14 # first N indices; failure (all even) = 2^-14 def oa(s1, i1, s2, i2, da, di): return {"operation": "add", "source": [s1, s2], "source_index1": i1, "source_index2": i2, "destination": da, "dest_index": di} def om(s1, i1, s2, i2, da, di): return {"operation": "mult", "source": [s1, s2], "source_index1": i1, "source_index2": i2, "destination": da, "dest_index": di} def oc(src, i, c, da, di): return {"operation": "mult_const", "source": src, "index": i, "const": c, "destination": da, "dest_index": di} def ok(src, i, c, da, di): return {"operation": "add_const", "source": src, "index": i, "const": c, "destination": da, "dest_index": di} def kbuild(): """Constant prologue: build K = 2^60 + 2^40 and NEGK = -K.""" ops = [oc("x", 0, 0, "x", K1), ok("x", K1, 1, "x", K1)] # x[K1] = 0 then 1 ops += [oc("x", K1, 2, "x", K1) for _ in range(60)] # *2^60 ops += [oc("x", 0, 0, "x", 91), ok("x", 91, 1, "x", 91)] # x[91] = 1 ops += [oc("x", 91, 2, "x", 91) for _ in range(40)] # *2^40 ops += [oa("x", K1, "x", 91, "x", K1), oc("x", K1, -1, "x", NEGK)] return ops def red(varr, vi, darr, di): """Residue reduction: dst = (src value) mod 256, centered into [-128, 128]. R = float(V+K) - K ; D = V - R. Compute R first (float), then V - R.""" return [oa(varr, vi, "x", K1, "x", T1), # T1 = V + K oc("x", T1, 1.0, "x", T2), # T2 = float(T1) oa("x", T2, "x", NEGK, "x", T3), # T3 = float(T1) - K = R oc("x", T3, -1, "x", T4), # T4 = -R oa(varr, vi, "x", T4, darr, di)] # D = V - R def mulred(a1, i1, a2, i2, da, di): """Multiply two cells, then reduce the product mod 256 centered.""" return [om(a1, i1, a2, i2, "x", PRODX)] + red("x", PRODX, da, di) def index_ops(xi): ops = [] # powers of x_i, reduced after every step: p2, p4, p8, p16, p32 ops += mulred("x", xi, "x", xi, "y", 60) ops += mulred("y", 60, "y", 60, "y", 61) ops += mulred("y", 61, "y", 61, "y", 62) ops += mulred("y", 62, "y", 62, "y", 63) ops += mulred("y", 63, "y", 63, "y", 64) # x^62 = p32 * p16 * p8 * p4 * p2 ops += mulred("y", 64, "y", 63, "y", 65) ops += mulred("y", 65, "y", 62, "y", 65) ops += mulred("y", 65, "y", 61, "y", 65) ops += mulred("y", 65, "y", 60, "y", 65) # n = x^64 (0 if even, 1 if odd) ops += mulred("y", 64, "y", 64, "y", 66) # V = y_i * x_i^62 == a (mod 256) for odd x_i ops += mulred("y", xi, "y", 65, "y", 69) # P += V * S ops += mulred("y", 69, "y", 67, "y", 70) ops += [oa("y", 68, "y", 70, "y", 68)] # S *= (1 - n) ops += [oc("y", 66, -1, "y", 66), ok("y", 66, 1, "y", 66)] ops += mulred("y", 67, "y", 66, "y", 67) return ops def round_ops(): ops = kbuild() ops += [oc("y", 68, 0, "y", 68), oc("y", 67, 0, "y", 67), ok("y", 67, 1, "y", 67)] for i in range(N): ops += index_ops(i) ops += red("y", 68, "y", 68) # P mod 256 (centered) ops += [ok("y", 68, 128, "y", 68)] # shift to [0, 255] for the read return ops def recv_all(s, total=3.0, idle=0.5): buf, start, last = b"", time.time(), time.time() while time.time() - start < total: try: d = s.recv(65536) except socket.timeout: if buf and time.time() - last > idle: break continue if not d: break buf += d last = time.time() return buf.decode(errors="replace") def play(): s = socket.create_connection((HOST, PORT), timeout=15) s.settimeout(0.4) time.sleep(0.3) try: s.recv(65536) except Exception: pass rnd = 1 last = "" while rnd <= 16: ops = round_ops() for k in range(0, len(ops), 400): # send in chunks to be safe s.sendall(b"".join((json.dumps(o) + "\n").encode() for o in ops[k:k + 400])) time.sleep(0.15) time.sleep(1.0) s.sendall((json.dumps({"operation": "submit", "source": "y", "index": 68}) + "\n").encode()) time.sleep(0.6) t = recv_all(s) m = re.search(r"submitted is:\s*([-0-9.eE+]+)", t) if not m: s.close() return None a = (int(float(m.group(1))) - 128) % 256 s.sendall((json.dumps({"a": a}) + "\n").encode()) time.sleep(0.6) r = recv_all(s) last = r print("round", rnd, "a =", a, "->", repr(r[-140:]), flush=True) if ("Round %d" % (rnd + 1)) in r: rnd += 1 continue if "Incorrect" in r or "error" in r: s.close() return None s.close() return r # 16 rounds cleared: the server sends the flag here s.close() return last for attempt in range(6): res = play() if res: print("== run complete ==") break print("=== attempt", attempt, "ended, retrying ===", flush=True)
The successful run cleared all 16 rounds (round 1 … round 16, each answering the recovered 8-bit coefficient) and the server then returned the flag. A wrong answer at any round would have closed the socket with Incorrect value submitted, so the 16/16 progression is proof that every recovered coefficient was exact.
Use this technique when:
add / mult / add_const / mult_const over integer arrays and you must recover a hidden parameter rather than a cell value.y[i] = a * x[i]^k) — to invert it modulo 2^m, use the group exponent λ(2^m) = 2^(m-2) to replace x^(λ-k) with x^(-k) for odd x.0 <= value <= 255) instead of by bit width — the cap does not stop a residue leak, it only forces a reduction step.mult_const — a sign that an IEEE-754 ULP trick is available to round values onto a coarse grid and read them modulo a power of two.int + float silently coerces the int to a float, so a residue reduction must compute the float subtraction before touching the int to avoid losing the low bits.0/1 indicators.a) is regenerated each round and the counter is per-connection, plan for many expressions per round and keep the whole 16-round sequence on a single socket.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar