A Message Has Three Dates
I built a six-stage stego challenge where the key sat in Discord rather than in the file. Nobody solved it. Here is the whole chain, and the reason it broke at the first gate.
The post in the channel was six lines, a flag field, five hints and a PNG:
A message has three dates.
One is shown.
One is stored.
One never happened.
A fourth is the key. It is not in the file.
It was seen on *here* 16 June 2026

here is italicised and it is the whole instruction: the fourth date was not sent to you, it
was sent in this server. Attachment: evidence_1700000000.png, 2048×2048, RGBA, 7.96 MB.
Flag format ctf{lowercase_with_underscores}. The two edit notes at the bottom are mine, from
the first day.
I wrote that post. Nobody solved it. Five hints and a week later I pulled the challenge, and this is the answer key I owe the handful of people who downloaded the file, tried things and told me they were lost.
Six stages, each built on the last, ending at ctf{the_message_existed_before_the_puzzle}.
The interesting part isn’t the chain, it’s that the chain has exactly one entrance. Stage 0
asks you to find a key that isn’t in the file at all. It’s in Discord. Everything after it is
mechanical, and unreachable without it.
What the file doesn’t give you matters as much: nothing appended past IEND, no archive in the
tail, zsteg clean across every plane, and no EXIF: Discord strips the eXIf chunk on upload,
so the copy you download has lost it. Everything the challenge hands you is either a
tEXt chunk, the ICC profile, or the alpha channel,
plus one line of the post itself.
Run exiftool with -a -G1, not bare. Modify Date exists twice in the pre-upload original,
as a PNG tEXt and in IFD0, and without -a you see one of them and don’t know which.
If you’re reproducing this: download the attachment. Discord’s inline preview is re-encoded and the alpha channel is dead in it.
Stage 0 — The fourth date isn’t a date in a file
The file carries three dates.
| Source | Value | Role |
|---|---|---|
| Filename | 1700000000 → 2023-11-14 22:13:20 UTC | ”One is shown” |
PNG tEXt Modify Date | 2024:11:03 01:14:15 | ”One is stored” |
PNG tEXt Creation Time | 2026-02-30T08:04:20Z | ”One never happened” |

February 30th doesn’t exist. That’s the point: the date in that string is worthless, the
time 08:04:20 is real. The post supplies the other half, the day: 16 June 2026 UTC.
Put them together:
2026-06-16 08:04:20 UTC
Search the server with the date filter set to that day:

One message on that day carries the second half of the hint, in a channel that has nothing to do with the challenge:

hmm... *here* might be something _hidden_, posted at 10:04 local time, which is 08:04:20
UTC. Right-click → Copy Message ID:
1516352718413762590
That’s the fourth date. It’s nowhere in the image. It’s a Discord snowflake, and a snowflake is a timestamp.
The step checks itself. Grab the wrong message and its snowflake doesn’t land on
08:04:20. Discord’s UI never shows seconds, so the only confirmation available is the ID.
Two more tEXt chunks are flavour and warning at once:
Software gardener 3.301
Comment Do not trust the filename.
Take the comment literally. It applies a second time later.
Stage 1 — Take the snowflake apart
A snowflake is a 64-bit integer with four components:
DISCORD_EPOCH = 1420070400000
mid = 1516352718413762590
ts_ms = (mid >> 22) + DISCORD_EPOCH # 1781597060541
worker = (mid & 0x3E0000) >> 17 # 0
process = (mid & 0x1F000) >> 12 # 1
increment = mid & 0xFFF # 30
ts_ms is 2026-06-16 08:04:20.541 UTC. The time matches, stage 0 is confirmed.
Two seeds come out of those four parts. The first is obvious:
seed_positions = ts_ms // 1000 # 1781597060
The second isn’t used until stage 3, but it’s fully determined here. “Neither the first nor
the last” means worker and process, the middle two of the four:
seed_icc = (worker << 5) | process # = 1
That phrase shows up three times in this challenge. It’s the motif.
Stage 2 — Alpha LSB, but not in reading order
zsteg returns nothing here, and that’s deliberate: it’s a
signal, not a dead end. Every mainstream scanner iterates a bit index over channels in reading
order; multi-bitplane-scanner searches
the full 12,288-variant cross product and still assumes reading order.
None of them iterates orderings. The image is RGBA: three channels carry the picture, the fourth
carries nothing visible and is still not empty.
The alpha LSB holds the payload, but the pixel positions come out of an xorshift32 generator
seeded with seed_positions, skipping collisions. Read the channel in pixel order and there is
nothing to see, because the carriers are too far apart for any window of consecutive pixels to hold a
readable byte.
def xs32(seed):
s = seed & 0xFFFFFFFF or 1
while True:
s ^= (s << 13) & 0xFFFFFFFF
s ^= s >> 17
s ^= (s << 5) & 0xFFFFFFFF
s &= 0xFFFFFFFF
yield s
def positions(seed, w, h, n):
g, seen, out = xs32(seed), set(), []
while len(out) < n:
x, y = next(g) % w, next(g) % h
if (y, x) in seen:
continue
seen.add((y, x))
out.append((y, x))
ys, xs = zip(*out)
return np.array(ys), np.array(xs)
First 32 bits are a length header, then the data:
arr = np.array(Image.open(PNG).convert("RGBA"), dtype=np.uint8)
h, w = arr.shape[:2]
ys, xs = positions(seed_positions, w, h, 32)
n = int.from_bytes(np.packbits(arr[ys, xs, 3] & 1).tobytes(), "big") # 4161
ys, xs = positions(seed_positions, w, h, 32 + n * 8)
armor = np.packbits(arr[ys, xs, 3][32:] & 1).tobytes().decode()

