← Back to Writeups
CUSTOMMediumForensics

Download

XESXOR9/2/20264 min read
#forensics#custom#medium#bittorrent_piece_extraction#image_watermark_analysis#tcp_stream_reassembly

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
#!/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
from 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") ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