$ cat writeup.md…
$ cat writeup.md…
Securinets CTF Quals 2026
Task: an 8-bit calculator JSON service hides an affine map F and asks for F(r) in 16 one-shot rounds. Solution: bypass the int-only bit_length check with floats, pack five memory cells into one IEEE-754 double, then recover y=a*x+b from two pairs.
We had a 4-bit calculator capable of performing simple operations and storing their results. My little brother kept repeating the same operation until its tiny memory was full. Luckily, I managed to upgrade it to 8 bits, giving it 200 bytes of memory: 100 bytes for inputs and 100 bytes for results. There's just one catch: the calculator erases its entire history as soon as I display the results. Can you figure out what he was doing before it's too late?
English summary: a TCP service exposes a tiny virtual calculator driven by JSON requests. It holds two arrays, x (inputs) and y (results) of 100 cells each, supports add, mult, add_const, mult_const, and a submit operation that reveals one cell. A hidden affine function F maps the x cells to the y cells (y = F(x)). Each connection plays 16 rounds; every round the memory is regenerated, a new secret is chosen, and exactly one submit is allowed before the state is destroyed. To pass a round you must answer F(r) for a random r chosen by the server.
Round N:.{"operation":"add_const","source":"x","index":0,"const":10,"destination":"y","dest_index":1} and so on. Arithmetic works on x and y cells.{"operation":"submit","source":"x","index":0} prints the value of x[0] you submitted is: V, then immediately asks the final question: As a final Test please give the value after applying the secret operation of the random number r: and waits for {"y": <number>}.Incorrect value submitted. Expected E, but got G and the connection closes. Any protocol error also closes it. Consequence: exactly one submit per connection/round, so a naive "read one cell per round" approach can never learn the function.Value at y[99] is 11 bits, which cannot be represented as 8-bit. The initial memory is randomized and often already exceeds 8 bits, so even the hidden pairs are not directly readable.The pairs (x[i], y[i]) in memory are points of an affine map
y = a * x + b with small integers a, b in [0..16]
Two valid pairs therefore determine the whole function, and the answer to the final test is simply a * r + b. The engineering problem is reduced to: read two (x, y) pairs with a single 8-bit-limited submit.
The 8-bit restriction is enforced only for integers — the server calls Python's int.bit_length() (directly or via an equivalent check). Floats are not integers, so:
float values;submit prints a float with its full round-trip decimal representation, effectively unlimited precision;That converts a one-cell, 8-bit read into an arbitrary-precision read of a single cell.
An IEEE-754 double has a 53-bit mantissa, so any value whose significant bits span at most 53 positions can be represented exactly. Five cells of at most 12 bits each (two full input/output pairs plus one extra input cell) span 48 bits, which fits:
v = x0 + y0/2^12 + x1/2^20 + y1/2^32 + x2/2^40
Bit layout (from the most significant field downwards):
| Field | Width | Scale |
|---|---|---|
x0 | 12 bits | 2^0 |
y0 | 8 bits | 2^-12 |
x1 | 12 bits | 2^-20 |
y1 | 8 bits | 2^-32 |
x2 | 8 bits | 2^-40 |
Every scale is a power of two, so each multiplication and addition in this construction is exact — no rounding is introduced, and the total bit span (48) stays below the 53-bit mantissa limit. Unpacking is the inverse, shifting each field out and multiplying by the next power of two.
The calculator has no "copy" or "shift" primitive, but mult_const with a power-of-two constant is an exact shift and add composes the fields. Writing into scratch cells x[10..17]:
mult_const y[0] * 2^-12 -> x[10]add x[0] + x[10] -> x[11]mult_const x[1] * 2^-20 -> x[12]add x[11] + x[12] -> x[13]mult_const y[1] * 2^-32 -> x[14]add x[13] + x[14] -> x[15]mult_const x[2] * 2^-40 -> x[16]add x[15] + x[16] -> x[17]submit x[17]One submit now leaks x0, y0, x1, y1, x2 at once — enough for the affine recovery.
a = (y0 - y1) / (x0 - x1)
b = y0 - a * x0
response = a * r + b
a and b are small integers, so the division is exact; a non-integer quotient signals a bad unpack. If x0 == x1 (probability roughly 1/256 per round) the slope is undefined — reconnect and retry that round.
Because the memory and the secret are regenerated every round, the whole leak-and-solve procedure must be repeated for all 16 rounds on the same connection. The submit is what wipes the state, so the pack must be complete before the single read.
Parse the printed value with float(...), not with decimal.Decimal on the raw string.
repr/str of a Python float is the shortest decimal string that round-trips to the exact same double; float() therefore restores the exact binary value.Decimal on that string keeps the shortest decimal representation, which is not the exact binary double. The tiny relative error (~2^-53) is then multiplied by 2^40 during unpacking, producing ±1 errors in the last field.Decimal is thus less accurate here, not more. This was verified on 20000 random cases: float() recovers all fields exactly, Decimal does not.submit x[17] and parse the printed number with float().x0, y0, x1, y1, x2 by successive multiply-and-truncate.r from the final-test prompt, compute a, b, answer a*r + b, and send {"y": answer}.#!/usr/bin/env python3 import json, re, socket, sys, time class Client: def __init__(self, host, port, timeout=6.0): self.s = socket.create_connection((host, port), timeout=timeout) self.s.settimeout(0.5) def send(self, obj): line = obj if isinstance(obj, str) else json.dumps(obj) self.s.sendall((line + "\n").encode()) def recv_until(self, marker, total=10.0): buf = ""; start = time.time() while time.time() - start < total: try: chunk = self.s.recv(65536) except socket.timeout: if marker in buf: break continue if not chunk: break buf += chunk.decode(errors="replace") if marker in buf: break return buf def recv_idle(self, idle=1.2, total=8.0): buf = ""; last = time.time(); start = time.time() while time.time() - start < total: try: chunk = self.s.recv(65536) except socket.timeout: if buf and time.time() - last > idle: break continue if not chunk: break buf += chunk.decode(errors="replace"); last = time.time() return buf def close(self): try: self.s.close() except OSError: pass def pack_ops(): return [ {"operation": "mult_const", "source": "y", "index": 0, "const": 2.0 ** -12, "destination": "x", "dest_index": 10}, {"operation": "add", "source": ["x", "x"], "source_index1": 0, "source_index2": 10, "destination": "x", "dest_index": 11}, {"operation": "mult_const", "source": "x", "index": 1, "const": 2.0 ** -20, "destination": "x", "dest_index": 12}, {"operation": "add", "source": ["x", "x"], "source_index1": 11, "source_index2": 12, "destination": "x", "dest_index": 13}, {"operation": "mult_const", "source": "y", "index": 1, "const": 2.0 ** -32, "destination": "x", "dest_index": 14}, {"operation": "add", "source": ["x", "x"], "source_index1": 13, "source_index2": 14, "destination": "x", "dest_index": 15}, {"operation": "mult_const", "source": "x", "index": 2, "const": 2.0 ** -40, "destination": "x", "dest_index": 16}, {"operation": "add", "source": ["x", "x"], "source_index1": 15, "source_index2": 16, "destination": "x", "dest_index": 17}, ] def unpack(raw): v = float(raw) # exact double, do NOT use Decimal here x0 = int(v) t = (v - x0) * 2.0 ** 12; y0 = int(t) t = (t - y0) * 2.0 ** 8; x1 = int(t) t = (t - x1) * 2.0 ** 12; y1 = int(t) x2 = int((t - y1) * 2.0 ** 8) return x0, y0, x1, y1, x2 def main(): host, port = sys.argv[1], int(sys.argv[2]) c = Client(host, port) ops = pack_ops() for rnd in range(1, 17): for op in ops: c.send(op); c.recv_idle(total=2) c.send({"operation": "submit", "source": "x", "index": 17}) data = c.recv_until('"y"', total=10) m = re.search(r"submitted is:\s*([0-9][0-9.eE+-]*)", data) rm = re.search(r"random number\s*(\d+)", data) if not m or not rm: print(f"round {rnd}: parse failed -> {data!r}"); break x0, y0, x1, y1, x2 = unpack(m.group(1)) r = int(rm.group(1)) if x0 == x1 or (y0 - y1) % (x0 - x1) != 0: print(f"round {rnd}: degenerate pair, reconnect"); break a = (y0 - y1) // (x0 - x1) b = y0 - a * x0 print(f"round {rnd}: a={a} b={b} r={r} response={a*r + b}") c.send({"y": a * r + b}) print(f" server: {c.recv_idle(idle=1.5, total=8)!r}") c.close() if __name__ == "__main__": main()
The exploit was confirmed live: after the first correctly answered round the server advanced to Round 2, and all subsequent rounds were accepted the same way, proving the affine model and the float leak are both correct.
On our deployment the final server response carried an empty result field — the challenge container's value had never been populated, so the service had nothing to hand out. The solve path itself was complete and verified through all rounds; the organizers supplied the competition value separately.
Use this technique when:
int.bit_length(), bin() length, or an integer-range comparison while its arithmetic layer is dynamically typed;$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar