Nothing To See Here — PDF → Mastodon → SSTV → QR
A passive-OSINT challenge that chains white-on-white PDF text, an emoji substitution cipher, a Morse-tagged Robot 36 SSTV transmission and a QR code into a physical meeting location.
Rules: passive only — no interaction, no authentication, no recovery attempts
Objective: find the meeting location of two agents
Starting material: a PDF hosted on pdfhost.io
Result up front: the meeting location is Please Don’t Tell, 113 St Marks Place, New York. Four links, four different media — invisible PDF text, an emoji cipher, an SSTV transmission, a QR code — and nothing along the way that needs a login or a human.
Then a fifth stage appeared. It was better built than anything the author had made, it decrypted perfectly, and it was fake. Another solver had planted it. I submitted it. That part is The trap, and it is the only section here worth your time if you are short on it.

Stage 1 — The PDF
One page, one visible line of text, and a filename promising something hidden.
The reflex on that prompt is to reach for binwalk and strings and hunt for appended data. Waste of time here: nothing follows the %%EOF marker, and pdfinfo reports no attachments, no metadata stream, no JavaScript. The file is exactly as long as it claims to be.
So the payload is in the page. Decompress the object streams and the content stream explains itself:
q 1 1 1 rg % fill colour → white
BT 56.8 83.989 Td /F1 12 Tf [ … ] TJ ET
White text on a white page. The oldest trick in the PDF book, invisible to a human and completely uninteresting to pdftotext, which just prints it:
NOTHING TO SEE HERE !
Hegev45373 @ m4st0d0n
m4st0d0n is leetspeak for Mastodon, which makes Hegev45373 a handle rather than a password. Worth logging separately: the catalog carries /Lang (fr-FR) and a LiberationSerif subset, so the document was built in LibreOffice on a French-locale machine. Nothing later in the chain hangs on it. Record it anyway — authorship metadata is cheap to note while the file is open and expensive to reconstruct once you’ve moved on.
Mastodon is federated, so “find this username” is really “find the right instance”. Search engines index it badly — don’t Google, query the instance API. The largest instance hit on the first try:
{"id":"117020153246352662","username":"hegev45373","bot":true,
"created_at":"2026-08-01T00:00:00.000Z"}
Created the day the challenge went live, flagged as a bot, following exactly one unrelated account as camouflage. That profile shape becomes the reference standard later.
Stage 2 — The emoji cipher
The account has one public status and nothing else:
👟👫👫👧👪🐱🐦🐦👤👘👪👫👦👛👦👥🐥👪👦👚👠👘👣🐦🐷👩👘👮👜👧👜🐬🐩🐰🐯
Thirty-five emoji, twenty-three distinct. The temptation is to start guessing at an emoji-to-letter table. Don’t — look at the numbers underneath first.
Every codepoint sits inside U+1F425–U+1F46E. A 74-value contiguous span holding 23 distinct symbols is the exact shape of a printable-ASCII alphabet dropped onto an emoji block one-to-one. Subtract the block base U+1F400 and you get printable ASCII, just wrong by a constant: the string opens _kkgj1&& where a URL should open https://. Every character is off by 9.
"".join(chr(ord(c) - 0x1F400 + 9) for c in status)
# https://mastodon.social/@rawepe5298
The shift is not a guess that happened to work. A wrong offset yields mojibake; +9 yields a syntactically valid URL that resolves to a live account matching the first one’s profile shape. Any single character being off would break the hostname.
The MP3 attached to this post is a decoy. It’s in the appendix.
Stage 3 — Morse and SSTV
Second account, same shape: created at launch, bot-flagged, exactly one status.
.- .--. .--. .-. --- .- -.-. .... .. -. --.
APPROACHING. That’s a hint, not data — it tells you the attachment is a transmission, not a file to carve.
The attachment is a 36.94-second mono MP3, and two things settle it before decoding anything. Robot 36 is a 36-second SSTV mode, and a clip landing within a second of a named mode’s transmission time is not coincidence. Confirm with a spectrogram: all energy between roughly 1500 and 2300 Hz — the SSTV luminance band — with 1200 Hz sync pulses cutting through at regular intervals.
Convert to WAV, hand it to colaclanth/sstv, and it finds the calibration header, identifies Robot 36 and decodes without argument. Out comes a 320×240 frame containing a QR code.
The duration and the spectrum are independent of each other, which is what makes the mode identification safe: a clip could hit 36 seconds by chance, but not while also carrying 1200 Hz sync pulses in the luminance band.

