← Back to portfolioOpen in the interactive reader
Challenge card for Old Calculator as shown on the CTF platform: category, points, the statement and the connection command.

Securinets CTF Qualifications 2026 · · 1.15 h read

Old Calculator

Flags are blurred: hover or focus one to reveal it.

Author: gha32a | CTF: Securinets CTF Qualifications 2026 | Category: Cryptography | Points: 436 | Solves: 65 | Date: October 19, 2026

Tools: Python 3 (socket, json, re, math), netcat, a local fake server for self-testing

TL;DR

A remote "calculator" lets me send additions and multiplications on two tables of 100 cells, then reveal one cell before it asks me for F(r), where F(x) = a·x + b is a secret affine map redrawn every round (a, b ∈ [1, 16]). The y table holds F(x_i) but is too large to display, and there is no subtraction and no division.

The break: the server accepts float constants and prints floats in full. That gives me division (through Newton's iteration, which only needs + and ×) and lets one revealed number carry both secrets, L = a + b/64. Each round I send 173 operations that compute L from the first 8 pairs, reveal it, decode (a, b), and answer a·r + b. The final solver passed all 16 rounds on its first run.

fig_01

What the server holds

Scatter plot of the eight pairs of the example round on the hidden line y = 9x + 6. Only the point at x = 24 (y = 222) is below the display limit of 256; the other seven reach y = 1824.
The server holds eight points on a hidden line. Two unknowns, a and b, decide everything, and y itself is too large to display.

Challenge

Service: nc calculator-crypto.securinets.tn 4873

The story: a 4-bit calculator was upgraded to 8 bits with 200 bytes of memory (100 for inputs, 100 for results), and "the calculator erases its entire history as soon as I display the results". In practice this means one reveal per round.

Round structure

A connection runs 16 rounds. In each round:

  1. The tables are filled: x with random bytes and y_i = F(x_i) with the round's secret F.
  2. I may send any number of operations (one JSON object per line). The server answers {"result": "Operation successful"} and prompts again with Enter JSON:.
  3. I send one submit. The server prints that cell, then runs the "final test": it gives a random number r and expects {"y": F(r)}.
  4. A correct answer starts the next round. A wrong one returns an error and closes the connection. After round 16 the server prints the flag.

Operations

The ReadMe lists four operations and submit. Its inline comments do not match the real JSON, so the JSON below is what counts:

json
{"operation":"add","source":["x","y"],"source_index1":0,"source_index2":1,"destination":"y","dest_index":2}
{"operation":"mult","source":["x","y"],"source_index1":0,"source_index2":1,"destination":"y","dest_index":2}
{"operation":"add_const","source":"x","index":0,"const":10,"destination":"y","dest_index":1}
{"operation":"mult_const","source":"y","index":1,"const":5,"destination":"x","dest_index":5}
{"operation":"submit","source":"x","index":0}

The destination can be any cell of x or y, including one of its own sources, so a cell can be updated in place. There is no operation that writes a constant directly: a constant k is built as 0·c + k (mult_const by 0, then add_const).

Messages the server can send

Server message When
Incorrect value submitted. Expected E, but got G wrong answer to the final test, the connection closes
Value at y[0] is 11 bits, which cannot be represented as 8-bit submit on a cell that is too large
Value at x[1] is negative, which cannot shown for performance reasons submit on a negative cell
Unsupported operation / Unknown operation operation name not in the list
Missing 'y' in input answer without the y field
'<=' not supported between instances of 'int' and 'str' a string passed as a constant (Python exception)

That last message is the first hint that the backend is Python.

1. Reconnaissance

Four small probe scripts (probe.py to probe4.py) each answered one question.

1.1 A wrong answer leaks the expected value

Answering the final test with a wrong number returns something like Expected 2446, but got 0. The expected value is an ordinary integer, not reduced mod 256. I collected twelve pairs (r, F(r)), each from a different connection:

r F(r) a b r F(r) a b
17 104 6 2 113 126 1 13
27 119 4 11 115 125 1 10
35 495 14 5 149 305 2 7
41 256 6 10 163 328 2 2
45 502 11 7 169 2536 15 1
55 290 5 15 244 2446 10 6

Every pair fits F(r) = a·r + b with 1 ≤ a, b ≤ 16 (later probes also produced a = 16 and b = 16). Because b < r in all of these samples, divmod(F(r), r) gives (a, b) directly.

The pairs cannot lie on a single line (r = 113 gives 126 but r = 115 gives 125), so the coefficients change with each connection and, as the final run showed, with each round.

1.2 Tables and arithmetic

  • y follows x. A revealed y cell was always consistent with y_i = a·x_i + b for the same (a, b). For example a leak of 129 with (a, b) = (9, 3) gives x_i = 14, an integer. Setting x0 to zero did not change y0, so y is filled once at the start of the round.
  • y cannot be read directly. Most y cells need 9 to 12 bits, and submit refuses them with the 8-bit check.
  • The server computes on unbounded numbers. The 8-bit limit only applies to what submit displays. Intermediate cells can be larger (11 to 12 bits seen) without any error.
  • Negative values can be stored but not displayed. mult_const by −1 works, and the result can feed later operations.
  • No hidden operations. sub, div, mod, floordiv, pow, xor, and, or, shl, shr, neg and their _const forms are all rejected.

The goal is clear but tight: determine one of 256 possibilities for (a, b) while seeing a single non-negative number below 256, with no subtraction and no division.

2. First attempt: guessing (a, b)

There are 256 possible pairs, so a fixed guess is right with probability 1/256. solve1.py fixed a guess, answered every round with it, and after each failure read the leaked Expected value to learn that connection's (a, b) and update the most frequent guess.

It passed round 1 after roughly 330 connections with the guess (16, 2). Round 2 then asked for r = 118 and expected 479, which is (a, b) = (4, 7): the pair is redrawn every round. Passing all 16 rounds by luck has probability

text
(1/256)^16 = 2^-128

so guessing is hopeless. solve1.py is only a record of the dead end.

3. The key observation: floats

probe4.py set y2 = 7 and applied constants of different types:

Test Result
mult_const with 0.5 on 7 leaked 3.5
mult_const with 2.0 on 7 leaked 14.0
add_const with 0.5 on 7 leaked 7.5
mult_const with the string "2" Python exception ('<=' not supported ...)

Three consequences:

  1. Division becomes possible. Multiplying by 1/K or by an approximate inverse gives non-integer cells, and the server stores and prints them.
  2. One revealed number can carry many bits. The submit check wants a non-negative value below 256, not an integer. A double-precision float carries 53 bits, so two small integers can be packed into one number and unpacked on my side.
  3. The backend is Python, and constants are compared with <=, but floats are not rejected.

I did not see the server source. The exact submit check (bit length of the integer part, a range test, or both) is inferred from the error messages. The attack only relies on what I observed: a float below 256 is printed in full.

4. The mathematics of the attack

4.1 Setting

Take the first K = 8 cells. I know x0..x7 and y0..y7 as values inside the server, even though I cannot see y_i. They satisfy y_i = a·x_i + b exactly. I want a and b computed inside the server, using only + and × of cells and constants.

4.2 Closed form from four sums

Define four sums over the K pairs:

text
Sx  = Σ x_i        Sy  = Σ y_i
Sxx = Σ x_i²       Sxy = Σ x_i·y_i

Each one is a chain of add (and mult for the products). Substituting y_i = a·x_i + b:

text
Sy  = a·Sx  + K·b
Sxy = a·Sxx + b·Sx

Multiply the second line by K, the first by Sx, and subtract. The K·b·Sx terms cancel:

text
K·Sxy − Sx·Sy = a·(K·Sxx − Sx²)

so

text
a = N / D        with  N = K·Sxy − Sx·Sy   and   D = K·Sxx − Sx²
b = (Sy − a·Sx) / K

D is a variance in disguise:

text
D = K·Sxx − Sx² = K · Σ (x_i − mean)²  ≥ 0

It is zero only if all eight x_i are equal (probability about 256⁻⁷, negligible), so D > 0 in practice. I never need a subtraction operation: −u is mult_const by −1 followed by an add.

Why eight pairs and not two? With two pairs, a = (y0 − y1)/(x0 − x1) is enough in theory. But x0 = x1 happens with probability 1/256, which would make the denominator zero in about 1 − (255/256)^16 ≈ 6% of 16-round runs. The sums over eight pairs remove that risk and keep the denominator non-negative by construction. Also 1/K = 1/8 is a power of two, so mult_const by 0.125 is exact in binary floating point.

4.3 Division with only + and ×: Newton's iteration

To compute 1/D without a division operator, iterate

text
z_{n+1} = z_n · (2 − D·z_n)

Let e_n = 1 − D·z_n. Then

text
e_{n+1} = 1 − D·z_n·(2 − D·z_n) = (1 − D·z_n)² = e_n²

The error squares at every step, so the iteration converges (quadratically) exactly when |e_0| < 1, that is 0 < z_0·D < 2. I start from the constant z_0 = 2⁻²².

Bounds on D for K = 8 bytes:

Case Bytes D z0·D
Largest four at 0, four at 255 8 · 8 · 127.5² = 1 040 400 0.248, inside (0, 2)
Smallest nonzero seven equal, one differing by 1 7 about 1.7·10⁻⁶, inside (0, 2)

So z0 = 2⁻²² is inside the convergence region for every possible input.

How many iterations? When e_n is close to 1, write e_n = 1 − ε. Then e_{n+1} = 1 − 2ε + ε² ≈ 1 − 2ε: the small number ε = D·z_n doubles at each step. From ε_0 ≈ 1.7·10⁻⁶ it takes about 19 steps to reach about 1/2, then a handful of squarings to fall below the floating-point floor. Figure 2 shows this on three values of D, computed with the same double-precision arithmetic the server uses.

fig_02

Newton's iteration finds 1/D with only + and ×

Log-scale chart; the error drops below 1e-15 at n=25 for D=7 and n=7 for D=1040400.
Error of the iteration for three values of D.

I ran the iteration for every D from 7 to 2999: the worst case is always D = 7 with 25 iterations to get under 1e-15, and bigger D converge faster. The solver uses ITERS = 28, a margin of three over the worst case.

Finally a = N · z with z ≈ 1/D. Since N = a·D exactly, the product equals a up to floating-point error of order 10⁻¹⁵.

4.4 Exactness of the integer parts

Sx, Sy, Sxx, Sxy, N and D are integers far below 2⁵³ ≈ 9·10¹⁵. For instance y ≤ 16·255 + 16 = 4096, so Sxy ≤ 8·255·4096 ≈ 8.4·10⁶, and N = a·D ≤ 16 · 1 040 400 ≈ 1.7·10⁷. The server computes all of them exactly. Floating-point error only appears in the Newton iteration and in the last few operations.

4.5 Packing a and b into one revealed number

I reveal

text
L = a + b/64          with 1 ≤ a, b ≤ 16

Then L ∈ [1 + 1/64, 16.25]: non-negative and below 256, so submit accepts it. Decoding with floating-point noise ε:

text
a = floor(L)
b = round( 64 · (L − a) )

This is correct as long as the fractional part of L stays inside (0, 1) and 64·|ε| < 1/2:

  • The divisor must exceed 16. With 16, the value b = 16 would give a fractional part of exactly 1 and be confused with a + 1. With 64, the fractional part is at most 16/64 = 0.25.
  • Since b ≥ 1, the fractional part is at least 1/64 ≈ 0.0156, far above the noise, so the floor never lands one unit too low.
fig_03

Packing a and b into one number

Number line of the fractional part of L = a + b/64 with sixteen windows, one per value of b from 1 to 16, each 1/64 wide, so noise must stay under 1/128 to decode correctly. The region after b = 16 is unused.
b sits in the fractional part (steps of 1/64), a in the integer part, so one float carries both.

4.6 The whole pipeline

fig_04

From the eight pairs to the answer

Flow diagram. Inside the server, 173 operations compute four sums, N and D, 28 Newton iterations, a, b and L, then submit reveals L. On our side, Python decodes a and b and answers a times r plus b.
Everything up to submit runs inside the server; decoding and answering happen on my side.

5. Code walkthrough (solve2.py)

The complete source is in the appendix. This section explains each part.

5.1 Constants

python
HOST, PORT = "calculator-crypto.securinets.tn", 4873
K = 8            # number of pairs used (power of two -> 1/K is exact)
ITERS = 28       # Newton iterations
Z0 = 2.0 ** -22  # initial estimate of 1/D (D <= ~1e6)

K is a power of two so that 1/K is exact. ITERS and Z0 come from the convergence analysis above.

5.2 A tiny program builder

The solver does not send operations as it thinks of them. It first builds the full list of JSON operations in a Prog object, then replays that same list every round. Temporary cells are handed out from y20 upward, so they never overwrite the inputs y0..y7.

python
class Prog:
    def __init__(self):
        self.ops, self.n = [], 0

    def tmp(self):
        c = ("y", 20 + self.n)  # next free scratch cell
        self.n += 1
        return c

    def add(self, a, b, d):     # d <- a + b  (cell + cell)
        self.ops.append({"operation": "add", "source": [a[0], b[0]],
                         "source_index1": a[1], "source_index2": b[1],
                         "destination": d[0], "dest_index": d[1]})
    # mult (cell * cell), addc (cell + const) and multc (cell * const) are analogous

A "cell" is a pair ("x", i) or ("y", i). The destination may equal a source, which the Newton loop uses for z ← z · u.

5.3 build() step by step

Sums. P is a scratch cell holding one product at a time.

python
Sx, Sy, Sxx, Sxy, P = [p.tmp() for _ in range(5)]
p.add(X(0), X(1), Sx)
p.add(Y(0), Y(1), Sy)
for i in range(2, K):
    p.add(Sx, X(i), Sx)               # Sx += x[i]
    p.add(Sy, Y(i), Sy)               # Sy += y[i]
p.mult(X(0), X(0), Sxx)
p.mult(X(0), Y(0), Sxy)
for i in range(1, K):
    p.mult(X(i), X(i), P); p.add(Sxx, P, Sxx)   # Sxx += x[i]^2
    p.mult(X(i), Y(i), P); p.add(Sxy, P, Sxy)   # Sxy += x[i]*y[i]

Numerator and denominator. Subtraction is "multiply by −1, then add":

python
A1, A2, N, B1, B2, Dn = [p.tmp() for _ in range(6)]
p.multc(Sxy, K, A1); p.mult(Sx, Sy, A2); p.multc(A2, -1, A2); p.add(A1, A2, N)
p.multc(Sxx, K, B1); p.mult(Sx, Sx, B2); p.multc(B2, -1, B2); p.add(B1, B2, Dn)

The first line computes N = K·Sxy + (−1)·(Sx·Sy), the second D = K·Sxx + (−1)·Sx².

Newton iteration. There is no "set constant" operation, so the initial value is built as 0·x0 + 2⁻²²:

python
Z, T, U = [p.tmp() for _ in range(3)]
p.multc(X(0), 0, Z); p.addc(Z, Z0, Z)     # Z = 0*x[0] + 2**-22  (a constant)
for _ in range(ITERS):
    p.mult(Dn, Z, T); p.multc(T, -1, U); p.addc(U, 2, U); p.mult(Z, U, Z)

One loop pass computes T = D·z, U = 2 − T, then z ← z·U, which is exactly z·(2 − D·z).

Recovering a and b, then packing:

python
A = p.tmp()
p.mult(N, Z, A)                           # a (float, close to an integer)
Bf, C = p.tmp(), p.tmp()
p.multc(Sy, 1.0 / K, Bf); p.mult(A, Sx, C); p.multc(C, -1.0 / K, C); p.add(Bf, C, Bf)   # b
L = p.tmp()
p.multc(Bf, 1.0 / 64, T); p.add(A, T, L)  # L = a + b/64
return p.ops, L

The line for b computes Sy/K + (−1/K)·(a·Sx) = (Sy − a·Sx)/K. The last line reuses T as a scratch cell for b/64.

5.4 Cell map and operation count

Cell Name Content
y20 Sx Sx
y21 Sy Sy
y22 Sxx Sxx
y23 Sxy Sxy
y24 P scratch for one product
y25, y26 A1, A2 K·Sxy and −Sx·Sy
y27 N numerator N
y28, y29 B1, B2 K·Sxx and −Sx²
y30 Dn denominator D
y31 Z Newton iterate, ends at about 1/D
y32, y33 T, U scratch (D·z and 2 − D·z); T is reused for b/64
y34 A a
y35, y36 Bf, C b and the intermediate −a·Sx/K
y37 L the revealed value a + b/64
fig_05

Where the 173 operations go

Horizontal bars of operations per stage: four sums 44, N and D 8, Newton 114, a 1, b 4, L 2. Total 173 per round, 2768 over 16 rounds.
Newton dominates: four operations per iteration, 28 iterations.
Stage Operations
Sx, Sy 14
Sxx, Sxy 30
N and D 8
Initial constant z0 2
Newton (28 × 4) 112
a 1
b 4
L 2
Total per round 173

By type, counted from the generated list: 76 mult, 36 mult_const, 32 add, 29 add_const.

5.5 Decoding

python
def decode(L):
    a = math.floor(L)
    b = round((L - a) * 64)
    if b >= 64:          # safety net, never triggered in practice
        a, b = a + 1, 0
    return int(a), int(b)

5.6 Local self-test

python3 solve2.py --selftest replays the exact operation list on a local copy of the tables, with a few lines that mimic the server (add, mult, add_const, mult_const on Python numbers). It covers all 256 pairs (a, b) plus random ones, with random bytes in x, and checks both that L ∈ [0, 255] and that decode(L) returns (a, b). This catches arithmetic and cell-indexing mistakes before the first network connection.

console
gha32a@calculator:~/ctf$ python3 solve2.py --selftest
173 operations per round
selftest: 3000/3000 OK

5.7 Network layer

python
def send_ops(s, ops, chunk=40):
    for i in range(0, len(ops), chunk):
        part = ops[i:i + chunk]
        s.sendall(b"".join(json.dumps(o).encode() + b"\n" for o in part))
        ...
        while got < len(part) and time.time() - t0 < 15:
            data = s.recv(65536)
            ...
            if '"error"' in buf:
                raise RuntimeError("server error: " + buf)
            got = buf.count('"result"')

(Abridged.) The 173 operations go out in chunks of 40 lines, which pipelines them instead of paying one round trip each. After each chunk the script counts the "result" replies until it has one per operation, and aborts immediately if an "error" appears. read_until reads from the socket until a given marker (Enter JSON or number }) shows up or a timeout expires.

