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,51 @@
|
|
|
1
|
+
"""The DoIP facet, shipped beside the client that consumes it.
|
|
2
|
+
|
|
3
|
+
Moved here from ``iotsploit_django.tools.doip_facet``: core deliberately
|
|
4
|
+
registers no protocol facets, and a facet belongs with the code that reads it.
|
|
5
|
+
The lookup helpers that used to live alongside this class did not come with it
|
|
6
|
+
-- they read the current target through Django, so they stayed in the binding
|
|
7
|
+
adapter. What is left is the part that is genuinely about DoIP.
|
|
8
|
+
|
|
9
|
+
The addresses this replaces were class constants on ``DoIP_Mgr``, which meant
|
|
10
|
+
adding a vehicle was a code edit. With a facet on the ECU component it is a
|
|
11
|
+
target edit.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from pydantic import Field
|
|
19
|
+
|
|
20
|
+
from iotsploit_core.domain.facet import Facet, register_facet
|
|
21
|
+
|
|
22
|
+
FACET_KEY = "doip"
|
|
23
|
+
|
|
24
|
+
# Stored as an int, written and read by humans as hex. An editor that only knew
|
|
25
|
+
# the JSON type would make you type 4113 for an address every document in the
|
|
26
|
+
# field calls 0x1011. The facet that knows the convention declares it; core
|
|
27
|
+
# stays ignorant of what any protocol's numbers mean.
|
|
28
|
+
HEX = {"format": "hex"}
|
|
29
|
+
|
|
30
|
+
# Same idea for uniqueness. A logical address identifies one ECU, so two of them
|
|
31
|
+
# on one address is a fault worth showing; a CAN facet's bus_id is a reference to
|
|
32
|
+
# something shared and every component on a segment repeats it. Nothing in JSON
|
|
33
|
+
# Schema tells those apart, and a view that guessed from the field name would
|
|
34
|
+
# flag correct configuration as broken.
|
|
35
|
+
HEX_UNIQUE = {"format": "hex", "unique": True}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@register_facet(FACET_KEY)
|
|
39
|
+
class DoipFacet(Facet):
|
|
40
|
+
"""DoIP/UDS addressing for one ECU.
|
|
41
|
+
|
|
42
|
+
``logical_address`` is an int on purpose: it is the join key to the
|
|
43
|
+
reference catalog, and "0x1011", "1011" and 4113 must not be three different
|
|
44
|
+
keys. Secrets are deliberately absent -- PINs stay in ClassifiedInfo/Env_Mgr
|
|
45
|
+
rather than being copied into a JSON column.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
logical_address: int = Field(json_schema_extra=HEX_UNIQUE)
|
|
49
|
+
tester_address: int = Field(default=0x0E80, json_schema_extra=HEX)
|
|
50
|
+
host: Optional[str] = None
|
|
51
|
+
port: int = 13400
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""UDS on top of a DoIP connection.
|
|
2
|
+
|
|
3
|
+
Separate from ``client.py`` because UDS is not DoIP: the same services run over
|
|
4
|
+
ISO-TP on CAN, and a layer that knows about session control should not also know
|
|
5
|
+
about routing activation. ``UdsClient`` needs only something with a
|
|
6
|
+
``request(bytes) -> bytes`` method.
|
|
7
|
+
|
|
8
|
+
What this replaces was a set of magic offsets -- ``resp_buf[12]``,
|
|
9
|
+
``resp_buf[-3]``, ``resp_buf[-5]`` -- each call site indexing the raw buffer
|
|
10
|
+
differently and each one wrong for some message shape. One layer parses the
|
|
11
|
+
response once and hands back something with a name.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Callable, Optional, Protocol
|
|
20
|
+
|
|
21
|
+
from iotsploit_protocols.errors import ProtocolError
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
NEGATIVE_RESPONSE = 0x7F
|
|
26
|
+
#: requestCorrectlyReceived-ResponsePending. The ECU is working; wait for it.
|
|
27
|
+
NRC_RESPONSE_PENDING = 0x78
|
|
28
|
+
|
|
29
|
+
#: Services used here. Names, so no caller writes a bare byte again.
|
|
30
|
+
SERVICE_DIAGNOSTIC_SESSION_CONTROL = 0x10
|
|
31
|
+
SERVICE_ROUTINE_CONTROL = 0x31
|
|
32
|
+
SERVICE_READ_DATA_BY_IDENTIFIER = 0x22
|
|
33
|
+
SERVICE_SECURITY_ACCESS = 0x27
|
|
34
|
+
SERVICE_TESTER_PRESENT = 0x3E
|
|
35
|
+
|
|
36
|
+
SESSION_DEFAULT = 0x01
|
|
37
|
+
SESSION_PROGRAMMING = 0x02
|
|
38
|
+
SESSION_EXTENDED = 0x03
|
|
39
|
+
|
|
40
|
+
ROUTINE_START = 0x01
|
|
41
|
+
ROUTINE_STOP = 0x02
|
|
42
|
+
ROUTINE_REQUEST_RESULTS = 0x03
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Transport(Protocol):
|
|
46
|
+
"""Anything that can carry one UDS payload and bring back the answer."""
|
|
47
|
+
|
|
48
|
+
def request(self, payload: bytes) -> bytes: ...
|
|
49
|
+
|
|
50
|
+
def read(self) -> bytes:
|
|
51
|
+
"""The next response with no new request.
|
|
52
|
+
|
|
53
|
+
Needed for responsePending: after a 0x78 the ECU sends the real answer
|
|
54
|
+
unprompted, so re-sending would put the request on the bus twice --
|
|
55
|
+
which for a routine that does something physical is not a retry, it is
|
|
56
|
+
doing it again.
|
|
57
|
+
"""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class UdsResponse:
|
|
63
|
+
"""One parsed UDS response.
|
|
64
|
+
|
|
65
|
+
``ok`` and ``nrc`` are the whole interface most callers need. A negative
|
|
66
|
+
response is not an exception here: "the ECU said no" is frequently the
|
|
67
|
+
finding being looked for, and raising would make the normal case awkward.
|
|
68
|
+
Callers that prefer an exception can check ``ok`` and raise their own.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
service: int
|
|
72
|
+
data: bytes
|
|
73
|
+
nrc: Optional[int] = None
|
|
74
|
+
raw: bytes = b""
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def ok(self) -> bool:
|
|
78
|
+
return self.nrc is None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def nrc_name(self) -> str:
|
|
82
|
+
if self.nrc is None:
|
|
83
|
+
return ""
|
|
84
|
+
return _nrc_name(self.nrc)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _nrc_name(nrc: int) -> str:
|
|
88
|
+
"""The standard label for a negative response code, via scapy's table."""
|
|
89
|
+
try:
|
|
90
|
+
from scapy.contrib.automotive.uds import UDS_NR
|
|
91
|
+
|
|
92
|
+
name = UDS_NR.fields_desc[1].i2s.get(nrc)
|
|
93
|
+
if name:
|
|
94
|
+
return str(name)
|
|
95
|
+
except Exception: # pragma: no cover - scapy layout changed
|
|
96
|
+
logger.debug("could not resolve NRC name from scapy", exc_info=True)
|
|
97
|
+
return f"0x{nrc:02X}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class UdsClient:
|
|
101
|
+
"""UDS requests over any transport that can carry them.
|
|
102
|
+
|
|
103
|
+
``response_pending_attempts`` bounds how many 0x78 replies are waited
|
|
104
|
+
through. The old code had this handling commented out, which meant a busy
|
|
105
|
+
ECU -- the normal state during a routine -- was recorded as a failure.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
transport: Transport,
|
|
111
|
+
response_pending_attempts: int = 10,
|
|
112
|
+
response_pending_deadline: float = 30.0,
|
|
113
|
+
) -> None:
|
|
114
|
+
self.transport = transport
|
|
115
|
+
self.response_pending_attempts = response_pending_attempts
|
|
116
|
+
self.response_pending_deadline = response_pending_deadline
|
|
117
|
+
|
|
118
|
+
# ── services ──────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def session(self, kind: int = SESSION_DEFAULT) -> UdsResponse:
|
|
121
|
+
return self.request(bytes([SERVICE_DIAGNOSTIC_SESSION_CONTROL, kind]))
|
|
122
|
+
|
|
123
|
+
def tester_present(self, suppress_response: bool = False) -> UdsResponse:
|
|
124
|
+
sub = 0x80 if suppress_response else 0x00
|
|
125
|
+
return self.request(bytes([SERVICE_TESTER_PRESENT, sub]))
|
|
126
|
+
|
|
127
|
+
def read_did(self, did: int) -> UdsResponse:
|
|
128
|
+
return self.request(bytes([SERVICE_READ_DATA_BY_IDENTIFIER]) + did.to_bytes(2, "big"))
|
|
129
|
+
|
|
130
|
+
def routine(self, control: int, routine_id: int, data: bytes = b"") -> UdsResponse:
|
|
131
|
+
return self.request(
|
|
132
|
+
bytes([SERVICE_ROUTINE_CONTROL, control]) + routine_id.to_bytes(2, "big") + data
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def is_alive(self) -> bool:
|
|
136
|
+
"""Whether the ECU answers at all.
|
|
137
|
+
|
|
138
|
+
A negative response still counts: it proves something is there and
|
|
139
|
+
speaking UDS, which is what "alive" means here.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
self.session(SESSION_DEFAULT)
|
|
143
|
+
return True
|
|
144
|
+
except (ProtocolError, TimeoutError, OSError):
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
def security_access(
|
|
148
|
+
self,
|
|
149
|
+
level: int,
|
|
150
|
+
pin: bytes,
|
|
151
|
+
key_fn: Callable[[bytes, bytes], bytes],
|
|
152
|
+
) -> bool:
|
|
153
|
+
"""Request a seed, derive the key with ``key_fn``, send it back.
|
|
154
|
+
|
|
155
|
+
``key_fn`` is passed in rather than looked up by name. The derivation is
|
|
156
|
+
one manufacturer's proprietary algorithm; a library that shipped a
|
|
157
|
+
registry of them would be a library that ships them.
|
|
158
|
+
|
|
159
|
+
``level`` is the requestSeed sub-function; the sendKey sub-function is
|
|
160
|
+
``level + 1``, as ISO 14229 defines.
|
|
161
|
+
"""
|
|
162
|
+
seed_response = self.request(bytes([SERVICE_SECURITY_ACCESS, level]))
|
|
163
|
+
if not seed_response.ok:
|
|
164
|
+
logger.error("security access: seed refused (%s)", seed_response.nrc_name)
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
# The response is 67 <echoed sub-function> <seed...>. The echo is not
|
|
168
|
+
# part of the seed, and feeding it to the derivation yields a key the
|
|
169
|
+
# ECU will reject -- which looks exactly like a wrong PIN.
|
|
170
|
+
if len(seed_response.data) < 2:
|
|
171
|
+
raise ProtocolError("security access: ECU returned an empty seed")
|
|
172
|
+
echoed, seed = seed_response.data[0], seed_response.data[1:]
|
|
173
|
+
if echoed != level:
|
|
174
|
+
raise ProtocolError(
|
|
175
|
+
f"security access: ECU echoed sub-function 0x{echoed:02X}, "
|
|
176
|
+
f"expected 0x{level:02X}"
|
|
177
|
+
)
|
|
178
|
+
if not seed:
|
|
179
|
+
raise ProtocolError("security access: ECU returned an empty seed")
|
|
180
|
+
if not any(seed):
|
|
181
|
+
# An all-zero seed means the ECU considers this level already open.
|
|
182
|
+
logger.info("security access: level 0x%02X already unlocked", level)
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
key = key_fn(seed, pin)
|
|
186
|
+
key_response = self.request(bytes([SERVICE_SECURITY_ACCESS, level + 1]) + key)
|
|
187
|
+
if not key_response.ok:
|
|
188
|
+
logger.error("security access: key rejected (%s)", key_response.nrc_name)
|
|
189
|
+
return False
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
# ── the one place a response is parsed ────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def request(self, payload: bytes) -> UdsResponse:
|
|
195
|
+
"""Send a UDS payload, waiting through any responsePending replies."""
|
|
196
|
+
if not payload:
|
|
197
|
+
raise ValueError("UDS payload must not be empty")
|
|
198
|
+
service = payload[0]
|
|
199
|
+
deadline = time.monotonic() + self.response_pending_deadline
|
|
200
|
+
|
|
201
|
+
raw = self.transport.request(payload)
|
|
202
|
+
|
|
203
|
+
for attempt in range(1, self.response_pending_attempts + 1):
|
|
204
|
+
response = self._parse(service, raw)
|
|
205
|
+
if response.nrc != NRC_RESPONSE_PENDING:
|
|
206
|
+
return response
|
|
207
|
+
if time.monotonic() >= deadline:
|
|
208
|
+
raise ProtocolError(
|
|
209
|
+
f"UDS service 0x{service:02X} still pending after "
|
|
210
|
+
f"{self.response_pending_deadline}s"
|
|
211
|
+
)
|
|
212
|
+
logger.debug("UDS service 0x%02X response pending (%d)", service, attempt)
|
|
213
|
+
# Read, do not re-send: the ECU is already working on it.
|
|
214
|
+
raw = self.transport.read()
|
|
215
|
+
|
|
216
|
+
raise ProtocolError(
|
|
217
|
+
f"UDS service 0x{service:02X} stayed pending for "
|
|
218
|
+
f"{self.response_pending_attempts} attempts"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _parse(service: int, raw: bytes) -> UdsResponse:
|
|
223
|
+
if not raw:
|
|
224
|
+
raise ProtocolError(f"UDS service 0x{service:02X}: empty response")
|
|
225
|
+
|
|
226
|
+
if raw[0] == NEGATIVE_RESPONSE:
|
|
227
|
+
if len(raw) < 3:
|
|
228
|
+
raise ProtocolError(f"UDS negative response truncated: {raw.hex()}")
|
|
229
|
+
echoed, nrc = raw[1], raw[2]
|
|
230
|
+
if echoed != service:
|
|
231
|
+
raise ProtocolError(
|
|
232
|
+
f"UDS negative response echoes service 0x{echoed:02X}, "
|
|
233
|
+
f"expected 0x{service:02X}"
|
|
234
|
+
)
|
|
235
|
+
return UdsResponse(service=service, data=b"", nrc=nrc, raw=raw)
|
|
236
|
+
|
|
237
|
+
expected = service + 0x40
|
|
238
|
+
if raw[0] != expected:
|
|
239
|
+
raise ProtocolError(
|
|
240
|
+
f"UDS response starts 0x{raw[0]:02X}, expected 0x{expected:02X} "
|
|
241
|
+
f"for service 0x{service:02X}"
|
|
242
|
+
)
|
|
243
|
+
return UdsResponse(service=service, data=raw[1:], nrc=None, raw=raw)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class DoipUdsClient(UdsClient):
|
|
247
|
+
"""A :class:`UdsClient` bound to a :class:`~.client.DoipClient`.
|
|
248
|
+
|
|
249
|
+
Exists only so callers do not have to write ``UdsClient(DoipClient(cfg))``
|
|
250
|
+
and remember which one is the context manager.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def __init__(self, doip_client, **kwargs) -> None:
|
|
254
|
+
super().__init__(doip_client, **kwargs)
|
|
255
|
+
self.doip = doip_client
|
|
256
|
+
|
|
257
|
+
def __enter__(self) -> "DoipUdsClient":
|
|
258
|
+
self.doip.connect()
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
262
|
+
self.doip.close()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Errors a protocol client raises.
|
|
2
|
+
|
|
3
|
+
Three types, not a hierarchy for its own sake. Each one exists because a caller
|
|
4
|
+
can do something different about it:
|
|
5
|
+
|
|
6
|
+
``NotConfigured`` the target is missing settings -- fix the target, not the code.
|
|
7
|
+
``NegativeResponse`` the peer answered and said no -- the scan worked, the ECU refused.
|
|
8
|
+
``ProtocolError`` everything else that is ours.
|
|
9
|
+
|
|
10
|
+
Transport failures are *not* wrapped. A connection refused is ``ConnectionError``
|
|
11
|
+
and a read timeout is ``TimeoutError``, both stdlib: re-spelling them in a new
|
|
12
|
+
namespace buys nothing and forces callers to catch two names for one condition.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProtocolError(Exception):
|
|
21
|
+
"""Base class for protocol-level failures."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotConfigured(ProtocolError):
|
|
25
|
+
"""Required configuration is absent.
|
|
26
|
+
|
|
27
|
+
Raised instead of falling back to a built-in address. A helper that guesses
|
|
28
|
+
a host silently probes the wrong device and reports its silence as a
|
|
29
|
+
finding, which is worse than not running.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NegativeResponse(ProtocolError):
|
|
34
|
+
"""The peer replied, and the reply says the request was refused.
|
|
35
|
+
|
|
36
|
+
Carries the raw code so a caller can branch on it. ``code`` is the SOME/IP
|
|
37
|
+
return code or the UDS NRC depending on who raised it; ``name`` is the
|
|
38
|
+
protocol's own label for that code when one is known.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, code: int, name: Optional[str] = None, message: str = "") -> None:
|
|
42
|
+
self.code = code
|
|
43
|
+
self.name = name
|
|
44
|
+
label = name or f"0x{code:02X}"
|
|
45
|
+
super().__init__(message or f"negative response: {label}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""SOME/IP: call a method on an ECU, and hear what a segment offers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"FACET_KEY",
|
|
7
|
+
"SdConfig",
|
|
8
|
+
"ServiceDiscovery",
|
|
9
|
+
"ServiceOffer",
|
|
10
|
+
"SomeIpClient",
|
|
11
|
+
"SomeIpConfig",
|
|
12
|
+
"SomeIpResponse",
|
|
13
|
+
"SomeipFacet",
|
|
14
|
+
"canonical_service_id",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
from iotsploit_protocols.someip.client import SomeIpClient, SomeIpConfig, SomeIpResponse
|
|
18
|
+
from iotsploit_protocols.someip.facet import FACET_KEY, SomeipFacet, canonical_service_id
|
|
19
|
+
from iotsploit_protocols.someip.sd import SdConfig, ServiceDiscovery, ServiceOffer
|