cryptofreehard

qss

b01lersc

Task: craft a quantum eavesdropping attack by submitting an ancilla state and 3-qubit unitary, then survive 96 rounds of QBER and information-theoretic score checks. Solution: embed the 2D Bell logical space into a tuned two-parameter isometry near the information-disturbance frontier, measure only Bob's qubit, and retry until finite-sample checks accept the run.

$ ls tags/ techniques/
logical_subspace_isometryinformation_disturbance_tradeoff_tuningorthonormal_completionfinite_sample_retry_strategy

qss — b01lers CTF 2026

Description

quantum secret sharing is the way of the future!

Remote: ncat --ssl qss.opus4-7.b01le.rs 8443

Source: challenge.py

English summary: the service asks us to submit a normalized 1-qubit ancilla and a 3 x 3-qubit attack unitary, then interact with a 96-round protocol containing both test rounds and information rounds. To get the flag we must leak enough information about the hidden secret bit while keeping the induced disturbance low and close to the challenge's theoretical information-disturbance frontier.

Challenge Summary

This is not a standard "break the crypto primitive" problem. The server already defines the protocol and the score function; our job is to engineer an eavesdropping attack that looks good under those metrics.

The important public ingredients are four Bell-style states:

phi_minus = [1, 0, 0, -1] / sqrt(2) psi_plus = [0, 1, 1, 0] / sqrt(2) psi_upper_plus = (phi_minus + psi_plus) / sqrt(2) phi_upper_minus = (phi_minus - psi_plus) / sqrt(2)

They are indexed as:

  • S0 = {|phi->, |psi+>}
  • S1 = {|Phi->, |Psi+>}

Those four states span only a 2-dimensional logical subspace. That immediately suggests reducing the problem to a BB84-like two-state attack inside the basis

|0_L> = |phi-> |1_L> = |psi+>

and treating S1 as the logical X basis obtained from superpositions of |0_L> and |1_L>.

Analysis

Protocol structure

The server fixes:

  • ROUNDS = 96
  • TEST_RATIO = 0.40
  • MAX_QBER = 0.32
  • MIN_QBER_CHECKED = 4
  • MIN_QBER_CHECKED_PER_BASIS = 2
  • MIN_INFO_ROUNDS = 16
  • MIN_MUTUAL_INFO_BITS = 0.5
  • FRONTIER_GAP_TOL_BITS = 0.25
  • MIN_RAW_INFO_ACCURACY = 0.60

For each round the server samples:

  • a secret bit secret
  • a public set bit set_bit
  • Alice's basis z or x
  • whether the round is a test round or an information round

Before some public disclosure, we must already announce a basis and outcome:

{"basis": "z|x", "outcome": 0|1}

Then the server reveals:

  • whether the public set is S0 or S1
  • Alice's basis

If it is a test round, the server only checks consistency between our pre-announced value and the hidden measurement outcome logic. If it is an information round, we may choose a measurement plan on qubit b and/or c, receive the outcomes, and then submit a secret guess.

What the score really checks

At the end, the server computes several filters:

  1. Enough valid test checks must survive basis filtering.
  2. Those valid checks must include both z and x bases.
  3. QBER must stay below 0.32.
  4. We need at least 16 kept information rounds.
  5. Our kept-round mutual information must exceed 0.5 bits.
  6. Our empirical point must stay within 0.25 bits of the server's theoretical frontier I_max(D).
  7. Raw information accuracy must be at least 0.60.
  8. The announced bases must not be too imbalanced.
  9. Kept-round information must beat the raw delayed-information baseline.

So the challenge is explicitly about living near an information-disturbance tradeoff, not about achieving zero disturbance.

Logical-subspace reduction

The most useful observation is that the four public Bell-style states all lie in the span of |phi-> and |psi+>. Therefore the attack only needs to define what happens to a 2D logical qubit tensored with a 1-qubit ancilla.

We take the ancilla to be:

|0> = [1, 0]

and build an isometry from the logical basis into three physical qubits (Alice, Bob, Eve-ancilla).

