m0leconCTF2025 crypto/one-more-bit

- 6 mins read

🔐 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_index
  • eval {function: "square", indices: [i...]}state_index
  • decrypt {index, position} → bit (0/1) for the requested position
  • guess {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 eval patterns (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