Neven Marić
Hidden Investigations CTF 2026

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.

  • steganography
  • spectrogram
  • audio
  • lsb
  • prng
  • splitmix64
  • custom-vm
  • ecc
  • ecdsa
  • hnp
  • lll
  • lattice
  • arx-cipher

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 data chunk 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 magic MIRRORS1, 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 to hiCTF{, so it looked like the flag itself. That reading was wrong. The glyphs are the hex digits of K. The look-alike pairs B/8 and O/0 were 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:

  1. the MirrorStego v1 carrier schedule and frame format
  2. the MVM1 VM: image layout, register file, R15 tamper hash, and the full ISA including self-modifying code
  3. 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^2 on the diagonal
  • a row q*t_j mod q^2 with corner W
  • a row q*c_j mod q^2 with c_j = -u_j and corner q*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, then d ^= 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 or files/ can be imported by accident.
  • fpylll failed to build its wheel on this machine. We used python-flint (pip install python-flint), whose fmpz_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 -I excludes. stage3_ecc.py therefore adds that path to sys.path explicitly.
  • Stages 1, 2 and the unseal need only the standard library. Stage 1 runs on a plain bytes slice 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 K reading worked on the first try.
  • Prior blind searches failed because the schedule was unknown, not because K was 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 flagRevealhiCTF{h1dd3n_numb3r_l4ttice_le4k}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.