5.8 Main loop

python
for rnd in range(1, 40):
    send_ops(s, ops)                                       # compute L inside the server
    leak = {"operation": "submit", "source": Lcell[0], "index": Lcell[1]}
    s.sendall(json.dumps(leak).encode() + b"\n")
    txt = read_until(s, ["number }"], 8)                   # "...submitted is: L ... random number r ..."
    Lv, r = float(mL.group(1)), int(mr.group(1))           # parsed with two regexes
    a, b = decode(Lv)
    s.sendall(json.dumps({"y": a * r + b}).encode() + b"\n")
    txt2 = read_until(s, ["Enter JSON"], 8)
    ...                                                    # failure, next round, or final message

Each round: compute L on the server, reveal it, decode, answer. After round 16 the server sends the flag instead of a new prompt, which is how the loop detects the end. The loop never uses the leaked Expected value: that leak was only needed during reconnaissance to discover the form of F.

6. Worked example of one round

Take the secret (a, b) = (9, 6) and the first eight bytes of x:

text
x0..7 = (165, 77, 202, 24, 37, 48, 187, 29)
y0..7 = (1491, 699, 1824, 222, 339, 438, 1689, 267)

(These y values are 9·x_i + 6; the solver never sees them.) I replayed the 173 operations on exactly these numbers. The server cells end up as:

