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 namevanir - 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}

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

the hint:

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

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.

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