"""PCAP file writer and reader for Wireshark analysis. Writes a standard PCAP file with Ethernet (LINKTYPE 1, DLT_EN10MB) encapsulation. Each packet is Ethernet + IPv4 + UDP (port 4729) + GSMTAP, matching a real ``tcpdump -i lo udp port 4729`` capture of GSMTAP-over-UDP (simtrace2-sniff / simtrace2-pysniff output). Reads classic PCAP (micro/nano, both byte orders) and PCAPNG files, extracting GSMTAP-SIM packets (APDU/ATR/PPS) for import into a session. """ import io import struct import time PCAP_MAGIC = 0xa1b2c3d4 LINKTYPE_ETHERNET = 1 # DLT_EN10MB GSMTAP_UDP_PORT = 4729 _LOOPBACK = b'\x7f\x00\x00\x01' _PCAP_GLOBAL_HDR_FMT = '> 16) + (s & 0xffff) s = (s >> 16) + (s & 0xffff) return ~s & 0xffff def wrap_gsmtap(gsmtap_payload): """Wrap a GSMTAP message in Ethernet + IPv4 + UDP (port 4729) headers.""" udp_len = 8 + len(gsmtap_payload) udp_nocs = struct.pack('!HHHH', GSMTAP_UDP_PORT, GSMTAP_UDP_PORT, udp_len, 0) pseudo = _LOOPBACK + _LOOPBACK + struct.pack('!BBH', 0, 17, udp_len) udp_csum = _checksum(pseudo + udp_nocs + gsmtap_payload) udp = struct.pack('!HHHH', GSMTAP_UDP_PORT, GSMTAP_UDP_PORT, udp_len, udp_csum) + gsmtap_payload ip_len = 20 + len(udp) ip_nocs = struct.pack('!BBHHHBBH4s4s', 0x45, 0x00, ip_len, 0, 0x4000, 64, 17, 0, _LOOPBACK, _LOOPBACK) ip_csum = _checksum(ip_nocs) ip = struct.pack('!BBHHHBBH4s4s', 0x45, 0x00, ip_len, 0, 0x4000, 64, 17, ip_csum, _LOOPBACK, _LOOPBACK) + udp eth = struct.pack('!6s6sH', b'\x00' * 6, b'\x00' * 6, 0x0800) return eth + ip def build_pcap(packets): """Build a PCAP file in memory. *packets* is a list of ``(gsmtap_hdr, data, ts)`` tuples, where gsmtap_hdr is the packed 16-byte GSMTAP header and ts is a seconds-since-epoch float. Returns the PCAP file contents as bytes. """ buf = io.BytesIO() buf.write(struct.pack( _PCAP_GLOBAL_HDR_FMT, PCAP_MAGIC, 2, 4, 0, 0, 65535, LINKTYPE_ETHERNET, )) for gsmtap_hdr, data, ts in packets: packet = wrap_gsmtap(gsmtap_hdr + data) ts_sec = int(ts) ts_usec = int((ts - ts_sec) * 1_000_000) buf.write(struct.pack( _PCAP_PKT_HDR_FMT, ts_sec, ts_usec, len(packet), len(packet), )) buf.write(packet) return buf.getvalue() class PcapWriter: """Write sniffed messages as Ethernet/IP/UDP/GSMTAP PCAP packets.""" def __init__(self, path): self._f = open(path, 'wb') self._write_global_header() def _write_global_header(self): hdr = struct.pack( _PCAP_GLOBAL_HDR_FMT, PCAP_MAGIC, # magic_number 2, # version_major 4, # version_minor 0, # thiszone (GMT) 0, # sigfigs 65535, # snaplen LINKTYPE_ETHERNET, # network (Ethernet) ) self._f.write(hdr) def write_packet(self, gsmtap_hdr, data, ts=None): """Write a single GSMTAP-encapsulated packet (wrapped in IP/UDP/Eth). *gsmtap_hdr* is the 16-byte GSMTAP header (already packed). *data* is the raw APDU/ATR bytes. """ if ts is None: ts = time.time() packet = wrap_gsmtap(gsmtap_hdr + data) ts_sec = int(ts) ts_usec = int((ts - ts_sec) * 1_000_000) pkt_hdr = struct.pack( _PCAP_PKT_HDR_FMT, ts_sec, # ts_sec ts_usec, # ts_usec len(packet), # incl_len len(packet), # orig_len ) self._f.write(pkt_hdr + packet) def close(self): self._f.close() # ──────────────────── Readers (import) ──────────────────── def _ipv4_udp_payload(ip): """Extract the UDP payload from an IPv4 packet (bytes).""" if len(ip) < 20 or (ip[0] >> 4) != 4: return None ihl = (ip[0] & 0x0F) * 4 if len(ip) < ihl + 8 or ip[9] != 17: # protocol UDP return None udp = ip[ihl:] udp_len = struct.unpack('!H', udp[4:6])[0] if udp_len < 8 or len(udp) < 8: return None return udp[8:udp_len] def _frame_to_udp_payload(frame, linktype): """Unwrap a link-layer frame down to the UDP payload (bytes or None).""" if linktype == LINKTYPE_ETHERNET: if len(frame) < 14: return None ethertype = frame[12:14] if ethertype == b'\x08\x00': # IPv4 return _ipv4_udp_payload(frame[14:]) # IPv6 and non-IP ethertypes are not GSMTAP-over-UDP for our use return None if linktype in (LINKTYPE_RAW, LINKTYPE_IPV4): return _ipv4_udp_payload(frame) if linktype == LINKTYPE_NULL: if len(frame) < 4: return None family = struct.unpack(' n - off: break frame = data[off:off + incl_len] off += incl_len ts = ts_sec + ts_frac / (1_000_000_000 if ns else 1_000_000) parsed = extract_gsmtap(frame, linktype) if parsed is None: continue sub_type, payload = parsed msg_type = _gsmtap_to_msg(sub_type, payload) if msg_type is not None: yield ts, msg_type, payload def parse_pcapng(data): """Parse a PCAPNG file, yielding (ts, msg_type, payload) tuples. Only GSMTAP-SIM packets are yielded. Timestamps are raw (ts_high << 32 | ts_low) scaled by each interface's resolution. """ if len(data) < 12 or struct.unpack(' linktype tsresols = {} # interface_id -> seconds per tick spb_ts = 0 # file-order timestamp for SPB blocks (no timestamp) off = 0 n = len(data) while off + 8 <= n: block_type = struct.unpack(endian + 'I', data[off:off + 4])[0] total_len = struct.unpack(endian + 'I', data[off + 4:off + 8])[0] if total_len < 12 or off + total_len > n: break body = data[off + 8:off + total_len - 4] off += total_len if block_type == _PNG_IDB and len(body) >= 8: linktype, _res, _snaplen = struct.unpack( endian + 'HHI', body[:8]) iface = len(linktypes) # IDBs are numbered by appearance linktypes[iface] = linktype tsresols[iface] = _parse_tsresol(body[8:], endian) elif block_type == _PNG_EPB and len(body) >= 20: iface, ts_hi, ts_lo, cap_len, _orig = struct.unpack( endian + 'IIIII', body[:20]) if cap_len <= len(body) - 20: frame = body[20:20 + cap_len] ts = _scale_ts(ts_hi, ts_lo, tsresols.get(iface)) parsed = extract_gsmtap(frame, linktypes.get(iface)) if parsed is not None: sub_type, payload = parsed msg_type = _gsmtap_to_msg(sub_type, payload) if msg_type is not None: yield ts, msg_type, payload elif block_type == _PNG_SPB and len(body) >= 4: cap_len, = struct.unpack(endian + 'I', body[:4]) if cap_len <= len(body) - 4: frame = body[4:4 + cap_len] # SPB has no timestamp and no interface; use file-order ts. parsed = _scan_gsmtap(frame) if parsed is not None: sub_type, payload = parsed msg_type = _gsmtap_to_msg(sub_type, payload) if msg_type is not None: yield float(spb_ts), msg_type, payload spb_ts += 1 def _parse_tsresol(options, endian): """Parse if_tsresol from IDB options; return seconds per timestamp tick.""" i = 0 n = len(options) while i + 4 <= n: code, length = struct.unpack(endian + 'HH', options[i:i + 4]) i += 4 if code == 9 and length == 1: b = options[i] if b & 0x80: return 10 ** (-(b & 0x7F)) return 10 ** (-b) i += length + (length & 1) # options are padded to 32-bit return 1e-6 # default: microseconds def _scale_ts(ts_hi, ts_lo, tsresol): """Combine 64-bit pcapng timestamp into seconds since epoch (approx).""" raw = (ts_hi << 32) | ts_lo return raw * (tsresol if tsresol is not None else 1e-6)