Initial commit: simtrace2-pysniff v0.1.0
Python-based SIMtrace2 sniffer — drop-in replacement for simtrace2-sniff. Single dependency (pyusb), auto-reconnect, GSMTAP/PCAP output, library API. Modules: protocol.py — SIMtrace2 USB sniffing protocol parser device.py — USB discovery + SniffSession with exponential backoff reconnect gsmtap.py — GSMTAP UDP sender for Wireshark pcap.py — PCAP file writer (LINKTYPE_GSMTAP) dump.py — hex dump formatters + file output __main__.py — CLI entry point Startup script sniff-start.sh handles udev permission checks and shows install instructions. udev rule uses TAG+=uaccess (systemd-logind ACL).
This commit is contained in:
commit
73b0277ef7
13 files changed
+1289
No files matched your search
@@ -0,0 +1,21 @@
|
||||
"""simtrace2-pysniff — Python replacement for simtrace2-sniff."""
|
||||
|
||||
from .device import SniffSession, DeviceDisconnected, find_sniffer_device
|
||||
from .protocol import parse_message, SniffMessage
|
||||
from .gsmtap import GsmtapSender
|
||||
from .pcap import PcapWriter
|
||||
from .dump import FileDumper, format_message
|
||||
|
||||
__version__ = '0.1.0'
|
||||
__all__ = [
|
||||
'SniffSession',
|
||||
'DeviceDisconnected',
|
||||
'find_sniffer_device',
|
||||
'parse_message',
|
||||
'SniffMessage',
|
||||
'GsmtapSender',
|
||||
'PcapWriter',
|
||||
'FileDumper',
|
||||
'format_message',
|
||||
'__version__',
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""simtrace2-pysniff — CLI entry point.
|
||||
|
||||
Usage: python -m simtrace2_pysniff [options]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
from . import (
|
||||
SniffSession,
|
||||
DeviceDisconnected,
|
||||
GsmtapSender,
|
||||
PcapWriter,
|
||||
FileDumper,
|
||||
format_message,
|
||||
__version__,
|
||||
)
|
||||
from .gsmtap import build_gsmtap_packet, GSMTAP_SIM_ATR, GSMTAP_SIM_APDU
|
||||
|
||||
|
||||
def parse_gsmtap_addr(value):
|
||||
parts = value.rsplit(':', 1)
|
||||
host = parts[0]
|
||||
port = 4729
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
port = int(parts[1])
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f'Invalid GSMTAP port: {parts[1]}')
|
||||
return host, port
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
prog='simtrace2-pysniff',
|
||||
description='Python-based SIMtrace2 sniffer with GSMTAP/PCAP output',
|
||||
)
|
||||
p.add_argument('--version', action='version',
|
||||
version=f'simtrace2-pysniff {__version__}')
|
||||
p.add_argument('--gsmtap', type=parse_gsmtap_addr, metavar='HOST[:PORT]',
|
||||
help='Send ATR/TPDU as GSMTAP over UDP (default port 4729)')
|
||||
p.add_argument('--pcap', metavar='FILE',
|
||||
help='Write sniffed data to PCAP file for Wireshark')
|
||||
p.add_argument('--output', '-o', metavar='FILE',
|
||||
help='Write hex dump to file')
|
||||
p.add_argument('--no-stdout', action='store_true',
|
||||
help='Suppress hex dump to stdout')
|
||||
p.add_argument('--vendor', type=lambda x: int(x, 16),
|
||||
default=0x1d50, metavar='HEX',
|
||||
help='USB vendor ID (default: 0x1d50)')
|
||||
p.add_argument('--product', type=lambda x: int(x, 16),
|
||||
default=None, metavar='HEX',
|
||||
help='USB product ID (default: auto-detect)')
|
||||
|
||||
rec = p.add_argument_group('recovery options')
|
||||
rec.add_argument('--no-reconnect', action='store_true',
|
||||
help='Exit on USB disconnect instead of reconnecting')
|
||||
rec.add_argument('--reconnect-delay-min', type=float, default=1.0,
|
||||
metavar='SECONDS',
|
||||
help='Initial reconnect delay (default: 1.0)')
|
||||
rec.add_argument('--reconnect-delay-max', type=float, default=30.0,
|
||||
metavar='SECONDS',
|
||||
help='Maximum reconnect delay (default: 30.0)')
|
||||
rec.add_argument('--backoff-factor', type=float, default=1.5,
|
||||
metavar='N',
|
||||
help='Exponential backoff multiplier (default: 1.5)')
|
||||
rec.add_argument('--inactivity-timeout', type=float, default=0.0,
|
||||
metavar='SECONDS',
|
||||
help='Reconnect after N seconds of silence (default: disabled)')
|
||||
return p
|
||||
|
||||
|
||||
def print_banner():
|
||||
print('simtrace2-pysniff — Phone-SIM card communication sniffer\n'
|
||||
f'Version {__version__} — Python-based replacement for simtrace2-sniff\n',
|
||||
file=sys.stderr)
|
||||
print('Entering main loop', file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
print_banner()
|
||||
|
||||
gsmtap = None
|
||||
if args.gsmtap:
|
||||
host, port = args.gsmtap
|
||||
gsmtap = GsmtapSender(host, port)
|
||||
print(f'GSMTAP output: {host}:{port}', file=sys.stderr)
|
||||
|
||||
pcap = None
|
||||
if args.pcap:
|
||||
pcap = PcapWriter(args.pcap)
|
||||
print(f'PCAP output: {args.pcap}', file=sys.stderr)
|
||||
|
||||
file_dumper = None
|
||||
if args.output:
|
||||
file_dumper = FileDumper(args.output)
|
||||
print(f'File output: {args.output}', file=sys.stderr)
|
||||
|
||||
session = SniffSession(
|
||||
reconnect=not args.no_reconnect,
|
||||
reconnect_delay_min=args.reconnect_delay_min,
|
||||
reconnect_delay_max=args.reconnect_delay_max,
|
||||
backoff_factor=args.backoff_factor,
|
||||
inactivity_timeout=args.inactivity_timeout,
|
||||
)
|
||||
|
||||
exiting = False
|
||||
|
||||
def on_signal(signum, frame):
|
||||
nonlocal exiting
|
||||
if exiting:
|
||||
raise SystemExit(1)
|
||||
exiting = True
|
||||
print('\nShutting down...', file=sys.stderr)
|
||||
session.close()
|
||||
if gsmtap:
|
||||
gsmtap.close()
|
||||
if pcap:
|
||||
pcap.close()
|
||||
if file_dumper:
|
||||
file_dumper.close()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, on_signal)
|
||||
signal.signal(signal.SIGTERM, on_signal)
|
||||
|
||||
try:
|
||||
for msg in session.iter_messages():
|
||||
if not args.no_stdout:
|
||||
print(format_message(msg))
|
||||
|
||||
if file_dumper is not None:
|
||||
file_dumper.write(msg)
|
||||
|
||||
if gsmtap is not None and msg.type in ('atr', 'tpdu'):
|
||||
if msg.type == 'atr':
|
||||
gsmtap.send_atr(msg.data, msg.slot_nr)
|
||||
else:
|
||||
gsmtap.send_apdu(msg.data, msg.slot_nr)
|
||||
|
||||
if pcap is not None:
|
||||
sub_type = GSMTAP_SIM_ATR if msg.type == 'atr' else GSMTAP_SIM_APDU
|
||||
gsmtap_hdr = build_gsmtap_packet(sub_type, b'')[:16]
|
||||
pcap.write_packet(gsmtap_hdr, msg.data, msg.timestamp)
|
||||
|
||||
except DeviceDisconnected as e:
|
||||
print(f'Device disconnected: {e}', file=sys.stderr)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
session.close()
|
||||
if gsmtap:
|
||||
gsmtap.close()
|
||||
if pcap:
|
||||
pcap.close()
|
||||
if file_dumper:
|
||||
file_dumper.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
"""USB device discovery and sniffing session with auto-reconnect."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import usb.core
|
||||
import usb.util
|
||||
|
||||
USB_VENDOR_OPENMOKO = 0x1d50
|
||||
USB_CLASS_PROPRIETARY = 0xff
|
||||
SIMTRACE_SNIFFER_USB_SUBCLASS = 1
|
||||
|
||||
FATAL_USB_ERRNOS = {
|
||||
2, # ENOENT
|
||||
5, # EIO (some platforms)
|
||||
19, # ENODEV (Linux)
|
||||
32, # EPIPE / ESHUTDOWN (some platforms)
|
||||
108, # ESHUTDOWN (Linux)
|
||||
}
|
||||
|
||||
|
||||
class DeviceDisconnected(Exception):
|
||||
"""Raised when the USB device disappears or becomes unresponsive."""
|
||||
pass
|
||||
|
||||
|
||||
def _is_fatal_usb_error(err):
|
||||
if isinstance(err, usb.core.USBError):
|
||||
if hasattr(err, 'errno') and err.errno is not None:
|
||||
return err.errno in FATAL_USB_ERRNOS
|
||||
if err.strerror and ('device' in err.strerror.lower() or
|
||||
'disconnect' in err.strerror.lower()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _usb_error_str(err):
|
||||
if isinstance(err, usb.core.USBError):
|
||||
return f'USBError(errno={getattr(err, "errno", "N/A")}, strerror={err.strerror})'
|
||||
return str(err)
|
||||
|
||||
|
||||
def find_sniffer_device(vendor_id=USB_VENDOR_OPENMOKO):
|
||||
"""Find a SIMtrace2 device running sniffer firmware.
|
||||
|
||||
Returns a ``usb.core.Device`` on success, or ``None``.
|
||||
"""
|
||||
devices = list(usb.core.find(
|
||||
find_all=True,
|
||||
idVendor=vendor_id,
|
||||
custom_match=lambda d: (
|
||||
True
|
||||
),
|
||||
))
|
||||
|
||||
for dev in devices:
|
||||
for cfg in dev:
|
||||
for intf in cfg:
|
||||
cls = intf.bInterfaceClass
|
||||
sub = intf.bInterfaceSubClass
|
||||
if cls == USB_CLASS_PROPRIETARY and sub == SIMTRACE_SNIFFER_USB_SUBCLASS:
|
||||
return dev
|
||||
return None
|
||||
|
||||
|
||||
def _get_ep_addrs(dev):
|
||||
"""Return (ep_out, ep_in, ep_irq) addresses from the active config."""
|
||||
cfg = dev.get_active_configuration()
|
||||
for intf in cfg:
|
||||
if (intf.bInterfaceClass == USB_CLASS_PROPRIETARY and
|
||||
intf.bInterfaceSubClass == SIMTRACE_SNIFFER_USB_SUBCLASS):
|
||||
for ep in intf:
|
||||
addr = ep.bEndpointAddress
|
||||
if addr & 0x80: # IN endpoint
|
||||
return addr
|
||||
return None
|
||||
|
||||
|
||||
class SniffSession:
|
||||
"""Manages a SIMtrace2 sniffer USB connection with auto-reconnect.
|
||||
|
||||
Usage::
|
||||
|
||||
session = SniffSession(
|
||||
inactivity_timeout=10.0,
|
||||
reconnect_delay_min=1.0,
|
||||
reconnect_delay_max=30.0,
|
||||
)
|
||||
try:
|
||||
for msg in session.iter_messages():
|
||||
print(f"{msg.type}: {msg.data.hex()}")
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
session.close()
|
||||
"""
|
||||
|
||||
def __init__(self, *,
|
||||
reconnect=True,
|
||||
reconnect_delay_min=1.0,
|
||||
reconnect_delay_max=30.0,
|
||||
backoff_factor=1.5,
|
||||
inactivity_timeout=0.0):
|
||||
self._reconnect = reconnect
|
||||
self._reconnect_delay_min = reconnect_delay_min
|
||||
self._reconnect_delay_max = reconnect_delay_max
|
||||
self._backoff_factor = backoff_factor
|
||||
self._inactivity_timeout = inactivity_timeout
|
||||
|
||||
self._dev = None
|
||||
self._devh = None
|
||||
self._ep_in = None
|
||||
self._last_msg_time = 0.0
|
||||
self._disconnect_count = 0
|
||||
|
||||
def _ensure_connected(self):
|
||||
"""Find, open, and claim the sniffer device.
|
||||
|
||||
Raises DeviceDisconnected if no device found or open fails.
|
||||
"""
|
||||
if self._devh is not None:
|
||||
return
|
||||
|
||||
dev = find_sniffer_device()
|
||||
if dev is None:
|
||||
raise DeviceDisconnected('No SIMtrace2 sniffer device found')
|
||||
|
||||
try:
|
||||
if dev.is_kernel_driver_active(0):
|
||||
dev.detach_kernel_driver(0)
|
||||
except (usb.core.USBError, NotImplementedError):
|
||||
pass
|
||||
|
||||
try:
|
||||
devh = dev
|
||||
devh.set_configuration()
|
||||
usb.util.claim_interface(devh, 0)
|
||||
except usb.core.USBError as e:
|
||||
raise DeviceDisconnected(
|
||||
f'Failed to open/claim device: {_usb_error_str(e)}') from e
|
||||
|
||||
ep_in = _get_ep_addrs(devh)
|
||||
if ep_in is None:
|
||||
usb.util.release_interface(devh, 0)
|
||||
raise DeviceDisconnected('No BULK IN endpoint found on sniffer interface')
|
||||
|
||||
self._dev = dev
|
||||
self._devh = devh
|
||||
self._ep_in = ep_in
|
||||
self._last_msg_time = time.monotonic()
|
||||
self._disconnect_count = 0
|
||||
|
||||
print(f'Connected to SIMtrace2 sniffer (EP IN 0x{ep_in:02x})',
|
||||
file=sys.stderr)
|
||||
|
||||
def _close(self):
|
||||
"""Release USB resources gracefully."""
|
||||
if self._devh is not None:
|
||||
try:
|
||||
usb.util.release_interface(self._devh, 0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
usb.util.dispose_resources(self._devh)
|
||||
except Exception:
|
||||
pass
|
||||
self._devh = None
|
||||
self._dev = None
|
||||
self._ep_in = None
|
||||
|
||||
def _backoff_wait(self):
|
||||
"""Sleep with exponential backoff before reconnecting."""
|
||||
delay = self._reconnect_delay_min * (self._backoff_factor ** self._disconnect_count)
|
||||
delay = min(delay, self._reconnect_delay_max)
|
||||
self._disconnect_count += 1
|
||||
|
||||
print(f'Disconnected — retrying in {delay:.1f}s '
|
||||
f'(attempt {self._disconnect_count})',
|
||||
file=sys.stderr)
|
||||
time.sleep(delay)
|
||||
|
||||
def _read_loop(self):
|
||||
"""Inner read loop. Yields SniffMessages. Raises DeviceDisconnected on error."""
|
||||
from .protocol import parse_message
|
||||
|
||||
buf = bytearray()
|
||||
max_buf = 1024 * 1024 # 1 MB
|
||||
|
||||
while True:
|
||||
try:
|
||||
chunk = self._devh.read(
|
||||
self._ep_in, 65536, timeout=1000)
|
||||
chunk = bytes(chunk)
|
||||
except usb.core.USBTimeoutError:
|
||||
self._check_inactivity()
|
||||
continue
|
||||
except usb.core.USBError as e:
|
||||
if _is_fatal_usb_error(e):
|
||||
raise DeviceDisconnected(
|
||||
f'USB read error: {_usb_error_str(e)}') from e
|
||||
print(f'Transient USB error: {_usb_error_str(e)}',
|
||||
file=sys.stderr)
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f'Unexpected read error: {e}', file=sys.stderr)
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
if not chunk:
|
||||
self._check_inactivity()
|
||||
continue
|
||||
|
||||
self._last_msg_time = time.monotonic()
|
||||
buf.extend(chunk)
|
||||
|
||||
if len(buf) > max_buf:
|
||||
print(f'Buffer overflow ({len(buf)} > {max_buf}), '
|
||||
f'discarding oldest data', file=sys.stderr)
|
||||
buf = buf[-max_buf // 2:]
|
||||
|
||||
while True:
|
||||
msg, consumed = parse_message(buf, timestamp=time.time())
|
||||
if msg is not None:
|
||||
yield msg
|
||||
if consumed <= 0 or consumed > len(buf):
|
||||
break
|
||||
buf = buf[consumed:]
|
||||
|
||||
def _check_inactivity(self):
|
||||
if self._inactivity_timeout <= 0:
|
||||
return
|
||||
elapsed = time.monotonic() - self._last_msg_time
|
||||
if elapsed > self._inactivity_timeout:
|
||||
raise DeviceDisconnected(
|
||||
f'Inactivity timeout ({elapsed:.1f}s > '
|
||||
f'{self._inactivity_timeout:.1f}s)')
|
||||
|
||||
def iter_messages(self):
|
||||
"""Generator of SniffMessage objects.
|
||||
|
||||
Automatically reconnects on disconnect if ``reconnect=True``.
|
||||
Raises ``DeviceDisconnected`` if reconnect is disabled or exhausted.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
yield from self._read_loop()
|
||||
except DeviceDisconnected:
|
||||
self._close()
|
||||
if not self._reconnect:
|
||||
raise
|
||||
self._backoff_wait()
|
||||
|
||||
def close(self):
|
||||
self._reconnect = False
|
||||
self._close()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Hex dump formatters for simtrace2 sniffing output."""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def hexdump(data, sep=' '):
|
||||
"""Return hex string representation of bytes.
|
||||
|
||||
>>> hexdump(b'\\x3b\\x9e\\x12\\x34')
|
||||
'3b 9e 12 34'
|
||||
"""
|
||||
return data.hex(sep)
|
||||
|
||||
|
||||
def _flag_str(flags, flag_names):
|
||||
if not flags:
|
||||
return ''
|
||||
names = []
|
||||
for bit, name in sorted(flag_names.items()):
|
||||
if flags & bit:
|
||||
names.append(name)
|
||||
flags &= ~bit
|
||||
if flags:
|
||||
names.append(f'unknown(0x{flags:08x})')
|
||||
return ', '.join(names)
|
||||
|
||||
|
||||
def format_message(msg, *, show_flags=True):
|
||||
"""Format a SniffMessage as a human-readable line.
|
||||
|
||||
Returns a str suitable for stdout or file output.
|
||||
"""
|
||||
from .protocol import (
|
||||
CHANGE_FLAG_CARD_INSERT, CHANGE_FLAG_CARD_EJECT,
|
||||
CHANGE_FLAG_RESET_ASSERT, CHANGE_FLAG_RESET_DEASSERT,
|
||||
CHANGE_FLAG_TIMEOUT_WT,
|
||||
DATA_FLAG_ERROR_INCOMPLETE, DATA_FLAG_ERROR_MALFORMED,
|
||||
DATA_FLAG_ERROR_CHECKSUM, DATA_FLAG_ERROR_OVERRUN,
|
||||
DATA_FLAG_ERROR_FRAMING, DATA_FLAG_ERROR_PARITY,
|
||||
)
|
||||
|
||||
change_flag_names = {
|
||||
CHANGE_FLAG_CARD_INSERT: 'card inserted',
|
||||
CHANGE_FLAG_CARD_EJECT: 'card ejected',
|
||||
CHANGE_FLAG_RESET_ASSERT: 'reset asserted',
|
||||
CHANGE_FLAG_RESET_DEASSERT: 'reset de-asserted',
|
||||
CHANGE_FLAG_TIMEOUT_WT: 'waiting time timeout',
|
||||
}
|
||||
|
||||
data_flag_names = {
|
||||
DATA_FLAG_ERROR_INCOMPLETE: 'incomplete',
|
||||
DATA_FLAG_ERROR_MALFORMED: 'malformed',
|
||||
DATA_FLAG_ERROR_CHECKSUM: 'checksum error',
|
||||
DATA_FLAG_ERROR_OVERRUN: 'overrun',
|
||||
DATA_FLAG_ERROR_FRAMING: 'framing error',
|
||||
DATA_FLAG_ERROR_PARITY: 'parity error',
|
||||
}
|
||||
|
||||
type_label = msg.type.upper()
|
||||
|
||||
if msg.type == 'change':
|
||||
flags_str = _flag_str(msg.flags, change_flag_names)
|
||||
line = f'Card state change: {flags_str}' if flags_str else 'Card state change: no changes'
|
||||
elif msg.type == 'fidi':
|
||||
fidi = msg.data[0] if len(msg.data) >= 1 else 0
|
||||
fi = fidi >> 4
|
||||
di = fidi & 0x0f
|
||||
line = f'Fi/Di switched to {_fi_table.get(fi, fi)}/{_di_table.get(di, di)}'
|
||||
else:
|
||||
flags_str = ''
|
||||
if show_flags and msg.flags:
|
||||
flags_str = f' ({_flag_str(msg.flags, data_flag_names)})'
|
||||
line = f'{type_label}{flags_str}: {hexdump(msg.data)}'
|
||||
|
||||
return line
|
||||
|
||||
|
||||
class FileDumper:
|
||||
"""Write formatted sniff messages to a file."""
|
||||
|
||||
def __init__(self, path):
|
||||
self._f = open(path, 'a', buffering=1)
|
||||
|
||||
def write(self, msg):
|
||||
line = format_message(msg)
|
||||
self._f.write(line + '\n')
|
||||
|
||||
def close(self):
|
||||
self._f.close()
|
||||
|
||||
|
||||
# ISO 7816-3:2006 Table 7
|
||||
_fi_table = {
|
||||
0: 372, 1: 372, 2: 558, 3: 744,
|
||||
4: 1116, 5: 1488, 6: 1860, 7: 0,
|
||||
8: 0, 9: 512, 10: 768, 11: 1024,
|
||||
12: 1536, 13: 2048, 14: 0, 15: 0,
|
||||
}
|
||||
|
||||
# ISO 7816-3:2006 Table 8
|
||||
_di_table = {
|
||||
0: 0, 1: 1, 2: 2, 3: 4,
|
||||
4: 8, 5: 16, 6: 32, 7: 64,
|
||||
8: 12, 9: 20, 10: 2, 11: 4,
|
||||
12: 8, 13: 16, 14: 32, 15: 64,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""GSMTAP UDP sender for Wireshark integration.
|
||||
|
||||
GSMTAP header format (big-endian, 16 bytes). Based on libosmocore's gsmtap.h.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import socket
|
||||
|
||||
GSMTAP_VERSION = 0x02
|
||||
GSMTAP_HDR_LEN = 4 # in 32-bit words (16 bytes)
|
||||
GSMTAP_TYPE_SIM = 0x07
|
||||
|
||||
GSMTAP_SIM_ATR = 0x01
|
||||
GSMTAP_SIM_APDU = 0x02
|
||||
|
||||
GSMTAP_UDP_PORT = 4729
|
||||
|
||||
_GSMTAP_HDR_FMT = '!BBBBHBBIBBBB' # 16 bytes, big-endian
|
||||
_GSMTAP_HDR_SIZE = struct.calcsize(_GSMTAP_HDR_FMT)
|
||||
|
||||
|
||||
def build_gsmtap_packet(sub_type, data, slot_nr=0):
|
||||
"""Build a GSMTAP packet as bytes.
|
||||
|
||||
Returns *gsmtap_hdr* + *data* as a single bytes object.
|
||||
"""
|
||||
hdr = struct.pack(
|
||||
_GSMTAP_HDR_FMT,
|
||||
GSMTAP_VERSION, # version
|
||||
GSMTAP_HDR_LEN, # hdr_len (in 32-bit words)
|
||||
GSMTAP_TYPE_SIM, # type
|
||||
0, # timeslot
|
||||
0, # arfcn
|
||||
0, # noise_db
|
||||
0, # signal_db
|
||||
0, # frame_number
|
||||
sub_type, # sub_type
|
||||
0, # antenna_nr
|
||||
slot_nr, # sub_slot
|
||||
0, # res
|
||||
)
|
||||
return hdr + data
|
||||
|
||||
|
||||
class GsmtapSender:
|
||||
"""Send ATR/APDU data as GSMTAP UDP packets."""
|
||||
|
||||
def __init__(self, host='127.0.0.1', port=GSMTAP_UDP_PORT):
|
||||
self._addr = (host, port)
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
def send_atr(self, data, slot_nr=0):
|
||||
packet = build_gsmtap_packet(GSMTAP_SIM_ATR, data, slot_nr)
|
||||
self._sock.sendto(packet, self._addr)
|
||||
|
||||
def send_apdu(self, data, slot_nr=0):
|
||||
packet = build_gsmtap_packet(GSMTAP_SIM_APDU, data, slot_nr)
|
||||
self._sock.sendto(packet, self._addr)
|
||||
|
||||
def close(self):
|
||||
self._sock.close()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""PCAP file writer for Wireshark analysis.
|
||||
|
||||
Writes a standard PCAP file with LINKTYPE_GSMTAP (155, 0x009b).
|
||||
Each packet is a GSMTAP header + raw APDU/ATR data.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import time
|
||||
|
||||
PCAP_MAGIC = 0xa1b2c3d4
|
||||
LINKTYPE_GSMTAP = 155 # 0x009b
|
||||
|
||||
_PCAP_GLOBAL_HDR_FMT = '<IHHiIII'
|
||||
_PCAP_GLOBAL_HDR_SIZE = struct.calcsize(_PCAP_GLOBAL_HDR_FMT)
|
||||
|
||||
_PCAP_PKT_HDR_FMT = '<IIII'
|
||||
_PCAP_PKT_HDR_SIZE = struct.calcsize(_PCAP_PKT_HDR_FMT)
|
||||
|
||||
|
||||
class PcapWriter:
|
||||
"""Write sniffed messages as GSMTAP-encapsulated 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_GSMTAP, # network (LINKTYPE_GSMTAP)
|
||||
)
|
||||
self._f.write(hdr)
|
||||
|
||||
def write_packet(self, gsmtap_hdr, data, ts=None):
|
||||
"""Write a single GSMTAP-encapsulated packet.
|
||||
|
||||
*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 = 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()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Message parsing for SIMtrace2 USB sniffing protocol."""
|
||||
|
||||
import struct
|
||||
from collections import namedtuple
|
||||
|
||||
MSG_HDR_FMT = '<BBBBHH'
|
||||
MSG_HDR_SIZE = struct.calcsize(MSG_HDR_FMT) # 8 bytes
|
||||
|
||||
SIMTRACE_MSGC_SNIFF = 3
|
||||
|
||||
# --- Message types within SIMTRACE_MSGC_SNIFF ---
|
||||
SIMTRACE_MSGT_SNIFF_CHANGE = 0
|
||||
SIMTRACE_MSGT_SNIFF_FIDI = 1
|
||||
SIMTRACE_MSGT_SNIFF_ATR = 2
|
||||
SIMTRACE_MSGT_SNIFF_PPS = 3
|
||||
SIMTRACE_MSGT_SNIFF_TPDU = 4
|
||||
|
||||
_TYPE_NAMES = {
|
||||
SIMTRACE_MSGT_SNIFF_CHANGE: 'change',
|
||||
SIMTRACE_MSGT_SNIFF_FIDI: 'fidi',
|
||||
SIMTRACE_MSGT_SNIFF_ATR: 'atr',
|
||||
SIMTRACE_MSGT_SNIFF_PPS: 'pps',
|
||||
SIMTRACE_MSGT_SNIFF_TPDU: 'tpdu',
|
||||
}
|
||||
|
||||
# --- Change flags ---
|
||||
CHANGE_FLAG_CARD_INSERT = 1 << 0
|
||||
CHANGE_FLAG_CARD_EJECT = 1 << 1
|
||||
CHANGE_FLAG_RESET_ASSERT = 1 << 2
|
||||
CHANGE_FLAG_RESET_DEASSERT = 1 << 3
|
||||
CHANGE_FLAG_TIMEOUT_WT = 1 << 4
|
||||
|
||||
_CHANGE_FLAG_NAMES = {
|
||||
CHANGE_FLAG_CARD_INSERT: 'card inserted',
|
||||
CHANGE_FLAG_CARD_EJECT: 'card ejected',
|
||||
CHANGE_FLAG_RESET_ASSERT: 'reset asserted',
|
||||
CHANGE_FLAG_RESET_DEASSERT: 'reset de-asserted',
|
||||
CHANGE_FLAG_TIMEOUT_WT: 'waiting time timeout',
|
||||
}
|
||||
|
||||
# --- Data flags ---
|
||||
DATA_FLAG_ERROR_INCOMPLETE = 1 << 5
|
||||
DATA_FLAG_ERROR_MALFORMED = 1 << 6
|
||||
DATA_FLAG_ERROR_CHECKSUM = 1 << 7
|
||||
DATA_FLAG_ERROR_OVERRUN = 1 << 8
|
||||
DATA_FLAG_ERROR_FRAMING = 1 << 9
|
||||
DATA_FLAG_ERROR_PARITY = 1 << 10
|
||||
|
||||
_DATA_FLAG_NAMES = {
|
||||
DATA_FLAG_ERROR_INCOMPLETE: 'incomplete',
|
||||
DATA_FLAG_ERROR_MALFORMED: 'malformed',
|
||||
DATA_FLAG_ERROR_CHECKSUM: 'checksum error',
|
||||
DATA_FLAG_ERROR_OVERRUN: 'overrun',
|
||||
DATA_FLAG_ERROR_FRAMING: 'framing error',
|
||||
DATA_FLAG_ERROR_PARITY: 'parity error',
|
||||
}
|
||||
|
||||
SniffMessage = namedtuple('SniffMessage', [
|
||||
'type',
|
||||
'type_hex',
|
||||
'data',
|
||||
'flags',
|
||||
'slot_nr',
|
||||
'seq_nr',
|
||||
'timestamp',
|
||||
])
|
||||
|
||||
|
||||
def flags_to_names(value, mapping):
|
||||
"""Convert a uint32 bitmask to a list of human-readable flag names."""
|
||||
names = []
|
||||
for bit, name in sorted(mapping.items()):
|
||||
if value & bit:
|
||||
names.append(name)
|
||||
value &= ~bit
|
||||
if value:
|
||||
names.append(f'unknown(0x{value:08x})')
|
||||
return names
|
||||
|
||||
|
||||
def _parse_change(payload):
|
||||
if len(payload) < 4:
|
||||
return None
|
||||
flags, = struct.unpack('<I', payload[:4])
|
||||
consumed = 4
|
||||
return consumed, flags, b''
|
||||
|
||||
_SNIFF_DATA_FMT = '<IH'
|
||||
_SNIFF_DATA_HDR_SIZE = struct.calcsize(_SNIFF_DATA_FMT) # 6 bytes
|
||||
|
||||
|
||||
def _parse_data(payload):
|
||||
if len(payload) < _SNIFF_DATA_HDR_SIZE:
|
||||
return None
|
||||
flags, length = struct.unpack(_SNIFF_DATA_FMT, payload[:_SNIFF_DATA_HDR_SIZE])
|
||||
total = _SNIFF_DATA_HDR_SIZE + length
|
||||
if len(payload) < total:
|
||||
return None
|
||||
data = payload[_SNIFF_DATA_HDR_SIZE:total]
|
||||
return total, flags, data
|
||||
|
||||
|
||||
_PAYLOAD_PARSERS = {
|
||||
SIMTRACE_MSGT_SNIFF_CHANGE: _parse_change,
|
||||
SIMTRACE_MSGT_SNIFF_FIDI: _parse_change,
|
||||
SIMTRACE_MSGT_SNIFF_ATR: _parse_data,
|
||||
SIMTRACE_MSGT_SNIFF_PPS: _parse_data,
|
||||
SIMTRACE_MSGT_SNIFF_TPDU: _parse_data,
|
||||
}
|
||||
|
||||
|
||||
def parse_message(buf, timestamp=None):
|
||||
"""Try to extract one SniffMessage from the front of *buf*.
|
||||
|
||||
Returns ``(SniffMessage, consumed)`` on success, or ``(None, 0)`` on failure.
|
||||
``consumed`` is the number of bytes to remove from the buffer.
|
||||
"""
|
||||
if len(buf) < MSG_HDR_SIZE:
|
||||
return None, 0
|
||||
|
||||
msg_class, msg_type, seq_nr, slot_nr, _reserved, msg_len = \
|
||||
struct.unpack(MSG_HDR_FMT, buf[:MSG_HDR_SIZE])
|
||||
|
||||
if msg_class != SIMTRACE_MSGC_SNIFF:
|
||||
if msg_len < MSG_HDR_SIZE or msg_len > 65535:
|
||||
consumed = MSG_HDR_SIZE
|
||||
else:
|
||||
consumed = msg_len
|
||||
return None, consumed
|
||||
|
||||
payload_size = msg_len - MSG_HDR_SIZE
|
||||
if payload_size < 0:
|
||||
return None, MSG_HDR_SIZE
|
||||
|
||||
if len(buf) < msg_len:
|
||||
return None, 0
|
||||
|
||||
payload = buf[MSG_HDR_SIZE:msg_len]
|
||||
type_name = _TYPE_NAMES.get(msg_type, f'unknown(0x{msg_type:02x})')
|
||||
|
||||
data = b''
|
||||
flags = 0
|
||||
|
||||
parser = _PAYLOAD_PARSERS.get(msg_type)
|
||||
if parser is not None:
|
||||
result = parser(payload)
|
||||
if result is None:
|
||||
return None, msg_len
|
||||
consumed_payload, flags, data = result
|
||||
else:
|
||||
data = payload
|
||||
consumed_payload = payload_size
|
||||
if consumed_payload != payload_size:
|
||||
return None, msg_len
|
||||
|
||||
return SniffMessage(
|
||||
type=type_name,
|
||||
type_hex=f'0x{msg_type:02x}',
|
||||
data=data,
|
||||
flags=flags,
|
||||
slot_nr=slot_nr,
|
||||
seq_nr=seq_nr,
|
||||
timestamp=timestamp,
|
||||
), msg_len
|
||||
Reference in new issue
Block a user