IntroFortID 2026
Baby Misc
A 7000-entry zip encodes flag-shaped strings as a trie of ASCII directory names, with the real one hidden among 32 taunting decoys and found only by comparing PNG dimensions.
What this one teaches
- A directory tree where each component is an ASCII code is a trie; walking root to leaf spells out a string.
- A text search (grep, ctrl-F) only beats decoys that were not specifically designed to look well-formed.
- When thousands of near-identical files exist, a cheap structural invariant (here, a PNG's own width and height) can be the one property a decoy generator forgot to normalize.
Task description
Heard you’re a M4st3r H4ck3r, can you retrieve the flag from this archive?
A trie disguised as a zip
The handout, retrieve.zip, is about 344 MB and expands to 7000 files, all named eow.png, each buried under a long chain of numeric directory names, for example retrieve/81/81/68/86/.../eow.png. Every one of those PNGs is exactly 48857 bytes.
The wordplay in the prompt (“retrieve”) and the filename eow.png (“end of word”) are the two hints. The directory structure is a trie (a tree where each path from the root spells out a sequence), and each numeric path component is an ASCII code point. I can read the whole thing from the archive’s central directory, which lists every filename without touching any file content, and map each path’s numbers through chr() to get one decoded string per entry:
import zipfile, re, collections
z = zipfile.ZipFile("retrieve.zip")
infos = z.infolist() # reads only the central directory
def decode(name):
# retrieve/70/111/114/.../eow.png -> "For..."
return "".join(chr(int(p)) for p in name.split("/")[1:-1])
words = [decode(i.filename) for i in infos]
candidates = [w for w in words if re.fullmatch(r"FortID\{[^{}]*\}", w)]
print("entries:", len(infos))
print("distinct file_size:", len(set(i.file_size for i in infos)))
print("distinct CRC-32:", len(set(i.CRC for i in infos)))
print("well-formed FortID{...}:", len(candidates))
entries: 7000
distinct file_size: 1
distinct CRC-32: 6635
well-formed FortID{...}: 33
Every entry is the same 48857 bytes, so file size tells nothing apart. The CRC-32 values are no help on their own either: 6635 of the 7000 are distinct, which only says the image bytes differ, not which one matters.
Decoys built to beat a text search
Decoding all 7000 paths produced a flood of flag-shaped strings. Most were malformed (stray punctuation, a missing closing brace), but 33 were strictly well-formed FortID{...}, and every one of those reads as a joke. A sample from the real 33:
FortID{grep_harder_the_truth_is_out_there}
FortID{ctrl_f_will_not_save_you_this_time}
FortID{congratulations_you_found_a_decoy}
FortID{your_princess_is_in_another_directory}
FortID{this_flag_has_been_moved_to_another_castle}
FortID{the_flag_is_a_lie_but_the_png_is_not}
The real flag is one of the 33, so the text cannot tell you which. Two of the taunts name the intended method directly: ctrl_f_will_not_save_you_this_time rules out a text search, and the_flag_is_a_lie_but_the_png_is_not says the discriminator is in the image content, not the filename.
Finding the one odd image
Every PNG starts with the 8-byte signature and an IHDR chunk, and IHDR stores the image width and height as two big-endian 32-bit integers at byte offset 16. The archive is stored without compression, so I can read just the first 24 bytes of each entry (signature + length/type + width + height) and never decode an image:
import struct
dims = collections.Counter()
for info in infos:
with z.open(info) as f:
width, height = struct.unpack(">II", f.read(24)[16:24])
dims[(width, height)] += 1
print(dims)
Counter({(290, 320): 6999, (288, 320): 1})
One file is 288 by 320 pixels; the other 6999 are 290 by 320. A two-pixel difference in width is the one property the decoy generator left un-normalised, and it isolates the needle without decoding a single image.
Decoding the real path
The outlier’s trie path decodes straight to a well-formed flag, one of the 33:
odd = next(i for i in infos
if struct.unpack(">II", z.open(i).read(24)[16:24]) != (290, 320))
print(decode(odd.filename)) # FortID{D0_or_D0_N07...}
open("needle.png", "wb").write(z.read(odd))
The name is a Yoda pun on the trie structure (“do or do not, there is no trie”). Its picture is the giveaway too: opening the outlier shows a hacker in sunglasses waving a red flag with “HACK THE PLANET” on the wall behind, while every one of the 6999 others is the same “needle in a haystack” meme, a hooded figure sitting in hay next to a sign reading “REAL FLAG SOMEWHERE IN HERE” and a mug captioned “still looking”. The real entry was drawn differently, and that is what the 2-pixel width tell reflects.
Why the filename trie was a dead end on its own
The real entry’s filename is indistinguishable from the 32 decoys by text alone; it only stands out because its PNG was rendered at a different resolution. When decoys are meant to be indistinguishable, every observable property has to be normalised, and here the image dimensions were not. File size and CRC were engineered to be unhelpful, one value and 6635 values respectively, so the puzzle forces the solver off text-matching and onto a structural property that the generator missed.
The flagReveal
FortID{D0_or_D0_N07_Th3r3_15_n0_Tri3}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.