The challenge
Three ordinary files arrived without trustworthy labels. Build an identity inventory from the bytes themselves and prove which labels can—and cannot—be trusted.
Single Solve: five questions, all about one file inside metadata-day-01-evidence.zip:
answer_id |
Question | Format |
|---|---|---|
evidence- |
What is the exact filename of the first evidence note? | Exact base filename with its extension |
evidence- |
What is its exact size in bytes? | Base-10 integer |
evidence- |
What is its MD5 digest? | 32 hex characters, any case |
evidence- |
What is its SHA-1 digest? | 40 hex characters, any case |
evidence- |
What is its SHA-256 digest? | 64 hex characters, any case |
Each size and hash question names its target as metadata-day-01-evidence.zip -> evidence-note.txt.
Reading the prompt: what it said we'd need
| Clue | What it pointed to | What I did about it |
|---|---|---|
| "identity inventory" | The standard file-identity set in forensics: name, size, and cryptographic hashes | Compute all of them for every file, not just the one asked about |
| "from the bytes themselves" | Hash the exact member bytes, not a re-saved or re-encoded copy | Read each member in memory, straight from the zip, without extracting to disk |
| "without trustworthy labels" / "which labels can—and cannot—be trusted" | Names, extensions, and the question's own - label may lie |
Take names from the zip's own headers. Compare what the content is (its magic bytes) with what the extension claims. Check that the zip's two name records agree |
| "exact filename" | The answer is checked character for character | Use the name exactly as stored: no case changes, no trimming, no path |
| "Three ordinary files", though only one is asked about | The later modes probably bring the other files | Build the solver for several files and several zips, not just one |
| Hex-length formats (32/40/64) and "integer" | Checkable shapes | Check every answer's format before it's submitted |
The zip detail behind "labels": every zip records each file's name twice. Once in the local header in front of the file's data, and again in the central directory at the end. Most tools read only the central directory, and a crafted zip can make the two disagree. So the solver checks both, and stops if they differ, rather than pick one.
Setup
The same as A Little Based: a Kali VM over the CTF VPN, driven by Claude Code from Windows over SSH, with every Game Master request relayed through the Homebase. This challenge was where our shared runner was built. It runs start or resume, downloads and hash-checks the artifacts, solves, checks formats, and submits once, so each challenge only needs a small solver.
What was in the files
Every evidence file followed the same template. Each variant randomised four values, the case marker, endpoint, sequence, and analyst, and kept their widths fixed. So the size stayed 240 bytes while every hash changed.
The Single Solve's evidence-note.txt, inside metadata-day-01-evidence.zip:
Huntress CTF 2026 — Metadata Forensics
Case marker: CASE-DED50DC2F828
Endpoint: WS-0142E587
Collected sequence: 8273759354
Analyst: analyst-457fe211
Preserve these bytes exactly. Filename, length, and cryptographic digests are evidence.
The Time Trial's roster.csv (129 bytes) is the same kind of record as CSV:
case_marker,endpoint_id,analyst_id,collected_sequence,status
CASE-30F928381244,WS-B8BBB60B,analyst-29ef710a,2181529668,collected
The zip structure, the same in every mode:
- One member per evidence zip, deflated, created on Unix.
- A fixed timestamp of 2026-01-01 00:00 UTC, in the DOS field and the UT extra field (555405000100b95569).
- A data descriptor (flag bit 3), so the local header's CRC and sizes are zero. The real values live after the data and in the central directory.
Mastery attempt 1 wrapped the two set zips in an outer zip. Those inner zips were stored (no compression) and carry 1980-01-01 DOS dates with a FAT origin, unlike the inner files.
Things that looked odd but were fine:
- zipinfo called evidence-note.txt "binary". The cause is the em dash in the title line: 3 non-ASCII UTF-8 bytes. file correctly says UTF-8 text. The extension and the content agree.
- In Mastery, the "renamed evidence-note copy" isn't a copy. It's 240 bytes too, but it carries different markers from the evidence note, so the hashes differ. The word "copy" was a label that couldn't be trusted.
Single Solve
Start: shape A, one download and one sha256, with no deadline. The zip was 372 bytes, its SHA-256 a511416d…5b64e5 matching the start reply.
Static checks before trusting anything:
- One local header, one central directory entry, one data descriptor, so no hidden extra entries.
- The local and central names match: evidence-note.txt, 17 bytes, plain ASCII.
- No path tricks (../, absolute paths), and the CRC checks out (af710f76).
Answers, computed in Python from the member's bytes, then cross-checked with unzip -p … | wc -c | md5sum | sha1sum | sha256sum. The digests are shortened here the same way as the flags:
| Field | Answer |
|---|---|
| Filename | evidence- |
| Size | 240 |
| MD5 | 6b5074cd…a7d5 |
| SHA-1 | 11f594f6…92d9 |
| SHA-256 | a6d2d239…4dc6 |
CORRECT (flag 8b4c7712…954f). This was the first live use of our shared runner, and the run took 1.4 s.
The solver
Our solver builds the inventory for every file it can reach, opening nested zips. For each file: name, size, MD5, SHA-1, SHA-256, and the type its magic bytes say, set against the extension. Then, for each question:
- Which field: from the
answer_idfirst (-md5,-size-bytes…), then the wording. - Which file, in order:
- the question's own
-> memberlabel, or a sibling question's, if that member really exists; - an ordinal ("first", "second");
- a member whose name contains the noun asked about ("the roster" →
*roster*); - the only file there is.
- Which zip (for Mastery): the set's own artifact, or the zip the question names, wherever it sits.
- Stop, never guess, on: local and central names that differ, unsafe paths, a question it doesn't understand, or a file it can't identify with certainty.
Time Trial: 2.77 s of a 30 s clock
The questions moved to the roster, roster-filename to roster-sha256. The filename question gave no ordinal: "What is the exact filename of the roster?" My solver would have stopped on that, so before starting the clock I added the sibling-label and noun-match steps above.
started_at 2026-10-02T20:05:46.501Z deadline 2026-10-02T20:06:16.501Z
artifact metadata-day-01-evidence.zip 269 bytes sha256 f075726ccf34543c… (checked)
roster-filename: roster.csv
roster-size-bytes: 129
roster-md5: d1d7be11…07e3
roster-sha1: 0bd2a514…b14c
roster-sha256: ca8713b5…f887
start 1486 ms
artifact 479 ms
solve 1 ms
submit 799 ms
TOTAL 2765 ms
result CORRECT flag 3713666b…0996
The zip held only roster.csv. The prompt's "three files" are spread across the modes, not packed into one zip.
Mastery: 2 sets, 10 questions, one 30 s clock (second attempt CORRECT in 2.62 s)
| Set | Zip | File asked about |
|---|---|---|
| 1 | metadata- |
"the renamed evidence-note copy", labelled evidence- |
| 2 | metadata- |
"the first evidence note", labelled evidence- |
Prepared before the first attempt, because A Little Based's Mastery had switched delivery shapes between attempts: - Nested zips: a member that is itself a zip is listed and then opened. - Each question is answered only from its own zip. - The noun match ignores words like "renamed" and "first". - Tests for every shape I could expect: two separate zips (distinct names, and identical names), and one outer zip holding both.
| Attempt | What arrived | What happened |
|---|---|---|
| 1 (20:08:19) | One outer zip, …- (1,052 B), holding the two set zips stored, uncompressed |
A false stop, my bug. My name check scanned the raw bytes for PK\x03\x04 header signatures. Because the inner zips were stored, their headers appear verbatim in the outer zip's bytes, so it found 4 "local headers" against 2 central entries and stopped. Left to expire |
| 2 (20:09:39, after retry) | Two separate artifacts, both named metadata- |
CORRECT in 2.62 s |
The fix between attempts: read each local header at the exact offset the central directory records for it (header_offset), instead of scanning for signatures. After the expiry I re-ran the fixed solver on the real attempt-1 zip offline: all 10 answers came out and passed their formats. Then I retried.
started_at 2026-10-02T20:09:39.846Z deadline 2026-10-02T20:10:09.846Z
artifact 01-metadata-day-01-evidence.zip 384 bytes sha256 7d9fd74dec861538… (checked)
artifact 02-metadata-day-01-evidence.zip 373 bytes sha256 19d84b78883e5c1c… (checked)
set1.evidence-note-copy-filename: evidence-note-copy.txt
set1.evidence-note-copy-size-bytes: 240
set1.evidence-note-copy-md5: b7700f4a…2a77
set1.evidence-note-copy-sha1: b840c63e…16cd
set1.evidence-note-copy-sha256: f1e8c5bd…1681
set2.evidence-note-filename: evidence-note.txt
set2.evidence-note-size-bytes: 240
set2.evidence-note-md5: 4eaa40b7…c44d
set2.evidence-note-sha1: 9563ebf1…a2ea
set2.evidence-note-sha256: f613ae22…fe17
retry 990 ms
start 334 ms
artifact 565 ms
solve 1 ms
submit 727 ms
TOTAL 2617 ms
result CORRECT flag c394e917…10e9
Time to solve
All three modes were solved on 2026-10-02 by 20:09:41 UTC, 24 min 49 s after the Single Solve started. The five answers were found in about 2 minutes, but the Single Solve was submitted at 10 min 32 s, after the shared runner and solver were built and tested; the Time Trial went through first time in 2.77 s, and the Mastery passed on its second attempt in 2.62 s after a false stop.
Takeaways
- "Identity" in forensics means name, size, and hashes, taken from the exact bytes. Read members in memory and cross-check with a second tool.
- Don't trust labels, including the question's own. The
-> memberhint, the extension, and a word like "copy" can all be wrong. Here, the "copy" wasn't a copy. - A zip names each file twice. Compare the local header and the central directory, reading each local header at its recorded offset. Never scan for signatures: stored inner zips put their headers verbatim in the outer zip.
- Read the later modes' questions before starting them. The Time Trial's "the roster", with no ordinal, would have stopped the solver. Reading the status for free found that before the clock.
- Mastery deliveries switch shapes between attempts, the same as in A Little Based. Prepare for every shape, stop safely on anything new, and fix it offline.