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,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# udev rules for SIMtrace2 USB sniffing (simtrace2-pysniff)
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# sudo cp 70-simtrace2-pysniff.rules /etc/udev/rules.d/
|
||||||
|
# sudo udevadm control --reload-rules
|
||||||
|
# sudo udevadm trigger
|
||||||
|
|
||||||
|
ACTION!="add", GOTO="pysniff_end"
|
||||||
|
SUBSYSTEM!="usb", GOTO="pysniff_end"
|
||||||
|
ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="60e3", TAG+="uaccess"
|
||||||
|
ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="616e", TAG+="uaccess"
|
||||||
|
LABEL="pysniff_end"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# simtrace2-pysniff
|
||||||
|
|
||||||
|
Python-based replacement for `simtrace2-sniff` — SIM card communication sniffer
|
||||||
|
for Osmocom SIMtrace2 hardware (firmware in trace mode).
|
||||||
|
|
||||||
|
**Single dependency**: PyUSB (libusb wrapper). No libosmocore, no libosmosim.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Install + run
|
||||||
|
pip install pyusb
|
||||||
|
python -m simtrace2_pysniff
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the bundled startup script:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./sniff-start.sh # hex dump to stdout
|
||||||
|
./sniff-start.sh --gsmtap 127.0.0.1:4729 # + Wireshark GSMTAP
|
||||||
|
./sniff-start.sh --pcap trace.pcap # save PCAP for later
|
||||||
|
./sniff-start.sh --inactivity-timeout 30 # reconnect on silence
|
||||||
|
```
|
||||||
|
|
||||||
|
The startup script performs a pre-flight USB permission check and prints
|
||||||
|
install instructions if the udev rule is missing (see below).
|
||||||
|
|
||||||
|
## USB Device Access
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
A udev rule is required for non-root USB access to the SIMtrace2 device.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo cp 70-simtrace2-pysniff.rules /etc/udev/rules.d/
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reconnect the SIMtrace2 USB cable. The rule uses `TAG+="uaccess"`
|
||||||
|
(systemd-logind ACL) — no group membership or world-writable permissions needed.
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
Windows does not ship a libusb-compatible driver for SIMtrace2. Use
|
||||||
|
[Zadig](https://zadig.akeo.ie/) to replace the default driver:
|
||||||
|
|
||||||
|
1. Download and run **Zadig**
|
||||||
|
2. Plug in the SIMtrace2 device
|
||||||
|
3. Select it in the dropdown (VID `1D50`, PID `60E3` — "Osmocom SIMtrace 2")
|
||||||
|
4. Choose **WinUSB** (or libusbK) as the replacement driver
|
||||||
|
5. Click **Replace Driver** (one-time operation)
|
||||||
|
|
||||||
|
After that, `python -m simtrace2_pysniff` works identically to Linux.
|
||||||
|
|
||||||
|
## CLI Options
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m simtrace2_pysniff [OPTIONS]
|
||||||
|
|
||||||
|
--gsmtap HOST[:PORT] Send ATR/TPDU as GSMTAP over UDP (default port 4729)
|
||||||
|
--pcap FILE Write sniffed data to PCAP file for Wireshark
|
||||||
|
--output, -o FILE Write hex dump to file
|
||||||
|
--no-stdout Suppress hex dump to stdout
|
||||||
|
--vendor HEX USB vendor ID (default: 0x1d50)
|
||||||
|
--product HEX USB product ID (default: auto-detect)
|
||||||
|
|
||||||
|
Recovery options:
|
||||||
|
--no-reconnect Exit on USB disconnect instead of reconnecting
|
||||||
|
--inactivity-timeout SEC Reconnect after N seconds of silence (default: disabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Library Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from simtrace2_pysniff import SniffSession
|
||||||
|
|
||||||
|
session = SniffSession(inactivity_timeout=30.0)
|
||||||
|
for msg in session.iter_messages():
|
||||||
|
print(f"{msg.type}: {msg.data.hex()}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
- **stdout**: `ATR: 3b 9e ...`, `TPDU: a0 a4 00 00 02 3f 00`, `Card state change: reset de-asserted`
|
||||||
|
- **GSMTAP**: ATR (subtype 1) and TPDU (subtype 2) over UDP to Wireshark
|
||||||
|
- **PCAP**: all messages as GSMTAP-encapsulated packets (LINKTYPE 155), openable in Wireshark
|
||||||
|
|
||||||
|
## Recovery
|
||||||
|
|
||||||
|
The tool survives hardware resets, cable disconnects, and firmware hangs
|
||||||
|
by default — it reconnects with exponential backoff (1s → 30s cap).
|
||||||
|
Use `--inactivity-timeout` to also trigger reconnect on silent firmware hangs.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=64"]
|
||||||
|
build-backend = "setuptools.backends._legacy:_Backend"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "simtrace2-pysniff"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)"
|
||||||
|
readme = {file = "README.md", content-type = "text/markdown"}
|
||||||
|
license = {text = "GPL-2.0-or-later"}
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"pyusb>=1.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
simtrace2-pysniff = "simtrace2_pysniff.__main__:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["simtrace2_pysniff*"]
|
||||||
@@ -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
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# simtrace2-pysniff startup script
|
||||||
|
#
|
||||||
|
# Usage: ./sniff-start.sh [--gsmtap HOST[:PORT]] [--pcap FILE] [--output FILE] [--inactivity-timeout SECONDS]
|
||||||
|
#
|
||||||
|
# Options set via environment variables act as defaults; CLI flags override them.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
GSMTAP="${GSMTAP:-}"
|
||||||
|
PCAP="${PCAP:-}"
|
||||||
|
OUTPUT="${OUTPUT:-}"
|
||||||
|
INACTIVITY_TIMEOUT="${INACTIVITY_TIMEOUT:-0}"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--gsmtap) GSMTAP="$2"; shift 2 ;;
|
||||||
|
--pcap) PCAP="$2"; shift 2 ;;
|
||||||
|
--output|-o) OUTPUT="$2"; shift 2 ;;
|
||||||
|
--inactivity-timeout) INACTIVITY_TIMEOUT="$2"; shift 2 ;;
|
||||||
|
--keep-running|--no-reconnect)
|
||||||
|
EXTRA_ARGS+=("$1"); shift ;;
|
||||||
|
*) EXTRA_ARGS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
check_permissions() {
|
||||||
|
local bad_perms=0
|
||||||
|
|
||||||
|
for devdir in /sys/bus/usb/devices/*/; do
|
||||||
|
[[ -f "${devdir}busnum" ]] || continue
|
||||||
|
[[ -f "${devdir}devnum" ]] || continue
|
||||||
|
[[ -f "${devdir}uevent" ]] || continue
|
||||||
|
|
||||||
|
local vid
|
||||||
|
vid=$(grep -oP 'PRODUCT=\K[a-f0-9]+' "${devdir}uevent" 2>/dev/null || true)
|
||||||
|
[[ "$vid" == "1d50" ]] || continue
|
||||||
|
|
||||||
|
local busnum devnum node
|
||||||
|
busnum=$(<"${devdir}busnum")
|
||||||
|
devnum=$(<"${devdir}devnum")
|
||||||
|
node=$(printf '/dev/bus/usb/%03d/%03d' "$busnum" "$devnum")
|
||||||
|
|
||||||
|
if [[ ! -e "$node" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -w "$node" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
bad_perms=1
|
||||||
|
echo "Permission denied on $node" >&2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $bad_perms -eq 1 ]]; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "Install the udev rule (then reconnect the device):" >&2
|
||||||
|
echo "──────────────────────────────────────────────────" >&2
|
||||||
|
echo " sudo cp '$PROJECT_DIR/70-simtrace2-pysniff.rules' /etc/udev/rules.d/" >&2
|
||||||
|
echo " sudo udevadm control --reload-rules" >&2
|
||||||
|
echo " sudo udevadm trigger" >&2
|
||||||
|
echo "──────────────────────────────────────────────────" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_permissions || exit 3
|
||||||
|
|
||||||
|
ARGS=()
|
||||||
|
[[ -n "$GSMTAP" ]] && ARGS+=(--gsmtap "$GSMTAP")
|
||||||
|
[[ -n "$PCAP" ]] && ARGS+=(--pcap "$PCAP")
|
||||||
|
[[ -n "$OUTPUT" ]] && ARGS+=(--output "$OUTPUT")
|
||||||
|
[[ "$INACTIVITY_TIMEOUT" != "0" ]] && ARGS+=(--inactivity-timeout "$INACTIVITY_TIMEOUT")
|
||||||
|
ARGS+=("${EXTRA_ARGS[@]}")
|
||||||
|
|
||||||
|
echo "Starting simtrace2-pysniff..." >&2
|
||||||
|
exec env PYTHONPATH="$PROJECT_DIR" python3 -m simtrace2_pysniff "${ARGS[@]}"
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Tests for protocol.py message parsing."""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from simtrace2_pysniff.protocol import (
|
||||||
|
parse_message,
|
||||||
|
MSG_HDR_FMT,
|
||||||
|
MSG_HDR_SIZE,
|
||||||
|
MSG_HDR_SIZE,
|
||||||
|
flags_to_names,
|
||||||
|
CHANGE_FLAG_CARD_INSERT,
|
||||||
|
CHANGE_FLAG_TIMEOUT_WT,
|
||||||
|
DATA_FLAG_ERROR_CHECKSUM,
|
||||||
|
DATA_FLAG_ERROR_MALFORMED,
|
||||||
|
DATA_FLAG_ERROR_INCOMPLETE,
|
||||||
|
SIMTRACE_MSGC_SNIFF,
|
||||||
|
SIMTRACE_MSGT_SNIFF_CHANGE,
|
||||||
|
SIMTRACE_MSGT_SNIFF_FIDI,
|
||||||
|
SIMTRACE_MSGT_SNIFF_ATR,
|
||||||
|
SIMTRACE_MSGT_SNIFF_PPS,
|
||||||
|
SIMTRACE_MSGT_SNIFF_TPDU,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_hdr(msg_class, msg_type, slot_nr=0, seq_nr=0, payload=b''):
|
||||||
|
msg_len = MSG_HDR_SIZE + len(payload)
|
||||||
|
return struct.pack(MSG_HDR_FMT, msg_class, msg_type, seq_nr, slot_nr, 0, msg_len) + payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseMessage(unittest.TestCase):
|
||||||
|
def test_empty_buffer(self):
|
||||||
|
msg, consumed = parse_message(b'')
|
||||||
|
self.assertIsNone(msg)
|
||||||
|
self.assertEqual(consumed, 0)
|
||||||
|
|
||||||
|
def test_too_short_for_header(self):
|
||||||
|
msg, consumed = parse_message(b'\x00' * 5)
|
||||||
|
self.assertIsNone(msg)
|
||||||
|
self.assertEqual(consumed, 0)
|
||||||
|
|
||||||
|
def test_non_sniff_class_skipped(self):
|
||||||
|
payload = b'junk_data'
|
||||||
|
buf = _build_hdr(0, 0, payload=payload)
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertIsNone(msg)
|
||||||
|
self.assertEqual(consumed, len(buf))
|
||||||
|
|
||||||
|
def test_msg_len_smaller_than_header_skip(self):
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_CHANGE)
|
||||||
|
buf = bytearray(buf)
|
||||||
|
struct.pack_into('<H', buf, 6, 4) # set msg_len to 4 (< MSG_HDR_SIZE)
|
||||||
|
msg, consumed = parse_message(bytes(buf))
|
||||||
|
self.assertIsNone(msg)
|
||||||
|
self.assertEqual(consumed, MSG_HDR_SIZE)
|
||||||
|
|
||||||
|
def test_incomplete_message_needs_more_data(self):
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_ATR,
|
||||||
|
payload=b'\x01\x02\x03')
|
||||||
|
msg, consumed = parse_message(buf[:-2])
|
||||||
|
self.assertIsNone(msg)
|
||||||
|
self.assertEqual(consumed, 0)
|
||||||
|
|
||||||
|
def test_change_message_card_insert(self):
|
||||||
|
payload = struct.pack('<I', CHANGE_FLAG_CARD_INSERT)
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_CHANGE,
|
||||||
|
slot_nr=1, seq_nr=5, payload=payload)
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertEqual(consumed, len(buf))
|
||||||
|
self.assertEqual(msg.type, 'change')
|
||||||
|
self.assertEqual(msg.type_hex, '0x00')
|
||||||
|
self.assertEqual(msg.slot_nr, 1)
|
||||||
|
self.assertEqual(msg.seq_nr, 5)
|
||||||
|
self.assertEqual(msg.flags, CHANGE_FLAG_CARD_INSERT)
|
||||||
|
self.assertEqual(msg.data, b'')
|
||||||
|
|
||||||
|
def test_change_message_timeout(self):
|
||||||
|
payload = struct.pack('<I', CHANGE_FLAG_TIMEOUT_WT)
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_CHANGE,
|
||||||
|
payload=payload)
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertEqual(msg.type, 'change')
|
||||||
|
self.assertEqual(msg.flags, CHANGE_FLAG_TIMEOUT_WT)
|
||||||
|
|
||||||
|
def test_fidi_message(self):
|
||||||
|
payload = struct.pack('<I', 0x97) # Fi=9, Di=7
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_FIDI,
|
||||||
|
payload=payload)
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertEqual(msg.type, 'fidi')
|
||||||
|
self.assertEqual(len(msg.data), 0)
|
||||||
|
self.assertEqual(msg.flags, 0x97)
|
||||||
|
|
||||||
|
def test_atr_message(self):
|
||||||
|
atr = b'\x3b\x9e\x12\x34\x56\x78\x90'
|
||||||
|
payload = struct.pack('<IH', 0, len(atr)) + atr
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_ATR,
|
||||||
|
payload=payload)
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertEqual(consumed, len(buf))
|
||||||
|
self.assertEqual(msg.type, 'atr')
|
||||||
|
self.assertEqual(msg.data, atr)
|
||||||
|
self.assertEqual(msg.flags, 0)
|
||||||
|
|
||||||
|
def test_atr_message_with_flags(self):
|
||||||
|
atr = b'\x3b\x00'
|
||||||
|
flags = DATA_FLAG_ERROR_CHECKSUM | DATA_FLAG_ERROR_MALFORMED
|
||||||
|
payload = struct.pack('<IH', flags, len(atr)) + atr
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_ATR,
|
||||||
|
payload=payload)
|
||||||
|
msg, _ = parse_message(buf)
|
||||||
|
self.assertEqual(msg.flags, flags)
|
||||||
|
|
||||||
|
def test_pps_message(self):
|
||||||
|
pps = b'\xff\x10\x97\x7e'
|
||||||
|
payload = struct.pack('<IH', 0, len(pps)) + pps
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_PPS,
|
||||||
|
payload=payload)
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertEqual(msg.type, 'pps')
|
||||||
|
self.assertEqual(msg.data, pps)
|
||||||
|
|
||||||
|
def test_tpdu_message(self):
|
||||||
|
tpdu = b'\xa0\xa4\x00\x00\x02\x3f\x00'
|
||||||
|
payload = struct.pack('<IH', 0, len(tpdu)) + tpdu
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_TPDU,
|
||||||
|
payload=payload)
|
||||||
|
msg, _ = parse_message(buf)
|
||||||
|
self.assertEqual(msg.type, 'tpdu')
|
||||||
|
self.assertEqual(msg.data, tpdu)
|
||||||
|
|
||||||
|
def test_tpdu_flags_incomplete(self):
|
||||||
|
tpdu = b'\x00\xb0\x00\x00\x0a'
|
||||||
|
flags = DATA_FLAG_ERROR_INCOMPLETE
|
||||||
|
payload = struct.pack('<IH', flags, len(tpdu)) + tpdu
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_TPDU,
|
||||||
|
payload=payload)
|
||||||
|
msg, _ = parse_message(buf)
|
||||||
|
self.assertEqual(msg.flags, flags)
|
||||||
|
|
||||||
|
def test_timestamp_passed_through(self):
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_CHANGE,
|
||||||
|
payload=struct.pack('<I', 0))
|
||||||
|
msg, _ = parse_message(buf, timestamp=12345.678)
|
||||||
|
self.assertEqual(msg.timestamp, 12345.678)
|
||||||
|
|
||||||
|
def test_multiple_messages_in_buffer(self):
|
||||||
|
buf1 = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_CHANGE,
|
||||||
|
payload=struct.pack('<I', 1))
|
||||||
|
atr_payload = struct.pack('<IH', 0, 2) + b'\x3b\x00'
|
||||||
|
buf2 = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_ATR,
|
||||||
|
payload=atr_payload)
|
||||||
|
combined = buf1 + buf2
|
||||||
|
|
||||||
|
msg1, consumed1 = parse_message(combined)
|
||||||
|
self.assertIsNotNone(msg1)
|
||||||
|
self.assertEqual(msg1.type, 'change')
|
||||||
|
self.assertEqual(consumed1, len(buf1))
|
||||||
|
|
||||||
|
remaining = combined[consumed1:]
|
||||||
|
msg2, consumed2 = parse_message(remaining)
|
||||||
|
self.assertIsNotNone(msg2)
|
||||||
|
self.assertEqual(msg2.type, 'atr')
|
||||||
|
self.assertEqual(consumed2, len(buf2))
|
||||||
|
|
||||||
|
def test_trailing_garbage_before_valid_message(self):
|
||||||
|
msg1 = _build_hdr(0, 1, slot_nr=3, payload=b'card_emu_data!!!')
|
||||||
|
buf = msg1 + _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_CHANGE,
|
||||||
|
payload=struct.pack('<I', 1))
|
||||||
|
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertIsNone(msg)
|
||||||
|
self.assertEqual(consumed, len(msg1))
|
||||||
|
|
||||||
|
msg, consumed = parse_message(buf[len(msg1):])
|
||||||
|
self.assertIsNotNone(msg)
|
||||||
|
self.assertEqual(msg.type, 'change')
|
||||||
|
|
||||||
|
def test_non_sniff_msg_class_with_short_msg_len(self):
|
||||||
|
hdr = struct.pack(MSG_HDR_FMT, 0, 0, 0, 0, 0, 4)
|
||||||
|
msg, consumed = parse_message(hdr + b'\x00')
|
||||||
|
self.assertIsNone(msg)
|
||||||
|
self.assertEqual(consumed, MSG_HDR_SIZE)
|
||||||
|
|
||||||
|
def test_non_sniff_msg_class_known_len_skip(self):
|
||||||
|
payload = b'test_data_for_cardem'
|
||||||
|
buf = _build_hdr(1, 0, payload=payload)
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertIsNone(msg)
|
||||||
|
self.assertEqual(consumed, len(buf))
|
||||||
|
|
||||||
|
def test_zero_length_data(self):
|
||||||
|
payload = struct.pack('<IH', 0, 0)
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_ATR,
|
||||||
|
payload=payload)
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertEqual(msg.type, 'atr')
|
||||||
|
self.assertEqual(msg.data, b'')
|
||||||
|
self.assertEqual(msg.flags, 0)
|
||||||
|
|
||||||
|
def test_truncated_data_hdr(self):
|
||||||
|
buf = _build_hdr(SIMTRACE_MSGC_SNIFF, SIMTRACE_MSGT_SNIFF_TPDU,
|
||||||
|
payload=b'\x00\x00\x00\x00\x10')
|
||||||
|
msg, consumed = parse_message(buf)
|
||||||
|
self.assertIsNone(msg)
|
||||||
|
self.assertEqual(consumed, len(buf))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlagsToNames(unittest.TestCase):
|
||||||
|
def test_empty(self):
|
||||||
|
self.assertEqual(flags_to_names(0, {}), [])
|
||||||
|
|
||||||
|
def test_single(self):
|
||||||
|
names = flags_to_names(CHANGE_FLAG_CARD_INSERT, {
|
||||||
|
CHANGE_FLAG_CARD_INSERT: 'card inserted',
|
||||||
|
})
|
||||||
|
self.assertEqual(names, ['card inserted'])
|
||||||
|
|
||||||
|
def test_multiple(self):
|
||||||
|
names = flags_to_names(
|
||||||
|
CHANGE_FLAG_CARD_INSERT | CHANGE_FLAG_TIMEOUT_WT,
|
||||||
|
{
|
||||||
|
CHANGE_FLAG_CARD_INSERT: 'card inserted',
|
||||||
|
CHANGE_FLAG_TIMEOUT_WT: 'timeout',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(names, ['card inserted', 'timeout'])
|
||||||
|
|
||||||
|
def test_unknown_bit_preserved(self):
|
||||||
|
names = flags_to_names(0xDEAD0000, {})
|
||||||
|
self.assertIn('unknown(0xdead0000)', names)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user