The solver uses the family

v0 = Z_A [ λ|000> + sqrt(1-λ^2)(sqrt(p)|011> + sqrt(1-p)|101>) ] v1 = Z_A [ λ|111> + sqrt(1-λ^2)(sqrt(p)|100> + sqrt(1-p)|010>) ]

where Z_A is a Pauli-Z correction on Alice's qubit.

That correction is not cosmetic. Without it, the server's secret labels do not line up with the way the challenge scores the two public bases. Applying Z on Alice makes the label convention consistent in both S0 and S1.

Intuitively:

  • the λ term preserves stronger correlation with the original logical state,
  • the sqrt(1-λ^2) branch leaks information into Eve's side information,
  • the parameter p tunes how that leaked amplitude is split between the two asymmetric branches,
  • the whole family traces a practical slice of the information-disturbance frontier.

From isometry to full unitary

The service does not accept an isometry directly. It demands a full 8x8 unitary.

So the solver:

  1. Defines input basis vectors
    • x0 = |phi->|0>
    • x1 = |psi+>|0>
  2. Maps them to the desired output vectors v0 and v1.
  3. Completes both pairs to orthonormal bases in dimension 8.
  4. Sets
U = V W*

where W is the basis completion with first columns x0, x1 and V is the completion with first columns v0, v1.

This guarantees a valid unitary whose action on the logical subspace is exactly the chosen attack.

Deriving the Practical Attack

Why a Bob-only measurement was enough

A more complicated two-measurement strategy was considered, but in the live solve the simple version worked:

  • on S0, measure Bob's qubit in z
  • on S1, measure Bob's qubit in x

Then guess:

if public_set == S0: secret = b if public_set == S1: secret = 1 - b

This is exactly what the final solve.py does.

The reason it works is that the chosen isometry preserves enough logical structure on Bob's subsystem that one carefully matched basis measurement already captures a useful fraction of the secret information, while still keeping the disturbance under control.

Pre-public announcement strategy

The protocol also evaluates our pre-public test-round announcement. The solver uses a deterministic pattern instead of trying to infer anything impossible in advance:

  • alternate announced basis as z, x, z, x, ...
  • always announce outcome 0

This is a deliberately pragmatic choice. It ensures both bases get represented often enough to satisfy the balanced-check conditions, and the isometry parameters are tuned so the resulting aggregate disturbance/information statistics can still pass.

Chosen live parameters

The successful run used:

p = 0.09 lambda = 0.84

This point gave a good empirical balance:

  • enough information on kept rounds,
  • acceptable QBER,
  • frontier deviation small enough for the challenge's tolerance,
  • good odds of surviving the finite-sample thresholds.

This is the main reason the challenge is probabilistic. Even if the underlying strategy is good, only 96 rounds are sampled, and several conditions depend on random filtering:

  • enough valid test rounds must occur,
  • both announced bases must appear in the valid subset,
  • enough information rounds must be kept,
  • the observed QBER and accuracy must fluctuate favorably.

So a theoretically decent attack can still fail one connection and pass the next. The successful flag was obtained on attempt 2.

Solution

End-to-end strategy

  1. Read challenge.py and identify the 2D logical Bell subspace.
  2. Treat the task as a BB84-like eavesdropping problem with an ancilla-assisted isometry.
  3. Fix ancilla |0>.
  4. Build the two-parameter attack family v0, v1 with Alice-side Z correction.
  5. Extend that mapping to a full 8x8 unitary by orthonormal completion.
  6. Send alternating pre-public bases with constant announced outcome 0.
  7. On information rounds, measure only Bob:
    • S0 -> ["b", "z"]
    • S1 -> ["b", "x"]
  8. Guess the secret from Bob's bit using the set-dependent rule.
  9. Retry sessions until the finite-sample score checks align.

Solver walkthrough (solve.py)

The solver begins by defining the logical Bell states and a helper for orthonormal completion. The heart of the attack is:

def build_unitary(p: float, lam: float) -> np.ndarray: q = 1.0 - p bar = math.sqrt(max(0.0, 1.0 - lam * lam)) e = np.eye(8, dtype=np.complex128) idx = lambda bits: int(bits, 2) v0 = lam * e[idx("000")] + bar * (math.sqrt(p) * e[idx("011")] + math.sqrt(q) * e[idx("101")]) v1 = lam * e[idx("111")] + bar * (math.sqrt(p) * e[idx("100")] + math.sqrt(q) * e[idx("010")]) za = np.diag([1, 1, 1, 1, -1, -1, -1, -1]).astype(np.complex128) v0 = za @ v0 v1 = za @ v1 bell = build_bell_states() anc = np.array([1.0, 0.0], dtype=np.complex128) x0 = np.kron(bell[(0, 0)], anc) x1 = np.kron(bell[(0, 1)], anc) w = orthonormal_completion([x0, x1]) v = orthonormal_completion([v0, v1]) u = v @ w.conj().T return u

It serializes the ancilla as [1.0, 0.0] and the unitary as JSON, then interacts with the remote service over TLS.

For the pre-public announcement phase:

if round_type == "test": basis = "z" if test_counter % 2 == 0 else "x" else: basis = "z" if info_counter % 2 == 0 else "x" f.write(json.dumps({"basis": basis, "outcome": 0}) + "\n")

For information rounds:

if public_set_basis == "z": plan = [["b", "z"]] else: plan = [["b", "x"]]

and the secret guess is computed as:

bit = int(m.group(1)) current_guess = bit if public_set_basis == "z" else 1 - bit

Because the challenge is probabilistic, the script loops over multiple attempts:

for attempt in range(1, ATTEMPTS + 1): flag, summary = solve_once(HOST, PORT, ancilla_json, unitary_json)

That retry loop is essential rather than optional.

Full working solver (tasks/b01lersc/crypto/qss/solve.py):

