spirit — Minecraft PCAP forensics CTF

i made this challenge for the ITC CTF 2026.

Flag

ITC{ANTI_ENS_LIVES_ON!} (case insensitive ig)

Capture

  • protocol 772, offline-mode (online-mode=false), login name vanir
  • flag = block geometry, NOT text. 208 Use Item On (0x3f) packets, client->server
  • placed blocks at y=70, band z=-253..-249, X 96..199
  • art reads ROTATED 180 from map orientation (north-up/east-right shows it inverted)
  • s2c stream has 24 capture gaps (~37KB) — Block Update path unreliable, use c2s
  • c2s stream frames 100% cleanly — this is the intended solve

Decoys (intentionally greppable)

  • ITC{never_easy} (sign)
  • ITC{fake_flag} (sign)
  • base64 SVRDe3N0b3BfZ3JlcHBpbmdfYnVkZHl9 -> ITC{stop_grepping_buddy} (book)
  • real flag must NOT appear in strings — verified

Solve

the player is given a pcap of traffic generated by a player client to a minecraft server (ran on my vps)
description:

someone got the ip to my cracked minecraft server, and logged in using my username, luckily i kept the server’s network monitored. analyze ts.

MC_Dissector + MC Protocols install

to solve this challenge easily, the player needs MC Dissector
installation method is explained in the repo

integration

Edit → Preferences → Protocols → scroll to Minecraft → set “Directory for protocol data” to the MC Protocols data folder

important:
Edit → Preferences → Protocols → TCP:

  • Confirm “Allow subdissector to reassemble TCP streams” is checked (it’s default-on).
  • Check “Reassemble out-of-order segments” — this is off by default and without it you’ll only recover ~13 of 208 placement packets, making the art unreadable. This is the one non-obvious step.

Red herrings + hints

Chat messages: Distracting chat messages, packet id: 0x08. one contains 4954437b66616b655f666c61677d which is hex for ITC{fake_flag}

herring

Sign Writings: packet id: 0x3b. distractions + a hint about reading the block placements with fake flags: ITC{never_easy}, ITC{fake_flag}

false

the hint:

hint

Book Writing: packet id: 0x17. distractions: SVRDe3N0b3BfZ3JlcHBpbmdfYnVkZHl9 base64 for ITC{stop_grepping_buddy}

book

Actual solve

The solve is knowing that the flag is not plaintext, but rather spelled with block placements

Filter to the block placements

In the display filter bar, type:

ip.src==105.107.152.237 && mcjeje.packet_id==0x3f

Press Enter. You should see 208 packets listed (check the status bar packet count at the bottom).

(Alternative if you’d rather discover the filter yourself instead of typing it: find any packet whose Packet Name reads “Server Use Item on”, right-click that field in the detail pane → Apply as Filter → Selected.)

Add X/Z columns so you can export them

Edit → Preferences → Appearance → Columns → click + to add a column, twice:

  • Title X, Type Custom, Field name mcje.int32, Occurrence 1
  • Title Z, Type Custom, Field name mcje.int32, Occurrence 2

(Occurrence disambiguates which of the three repeated x/z/y int32 fields you want — 1st and 2nd occurrence are x and z respectively. You can skip y/direction; the offset math turns out not to matter, see below.)

Export

File → Export Packet Dissections → As CSV…

  • Make sure “Displayed packets only” is checked (so it only exports your filtered 208).
  • Save it somewhere, e.g. ~/spirit_blocks.csv.

Plot it

Open the CSV in LibreOffice Calc or any other CSV viewer → select the X and Z columns → Insert → Chart → choose XY (Scatter).

  • Resize the chart to be very wide and long (the art is ~104 cols × 5 rows — a square chart will squish it unreadably).
  • Right-click the y-axis and click “Format Axis…” then check “Reverse” so that it flips over to be readeable

You should now see ITC{ANTI_ENS_LIVES_ON!} spelled out in the scatter of points.

solve

Other method

The challenge could be solved using a script:


