iotsploit-protocols 0.0.9__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.
- iotsploit_protocols/__init__.py +20 -0
- iotsploit_protocols/autosar/__init__.py +12 -0
- iotsploit_protocols/autosar/arxml.py +765 -0
- iotsploit_protocols/canbus/__init__.py +56 -0
- iotsploit_protocols/canbus/bus_match.py +121 -0
- iotsploit_protocols/canbus/catalog.py +425 -0
- iotsploit_protocols/canbus/codec.py +455 -0
- iotsploit_protocols/canbus/definitions.py +252 -0
- iotsploit_protocols/canbus/errorframes.py +154 -0
- iotsploit_protocols/canbus/errors.py +47 -0
- iotsploit_protocols/canbus/logfile.py +753 -0
- iotsploit_protocols/canbus/socketcan.py +385 -0
- iotsploit_protocols/doip/__init__.py +22 -0
- iotsploit_protocols/doip/client.py +267 -0
- iotsploit_protocols/doip/facet.py +51 -0
- iotsploit_protocols/doip/uds.py +262 -0
- iotsploit_protocols/errors.py +45 -0
- iotsploit_protocols/someip/__init__.py +19 -0
- iotsploit_protocols/someip/client.py +322 -0
- iotsploit_protocols/someip/codec.py +11 -0
- iotsploit_protocols/someip/facet.py +71 -0
- iotsploit_protocols/someip/sd.py +355 -0
- iotsploit_protocols-0.0.9.dist-info/METADATA +118 -0
- iotsploit_protocols-0.0.9.dist-info/RECORD +25 -0
- iotsploit_protocols-0.0.9.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""One-shot SocketCAN I/O: open, do exactly one thing, close.
|
|
2
|
+
|
|
3
|
+
This is deliberately not a driver. ``iotsploit_drivers.socketcan`` owns
|
|
4
|
+
discovery, link lifecycle, and a long-lived streaming socket, and it stays that
|
|
5
|
+
way -- borrowing it here would either drag privileged link mutation into a send
|
|
6
|
+
path or add a second mutable "current device" to race against. SocketCAN allows
|
|
7
|
+
several sockets on one interface, so a client here and the driver's monitor
|
|
8
|
+
coexist without contending for the bus.
|
|
9
|
+
|
|
10
|
+
Two prohibitions are absolute, and they are why this module is small:
|
|
11
|
+
|
|
12
|
+
*It never changes host networking.* No ``sudo``, no ``ip link``, no bitrate, no
|
|
13
|
+
listen-only, no bringing an interface up. A link is configured outside
|
|
14
|
+
IoTSploit; this opens one that is already up or fails saying so. A tool that
|
|
15
|
+
quietly reconfigures an interface to make its own call succeed has changed the
|
|
16
|
+
vehicle's bus to suit itself.
|
|
17
|
+
|
|
18
|
+
*It never retries.* One request puts at most one frame on the wire. A retry
|
|
19
|
+
loop around a send that may have already reached an ECU is how one confirmed
|
|
20
|
+
action becomes several unconfirmed ones.
|
|
21
|
+
|
|
22
|
+
``python-can`` is imported inside the functions that need it, not at module
|
|
23
|
+
scope. It opens platform sockets and reads host configuration on import, and a
|
|
24
|
+
preview must work on a host with no CAN interface at all.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Callable, Iterator, Optional
|
|
34
|
+
|
|
35
|
+
from iotsploit_protocols.canbus.definitions import EncodedFrame
|
|
36
|
+
from iotsploit_protocols.errors import NotConfigured, ProtocolError
|
|
37
|
+
|
|
38
|
+
#: Linux caps an interface name at IFNAMSIZ-1 and forbids whitespace and '/'.
|
|
39
|
+
#: Checked before opening because python-can reports a bad name as a generic
|
|
40
|
+
#: OSError, which reads as "the bus is down" rather than "that is not a name".
|
|
41
|
+
#: ``\Z`` rather than ``$``: ``$`` also matches just before a trailing
|
|
42
|
+
#: newline, so "can0\n" would pass as a valid name.
|
|
43
|
+
_CHANNEL_RE = re.compile(r"\A[A-Za-z0-9_.:-]{1,15}\Z")
|
|
44
|
+
|
|
45
|
+
#: What builds the underlying bus. Injected in tests so the deterministic suite
|
|
46
|
+
#: never opens can0, vcan0, or a socket of any kind.
|
|
47
|
+
BusFactory = Callable[..., Any]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CanTransportError(ProtocolError):
|
|
51
|
+
"""The local socket refused the frame.
|
|
52
|
+
|
|
53
|
+
Never means an ECU did or did not receive anything. It means this host's
|
|
54
|
+
CAN stack would not accept the message for transmission, which is a
|
|
55
|
+
different claim entirely and is the only one a sender can honestly make.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class SocketCanConfig:
|
|
61
|
+
"""Where to send, and how long to wait for the socket to take it.
|
|
62
|
+
|
|
63
|
+
``channel`` is the kernel interface name (``can0``), never the device
|
|
64
|
+
driver's own id (``can_001``). They look similar in a UI and only one of
|
|
65
|
+
them can be opened.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
channel: str
|
|
69
|
+
timeout: float = 1.0
|
|
70
|
+
#: Whether to open the socket in FD mode. Set from the frame, not guessed:
|
|
71
|
+
#: a classic socket rejects a 16-byte payload.
|
|
72
|
+
fd: bool = False
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
if not isinstance(self.channel, str) or not _CHANNEL_RE.match(self.channel):
|
|
76
|
+
raise NotConfigured(
|
|
77
|
+
f"{self.channel!r} is not a usable SocketCAN interface name; "
|
|
78
|
+
"expected something like 'can0' or 'vcan0'"
|
|
79
|
+
)
|
|
80
|
+
if self.timeout is None or self.timeout <= 0:
|
|
81
|
+
raise NotConfigured(f"send timeout must be positive, not {self.timeout!r}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _default_bus_factory(**kwargs: Any) -> Any:
|
|
85
|
+
import can # imported here: see the module docstring
|
|
86
|
+
|
|
87
|
+
return can.Bus(**kwargs)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_message(frame: EncodedFrame) -> Any:
|
|
91
|
+
import can
|
|
92
|
+
|
|
93
|
+
return can.Message(
|
|
94
|
+
arbitration_id=frame.frame_id,
|
|
95
|
+
is_extended_id=frame.is_extended,
|
|
96
|
+
is_fd=frame.is_fd,
|
|
97
|
+
data=frame.data,
|
|
98
|
+
# check=True makes python-can validate the id against the flag and the
|
|
99
|
+
# payload length against the frame type before anything reaches the
|
|
100
|
+
# kernel, so a mismatch is a stated error rather than a silent
|
|
101
|
+
# truncation on the wire.
|
|
102
|
+
check=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SocketCanClient:
|
|
107
|
+
"""A socket that exists for the duration of one send.
|
|
108
|
+
|
|
109
|
+
Used as a context manager so the socket closes on every path, including the
|
|
110
|
+
one where the send raised. A leaked SocketCAN socket keeps receiving into a
|
|
111
|
+
kernel buffer nobody drains.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
config: SocketCanConfig,
|
|
117
|
+
*,
|
|
118
|
+
bus_factory: Optional[BusFactory] = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
self.config = config
|
|
121
|
+
self._bus_factory = bus_factory or _default_bus_factory
|
|
122
|
+
self._bus: Any = None
|
|
123
|
+
|
|
124
|
+
def __enter__(self) -> "SocketCanClient":
|
|
125
|
+
self.open()
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
129
|
+
self.close()
|
|
130
|
+
|
|
131
|
+
def open(self) -> None:
|
|
132
|
+
if self._bus is not None:
|
|
133
|
+
return
|
|
134
|
+
try:
|
|
135
|
+
self._bus = self._bus_factory(
|
|
136
|
+
interface="socketcan",
|
|
137
|
+
channel=self.config.channel,
|
|
138
|
+
fd=self.config.fd,
|
|
139
|
+
# The host's can.conf must not be able to redirect this to
|
|
140
|
+
# another interface or quietly supply a bitrate.
|
|
141
|
+
ignore_config=True,
|
|
142
|
+
)
|
|
143
|
+
except Exception as error:
|
|
144
|
+
raise CanTransportError(
|
|
145
|
+
f"cannot open SocketCAN interface {self.config.channel!r}: {error}. "
|
|
146
|
+
"The interface has to exist and be up before sending; "
|
|
147
|
+
"IoTSploit does not configure it."
|
|
148
|
+
) from error
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
bus, self._bus = self._bus, None
|
|
152
|
+
if bus is None:
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
bus.shutdown()
|
|
156
|
+
except Exception: # noqa: BLE001 - a failed close must not mask the result
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
def send(self, frame: EncodedFrame) -> None:
|
|
160
|
+
"""Put exactly one frame on the wire.
|
|
161
|
+
|
|
162
|
+
Returning normally means the local socket accepted the frame. It does
|
|
163
|
+
not mean an ECU received it, acted on it, or even that anything was
|
|
164
|
+
listening -- and no caller may report otherwise.
|
|
165
|
+
"""
|
|
166
|
+
if self._bus is None:
|
|
167
|
+
self.open()
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
message = _build_message(frame)
|
|
171
|
+
except (ValueError, TypeError) as error:
|
|
172
|
+
# check=True validates the id against the extended flag and the
|
|
173
|
+
# payload length against the frame type. It raises from the
|
|
174
|
+
# constructor rather than from send(), so catching only around the
|
|
175
|
+
# send would let this escape as a bare ValueError.
|
|
176
|
+
raise CanTransportError(
|
|
177
|
+
f"frame 0x{frame.frame_id:X} cannot be represented on a CAN bus: {error}"
|
|
178
|
+
) from error
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
self._bus.send(message, timeout=self.config.timeout)
|
|
182
|
+
except Exception as error:
|
|
183
|
+
raise CanTransportError(
|
|
184
|
+
f"SocketCAN refused frame 0x{frame.frame_id:X} on "
|
|
185
|
+
f"{self.config.channel!r}: {error}"
|
|
186
|
+
) from error
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class CaptureBudget:
|
|
191
|
+
"""When a capture stops, stated two ways because either can run out first.
|
|
192
|
+
|
|
193
|
+
A capture with no budget is a leak: it runs inside a worker, and an
|
|
194
|
+
observation scope needs a run that ends in order to mean anything. The
|
|
195
|
+
frame budget is the one that saves you on a busy bus, where thirty seconds
|
|
196
|
+
is millions of frames; the duration is the one that saves you on a silent
|
|
197
|
+
bus, where the frame budget would never be reached.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
duration_s: float = 30.0
|
|
201
|
+
max_frames: int = 200_000
|
|
202
|
+
|
|
203
|
+
def __post_init__(self) -> None:
|
|
204
|
+
if self.duration_s <= 0:
|
|
205
|
+
raise NotConfigured(f"capture duration must be positive, not {self.duration_s!r}")
|
|
206
|
+
if self.max_frames <= 0:
|
|
207
|
+
raise NotConfigured(f"frame budget must be positive, not {self.max_frames!r}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class SocketCanReceiver:
|
|
211
|
+
"""A read-only socket that yields frames until its budget runs out.
|
|
212
|
+
|
|
213
|
+
There is no send path here, not even a disabled one. What this cannot do is
|
|
214
|
+
part of what it is.
|
|
215
|
+
|
|
216
|
+
It opens its own socket rather than borrowing the streaming driver's. That
|
|
217
|
+
is not duplication for its own sake: SocketCAN permits several sockets on
|
|
218
|
+
one interface, so a capture neither disturbs nor depends on the driver's
|
|
219
|
+
monitor, and neither one's lifetime is tied to the other's.
|
|
220
|
+
|
|
221
|
+
Note for anyone reading this next to the hardware: a CAN controller in
|
|
222
|
+
normal mode **acknowledges frames it receives, in silicon**. Attaching an
|
|
223
|
+
interface to a live bus is therefore not electrically inert, whatever this
|
|
224
|
+
software does or does not send. Listen-only is host link configuration
|
|
225
|
+
(``ip link set can0 type can listen-only on``) and this class must not set
|
|
226
|
+
it, for the same reason the sender must not set a bitrate.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
config: SocketCanConfig,
|
|
232
|
+
*,
|
|
233
|
+
bus_factory: Optional[BusFactory] = None,
|
|
234
|
+
clock: Optional[Callable[[], float]] = None,
|
|
235
|
+
) -> None:
|
|
236
|
+
self.config = config
|
|
237
|
+
self._bus_factory = bus_factory or _default_bus_factory
|
|
238
|
+
self._clock = clock or time.monotonic
|
|
239
|
+
self._bus: Any = None
|
|
240
|
+
self._stop = False
|
|
241
|
+
|
|
242
|
+
def __enter__(self) -> "SocketCanReceiver":
|
|
243
|
+
self.open()
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
247
|
+
self.close()
|
|
248
|
+
|
|
249
|
+
def open(self) -> None:
|
|
250
|
+
if self._bus is not None:
|
|
251
|
+
return
|
|
252
|
+
try:
|
|
253
|
+
self._bus = self._bus_factory(
|
|
254
|
+
interface="socketcan",
|
|
255
|
+
channel=self.config.channel,
|
|
256
|
+
fd=self.config.fd,
|
|
257
|
+
ignore_config=True,
|
|
258
|
+
)
|
|
259
|
+
except Exception as error:
|
|
260
|
+
raise CanTransportError(
|
|
261
|
+
f"cannot open SocketCAN interface {self.config.channel!r} for capture: "
|
|
262
|
+
f"{error}. The interface has to exist and be up; IoTSploit does not "
|
|
263
|
+
"configure it."
|
|
264
|
+
) from error
|
|
265
|
+
|
|
266
|
+
def close(self) -> None:
|
|
267
|
+
bus, self._bus = self._bus, None
|
|
268
|
+
if bus is None:
|
|
269
|
+
return
|
|
270
|
+
try:
|
|
271
|
+
bus.shutdown()
|
|
272
|
+
except Exception: # noqa: BLE001 - a failed close must not mask the capture
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
def stop(self) -> None:
|
|
276
|
+
"""Ask the loop to finish at its next opportunity."""
|
|
277
|
+
self._stop = True
|
|
278
|
+
|
|
279
|
+
def frames(
|
|
280
|
+
self,
|
|
281
|
+
budget: CaptureBudget,
|
|
282
|
+
*,
|
|
283
|
+
check_cancelled: Optional[Callable[[], None]] = None,
|
|
284
|
+
) -> Iterator[Any]:
|
|
285
|
+
"""Yield received messages until the budget is spent.
|
|
286
|
+
|
|
287
|
+
The socket is closed on every exit path -- normal end, exception, and
|
|
288
|
+
the consumer abandoning the generator -- because a leaked SocketCAN
|
|
289
|
+
socket keeps filling a kernel buffer that nobody drains.
|
|
290
|
+
"""
|
|
291
|
+
if self._bus is None:
|
|
292
|
+
self.open()
|
|
293
|
+
|
|
294
|
+
started = self._clock()
|
|
295
|
+
deadline = started + budget.duration_s
|
|
296
|
+
delivered = 0
|
|
297
|
+
try:
|
|
298
|
+
while not self._stop and delivered < budget.max_frames:
|
|
299
|
+
remaining = deadline - self._clock()
|
|
300
|
+
if remaining <= 0:
|
|
301
|
+
break
|
|
302
|
+
# Bounded by whichever is sooner, so a silent bus still wakes up
|
|
303
|
+
# to notice its own deadline instead of blocking past it.
|
|
304
|
+
try:
|
|
305
|
+
message = self._bus.recv(timeout=min(remaining, 0.25))
|
|
306
|
+
except Exception as error:
|
|
307
|
+
# SocketCAN binds to an interface that is down and only says
|
|
308
|
+
# so at the first read, so a capture that opened cleanly
|
|
309
|
+
# still fails here. python-can spells that CanOperationError,
|
|
310
|
+
# which is not an OSError: unwrapped it escapes every
|
|
311
|
+
# caller's except clause and reaches the operator as a crash.
|
|
312
|
+
raise CanTransportError(
|
|
313
|
+
f"capture on {self.config.channel!r} failed: {error}. The "
|
|
314
|
+
"interface has to stay up for the whole sample; IoTSploit "
|
|
315
|
+
"does not configure it."
|
|
316
|
+
) from error
|
|
317
|
+
if check_cancelled is not None:
|
|
318
|
+
check_cancelled()
|
|
319
|
+
if message is None:
|
|
320
|
+
continue
|
|
321
|
+
delivered += 1
|
|
322
|
+
yield message
|
|
323
|
+
finally:
|
|
324
|
+
self.close()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
#: ``ARPHRD_CAN`` from ``linux/if_arp.h``. What marks a net device as a CAN
|
|
328
|
+
#: interface rather than an Ethernet one.
|
|
329
|
+
ARPHRD_CAN = 280
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True)
|
|
333
|
+
class CanInterface:
|
|
334
|
+
"""A CAN interface this host has, as sysfs describes it."""
|
|
335
|
+
|
|
336
|
+
name: str
|
|
337
|
+
is_up: bool
|
|
338
|
+
#: ``vcan``/``vxcan`` devices carry no transceiver, so nothing an operator
|
|
339
|
+
#: does on one can reach a vehicle. Worth saying out loud before a capture.
|
|
340
|
+
is_virtual: bool
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def label(self) -> str:
|
|
344
|
+
state = "up" if self.is_up else "down"
|
|
345
|
+
kind = "virtual" if self.is_virtual else "hardware"
|
|
346
|
+
return f"{self.name} ({kind}, {state})"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def list_can_interfaces(sysfs_root: str = "/sys/class/net") -> "list[CanInterface]":
|
|
350
|
+
"""Every CAN interface on this host, newest kernel truth, read-only.
|
|
351
|
+
|
|
352
|
+
Reads sysfs rather than shelling out to ``ip`` or asking the device driver.
|
|
353
|
+
That keeps it unprivileged, keeps it free of the driver's lifecycle -- the
|
|
354
|
+
plugins must not initialize or connect a device merely to list one -- and
|
|
355
|
+
means it works when the driver has never been touched.
|
|
356
|
+
|
|
357
|
+
An interface that is *down* is still listed. Hiding it would leave an
|
|
358
|
+
operator wondering why the interface they can see in ``ip link`` is absent
|
|
359
|
+
here; saying "down" tells them what to fix.
|
|
360
|
+
"""
|
|
361
|
+
root = Path(sysfs_root)
|
|
362
|
+
found: list[CanInterface] = []
|
|
363
|
+
try:
|
|
364
|
+
entries = sorted(root.iterdir())
|
|
365
|
+
except OSError:
|
|
366
|
+
return found
|
|
367
|
+
|
|
368
|
+
for entry in entries:
|
|
369
|
+
try:
|
|
370
|
+
if int((entry / "type").read_text().strip()) != ARPHRD_CAN:
|
|
371
|
+
continue
|
|
372
|
+
operstate = (entry / "operstate").read_text().strip()
|
|
373
|
+
except (OSError, ValueError):
|
|
374
|
+
continue
|
|
375
|
+
found.append(
|
|
376
|
+
CanInterface(
|
|
377
|
+
name=entry.name,
|
|
378
|
+
# vcan reports "unknown" rather than "up"; treat anything that
|
|
379
|
+
# is not explicitly "down" as usable and let the open fail
|
|
380
|
+
# honestly if it is not.
|
|
381
|
+
is_up=operstate != "down",
|
|
382
|
+
is_virtual=entry.name.startswith(("vcan", "vxcan")),
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
return found
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""DoIP transport and UDS diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"FACET_KEY",
|
|
7
|
+
"DoipClient",
|
|
8
|
+
"DoipConfig",
|
|
9
|
+
"DoipFacet",
|
|
10
|
+
"DoipUdsClient",
|
|
11
|
+
"RoutingActivationFailed",
|
|
12
|
+
"UdsClient",
|
|
13
|
+
"UdsResponse",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
from iotsploit_protocols.doip.client import (
|
|
17
|
+
DoipClient,
|
|
18
|
+
DoipConfig,
|
|
19
|
+
RoutingActivationFailed,
|
|
20
|
+
)
|
|
21
|
+
from iotsploit_protocols.doip.facet import FACET_KEY, DoipFacet
|
|
22
|
+
from iotsploit_protocols.doip.uds import DoipUdsClient, UdsClient, UdsResponse
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""A DoIP connection: routing activation, framing, one diagnostic exchange.
|
|
2
|
+
|
|
3
|
+
Replaces the transport half of the old ``DoIP_Mgr``. The differences that
|
|
4
|
+
matter, each of them a defect in what came before:
|
|
5
|
+
|
|
6
|
+
**Framing follows the length field.** The old code read exactly 13 bytes, slept
|
|
7
|
+
half a second, then read 2048 more. TCP guarantees neither that one ``recv`` is
|
|
8
|
+
one message nor that an acknowledgement arrives alone, so a coalesced or split
|
|
9
|
+
segment silently desynchronized the stream and every subsequent response was
|
|
10
|
+
attributed to the wrong request. Here the 8-byte header is read, its length
|
|
11
|
+
field is believed, and exactly that many bytes follow.
|
|
12
|
+
|
|
13
|
+
**One client is one connection to one ECU.** The old module exported a
|
|
14
|
+
process-wide singleton, so two ECUs could not be addressed at once and a
|
|
15
|
+
half-open socket poisoned every later caller.
|
|
16
|
+
|
|
17
|
+
**Nothing here knows about a vehicle.** No default host, no NIC name, no sudo,
|
|
18
|
+
no interactive prompt. A caller supplies a config; an unconfigured target fails
|
|
19
|
+
loudly rather than probing whatever used to live at a hardcoded address.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import socket
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from iotsploit_protocols.errors import NotConfigured, ProtocolError
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
#: protocol_version, inverse_version, payload_type, payload_length.
|
|
34
|
+
HEADER_LEN = 8
|
|
35
|
+
|
|
36
|
+
#: ISO 13400-2 version 0x02, followed by its bitwise inverse. The pair is how a
|
|
37
|
+
#: receiver rejects a stream that is not DoIP at all.
|
|
38
|
+
PROTOCOL_VERSION = b"\x02\xfd"
|
|
39
|
+
|
|
40
|
+
#: The payload types this client uses. ISO 13400-2.
|
|
41
|
+
PT_GENERIC_NACK = 0x0000
|
|
42
|
+
PT_ROUTING_ACTIVATION_REQUEST = 0x0005
|
|
43
|
+
PT_ROUTING_ACTIVATION_RESPONSE = 0x0006
|
|
44
|
+
PT_ALIVE_CHECK_REQUEST = 0x0007
|
|
45
|
+
PT_ALIVE_CHECK_RESPONSE = 0x0008
|
|
46
|
+
PT_DIAGNOSTIC_MESSAGE = 0x8001
|
|
47
|
+
PT_DIAGNOSTIC_ACK = 0x8002
|
|
48
|
+
PT_DIAGNOSTIC_NACK = 0x8003
|
|
49
|
+
|
|
50
|
+
#: Routing activation response codes worth naming. 0x10 is the only success.
|
|
51
|
+
ROUTING_ACTIVATION_OK = 0x10
|
|
52
|
+
ROUTING_ACTIVATION_CODES = {
|
|
53
|
+
0x00: "unknown source address",
|
|
54
|
+
0x01: "all sockets registered and active",
|
|
55
|
+
0x02: "source address does not match",
|
|
56
|
+
0x03: "source address already registered",
|
|
57
|
+
0x04: "missing authentication",
|
|
58
|
+
0x05: "rejected confirmation",
|
|
59
|
+
0x06: "unsupported activation type",
|
|
60
|
+
0x10: "success",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RoutingActivationFailed(ProtocolError):
|
|
65
|
+
"""The gateway refused to route for us. Nothing else can work until it does."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, code: int) -> None:
|
|
68
|
+
self.code = code
|
|
69
|
+
name = ROUTING_ACTIVATION_CODES.get(code, "unknown")
|
|
70
|
+
super().__init__(f"routing activation refused: 0x{code:02X} ({name})")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class DoipConfig:
|
|
75
|
+
"""Where to connect, and which ECU to address.
|
|
76
|
+
|
|
77
|
+
``host`` has no default on purpose. The address it replaces was a function
|
|
78
|
+
default naming one vehicle's gateway, so an unconfigured bench quietly
|
|
79
|
+
probed that address and reported its silence as a result.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
host: str
|
|
83
|
+
logical_address: int
|
|
84
|
+
port: int = 13400
|
|
85
|
+
tester_address: int = 0x0E80
|
|
86
|
+
activation_type: int = 0x00
|
|
87
|
+
timeout: float = 5.0
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
if not self.host:
|
|
91
|
+
raise NotConfigured("DoIP host is required; there is no default")
|
|
92
|
+
if not 0 < self.port < 65536:
|
|
93
|
+
raise NotConfigured(f"DoIP port {self.port!r} is out of range")
|
|
94
|
+
for name in ("logical_address", "tester_address"):
|
|
95
|
+
value = getattr(self, name)
|
|
96
|
+
if not 0 <= value <= 0xFFFF:
|
|
97
|
+
raise NotConfigured(f"DoIP {name} 0x{value:X} is not a 16-bit address")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DoipClient:
|
|
101
|
+
"""One TCP connection to one DoIP entity.
|
|
102
|
+
|
|
103
|
+
Use as a context manager: routing activation happens on entry and the socket
|
|
104
|
+
is closed on exit even when a request raises.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, config: DoipConfig) -> None:
|
|
108
|
+
self.config = config
|
|
109
|
+
self._sock: Optional[socket.socket] = None
|
|
110
|
+
|
|
111
|
+
# ── lifecycle ─────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> "DoipClient":
|
|
114
|
+
self.connect()
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
118
|
+
self.close()
|
|
119
|
+
|
|
120
|
+
def connect(self) -> None:
|
|
121
|
+
"""Open the socket and activate routing.
|
|
122
|
+
|
|
123
|
+
Routing activation is not optional politeness: until the gateway accepts
|
|
124
|
+
it, diagnostic messages are discarded, and the old code's habit of
|
|
125
|
+
pressing on regardless is why a refused activation looked like a silent
|
|
126
|
+
ECU.
|
|
127
|
+
"""
|
|
128
|
+
if self._sock is not None:
|
|
129
|
+
return
|
|
130
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
131
|
+
sock.settimeout(self.config.timeout)
|
|
132
|
+
sock.connect((self.config.host, self.config.port))
|
|
133
|
+
self._sock = sock
|
|
134
|
+
logger.debug("DoIP connected to %s:%d", self.config.host, self.config.port)
|
|
135
|
+
try:
|
|
136
|
+
self._activate_routing()
|
|
137
|
+
except Exception:
|
|
138
|
+
self.close()
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
if self._sock is None:
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
self._sock.close()
|
|
146
|
+
except OSError:
|
|
147
|
+
logger.debug("DoIP socket close failed", exc_info=True)
|
|
148
|
+
finally:
|
|
149
|
+
self._sock = None
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def connected(self) -> bool:
|
|
153
|
+
return self._sock is not None
|
|
154
|
+
|
|
155
|
+
# ── exchanges ─────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def request(self, payload: bytes) -> bytes:
|
|
158
|
+
"""Send one UDS payload to the configured ECU and return the response.
|
|
159
|
+
|
|
160
|
+
The positive/negative acknowledgement DoIP interposes (0x8002 / 0x8003)
|
|
161
|
+
is consumed here rather than handed upward: it says the *gateway* took
|
|
162
|
+
the message, which is not an answer to the diagnostic request and was
|
|
163
|
+
the source of the old code's magic offsets.
|
|
164
|
+
"""
|
|
165
|
+
self._send(PT_DIAGNOSTIC_MESSAGE, self._addresses() + payload)
|
|
166
|
+
return self.read()
|
|
167
|
+
|
|
168
|
+
def read(self) -> bytes:
|
|
169
|
+
"""The next diagnostic response, sending nothing.
|
|
170
|
+
|
|
171
|
+
Used by the UDS layer after a responsePending: the ECU will send the
|
|
172
|
+
real answer by itself, and re-sending the request would perform it
|
|
173
|
+
twice.
|
|
174
|
+
"""
|
|
175
|
+
while True:
|
|
176
|
+
payload_type, body = self._read_message()
|
|
177
|
+
|
|
178
|
+
if payload_type == PT_DIAGNOSTIC_ACK:
|
|
179
|
+
logger.debug("DoIP ack")
|
|
180
|
+
continue
|
|
181
|
+
if payload_type == PT_DIAGNOSTIC_NACK:
|
|
182
|
+
code = body[4] if len(body) > 4 else -1
|
|
183
|
+
raise ProtocolError(f"DoIP refused the diagnostic message: 0x{code:02X}")
|
|
184
|
+
if payload_type == PT_ALIVE_CHECK_REQUEST:
|
|
185
|
+
# Answering keeps the socket registered. Ignoring it, as the old
|
|
186
|
+
# code did, gets the connection dropped mid-session.
|
|
187
|
+
self._send(PT_ALIVE_CHECK_RESPONSE, self._addresses()[:2])
|
|
188
|
+
continue
|
|
189
|
+
if payload_type == PT_GENERIC_NACK:
|
|
190
|
+
code = body[0] if body else -1
|
|
191
|
+
raise ProtocolError(f"DoIP header negative acknowledge: 0x{code:02X}")
|
|
192
|
+
if payload_type == PT_DIAGNOSTIC_MESSAGE:
|
|
193
|
+
# source(2) + target(2) then the UDS payload.
|
|
194
|
+
return body[4:]
|
|
195
|
+
|
|
196
|
+
logger.debug("DoIP ignoring payload type 0x%04X", payload_type)
|
|
197
|
+
|
|
198
|
+
# ── internals ─────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def _addresses(self) -> bytes:
|
|
201
|
+
return self.config.tester_address.to_bytes(2, "big") + self.config.logical_address.to_bytes(
|
|
202
|
+
2, "big"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _activate_routing(self) -> None:
|
|
206
|
+
request = (
|
|
207
|
+
self.config.tester_address.to_bytes(2, "big")
|
|
208
|
+
+ bytes([self.config.activation_type])
|
|
209
|
+
+ b"\x00\x00\x00\x00"
|
|
210
|
+
)
|
|
211
|
+
self._send(PT_ROUTING_ACTIVATION_REQUEST, request)
|
|
212
|
+
|
|
213
|
+
payload_type, body = self._read_message()
|
|
214
|
+
if payload_type != PT_ROUTING_ACTIVATION_RESPONSE:
|
|
215
|
+
raise ProtocolError(
|
|
216
|
+
f"expected routing activation response, got payload type 0x{payload_type:04X}"
|
|
217
|
+
)
|
|
218
|
+
if len(body) < 5:
|
|
219
|
+
raise ProtocolError(f"routing activation response truncated: {len(body)} bytes")
|
|
220
|
+
code = body[4]
|
|
221
|
+
if code != ROUTING_ACTIVATION_OK:
|
|
222
|
+
raise RoutingActivationFailed(code)
|
|
223
|
+
logger.debug("DoIP routing activated for tester 0x%04X", self.config.tester_address)
|
|
224
|
+
|
|
225
|
+
def _require_socket(self) -> socket.socket:
|
|
226
|
+
if self._sock is None:
|
|
227
|
+
raise ProtocolError("DoIP client is not connected; use it as a context manager")
|
|
228
|
+
return self._sock
|
|
229
|
+
|
|
230
|
+
def _send(self, payload_type: int, body: bytes) -> None:
|
|
231
|
+
"""Header plus body.
|
|
232
|
+
|
|
233
|
+
Built by hand rather than through scapy's ``DoIP`` layer, which declares
|
|
234
|
+
source_address/target_address as *conditional* fields for some payload
|
|
235
|
+
types: handing it a body that already contains those addresses encodes
|
|
236
|
+
them twice, once zeroed. The header is four fields and the parser here
|
|
237
|
+
already reads it directly, so building it directly keeps one definition
|
|
238
|
+
of the format instead of two that must agree.
|
|
239
|
+
"""
|
|
240
|
+
header = (
|
|
241
|
+
PROTOCOL_VERSION
|
|
242
|
+
+ payload_type.to_bytes(2, "big")
|
|
243
|
+
+ len(body).to_bytes(4, "big")
|
|
244
|
+
)
|
|
245
|
+
self._require_socket().sendall(header + body)
|
|
246
|
+
|
|
247
|
+
def _read_message(self) -> tuple[int, bytes]:
|
|
248
|
+
"""One whole DoIP message: header, then exactly what it declares."""
|
|
249
|
+
header = self._read_exactly(HEADER_LEN)
|
|
250
|
+
payload_type = int.from_bytes(header[2:4], "big")
|
|
251
|
+
declared = int.from_bytes(header[4:HEADER_LEN], "big")
|
|
252
|
+
if declared > 0xFFFF:
|
|
253
|
+
# A length this large is a desynchronized stream, not a real message.
|
|
254
|
+
raise ProtocolError(f"implausible DoIP payload length {declared}")
|
|
255
|
+
return payload_type, self._read_exactly(declared) if declared else b""
|
|
256
|
+
|
|
257
|
+
def _read_exactly(self, count: int) -> bytes:
|
|
258
|
+
sock = self._require_socket()
|
|
259
|
+
chunks = []
|
|
260
|
+
remaining = count
|
|
261
|
+
while remaining > 0:
|
|
262
|
+
chunk = sock.recv(remaining)
|
|
263
|
+
if not chunk:
|
|
264
|
+
raise ProtocolError(f"DoIP peer closed after {count - remaining} of {count} bytes")
|
|
265
|
+
chunks.append(chunk)
|
|
266
|
+
remaining -= len(chunk)
|
|
267
|
+
return b"".join(chunks)
|