Quantity Value
Sx 769
Sy 6969
Sxx 114 017
Sxy 1 030 767
N = 8·Sxy − Sx·Sy 2 886 975
D = 8·Sxx − Sx² 320 775

Newton gives z = 3.1174499259605643·10⁻⁶ ≈ 1/320775, so a = N·z = 9.0. Then b = (6969 − 9·769)/8 = 6 and

text
L = 9 + 6/64 = 9.09375

I reveal L, compute floor(9.09375) = 9 and round(64 · 0.09375) = 6, and answer 9·r + 6.

In the real run the leaked values carried float noise, as expected, for example 2.156250000000001 for (2, 10) or 13.031250000000002 for (13, 2). Decoding absorbs it because the noise is about 10⁻¹⁵ while the rounding margin is 1/128.

7. Result

One connection, 16 rounds, no retry:

console
gha32a@calculator:~/ctf$ python3 solve2.py
round 1 OK (a,b)=(2,10) L=2.156250000000001
round 2 OK (a,b)=(3,1) L=3.015625
round 3 OK (a,b)=(11,15) L=11.234375
...
round 16 OK (a,b)=(9,2) L=9.031249999999998

=== FINAL REPLY ===
{"flag": "Securinets{...}"}
Round (a, b) Leaked L Float error
1 (2, 10) 2.156250000000001 8.9e-16
2 (3, 1) 3.015625 exact
3 (11, 15) 11.234375 exact
4 (13, 2) 13.031250000000002 1.8e-15
5 (1, 9) 1.140625 exact
6 (12, 2) 12.031249999999998 1.8e-15
7 (14, 13) 14.203124999999995 5.3e-15
8 (2, 15) 2.234375 exact
9 (6, 2) 6.03125 exact
10 (10, 2) 10.03125 exact
11 (5, 16) 5.25 exact
12 (11, 11) 11.171875000000002 1.8e-15
13 (15, 12) 15.187500000000005 5.3e-15
14 (3, 4) 3.0625 exact
15 (9, 6) 9.093750000000002 1.8e-15
16 (9, 2) 9.031249999999998 1.8e-15
fig_06

