Neven Marić
Hidden Investigations CTF 2026

MiscHidden Investigations CTF 2026

The Collector's Trail

Six famous paintings each hide a steganographic fragment. Fragments ordered by artist birth year feed an HKDF chain that unlocks an AES ZIP, whose stego image points to a GitHub release with the GCM-encrypted flag.

  • misc
  • steganography
  • png
  • jpeg
  • hkdf
  • aes-gcm
  • steghide
  • lsb
  • hmac
  • github-api
  • art-identification
  • multi-stage

What this one teaches

  • Running a multi-stage HKDF key-derivation chain where each stage's output feeds the next — fragment order, catalogue locators, and a serial all contribute.
  • Extracting steganographic payloads using six different techniques (PNG chunks, appended data, alpha channel, steghide, blue-LSB sampling, filter-byte encoding) guided by per-image hints.
  • Looking beyond a GitHub release's body text — the serial was in the annotated git tag object, not the release notes.

Task description

A private collection has been separated from its catalogue. Two archives remain, but neither tells the whole story.

Six works once belonged to the collection. Identify the works and their artists, trace the correct records in the museums’ official collections, and recover what the collector left behind. Six Missing Records

Record I

A young woman turns her gaze toward you. Blue and gold frame her face, while a single pearl catches the light. Her painter belonged to the age of the Dutch masters.

Record II

Under the light of a lantern, a man in white stands with arms spread before a firing squad. Madrid remembers the night, and the painter remembered its horror.

Record III

A quiet village sleeps beneath a restless sky. Stars burn, clouds spiral, and a crescent moon watches over a world transformed by a troubled hand.

Record IV

Two lovers hold each other amid gold, patterns, and ornament. Their painter turned intimacy into a symbol and made gold speak.

Record V

A figure stands upon a bridge, hands pressed to its face. Behind it, the sky burns red. More than one version exists, and not every record tells the same story.

Record VI

Two figures cling together, caught between tenderness and death. Their painter’s life was brief, his lines unmistakable, and the year of his passing was 1918. The Collector’s Warning

A picture may survive a change of frame, yet lose what the eye was never meant to see.

A copy may preserve the scene while forgetting its smallest marks. Keep the originals untouched; a faithful likeness is not always a faithful record.

Six fragments lie scattered, each bearing its own seal. Validate what you recover, then arrange the fragments by the years their artists were born.

The catalogue records hold another part of the answer. Preserve their details, for even a small difference may change the result.

Joined in their proper order, the fragments provide the material needed to derive the key. The catalogue must be accounted for before the cabinet can be opened.

The collector was careful. Be more careful.


Overview

Two archives are provided:

  • image_vault.zip (~36 MB) — six record-xxxx/ folders, each with an image file (no extension) and a conservation.txt hint, plus catalogue/colophon.txt describing the whole mechanism.
  • sealed_cabinet.zip (~1.6 MB) — WinZip-AES-encrypted (compression method 99), containing readme.txt and stego.png.

Each of the six images is a famous painting. Each painting hides an 8-byte fragment (with a 2-byte seal) using a different steganography technique, dictated by its conservation.txt. The six fragments, ordered by artist birth year, feed an HKDF chain that produces the password for the AES zip. Inside the zip, stego.png hides a second payload (HMAC-driven LSB stego) that points to a GitHub release; the final flag is an AES-256-GCM blob whose key is derived from the master key and a serial hidden in the git tag’s annotation.

The whole chain is self-validating: every stage carries a checksum or auth tag, so a correct path is unambiguous.


Approach

Identify the six works

Thumbnailing each image identifies them. Ordered by the artist’s year of birth (the ordering the colophon requires for both fragments and locators):

