SteganographyHidden Investigations CTF 2026
Hall of Mirrors
The handout is a 26-minute stereo WAV (48 kHz, int16). Its spectrogram contains text that gives a 64-bit seed K. That seed drives a pseudo-random LSB schedule, which hides a bytecode image for a custom VM.
What this one teaches
- A chained steg challenge where each stage gates the next — a verifiable checkpoint (magic + CRC) settles ambiguous spectrogram readings quickly.
- Recognising biased-nonce ECDSA as the Hidden Number Problem and solving it with LLL on a Kannan embedding.
- Emulator correctness matters: a single Python operator-precedence bug in immediate decoding can silently derail the whole VM execution.
Task description
A field recording from the atrium — twenty-six minutes of rain on glass, wind, and a drone that never quite resolves. The ensemble swears nothing was added in post. The archivist disagrees: she says the room is answering itself, and that if you look at the recording instead of listening to it, you can see where. Every reflection in here points at another one. Only one of them points out.
Hint: archivist-datasheet.zip — extracted to
files/archivist_datasheet.txt
| Category | steg (chain: steg -> custom VM reverse -> ECC lattice crypto) |
| Points | 500 (0 solves before ours, first blood) |
| Platform ID | 94f0bbb8-c6c1-491b-b576-90309bbf01d3 |
| Flag | hiCTF{h1dd3n_numb3r_l4ttice_le4k} (confirmed by the platform) |
Artifact
- ZIP SHA-256
b83e44399c1103b1dcea13690349fa6493360a0c2ac5cc0b4fad4c882ab4c7e0 - WAV: 48,000 Hz, 2 channels, signed 16-bit PCM, 74,880,000 frames (1,560 s). The PCM
datachunk starts at byte 216 and is 299,520,000 bytes long, which is 149,760,000 interleaved samples. - The metadata claims “layered atrium ambience, binaural pair, 48k/16, unprocessed”. This is flavour text.
The spectrogram mirror region
The description says “if you look at the recording instead of listening to it, you can see where.” We took the STFT of L+R, divided each bin by its median over time, and subtracted the off-carrier noise. This shows painted text between about t = 1180 s and 1245 s, in two bands that are placed symmetrically around ~10 kHz. Both bands use tone carriers spaced about 194 Hz apart.
- Upper band (~10.7-13.5 kHz). Our early passes read this as “MIRRORS I SHOW A GAP” and
treated it as a decoy, because none of the variants was the flag. A later, cleaner pass read
it as
MIRRORS1 SM64 / GAP / PAIR. Read that way, it is a description of the carrier: the frame magicMIRRORS1, a SplitMix64 PRNG, and a gap-based (skip) schedule. Either way it is neither the key nor the flag. - Lower band (~6.8-9.3 kHz). It reads
K=9C4F1E7A3B50D682, the 64-bit SplitMix64 seed. The two bands carry different text. If you fold or average them together, as early attempts did, the key text gets buried.
Decoys
hiCTF{just_lsb_n0ise}. Plain interleaved-sample LSBs, packed MSB-first, contain this string at packed byte offset 512 (sample index 4096), followed by a NUL. It is a real string in the file but it is a planted decoy. The name gives it away, and so does the description: “Every reflection in here points at another one. Only one of them points out.”- The “aGlDVEZ7” misread. One early pass read the lower band as 16 Base64 characters. The
first eight,
aGlDVEZ7, decode tohiCTF{, so it looked like the flag itself. That reading was wrong. The glyphs are the hex digits ofK. The look-alike pairsB/8andO/0were compared directly in glyph comparison renders.
The archivist datasheet hint
Before the hint came out, we knew K but not how it was used. Thousands of guessed schedules
(gap moduli and bases, sample pairing, channel selection, mask variants) all failed.
The released hint, files/archivist_datasheet.txt, is a fake hardware datasheet that specifies
all three stages exactly:
- the MirrorStego v1 carrier schedule and frame format
- the MVM1 VM: image layout, register file, R15 tamper hash, and the full ISA including self-modifying code
- the TLV record types, the KDF, and the ARX stream cipher
It does not say how to recover d. That still requires recognising the biased-nonce HNP from
the record contents.
Stage 1: the MirrorStego v1 carrier
The bits live in low_byte(S[i]), where S is the interleaved int16 LE sample array. The low
byte of a little-endian int16 is its first byte, so the whole stream is just data_chunk[0::2].
The schedule uses standard SplitMix64 seeded with K. It starts at p = -1 and makes two draws
per hidden bit:
d1 = next(); p += 1 + (d1 % 4096)
d2 = next(); w = (d2 >> 17) & 0xFF
bit = popcount(low_byte(S[p]) ^ w) & 1 # packed MSB-first into bytes
The frame is "MIRRORS1" | payload_len (u32 LE) | crc32 (u32 LE) | payload.
With K = 0x9C4F1E7A3B50D682 (the hex digits taken as a big-endian integer):
- the magic is
MIRRORS1 payload_len= 6266- crc32 =
0xE2046FB1, which matches the crc32 we compute over the extracted payload
The byte-reversed seed 0x82D6503B7A1E4F9C produces no magic. The magic plus a matching CRC
confirms both the key and the schedule.
import struct, zlib
MASK = (1 << 64) - 1
POP = bytes(bin(i).count('1') for i in range(256))
class SM64:
__slots__ = ('s',)
def __init__(self, seed):
self.s = seed & MASK
def next(self):
self.s = (self.s + 0x9E3779B97F4A7C15) & MASK
z = self.s
z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & MASK
z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & MASK
z = z ^ (z >> 31)
return z
def extract_bits(low, K, nbits):
rng = SM64(K)
p = -1
out = bytearray()
cur = 0; nb = 0
for _ in range(nbits):
d1 = rng.next()
p += 1 + (d1 % 4096)
d2 = rng.next()
w = (d2 >> 17) & 0xFF
if p >= len(low):
return None
bit = POP[low[p] ^ w] & 1
cur = (cur << 1) | bit
nb += 1
if nb == 8:
out.append(cur); cur = 0; nb = 0
return bytes(out)
# Load low bytes from WAV (low byte of each int16 LE sample = data_chunk[0::2])
raw = open('hall-of-mirrors.wav', 'rb').read()
idx = raw.find(b'data')
size = struct.unpack('<I', raw[idx+4:idx+8])[0]
low = raw[idx+8:idx+8+size:2]
K = 0x9C4F1E7A3B50D682
hdr = extract_bits(low, K, 16 * 8)
assert hdr[:8] == b'MIRRORS1'
plen, crc = struct.unpack('<II', hdr[8:16])
full = extract_bits(low, K, (16 + plen) * 8)
payload = full[16:16 + plen]
assert zlib.crc32(payload) & 0xffffffff == crc
# payload is a 6266-byte MVM1 VM image
Stage 2: the MVM1 processor
Image
The header is "MVM1" | code_len=6107 | data_len=143 | entry=0 | rsv=0. CODE is
payload[16:6123] and the initial DATA is payload[6123:6266]. The VM has separate 64 KiB CODE
and DATA spaces, sixteen u64 registers, and a 256-entry call stack.
R15 tamper hash
R15 starts at the FNV-64 offset basis 0xCBF29CE484222325. It is updated on every instruction
fetch, before decode:
R15 = ((R15 ^ opcode) * 0x100000001B3) mod 2^64
R15 ^= R15 >> 31
The hash depends on every opcode fetched so far, NOPs included, so the emulator has to fetch exactly as the real machine would.
Entry stub
0000 IMM R0, 1
000A JZ R0, 0x0100 ; guard: not taken when R0 is correctly 1
000E IMM R1, 0x0200
0018 IMM R2, "PHANTOM\0" ; 0x004D4F544E414850 bytes LE
0022 SMC R1, R2, 0x0100 ; decrypt CODE[512:768], seed = R2 ^ R15
0026 JMP 0x0200
SMC sets x = R[rs] ^ R15, or the constant 0x9E3779B97F4A7C15 if that value is 0. For each
byte it applies xorshift64 (x ^= x<<13; x ^= x>>7; x ^= x<<17) and XORs (x >> 24) & 0xFF into
CODE[(R[rd]+i) & 0xFFFF]. The seed includes R15 at the moment of the SMC, so a wrong opcode
history gives a wrong key and garbage code. Execution then jumps into the decrypted region and
runs to HLT. The OUT bytes it writes make up a 5083-byte TLV blob that starts with
MIRRORS1.
Bug we hit and fixed
Our first emulator stopped after 10 instructions with OUT = b"h". The cause was the IMM
immediate slice CODE[(p+2)&0xFFFF:(p+2)&0xFFFF+8]. In Python, + binds tighter than &, so
the end index became (p+2) & 0x10007. That made the slice empty and every IMM loaded 0.
As a result R0 was 0 and the JZ guard at 0x000A jumped to 0x0100, which is a short dead
end. Reading the eight immediate bytes one by one with explicit masking fixed it. A trace that
shows the actual register values next to the disassembly finds this kind of bug quickly.
Stage 3: TLV and ECC unsealing
TLV parsing
After MIRRORS1, the records are type (u8) | len (u16 LE) | value, ending at type 0x00:
| Type | Content |
|---|---|
| 01-06 | n, p, a=0, b=7, Gx, Gy, which is exactly secp256k1 |
| 07 | public key Q (x, y) |
| 08 | L = 12 |
| 09 | count = 48 |
| 0A x 48 | tuples of three 32-byte big-endian integers |
| 0B | sealed blob, 33 bytes |
Recovering d
Each tuple is an ECDSA signature stored as (h, r, s). We found that order by trying all six
orders and keeping the one that verifies. L = 12 means each nonce has its top 12 bits set to
zero, so 0 <= k_i < 2^244.
From s*k = h + r*d (mod n), define t_i = r_i/s_i and u_i = h_i/s_i (mod n). Then
t_i * d + u_i ≡ k_i (mod n), 0 <= k_i < W = 2^(256-L)
This is the Hidden Number Problem with bounded error. We built the standard integer-scaled Kannan embedding of dimension m+2, using m = 40 signatures:
q^2on the diagonal- a row
q*t_j mod q^2with cornerW - a row
q*c_j mod q^2withc_j = -u_jand cornerq*W
After LLL, the row whose last entry is ±q*W gives d = row[m] / W.
import sys
sys.path.append(r'<path-to-flint-site-packages>')
import flint
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
def solve_hnp(rs, ss, hs, L, nsamp=40):
n = min(len(rs), nsamp)
rs, ss, hs = rs[:n], ss[:n], hs[:n]
W = 1 << (256 - L)
q = N
ts = [rs[i] * pow(ss[i], -1, q) % q for i in range(n)]
cs = [(-hs[i] * pow(ss[i], -1, q)) % q for i in range(n)]
rows = [[0] * (n + 2) for _ in range(n + 2)]
for i in range(n):
rows[i][i] = q * q
for j in range(n):
rows[n][j] = (q * ts[j]) % (q * q)
rows[n + 1][j] = (q * cs[j]) % (q * q)
rows[n][n] = W
rows[n + 1][n + 1] = q * W
M = flint.fmpz_mat(rows).lll()
red = [[int(M[i, j]) for j in range(M.ncols())] for i in range(M.nrows())]
for row in red:
if abs(row[n + 1]) == q * W and row[n] % W == 0:
return (row[n] // W) % q
return None
d = 0x278c1c0ea08921ba895d3d4c971e4e0a8c8b29d8016f42d9861fe49c6fa826c6
We checked it by computing d·G = Q on secp256k1. LLL took about 1 second.
KDF and ARX unseal
The KDF is applied to d:
st = 0x4D6972726F72734B ("MirrorsK"); D = d.to_bytes(32,'big')
for rnd in 0..3:
for b in D: st=(st^b)*0x100000001B3; st^=st>>29; st*=0x9E3779B97F4A7C15; st^=st>>32
st += rnd*0xFF51AFD7ED558CCD; out += st.to_bytes(8,'big')
The resulting key is a34c6fd7c69371c4c72e14d046b00a2c147f00067dfff01aaec8ef0097d10fdc.
For the stream cipher, the key is loaded as four little-endian u64 words a, b, c, d. For each
8-byte block:
ctr += 0x9E3779B97F4A7C15, thend ^= ctr- four ChaCha-style ARX quarter rounds with rotations 32/24/16/37
- emit
(a ^ c)as 8 little-endian bytes
M = (1 << 64) - 1
def kdf(d):
st = 0x4D6972726F72734B
D = d.to_bytes(32, 'big')
out = b""
for rnd in range(4):
for b in D:
st = ((st ^ b) * 0x100000001B3) & M
st ^= st >> 29
st = (st * 0x9E3779B97F4A7C15) & M
st ^= st >> 32
st = (st + rnd * 0xFF51AFD7ED558CCD) & M
out += st.to_bytes(8, 'big')
return out
def rotl(v, r):
return ((v << r) | (v >> (64 - r))) & M
def stream(key, n):
a, b, c, d = (int.from_bytes(key[i*8:i*8+8], 'little') for i in range(4))
ctr = 0
ks = b""
while len(ks) < n:
ctr = (ctr + 0x9E3779B97F4A7C15) & M
d ^= ctr
for _ in range(4):
a = (a + b) & M; d = rotl(d ^ a, 32)
c = (c + d) & M; b = rotl(b ^ c, 24)
a = (a + b) & M; d = rotl(d ^ a, 16)
c = (c + d) & M; b = rotl(b ^ c, 37)
ks += (a ^ c).to_bytes(8, 'little')
return ks[:n]
d_val = 0x278c1c0ea08921ba895d3d4c971e4e0a8c8b29d8016f42d9861fe49c6fa826c6
key = kdf(d_val)
pt = bytes(x ^ y for x, y in zip(sealed_blob, stream(key, len(sealed_blob))))
# hiCTF{h1dd3n_numb3r_l4ttice_le4k}
XORing that stream with the 33-byte sealed blob gives:
hiCTF{h1dd3n_numb3r_l4ttice_le4k}
Tooling notes
- Python 3.14 on Windows. All scripts run with
python -I, so nothing in the current directory orfiles/can be imported by accident. fpylllfailed to build its wheel on this machine. We used python-flint (pip install python-flint), whosefmpz_mat.lll()handled the 42x42 lattice with ~512-bit entries in about 1 second. A pure-Python Fraction-based LLL was far too slow at this size.- python-flint landed in the user site-packages, which
-Iexcludes.stage3_ecc.pytherefore adds that path tosys.pathexplicitly. - Stages 1, 2 and the unseal need only the standard library. Stage 1 runs on a plain
bytesslice of low bytes, with no numpy.
Lessons
- In a chained challenge, a verifiable checkpoint (here magic + CRC) settles ambiguous readings
quickly. Once the exact schedule was known, the earlier
Kreading worked on the first try. - Prior blind searches failed because the schedule was unknown, not because
Kwas wrong. - Flag-shaped strings found outside the intended pipeline (
just_lsb_n0ise, the Base64 misread) were decoys. - When an emulator halts suspiciously early, check operand decoding before suspecting the hash or the SMC.
The flagReveal
hiCTF{h1dd3n_numb3r_l4ttice_le4k}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.