Stage 4 — The QR code
The QR decodes to:
SMSTO:0000:Please Don't Tell, 113 St Marks Place, NY 10009
SMSTO: is a standard QR action prefix that pre-fills an SMS. The number 0000 is a placeholder, so the message body is the whole payload.
Please Don’t Tell is a real bar at 113 St Marks Place in Manhattan’s East Village. It’s a speakeasy with no street entrance — you get in through a phone booth inside the Crif Dogs hot dog shop next door. For a challenge called “Nothing To See Here” about two agents arranging a covert meeting, that is a very well-chosen endpoint. The venue’s entire premise is a door that doesn’t look like a door.
Flag
OSINT{Please_Don't_Tell,_113_St_Marks_Place,_NY_10009}
The brief’s format example reads OSINT{The_Great_Palace,_20_St_John_Doe_Hood,LA_90001} — underscore after the first comma, none after the second. That inconsistency is in the example only, and imitating it cost me two rejected submissions. The checker wants the literal QR payload with spaces replaced by underscores, apostrophe intact. Transcribe the artefact, don’t copy the sample.
The trap
While I was working stage 2, a reply appeared on the emoji post from a third account, favcolbun. Over the next few minutes it posted three more messages. Together they were a complete, genuinely well-made puzzle:
- An emoji block using the same +9 shift, this time wrapping Base64 — 86 bytes of high-entropy binary.
- A taunt: “Funny how everyone keeps searching where the message was found, as though a letter must describe the room in which it was opened.”
- A riddle — neoteny, regeneration, an external feathery crown, lakes. Axolotl.
sha256(b"axolotl")is a 32-byte AES-256 key. - Sydney Opera House sits on Bennelong Point.
Point→Pt, “close it with surprise” →BennelongPt!, exactly 12 characters, which is an AES-GCM nonce. The PDF’s four visible words becomenothing_to_see_here, and “beside the message” is a description of Additional Authenticated Data.
Every piece fits. It decrypts cleanly, tag verified, no ambiguity:
OSINT{Sydney_Opera_House,_Bennelong_Point,_Sydney_NSW_2000,_Australia}
And it is wrong. It is bait laid by another solver, and I submitted it.
The tells were all on screen before I decrypted a single byte:
hegev45373 / rawepe5298 | favcolbun | |
|---|---|---|
| Account created | Aug 1 (challenge launch) | Aug 6 (that same day) |
bot flag | true | false |
| Posts | exactly 1 each | 4, three of them live |
| Entry point | referenced by the PDF | a reply to a five-day-old post |
| Follows | unrelated decoys | rawepe5298 — someone who had cleared stage 2 |
The strongest signal isn’t in the table, though. It’s the payload itself. Every real stage hands you a raw artefact — white text, a bare URL, a QR string containing an unformatted street address — and leaves the flag assembly to you. The trap hands you a finished flag, ready to paste. That is not how the author built one single other thing in this chain, and it is exactly what someone farming wrong submissions would produce.
Two lessons that outlast the challenge:
A successful decryption authenticates the ciphertext, not the author. GCM verifying its tag proved I’d solved the riddles correctly and that nobody had tampered with the blob in transit. It said nothing whatsoever about who created it. I caught myself treating cryptographic validity as provenance, which is a category error I would have flagged instantly in someone else’s work.
The taunt was the payload. Message 2 exists for no reason except to make you distrust the answer you already hold. It landed at the exact moment I had the correct address and was second-guessing a formatting rejection — which is precisely when that kind of nudge works. If a new voice turns up mid-investigation and its main effect is to discredit your existing conclusion, date the account before you follow it anywhere.
Appendix — the decoy audio
The MP3 on the first account’s emoji post is 3.6 seconds of what sounds like distorted noise. Speech-to-text returns nothing usable, which is the tell — it isn’t speech. Rendered as a spectrogram, it’s text painted directly into the frequency domain:
No, not here… They will know

Purely atmospheric, no link to the chain. But there’s a rendering trap worth knowing: zooming into the frequency band containing the glyphs makes them less legible, not more, because the STFT window smears each glyph vertically across bins. Render the full 0–22 kHz at a modest figure size and the text compresses back into readability. If your spectrogram looks like abstract stripes, zoom out before concluding there’s nothing there.
Reproduce it
Everything in order, if you want to walk it yourself:
# Stage 1 — PDF
qpdf --qdf --object-streams=disable nothing.pdf out.pdf # reveals `1 1 1 rg`
pdftotext nothing.pdf - # prints the invisible text
# Stage 1→2 — resolve the handle
curl -s "https://mastodon.social/api/v1/accounts/lookup?acct=Hegev45373"
curl -s "https://mastodon.social/api/v1/accounts/117020153246352662/statuses?limit=40"
# Stage 3 — SSTV
ffmpeg -i a2.mp3 a2.wav
sstv -d a2.wav -o decoded.png
# Stage 2 — emoji cipher
"".join(chr(ord(c) - 0x1F400 + 9) for c in status)
# Stage 4 — QR
from pyzbar.pyzbar import decode
from PIL import Image
decode(Image.open("decoded.png"))
Two snags you may hit. colaclanth/sstv calls os.get_terminal_size() for its progress bar and dies with OSError: [Errno 25] when there’s no TTY — swap that import in sstv/common.py for shutil.get_terminal_size, which falls back instead of raising. And the SSTV decode is the only step that needs a real terminal; everything else runs fine in a notebook.
Passive-only footprint. The whole solve used four public read endpoints — account lookup, statuses, the follow graph, and direct media fetches from files.mastodon.social. No authentication, no follows, no replies, no password-reset probing. Mastodon’s public API is unauthenticated by design for public content, so you never have to choose between getting the data and staying inside the rules.
Takeaways
- Check rendering, not just structure.
binwalk,stringsand EOF-trailer checks all came up empty. The payload was in the content stream, styled invisible. When the trick is presentational,pdftotextbeats forensic tooling. - Codepoint ranges are a fingerprint. A contiguous emoji block used as an alphabet almost always maps back to ASCII with a fixed offset. Do the arithmetic before hunting for a lookup table.
- Duration identifies SSTV modes. 36 s → Robot 36, 60 s → Scottie 2, 110 s → Martin M1. Combine with the spectral signature and you can pick a decoder without guessing.
- Date every account before you trust its content. Creation date, post count,
botflag and follow graph separated the author’s infrastructure from a rival’s trap in under a minute — and I had all of it on screen before I went down the wrong path anyway.