# Birth Artist Work Vault record Image type
1 1632 Johannes Vermeer Girl with a Pearl Earring record-7f2a PNG RGB
2 1746 Francisco de Goya The Third of May 1808 record-b19d PNG RGB
3 1853 Vincent van Gogh The Starry Night record-31c8 PNG RGBA
4 1862 Gustav Klimt The Kiss record-d604 JPEG
5 1863 Edvard Munch The Scream (1893 version) record-5a73 PNG RGB
6 1890 Egon Schiele Death and the Maiden record-e2f1 PNG RGB

The vault record IDs do not correspond to the description order — each painting must be identified from its pixels.

Recover and validate the six fragments

Every fragment has the form <16 hex chars>-<4 hex chars> = 8 raw bytes + a 2-byte check. The seal is validated as: sha256(the_8_bytes).hexdigest()[:4] == check.

Each conservation.txt tells you where/how its fragment hides:

Record Technique (from the conservation hint) Fragment
7f2a Vermeer “preserved inside the object’s own structure, no label” — custom PNG ancillary chunk coLl (21 ASCII bytes) be5f329a4841bece-ee99
b19d Goya “what remains beyond the final mark” — base64 appended after the IEND chunk b086be35786cac71-8ab3
31c8 Van Gogh “what survives when opacity disappears” — text painted in RGB under a fully transparent (alpha=0) border; drop the alpha channel to reveal c1d322deee71edf2-cfb8
d604 Klimt “asks for an identity, not merely a number … complete catalogue locator” — steghide, passphrase = the Klimt catalogue locator belvedere.at/912 07961017e80fb1e3-b14e
5a73 Munch “repeated interval through the blue … every seventh position, count from zero” — LSB of the blue channel, every 7th pixel from index 0; length-prefixed with 0x15 (21) 7141415e3a146329-c2dc
e2f1 Schiele “what precedes the row’s pixel data; the first mark” — the PNG per-scanline filter-type byte: map filter 1→bit 0, 2→bit 1, MSB-first; length-prefixed with 0x15 74718896e450c77c-e551

The Klimt fragment is special — it is only recoverable once its catalogue locator is known, and a successful steghide extraction doubles as independent proof that the locator is exactly right.

Official museum catalogue locators

The colophon’s normalization: lowercase the domain, drop scheme and leading www., append / + the exact accession identifier as displayed in the museum’s official collection record (preserve capitalization/periods/punctuation; no query/fragment/trailing slash). Ordered by birth year:

mauritshuis.nl/670
museodelprado.es/P000749
moma.org/472.1941
belvedere.at/912
nasjonalmuseet.no/NG.M.00939
belvedere.at/3171
  • Vermeer — Mauritshuis, The Hague, inv. 670
  • Goya — Museo del Prado, Madrid, P000749
  • Van Gogh — MoMA, New York, accession 472.1941
  • Klimt — Belvedere, Vienna, 912 (verified by steghide success)
  • Munch — Nasjonalmuseet, Oslo — the 1893 painted version, NG.M.00939
  • Schiele — Belvedere, Vienna, 3171

Key-derivation chain

All HKDF is HKDF-SHA256. The derivation script validates all six seals, computes the master key, and derives the ZIP password:

import hashlib, hmac

def hkdf(ikm, salt, info, L):
    prk = hmac.new(salt, ikm, hashlib.sha256).digest()
    okm = b""; t = b""; i = 1
    while len(okm) < L:
        t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
        okm += t; i += 1
    return okm[:L]

works = [
    ("Vermeer 1632",  "be5f329a4841bece-ee99", "mauritshuis.nl/670"),
    ("Goya 1746",     "b086be35786cac71-8ab3", "museodelprado.es/P000749"),
    ("Van Gogh 1853", "c1d322deee71edf2-cfb8", "moma.org/472.1941"),
    ("Klimt 1862",    "07961017e80fb1e3-b14e", "belvedere.at/912"),
    ("Munch 1863",    "7141415e3a146329-c2dc", "nasjonalmuseet.no/NG.M.00939"),
    ("Schiele 1890",  "74718896e450c77c-e551", "belvedere.at/3171"),
]