The float noise never gets near the decoding margin

Bar chart on a log scale of the float error in each of the 16 rounds. The largest error is about 5.3e-15, against a decoding margin of 1/128, about 7.8e-3.
All 16 rounds decoded correctly; a bar labelled 0 means the revealed value was exact.

Flag

text
Securinets{ed9a482110a5a6936482e700155f811742f97a04decfa75d3cee28bca77239c1}

Solve chain at a glance

Step Action
Recon wrong answers leak Expected; twelve pairs decompose as a·r + b with a, b ∈ [1, 16]
Dead end guessing (a, b) succeeds with probability 2⁻¹²⁸ over 16 rounds
Key observation float constants are accepted and floats are printed in full
Closed form four sums over 8 pairs give a = N/D, b = (Sy − a·Sx)/8
Division Newton's iteration z ← z(2 − D·z), 28 iterations, only + and ×
Packing reveal L = a + b/64, one float below 17
Decode a = floor(L), b = round(64·(L − a)), answer a·r + b
Result 16/16 rounds, 173 operations per round

Variations I considered

These are extensions of the same idea. Only the first one was checked numerically.

  • Larger coefficients (a, b ≤ 255). Pack as a fixed-point number: L = (256·a + b)/512. Since 256·a + b < 65536, L < 128. Decode m = round(512·L), then a = m // 256 and b = m % 256. For example a = 200, b = 37 gives L = 100.072265625 and decodes back. Dividing by a power of two keeps the scaling exact.
  • Quadratic F(x) = a·x² + b·x + c. Three unknowns need sums up to Σ x⁴ and the 3×3 normal equations, solved by Cramer's rule: every numerator and the determinant are polynomials in the sums (only + and ×), followed by one Newton reciprocal. Pack as L = a + b/64 + c/4096 if each value is small enough. This is a sketch, not an implementation.
  • More noise or fewer bits of precision. The decode margin would have to be revisited: choose a larger gap between packed fields and fewer fields per number.