In pixel order the channel is a wall of ff: 17,732 of 4,194,304 pixels carry a zero bit, and
they’re spread out far enough that no window of consecutive pixels holds anything readable.
Walk the same channel in the generator’s order and the block falls out whole.
4161 bytes, dressed as an RFC 4880 cleartext-signed PGP block:
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
There are 3301 paths through the garden.
Take neither the first nor the last.
8 21 37 55 64 77 82 233
SHA256:
d3b07384d113edec49eaa6238ad5ff00
-----BEGIN PGP SIGNATURE-----
Comment: Gardener <library@nowhere.invalid>
[3797 characters of base64 alphabet]
=8Kq/
-----END PGP SIGNATURE-----
Three things in there lie, and all three lie on purpose.
3301. A Cicada 3301 nod. Atmosphere, nothing else.
SHA256: over 32 hex characters. That’s an MD5, specifically the digest of foo with a trailing newline. This is
what “Do not trust the filename” was actually about: not just the filename is mislabelled,
every label in this challenge is.
The signature signs nothing. It’s the carrier for stage 6.
The number row 8 21 37 55 64 77 82 233 is useless without a text. The text is somewhere else.
Stage 3 — An ICC profile with one tag too many
The PNG carries a 1304-byte ICC profile. A baseline sRGB profile is 588 bytes, so something is riding along. The tag table has an entry no standard profile contains:
gard 702 B

A four-character tag, matching the gardener in the Software chunk and the garden in the
PGP block. You don’t even need the tag name to spot it: every ICC tag starts with a type
signature, and in a real profile that signature is ASCII (mluc, XYZ , para). gard starts
47 c5 19 f4, which is no type at all.
The contents are zlib-compressed, then XORed with an xorshift32 keystream seeded with
seed_icc.
You don’t have to have guessed the seed. The key space is worker << 5 | process, so
1024 candidates, and zlib announces itself with 78 da. Two bytes are enough of an anchor.
def ks(seed, n):
s = ((seed & 0xFFFFFFFF) * 2654435761 + 0x9E3779B9) & 0xFFFFFFFF or 1
for _ in range(16): # warmup
s ^= (s << 13) & 0xFFFFFFFF
s ^= s >> 17
s ^= (s << 5) & 0xFFFFFFFF
s &= 0xFFFFFFFF
out = bytearray()
while len(out) < n:
s ^= (s << 13) & 0xFFFFFFFF
s ^= s >> 17
s ^= (s << 5) & 0xFFFFFFFF
s &= 0xFFFFFFFF
out += struct.pack(">I", s)
return bytes(out[:n])
for k in range(1024):
if bytes(a ^ b for a, b in zip(blob[:2], ks(k, 2))) == b"\x78\xda":
book = zlib.decompress(bytes(a ^ b for a, b in zip(blob, ks(k, len(blob)))))
break
# k == 1 == (worker << 5) | process
Exactly one key hits. Out comes 620 words of prose in the Cicada register:
the that a teachers path language wears THE brought for garden
has question and is with the seed in those WORDS learned
asked cruel older no clothing buried the who many its twice
...
The capitalised words stand out immediately: they sit at exactly the eight positions from stage 2.
Stage 4 — Book cipher
The numbers are 1-based word positions in the book text:
POSITIONS = [8, 21, 37, 55, 64, 77, 82, 233]
words = book.decode().split()
picked = [words[i - 1] for i in POSITIONS]
THE WORDS ARE NOT THE MESSAGE READ DISTANCES
The text says of itself that it isn’t the message. The numbers get used a second time, not their values now but their gaps.
Stage 5 — Distances, minus the first and the last
d = [b - a for a, b in zip(POSITIONS, POSITIONS[1:])]
# [13, 16, 18, 9, 13, 5, 151]
The last distance is 151 and falls outside A1Z26. That’s what the line from the PGP block was aimed at, and you need it here for the second time:
Take neither the first nor the last.
mid = d[1:-1] # [16, 18, 9, 13, 5]
key = "".join(chr(64 + n) for n in mid) # PRIME

