From c515d689017d22874c08538829d57d2ea2ae063a Mon Sep 17 00:00:00 2001 From: Dominik Bay Date: Thu, 16 Apr 2026 07:34:04 +0200 Subject: [PATCH 1/2] Wireshark DPL dissector small lua dissector for DPL framing (ch/len/payload/xor/0x03) plus a python helper that builds a sample pcap for testing. heuristic fires on tcp/udp/usb.bulk/usb.interrupt when length, xor and terminator all line up; otherwise decode-as works. --- wireshark/README.md | 64 ++++++++++++++++++ wireshark/dpl.lua | 131 ++++++++++++++++++++++++++++++++++++ wireshark/dpl_encode.py | 122 +++++++++++++++++++++++++++++++++ wireshark/tests/sample.pcap | Bin 0 -> 460 bytes 4 files changed, 317 insertions(+) create mode 100644 wireshark/README.md create mode 100644 wireshark/dpl.lua create mode 100644 wireshark/dpl_encode.py create mode 100644 wireshark/tests/sample.pcap diff --git a/wireshark/README.md b/wireshark/README.md new file mode 100644 index 0000000..3f7b8ce --- /dev/null +++ b/wireshark/README.md @@ -0,0 +1,64 @@ +# dpl wireshark dissector + +small lua dissector for DPL, the byte-level framing used on `ch=0x05` (firmware upgrade transport) and `ch=0x81` (host-to-modem control link). + +## framing + +``` ++--------+--------+---------------------+---------+------+ +| ch (1) | len(1) | payload (len bytes) | xor (1) | 0x03 | ++--------+--------+---------------------+---------+------+ + +xor = ch ^ len ^ payload[0] ^ ... ^ payload[len-1] +``` + +no sync word, no CRC. integrity is the one-byte xor. terminator is always `0x03`. + +## channels + +| ch | purpose | +|---|---| +| 0x05 | firmware upgrade transport | +| 0x81 | host-to-modem control link | + +## ch=0x05 opcodes + +the first payload byte is the opcode. known values: + +| opcode | name | +|---|---| +| 0x80 | PING | +| 0x83 | GETID | +| 0x85 | WRITEBUFF | +| 0x86 | READBUFF | +| 0x89 | CLEARBUFF | +| 0x8a | ERASECHIP | +| 0x8c | ERASESECT | +| 0x8f | WRITESECT | +| 0xc0 | READSECT | +| 0xc6 | BOOT | + +arg layout after the opcode byte is opcode-specific and not tabulated here. + +## install + +drop `dpl.lua` into your Wireshark personal Lua plugins dir. on linux/macOS that's usually `~/.local/lib/wireshark/plugins/` or `~/.config/wireshark/plugins/`. check `Help -> About Wireshark -> Folders` if unsure. reload plugins (`Analyze -> Reload Lua Plugins`) or restart. + +the dissector registers a heuristic on `tcp`, `udp`, `usb.bulk`, `usb.interrupt` that only fires when length + xor + terminator all match and the channel byte is one we know. anything marginal stays as plain `Data` so you can still use `Decode As -> DPL` manually if you want to force it. + +## generating test frames + +`dpl_encode.py` builds DPL frames and wraps them in UDP over the BSD null link-type so Wireshark can pick them up. + +``` +python3 dpl_encode.py # regenerate tests/sample.pcap +python3 dpl_encode.py --hex 05 80 # print hex for one frame +``` + +verify: + +``` +tshark -r tests/sample.pcap -X lua_script:dpl.lua -O dpl +``` + +`tests/sample.pcap` covers one frame per known opcode on `ch=0x05`, a couple of opaque `ch=0x81` payloads, and one deliberately-corrupted xor to confirm the heuristic doesn't accept bad frames. diff --git a/wireshark/dpl.lua b/wireshark/dpl.lua new file mode 100644 index 0000000..593a7cf --- /dev/null +++ b/wireshark/dpl.lua @@ -0,0 +1,131 @@ +-- DPL dissector for Wireshark +-- +-- Usage: +-- 1. Drop this file into your Wireshark personal Lua plugins directory +-- (Help -> About Wireshark -> Folders -> Personal Lua Plugins). +-- 2. Reload Lua plugins (Analyze -> Reload Lua Plugins) or restart Wireshark. +-- 3. Right-click a packet -> "Decode As..." -> pick "DPL". +-- +-- Heuristic activation is also registered on a few common transports. +-- Accepts a single DPL frame per dissected PDU. + +local dpl = Proto("dpl", "DPL") + +local channel_names = { + [0x05] = "firmware upgrade transport", + [0x81] = "host-to-modem control link", +} + +local ch05_opcode_names = { + [0x80] = "PING", + [0x83] = "GETID", + [0x85] = "WRITEBUFF", + [0x86] = "READBUFF", + [0x89] = "CLEARBUFF", + [0x8a] = "ERASECHIP", + [0x8c] = "ERASESECT", + [0x8f] = "WRITESECT", + [0xc0] = "READSECT", + [0xc6] = "BOOT", +} + +local f_ch = ProtoField.uint8 ("dpl.ch", "Channel", base.HEX, channel_names) +local f_len = ProtoField.uint8 ("dpl.len", "Length", base.DEC) +local f_payload = ProtoField.bytes ("dpl.payload", "Payload") +local f_opcode = ProtoField.uint8 ("dpl.opcode", "Opcode", base.HEX, ch05_opcode_names) +local f_xor = ProtoField.uint8 ("dpl.xor", "XOR", base.HEX) +local f_xor_ok = ProtoField.bool ("dpl.xor_ok", "XOR valid") +local f_eom = ProtoField.uint8 ("dpl.eom", "End-of-message",base.HEX) + +dpl.fields = { f_ch, f_len, f_payload, f_opcode, f_xor, f_xor_ok, f_eom } + +local ef_bad_xor = ProtoExpert.new("dpl.bad_xor.expert", "DPL XOR mismatch", + expert.group.CHECKSUM, expert.severity.WARN) +local ef_bad_eom = ProtoExpert.new("dpl.bad_eom.expert", "DPL terminator is not 0x03", + expert.group.MALFORMED, expert.severity.WARN) +dpl.experts = { ef_bad_xor, ef_bad_eom } + +local function compute_xor(tvb, first, last) + local x = 0 + for i = first, last do + x = bit.bxor(x, tvb(i, 1):uint()) + end + return x +end + +-- Validate framing without asserting on errors. Returns: +-- ok, len, expected_xor, actual_xor, eom_byte +-- ok is false if the buffer cannot be a DPL frame at all. +local function validate(tvb) + if tvb:len() < 4 then return false end + local plen = tvb(1, 1):uint() + local expected_size = 1 + 1 + plen + 1 + 1 + if tvb:len() ~= expected_size then return false end + local eom = tvb(tvb:len() - 1, 1):uint() + local expected_xor = compute_xor(tvb, 0, 1 + plen) + local actual_xor = tvb(2 + plen, 1):uint() + return true, plen, expected_xor, actual_xor, eom +end + +function dpl.dissector(tvb, pinfo, tree) + local ok, plen, expected_xor, actual_xor, eom = validate(tvb) + if not ok then return 0 end + + pinfo.cols.protocol = "DPL" + + local subtree = tree:add(dpl, tvb(), "DPL") + subtree:add(f_ch, tvb(0, 1)) + subtree:add(f_len, tvb(1, 1)) + + local ch = tvb(0, 1):uint() + + if plen > 0 then + local payload_tvb = tvb(2, plen) + subtree:add(f_payload, payload_tvb) + if ch == 0x05 then + subtree:add(f_opcode, tvb(2, 1)) + end + end + + local xor_item = subtree:add(f_xor, tvb(2 + plen, 1)) + subtree:add(f_xor_ok, expected_xor == actual_xor) + :set_generated() + if expected_xor ~= actual_xor then + xor_item:add_proto_expert_info(ef_bad_xor, + string.format("expected 0x%02x, got 0x%02x", expected_xor, actual_xor)) + end + + local eom_item = subtree:add(f_eom, tvb(tvb:len() - 1, 1)) + if eom ~= 0x03 then + eom_item:add_proto_expert_info(ef_bad_eom) + end + + local info + if ch == 0x05 and plen > 0 then + local op = tvb(2, 1):uint() + info = string.format("ch=0x%02x op=%s len=%d", ch, + ch05_opcode_names[op] or string.format("0x%02x", op), + plen) + else + info = string.format("ch=0x%02x len=%d", ch, plen) + end + pinfo.cols.info = info + + return tvb:len() +end + +local function dpl_heur(tvb, pinfo, tree) + local ok, _, expected_xor, actual_xor, eom = validate(tvb) + if not ok then return false end + if eom ~= 0x03 then return false end + if expected_xor ~= actual_xor then return false end + local ch = tvb(0, 1):uint() + if channel_names[ch] == nil then return false end + dpl.dissector(tvb, pinfo, tree) + return true +end + +dpl:register_heuristic("tcp", dpl_heur) +dpl:register_heuristic("udp", dpl_heur) +dpl:register_heuristic("usb.bulk", dpl_heur) +dpl:register_heuristic("usb.interrupt", dpl_heur) diff --git a/wireshark/dpl_encode.py b/wireshark/dpl_encode.py new file mode 100644 index 0000000..f5a9559 --- /dev/null +++ b/wireshark/dpl_encode.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# Encode DPL frames and emit a pcap for dissector testing. +# +# Usage: +# python3 dpl_encode.py # regenerate tests/sample.pcap +# python3 dpl_encode.py --hex 05 80 # print hex for a single frame +# +# No third-party deps. Frames are wrapped in UDP/IP over the BSD null +# loopback link type; Wireshark's heuristic dissector lookup handles the rest. + +import argparse +import os +import struct +import sys +import time + + +def encode(ch: int, payload: bytes = b"") -> bytes: + """Return a full DPL frame: ch, len, payload, xor, 0x03.""" + if not 0 <= ch <= 0xff: + raise ValueError("channel out of range") + if len(payload) > 0xff: + raise ValueError("payload too long for 1-byte length field") + header = bytes([ch, len(payload)]) + body = header + payload + x = 0 + for b in body: + x ^= b + return body + bytes([x, 0x03]) + + +def wrap_udp(dpl_frame: bytes, sport: int = 4000, dport: int = 4001) -> bytes: + """Wrap a DPL frame as UDP/IPv4 over loopback (BSD null encap).""" + udp_len = 8 + len(dpl_frame) + udp = struct.pack("!HHHH", sport, dport, udp_len, 0) + dpl_frame + + ip_total = 20 + udp_len + ip_hdr = struct.pack( + "!BBHHHBBH4s4s", + 0x45, 0x00, ip_total, + 0x0000, 0x0000, + 64, 17, 0x0000, + b"\x7f\x00\x00\x01", + b"\x7f\x00\x00\x01", + ) + ip_chk = _ip_checksum(ip_hdr) + ip_hdr = ip_hdr[:10] + struct.pack("!H", ip_chk) + ip_hdr[12:] + + loopback_family = struct.pack(" int: + s = 0 + for i in range(0, len(hdr), 2): + s += (hdr[i] << 8) | hdr[i + 1] + while s >> 16: + s = (s & 0xffff) + (s >> 16) + return (~s) & 0xffff + + +def write_pcap(path: str, frames: list[bytes]) -> None: + """Write pcap file in LINKTYPE_NULL (BSD loopback) format.""" + with open(path, "wb") as f: + f.write(struct.pack("=IHHIIII", + 0xa1b2c3d4, + 2, 4, + 0, 0, + 65535, + 0)) # link_type 0 = LINKTYPE_NULL + ts = int(time.time()) + for i, frame in enumerate(frames): + pkt = wrap_udp(frame) + f.write(struct.pack("=IIII", ts, i, len(pkt), len(pkt))) + f.write(pkt) + + +def sample_frames() -> list[bytes]: + frames = [] + # channel 0x05: firmware upgrade transport + frames.append(encode(0x05, bytes([0x80]))) # PING + frames.append(encode(0x05, bytes([0x83]))) # GETID + frames.append(encode(0x05, bytes([0x86, 0x00, 0x00, 0x00]))) # READBUFF + addr + frames.append(encode(0x05, bytes([0xc0, 0x12, 0x34]))) # READSECT + frames.append(encode(0x05, bytes([0xc6]))) # BOOT + # channel 0x81: host-to-modem control link, opaque payloads + frames.append(encode(0x81, b"AT\r")) + frames.append(encode(0x81, b"hello")) + # malformed: bad xor (swap last data byte) + good = bytearray(encode(0x05, bytes([0x80, 0x11]))) + good[-2] ^= 0xff + frames.append(bytes(good)) + return frames + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--hex", nargs="+", metavar="BYTE", + help="print hex for one DPL frame: first byte is channel, " + "rest is payload. Example: --hex 05 80") + ap.add_argument("-o", "--out", default=None, + help="output pcap path (default: tests/sample.pcap next to this script)") + args = ap.parse_args() + + if args.hex: + vals = [int(x, 16) for x in args.hex] + frame = encode(vals[0], bytes(vals[1:])) + print(frame.hex()) + return 0 + + out = args.out + if out is None: + here = os.path.dirname(os.path.abspath(__file__)) + out = os.path.join(here, "tests", "sample.pcap") + os.makedirs(os.path.dirname(out), exist_ok=True) + write_pcap(out, sample_frames()) + print("wrote", out, file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wireshark/tests/sample.pcap b/wireshark/tests/sample.pcap new file mode 100644 index 0000000000000000000000000000000000000000..096bb6d2d3adce04072a264981fab51f570eaaed GIT binary patch literal 460 zcmca|c+)~A1{MYw`2Qcmx_Iq=CWxg9#Y{ljm4QJKB<3JkbE=+!ff0oH7w|7+;ALQ7 zWo&3+25Dl1Y5-{@tf{#jtO=x01B#)VRDfKNO~+Ae5&+u7(gu`)=wk-5)S(!vPZ`Js z={td?34XBeP=&?<}x3HlT7%D28eSSq*aH rF%%~XGB7lr^Ieq(A7%wz@tV>3@w literal 0 HcmV?d00001 From 1c4c092a2875c557f707e9e2f96aa9c0170c4c61 Mon Sep 17 00:00:00 2001 From: Dominik Bay Date: Thu, 16 Apr 2026 07:34:04 +0200 Subject: [PATCH 2/2] =?UTF-8?q?wireshark:=20expand=20DPL=20dissector=20?= =?UTF-8?q?=E2=80=94=20STX=20+=20PMH=20+=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 2: accept all known STX values (DLOG, TPI, STDIO, IP1-IP7) in addition to the firmware upgrade transport. Previously only 0x05 and 0x81 were recognised. Layer 3: for STX[IPn] frames decode the 5-byte Peripheral Message Header (address/sequence, interface ID, primitive ID, sub-addresses) and name the primitive when known. Tables cover interfaces 0xA7, 0xA8, 0x14, 0x94, 0x98 — 67 primitives in total. Expert hint when an STX[IPn] payload is too short to carry a PMH. dpl_encode.py grows a pmh() builder; sample.pcap now exercises INIT, RTI, ip_call_start_req, ip_call_status_ind, seem_status_cnf, sim_instruction_req, plus STDIO and DLOG. --- wireshark/README.md | 76 ++++++++++-- wireshark/dpl.lua | 235 +++++++++++++++++++++++++++++++----- wireshark/dpl_encode.py | 100 ++++++++++++--- wireshark/tests/sample.pcap | Bin 460 -> 904 bytes 4 files changed, 352 insertions(+), 59 deletions(-) diff --git a/wireshark/README.md b/wireshark/README.md index 3f7b8ce..d22ac70 100644 --- a/wireshark/README.md +++ b/wireshark/README.md @@ -1,27 +1,38 @@ # dpl wireshark dissector -small lua dissector for DPL, the byte-level framing used on `ch=0x05` (firmware upgrade transport) and `ch=0x81` (host-to-modem control link). +lua dissector for DPL (Digital Peripheral Link), the byte-level protocol on the UART link between an Iridium ISU and attached peripherals (handsets, SIM readers, controllers), plus the firmware-upgrade transport. -## framing +## framing (Layer 2) ``` +--------+--------+---------------------+---------+------+ -| ch (1) | len(1) | payload (len bytes) | xor (1) | 0x03 | +| STX(1) | LEN(1) | PAYLOAD (LEN bytes) | XOR(1) | ETX | +--------+--------+---------------------+---------+------+ -xor = ch ^ len ^ payload[0] ^ ... ^ payload[len-1] +XOR = STX ^ LEN ^ payload[0] ^ ... ^ payload[LEN-1] +ETX = 0x03 (always) ``` no sync word, no CRC. integrity is the one-byte xor. terminator is always `0x03`. -## channels +## STX values -| ch | purpose | -|---|---| -| 0x05 | firmware upgrade transport | -| 0x81 | host-to-modem control link | +`STX` is the Start-of-I-Frame character. besides being the frame delimiter it also selects which logical device / protocol family the frame is addressed to. + +| STX | Name | Direction | Purpose | +|---|---|---|---| +| 0x01 | DLOG | ISU ↔ host | Data Logger | +| 0x02 | TPI | ISU ↔ host | Test/Production Interface | +| 0x04 | reserved | — | special use | +| 0x05 | firmware upgrade transport | ISU ↔ host | | +| 0x07 | STDIO | ISU → host | text | +| 0x81 … 0x87 | IP1 … IP7 | ISU ↔ peripheral | Intelligent Peripheral addresses | + +ACK (0x06) and NAK (0x15) are single-byte control characters outside the I-frame; they don't carry a length byte and are not dissected as separate frames. -## ch=0x05 opcodes +only frames with STX in this table are accepted by the heuristic. + +## firmware upgrade opcodes (STX=0x05) the first payload byte is the opcode. known values: @@ -40,11 +51,50 @@ the first payload byte is the opcode. known values: arg layout after the opcode byte is opcode-specific and not tabulated here. +## Layer 3 — PMH (for STX=0x81 … 0x87) + +for frames addressed to an Intelligent Peripheral the payload starts with a 5-byte **Peripheral Message Header** followed by primitive-specific Layer 3 data: + +``` +byte 0 : addr(hi nybble) | sequence(lo nybble) ; 0xE=ISU, 0x1..0x7=IP +byte 1 : Interface ID ; protocol family +byte 2 : Primitive ID ; command / indication +byte 3 : Destination sub-address +byte 4 : Source sub-address +byte 5…: Layer 3 data +``` + +the dissector decodes the header and names the primitive where possible. + +### Interface IDs + +| id | family | +|---|---| +| 0x14 | SEEM (SIM External Module) | +| 0x94 | Audio | +| 0x98 | SIM (external card reader) | +| 0xA7 | DPL control / MMI / TEST (includes INIT/RTI) | +| 0xA8 | MMI / Call Control / Audio | + +### Recognised primitives + +the dissector names every (iface, prim) combination it knows. it does **not** currently decode primitive payload fields — only the header. Layer 3 data is shown as raw bytes with a label; extend per-primitive decoders as captures require them. + +representative primitives: + +- **0xA7 (discovery / MMI / TEST):** `INIT` (0x12), `RTI` (0x13), `ECHO` (0x14), `DEBUG` (0xFF), `HSCTRL`/`HSLCD`/`HSKPD` (0x15/0x16/0x17), `ip_change_pin_req` (0x02), `ip_man_reg_req` (0x06), `ip_ss_status_ind` (0x0C), and related `_cnf`s. +- **0xA8 (MMI/CC/Audio):** `ip_call_start_req` (0x05), `ip_call_accept_req` (0x20), `ip_call_release_req` (0x21), `ip_call_status_ind` (0x27), `ip_class_ind` (0x29), `ip_call_dtmf_req` (0x35), `ip_gen_imei_req/_cnf` (0x36/0x37), `ip_gen_pin_stat_req/_cnf` (0x38/0x39), `ip_gen_pin_set_req/_cnf` (0x3A/0x3B), `ip_signal_level_req/_cnf` (0x04/0x2D), `ip_mute_req/_ind` (0x22/0x08), `ip_sidetone_req/_cnf` (0x41/0x42), `ip_audio_routing_req` (0x40), and more. +- **0x14 (SEEM):** `seem_activate_ind`/`_cnf` (0x03/0x02), `seem_status_cnf` (0x0F), the PIN `_cnf` family (0x11, 0x13, 0x15, 0x17), `seem_unblocking_cnf` (0x19), `ip_get_info_element_cnf` (0x0C). +- **0x94 (Audio):** `ip_key_feedback_ind` (0x0E). +- **0x98 (SIM):** `sim_activate_req`/`_cnf` (0x00/0x20), `sim_deactivate_req/_cnf` (0x06/0x21), `sim_instruction_req/_cnf` (0x03/0x23) (GSM 11.11 passthrough), `sim_card_detect_ind` (0x40), `sim_switch_act_volt_req` (0x01). + +see `dpl.lua` for the full table. + ## install drop `dpl.lua` into your Wireshark personal Lua plugins dir. on linux/macOS that's usually `~/.local/lib/wireshark/plugins/` or `~/.config/wireshark/plugins/`. check `Help -> About Wireshark -> Folders` if unsure. reload plugins (`Analyze -> Reload Lua Plugins`) or restart. -the dissector registers a heuristic on `tcp`, `udp`, `usb.bulk`, `usb.interrupt` that only fires when length + xor + terminator all match and the channel byte is one we know. anything marginal stays as plain `Data` so you can still use `Decode As -> DPL` manually if you want to force it. +the dissector registers a heuristic on `tcp`, `udp`, `usb.bulk`, `usb.interrupt` that only fires when length + xor + terminator all match and STX is one we know. anything marginal stays as plain `Data` so you can still use `Decode As -> DPL` manually if you want to force it. ## generating test frames @@ -55,10 +105,12 @@ python3 dpl_encode.py # regenerate tests/sample.pcap python3 dpl_encode.py --hex 05 80 # print hex for one frame ``` +the module exports two helpers: `encode(stx, payload)` wraps a full DPL frame, and `pmh(addr, seq, iface, prim, dest, src, data)` builds a Peripheral Message Header + L3 data (use as the `payload` argument to `encode` for STX[IPn] frames). + verify: ``` tshark -r tests/sample.pcap -X lua_script:dpl.lua -O dpl ``` -`tests/sample.pcap` covers one frame per known opcode on `ch=0x05`, a couple of opaque `ch=0x81` payloads, and one deliberately-corrupted xor to confirm the heuristic doesn't accept bad frames. +`tests/sample.pcap` covers every known firmware-upgrade opcode on STX=0x05, an STDIO (STX=0x07) and a DLOG (STX=0x01) opaque payload, PMH-bearing STX=0x81 frames (`INIT`, `RTI`, `ip_call_start_req`, `ip_call_status_ind`, `sim_instruction_req`), a STX=0x82 (IP2) frame carrying a SEEM primitive, a STX=0x81 frame with a too-short payload (triggers the "payload too short for PMH" expert hint), and one deliberately-corrupted xor to confirm the heuristic doesn't accept bad frames. diff --git a/wireshark/dpl.lua b/wireshark/dpl.lua index 593a7cf..8ce0fc1 100644 --- a/wireshark/dpl.lua +++ b/wireshark/dpl.lua @@ -11,12 +11,26 @@ local dpl = Proto("dpl", "DPL") -local channel_names = { +-- Layer 2 start characters. +local stx_names = { + [0x01] = "DLOG", + [0x02] = "TPI", + [0x04] = "reserved", [0x05] = "firmware upgrade transport", - [0x81] = "host-to-modem control link", + [0x07] = "STDIO", + [0x81] = "IP1", + [0x82] = "IP2", + [0x83] = "IP3", + [0x84] = "IP4", + [0x85] = "IP5", + [0x86] = "IP6", + [0x87] = "IP7", } -local ch05_opcode_names = { +local function is_ip_stx(b) return b >= 0x81 and b <= 0x87 end + +-- Firmware upgrade opcodes (STX=0x05). +local fw_opcode_names = { [0x80] = "PING", [0x83] = "GETID", [0x85] = "WRITEBUFF", @@ -29,21 +43,143 @@ local ch05_opcode_names = { [0xc6] = "BOOT", } -local f_ch = ProtoField.uint8 ("dpl.ch", "Channel", base.HEX, channel_names) -local f_len = ProtoField.uint8 ("dpl.len", "Length", base.DEC) -local f_payload = ProtoField.bytes ("dpl.payload", "Payload") -local f_opcode = ProtoField.uint8 ("dpl.opcode", "Opcode", base.HEX, ch05_opcode_names) -local f_xor = ProtoField.uint8 ("dpl.xor", "XOR", base.HEX) -local f_xor_ok = ProtoField.bool ("dpl.xor_ok", "XOR valid") -local f_eom = ProtoField.uint8 ("dpl.eom", "End-of-message",base.HEX) +-- Layer 3 Interface IDs. +local iface_names = { + [0x14] = "SEEM", -- SIM External Module + [0x94] = "Audio", + [0x98] = "SIM", + [0xa7] = "DPL/MMI/TEST", + [0xa8] = "MMI/CC/Audio", +} + +-- Primitive tables, keyed by (Interface ID, Prim ID) → name. +local prim_names = { + [0xa7] = { + [0x00] = "ip_dtmf_rec_ind", + [0x01] = "ip_mute_dtmf_fbk_req", + [0x02] = "ip_change_pin_req", + [0x03] = "ip_change_pin_cnf", + [0x04] = "ip_change_pin_state_req", + [0x05] = "ip_change_pin_state_cnf", + [0x06] = "ip_man_reg_req", + [0x07] = "ip_man_reg_cnf", + [0x0c] = "ip_ss_status_ind", + [0x12] = "INIT", + [0x13] = "RTI", + [0x14] = "ECHO", + [0x15] = "HSCTRL", + [0x16] = "HSLCD", + [0x17] = "HSKPD", + [0xff] = "DEBUG", + }, + [0xa8] = { + [0x03] = "ip_time_charge_req", + [0x04] = "ip_signal_level_req", + [0x05] = "ip_call_start_req", + [0x06] = "ip_get_info_element_req", + [0x08] = "ip_mute_ind", + [0x20] = "ip_call_accept_req", + [0x21] = "ip_call_release_req", + [0x22] = "ip_mute_req", + [0x23] = "ip_step_volume_level_req", + [0x25] = "ip_indr_ctrl_state_ind", + [0x26] = "ip_pd_usage_ind", + [0x27] = "ip_call_status_ind", + [0x28] = "ip_audio_status_ind", + [0x29] = "ip_class_ind", + [0x2c] = "ip_time_charge_cnf", + [0x2d] = "ip_signal_level_cnf", + [0x2e] = "ip_call_start_cnf", + [0x2f] = "ip_stop_req", + [0x30] = "ip_stop_cnf", + [0x31] = "ip_abbr_dial_tbl_ind", + [0x32] = "ip_step_volume_level_cnf", + [0x33] = "ip_mmi_display_update_ind", + [0x35] = "ip_call_dtmf_req", + [0x36] = "ip_gen_imei_req", + [0x37] = "ip_gen_imei_cnf", + [0x38] = "ip_gen_pin_stat_req", + [0x39] = "ip_gen_pin_stat_cnf", + [0x3a] = "ip_gen_pin_set_req", + [0x3b] = "ip_gen_pin_set_cnf", + [0x40] = "ip_audio_routing_req", + [0x41] = "ip_sidetone_req", + [0x42] = "ip_sidetone_cnf", + }, + [0x14] = { + [0x02] = "seem_activate_cnf", + [0x03] = "seem_activate_ind", + [0x06] = "seem_deactivate_ind", + [0x0c] = "ip_get_info_element_cnf", + [0x0f] = "seem_status_cnf", + [0x11] = "seem_pin_change_cnf", + [0x13] = "seem_pin_disable_cnf", + [0x15] = "seem_pin_enable_cnf", + [0x17] = "seem_pin_verify_cnf", + [0x19] = "seem_unblocking_cnf", + }, + [0x94] = { + [0x0e] = "ip_key_feedback_ind", + }, + [0x98] = { + [0x00] = "sim_activate_req", + [0x01] = "sim_switch_act_volt_req", + [0x03] = "sim_instruction_req", + [0x06] = "sim_deactivate_req", + [0x20] = "sim_activate_cnf", + [0x21] = "sim_deactivate_cnf", + [0x23] = "sim_instruction_cnf", + [0x40] = "sim_card_detect_ind", + }, +} + +local function prim_name(iface, prim) + local t = prim_names[iface] + if t == nil then return nil end + return t[prim] +end + +local function addr_name(a) + if a == 0xe then return "ISU" end + if a >= 0x1 and a <= 0x7 then return "IP" .. a end + return string.format("0x%x", a) +end + +-- Protocol fields +local f_stx = ProtoField.uint8 ("dpl.stx", "STX", base.HEX, stx_names) +local f_len = ProtoField.uint8 ("dpl.len", "Length", base.DEC) +local f_payload = ProtoField.bytes ("dpl.payload", "Payload") +local f_fw_opcode = ProtoField.uint8 ("dpl.fw.opcode", "Firmware opcode",base.HEX, fw_opcode_names) +local f_xor = ProtoField.uint8 ("dpl.xor", "XOR", base.HEX) +local f_xor_ok = ProtoField.bool ("dpl.xor_ok", "XOR valid") +local f_eom = ProtoField.uint8 ("dpl.eom", "End-of-message", base.HEX) -dpl.fields = { f_ch, f_len, f_payload, f_opcode, f_xor, f_xor_ok, f_eom } +-- PMH (Peripheral Message Header) — only present for STX[IPn] +local f_pmh_addr = ProtoField.uint8 ("dpl.pmh.addr", "Address", base.HEX, nil, 0xf0) +local f_pmh_seq = ProtoField.uint8 ("dpl.pmh.seq", "Sequence", base.DEC, nil, 0x0f) +local f_pmh_iface = ProtoField.uint8 ("dpl.pmh.iface", "Interface ID", base.HEX, iface_names) +local f_pmh_prim = ProtoField.uint8 ("dpl.pmh.prim", "Primitive ID", base.HEX) +local f_pmh_dest = ProtoField.uint8 ("dpl.pmh.dest", "Dest sub-addr", base.HEX) +local f_pmh_src = ProtoField.uint8 ("dpl.pmh.src", "Src sub-addr", base.HEX) +local f_pmh_data = ProtoField.bytes ("dpl.pmh.data", "L3 Data") + +-- Generated / resolved fields +local f_pmh_prim_name = ProtoField.string("dpl.pmh.prim_name", "Primitive name") + +dpl.fields = { + f_stx, f_len, f_payload, f_fw_opcode, f_xor, f_xor_ok, f_eom, + f_pmh_addr, f_pmh_seq, f_pmh_iface, f_pmh_prim, + f_pmh_dest, f_pmh_src, f_pmh_data, f_pmh_prim_name, +} local ef_bad_xor = ProtoExpert.new("dpl.bad_xor.expert", "DPL XOR mismatch", expert.group.CHECKSUM, expert.severity.WARN) local ef_bad_eom = ProtoExpert.new("dpl.bad_eom.expert", "DPL terminator is not 0x03", expert.group.MALFORMED, expert.severity.WARN) -dpl.experts = { ef_bad_xor, ef_bad_eom } +local ef_short_ip = ProtoExpert.new("dpl.short_ip.expert", + "STX[IPn] payload too short for PMH (<5 bytes)", + expert.group.MALFORMED, expert.severity.WARN) +dpl.experts = { ef_bad_xor, ef_bad_eom, ef_short_ip } local function compute_xor(tvb, first, last) local x = 0 @@ -53,9 +189,8 @@ local function compute_xor(tvb, first, last) return x end --- Validate framing without asserting on errors. Returns: --- ok, len, expected_xor, actual_xor, eom_byte --- ok is false if the buffer cannot be a DPL frame at all. +-- Returns: ok, len, expected_xor, actual_xor, eom_byte +-- ok=false means the buffer cannot be a DPL frame at all. local function validate(tvb) if tvb:len() < 4 then return false end local plen = tvb(1, 1):uint() @@ -67,6 +202,39 @@ local function validate(tvb) return true, plen, expected_xor, actual_xor, eom end +-- Dissect the 5-byte PMH + optional L3 data, starting at offset `off`. +-- Returns an info string describing the primitive for the column display. +local function dissect_pmh(tvb, off, plen, subtree) + local pmh = subtree:add(dpl, tvb(off, plen), "Peripheral Message Header") + + local addr_byte = tvb(off, 1):uint() + local addr = bit.rshift(addr_byte, 4) + local seq = bit.band(addr_byte, 0x0f) + pmh:add(f_pmh_addr, tvb(off, 1)):append_text(string.format(" (%s)", addr_name(addr))) + pmh:add(f_pmh_seq, tvb(off, 1)) + + local iface = tvb(off + 1, 1):uint() + local prim = tvb(off + 2, 1):uint() + pmh:add(f_pmh_iface, tvb(off + 1, 1)) + local prim_item = pmh:add(f_pmh_prim, tvb(off + 2, 1)) + local pname = prim_name(iface, prim) + if pname then + prim_item:append_text(string.format(" (%s)", pname)) + pmh:add(f_pmh_prim_name, tvb(off + 2, 1), pname):set_generated() + end + + pmh:add(f_pmh_dest, tvb(off + 3, 1)) + pmh:add(f_pmh_src, tvb(off + 4, 1)) + + if plen > 5 then + subtree:add(f_pmh_data, tvb(off + 5, plen - 5)) + end + + local iface_label = iface_names[iface] or string.format("iface=0x%02x", iface) + local prim_label = pname or string.format("prim=0x%02x", prim) + return string.format("%s %s seq=%d", iface_label, prim_label, seq) +end + function dpl.dissector(tvb, pinfo, tree) local ok, plen, expected_xor, actual_xor, eom = validate(tvb) if not ok then return 0 end @@ -74,22 +242,33 @@ function dpl.dissector(tvb, pinfo, tree) pinfo.cols.protocol = "DPL" local subtree = tree:add(dpl, tvb(), "DPL") - subtree:add(f_ch, tvb(0, 1)) + subtree:add(f_stx, tvb(0, 1)) subtree:add(f_len, tvb(1, 1)) - local ch = tvb(0, 1):uint() + local stx = tvb(0, 1):uint() + local info = string.format("stx=0x%02x len=%d", stx, plen) if plen > 0 then local payload_tvb = tvb(2, plen) subtree:add(f_payload, payload_tvb) - if ch == 0x05 then - subtree:add(f_opcode, tvb(2, 1)) + + if stx == 0x05 then + local op = tvb(2, 1):uint() + subtree:add(f_fw_opcode, tvb(2, 1)) + info = string.format("stx=0x%02x fw_op=%s len=%d", + stx, fw_opcode_names[op] or string.format("0x%02x", op), plen) + elseif is_ip_stx(stx) then + if plen >= 5 then + local pmh_info = dissect_pmh(tvb, 2, plen, subtree) + info = string.format("%s %s", stx_names[stx] or string.format("0x%02x", stx), pmh_info) + else + subtree:add_proto_expert_info(ef_short_ip) + end end end local xor_item = subtree:add(f_xor, tvb(2 + plen, 1)) - subtree:add(f_xor_ok, expected_xor == actual_xor) - :set_generated() + subtree:add(f_xor_ok, expected_xor == actual_xor):set_generated() if expected_xor ~= actual_xor then xor_item:add_proto_expert_info(ef_bad_xor, string.format("expected 0x%02x, got 0x%02x", expected_xor, actual_xor)) @@ -100,17 +279,7 @@ function dpl.dissector(tvb, pinfo, tree) eom_item:add_proto_expert_info(ef_bad_eom) end - local info - if ch == 0x05 and plen > 0 then - local op = tvb(2, 1):uint() - info = string.format("ch=0x%02x op=%s len=%d", ch, - ch05_opcode_names[op] or string.format("0x%02x", op), - plen) - else - info = string.format("ch=0x%02x len=%d", ch, plen) - end pinfo.cols.info = info - return tvb:len() end @@ -119,8 +288,8 @@ local function dpl_heur(tvb, pinfo, tree) if not ok then return false end if eom ~= 0x03 then return false end if expected_xor ~= actual_xor then return false end - local ch = tvb(0, 1):uint() - if channel_names[ch] == nil then return false end + local stx = tvb(0, 1):uint() + if stx_names[stx] == nil then return false end dpl.dissector(tvb, pinfo, tree) return true end diff --git a/wireshark/dpl_encode.py b/wireshark/dpl_encode.py index f5a9559..a16ab1c 100644 --- a/wireshark/dpl_encode.py +++ b/wireshark/dpl_encode.py @@ -15,13 +15,13 @@ import time -def encode(ch: int, payload: bytes = b"") -> bytes: - """Return a full DPL frame: ch, len, payload, xor, 0x03.""" - if not 0 <= ch <= 0xff: - raise ValueError("channel out of range") +def encode(stx: int, payload: bytes = b"") -> bytes: + """Return a full DPL frame: STX, LEN, payload, XOR, ETX (0x03).""" + if not 0 <= stx <= 0xff: + raise ValueError("STX out of range") if len(payload) > 0xff: raise ValueError("payload too long for 1-byte length field") - header = bytes([ch, len(payload)]) + header = bytes([stx, len(payload)]) body = header + payload x = 0 for b in body: @@ -29,6 +29,32 @@ def encode(ch: int, payload: bytes = b"") -> bytes: return body + bytes([x, 0x03]) +def pmh( + addr: int, + seq: int, + iface: int, + prim: int, + dest: int = 0x00, + src: int = 0x00, + data: bytes = b"", +) -> bytes: + """Build a Peripheral Message Header + Layer 3 data payload. + + Used as the payload of an STX[IPn] frame. + + addr: 4-bit destination address (0xE=ISU, 0x1..0x7=IP). + seq: 4-bit sequence number. + """ + if not 0 <= addr <= 0xf: + raise ValueError("addr is a nybble") + if not 0 <= seq <= 0xf: + raise ValueError("seq is a nybble") + for name, v in (("iface", iface), ("prim", prim), ("dest", dest), ("src", src)): + if not 0 <= v <= 0xff: + raise ValueError(f"{name} out of range") + return bytes([(addr << 4) | seq, iface, prim, dest, src]) + data + + def wrap_udp(dpl_frame: bytes, sport: int = 4000, dport: int = 4001) -> bytes: """Wrap a DPL frame as UDP/IPv4 over loopback (BSD null encap).""" udp_len = 8 + len(dpl_frame) @@ -77,26 +103,72 @@ def write_pcap(path: str, frames: list[bytes]) -> None: def sample_frames() -> list[bytes]: frames = [] - # channel 0x05: firmware upgrade transport + + # STX=0x05: firmware upgrade transport frames.append(encode(0x05, bytes([0x80]))) # PING frames.append(encode(0x05, bytes([0x83]))) # GETID - frames.append(encode(0x05, bytes([0x86, 0x00, 0x00, 0x00]))) # READBUFF + addr + frames.append(encode(0x05, bytes([0x86, 0x00, 0x00, 0x00]))) # READBUFF frames.append(encode(0x05, bytes([0xc0, 0x12, 0x34]))) # READSECT frames.append(encode(0x05, bytes([0xc6]))) # BOOT - # channel 0x81: host-to-modem control link, opaque payloads + + # STX=0x07: STDIO + frames.append(encode(0x07, b"ready\r\n")) + + # STX=0x01: DLOG, opaque payload + frames.append(encode(0x01, bytes([0xde, 0xad, 0xbe, 0xef]))) + + # STX=0x81 (IP1) with PMH: INIT + # ISU -> IP1, seq=0, iface=0xA7, prim=0x12, data=protocol version byte + frames.append(encode(0x81, pmh(0x1, 0, 0xa7, 0x12, 0x00, 0x00, bytes([0x02])))) + + # STX=0x81 with PMH: RTI (Response To INIT) — 16 bytes + # IP1 -> ISU, seq=0, iface=0xA7, prim=0x13 + # serial(6) + device_emulation_group(4 LE) + requested_events_group(4 LE) + rsvd(2) + rti_data = ( + bytes([0x80, 0x32, 0xe0, 0x00, 0x00, 0x01]) + + b"\x80\x00\x00\x00" + + b"\xc8\x00\x00\x00" + + b"\x00\x00" + ) + frames.append(encode(0x81, pmh(0xe, 0, 0xa7, 0x13, 0x00, 0x00, rti_data))) + + # STX=0x81 with PMH: ip_call_start_req (keypad dial) + # IP1 -> ISU, seq=1, iface=0xA8, prim=0x05, dest=0x17, src=0x00 + call_start = ( + bytes([0x00, 0x00, 0x91, 0x00]) + + b"+14805551212\x00" + ) + frames.append(encode(0x81, pmh(0xe, 1, 0xa8, 0x05, 0x17, 0x00, call_start))) + + # STX=0x81 with PMH: ip_call_status_ind + # ISU -> IP1, seq=2, iface=0xA8, prim=0x27 + frames.append(encode(0x81, pmh(0x1, 2, 0xa8, 0x27, 0x00, 0x00, bytes([0x02, 0x01, 0x00])))) + + # STX=0x82 (IP2) with PMH: seem_status_cnf + # ISU -> IP2, seq=0, iface=0x14, prim=0x0F + frames.append(encode(0x82, pmh(0x2, 0, 0x14, 0x0f, 0x00, 0x00, bytes([0x06])))) + + # STX=0x81 with PMH: sim_instruction_req (GSM 11.11 passthrough) + # IP1 -> ISU, seq=3, iface=0x98, prim=0x03 + # minimal GSM 11.11 header: CLA=A0 INS=C0 P1=00 P2=00 P3=16 + direction=0 + sim_instr = bytes([0xa0, 0xc0, 0x00, 0x00, 0x16, 0x00]) + frames.append(encode(0x81, pmh(0xe, 3, 0x98, 0x03, 0xfa, 0x00, sim_instr))) + + # STX=0x81 too short for PMH (should trigger expert info) frames.append(encode(0x81, b"AT\r")) - frames.append(encode(0x81, b"hello")) - # malformed: bad xor (swap last data byte) - good = bytearray(encode(0x05, bytes([0x80, 0x11]))) - good[-2] ^= 0xff - frames.append(bytes(good)) + + # Malformed: bad XOR + bad = bytearray(encode(0x05, bytes([0x80, 0x11]))) + bad[-2] ^= 0xff + frames.append(bytes(bad)) + return frames def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--hex", nargs="+", metavar="BYTE", - help="print hex for one DPL frame: first byte is channel, " + help="print hex for one DPL frame: first byte is STX, " "rest is payload. Example: --hex 05 80") ap.add_argument("-o", "--out", default=None, help="output pcap path (default: tests/sample.pcap next to this script)") diff --git a/wireshark/tests/sample.pcap b/wireshark/tests/sample.pcap index 096bb6d2d3adce04072a264981fab51f570eaaed..b41ff157e711b11161e3d1909086e05f372056bf 100644 GIT binary patch literal 904 zcma))J5K^Z6ov2ZvI~O9LxdKe7%@=@Yt%{$TPy#7i54VQb|yv_HCo#lE2yBcG10`v z5K%;HI}@|UA7F2T=ML9mD%s>@XLjy)&dl6>YhBk&3eexv?e<3P9ly+@Ns)>78qp|! zTS@G{9ugU>ST$CoD3NV=Cl*7701W;Q9iJ(P3#UZ_O<>AN&stI(Nr6+W^f(KnoJhEF zOquIwTnwDmOqP5V$H(peVC@G5*?{sQ$>M0vdepLpiGso1?ai&dXn04V4$AsHo)P%C zy8q58E(mT$B#~2?a^#a1nIx(>@j6~+R*@l8Es2D5g}l{BG_*3}*Rf}>=%b$=sybXr z@5*QZqh*m8E$}L~aHAQG!6^ONurpXZr+lHbIPbb{p;#!=x^fC*VOAtgd?)PG(wtIo z3MCu3T+^V8Vk4;Q_acs*Yzpmm?i4u)a!w?v+(b@oYN^zf(#6etQn~}Ir-1PMzBu~ HXEp=?)%`1b