Notes and assumptions

  • I did not see the server source or the official solution. The float route may or may not be the intended one.
  • The exact rule behind the 8-bit submit check is inferred from error messages and from the fact that floats such as 3.5 were displayed.
  • My first approach (guessing) failed, and the reconnaissance phase is how I learned the structure of the service. The message formats in the Challenge section come from those probes.

Lessons and hardening

For solvers

  • Compare the information you need with the channel you have: here 8 bits were needed against a channel that looked like 8 bits. Probing the input validation (floats, negatives, strings) showed that the real channel was much wider.
  • Error messages are oracles. Expected revealed the model, and the bit-length and sign errors revealed the display rules.
  • Probe first, attack second, and test the arithmetic locally before touching the server.

For the challenge author

  • Reject non-integer constants, or wrap every operation mod 256 as the story promises. The machine is only "8-bit" at the display.
  • Do not return the expected value after a wrong answer.

What I learned

  • Closed-form least squares over a few exact pairs removes a whole class of unlucky inputs compared with a two-point formula.
  • Newton's iteration turns a division into repeated + and ×, and a quick convergence analysis (error squaring, bounds on D) tells exactly how many iterations are enough.
  • A float is a 53-bit channel: packing two small integers into the integer and fractional parts is simple if the fields are separated by more than the noise.
  • A local fake server that replays the exact operation list catches indexing and arithmetic mistakes before the first network connection.