#!/usr/bin/env python3
"""
solve_spirit.py - reference solution for "spirit" (Minecraft PCAP forensics).

Path:
  1. reassemble the client->server TCP stream (port 25565)
  2. frame Minecraft packets: VarInt(length) VarInt(id) payload
  3. keep "Use Item On" (0x3f on protocol 772): hand, position(long), face, ...
  4. apply the face offset -> the block that was actually placed
  5. plot the dense Y plane top-down; read it rotated 180

Usage: python3 solve_spirit.py spirit.pcap
"""
import sys
from collections import Counter, defaultdict
from scapy.all import PcapReader, TCP, IP

USE_ITEM_ON = 0x3F
FACE = {0: (0, -1, 0), 1: (0, 1, 0), 2: (0, 0, -1),
        3: (0, 0, 1), 4: (-1, 0, 0), 5: (1, 0, 0)}


def read_varint(buf, i):
    val = 0
    for k in range(5):
        if i >= len(buf):
            return None, i
        b = buf[i]; i += 1
        val |= (b & 0x7F) << (7 * k)
        if not (b & 0x80):
            return val, i
    return None, i


def decode_pos(v):
    """Packed block position: x=26 bits, z=26 bits, y=12 bits."""
    x, z, y = (v >> 38) & 0x3FFFFFF, (v >> 12) & 0x3FFFFFF, v & 0xFFF
    if x >= 1 << 25: x -= 1 << 26
    if z >= 1 << 25: z -= 1 << 26
    if y >= 1 << 11: y -= 1 << 12
    return x, y, z


def reassemble_c2s(pcap):
    """Rebuild every client->server stream. Trim Ethernet padding via IP len."""
    flows = defaultdict(dict)
    with PcapReader(pcap) as pr:
        for pkt in pr:
            if TCP not in pkt or IP not in pkt:
                continue
            ip, t = pkt[IP], pkt[TCP]
            if t.dport != 25565:
                continue
            plen = ip.len - (ip.ihl * 4) - (t.dataofs * 4)
            if plen <= 0:
                continue
            key = (ip.src, t.sport)
            flows[key].setdefault(t.seq, bytes(t.payload)[:plen])
    return {k: b"".join(p for _, p in sorted(v.items())) for k, v in flows.items()}


def frames(blob):
    i = 0
    while i < len(blob):
        ln, j = read_varint(blob, i)
        if ln is None or ln <= 0 or j + ln > len(blob):
            return
        body = blob[j:j + ln]
        pid, k = read_varint(body, 0)
        yield pid, body[k:]
        i = j + ln


def main():
    pcap = sys.argv[1] if len(sys.argv) > 1 else "spirit.pcap"
    streams = reassemble_c2s(pcap)
    blob = max(streams.values(), key=len)          # the play session
    print(f"[+] client->server stream: {len(blob)} bytes")

    placed = []
    for pid, payload in frames(blob):
        if pid != USE_ITEM_ON or len(payload) < 10:
            continue
        p = 0
        hand, p = read_varint(payload, p)
        if hand is None or hand > 1:
            continue
        v = int.from_bytes(payload[p:p + 8], "big"); p += 8
        cx, cy, cz = decode_pos(v)
        face, p = read_varint(payload, p)
        dx, dy, dz = FACE.get(face, (0, 0, 0))
        placed.append((cx + dx, cy + dy, cz + dz))

    print(f"[+] block placements: {len(placed)}")
    uniq = sorted(set(placed))
    plane_y, _ = Counter(y for _, y, _ in uniq).most_common(1)[0]
    pts = {(x, z) for x, y, z in uniq if y == plane_y}
    print(f"[+] flag plane: y={plane_y} ({len(pts)} blocks)")

    # keep only the dense rows (drop stray noise placements)
    cz = Counter(z for _, z in pts)
    rows = sorted(z for z, n in cz.items() if n >= 10)
    pts = {(x, z) for x, z in pts if rows[0] <= z <= rows[-1]}

    xs = sorted({x for x, _ in pts})
    grid = [[('#' if (x, z) in pts else '.') for x in range(min(xs), max(xs) + 1)]
            for z in range(rows[0], rows[-1] + 1)]
    grid = [list(reversed(r)) for r in reversed(grid)]   # rotate 180

    print(f"[+] flag plane spans X {min(xs)}..{max(xs)}, Z {rows[0]}..{rows[-1]}\n")
    for r in grid:
        print("".join(r))


if __name__ == "__main__":
    main()