usb-rea 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- usb_rea/__init__.py +2 -0
- usb_rea/actions.py +73 -0
- usb_rea/capture.py +38 -0
- usb_rea/checksums.py +46 -0
- usb_rea/cli.py +180 -0
- usb_rea/dissector.py +271 -0
- usb_rea/hid.py +344 -0
- usb_rea/infer.py +434 -0
- usb_rea/mcp_server.py +190 -0
- usb_rea/model.py +87 -0
- usb_rea/pcap.py +162 -0
- usb_rea/pipeline.py +64 -0
- usb_rea/replay.py +19 -0
- usb_rea/report.py +43 -0
- usb_rea/scaffold.py +136 -0
- usb_rea/synth.py +216 -0
- usb_rea-0.2.1.dist-info/METADATA +140 -0
- usb_rea-0.2.1.dist-info/RECORD +21 -0
- usb_rea-0.2.1.dist-info/WHEEL +4 -0
- usb_rea-0.2.1.dist-info/entry_points.txt +2 -0
- usb_rea-0.2.1.dist-info/licenses/LICENSE +21 -0
usb_rea/__init__.py
ADDED
usb_rea/actions.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Action markers: which transfers belong to which user action."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .model import Action, Transfer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_markers(path: str | Path) -> list[dict]:
|
|
11
|
+
"""Markers file: JSON list of {label, start, end?} (epoch seconds).
|
|
12
|
+
|
|
13
|
+
Missing `end` means "until the next marker starts" (or +infinity).
|
|
14
|
+
"""
|
|
15
|
+
marks = json.loads(Path(path).read_text())
|
|
16
|
+
marks = sorted(marks, key=lambda m: m["start"])
|
|
17
|
+
for i, m in enumerate(marks):
|
|
18
|
+
if m.get("end") is None:
|
|
19
|
+
m["end"] = marks[i + 1]["start"] if i + 1 < len(marks) else float("inf")
|
|
20
|
+
return marks
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def append_marker(path: str | Path, label: str, ts: float | None = None) -> dict:
|
|
24
|
+
p = Path(path)
|
|
25
|
+
marks = json.loads(p.read_text()) if p.exists() else []
|
|
26
|
+
now = time.time() if ts is None else ts
|
|
27
|
+
if marks and marks[-1].get("end") is None:
|
|
28
|
+
marks[-1]["end"] = now
|
|
29
|
+
m = {"label": label, "start": now, "end": None}
|
|
30
|
+
marks.append(m)
|
|
31
|
+
p.write_text(json.dumps(marks, indent=1))
|
|
32
|
+
return m
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def assign(transfers: list[Transfer], marks: list[dict]) -> list[Action]:
|
|
36
|
+
acts = [Action(m["label"], m["start"], m["end"]) for m in marks]
|
|
37
|
+
for t in transfers:
|
|
38
|
+
for a in acts:
|
|
39
|
+
if a.start <= t.ts <= a.end:
|
|
40
|
+
a.transfers.append(t)
|
|
41
|
+
break
|
|
42
|
+
return acts
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def commands_of(action: Action) -> list[Transfer]:
|
|
46
|
+
"""Host->device messages that carry data (the interesting part)."""
|
|
47
|
+
return [t for t in action.transfers if t.is_out and t.data]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def segment_by_gaps(transfers: list[Transfer], labels: list[str]) -> list[dict]:
|
|
51
|
+
"""Build markers without timestamps: split host->device traffic at the largest pauses.
|
|
52
|
+
|
|
53
|
+
For captures made in Wireshark/USBPcap where you simply clicked the actions in order with a
|
|
54
|
+
short pause between them. Returns markers usable by `assign`.
|
|
55
|
+
"""
|
|
56
|
+
outs = sorted((t for t in transfers if t.is_out and t.data), key=lambda t: t.ts)
|
|
57
|
+
n = len(labels)
|
|
58
|
+
if n == 0 or len(outs) < n:
|
|
59
|
+
raise ValueError(f"{len(labels)} labels but only {len(outs)} host->device messages in the capture")
|
|
60
|
+
gaps = sorted(range(1, len(outs)), key=lambda i: outs[i].ts - outs[i - 1].ts, reverse=True)[:n - 1]
|
|
61
|
+
cuts = [0] + sorted(gaps) + [len(outs)]
|
|
62
|
+
marks = []
|
|
63
|
+
for label, a, b in zip(labels, cuts, cuts[1:]):
|
|
64
|
+
marks.append({"label": label, "start": outs[a].ts - 1e-6, "end": outs[b - 1].ts + 1e-6})
|
|
65
|
+
return marks
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_labels(spec: str) -> list[str]:
|
|
69
|
+
"""Labels from a file (one per line) or a ';'-separated string."""
|
|
70
|
+
p = Path(spec)
|
|
71
|
+
if p.is_file():
|
|
72
|
+
return [l.strip() for l in p.read_text().splitlines() if l.strip() and not l.startswith("#")]
|
|
73
|
+
return [s.strip() for s in spec.split(";") if s.strip()]
|
usb_rea/capture.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Capture helpers: thin wrappers around dumpcap/tshark (Linux usbmon) and USBPcapCMD (Windows)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
PIDFILE = ".usb-rea-capture.pid"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def capture_command(out: str, bus: int = 0) -> list[str]:
|
|
14
|
+
if sys.platform.startswith("win"):
|
|
15
|
+
exe = shutil.which("USBPcapCMD.exe") or r"C:\Program Files\USBPcap\USBPcapCMD.exe"
|
|
16
|
+
return [exe, "-d", rf"\\.\USBPcap{bus or 1}", "-o", out, "-A"]
|
|
17
|
+
tool = shutil.which("dumpcap") or shutil.which("tshark")
|
|
18
|
+
if not tool:
|
|
19
|
+
raise RuntimeError("need dumpcap or tshark (apt install tshark) and `sudo modprobe usbmon`")
|
|
20
|
+
return [tool, "-i", f"usbmon{bus}", "-w", out, "-q"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def start(out: str, bus: int = 0, workdir: str = ".") -> int:
|
|
24
|
+
proc = subprocess.Popen(capture_command(out, bus), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
25
|
+
Path(workdir, PIDFILE).write_text(str(proc.pid))
|
|
26
|
+
return proc.pid
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def stop(workdir: str = ".") -> bool:
|
|
30
|
+
p = Path(workdir, PIDFILE)
|
|
31
|
+
if not p.exists():
|
|
32
|
+
return False
|
|
33
|
+
try:
|
|
34
|
+
os.kill(int(p.read_text()), signal.SIGINT)
|
|
35
|
+
except ProcessLookupError:
|
|
36
|
+
pass
|
|
37
|
+
p.unlink()
|
|
38
|
+
return True
|
usb_rea/checksums.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Checksum / CRC algorithms usb-rea can recognise. Self-contained: copied verbatim into generated drivers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _crc(data, width, poly, init, refin, refout, xorout):
|
|
5
|
+
top, mask = 1 << (width - 1), (1 << width) - 1
|
|
6
|
+
crc = init
|
|
7
|
+
for byte in data:
|
|
8
|
+
if refin:
|
|
9
|
+
byte = int("{:08b}".format(byte)[::-1], 2)
|
|
10
|
+
crc ^= byte << (width - 8)
|
|
11
|
+
for _ in range(8):
|
|
12
|
+
crc = ((crc << 1) ^ poly) & mask if crc & top else (crc << 1) & mask
|
|
13
|
+
if refout:
|
|
14
|
+
crc = int("{:0{w}b}".format(crc, w=width)[::-1], 2)
|
|
15
|
+
return crc ^ xorout
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _xor(data):
|
|
19
|
+
x = 0
|
|
20
|
+
for c in data:
|
|
21
|
+
x ^= c
|
|
22
|
+
return x
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# name -> (width in bytes, function)
|
|
26
|
+
CHECKSUMS = {
|
|
27
|
+
"sum8": (1, lambda b: sum(b) & 0xFF),
|
|
28
|
+
"xor8": (1, _xor),
|
|
29
|
+
"neg_sum8": (1, lambda b: (-sum(b)) & 0xFF),
|
|
30
|
+
"inv_sum8": (1, lambda b: (~sum(b)) & 0xFF),
|
|
31
|
+
"crc8": (1, lambda b: _crc(b, 8, 0x07, 0x00, False, False, 0x00)),
|
|
32
|
+
"crc8_maxim": (1, lambda b: _crc(b, 8, 0x31, 0x00, True, True, 0x00)),
|
|
33
|
+
"sum16": (2, lambda b: sum(b) & 0xFFFF),
|
|
34
|
+
"crc16_ccitt_false": (2, lambda b: _crc(b, 16, 0x1021, 0xFFFF, False, False, 0x0000)),
|
|
35
|
+
"crc16_xmodem": (2, lambda b: _crc(b, 16, 0x1021, 0x0000, False, False, 0x0000)),
|
|
36
|
+
"crc16_modbus": (2, lambda b: _crc(b, 16, 0x8005, 0xFFFF, True, True, 0x0000)),
|
|
37
|
+
"crc16_kermit": (2, lambda b: _crc(b, 16, 0x1021, 0x0000, True, True, 0x0000)),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compute(algo, data):
|
|
42
|
+
return CHECKSUMS[algo][1](bytes(data))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def width(algo):
|
|
46
|
+
return CHECKSUMS[algo][0]
|
usb_rea/cli.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""usb-rea command line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _int(s):
|
|
12
|
+
return int(s, 0)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None) -> int:
|
|
16
|
+
p = argparse.ArgumentParser(prog="usb-rea", description="Reverse Engineer any USB gadget")
|
|
17
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
18
|
+
sub = p.add_subparsers(dest="cmd")
|
|
19
|
+
|
|
20
|
+
c = sub.add_parser("capture", help="start/stop a usbmon/USBPcap capture")
|
|
21
|
+
c.add_argument("op", choices=["start", "stop"])
|
|
22
|
+
c.add_argument("-o", "--out", default="capture.pcapng")
|
|
23
|
+
c.add_argument("--bus", type=int, default=0)
|
|
24
|
+
|
|
25
|
+
m = sub.add_parser("mark", help="mark the action you are about to do in the vendor app")
|
|
26
|
+
m.add_argument("label")
|
|
27
|
+
m.add_argument("-m", "--markers", default="capture.pcapng.actions.json")
|
|
28
|
+
|
|
29
|
+
for name, hlp in (("transfers", "list parsed transfers"), ("infer", "infer protocol (JSON)"),
|
|
30
|
+
("report", "write evidence report (markdown)"), ("scaffold", "write pyusb driver"),
|
|
31
|
+
("dissector", "write a Wireshark Lua dissector")):
|
|
32
|
+
s = sub.add_parser(name, help=hlp)
|
|
33
|
+
s.add_argument("capture")
|
|
34
|
+
s.add_argument("-m", "--markers")
|
|
35
|
+
s.add_argument("-l", "--labels", help="no markers? actions in the order you clicked them, "
|
|
36
|
+
"';'-separated or a file with one per line; split at the pauses between them")
|
|
37
|
+
s.add_argument("-d", "--device", type=int)
|
|
38
|
+
s.add_argument("-o", "--out")
|
|
39
|
+
if name == "dissector":
|
|
40
|
+
s.add_argument("-n", "--name", default="usbrea", help="protocol/filter name in Wireshark")
|
|
41
|
+
|
|
42
|
+
h = sub.add_parser("hid", help="decode a HID report descriptor (from a capture or hex)")
|
|
43
|
+
h.add_argument("source", help="capture file, or descriptor hex (e.g. from /sys/class/hidraw/*/device/report_descriptor)")
|
|
44
|
+
h.add_argument("--decode", metavar="HEX", help="also decode one report payload")
|
|
45
|
+
h.add_argument("--type", default="Input", choices=["Input", "Output", "Feature"], help="report type for --decode")
|
|
46
|
+
h.add_argument("--items", action="store_true", help="print every descriptor item")
|
|
47
|
+
|
|
48
|
+
d = sub.add_parser("demo", help="generate a synthetic capture and run the whole pipeline")
|
|
49
|
+
d.add_argument("-o", "--outdir", default="usb-rea-demo")
|
|
50
|
+
d.add_argument("--device", choices=["lamp", "fan", "dial"], default="lamp")
|
|
51
|
+
|
|
52
|
+
r = sub.add_parser("replay", help="send one packet to a real device (requires --yes)")
|
|
53
|
+
r.add_argument("vid", type=_int)
|
|
54
|
+
r.add_argument("pid", type=_int)
|
|
55
|
+
r.add_argument("endpoint", type=_int)
|
|
56
|
+
r.add_argument("hex")
|
|
57
|
+
r.add_argument("--setup", help="8-byte setup packet hex for control transfers")
|
|
58
|
+
r.add_argument("--yes", action="store_true")
|
|
59
|
+
|
|
60
|
+
sub.add_parser("mcp", help="run the MCP server (stdio)")
|
|
61
|
+
|
|
62
|
+
a = p.parse_args(argv)
|
|
63
|
+
if a.cmd is None:
|
|
64
|
+
p.print_help()
|
|
65
|
+
return 0
|
|
66
|
+
if a.cmd == "capture":
|
|
67
|
+
from . import capture
|
|
68
|
+
if a.op == "start":
|
|
69
|
+
print(f"capturing to {a.out} (pid {capture.start(a.out, a.bus)})")
|
|
70
|
+
else:
|
|
71
|
+
print("stopped" if capture.stop() else "no capture running")
|
|
72
|
+
return 0
|
|
73
|
+
if a.cmd == "mark":
|
|
74
|
+
from .actions import append_marker
|
|
75
|
+
print(json.dumps(append_marker(a.markers, a.label)))
|
|
76
|
+
return 0
|
|
77
|
+
if a.cmd == "replay":
|
|
78
|
+
from .replay import replay
|
|
79
|
+
try:
|
|
80
|
+
print(replay(a.vid, a.pid, a.endpoint, bytes.fromhex(a.hex),
|
|
81
|
+
bytes.fromhex(a.setup) if a.setup else None, confirm=a.yes))
|
|
82
|
+
except PermissionError as e:
|
|
83
|
+
print(e, file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
return 0
|
|
86
|
+
if a.cmd == "mcp":
|
|
87
|
+
from .mcp_server import run
|
|
88
|
+
run()
|
|
89
|
+
return 0
|
|
90
|
+
if a.cmd == "hid":
|
|
91
|
+
return _hid(a)
|
|
92
|
+
if a.cmd == "demo":
|
|
93
|
+
return _demo(Path(a.outdir), a.device)
|
|
94
|
+
if a.cmd == "transfers":
|
|
95
|
+
from .pcap import read_transfers
|
|
96
|
+
for t in read_transfers(a.capture):
|
|
97
|
+
print(f"{t.frame:6d} dev {t.device:3d} ep 0x{t.endpoint:02x} {t.kind:9s} "
|
|
98
|
+
f"{'OUT' if t.is_out else 'IN '} {(t.setup.hex() + ' | ') if t.setup else ''}{t.data.hex(' ')}")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
from .pipeline import analyze, sha256
|
|
102
|
+
labels = None
|
|
103
|
+
if getattr(a, "labels", None):
|
|
104
|
+
from .actions import read_labels
|
|
105
|
+
labels = read_labels(a.labels)
|
|
106
|
+
proto = analyze(a.capture, a.markers, a.device, labels)
|
|
107
|
+
if a.cmd == "infer":
|
|
108
|
+
text = json.dumps(proto.to_dict(), indent=1, default=str)
|
|
109
|
+
elif a.cmd == "report":
|
|
110
|
+
from .report import render
|
|
111
|
+
text = render(proto, a.capture, sha256(a.capture))
|
|
112
|
+
elif a.cmd == "dissector":
|
|
113
|
+
from .dissector import generate
|
|
114
|
+
text = generate(proto, a.name)
|
|
115
|
+
else:
|
|
116
|
+
from .scaffold import generate
|
|
117
|
+
text = generate(proto)
|
|
118
|
+
if a.out:
|
|
119
|
+
Path(a.out).write_text(text)
|
|
120
|
+
print(f"wrote {a.out}")
|
|
121
|
+
else:
|
|
122
|
+
print(text)
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _hid(a) -> int:
|
|
127
|
+
from . import hid
|
|
128
|
+
src = Path(a.source)
|
|
129
|
+
if src.exists() and src.suffix in (".pcap", ".pcapng", ".cap"):
|
|
130
|
+
from .pcap import read_transfers
|
|
131
|
+
found = hid.from_transfers(read_transfers(src))
|
|
132
|
+
if not found:
|
|
133
|
+
print("no HID report descriptor (GET_DESCRIPTOR 0x22) in this capture; "
|
|
134
|
+
"replug the device while capturing, or pass the descriptor hex", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
descs = [(f"device {d}, interface {i}", raw) for (d, i), raw in sorted(found.items())]
|
|
137
|
+
elif src.exists():
|
|
138
|
+
raw = src.read_bytes()
|
|
139
|
+
descs = [(str(src), raw)]
|
|
140
|
+
else:
|
|
141
|
+
descs = [("hex", bytes.fromhex(a.source.replace("0x", "").replace(",", " ")))]
|
|
142
|
+
for name, raw in descs:
|
|
143
|
+
desc = hid.parse(raw)
|
|
144
|
+
print(f"# {name}: {len(raw)} bytes, top level {', '.join(desc.top_usages) or '?'}")
|
|
145
|
+
if a.items:
|
|
146
|
+
print(hid.dump(desc))
|
|
147
|
+
for r in desc.reports:
|
|
148
|
+
print(f"\n{r.title()}")
|
|
149
|
+
for f in r.fields:
|
|
150
|
+
print(f" {f.describe()}")
|
|
151
|
+
if a.decode:
|
|
152
|
+
print(f"\n{a.type} {a.decode}:")
|
|
153
|
+
for label, v in hid.decode(desc, a.type, bytes.fromhex(a.decode)):
|
|
154
|
+
print(f" {label} = {v}")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _demo(outdir: Path, device: str = "lamp") -> int:
|
|
159
|
+
from .synth import make_capture
|
|
160
|
+
from .pipeline import analyze, sha256
|
|
161
|
+
from .report import render
|
|
162
|
+
from .scaffold import generate
|
|
163
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
cap, _ = make_capture(outdir / f"{device}.pcap", device=device)
|
|
165
|
+
proto = analyze(cap)
|
|
166
|
+
(outdir / "report.md").write_text(render(proto, str(cap), sha256(cap)))
|
|
167
|
+
(outdir / f"{device}_driver.py").write_text(generate(proto))
|
|
168
|
+
from .dissector import generate as dissector
|
|
169
|
+
(outdir / f"{device}.lua").write_text(dissector(proto))
|
|
170
|
+
(outdir / "__init__.py").touch()
|
|
171
|
+
print(f"captured {sum(len(c.samples) for c in proto.commands)} labelled actions, "
|
|
172
|
+
f"device {proto.vid:04x}:{proto.pid:04x}\n")
|
|
173
|
+
for c in proto.commands:
|
|
174
|
+
print(f"{c.name:12s} {c.confidence:4.0%} " + " | ".join(f.describe() for f in c.fields))
|
|
175
|
+
print(f"\nwrote {outdir}/report.md, {outdir}/{device}_driver.py and the Wireshark dissector {outdir}/{device}.lua")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|
usb_rea/dissector.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Generate a Wireshark Lua dissector from an inferred protocol.
|
|
2
|
+
|
|
3
|
+
The dissector is a postdissector: it looks at the payload Wireshark already extracted for every
|
|
4
|
+
USB transfer (bulk/interrupt `usb.capdata`, control `usb.data_fragment`, HID `usbhid.data`), matches
|
|
5
|
+
it against the inferred commands (endpoint or setup packet, length, constant bytes) and labels each
|
|
6
|
+
field, decodes values, and verifies checksums. It is pure Lua with no bit library, so it runs on
|
|
7
|
+
Wireshark 3.x (Lua 5.2) and 4.x (Lua 5.4).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from .infer import Protocol
|
|
13
|
+
|
|
14
|
+
# name -> (width, lua expression over `d`, a list of byte values)
|
|
15
|
+
_LUA_CHECKSUMS = {
|
|
16
|
+
"sum8": "sum(d) % 256",
|
|
17
|
+
"xor8": "xorall(d)",
|
|
18
|
+
"neg_sum8": "(256 - sum(d) % 256) % 256",
|
|
19
|
+
"inv_sum8": "255 - sum(d) % 256",
|
|
20
|
+
"sum16": "sum(d) % 65536",
|
|
21
|
+
"crc8": "crc(d, 8, 0x07, 0x00, false, false, 0x00)",
|
|
22
|
+
"crc8_maxim": "crc(d, 8, 0x31, 0x00, true, true, 0x00)",
|
|
23
|
+
"crc16_ccitt_false": "crc(d, 16, 0x1021, 0xFFFF, false, false, 0x0000)",
|
|
24
|
+
"crc16_xmodem": "crc(d, 16, 0x1021, 0x0000, false, false, 0x0000)",
|
|
25
|
+
"crc16_modbus": "crc(d, 16, 0x8005, 0xFFFF, true, true, 0x0000)",
|
|
26
|
+
"crc16_kermit": "crc(d, 16, 0x1021, 0x0000, true, true, 0x0000)",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_HELPERS = r'''
|
|
30
|
+
-- ---- arithmetic-only helpers (no bit library needed) ----
|
|
31
|
+
local function bxor(a, b)
|
|
32
|
+
local r, p = 0, 1
|
|
33
|
+
while a > 0 or b > 0 do
|
|
34
|
+
if a % 2 ~= b % 2 then r = r + p end
|
|
35
|
+
a, b, p = math.floor(a / 2), math.floor(b / 2), p * 2
|
|
36
|
+
end
|
|
37
|
+
return r
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local function reflect(v, w)
|
|
41
|
+
local r = 0
|
|
42
|
+
for _ = 1, w do r = r * 2 + v % 2; v = math.floor(v / 2) end
|
|
43
|
+
return r
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
local function sum(d) local s = 0 for _, b in ipairs(d) do s = s + b end return s end
|
|
47
|
+
local function xorall(d) local x = 0 for _, b in ipairs(d) do x = bxor(x, b) end return x end
|
|
48
|
+
|
|
49
|
+
local function crc(d, w, poly, init, refin, refout, xorout)
|
|
50
|
+
local top, mod, c = 2 ^ (w - 1), 2 ^ w, init
|
|
51
|
+
for _, b in ipairs(d) do
|
|
52
|
+
if refin then b = reflect(b, 8) end
|
|
53
|
+
c = bxor(c, b * 2 ^ (w - 8))
|
|
54
|
+
for _ = 1, 8 do
|
|
55
|
+
if c >= top then c = bxor((c * 2) % mod, poly) else c = (c * 2) % mod end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
if refout then c = reflect(c, w) end
|
|
59
|
+
return math.floor(bxor(c, xorout))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
local function bytes_of(r)
|
|
63
|
+
local t = {}
|
|
64
|
+
for i = 0, r:len() - 1 do t[#t + 1] = r(i, 1):uint() end
|
|
65
|
+
return t
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
-- show the integer the user most likely picked when the raw byte is consistent with it
|
|
69
|
+
-- (brightness 25% -> 0x40 -> 25.098 is shown as 25)
|
|
70
|
+
local function fmt(v, scale)
|
|
71
|
+
local n = math.floor(v + 0.5)
|
|
72
|
+
if math.abs(v - n) <= 0.5 / math.abs(scale or 1) + 1e-9 then return string.format("%d", n) end
|
|
73
|
+
return string.format("%.4g", v)
|
|
74
|
+
end
|
|
75
|
+
'''
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ident(name: str) -> str:
|
|
79
|
+
s = re.sub(r"\W+", "_", name).strip("_").lower() or "cmd"
|
|
80
|
+
return s if not s[0].isdigit() else "cmd_" + s
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _lua_str(s: str) -> str:
|
|
84
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ") + '"'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def generate(proto: Protocol, name: str = "usbrea") -> str:
|
|
88
|
+
p = _ident(name)
|
|
89
|
+
ids = f"{proto.vid:04x}:{proto.pid:04x}" if proto.vid is not None else "unknown id"
|
|
90
|
+
out = [f'''-- Wireshark dissector for USB device {ids}.
|
|
91
|
+
-- Generated by usb-rea (https://github.com/emirmrkaljevic/usb-rea) from captured traffic;
|
|
92
|
+
-- every command below is backed by frames listed in the accompanying report.md.
|
|
93
|
+
--
|
|
94
|
+
-- Use: wireshark -X lua_script:{p}.lua capture.pcapng
|
|
95
|
+
-- tshark -X lua_script:{p}.lua -r capture.pcapng -Y {p}
|
|
96
|
+
-- or copy this file to your personal Lua plugins folder (Help > About Wireshark > Folders).
|
|
97
|
+
-- Filter fields: {p}.command, {p}.<command>.<field>, {p}.checksum_ok
|
|
98
|
+
''']
|
|
99
|
+
out.append(_HELPERS)
|
|
100
|
+
out.append(f'''
|
|
101
|
+
local P = Proto({_lua_str(p)}, {_lua_str(f"usb-rea: USB device {ids}")})
|
|
102
|
+
local f_command = ProtoField.string("{p}.command", "Command")
|
|
103
|
+
local f_checksum_ok = ProtoField.bool("{p}.checksum_ok", "Checksum OK")
|
|
104
|
+
local f_unknown = ProtoField.bytes("{p}.unknown", "Unknown bytes")
|
|
105
|
+
local fields = {{ f_command, f_checksum_ok, f_unknown }}
|
|
106
|
+
local e_bad_checksum = ProtoExpert.new("{p}.checksum_bad", "Checksum mismatch", expert.group.CHECKSUM, expert.severity.WARN)
|
|
107
|
+
P.experts = {{ e_bad_checksum }}
|
|
108
|
+
|
|
109
|
+
local function pf(f) fields[#fields + 1] = f; return f end
|
|
110
|
+
''')
|
|
111
|
+
cmds = []
|
|
112
|
+
for c in proto.commands:
|
|
113
|
+
cid = _ident(c.name)
|
|
114
|
+
roles = []
|
|
115
|
+
for f in c.fields:
|
|
116
|
+
if f.kind in ("const", "part"):
|
|
117
|
+
continue
|
|
118
|
+
idx = "" if f.index == 0 else str(f.index)
|
|
119
|
+
if f.kind == "command":
|
|
120
|
+
fid, decl = f"{cid}.command_id", f'ProtoField.uint8("{p}.{cid}.command_id", "Command id", base.HEX)'
|
|
121
|
+
elif f.kind in ("value", "bitfield"):
|
|
122
|
+
label = c.name if f.index == 0 else f"{c.name}[{f.index}]"
|
|
123
|
+
fid = f"{cid}.value{idx}"
|
|
124
|
+
decl = f'ProtoField.double("{p}.{fid}", {_lua_str(label)})'
|
|
125
|
+
elif f.kind == "lookup":
|
|
126
|
+
fid = f"{cid}.value{idx}"
|
|
127
|
+
vs = ", ".join(f"[{v}] = {_lua_str(f'{k:g}')}" for k, v in sorted(f.table.items()))
|
|
128
|
+
decl = f'ProtoField.uint8("{p}.{fid}", {_lua_str(c.name + " (lookup)")}, base.HEX, {{ {vs} }})'
|
|
129
|
+
elif f.kind == "counter":
|
|
130
|
+
fid = f"{cid}.counter"
|
|
131
|
+
decl = f'ProtoField.uint{8 * f.width}("{p}.{fid}", "Sequence counter")'
|
|
132
|
+
elif f.kind == "checksum":
|
|
133
|
+
fid = f"{cid}.checksum"
|
|
134
|
+
decl = f'ProtoField.uint{8 * f.width}("{p}.{fid}", {_lua_str(f.algo)}, base.HEX)'
|
|
135
|
+
else:
|
|
136
|
+
fid, decl = None, None
|
|
137
|
+
var = "f_" + re.sub(r"\W", "_", fid) if fid else "f_unknown"
|
|
138
|
+
if decl:
|
|
139
|
+
out.append(f"local {var} = pf({decl})\n")
|
|
140
|
+
role = {"kind": f.kind, "var": var, "off": f.offset, "w": f.width, "le": f.endian == "le"}
|
|
141
|
+
if f.kind in ("value", "bitfield"):
|
|
142
|
+
role.update(scale=f.scale, bias=f.bias, idx=f.index)
|
|
143
|
+
if f.kind == "bitfield":
|
|
144
|
+
role.update(bo=f.bit_offset, bw=f.bit_width)
|
|
145
|
+
if f.kind == "lookup":
|
|
146
|
+
role.update(idx=f.index, table={v: k for k, v in f.table.items()})
|
|
147
|
+
if f.kind == "checksum":
|
|
148
|
+
role.update(algo=f.algo, a=f.covers[0], z=f.covers[1])
|
|
149
|
+
roles.append(role)
|
|
150
|
+
consts = [(f.offset, f.const) for f in c.fields if f.kind in ("const", "command")]
|
|
151
|
+
setup = None
|
|
152
|
+
if c.setup:
|
|
153
|
+
s = c.setup
|
|
154
|
+
setup = (s[0], s[1], s[2] | s[3] << 8)
|
|
155
|
+
cmds.append((c, cid, roles, consts, setup))
|
|
156
|
+
|
|
157
|
+
out.append("\nlocal COMMANDS = {\n")
|
|
158
|
+
for c, cid, roles, consts, setup in cmds:
|
|
159
|
+
frames = ", ".join(map(str, c.frames[:8]))
|
|
160
|
+
out.append(f" -- {c.name}: {c.confidence:.0%} confidence, evidence frames {frames}\n")
|
|
161
|
+
cs = ", ".join(f"{{{o}, 0x{v:02x}}}" for o, v in consts)
|
|
162
|
+
su = f"{{0x{setup[0]:02x}, 0x{setup[1]:02x}, 0x{setup[2]:04x}}}" if setup else "nil"
|
|
163
|
+
rs = []
|
|
164
|
+
for r in roles:
|
|
165
|
+
parts = [f'kind = "{r["kind"]}"', f'f = {r["var"]}', f'off = {r["off"]}', f'w = {r["w"]}',
|
|
166
|
+
f'le = {"true" if r["le"] else "false"}']
|
|
167
|
+
for k in ("scale", "bias"):
|
|
168
|
+
if k in r:
|
|
169
|
+
parts.append(f"{k} = {r[k]!r}")
|
|
170
|
+
for k in ("idx", "bo", "bw", "a", "z"):
|
|
171
|
+
if k in r:
|
|
172
|
+
parts.append(f"{k} = {r[k]}")
|
|
173
|
+
if "table" in r:
|
|
174
|
+
parts.append("table = { " + ", ".join(f"[{b}] = {v!r}" for b, v in sorted(r["table"].items())) + " }")
|
|
175
|
+
if "algo" in r:
|
|
176
|
+
parts.append(f'check = function(d) return {_LUA_CHECKSUMS[r["algo"]]} end')
|
|
177
|
+
rs.append(" { " + ", ".join(parts) + " },\n")
|
|
178
|
+
out.append(f''' {{ name = {_lua_str(c.name)}, ep = {c.endpoint}, control = {"true" if setup else "false"}, setup = {su},
|
|
179
|
+
length = {c.length}, consts = {{ {cs} }},
|
|
180
|
+
roles = {{
|
|
181
|
+
{"".join(rs)} }} }},
|
|
182
|
+
''')
|
|
183
|
+
out.append("}\n")
|
|
184
|
+
out.append(f'''
|
|
185
|
+
P.fields = fields
|
|
186
|
+
|
|
187
|
+
local fd_capdata = Field.new("usb.capdata")
|
|
188
|
+
local fd_fragment = Field.new("usb.data_fragment")
|
|
189
|
+
local fd_endpoint = Field.new("usb.endpoint_address")
|
|
190
|
+
local fd_reqtype = Field.new("usb.bmRequestType")
|
|
191
|
+
local fd_request = Field.new("usb.setup.bRequest")
|
|
192
|
+
local fd_wvalue = Field.new("usb.setup.wValue")
|
|
193
|
+
local ok_hid, fd_hid = pcall(Field.new, "usbhid.data")
|
|
194
|
+
if not ok_hid then fd_hid = nil end
|
|
195
|
+
|
|
196
|
+
local function payload()
|
|
197
|
+
for _, fd in ipairs({{ fd_capdata, fd_fragment, fd_hid }}) do
|
|
198
|
+
local fi = fd and fd()
|
|
199
|
+
if fi and fi.range and fi.len > 0 then return fi.range end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
local function matches(c, r)
|
|
204
|
+
if r:len() ~= c.length then return false end
|
|
205
|
+
if c.control then
|
|
206
|
+
local rt, rq, wv = fd_reqtype(), fd_request(), fd_wvalue()
|
|
207
|
+
if not (rt and rq and wv) then return false end
|
|
208
|
+
if rt.value ~= c.setup[1] or rq.value ~= c.setup[2] or wv.value ~= c.setup[3] then return false end
|
|
209
|
+
else
|
|
210
|
+
local ep = fd_endpoint()
|
|
211
|
+
if not ep or ep.value ~= c.ep then return false end
|
|
212
|
+
end
|
|
213
|
+
for _, k in ipairs(c.consts) do
|
|
214
|
+
if r(k[1], 1):uint() ~= k[2] then return false end
|
|
215
|
+
end
|
|
216
|
+
return true
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
local function raw(role, r)
|
|
220
|
+
local s = r(role.off, role.w)
|
|
221
|
+
return role.le and s:le_uint() or s:uint(), s
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
function P.dissector(tvb, pinfo, tree)
|
|
225
|
+
local r = payload()
|
|
226
|
+
if not r then return end
|
|
227
|
+
for _, c in ipairs(COMMANDS) do
|
|
228
|
+
if matches(c, r) then
|
|
229
|
+
local t = tree:add(P, r)
|
|
230
|
+
t:add(f_command, r, c.name)
|
|
231
|
+
local vals, ok = {{}}, nil
|
|
232
|
+
for _, role in ipairs(c.roles) do
|
|
233
|
+
local v, s = raw(role, r)
|
|
234
|
+
local add = role.le and t.add_le or t.add
|
|
235
|
+
if role.kind == "value" then
|
|
236
|
+
local x = (v - role.bias) / role.scale
|
|
237
|
+
add(t, role.f, s, x); vals[#vals + 1] = fmt(x, role.scale)
|
|
238
|
+
elseif role.kind == "bitfield" then
|
|
239
|
+
local bits = math.floor(v / 2 ^ role.bo) % 2 ^ role.bw
|
|
240
|
+
local x = (bits - role.bias) / role.scale
|
|
241
|
+
add(t, role.f, s, x):append_text(string.format(" (bits %d-%d)", role.bo, role.bo + role.bw - 1))
|
|
242
|
+
vals[#vals + 1] = fmt(x, role.scale)
|
|
243
|
+
elseif role.kind == "lookup" then
|
|
244
|
+
add(t, role.f, s); vals[#vals + 1] = role.table[v] and fmt(role.table[v]) or string.format("0x%02x?", v)
|
|
245
|
+
elseif role.kind == "checksum" then
|
|
246
|
+
local want = role.check(bytes_of(r(role.a, role.z - role.a)))
|
|
247
|
+
local item = add(t, role.f, s)
|
|
248
|
+
ok = (ok ~= false) and (v == want)
|
|
249
|
+
if v == want then item:append_text(" [correct]")
|
|
250
|
+
else
|
|
251
|
+
item:append_text(string.format(" [incorrect, should be 0x%x]", want))
|
|
252
|
+
item:add_proto_expert_info(e_bad_checksum)
|
|
253
|
+
end
|
|
254
|
+
elseif role.kind == "unknown" then
|
|
255
|
+
t:add(f_unknown, s)
|
|
256
|
+
else
|
|
257
|
+
add(t, role.f, s)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
if ok ~= nil then t:add(f_checksum_ok, ok):set_generated() end
|
|
261
|
+
pinfo.cols.protocol:set({_lua_str(p.upper())})
|
|
262
|
+
pinfo.cols.info:set(c.name .. (#vals > 0 and (" " .. table.concat(vals, " ")) or "")
|
|
263
|
+
.. (ok == false and " [bad checksum]" or ""))
|
|
264
|
+
return
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
register_postdissector(P)
|
|
270
|
+
''')
|
|
271
|
+
return "".join(out)
|