Appendix A: reconnaissance scripts

Script Purpose
probe.py First contact: banner, submit on x0 and y0, behaviour after submit. Showed that the final test asks for F(r) and that submit on y needs a different field.
probe2.py Wrong answers on purpose: revealed the Expected value (twelve (r, F(r)) pairs), the 8-bit refusal on y0, and that an error closes the connection.
probe3.py 27 connections: y cells at several indices, x at several indices, x0 set to zero, in-place arithmetic, negative constants. Established y_i = a·x_i + b and the display rules.
probe4.py Hidden operations (all rejected), float and string constants (floats accepted), structure of x (no visible structure). Led to the float idea.
solve1.py Probabilistic guessing, failed at round 2.

Appendix B: full solver source

python
#!/usr/bin/env python3
"""Old Calculator (Securinets CTF Quals 2026): exact solver.

Idea: the server accepts float constants and prints floats in full.
  - y[i] = a*x[i] + b  (a, b integers in [1..16], redrawn every round)
  - least squares on K pairs -> a = N/D, b = (Sy - a*Sx)/K
  - the division N/D is done with Newton's iteration (only * and +)
  - we reveal L = a + b/64 (a float <= 16.25), decode a and b, then answer
    a*r + b to the random number r.

Usage:
  python3 solve2.py --selftest   # check the math locally (no network)
  python3 solve2.py              # attack the server
"""
import socket, json, re, time, math, random, sys

