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.
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) — sixrecord-xxxx/folders, each with animagefile (no extension) and aconservation.txthint, pluscatalogue/colophon.txtdescribing the whole mechanism.sealed_cabinet.zip(~1.6 MB) — WinZip-AES-encrypted (compression method 99), containingreadme.txtandstego.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 tagv1.0- Assetflag.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— assetflag.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.00939for 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 flagReveal
hiCTF{marble_sparrow_9eb44602eedb764aac082a3816b7d7cb}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.