$ cat writeup.md…
$ cat writeup.md…
HackTheBox
Task: recover a 64-bit vault key from a single noisy Qiskit oracle while satisfying structural ancilla-validation constraints. Solution: submit the smallest valid CX-only circuit, aggregate 4096 measurement results with per-bit majority voting, then reverse the recovered bitstring to match Qiskit's endianness.
Noisy Vault Challenge Scenario
The archive contained quantum_noisy_vault/server.py. The service prepares a random 64-bit secret key on 64 data qubits and exposes 16 ancilla qubits. We are allowed exactly one oracle query and one final unlock attempt, so the whole attack must recover the secret from a single noisy measurement batch.
The key idea is to satisfy the validator with the smallest possible corrective circuit, so we do not disturb the already-encoded secret more than necessary. A chain of 16 CX gates from data qubits 0..15 into ancillas 64..79 passes validation after transpilation, while leaving the data qubits themselves unchanged in the computational basis.
The oracle then returns noisy measurement counts over 4096 shots. Because the circuit mostly preserves the prepared basis state and the noise rates are modest, each secret bit still appears more often than its flipped value. Taking a per-bit majority vote across all returned bitstrings recovers the full key, and reversing the final bitstring fixes Qiskit's classical-bit endianness.
Reading server.py shows the important constraints:
visible_bits = 64ancilla_qubits = 16max_oracle_calls = 1get_counts() from 4096 noisy shotsThe validator does not care whether our circuit actually corrects noise. It only checks structural activity after transpile(..., optimization_level=3):
total_data_qubits // 4 = 16 data-ancilla linksancilla_qubits // 4 = 4 active ancillasSo the goal is not fancy error correction. The goal is to pass validation while touching the data register as little as possible.
This was enough:
CX:0,64;CX:1,65;CX:2,66;CX:3,67;CX:4,68;CX:5,69;CX:6,70;CX:7,71;CX:8,72;CX:9,73;CX:10,74;CX:11,75;CX:12,76;CX:13,77;CX:14,78;CX:15,79
Why this works:
CX|0⟩, so the data qubit is only used as control and its classical value is preservedThis gives the validator what it wants without adding unnecessary disturbance to the secret-bearing qubits.
The server prepares a computational-basis state corresponding to the secret key by applying X only on qubits whose secret bit is 1. If we avoid destructive transformations on the data qubits, every shot ideally measures the same 64-bit string.
Noise changes some outcomes:
CX gates add some extra two-qubit noise, but only on part of the registerStill, with 4096 shots and relatively small error probabilities, the true value of each bit remains biased above 50%. That means we do not need the most common full 64-bit string. We can recover each bit independently by counting how many shots produced 1 in that position and taking the majority.
This is more robust because different shots can have noise in different positions. Even if no single complete bitstring dominates, the correct value of each individual bit still dominates.
result.get_counts() returns classical bitstrings in Qiskit's usual order, where the leftmost character corresponds to the highest-index classical bit. Since the server measures range(self.total_data_qubits) directly into range(self.total_data_qubits), the recovered count strings appear reversed relative to the secret key's original qubit indexing.
So after building the majority-voted bitstring, it must be reversed:
recovered = measured[::-1]
Without this reversal, the unlock attempt fails even if the per-bit voting is otherwise correct.
CX links.counts dictionary returned from 4096 shots.1 at that position.1 if that count is greater than half the total shots, otherwise 0.Remote recovered key:
1000011100010101100000000100010101101000000101100011001000100111
Remote result:
[+] Access Granted! The vault opens: HTB{Qu4nTUm_n01s3_c4nt_st0p_th3_v4ult_h4ck!}
Local solver path:
tasks/hackthebox/Noisy_Vault_Challenge_Scenario/solver.py
Minimal solver logic:
#!/usr/bin/env python3 import json DATA_QUBITS = 64 ANCILLA_BASE = 64 CIRCUIT = ";".join(f"CX:{i},{ANCILLA_BASE + i}" for i in range(16)) def decode_counts(counts): totals = [0] * DATA_QUBITS shots = sum(counts.values()) for bitstring, count in counts.items(): for i, bit in enumerate(bitstring): if bit == "1": totals[i] += count measured = "".join("1" if total > shots / 2 else "0" for total in totals) return measured[::-1] # Qiskit bitstring order # 1) send oracle choice # 2) send CIRCUIT # 3) parse JSON counts from server output # 4) key = decode_counts(counts) # 5) send unlock choice, then send key
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar