Category playbooks
Seven categories, each with: how to recognise it from the challenge text, the concept in the fewest words that make the attack obvious, the solve procedure, a worked example, the gotchas, and the toolkit call. Read the recognition line and the toolkit line first; drop into the rest only when stuck.
Triage: how to work the board
Every category on this board opens with a low-value intro challenge, and in past years the harder challenges in a category stayed locked until the intro was solved. So the first pass is not "pick the category I know best" — it is a sweep.
- Sweep all intros first (first 60–90 minutes). Open every category, solve the cheapest challenge in each. This unlocks the board and tells you which categories this year's authors actually leaned into.
- Then sort by points ascending inside the categories you unlocked. Point value is the organisers' own difficulty estimate and is more reliable than your read of the challenge title. A 10-point challenge is a transcription or a trivia lookup; 200 points is a real build.
- Timebox at 25 minutes. If a challenge has not produced a concrete next step in 25 minutes, write down what you know in a scratch file, move on, come back after clearing something else. First-CTF failure mode is sinking three hours into one problem out of sunk-cost.
- Re-read the prompt every 10 minutes. Quantum challenge text is short and every clause is load-bearing — "at most 7 lines", "one query", "the same nonce", "measure only qubit 0". Most stalls are a missed constraint, not a missed concept.
- Submit partials. Flag formats are usually checked verbatim; try
the obvious wrapper (
flag{...},QV{...},DDC{...}) if the prompt does not state one.
| Points | What it usually is | Target time |
|---|---|---|
| 10–50 | Intro/unlock: trivia, a two-gate circuit, a transcription, one strings run | < 10 min |
| 50–150 | One standard technique applied cleanly (sift a transcript, run Grover, factor N) | 15–30 min |
| 150–300 | Build something under a constraint (golf a circuit, beat 0.75 over a socket, chain a misuse bug) | 45–90 min |
| 300+ | Multi-stage, or genuinely novel. Attempt only after the board is otherwise cleared | open-ended |
Qiskit is little-endian throughout. In a counts key like '101', the
rightmost character is qubit 0. This bites on every category that reads a
measurement — write down the mapping once at the start of each challenge rather than
guessing twice.
QASM circuit golf
Recognise it in 10 seconds
The prompt states a behaviour plus a budget: "in at most N lines of OpenQASM", "using no more than 5 gates", "shortest circuit wins". Often ships a submission box that takes raw QASM text and a checker that runs it. DEF CON 32's XOR-bit-al (200 pts) was exactly this shape: implement the behaviour in ≤ 7 lines.
Concept
You are not being asked for anything deep. You are being asked to know which gate
sequences are equal, so you can spend fewer lines saying the same thing. QASM 2.0
counts one instruction per line and ends each with ;. Declarations
(OPENQASM, include, qreg, creg,
qubit, bit, custom gate definitions) normally do
not count against the budget — but confirm against the challenge's own checker if it
tells you a line count, because that is the number that matters.
Procedure
- Write the obvious correct circuit in Qiskit first. Correctness before length.
- Emit it with
to_qasm(qc)and count withline_count(). - If over budget, apply rewrites: fold gate pairs into single gates, use register-wide
operations (
measure q -> c;is one line for the whole register, andh q;applies H to every qubit ofqin one line), drop gates that cancel, and drop the final measurement entirely if the checker inspects the statevector. - Re-load the golfed QASM with
load_qasm()and simulate it to confirm you did not break it while shortening. - Submit and keep the pre-golf version in a scratch file in case the checker disagrees about behaviour.
Rewrites that buy lines
| Long form | Short form | Verified |
|---|---|---|
h q[0]; z q[0]; h q[0]; | x q[0]; | yes |
h q[0]; x q[0]; h q[0]; | z q[0]; | yes |
s q[0]; s q[0]; | z q[0]; | yes |
h q[1]; cx q[0],q[1]; h q[1]; | cz q[0],q[1]; | yes |
cx q[0],q[1]; cx q[1],q[0]; cx q[0],q[1]; | swap q[0],q[1]; | yes |
h q[0]; h q[1]; h q[2]; | h q; (whole register) | yes |
measure q[0]->c[0]; measure q[1]->c[1]; | measure q -> c; | yes |
All checked with Operator(a).equiv(Operator(b)), which ignores global
phase — the right comparison for golf, since global phase is unobservable. Use
Operator(a) == Operator(b) only if the challenge somehow cares about phase.
Common targets
| Asked for | Minimal QASM body | Operative lines |
|---|---|---|
| Bell pair (|00>+|11>)/√2 | h q[0]; cx q[0],q[1]; | 2 |
| 3-qubit GHZ | h q[0]; cx q[0],q[1]; cx q[0],q[2]; | 3 |
| XOR/parity of q0,q1 into ancilla q2 | cx q[0],q[2]; cx q[1],q[2]; | 2 |
| Parity of n qubits into an ancilla | one cx q[i],q[anc]; per input | n |
| AND of q0,q1 into q2 (Toffoli) | ccx q[0],q[1],q[2]; | 1 |
| Uniform superposition over n qubits | h q; | 1 |
| A given truth table f | X-conjugate each input pattern around a ccx/mcx | varies |
Worked example — XOR into an ancilla, budget 7
Put q0 and q1 in superposition, write q0⊕q1 into q2, measure everything. Five operative lines against a budget of seven.
import sys; sys.path.insert(0, "toolkit")
from qasm_tools import load_qasm, line_count, golf_report
from qiskit_aer import AerSimulator
src = """OPENQASM 2.0;
include "qelib1.inc";
qreg q[3];
creg c[3];
h q[0];
h q[1];
cx q[0],q[2];
cx q[1],q[2];
measure q -> c;
"""
print(golf_report(src, budget=7))
counts = AerSimulator().run(load_qasm(src), shots=4000).result().get_counts()
print(counts)
Output:
5 operative line(s) / budget 7 -> OK
1: h q[0];
2: h q[1];
3: cx q[0],q[2];
4: cx q[1],q[2];
5: measure q -> c;
{'011': 963, '101': 1016, '110': 998, '000': 1023}
Reading a key such as '110' little-endian: q0=0, q1=1, q2=1, and
0⊕1 = 1. Every one of the four outcomes satisfies q2 = q0⊕q1, and the
four impossible strings (001, 010, 100,
111) never appear. That is the check the grader will run.
Gotchas
- The checker's line count may differ from yours.
line_count()skips blanks,//comments, and declaration lines — if the challenge says you are at 8 and the toolkit says 5, the challenge counts declarations. Re-golf to their number. measure q -> c;is one QASM line but expands to one instruction per qubit inside Qiskit.line_count()gives 3 for a Bell circuit whilegate_count()gives 4. Golf cares aboutline_count().- Loading OpenQASM 3 through
load_qasm()needsqiskit_qasm3_import, which is inenv/requirements.txtand installed here; on a rebuilt environment without it you getMissingOptionalLibraryError.line_count()works on QASM 3 text regardless because it is pure string work. If parsing fails, hand-translate to QASM 2 (qubit[2] q;→qreg q[2];,bit[2] c;→creg c[2];,c = measure q;→measure q -> c;). - QASM 2 needs
include "qelib1.inc";or named gates will not resolve. - Do not transpile before emitting —
transpile()decomposes to a basis gate set and will multiply your line count. - If the target is a statevector, global phase does not matter and the final measurement is often unnecessary. Delete it and save a line.
Toolkit
from qasm_tools import load_qasm, to_qasm, line_count, operative_lines, gate_count, golf_report
load_qasm(source: str) -> QuantumCircuit # QASM 2 or 3; str or path ending .qasm
to_qasm(qc, version: int = 2) -> str
line_count(qasm_str: str) -> int # operative lines only
operative_lines(qasm_str: str) -> list[str]
gate_count(qc) -> int # instructions, barriers excluded
golf_report(qasm_str, budget: int | None = None) -> str
Also runs as a CLI: python toolkit/qasm_tools.py my.qasm 7.
Q# katas and unitary discrimination
Recognise it in 10 seconds
Kata-shaped prompt with a signature to fill in: "you are given an operation that implements either A or B; return 0 if A and 1 if B, using exactly one application". The phrases one query, a single call, you may also use Controlled/Adjoint are the tell. Q# is the native language of these challenges because Microsoft's Quantum Katas are the source material.
Concept
Two facts cover almost all of these:
- If the two unitaries differ by more than a global phase, some input state distinguishes them directly. Find a state where their outputs are orthogonal, prepare it, apply the black box once, measure in the basis that separates them.
- If they differ only by a global phase (U vs −U, or U vs iU), no direct measurement can tell them apart — global phase is unobservable. The fix is phase kickback: apply controlled-U with the control in |+>. The target picks up the phase, and because the control was in superposition that global phase becomes a relative phase between the control's |0> and |1> branches. Hadamard the control and measure it.
The kickback algebra, target prepared in an eigenstate |ψ> with U|ψ> = ±|ψ>:
H on control: (|0> + |1>)/√2 ⊗ |ψ>
controlled-U: (|0>|ψ> + |1>U|ψ>)/√2 = (|0> ± |1>)/√2 ⊗ |ψ>
H on control: |0> if +, |1> if −
Measuring the control gives the sign deterministically. This is the Hadamard test.
Procedure
- Write both unitaries as 2×2 matrices.
- Do they differ by more than global phase? Check whether U†V is proportional to the identity. If it is not, go to step 3; if it is, go to step 4.
- Direct query. Find |ψ> with <ψ|U†V|ψ> = 0.
Prepare it, apply the box, measure in a basis containing U|ψ>. For I vs Z:
|ψ> = |+>, since I|+> = |+> and Z|+> = |−>, which are
orthogonal. Circuit:
H, box,H, measure. - Hadamard test. Two qubits. H the control, apply
Controlled U, H the control, measure the control. Prepare the target in an eigenstate of U (|0> works for anything diagonal). - Map outcome 0 → first unitary, 1 → second, and check the direction against the prompt's numbering before submitting.
Worked example (Qiskit)
Case A distinguishes I from Z, which differ by more than a phase. Case B distinguishes U from −U, which do not.
from qiskit import QuantumCircuit, transpile
from qiskit.circuit.library import ZGate, UnitaryGate
from qiskit_aer import AerSimulator
import numpy as np
sim = AerSimulator()
# Case A: I vs Z -- one direct query, no control needed.
def direct(U, shots=2000):
qc = QuantumCircuit(1, 1)
qc.h(0); qc.append(U, [0]); qc.h(0); qc.measure(0, 0)
return sim.run(transpile(qc, sim), shots=shots).result().get_counts()
# Case B: U vs -U -- global phase, so put it on a control.
def hadamard_test(U, shots=2000):
qc = QuantumCircuit(2, 1) # q0 = control, q1 = target
qc.h(0)
qc.append(U.control(1), [0, 1])
qc.h(0)
qc.measure(0, 0)
return sim.run(transpile(qc, sim), shots=shots).result().get_counts()
I = UnitaryGate(np.eye(2), label="I")
Z = ZGate()
mI = UnitaryGate(-np.eye(2), label="-I")
mZ = UnitaryGate(-np.diag([1, -1]), label="-Z")
print("A direct U=I ->", direct(I))
print("A direct U=Z ->", direct(Z))
print("B H-test U=I ->", hadamard_test(I))
print("B H-test U=-I ->", hadamard_test(mI))
print("B H-test U=Z ->", hadamard_test(Z))
print("B H-test U=-Z ->", hadamard_test(mZ))
Output — every case is deterministic, no sampling noise:
A direct U=I -> {'0': 2000}
A direct U=Z -> {'1': 2000}
B H-test U=I -> {'0': 2000}
B H-test U=-I -> {'1': 2000}
B H-test U=Z -> {'0': 2000}
B H-test U=-Z -> {'1': 2000}
The same answer in Q#
If .NET and the QDK are already installed, write it natively; if not, do not burn CTF hours on the install. Reason it out in Qiskit, then transcribe — the operation bodies are four lines and translate one-for-one.
// Case A: distinguish I from Z. Returns 0 for I, 1 for Z.
operation DistinguishIfromZ(unitary : (Qubit => Unit is Adj + Ctl)) : Int {
use q = Qubit();
H(q);
unitary(q);
H(q);
return M(q) == One ? 1 | 0;
}
// Case B: distinguish U from -U via phase kickback on a control.
operation DistinguishUfromMinusU(unitary : (Qubit => Unit is Adj + Ctl)) : Int {
use (ctl, tgt) = (Qubit(), Qubit());
H(ctl);
Controlled unitary([ctl], tgt); // tgt starts in |0>, an eigenstate
H(ctl);
let res = M(ctl) == One ? 1 | 0;
ResetAll([ctl, tgt]);
return res;
}
Two API notes for transcription: Q# is big-endian by convention (its
LittleEndian/BigEndian wrappers are explicit), the opposite of
Qiskit's implicit little-endian; and Q# requires every qubit be reset to |0> before
release or the runtime raises.
Gotchas
- The Hadamard test is only deterministic when the target is an eigenstate of U. In general the control reads 0 with probability (1 + Re<ψ|U|ψ>)/2, so a badly chosen target gives a near-coin-flip and you will conclude the technique is broken. Pick |0> for diagonal U; otherwise diagonalise first.
- Aer cannot simulate a raw
c-unitaryinstruction — calling.control(1)on aUnitaryGateand running it directly raisesAerError: 'unknown instruction: c-unitary'. Wrap the circuit intranspile(qc, sim)first. - Watch the return polarity. Katas frequently number the unitaries in the order that makes the naive mapping backwards; a solution that is right but inverted reads as "always wrong", which is the easiest bug to miss.
- "Exactly one application" is enforced by the grader in Q# katas. Adding a second call to make the answer clearer fails even when the output is correct.
AdjointandControlledare usually allowed and usually free.Controlledis the whole trick for phase cases.
Toolkit
No dedicated module. Use qsim for the plumbing:
from qsim import run_counts, statevector, show
run_counts(qc, shots: int = 1024) -> dict[str, int]
statevector(qc) # strips final measurements first
show(qc) -> str # ASCII diagram
run_counts does not transpile. For controlled UnitaryGates,
transpile yourself before calling it, or build the controlled operation from standard
library gates (cz, cp, ch) which Aer handles
directly.
BB84 / QKD
Recognise it in 10 seconds
The challenge hands you three parallel strings or columns — Alice's bases, Bob's
bases, Bob's measurements — possibly as a CSV, a pcap, or a wall of
+/x and 0/1. Words that appear:
rectilinear, diagonal, sifting, QBER,
Eve, photon, polarisation. There is nearly always a
ciphertext blob alongside.
Concept
Alice sends each bit encoded in a randomly chosen basis. Bob measures in his own random basis. When the bases agree, Bob gets Alice's bit exactly; when they disagree, his result is a coin flip and is worthless. Afterwards they announce the bases (not the bits) publicly and throw away the mismatches — that is sifting, and about half the pulses survive. The surviving bits are the key. There is nothing else to compute.
| Basis | Code | bit 0 | bit 1 | Symbols you may see |
|---|---|---|---|---|
| Rectilinear / Z | 0 | |0> | |1> | +, Z, R, ↑→ |
| Diagonal / X | 1 | |+> | |−> | x, X, D, ↗↘ |
An intercept-resend eavesdropper measures each pulse in a random basis and resends what she saw. She picks the wrong basis half the time; when she does, Bob's bit is randomised even on a matched-basis position, and he is wrong half of those. So sifted QBER lands at 1/2 × 1/2 = 25%. A QBER near 0 means a clean line; near 0.25 means Eve; near 0.5 means your basis convention is inverted, not that the channel is maximally noisy.
Procedure
- Parse the three strings into 0/1 lists. Normalise whatever symbols the challenge used to the 0 = Z / 1 = X convention.
- Sift: keep positions where Alice's basis equals Bob's basis. Expect ~50% survival — if you get ~100% or ~0%, you mis-parsed.
- If a public sample of Alice's bits is given, compute QBER against your sifted key. 0 means proceed; ~0.25 means the challenge wants you to report Eve, and the question may be "was the channel secure" rather than "what is the key".
- Pack the sifted bits to bytes, MSB-first. Take the first 128 bits for AES-128, 256 for AES-256, or all of them for a XOR keystream.
- Decrypt. Try in order: XOR against the keystream; AES-CBC with a zero IV; AES-ECB;
AES-CBC with the first 16 bytes of the ciphertext as the IV. Look for
flag{in the result.
Worked example — transcript to flag
import sys; sys.path.insert(0, "toolkit")
from bb84 import simulate_bb84, reconstruct_key, sift, qber
from crypto_utils import bits_to_bytes, aes_decrypt
# Stand-in for the challenge's transcript.
t = simulate_bb84(n=512, eve=False, seed=11)
alice_bases, bob_bases, bob_bits = t.alice_bases, t.bob_bases, t.bob_bits
key_bits = reconstruct_key(alice_bases, bob_bases, bob_bits)
print("sifted:", len(key_bits), "of", len(alice_bases))
alice_sifted = sift(alice_bases, bob_bases, t.alice_bits)
print("QBER:", qber(alice_sifted, key_bits))
aes_key = bits_to_bytes(key_bits[:128]) # 16 bytes, MSB-first
print("AES key:", aes_key.hex())
pt = aes_decrypt(ciphertext, aes_key, iv=bytes(16), mode="CBC")
print(pt[:-pt[-1]]) # strip PKCS#7 padding
# Eavesdropper check on a different transcript:
te = simulate_bb84(n=2000, eve=True, seed=3)
print("QBER with Eve:", round(qber(sift(te.alice_bases, te.bob_bases, te.alice_bits),
te.sifted_key), 3))
Output:
sifted: 278 of 512
QBER: 0.0
AES key: d118f98e4196d31c09618b94b547575c
b'flag{sifted_key_is_the_aes_key}'
QBER with Eve: 0.259
278/512 = 54% survival, the expected ~50%. QBER 0.259 on the Eve transcript is the 25% signature.
Gotchas
- Basis symbol mapping. If the challenge uses
+/xorR/D, map to 0/1 before sifting. Getting this backwards still yields a ~50%-length key that decrypts to garbage — the length check will not catch it. If decryption fails, invert the mapping and retry before suspecting anything else. - Bit packing order.
bits_to_bytesis MSB-first and left-pads to a byte boundary. If the flag comes out as noise, try LSB-first packing; it is the second most common convention. - Offset. Some challenges reserve the first k sifted bits as
the publicly-disclosed QBER sample and start the key at k. If a 128-bit slice
fails, try
key_bits[k:k+128]for small k. - Not enough bits. ~50% survival means an n-pulse transcript gives ~n/2 bits = ~n/16 bytes. You need 512+ pulses for an AES-256 key. If you are short, you mis-parsed or the key is meant to be XOR-cycled.
- The QBER is the answer sometimes. If the prompt asks whether the exchange was secure, the flag may be the QBER value or a yes/no, not a decrypted string.
- Alice's bits are not always given.
sift()takes whichever side's bits you have; on matched positions and a clean channel they agree, so either works. With Eve present they do not, which is exactly whatqber()measures.
Toolkit
from bb84 import simulate_bb84, sift, reconstruct_key, qber, Transcript
simulate_bb84(n: int = 256, eve: bool = False, seed: int | None = None) -> Transcript
# Transcript fields: alice_bits, alice_bases, bob_bases, bob_bits,
# eve_bases, sifted_positions, sifted_key
sift(alice_bases, bob_bases, bits) -> list[int]
reconstruct_key(alice_bases, bob_bases, bob_bits) -> list[int] # alias of sift
qber(key_a: list[int], key_b: list[int]) -> float
from crypto_utils import bits_to_bytes, xor, aes_decrypt, b64, unb64
bits_to_bytes(bits: list[int] | str) -> bytes # MSB-first, left-padded
xor(a: bytes, b: bytes) -> bytes # b cycles to a's length
aes_decrypt(ciphertext, key, iv=None, mode="CBC") # mode in ECB/CBC/CTR
CLI for a fast sift: python toolkit/bb84.py --sift 0101 0011 1010.
CHSH / Bell game
Recognise it in 10 seconds
Anything mentioning Bell inequality, CHSH, nonlocal game,
Tsirelson, 0.75, or 2√2. Usually accompanied by a
netcat address and a round count: "connect to chsh.quantum.village:1337
and win more than 75% of 1000 rounds".
Concept
A referee sends Alice a bit x and Bob a bit y, both uniform and independent. Alice outputs a, Bob outputs b. They cannot communicate after receiving their inputs. They win the round iff
a XOR b == x AND y
So they must agree (a = b) on three of the four input pairs, and disagree only on (x,y) = (1,1).
Classical ceiling 0.75. Without communication each player's output is a function of their own bit only: a(x), b(y). That is 16 deterministic strategies. The four win conditions are a(0)⊕b(0)=0, a(0)⊕b(1)=0, a(1)⊕b(0)=0, a(1)⊕b(1)=1. XOR all four left sides and every term appears twice, giving 0; XOR the right sides and you get 1. The four constraints are jointly contradictory, so no strategy satisfies all four — at most 3 of 4, i.e. 0.75. Randomised strategies are mixtures of deterministic ones and cannot beat the best of them. Brute-forcing all 16 confirms the maximum is 0.75.
Quantum 0.8536. Share |Φ⁺> = (|00>+|11>)/√2. Measure along an axis in the X–Z plane whose angle depends on your input:
| Player | input 0 | input 1 |
|---|---|---|
| Alice | 0 | π/4 |
| Bob | π/8 | −π/8 |
Every one of the four settings has an angle gap of π/8 between the two axes (the
(1,1) case gaps by π/8 in the direction that flips the correlation), and correlated
outcomes occur with probability cos²(π/8) = 0.8536 — the Tsirelson bound. To
measure along angle θ in the X–Z plane, apply ry(-2*theta) then
measure in Z.
Procedure
- Confirm the win condition matches
a^b == x&y. Some variants flip a sign or use a different predicate; if so, re-derive the angles rather than reusing these. - Compute the strategy offline first.
quantum_winrate()should print ~0.854; if it does not, fix that before touching the socket. - Write the socket loop: read x (Alice side) or y (Bob side), build the
one-round circuit, run it for
shots=1, parse the counts key little-endian, send the bit. - Play the full round count. Do not stop early on a bad streak — 0.854 over 1000 rounds has a standard deviation of about 1.1 percentage points, so a run sitting at 0.83 after 100 rounds is normal.
- Read the flag from the final server message.
Worked example
import sys; sys.path.insert(0, "toolkit")
from chsh import quantum_winrate, classical_winrate
import itertools
print("classical, always 0 :", classical_winrate())
print("classical, a=x b=0 :", classical_winrate(lambda x, y: (x, 0)))
print("quantum, 8000 shots :", round(quantum_winrate(shots=8000), 4))
best = max(classical_winrate(lambda x, y, b=bits: (b[x], b[2 + y]))
for bits in itertools.product([0, 1], repeat=4))
print("best of all 16 deterministic strategies:", best)
Output:
classical, always 0 : 0.75
classical, a=x b=0 : 0.75
quantum, 8000 shots : 0.8516
best of all 16 deterministic strategies: 0.75
0.8516 against the exact cos²(π/8) = 0.8536; the gap is sampling noise at 8000 shots per setting.
Socket skeleton
The per-round circuit from chsh.py, adapted for one shot at a time:
from math import pi
from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
ALICE_ANGLE = {0: 0.0, 1: pi / 4}
BOB_ANGLE = {0: pi / 8, 1: -pi / 8}
sim = AerSimulator()
def play(x, y):
qc = QuantumCircuit(2, 2)
qc.h(0); qc.cx(0, 1) # shared |Phi+>
qc.ry(-2 * ALICE_ANGLE[x], 0)
qc.ry(-2 * BOB_ANGLE[y], 1)
qc.measure([0, 1], [0, 1])
key = next(iter(sim.run(qc, shots=1).result().get_counts()))
return int(key[1]), int(key[0]) # little-endian: key[-1] is q0 = Alice
Gotchas
- The server wants many rounds, not a proof. The deliverable is a socket client that beats 0.75 statistically. Budget the time for the I/O, not the physics.
- Endianness in the counts key. In
'10', qubit 0 is the rightmost character.chsh.pyindexesbitstr[0]andbitstr[1], which is fine for a symmetric XOR check but wrong if you need to know which bit is Alice's. In the socket loop, be explicit. - Sign of the rotation.
ry(-2*theta), notry(2*theta). Flipping it turns 0.854 into 0.146 — a suspiciously bad win rate is the signature of an inverted angle, and it is fixed by one minus sign. - Not a socket-per-round. Some servers keep one connection for all rounds and stream the inputs. Reconnecting each round times out.
- Simulating both sides is fine. The game forbids communication between the players; nothing stops your one process from simulating both measurements, since the correlations are what the server checks.
- 0.85 is the ceiling. If a challenge demands >0.90 with genuinely independent inputs, it is testing whether you know the Tsirelson bound. The answer may be "impossible".
Toolkit
from chsh import quantum_winrate, classical_winrate, ALICE_ANGLE, BOB_ANGLE
quantum_winrate(shots: int = 4000) -> float # ~0.854, averaged over the 4 inputs
classical_winrate(strategy=lambda x, y: (0, 0)) -> float # exact, over all 4 inputs
ALICE_ANGLE = {0: 0.0, 1: pi/4}
BOB_ANGLE = {0: pi/8, 1: -pi/8}
Grover and Shor
Recognise them in 10 seconds
| Prompt looks like | Algorithm |
|---|---|
| "find the input that satisfies this predicate", "which of the 2^n states is marked", an oracle circuit you can call, a small search space | Grover |
| "factor this modulus", "break this RSA", an n plus an e plus a c, "period finding", "order finding" | Shor |
Grover — concept
Start in a uniform superposition over all 2ⁿ inputs. The phase oracle flips
the sign of the marked states. The diffuser reflects every amplitude about the mean,
which converts that sign flip into extra magnitude on the marked states. Each
oracle+diffuser pair rotates the state a fixed angle toward the marked subspace, so
after floor((π/4)·√(N/M)) iterations the marked states
dominate the measurement.
Grover — procedure
- Determine n (qubits) and the marked set. If the predicate is cheap, enumerate the targets classically and use a bitstring oracle — a CTF search space is small enough that this is faster than building a reversible predicate circuit.
- Compute iterations:
floor((pi/4) * sqrt(2**n / len(targets))), minimum 1. The toolkit does this wheniterationsis leftNone. - Run and read the top outcomes. With M targets, all M should sit clearly above the rest.
- Verify each candidate against the original predicate before submitting.
Grover — worked example
Find every 4-bit x with x² mod 16 = 9 and x odd.
import sys; sys.path.insert(0, "toolkit")
from algorithms import grover, grover_search
targets = [format(x, "04b") for x in range(16) if (x*x) % 16 == 9 and x % 2 == 1]
print("targets:", targets)
counts = grover(targets, n=4, shots=4000)
print("top 4:", sorted(counts.items(), key=lambda kv: -kv[1])[:4])
print("single target:", grover_search(["1011"], n=4))
# The degenerate case: mark half the space.
deg = grover([format(x, "03b") for x in range(0, 8, 2)], n=3, shots=4000)
print("M/N = 1/2:", dict(sorted(deg.items())))
Output:
targets: ['0011', '0101', '1011', '1101']
top 4: [('0011', 1052), ('1011', 999), ('0101', 982), ('1101', 967)]
single target: 1011
M/N = 1/2: {'000': 489, '001': 495, '010': 509, '011': 525,
'100': 496, '101': 466, '110': 522, '111': 498}
The first run puts all four marked states at ~1000 shots each out of 4000 — the other twelve states got essentially nothing. The second run marked 4 of 8 states and the distribution is flat: at M/N = 1/2 amplitude amplification does nothing. This is not a bug in the code, it is the geometry. The rotation angle per iteration is θ = 2·arcsin(√(M/N)), which at M/N = 1/2 is π/2 — one iteration overshoots straight past the target and lands back on the uniform state. Fix it by adding a qubit (doubling N while M stays fixed) or by marking fewer states and running Grover more than once.
Shor — concept
To factor N, pick a coprime to N and find its multiplicative order r:
the smallest r with ar ≡ 1 (mod N). If r is even and
ar/2 ≢ −1 (mod N), then ar/2 is a nontrivial square
root of 1 mod N, so (ar/2−1)(ar/2+1) ≡ 0 (mod N) with
neither factor divisible by N — hence gcd(a**(r//2) - 1, N) is a proper
factor. Order-finding is the only quantum part; everything else is elementary number
theory. For any modulus small enough to appear in a CTF, computing the order classically
is instant, so run the classical version and describe the quantum one if asked.
Shor — worked example
import sys; sys.path.insert(0, "toolkit")
from algorithms import shor_factor, multiplicative_order
from math import gcd
N = 3233
p, q = shor_factor(N)
print(f"shor_factor({N}) = {p} * {q}")
for a in (2, 3, 13):
r = multiplicative_order(a, N)
if r % 2:
print(f"a={a:2d} r={r:3d} odd -> retry with another a"); continue
x = pow(a, r // 2, N)
if x == N - 1:
print(f"a={a:2d} r={r:3d} a^(r/2) = N-1 -> trivial, retry"); continue
print(f"a={a:2d} r={r:3d} a^(r/2)={x} gcds: {gcd(x-1, N)}, {gcd(x+1, N)}")
e = 17
d = pow(e, -1, (p - 1) * (q - 1))
c = pow(1337, e, N)
print("RSA recovered:", pow(c, d, N))
Output:
shor_factor(3233) = 61 * 53
a= 2 r=780 a^(r/2) = N-1 -> trivial, retry
a= 3 r=260 a^(r/2)=794 gcds: 61, 53
a=13 r= 39 odd -> retry with another a
RSA recovered: 1337
Three of the first thirteen bases fail for one of the two reasons. shor_factor
handles the retry loop; the manual version is here because challenges sometimes ask you
to show the order and the base.
Gotchas
- Grover at M/N ≈ 1/2 looks uniform. Shown above. Add a qubit or mark fewer states.
- Too many iterations un-amplifies. The amplitude oscillates. Doing 2× the optimal iteration count sends the marked probability back down toward zero. Trust the formula; do not "run it a few more times to be sure".
- Oracle bitstrings are big-endian.
phase_oracle's docstring is explicit: leftmost char = most significant qubit. Aer's counts keys are also printed most-significant-first, so target strings and counts keys agree with each other — but both are the reverse of qubit index order. Do not re-reverse. - Grover needs a phase oracle, not a boolean one. If the challenge supplies a circuit computing f(x) into an ancilla, put the ancilla in |−> (X then H) so the bit flip becomes a phase flip.
- Shor's odd-order and x = N−1 cases. Both give only trivial factors; retry with a different base rather than concluding N is prime.
- Do not build a quantum order-finding circuit at CTF scale. Factoring even 3233 quantumly needs enough qubits and depth that Aer will be slow, and the answer is identical. Save that for a writeup.
- Even N shortcut.
shor_factorreturns(2, N//2)immediately for even N — check that this is the factorisation the challenge wanted, not the full prime decomposition.
Toolkit
from algorithms import phase_oracle, grover, grover_search, multiplicative_order, shor_factor
phase_oracle(targets: list[str], n: int) -> QuantumCircuit # big-endian bitstrings
grover(targets: list[str], n: int, iterations: int | None = None,
shots: int = 2000) -> dict[str, int] # iterations auto if None
grover_search(targets: list[str], n: int, shots: int = 2000) -> str # top bitstring
multiplicative_order(a: int, N: int) -> int # raises if gcd(a,N) != 1
shor_factor(N: int) -> tuple[int, int] # (p, q) with p*q == N
Post-quantum crypto
Recognise it in 10 seconds
Names: Kyber, ML-KEM, Dilithium, ML-DSA, Falcon, SPHINCS+, liboqs, "NIST PQC". Shape: a service that does a handshake, plus source. Byte-length fingerprints for the default parameter sets:
| Object | ML-KEM-768 | ML-DSA-65 |
|---|---|---|
| public key | 1184 B | 1952 B |
| secret key | 2400 B | 4032 B |
| ciphertext / signature | 1088 B | 3309 B |
| shared secret | 32 B | — |
A 1088-byte blob in a handshake is an ML-KEM-768 ciphertext. That is often the fastest identification available.
Concept
Nobody is breaking Module-LWE in a weekend. These challenges are protocol-misuse bugs wearing a lattice costume — the same class of bug as ECDSA nonce reuse, in a primitive people have not built habits around yet. The SandboxAQ RWPQC 2024 CTF and the Quarkslab writeup of it are the canonical worked collection; both are worth 30 minutes of reading before the con, because the bug families recur.
| Misuse | What you look for in the source | Attack |
|---|---|---|
| Shared secret used directly as key/keystream | KEM output fed to XOR or AES without a KDF | Two-time pad if it repeats; low entropy if truncated |
| Encapsulation randomness reused | A fixed seed, a cached ciphertext, a global RNG seeded once | Same ciphertext → same shared secret → keystream reuse |
| Decapsulation-failure oracle | Server distinguishes "bad ciphertext" from "bad MAC", or times differently | Chosen-ciphertext key recovery; look for the distinguisher first |
| Signature verified without binding to identity | Attacker supplies both the signature and the public key | Sign your own message under your own key; it verifies |
| Truncated shared secret | ss[:8], ss[:4], hex-then-truncate | Brute force the remaining bits |
| Message not covered by the signature | Signature over a digest of only part of the message | Vary the uncovered part freely |
| Ciphertext not authenticated separately | Skipping the AEAD tag check because "the KEM is CCA-secure" | Malleability on the symmetric layer |
Procedure
- Identify the algorithm and parameter set from the byte lengths or the source.
- Reproduce the honest protocol locally with the toolkit. Confirm you can do a clean encaps/decaps or sign/verify round trip against the challenge's own transcript. Until that works you cannot tell a bug from your own mistake.
- Read the source for the misuse list above. Grep for a fixed seed, a
[:n]slice on a secret, a missing comparison, an exception handler that leaks which check failed. - Diff behaviour: send a valid input and a tampered one, compare responses byte for byte and by timing. Any difference is the oracle.
- Exploit the bug classically. The lattice math never enters.
Worked example — three misuses
import sys; sys.path.insert(0, "toolkit")
from pqc import (kem_alg, sig_alg, kyber_keypair, kyber_encaps, kyber_decaps,
dilithium_keypair, dilithium_sign, dilithium_verify)
from crypto_utils import xor
print("KEM:", kem_alg(), " SIG:", sig_alg())
pk, sk = kyber_keypair()
ct, ss = kyber_encaps(pk)
print("pk", len(pk), "sk", len(sk), "ct", len(ct), "ss", len(ss))
print("decaps matches:", kyber_decaps(sk, ct) == ss)
# 1. Implicit rejection: a tampered ciphertext does NOT raise. It returns a
# different, deterministic secret. A server that doesn't compare gives an oracle.
bad = bytearray(ct); bad[0] ^= 1
ss_bad = kyber_decaps(sk, bytes(bad))
print("tampered ct raised? no")
print("tampered secret differs:", ss_bad != ss)
print("and is deterministic: ", kyber_decaps(sk, bytes(bad)) == ss_bad)
# 2. Shared secret used raw as a keystream, across two messages.
m1 = b"hello world, this is msg one!!!!"
m2 = b"flag{kem_secret_reused_as_otp} "
c1, c2 = xor(m1, ss), xor(m2, ss)
print("recovered m2:", xor(xor(c1, c2), m1))
# 3. Signature verified against an attacker-supplied public key.
vpk, vsk = dilithium_keypair()
epk, esk = dilithium_keypair()
forged = b"transfer 1 BTC to eve"
esig = dilithium_sign(esk, forged)
print("verifies under Eve's pk:", dilithium_verify(epk, forged, esig))
print("verifies under real pk: ", dilithium_verify(vpk, forged, esig))
Output:
KEM: ML-KEM-768 SIG: ML-DSA-65
pk 1184 sk 2400 ct 1088 ss 32
decaps matches: True
tampered ct raised? no
tampered secret differs: True
and is deterministic: True
recovered m2: b'flag{kem_secret_reused_as_otp} '
verifies under Eve's pk: True
verifies under real pk: False
Misuse 1 is the one that surprises people. ML-KEM uses implicit rejection: a malformed ciphertext yields a pseudorandom secret derived from the secret key rather than an error. That is by design and it is what makes the KEM CCA-secure — but only if the application never reveals whether decapsulation "worked". Misuse 3 is the classic key-substitution setup: the signature is valid, it is just valid under the wrong key, and a server that takes the public key from the same request as the signature has verified nothing.
Gotchas
- The import is
oqs, the package isliboqs-python. It needs the liboqs C library present (brew install liboqs). Verify this works before the con, not during it — a failed native build is a 40-minute hole. - liboqs prints
liboqs-python faulthandler is disabledon stderr. That is a startup notice, not an error. Do not chase it. - Naming. ML-KEM-768 and Kyber768 are different algorithms with
different byte layouts (ML-KEM is the standardised, hash-tweaked version). This build
has both enabled. Pass the exact name the challenge uses via the
alg=parameter;kem_alg()defaults to ML-KEM-768 and only falls back to Kyber768. kyber_keypair()andkyber_encaps()each open their ownoqscontext. To reproduce a challenge's exact keys you need its seed or its raw key bytes, not a fresh keypair from these helpers.dilithium_verifyreturns a bool, it does not raise. Check the value; a bare call in anif-less script silently does nothing.- Do not attempt lattice cryptanalysis. If two hours of source reading turns up no misuse, the bug is somewhere you have not read yet — the transport, the padding, the framing — not in the lattice.
Toolkit
from pqc import (kem_alg, sig_alg,
kyber_keypair, kyber_encaps, kyber_decaps,
dilithium_keypair, dilithium_sign, dilithium_verify)
kem_alg() -> str # "ML-KEM-768", else "Kyber768"
sig_alg() -> str # "ML-DSA-65", else "Dilithium3"
kyber_keypair(alg=None) -> (public_key, secret_key)
kyber_encaps(public_key, alg=None) -> (ciphertext, shared_secret)
kyber_decaps(secret_key, ciphertext, alg=None) -> shared_secret
dilithium_keypair(alg=None) -> (public_key, secret_key)
dilithium_sign(secret_key, message: bytes, alg=None) -> bytes
dilithium_verify(public_key, message: bytes, signature: bytes, alg=None) -> bool
Misc, stego, trivia
Recognise it in 10 seconds
Low point value, a file attachment with no explanation, an image, or a question with a factual answer. If the prompt does not describe a computation, it is this category. DEF CON 32 had a 10-point challenge that was an image requiring Cyrillic transcription — no quantum content at all, just "read the picture and type what it says".
Concept
These exist to seed the scoreboard and unlock the category. They reward mechanical thoroughness, not insight. Run the standard checks in order and stop when something pops.
Procedure — file triage
file chall.bin # what is it really, ignore the extension
xxd chall.bin | head -5 # magic bytes; PNG=89504e47 JPG=ffd8ff PDF=%PDF PK=zip
strings -n 6 chall.bin | grep -iE 'flag|ctf|qv\{|key'
exiftool chall.png # comments, GPS, software fields, custom tags
binwalk -e chall.png # appended/embedded archives
unzip -l chall.png # a PNG with a zip stapled on is common
zsteg chall.png # PNG/BMP LSB, all channel/bit orders
steghide extract -sf chall.jpg # JPEG, tries an empty passphrase first
Procedure — encoding chains
Decode outermost first, re-inspect at each step, keep going until it is printable.
Recognition: = padding and mixed case → base64; only 0-9a-f and even
length → hex; all uppercase A-Z and 2-7 → base32; %xx → URL;
\uXXXX → JSON unicode; a single repeated shift → ROT13/Caesar.
Worked example — decode chain, flag sweep, LSB
import sys; sys.path.insert(0, "toolkit")
from crypto_utils import unb64, xor
import binascii, re
from PIL import Image
# 1. base64 -> hex -> single-byte XOR
enc = b"NGM0NjRiNGQ1MTQ2NGI1MzRmNTg1OTc1NGI0NjQ2NzU1ZTQyNGY3NTVkNGI1Mzc1NGU0NTVkNDQ1Nw=="
s1 = unb64(enc); print("b64 ->", s1[:24], "...")
s2 = binascii.unhexlify(s1); print("hex ->", s2[:12])
print("xor 0x2a ->", xor(s2, b"\x2a"))
# 2. Flag regex over any blob, run this on every artefact you produce
blob = b"\x00\x01junk" + xor(s2, b"\x2a") + b"more\xff"
print(re.findall(rb"[Ff][Ll][Aa][Gg]\{[^}]{1,80}\}", blob))
# 3. LSB of the red channel, row-major, MSB-first
im = Image.open("stego.png").convert("RGB")
bits = "".join(str(im.getpixel((i % im.width, i // im.width))[0] & 1)
for i in range(im.width * im.height))
out = bytes(int(bits[i:i+8], 2) for i in range(0, len(bits) // 8 * 8, 8))
print("LSB ->", out.split(b"\x00")[0])
Output:
b64 -> b'4c464b4d51464b534f585975' ...
hex -> b'LFKMQFKSOXYu'
xor 0x2a -> b'flag{layers_all_the_way_down}'
[b'flag{layers_all_the_way_down}']
LSB -> b'flag{lsb_in_the_red_channel}'
The single-byte XOR key (0x2a) was found by trying all 256 values. Filtering only on
"all bytes printable" left 63 surviving candidates on this 29-byte input, so filter on the
flag pattern instead, or eyeball the shortlist. If LSB-red gives nothing, iterate the other five combinations (green, blue, and
column-major for each) plus LSB-first bit order; zsteg does all of them at
once if it is installed.
Trivia
Quantum-history questions have short factual answers, and the answer is the flag or goes into a fixed wrapper. Worth having ready: Bennett and Brassard published BB84 in 1984; Ekert's entanglement-based protocol is E91 (1991); Shor 1994, Grover 1996; CHSH is Clauser–Horne–Shimony–Holt 1969, following Bell 1964; the no-cloning theorem is Wootters and Zurek 1982; Tsirelson's bound is 2√2 ≈ 2.828 for the CHSH correlator, equivalently a 0.8536 win rate; Deutsch–Jozsa 1992; Bernstein–Vazirani 1993; the four Bell states are Φ⁺, Φ⁻, Ψ⁺, Ψ⁻; teleportation costs one Bell pair and two classical bits.
Gotchas
- Submission format. Case, braces, and surrounding whitespace are usually checked verbatim. If the flag is a transcription or a trivia answer, try the literal string, then lowercase, then with underscores for spaces.
- Non-Latin text. Cyrillic and Greek transcription challenges want the characters typed as shown, not transliterated. Copy from a character picker rather than guessing the mapping.
- Homoglyphs. Cyrillic
а е о р с хrender identically to Latina e o p c xand will fail a byte-comparison. If a transcription looks right and is rejected, that is why. - Do not over-think a 10-pointer. If it has taken more than 15 minutes, you have invented a puzzle the author did not write. Re-read the prompt literally.
- Re-run the flag regex on every intermediate artefact, including
binwalkoutput directories and any bytes you produce mid-chain. The flag is frequently one layer earlier than you assumed.
Toolkit
from crypto_utils import b64, unb64, xor, bits_to_bytes, aes_decrypt
b64(data: bytes) -> str
unb64(s: str) -> bytes
xor(a: bytes, b: bytes) -> bytes # repeating key; b cycles to a's length
bits_to_bytes(bits: list[int] | str) -> bytes
aes_decrypt(ciphertext, key, iv=None, mode="CBC")
Single-byte XOR brute force, the highest-yield four lines in this category:
import re
for k in range(256):
out = xor(data, bytes([k]))
if re.search(rb"[Ff][Ll][Aa][Gg]\{", out): # tighten to the flag pattern
print(k, out)
# fall back to `if all(32 <= c < 127 for c in out)` if the format is unknown --
# it is noisy on short inputs but catches non-flag{} wrappers