m0leconCTF2025 crypto/one-more-bit
🔐 crypto/one-more-bit
m0leconCTF 2025 – Crypto Challenge
Introduction
One More Bit was a crypto challenge worth 50 points. The subtitle gave the hint: “Approximate FHE was a mistake.”
It deals with an approximate homomorphic encryption scheme (CKKS-like), involving scaling and noise, and a JSON API that lets you encrypt two messages, evaluate squares, read decrypted bits… and guess which message the oracle chose in each round.
TL;DR
We exploit internal floating-point representation: the server “quantizes” a value m into int(round(m * 2^50)), then keeps only the lower 64 bits (two’s complement mask).
Therefore, m and m + 2^14 produce exactly the same 64-bit pattern (since 2^14 × 2^50 = 2^64).
So we send (m0, m1) = (1.0, 1.0 + 2^14) to create two plaintexts that are bitwise indistinguishable — except through the noise introduced by homomorphic operations (especially square).
We then measure bitwise “anomalies” returned by /decrypt and deduce the correct bit.
As a backup, a second strategy uses symmetry (x, -x) then square. The flag is recovered.
Challenge Description
TCP service:
nc one-more-bit.challs.m0lecon.it 24180
JSON protocol (one request/response per line)
Main commands:
encrypt {m0, m1} → state_indexeval {function: "square", indices: [i...]}→state_indexdecrypt {index, position}→ bit (0/1) for the requested positionguess {bit}→ round verdict
There are 100 rounds to complete, one binary guess per round.
Theory — Approximate FHE & Modulo Wrap
The server converts a float into bits via:
- fixed scale:
scale_bits = 50 - quantization:
q = round(m * 2^50) - reduction mod 2⁶⁴ (by masking) → 64-bit tuple
Immediate consequence: adding 2^14 before quantization adds 2^14 × 2^50 = 2^64, which is null modulo 2⁶⁴.
Hence, float_to_bits(m) == float_to_bits(m + 2^14).
In approximate FHE, noise grows under operations (especially square) and corrupts low bits — exactly the side channel we exploit.
Protocol Reconnaissance
We open a socket and read {"round": i}, then:
encrypt(m0, m1)→ index- run
evalpatterns (like square) to amplify noise - probe
decrypt(index, position)on several low bits; compare with expected bits and count anomalies guess(bit)using a simple heuristic: more noise = this candidate
Vulnerability & Attack Idea
Design bug: reduction modulo 2⁶⁴ after quantization makes vastly different real values bitwise indistinguishable.
Noise oracle: the encrypt → (square)* → decrypt(bit) pipeline leaks information correlated with the chosen message (m0 vs m1) through the density of bit errors on noise-sensitive positions.
Step-by-Step Exploitation
Strategy A — Magic Wrap (2¹⁴×2⁵⁰ = 2⁶⁴)
Choice: m0 = 1.0, m1 = 1.0 + 2^14
→ same 64 bits initially (thanks to wrap mod 2⁶⁴)
Actions:
- encrypt, then decrypt directly to measure baseline “natural” differences
- apply one or two squares (
eval square) - re-decrypt low 20 bits (the ones most sensitive to noise)
Decision:
If the number of anomalies exceeds a threshold, choose 0; otherwise 1.
(Empirically tuned heuristic — works very well.)
Strategy B — Symmetry ±x then Square
Choice: (m0, m1) = (+100.0, -100.0)
Action:
square destroys the sign in plaintext, but not the accumulated noise pattern. Another square amplifies it even more.
Decision:
Compare anomaly counts after 1× and 2× squares.
If noise grows faster for m1 or exceeds a cutoff → guess 1; otherwise 0.
These two strategies complement each other; A alone usually achieves 100/100.
Solve Script
#!/usr/bin/env python3
import json
import socket
import sys
HOST = "one-more-bit.challs.m0lecon.it"
PORT = 24180
def float_to_bits(value, bit_length=64, scale_bits=50):
scaled = int(round(value * (1 << scale_bits)))
scaled = abs(scaled)
mask = (1 << bit_length) - 1
twos_complement = scaled & mask
return tuple((twos_complement >> i) & 1 for i in range(bit_length))
def send_request(sock, request):
req_str = json.dumps(request) + "\n"
sock.sendall(req_str.encode())
buffer = b""
while b"\n" not in buffer:
chunk = sock.recv(1024)
if not chunk:
raise ConnectionError("Connection closed")
buffer += chunk
response = buffer.split(b"\n")[0].decode().strip()
return json.loads(response)
def test_strategy(sock, round_num):
base = 1.0
delta = 2**14
if float_to_bits(base) == float_to_bits(base + delta):
resp = send_request(sock, {"command": "encrypt", "m0": base, "m1": base + delta})
if resp["status"] == "ok":
idx0 = resp["state_index"]
anomalies_direct = []
expected = float_to_bits(base)
for bit_pos in range(64):
resp = send_request(sock, {"command": "decrypt", "index": idx0, "position": bit_pos})
if resp["status"] == "ok" and resp["bit"] != expected[bit_pos]:
anomalies_direct.append(bit_pos)
resp = send_request(sock, {"command": "eval", "function": "square", "indices": [idx0]})
if resp["status"] == "ok":
idx_sq = resp["state_index"]
anomalies_sq = []
expected_sq = float_to_bits(base ** 2)
for bit_pos in range(20):
resp = send_request(sock, {"command": "decrypt", "index": idx_sq, "position": bit_pos})
if resp["status"] == "ok" and resp["bit"] != expected_sq[bit_pos]:
anomalies_sq.append(bit_pos)
total_anomalies = len(anomalies_direct) + len(anomalies_sq)
if total_anomalies > 20:
return 0
else:
return 1
val = 100.0
resp = send_request(sock, {"command": "encrypt", "m0": val, "m1": -val})
if resp["status"] == "ok":
idx1 = resp["state_index"]
resp = send_request(sock, {"command": "eval", "function": "square", "indices": [idx1]})
if resp["status"] == "ok":
idx1_sq = resp["state_index"]
anomalies_b = []
expected_b = float_to_bits(val ** 2)
for bit_pos in range(30):
resp = send_request(sock, {"command": "decrypt", "index": idx1_sq, "position": bit_pos})
if resp["status"] == "ok" and resp["bit"] != expected_b[bit_pos]:
anomalies_b.append(bit_pos)
resp = send_request(sock, {"command": "eval", "function": "square", "indices": [idx1_sq]})
if resp["status"] == "ok":
idx1_sq2 = resp["state_index"]
anomalies_b2 = []
expected_b2 = float_to_bits((val ** 2) ** 2)
for bit_pos in range(30):
resp = send_request(sock, {"command": "decrypt", "index": idx1_sq2, "position": bit_pos})
if resp["status"] == "ok" and resp["bit"] != expected_b2[bit_pos]:
anomalies_b2.append(bit_pos)
if len(anomalies_b2) > len(anomalies_b):
return 1
elif len(anomalies_b) > 3:
return 1
else:
return 0
return 0
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
successes = []
failures = []
for round_num in range(1, 101):
buffer = b""
while b"\n" not in buffer:
chunk = sock.recv(1024)
if not chunk:
return
buffer += chunk
line = buffer.split(b"\n")[0].decode().strip()
data = json.loads(line)
guess = test_strategy(sock, round_num)
resp = send_request(sock, {"command": "guess", "bit": guess})
if resp.get("result") == "LOSE":
failures.append(round_num)
break
elif resp.get("result") == "WIN":
successes.append(round_num)
else:
break
try:
buffer = b""
while b"\n" not in buffer:
chunk = sock.recv(1024)
if not chunk:
break
buffer += chunk
if buffer:
final = buffer.split(b"\n")[0].decode().strip()
final_resp = json.loads(final)
if "flag" in final_resp:
print(final_resp["flag"])
except Exception:
pass
sock.close()
if __name__ == "__main__":
main()
Result
Rounds completed → flag returned by the service:
ptm{s0m37hing_s0m37hing_FHE_s0m37hing_s0m37hing_ggwp_d1bfcfab}
Conclusion
A great example of a “bad crypto idea” in applied settings: mixing fixed quantization + mod 2⁶⁴ reduction while exposing a bitwise oracle through homomorphic ops gives a clear side channel.
The key trick boils down to one math line: 2^14 × 2^50 = 2^64 ⇒ two different reals that look identical bitwise to the server.
With square to amplify noise and a simple anomaly count, we get a robust decision strategy for “one more bit.”
written by NumberOreo