HOST, PORT = "calculator-crypto.securinets.tn", 4873
K = 8            # number of pairs used (power of two -> 1/K is exact)
ITERS = 28       # Newton iterations
Z0 = 2.0 ** -22  # initial estimate of 1/D (D <= ~1e6)


class Prog:
    """Builds the list of JSON operations; cells are ("x", i) or ("y", i)."""

    def __init__(self):
        self.ops, self.n = [], 0

    def tmp(self):
        c = ("y", 20 + self.n)  # scratch cells start at y20, away from y0..y7
        self.n += 1
        return c

    def add(self, a, b, d):
        self.ops.append({"operation": "add", "source": [a[0], b[0]],
                         "source_index1": a[1], "source_index2": b[1],
                         "destination": d[0], "dest_index": d[1]})

    def mult(self, a, b, d):
        self.ops.append({"operation": "mult", "source": [a[0], b[0]],
                         "source_index1": a[1], "source_index2": b[1],
                         "destination": d[0], "dest_index": d[1]})

    def addc(self, a, c, d):
        self.ops.append({"operation": "add_const", "source": a[0], "index": a[1],
                         "const": c, "destination": d[0], "dest_index": d[1]})

    def multc(self, a, c, d):
        self.ops.append({"operation": "mult_const", "source": a[0], "index": a[1],
                         "const": c, "destination": d[0], "dest_index": d[1]})


def build():
    p = Prog()
    X = lambda i: ("x", i)
    Y = lambda i: ("y", i)

    # four sums over the first K pairs
    Sx, Sy, Sxx, Sxy, P = [p.tmp() for _ in range(5)]
    p.add(X(0), X(1), Sx)
    p.add(Y(0), Y(1), Sy)
    for i in range(2, K):
        p.add(Sx, X(i), Sx)
        p.add(Sy, Y(i), Sy)
    p.mult(X(0), X(0), Sxx)
    p.mult(X(0), Y(0), Sxy)
    for i in range(1, K):
        p.mult(X(i), X(i), P); p.add(Sxx, P, Sxx)
        p.mult(X(i), Y(i), P); p.add(Sxy, P, Sxy)

    # numerator N = K*Sxy - Sx*Sy and denominator D = K*Sxx - Sx^2
    A1, A2, N, B1, B2, Dn = [p.tmp() for _ in range(6)]
    p.multc(Sxy, K, A1); p.mult(Sx, Sy, A2); p.multc(A2, -1, A2); p.add(A1, A2, N)
    p.multc(Sxx, K, B1); p.mult(Sx, Sx, B2); p.multc(B2, -1, B2); p.add(B1, B2, Dn)

    # Newton iteration for 1/D: z <- z * (2 - D*z)
    Z, T, U = [p.tmp() for _ in range(3)]
    p.multc(X(0), 0, Z); p.addc(Z, Z0, Z)   # Z = 0*x0 + 2**-22 (a constant)
    for _ in range(ITERS):
        p.mult(Dn, Z, T); p.multc(T, -1, U); p.addc(U, 2, U); p.mult(Z, U, Z)

    # a = N * z, b = (Sy - a*Sx) / K, and the packed value L = a + b/64
    A = p.tmp()
    p.mult(N, Z, A)  # a (float, close to an integer)
    Bf, C = p.tmp(), p.tmp()
    p.multc(Sy, 1.0 / K, Bf); p.mult(A, Sx, C); p.multc(C, -1.0 / K, C); p.add(Bf, C, Bf)  # b
    L = p.tmp()
    p.multc(Bf, 1.0 / 64, T); p.add(A, T, L)  # L = a + b/64
    return p.ops, L


