Westbound: OSINT

Flag: KPMG_CTF{g1t_r3m3mb3r5_wh4t_y0u_d3l3t3}

Scenario

Kestrel Maritime is being extorted by an actor called nulltide. He claims to have planted a marker inside an encrypted export. We get phish.eml and offline copies of four of his sites (blog, file locker, git repo, vault). The README warns: “He does not lie with words. He lies with metadata, with bait, and with the things people forget to check twice.”

1. The email: LSB steganography in proof.png

phish.eml has a base64 PNG attachment. Its chunks are clean (IHDR/IDAT/IEND, no trailing data), and the screenshot itself says “the marker is not on this screen.” Reading the least significant bit of every R, G, B byte in pixel order, packed MSB-first, gives:

you found the layer under the picture. most never get this far.
the marker everyone submits is bait: KPMG_CTF{n0t_th3_r34l_0n3}  <- don't.
the rest of me is in four pieces: a locker, a wire, a desk, a voice.
one holds the others. one is a door, one is a code, one is a key. match them yourself.

The flag in this message is bait. The useful part is the map of the four pieces.

2. The locker: sites/files/

The download buttons are blob URLs built from base64 strings in an inline <script> (var B={"pcap":...,"wav":...,"jpg":...}). Decoding them gives westbound.pcap, call.wav and desk.jpg. The locker is the piece that “holds the others”.

3. The desk → code (desk.jpg)

A sticky note in the photo reads vault drop code 7c41f9a2e0b3, “code goes after /d/”. This leads to sites/vault/7c41f9a2e0b3/, the dead drop. The same ID also appears in the email’s Message-ID and Received id.

The EXIF data (Canon EOS 6D, GPS 53°33’N 9°59’E, which is Hamburg) was edited in GIMP. The blog says “I edit [photo paperwork] by hand”, so this metadata is a decoy.

4. The wire → door (westbound.pcap)

The capture has 62 packets: DNS lookups, fake TLS, and plaintext HTTP. One POST shows the login:

POST /api/login HTTP/1.1
Host: vault.nulltide.example
user=nulltide&phrase=velvet-harbor

The drop page’s JavaScript compares SHA-256(lowercase(input)) with 251eb9fe…6eed. The phrase matches:

$ echo -n velvet-harbor | sha256sum
251eb9febd456d3f6b3e16aff9f0c90faec5c29ac2ed01cbfd143762a11c6eed

This check only unhides the download link. The encrypted file is already embedded in the page as base64, so you can also pull it out directly. The git page’s “Download (encrypted)” blob is byte-for-byte the same file (same MD5).

The file starts with Salted__ plus an 8-byte salt, which is OpenSSL enc format. The page labels it AES-256-CBC · pbkdf2 · openssl-compatible.

5. The voice → key (call.wav)

call.wav is 35 s, mono, 44.1 kHz. Its parts:

Time Content
0.7–21.2 s Speech under broadband noise. Transcribed with Whisper: “…Nobody reads the wire, so relax about the capture. The key? It is not in the file, and it is not in the mail. It is where I always put things.”
21.6 s 1 kHz beep
22.5–32.3 s Text drawn in the spectrogram (3–6 kHz band)
32.9–34.7 s Short tone, then DTMF 0 0 0 0 (941 + 1336 Hz). Filler.

A spectrogram shows the key:

ffmpeg -i call.wav -lavfi showspectrumpic=s=1800x700:legend=1 spec.png

The image reads ARCHIVE KEY s4lt3d-t1d35.

With OpenSSL’s default PBKDF2 settings (10,000 iterations), this key does not work. That makes it look like bait, but only the iteration count is non-default. A sweep over common iteration counts and digests, looking for a PK\x03\x04 header in the first decrypted block, finds PBKDF2-HMAC-SHA256 with 200,000 iterations:

openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -md sha256 \
  -in westbound-export.zip.enc -out export.zip -pass pass:s4lt3d-t1d35

6. Inside the export: git history

export.zip holds the full tidewatch/ repo, including .git/. The working tree is clean, but the git page already listed the history:

fabe99b  init: host lookup
6b26aa5  tag the fields i care about
5ff35ab  notes before i forget        <- adds notes.b64
72eb7a7  remove notes before push     <- deletes it

Deleting a file does not remove its blob from .git/objects. You can recover it from the deleting commit’s parent tree:

$ git show 5ff35ab:notes.b64 | base64 -d     # or: git cat-file -p 12a6e08 | base64 -d
tidewatch stays public because nobody reads a recon tool, and nobody
reads the history of one.

kestrel paid for the audit and got the copy instead. the marker they are
hunting is in this export, planted where it will outlive the job:

  KPMG_CTF{g1t_r3m3mb3r5_wh4t_y0u_d3l3t3}

Summary

Piece Source Gives
Bait proof.png LSB fake flag + map of the pieces
Locker files/ inline base64 pcap, wav, jpg
Code desk.jpg sticky note /d/7c41f9a2e0b3
Door westbound.pcap HTTP POST velvet-harbor
Key call.wav spectrogram s4lt3d-t1d35 (PBKDF2-SHA256, 200k iterations)
Marker deleted notes.b64 in .git KPMG_CTF{g1t_r3m3mb3r5_wh4t_y0u_d3l3t3}

Takeaways: check LSB layers, not just the visible image. Treat “obvious” flags and metadata as bait. Look at audio as a spectrogram. When a password fails, try non-default KDF settings before giving up on it. Git history keeps content after the file is deleted.