Download
Download
Platform: SPbCTF | Category: Forensics | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-03-17 | Status: Solved Techniques: bittorrent_piece_extraction, image_watermark_analysis, tcp_stream_reassembly
Summary
Task: PCAP file with network traffic, find what was downloaded and extract the flag. Solution: Reassemble TCP streams to extract BitTorrent piece messages, reconstruct the downloaded PNG file, and analyze image watermark using channel difference to reveal the flag.
Download — SPbCTF
Description
PCAP файл (21MB) содержит сетевой трафик. Задача — найти что было скачано и извлечь флаг.
Analysis
PCAP contains exclusively BitTorrent traffic:
- 8 TCP connections from 10.9.0.10 to various BitTorrent peers
- One torrent with info_hash:
49e9bf2a3886fa794d7fce93ae3ea5774d48895a - Clients: libTorrent 0.13.6 and Tixati 2.51
- BEP 9 (ut_metadata) extension, metadata_size = 5831 bytes
- Piece size: 65536 bytes (64KB), split into 4 blocks of 16384 bytes
BitTorrent piece message format:
- 4 bytes: message length = 0x00004009 (16393 = 9 header + 16384 data)
- 1 byte: message ID = 0x07 (piece)
- 4 bytes: piece index
- 4 bytes: begin offset (0x0000, 0x4000, 0x8000, or 0xC000)
- 16384 bytes: block data
Solution
Step 1: TCP Stream Reassembly
Proper TCP stream reassembly handling sequence numbers, retransmissions, and overlaps:
python read-only • copy allowed#!/usr/bin/env python3 """BitTorrent piece extraction with proper TCP reassembly""" import dpkt import struct from collections import defaultdict import bencodepy def reassemble_tcp_streams(pcap_path): """Reassemble TCP streams handling retransmissions and ordering.""" streams = defaultdict(list) # key -> [(seq, data, timestamp)] with open(pcap_path, 'rb') as f: pcap = dpkt.pcap.Reader(f) for ts, buf in pcap: try: eth = dpkt.ethernet.Ethernet(buf) if not isinstance(eth.data, dpkt.ip.IP): continue ip = eth.data if not isinstance(ip.data, dpkt.tcp.TCP): continue tcp = ip.data if len(tcp.data) == 0: continue src = (dpkt.utils.inet_to_str(ip.src), tcp.sport) dst = (dpkt.utils.inet_to_str(ip.dst), tcp.dport) stream_key = (src, dst) streams[stream_key].append((tcp.seq, tcp.data, ts)) except: continue # Reassemble each stream reassembled = {} for key, segments in streams.items(): if not segments: continue segments.sort(key=lambda x: (x[0], x[2])) min_seq = segments[0][0] # Determine total size max_end = 0 for seq, data, ts in segments: rel_seq = (seq - min_seq) & 0xFFFFFFFF if rel_seq > 100_000_000: continue end = rel_seq + len(data) if end > max_end: max_end = end if max_end == 0 or max_end > 100_000_000: continue # Fill in data (first occurrence wins for overlaps) result = bytearray(max_end) filled = bytearray(max_end) for seq, data, ts in segments: rel_seq = (seq - min_seq) & 0xFFFFFFFF if rel_seq > 100_000_000: continue for i in range(len(data)): pos = rel_seq + i if pos < max_end and not filled[pos]: result[pos] = data[i] filled[pos] = 1 reassembled[key] = bytes(result) return reassembled def parse_bt_messages(data): """Parse BitTorrent messages from reassembled stream.""" messages = [] offset = 0 # Skip handshake if present if len(data) >= 68 and data[0:1] == b'\x13' and data[1:20] == b'BitTorrent protocol': offset = 68 while offset + 4 <= len(data): msg_len = struct.unpack('>I', data[offset:offset+4])[0] if msg_len == 0: # keepalive offset += 4 continue if msg_len > 200000: offset += 1 continue if offset + 4 + msg_len > len(data): break msg_id = data[offset + 4] payload = data[offset + 5:offset + 4 + msg_len] messages.append({'msg_id': msg_id, 'payload': payload}) offset += 4 + msg_len return messages def main(): pcap_path = '2017-09-01_network_dump.pcap' # Step 1: Reassemble TCP streams streams = reassemble_tcp_streams(pcap_path) # Identify BitTorrent streams bt_streams = {k: v for k, v in streams.items() if len(v) >= 68 and v[0:1] == b'\x13' and v[1:20] == b'BitTorrent protocol'} # Step 2: Extract piece messages pieces = {} # (piece_index, begin_offset) -> data for key, data in bt_streams.items(): messages = parse_bt_messages(data) for msg in messages: if msg['msg_id'] == 7 and len(msg['payload']) >= 8: # Piece message payload = msg['payload'] piece_index = struct.unpack('>I', payload[0:4])[0] begin_offset = struct.unpack('>I', payload[4:8])[0] block_data = payload[8:] pkey = (piece_index, begin_offset) if pkey not in pieces: pieces[pkey] = block_data print(f"Extracted {len(pieces)} unique blocks") # Step 3: Reassemble file piece_length = 65536 total_pieces = max(k[0] for k in pieces.keys()) + 1 file_data = bytearray(piece_length * total_pieces) for (pid, off), block_data in pieces.items(): start = pid * piece_length + off file_data[start:start + len(block_data)] = block_data # Step 4: Extract PNG png_sig = b'\x89PNG\r\n\x1a\n' png_pos = bytes(file_data).find(png_sig) if png_pos >= 0: # Find IEND chunk pos = png_pos + 8 while pos + 8 <= len(file_data): chunk_len = struct.unpack('>I', file_data[pos:pos+4])[0] chunk_type = file_data[pos+4:pos+8] chunk_end = pos + 4 + 4 + chunk_len + 4 if chunk_type == b'IEND': png_data = file_data[png_pos:chunk_end] with open('extracted_image.png', 'wb') as f: f.write(png_data) print(f"Saved PNG: {len(png_data)} bytes") break pos = chunk_end if __name__ == '__main__': main()
Step 2: Image Analysis
Result — PNG 6000x4000 with an airplane against sky/mountains background. The image contains a diagonal semi-transparent watermark.
Step 3: Watermark Extraction
Watermark is visible when analyzing the blue channel and channel difference (R-B diff):
python read-only • copy allowedfrom PIL import Image, ImageEnhance import numpy as np img = Image.open('extracted_image.png') r, g, b = img.split() # Blue channel isolation b.save('blue_channel.png') # R-B difference (highlights watermark) r_arr = np.array(r, dtype=np.int16) b_arr = np.array(b, dtype=np.int16) diff = np.abs(r_arr - b_arr) diff = np.clip(diff * 10, 0, 255).astype(np.uint8) Image.fromarray(diff).save('rb_diff_enhanced.png')
Watermark reads as: REDACTED (leetspeak "Awesome torrent seed")