def decode(L):
    a = math.floor(L)
    b = round((L - a) * 64)
    if b >= 64:  # safety net, never triggered in practice
        a, b = a + 1, 0
    return int(a), int(b)


# ---------------- local test (fake server) ----------------
def selftest(trials=3000):
    ops, L = build()
    print(f"{len(ops)} operations per round")
    bad = 0
    for t in range(trials):
        a, b = random.randint(1, 16), random.randint(1, 16)
        if t < 256:
            a, b = t // 16 + 1, t % 16 + 1  # every (a, b) pair
        x = [random.randint(0, 255) for _ in range(100)]
        tabs = {"x": x[:], "y": [a * v + b for v in x]}
        for op in ops:
            g = lambda tab, i: tabs[tab][i]
            o = op["operation"]
            if o in ("add", "mult"):
                u = g(op["source"][0], op["source_index1"])
                v = g(op["source"][1], op["source_index2"])
                val = u + v if o == "add" else u * v
            else:
                u = g(op["source"], op["index"])
                val = u + op["const"] if o == "add_const" else u * op["const"]
            tabs[op["destination"]][op["dest_index"]] = val
        Lv = tabs[L[0]][L[1]]
        if not (0 <= Lv <= 255) or decode(Lv) != (a, b):
            bad += 1
            print("FAIL", a, b, Lv, decode(Lv))
    print(f"selftest: {trials - bad}/{trials} OK")


# ---------------- network ----------------
def read_until(s, pats, timeout=5.0):
    s.settimeout(timeout)
    buf = b""
    t0 = time.time()
    while time.time() - t0 < timeout:
        try:
            chunk = s.recv(65536)
        except socket.timeout:
            break
        if not chunk:
            buf += b"<CLOSED>"
            break
        buf += chunk
        if any(p in buf.decode(errors="replace") for p in pats):
            break
    return buf.decode(errors="replace")


def send_ops(s, ops, chunk=40):
    for i in range(0, len(ops), chunk):
        part = ops[i:i + chunk]
        s.sendall(b"".join(json.dumps(o).encode() + b"\n" for o in part))
        s.settimeout(10)
        got, buf = 0, ""
        t0 = time.time()
        while got < len(part) and time.time() - t0 < 15:
            data = s.recv(65536)
            if not data:
                raise RuntimeError("connection closed during operations: " + buf)
            buf += data.decode(errors="replace")
            if '"error"' in buf:
                raise RuntimeError("server error: " + buf)
            got = buf.count('"result"')


def attack():
    ops, Lcell = build()
    s = socket.create_connection((HOST, PORT), timeout=10)
    read_until(s, ["Enter JSON"], 5)
    for rnd in range(1, 40):
        send_ops(s, ops)
        leak = {"operation": "submit", "source": Lcell[0], "index": Lcell[1]}
        s.sendall(json.dumps(leak).encode() + b"\n")
        txt = read_until(s, ["number }"], 8)
        mL = re.search(r"submitted is: (\S+)", txt)
        mr = re.search(r"random number (\d+)", txt)
        if not (mL and mr):
            print("unexpected reply:", txt)
            return
        Lv, r = float(mL.group(1)), int(mr.group(1))
        a, b = decode(Lv)
        s.sendall(json.dumps({"y": a * r + b}).encode() + b"\n")
        txt2 = read_until(s, ["Enter JSON"], 8)
        if "Expected" in txt2 or "error" in txt2:
            print(f"FAIL round {rnd}: L={Lv} -> (a,b)=({a},{b}) r={r}\n{txt2}")
            return
        print(f"round {rnd} OK (a,b)=({a},{b}) L={Lv}")
        if "Enter JSON" not in txt2:
            print("\n=== FINAL REPLY ===")
            print(txt2)
            return
    print("end of loop")


if __name__ == "__main__":
    if "--selftest" in sys.argv:
        selftest()
    else:
        attack()