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

The challenge post in Discord: the six lines, an empty flag field, five hint fields, and two edit notes reading 'flag change' and 'wording issue, should be clearer now', with the attached 2048-pixel scan of a handwritten manuscript below

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.

SourceValueRole
Filename1700000000 → 2023-11-14 22:13:20 UTC”One is shown”
PNG tEXt Modify Date2024:11:03 01:14:15”One is stored”
PNG tEXt Creation Time2026-02-30T08:04:20Z”One never happened”

exiftool output for the file: Creation Time 2026-02-30T08:04:20Z with the impossible date crossed out and the time 08:04:20Z marked, above Software 'gardener 3.301', Comment 'Do not trust the filename.', and the EXIF block carrying Image Description, Modify Date and Date/Time Original

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:

Discord's search filter panel with Date set to During, Jun 16, 2026

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

Discord search results for 16 June 2026: the message 'hmm... here might be something hidden' at 10:04 in a #casino-store channel, circled, surrounded by challenge chatter from the same morning including 'where are you guys stuck?' and 'I can assure you, it's not tiktok'

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()

Left: a hexdump of the alpha channel in pixel order, a wall of ff with a handful of red non-ff bytes scattered through it. Right: the same channel walked in xorshift32 order, seed 1781597060, spelling out the PGP block in plain ASCII

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

The iCCP tag table: eleven sRGB boilerplate tags whose type signatures read as ASCII (mluc, XYZ, sf32, para, chrm), and a twelfth tag 'gard' at offset 600, 702 bytes, whose type signature is 47 c5 19 f4 and not ASCII at all

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 eight positions in a row with their book-cipher words underneath, the four Fibonacci numbers 8, 21, 55 and 233 marked in red, the seven gaps between them below, first and last greyed out as dropped, and the surviving five mapped through A1Z26 to 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

StageWhatResult
0Three dates + post date → Discord searchMessage ID 1516352718413762590
1Split the snowflakeseed_pos = 1781597060, seed_icc = 1
2Alpha LSB, xorshift32-scattered4161 B PGP block, 8 numbers
3ICC tag gard, 1024-key brute force620-word book text
4Book cipherTHE WORDS ARE NOT THE MESSAGE READ DISTANCES
5Distances, middle, A1Z26PRIME
6Prime positions, LSB of the signatureFlag

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