The bait
Four of the eight positions (8, 21, 55, 233) are Fibonacci numbers. The other four
aren’t. The bait is deliberately half true: strong enough to be called, never quite right.
Extend or filter the row on Fibonacci and you lose time, and the Fibonacci numbers sit
precisely where the row has to be cut.
I have no idea whether it ever fired. As far as I can tell nobody got this far.
Stage 6 — The signature that signs nothing
PRIME says what to do with the base64 block from stage 2: characters at prime positions
(1-based), lowest bit of each, eight bits to a byte.
The block is 3797 characters long. Not an accident: that’s the 528th prime. 528 bits = 66 characters = exactly the closing message.
Split at the signature header first. The MD5 above it is 32 characters of [a-z0-9] and a
naive line filter swallows it, so you get 3829 characters, every prime index shifts, and the
output is noise that looks like a wrong key rather than a wrong slice.
def primes_upto_count(k):
out, n = [], 2
while len(out) < k:
if all(n % p for p in out if p * p <= n):
out.append(n)
n += 1
return out
body = armor.split("-----BEGIN PGP SIGNATURE-----", 1)[1]
sig = "".join(l for l in body.splitlines()
if re.fullmatch(r"[A-Za-z0-9+/]{20,}", l))
assert len(sig) == 3797
primes = primes_upto_count(528)
bits = [ord(sig[p - 1]) & 1 for p in primes]
msg = "".join(chr(int("".join(map(str, bits[i:i+8])), 2))
for i in range(0, len(bits) - 7, 8))
THE MESSAGE WAS THE KEY
ctf{the_message_existed_before_the_puzzle}
Building it, every character at a prime position was drawn from either the even or the odd half of the base64 alphabet depending on the bit it had to carry. So the block is all legal base64 and statistically unremarkable.
The chain at a glance
| Stage | What | Result |
|---|---|---|
| 0 | Three dates + post date → Discord search | Message ID 1516352718413762590 |
| 1 | Split the snowflake | seed_pos = 1781597060, seed_icc = 1 |
| 2 | Alpha LSB, xorshift32-scattered | 4161 B PGP block, 8 numbers |
| 3 | ICC tag gard, 1024-key brute force | 620-word book text |
| 4 | Book cipher | THE WORDS ARE NOT THE MESSAGE READ DISTANCES |
| 5 | Distances, middle, A1Z26 | PRIME |
| 6 | Prime positions, LSB of the signature | Flag |
Checkpoints
SHA256, first 12 hex characters, no trailing newline:
stage 1 the seed, decimal string 263dd69666dd
stage 2 the extracted block, raw bytes b1a586095127
stage 3 the book text, raw bytes 80badb8c7cb2
stage 4 the eight words, CAPS, spaced 0a8d18ddfd3a
stage 5 the derived word b2126f0e4769
What I was going for
Every label in that file lies. The filename shows a date that means nothing, SHA256: sits
over an MD5, the Fibonacci numbers are half real, and the PGP block is signed by a signature
that signs nothing. The only line telling the truth is the one admitting to the lie:
Do not trust the filename. On top of that sits one repeated rule, “neither the first nor the
last”, which decides the snowflake components in stage 1 and the distances in stage 5, and
which the book text uses to describe itself. Learn it once, apply it twice.
And the title was meant to be the answer. “A message has three dates” reads as a metadata
puzzle. What falls out at the end is THE MESSAGE WAS THE KEY: the message was never a hint
pointing at the key, it was the key, and it had been sitting in the server before I posted
anything.
Why nobody got through
I was proud of this line while building it, and it is the reason the challenge failed:
No fact has two derivation paths. The post gives the day, the file gives the time. Neither half is enough alone and neither is redundant.
A chain with no redundancy has one entrance, and everything behind it is unreachable for
everyone at the same moment. Six stages, one gate. What made it worse is that stage 0 gives
back nothing. You reconstruct 2026-06-16 08:04:20 UTC, you search, you find a message, and
there is no way to tell whether it’s the right one until stage 2 either produces a PGP header
or produces noise. The seed is exactly right or it is useless. No partial credit, no warmer,
no colder.
Then there’s what the file looks like to standard tooling. exiftool shows three plausible
dates, binwalk finds nothing after IEND, zsteg reports clean on every plane. Every
instrument says empty, which reads as a dead end rather than as an instruction to look
somewhere that isn’t the file. I meant A fourth is the key. It is not in the file. as a
directive. It was read as atmosphere.
The chat from that first morning shows where people actually went. I can assure you, it's not tiktok, and you will immediately know if you have the right person is me answering a room that
had decided the fourth date pointed at a person — a profile to find, an account to trace. It
is the reading the wording invites: a date, a server, something hidden, therefore somebody
hidden. Nobody was looking for a message ID, because a message ID doesn’t feel like a find.
Five hints went out over the week. None of them moved anyone through, which is the part worth sitting with. A hint that arrives after the gate is closed doesn’t reopen it, it only tells you the gate was the problem. And I edited the post twice on day one, once for the flag and once because the wording wasn’t clear, so I already knew stage 0 was the weak joint while the challenge was live and patched the symptom instead of the structure.
The real fix isn’t a hint. It’s a second path to the seed, or an honest signal in stage 2: a few bytes of plaintext magic at the head of the payload, so that a correct seed announces itself and a wrong one is visibly wrong. Cheap to build, and I left it out because the version without it looked cleaner. Elegance for the author, a wall for everyone else.
What to take to the next file
A timestamp with an impossible date still has a real time. Don’t discard a malformed field, split it. Half of it is usually still load-bearing, and the invalid half tells you which half to use.
An ID from a platform is a timestamp, a shard and a counter. Snowflakes, ULIDs, ObjectIDs, UUIDv7 all decompose, and all of them make good seeds precisely because they look like opaque noise. If something asks for a key that isn’t in the file, look for an ID.
Two more, shorter. A clean scanner result is a data point about the scanner: zsteg iterates
bit indices, not pixel orderings, so a scattered payload is invisible to it by construction.
And colour profiles are carriers, because ICC has a tag table with four-character keys and no
enforcement of what goes in it. Diffing the profile against the 588-byte sRGB baseline is the
whole finding.
Build notes
The Discord round trip is verified (25 Aug 2026). Upload plus download leaves the pixels
bit-identical, 0 deviations across R/G/B/A, and the ICC profile and every tEXt chunk survive.
The only casualty is the eXIf chunk, 131 bytes. That’s why all three dates live in tEXt or
in the filename: an EXIF-only date would have vanished in transit and taken the challenge with
it. The pre-upload original still carries EXIF, which is why exiftool -a -G1 shows
Modify Date twice.
The book text is generated rather than borrowed, so there’s no rights question and the word
positions stay exactly controllable. The stage 3 keystream got a warmup because small seeds
(worker = 0, process = 1) otherwise produce null bytes at the head of the stream; the zlib
magic would have shone straight through and made the brute force pointless. And the post could
not be a reply, since a target message sitting right above it as a parent gives stage 0 away
for free.
One thing I only found while writing this up: the obvious way to slice the base64 block in
stage 6 is wrong. A line filter over the whole armour also swallows the 32-character MD5 from
the cleartext part, which shifts every prime index and turns the output into binary rubbish
that looks exactly like a wrong key. Split at -----BEGIN PGP SIGNATURE----- first. If anyone
had reached stage 6, that’s where they would have burned an evening.
Flag: ctf{the_message_existed_before_the_puzzle}
Sources
- Discord — Snowflakes — the epoch and the four-component layout stage 1 decodes
- Discord — search operators —
during:is what turns the reconstructed date into one message - PNG Specification,
tEXtchunks — where two of the three dates live, and why they survive a re-upload - ICC.1:2022 specification — the tag table format;
gardis a private tag no standard profile defines - Marsaglia, “Xorshift RNGs” — the 13/17/5 shift triple used for both the position walk and the keystream
- RFC 4880 §7, cleartext signatures — the armour the stage 2 payload imitates
- zsteg — the scanner that reports clean here
- multi-bitplane-scanner — my own scanner, and the post about what it does and doesn’t cover; it widens the bitplane search and still reads pixels in order, which is exactly the gap this challenge sits in
- Cicada 3301 — the register the book text and the
3301are borrowing