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.
What the server holds

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:
- The tables are filled:
xwith random bytes andy_i = F(x_i)with the round's secretF. - I may send any number of operations (one JSON object per line). The server answers
{"result": "Operation successful"}and prompts again withEnter JSON:. - I send one
submit. The server prints that cell, then runs the "final test": it gives a random numberrand expects{"y": F(r)}. - 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:
{"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 + bfor the same(a, b). For example a leak of 129 with(a, b) = (9, 3)givesx_i = 14, an integer. Settingx0to zero did not changey0, 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
submitrefuses them with the 8-bit check. - The server computes on unbounded numbers. The 8-bit limit only applies to what
submitdisplays. Intermediate cells can be larger (11 to 12 bits seen) without any error. - Negative values can be stored but not displayed.
mult_constby −1 works, and the result can feed later operations. - No hidden operations.
sub,div,mod,floordiv,pow,xor,and,or,shl,shr,negand their_constforms 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
(1/256)^16 = 2^-128so 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:
- Division becomes possible. Multiplying by
1/Kor by an approximate inverse gives non-integer cells, and the server stores and prints them. - One revealed number can carry many bits. The
submitcheck 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. - 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:
Sx = Σ x_i Sy = Σ y_i
Sxx = Σ x_i² Sxy = Σ x_i·y_iEach one is a chain of add (and mult for the products). Substituting y_i = a·x_i + b:
Sy = a·Sx + K·b
Sxy = a·Sxx + b·SxMultiply the second line by K, the first by Sx, and subtract. The K·b·Sx terms cancel:
K·Sxy − Sx·Sy = a·(K·Sxx − Sx²)so
a = N / D with N = K·Sxy − Sx·Sy and D = K·Sxx − Sx²
b = (Sy − a·Sx) / KD is a variance in disguise:
D = K·Sxx − Sx² = K · Σ (x_i − mean)² ≥ 0It 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
z_{n+1} = z_n · (2 − D·z_n)Let e_n = 1 − D·z_n. Then
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.
Newton's iteration finds 1/D with only + and ×

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
L = a + b/64 with 1 ≤ a, b ≤ 16Then L ∈ [1 + 1/64, 16.25]: non-negative and below 256, so submit accepts it. Decoding with floating-point noise ε:
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 = 16would give a fractional part of exactly 1 and be confused witha + 1. With 64, the fractional part is at most16/64 = 0.25. - Since
b ≥ 1, the fractional part is at least1/64 ≈ 0.0156, far above the noise, so the floor never lands one unit too low.
Packing a and b into one number

4.6 The whole pipeline
From the eight pairs to the answer

5. Code walkthrough (solve2.py)
The complete source is in the appendix. This section explains each part.
5.1 Constants
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.
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 analogousA "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.
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":
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⁻²²:
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:
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, LThe 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 |
Where the 173 operations go

| 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
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.
gha32a@calculator:~/ctf$ python3 solve2.py --selftest
173 operations per round
selftest: 3000/3000 OK5.7 Network layer
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
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 messageEach 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:
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
L = 9 + 6/64 = 9.09375I 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:
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 |
The float noise never gets near the decoding margin

Flag
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. Since256·a + b < 65536,L < 128. Decodem = round(512·L), thena = m // 256andb = m % 256. For examplea = 200, b = 37givesL = 100.072265625and 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 asL = a + b/64 + c/4096if 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
submitcheck is inferred from error messages and from the fact that floats such as3.5were 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.
Expectedrevealed 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
#!/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()