TJCTF 2026 — loud-packets Writeup

A pcap posing as a Bluetooth file transfer actually carries a WAV file split across 459 UDP packets. The audio is not a protocol — it is a Hellschreiber bitmap, and the spectrogram is the decoder.

4 min read
ibrahimsql
740 words

TJCTF 2026 — loud-packets

A forensics challenge where the intended decoder is your eyes, not your toolchain.

  • Event: TJCTF 2026
  • Category: Forensics
  • Artifact: chall.pcap
  • Flag format: tjctf{...}

1. The capture is not Bluetooth

The description says a sensitive file was "transferred over bluetooth". It is a misdirection. Open the capture and there is no HCI, no L2CAP, no SDP — just 509 UDP-over-IPv4 frames.

eth     frames:509
  ip    frames:509
    udp frames:509

Splitting by flow shows two very different things:

  • 459 "main" packets 192.168.1.100:50000 → 192.168.1.200:62000, every one exactly 608 bytes.
  • 50 "decoy" packets from random 10.0.0.x addresses with random-looking ports and payloads.

The decoys are noise. The main flow is a stream.

2. Reassembling the file

Every main packet payload starts with the same 8-byte header:

42 54 41 56   BTAV            magic
00 00 00 ae   sequence (big-endian u32)
... 600 bytes of chunk data

BTAV is the author's wink at "Bluetooth Audio". The next four bytes are a contiguous sequence number in the range 0..458 (459 values, no gaps). The bytes after the header are a slice of one file: 600 bytes each, except the last packet at 428 bytes. Total 458 * 600 + 428 = 275228 bytes.

Drop the 8-byte header, sort by sequence, concatenate:

Python
from scapy.all import rdpcap, UDP
 
def carve(pcap):
    chunks = {}
    for p in rdpcap(pcap):
        if UDP not in p:
            continue
        if (p[UDP].sport, p[UDP].dport) != (50000, 62000):
            continue
        data = bytes(p[UDP].payload)
        if data[:4] != b"BTAV":
            continue
        seq = int.from_bytes(data[4:8], "big")
        chunks[seq] = data[8:]
    return b"".join(chunks[i] for i in sorted(chunks))
 
blob = carve("chall.pcap")
open("ordered.wav", "wb").write(blob)

The result is a valid WAV: RIFF header, fmt chunk, data chunk — 44.1 kHz, mono, 16-bit PCM, 3.12 seconds. The very first chunk even starts with RIFF....WAVEfmt right after the BTAV header.

3. The audio looks like a protocol

Play the WAV and you hear a fast series of staccato multi-tone bursts. In a spectrogram it is almost too tidy: seven horizontal carriers, all landing on clean multiples of ~440 Hz:

 880   1320   1760   2200   2640   3080   3520 Hz

There are about 26 discrete bursts spaced roughly 120 ms apart. It is tempting to reach for MFSK, DTMF, ggwave, SSTV — but none of them decode anything. The reason is simple: there is no symbol table. The audio is not encoding text with a protocol. It is drawing text.

4. Hellschreiber — the picture protocol

Hellschreiber transmits a character as a tiny bitmap, painted column by column. A standard font is 7 pixels tall; the seven carriers here are exactly those seven rows. Each burst is one character cell:

  • Character cell = 120 ms wide, made of 6 columns of 20 ms each.
  • A lit pixel in a column = the corresponding carrier tone is active.
  • A column with only the lowest carrier (880 Hz) = a single dot on the bottom row = the underscore.

26 character cells across 3.12 s. The spectrogram is the decoder — render it and read the dots.

5. Rendering the picture

The trick is horizontal resolution. Feed matplotlib a spectrogram whose column step is a few samples (near-maximal overlap) and stretch the figure wide:

Python
import wave, numpy as np
import matplotlib.pyplot as plt
 
with wave.open("ordered.wav") as w:
    sr = w.getframerate()
    data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32)
 
Pxx, freqs, t = plt.specgram(data, NFFT=2048, Fs=sr, noverlap=2048 - 4, mode="magnitude")[:3]
plt.close()
 
fm = (freqs >= 700) & (freqs <= 4000)
img = 20 * np.log10(Pxx[fm, :] + 1e-10)
img -= img.max()
 
fig, ax = plt.subplots(figsize=(48, 8))
ax.imshow(-img, aspect="auto", origin="lower", cmap="gray",
          extent=[t[0], t[-1], freqs[fm][0], freqs[fm][-1]], vmin=0, vmax=35)
plt.tight_layout()
plt.savefig("flag.png", dpi=180)

The noverlap=2048-4 shifts the window by 4 samples, giving enough columns to resolve the painted glyphs. Inverting the colormap (-img) paints the dots dark on a light background, which reads far better than glowing amber on black. The seven carrier rows become seven rows of a block font, and the flag is sitting there in 7-pixel-tall letters.

6. Flag

tjctf{v3ry_l0ud_pc4p_f1le}

26 characters, 26 burst cells, and the underscores line up exactly with the three bursts that lit only the lowest carrier.

The takeaway: when a spectrogram has obvious banding and per-column structure, try reading it before decoding it. Hellschreiber, spectrogram art and MFSK all share one property — there is no decoder, because the picture already is the message.

---
Share this post:

What do you think?

React to show your appreciation

Related Posts