ikm = b""
for name, fr, loc in works:
    h, c = fr.split("-")
    raw = bytes.fromhex(h)
    ok = hashlib.sha256(raw).hexdigest()[:4] == c
    print(f"{name:14} {fr}  seal {'OK' if ok else 'FAIL'}")
    ikm += raw

master = hkdf(ikm, b"collector-v2:ossuary", b"master", 32)
locs = "\n".join(w[2] for w in works)
cat_salt = hashlib.sha256(locs.encode("utf-8")).digest()
pw = hkdf(master, cat_salt, b"zip", 16)
print("ZIP PASSWORD:", pw.hex())

This produces:

master key:   bbb26315a1493bc6e013364b1ccf78ecb7bcc2a3aeb3972f2885f9b2b444b9bf
ZIP PASSWORD: 77dad76b506abec33742bc43b14710e4

The password opens sealed_cabinet.zip (WinZip AES, method 99) to reveal readme.txt + stego.png (1920x1080 RGB).

Stego PRNG extraction

From readme.txt, the image key is:

image_key = HKDF(IKM=master, salt=b"" (empty), info=b"stego", L=32)
          = e2026b389498623406acdcef59177de909c15fbb3f409151b37c23ef89d0ed3e

Treat the image as a row-major sequence of RGB bytes. For counter = 0, 1, 2, …:

pos = int(HMAC_SHA256(key=image_key, msg=counter_as_8byte_BE)[:8], big) % total

Skip already-chosen positions; otherwise record the LSB of that byte. Assemble bits MSB-first into bytes. Frame = len(4 BE) + payload[len] + CRC32(4 BE); the CRC-32 over the payload must match (it does: 0x1cc2b9d2). The recovered 451-byte payload points to:

GitHub: broski-creator/velarium-ossuary-ledger-7f3c2a - Release tag v1.0 - Asset flag.enc (in the Release, not the repo tree). The serial is in the tag’s note.

GitHub OSINT

  • gh api repos/broski-creator/velarium-ossuary-ledger-7f3c2a/releases/tags/v1.0 — asset flag.enc (82 bytes).
  • The serial is not in the release body (that just says “The catalogue is complete.”). It is in the annotated git tag object’s message. Fetch via the git-data API: git/refs/tags/v1.0 -> tag SHA -> git/tags/<sha> -> message contains:
    Serial: 1ec5d6c0778858ef9bd8e651ef1efeae

Final decrypt

import hashlib, hmac, sys
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def hkdf(ikm, salt, info, L):
    prk = hmac.new(salt, ikm, hashlib.sha256).digest()
    okm = b""; t = b""; i = 1
    while len(okm) < L:
        t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
        okm += t; i += 1
    return okm[:L]

master = bytes.fromhex(
    "bbb26315a1493bc6e013364b1ccf78ecb7bcc2a3aeb3972f2885f9b2b444b9bf"
)
serial = "1ec5d6c0778858ef9bd8e651ef1efeae"

flag_key = hkdf(master, bytes.fromhex(serial), b"flag", 32)
blob = open(sys.argv[1], "rb").read()   # flag.enc, 82 bytes
nonce = blob[:12]
ct = blob[12:]
pt = AESGCM(flag_key).decrypt(nonce, ct, b"collector-v2")
print("FLAG:", pt.decode())
hiCTF{marble_sparrow_9eb44602eedb764aac082a3816b7d7cb}

Lessons

  • Multi-stage challenges with self-validating steps (seal checks, CRC, auth tags) mean a correct path is unambiguous — if the checksum passes, the extraction is right.
  • Museum accession identifiers have non-obvious formatting (e.g., NG.M.00939 for Nasjonalmuseet) that must be exactly right for the cryptographic chain to work.
  • GitHub release bodies and annotated tag messages are different objects — the serial was in the tag annotation, not the release description.
The flagRevealhiCTF{marble_sparrow_9eb44602eedb764aac082a3816b7d7cb}

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