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.

On this page
  1. Triage: how to work the board
  2. QASM circuit golf
  3. Q# katas and unitary discrimination
  4. BB84 / QKD
  5. CHSH / Bell game
  6. Grover and Shor
  7. Post-quantum crypto
  8. Misc, stego, trivia

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Submit partials. Flag formats are usually checked verbatim; try the obvious wrapper (flag{...}, QV{...}, DDC{...}) if the prompt does not state one.
PointsWhat it usually isTarget time
10–50Intro/unlock: trivia, a two-gate circuit, a transcription, one strings run< 10 min
50–150One standard technique applied cleanly (sift a transcript, run Grover, factor N)15–30 min
150–300Build 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 clearedopen-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

  1. Write the obvious correct circuit in Qiskit first. Correctness before length.
  2. Emit it with to_qasm(qc) and count with line_count().
  3. 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, and h q; applies H to every qubit of q in one line), drop gates that cancel, and drop the final measurement entirely if the checker inspects the statevector.
  4. Re-load the golfed QASM with load_qasm() and simulate it to confirm you did not break it while shortening.
  5. Submit and keep the pre-golf version in a scratch file in case the checker disagrees about behaviour.

Rewrites that buy lines

Long formShort formVerified
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 forMinimal QASM bodyOperative lines
Bell pair (|00>+|11>)/√2h q[0]; cx q[0],q[1];2
3-qubit GHZh q[0]; cx q[0],q[1]; cx q[0],q[2];3
XOR/parity of q0,q1 into ancilla q2cx q[0],q[2]; cx q[1],q[2];2
Parity of n qubits into an ancillaone cx q[i],q[anc]; per inputn
AND of q0,q1 into q2 (Toffoli)ccx q[0],q[1],q[2];1
Uniform superposition over n qubitsh q;1
A given truth table fX-conjugate each input pattern around a ccx/mcxvaries

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

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:

  1. 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.
  2. 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

  1. Write both unitaries as 2×2 matrices.
  2. 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.
  3. 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.
  4. 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).
  5. 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

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.

BasisCodebit 0bit 1Symbols you may see
Rectilinear / Z0|0>|1>+, Z, R, ↑→
Diagonal / X1|+>|−>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

  1. Parse the three strings into 0/1 lists. Normalise whatever symbols the challenge used to the 0 = Z / 1 = X convention.
  2. Sift: keep positions where Alice's basis equals Bob's basis. Expect ~50% survival — if you get ~100% or ~0%, you mis-parsed.
  3. 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".
  4. 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.
  5. 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

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:

Playerinput 0input 1
Alice0π/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

  1. 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.
  2. Compute the strategy offline first. quantum_winrate() should print ~0.854; if it does not, fix that before touching the socket.
  3. 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.
  4. 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.
  5. 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

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 likeAlgorithm
"find the input that satisfies this predicate", "which of the 2^n states is marked", an oracle circuit you can call, a small search spaceGrover
"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

  1. 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.
  2. Compute iterations: floor((pi/4) * sqrt(2**n / len(targets))), minimum 1. The toolkit does this when iterations is left None.
  3. Run and read the top outcomes. With M targets, all M should sit clearly above the rest.
  4. 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

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:

ObjectML-KEM-768ML-DSA-65
public key1184 B1952 B
secret key2400 B4032 B
ciphertext / signature1088 B3309 B
shared secret32 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.

MisuseWhat you look for in the sourceAttack
Shared secret used directly as key/keystreamKEM output fed to XOR or AES without a KDFTwo-time pad if it repeats; low entropy if truncated
Encapsulation randomness reusedA fixed seed, a cached ciphertext, a global RNG seeded onceSame ciphertext → same shared secret → keystream reuse
Decapsulation-failure oracleServer distinguishes "bad ciphertext" from "bad MAC", or times differentlyChosen-ciphertext key recovery; look for the distinguisher first
Signature verified without binding to identityAttacker supplies both the signature and the public keySign your own message under your own key; it verifies
Truncated shared secretss[:8], ss[:4], hex-then-truncateBrute force the remaining bits
Message not covered by the signatureSignature over a digest of only part of the messageVary the uncovered part freely
Ciphertext not authenticated separatelySkipping the AEAD tag check because "the KEM is CCA-secure"Malleability on the symmetric layer

Procedure

  1. Identify the algorithm and parameter set from the byte lengths or the source.
  2. 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.
  3. 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.
  4. Diff behaviour: send a valid input and a tampered one, compare responses byte for byte and by timing. Any difference is the oracle.
  5. 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

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

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