import json import math import re import socket import ssl import sys from typing import List, Tuple import numpy as np HOST = "qss.opus4-7.b01le.rs" PORT = 8443 ATTEMPTS = 40 # Empirically good point in the isometry family. P = 0.09 LAMBDA = 0.84 def build_bell_states() -> dict[Tuple[int, int], np.ndarray]: phi_minus = np.array([1, 0, 0, -1], dtype=np.complex128) / math.sqrt(2.0) psi_plus = np.array([0, 1, 1, 0], dtype=np.complex128) / math.sqrt(2.0) return { (0, 0): phi_minus, (0, 1): psi_plus, (1, 0): (phi_minus - psi_plus) / math.sqrt(2.0), (1, 1): (phi_minus + psi_plus) / math.sqrt(2.0), } def orthonormal_completion(cols: List[np.ndarray], dim: int = 8) -> np.ndarray: basis: List[np.ndarray] = [] for vec in cols: w = vec.astype(np.complex128).copy() for b in basis: w -= np.vdot(b, w) * b n = float(np.linalg.norm(w)) if n > 1e-12: basis.append(w / n) for i in range(dim): e = np.zeros(dim, dtype=np.complex128) e[i] = 1.0 w = e.copy() for b in basis: w -= np.vdot(b, w) * b n = float(np.linalg.norm(w)) if n > 1e-12: basis.append(w / n) if len(basis) == dim: break return np.column_stack(basis) def build_unitary(p: float, lam: float) -> np.ndarray: q = 1.0 - p bar = math.sqrt(max(0.0, 1.0 - lam * lam)) e = np.eye(8, dtype=np.complex128) idx = lambda bits: int(bits, 2) v0 = lam * e[idx("000")] + bar * (math.sqrt(p) * e[idx("011")] + math.sqrt(q) * e[idx("101")]) v1 = lam * e[idx("111")] + bar * (math.sqrt(p) * e[idx("100")] + math.sqrt(q) * e[idx("010")]) # Z on Alice so the challenge's secret labels line up in both public bases. za = np.diag([1, 1, 1, 1, -1, -1, -1, -1]).astype(np.complex128) v0 = za @ v0 v1 = za @ v1 bell = build_bell_states() anc = np.array([1.0, 0.0], dtype=np.complex128) x0 = np.kron(bell[(0, 0)], anc) x1 = np.kron(bell[(0, 1)], anc) w = orthonormal_completion([x0, x1]) v = orthonormal_completion([v0, v1]) u = v @ w.conj().T if not np.allclose(u.conj().T @ u, np.eye(8), atol=1e-9): raise ValueError("generated matrix is not unitary") return u def matrix_to_jsonable(u: np.ndarray) -> list[list[float]]: u = np.real_if_close(u, tol=1_000) out: list[list[float]] = [] for row in u: out.append([float(x) for x in row]) return out def solve_once(host: str, port: int, ancilla_json: str, unitary_json: str) -> tuple[str | None, str]: ctx = ssl.create_default_context() sock = socket.create_connection((host, port), timeout=10) ssock = ctx.wrap_socket(sock, server_hostname=host) f = ssock.makefile("rw", encoding="utf-8", newline="\n") transcript: list[str] = [] test_counter = 0 info_counter = 0 round_type = None public_set_basis = None current_guess = 0 try: while True: line = f.readline() if line == "": break transcript.append(line) stripped = line.strip() if stripped == "ancilla_statevector_json>": f.write(ancilla_json + "\n") f.flush() elif stripped == "unitary_8x8_json>": f.write(unitary_json + "\n") f.flush() elif stripped.startswith("round_type:"): round_type = stripped.split(":", 1)[1].strip() public_set_basis = None elif stripped == "pre_public_announce_json>": if round_type == "test": basis = "z" if test_counter % 2 == 0 else "x" test_counter += 1 else: basis = "z" if info_counter % 2 == 0 else "x" info_counter += 1 f.write(json.dumps({"basis": basis, "outcome": 0}) + "\n") f.flush() elif stripped.startswith("public_set:"): public_set_basis = "z" if "S0" in stripped else "x" elif stripped == "measurement_plan_json>": if public_set_basis == "z": plan = [["b", "z"]] else: plan = [["b", "x"]] f.write(json.dumps(plan) + "\n") f.flush() elif stripped.startswith("measurement_outcomes:"): m = re.search(r"b:[zx]=([01])", stripped) if not m: raise ValueError(f"could not parse measurement line: {stripped!r}") bit = int(m.group(1)) current_guess = bit if public_set_basis == "z" else 1 - bit elif stripped == "secret_guess_bit>": f.write(str(current_guess) + "\n") f.flush() text = "".join(transcript) finally: try: f.close() finally: ssock.close() flag_match = re.search(r"([A-Za-z0-9_]+\{[^\n{}]+\})", text) if flag_match: return flag_match.group(1), flag_match.group(1) summary = "no summary" for line in reversed(text.splitlines()): if line.startswith("No flag yet:") or line.startswith("ABORT:") or line.startswith("qber="): summary = line break return None, summary def main() -> None: ancilla_json = json.dumps([1.0, 0.0]) unitary_json = json.dumps(matrix_to_jsonable(build_unitary(P, LAMBDA))) for attempt in range(1, ATTEMPTS + 1): print(f"[+] attempt {attempt}/{ATTEMPTS} with p={P} lambda={LAMBDA}") try: flag, summary = solve_once(HOST, PORT, ancilla_json, unitary_json) except Exception as exc: print(f"[!] attempt failed with exception: {exc}") continue if flag: print(f"[+] FLAG: {flag}") return print(f"[-] no flag this attempt ({summary})") print("[!] exhausted attempts without a flag") if __name__ == "__main__": main()

Notes

  • I searched the existing CTFBase knowledge base for related BB84/QBER/mutual-information writeups but found no relevant prior entry to reuse.
  • A crypto subagent also reviewed the problem and suggested a more complex two-measurement variant, but the simple Bob-only strategy above was the one that actually worked live.
  • Conceptually this is an information-disturbance frontier challenge; operationally the solve is a pragmatic finite-sample attack that retries until the random score filters accept a good run.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md