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,322 @@
|
|
|
1
|
+
"""A SOME/IP client: build a request, send it, match the response.
|
|
2
|
+
|
|
3
|
+
scapy is used as a **codec, not as a socket**. Its sending paths want root and a
|
|
4
|
+
live interface, which would make calling a method a privileged operation for no
|
|
5
|
+
reason; the wire format is the hard part and that is what scapy is for. So this
|
|
6
|
+
module builds and parses with ``scapy.contrib.automotive.someip`` and does I/O
|
|
7
|
+
on an ordinary socket.
|
|
8
|
+
|
|
9
|
+
Framing is done here rather than delegated, because scapy will not do it: given
|
|
10
|
+
two concatenated SOME/IP messages, ``SOMEIP(raw)`` returns one packet whose
|
|
11
|
+
payload is *both*. Over TCP, where one ``recv`` is not one message, that is the
|
|
12
|
+
same class of bug as reading a fixed 13 bytes and hoping. The length field is
|
|
13
|
+
authoritative and this module reads it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import socket
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from iotsploit_protocols.errors import NotConfigured, ProtocolError
|
|
24
|
+
from iotsploit_protocols.someip.codec import application_payload
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
#: Bytes before the payload: srv_id, sub_id, len, client_id, session_id,
|
|
29
|
+
#: proto_ver, iface_ver, msg_type, retcode.
|
|
30
|
+
HEADER_LEN = 16
|
|
31
|
+
#: srv_id(2) + sub_id(2) + len(4). Everything after this is covered by ``len``.
|
|
32
|
+
LEN_PREFIX = 8
|
|
33
|
+
|
|
34
|
+
TYPE_REQUEST = 0x00
|
|
35
|
+
TYPE_REQUEST_NO_RETURN = 0x01
|
|
36
|
+
TYPE_RESPONSE = 0x80
|
|
37
|
+
TYPE_ERROR = 0x81
|
|
38
|
+
|
|
39
|
+
RETURN_CODE_NAMES = {
|
|
40
|
+
0x00: "E_OK",
|
|
41
|
+
0x01: "E_NOT_OK",
|
|
42
|
+
0x02: "E_UNKNOWN_SERVICE",
|
|
43
|
+
0x03: "E_UNKNOWN_METHOD",
|
|
44
|
+
0x04: "E_NOT_READY",
|
|
45
|
+
0x05: "E_NOT_REACHABLE",
|
|
46
|
+
0x06: "E_TIMEOUT",
|
|
47
|
+
0x07: "E_WRONG_PROTOCOL_VERSION",
|
|
48
|
+
0x08: "E_WRONG_INTERFACE_VERSION",
|
|
49
|
+
0x09: "E_MALFORMED_MESSAGE",
|
|
50
|
+
0x0A: "E_WRONG_MESSAGE_TYPE",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class SomeIpConfig:
|
|
56
|
+
"""Where to send SOME/IP, and as whom.
|
|
57
|
+
|
|
58
|
+
``host`` has no default on purpose. A helper that falls back to a built-in
|
|
59
|
+
address probes whatever happens to be at that address and reports its
|
|
60
|
+
silence as a finding. An unconfigured target must fail, loudly, here.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
host: str
|
|
64
|
+
port: int
|
|
65
|
+
transport: str = "tcp"
|
|
66
|
+
client_id: int = 0x0001
|
|
67
|
+
timeout: float = 5.0
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
if not self.host:
|
|
71
|
+
raise NotConfigured("SOME/IP host is required; there is no default")
|
|
72
|
+
if not 0 < self.port < 65536:
|
|
73
|
+
raise NotConfigured(f"SOME/IP port {self.port!r} is out of range")
|
|
74
|
+
if self.transport not in ("tcp", "udp"):
|
|
75
|
+
raise NotConfigured(f"transport must be 'tcp' or 'udp', got {self.transport!r}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class SomeIpResponse:
|
|
80
|
+
"""One reply, already parsed.
|
|
81
|
+
|
|
82
|
+
``ok`` is the only thing most callers need. It is deliberately not "did we
|
|
83
|
+
get bytes back": an ERROR message with E_UNKNOWN_METHOD is a perfectly
|
|
84
|
+
well-formed reply that means no.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
service_id: int
|
|
88
|
+
method_id: int
|
|
89
|
+
client_id: int
|
|
90
|
+
session_id: int
|
|
91
|
+
message_type: int
|
|
92
|
+
return_code: int
|
|
93
|
+
payload: bytes
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def ok(self) -> bool:
|
|
97
|
+
return self.message_type == TYPE_RESPONSE and self.return_code == 0x00
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def return_code_name(self) -> str:
|
|
101
|
+
return RETURN_CODE_NAMES.get(self.return_code, f"0x{self.return_code:02X}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SomeIpClient:
|
|
105
|
+
"""One client is one socket to one endpoint.
|
|
106
|
+
|
|
107
|
+
Not a singleton and not shared: two endpoints means two clients. Use it as a
|
|
108
|
+
context manager so the socket is closed even when a call raises.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, config: SomeIpConfig) -> None:
|
|
112
|
+
self.config = config
|
|
113
|
+
self._sock: Optional[socket.socket] = None
|
|
114
|
+
# Wraps at 16 bits like the field it fills. Starts at 1 because 0 is
|
|
115
|
+
# conventionally "session handling disabled".
|
|
116
|
+
self._session_id = 0
|
|
117
|
+
|
|
118
|
+
# ── lifecycle ─────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> "SomeIpClient":
|
|
121
|
+
self.connect()
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
125
|
+
self.close()
|
|
126
|
+
|
|
127
|
+
def connect(self) -> None:
|
|
128
|
+
if self._sock is not None:
|
|
129
|
+
return
|
|
130
|
+
kind = socket.SOCK_STREAM if self.config.transport == "tcp" else socket.SOCK_DGRAM
|
|
131
|
+
sock = socket.socket(socket.AF_INET, kind)
|
|
132
|
+
sock.settimeout(self.config.timeout)
|
|
133
|
+
# connect() on a UDP socket sets the default peer; it does not handshake,
|
|
134
|
+
# so this is cheap and lets send()/recv() be used for both transports.
|
|
135
|
+
sock.connect((self.config.host, self.config.port))
|
|
136
|
+
self._sock = sock
|
|
137
|
+
logger.debug(
|
|
138
|
+
"SOME/IP connected %s://%s:%d", self.config.transport, self.config.host, self.config.port
|
|
139
|
+
)
|
|
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("SOME/IP socket close failed", exc_info=True)
|
|
148
|
+
finally:
|
|
149
|
+
self._sock = None
|
|
150
|
+
|
|
151
|
+
# ── requests ──────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def call(
|
|
154
|
+
self,
|
|
155
|
+
service: int,
|
|
156
|
+
instance: int,
|
|
157
|
+
method: int,
|
|
158
|
+
payload: bytes = b"",
|
|
159
|
+
interface_version: int = 0x01,
|
|
160
|
+
) -> SomeIpResponse:
|
|
161
|
+
"""Send a request and wait for the matching response.
|
|
162
|
+
|
|
163
|
+
``instance`` is accepted because callers think in service instances, but
|
|
164
|
+
it is not on the wire: SOME/IP addresses an instance by *endpoint*, and
|
|
165
|
+
the endpoint is what this client is connected to. It is used for logging
|
|
166
|
+
and to keep call sites honest about which instance they meant.
|
|
167
|
+
"""
|
|
168
|
+
session_id = self._next_session_id()
|
|
169
|
+
request = self._build(
|
|
170
|
+
service, method, session_id, TYPE_REQUEST, payload, interface_version
|
|
171
|
+
)
|
|
172
|
+
logger.debug(
|
|
173
|
+
"SOME/IP -> service=%04X instance=%04X method=%04X session=%d len=%d",
|
|
174
|
+
service, instance, method, session_id, len(payload),
|
|
175
|
+
)
|
|
176
|
+
self._send(request)
|
|
177
|
+
return self._await_response(service, method, session_id)
|
|
178
|
+
|
|
179
|
+
def notify(
|
|
180
|
+
self,
|
|
181
|
+
service: int,
|
|
182
|
+
instance: int,
|
|
183
|
+
method: int,
|
|
184
|
+
payload: bytes = b"",
|
|
185
|
+
interface_version: int = 0x01,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Fire-and-forget. No response is expected and none is waited for."""
|
|
188
|
+
session_id = self._next_session_id()
|
|
189
|
+
logger.debug(
|
|
190
|
+
"SOME/IP -> (no return) service=%04X instance=%04X method=%04X session=%d",
|
|
191
|
+
service, instance, method, session_id,
|
|
192
|
+
)
|
|
193
|
+
self._send(
|
|
194
|
+
self._build(
|
|
195
|
+
service, method, session_id, TYPE_REQUEST_NO_RETURN, payload, interface_version
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# ── internals ─────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def _next_session_id(self) -> int:
|
|
202
|
+
# 1..0xFFFF, skipping 0.
|
|
203
|
+
self._session_id = (self._session_id % 0xFFFF) + 1
|
|
204
|
+
return self._session_id
|
|
205
|
+
|
|
206
|
+
def _build(
|
|
207
|
+
self,
|
|
208
|
+
service: int,
|
|
209
|
+
method: int,
|
|
210
|
+
session_id: int,
|
|
211
|
+
message_type: int,
|
|
212
|
+
payload: bytes,
|
|
213
|
+
interface_version: int,
|
|
214
|
+
) -> bytes:
|
|
215
|
+
from scapy.contrib.automotive.someip import SOMEIP
|
|
216
|
+
from scapy.packet import Raw
|
|
217
|
+
|
|
218
|
+
packet = SOMEIP(
|
|
219
|
+
srv_id=service,
|
|
220
|
+
sub_id=method,
|
|
221
|
+
client_id=self.config.client_id,
|
|
222
|
+
session_id=session_id,
|
|
223
|
+
iface_ver=interface_version,
|
|
224
|
+
msg_type=message_type,
|
|
225
|
+
)
|
|
226
|
+
if payload:
|
|
227
|
+
packet = packet / Raw(payload)
|
|
228
|
+
return bytes(packet)
|
|
229
|
+
|
|
230
|
+
def _require_socket(self) -> socket.socket:
|
|
231
|
+
if self._sock is None:
|
|
232
|
+
raise ProtocolError("SOME/IP client is not connected; use it as a context manager")
|
|
233
|
+
return self._sock
|
|
234
|
+
|
|
235
|
+
def _send(self, data: bytes) -> None:
|
|
236
|
+
self._require_socket().sendall(data)
|
|
237
|
+
|
|
238
|
+
def _await_response(self, service: int, method: int, session_id: int) -> SomeIpResponse:
|
|
239
|
+
"""Read messages until the one we asked for arrives.
|
|
240
|
+
|
|
241
|
+
Responses can interleave -- notably over UDP, where an event from
|
|
242
|
+
another service may land between our request and its answer. Matching on
|
|
243
|
+
(client_id, session_id) is what keeps a reply attached to its request;
|
|
244
|
+
anything else is logged and dropped rather than returned to the caller
|
|
245
|
+
as if it were the answer.
|
|
246
|
+
"""
|
|
247
|
+
while True:
|
|
248
|
+
response = self._parse(self._read_message())
|
|
249
|
+
if response.client_id == self.config.client_id and response.session_id == session_id:
|
|
250
|
+
if response.service_id != service or response.method_id != method:
|
|
251
|
+
# Same session id, different message id: the peer is
|
|
252
|
+
# confused or something is spoofing. Do not silently accept.
|
|
253
|
+
raise ProtocolError(
|
|
254
|
+
f"response session {session_id} carries service "
|
|
255
|
+
f"{response.service_id:04X}/{response.method_id:04X}, "
|
|
256
|
+
f"expected {service:04X}/{method:04X}"
|
|
257
|
+
)
|
|
258
|
+
return response
|
|
259
|
+
logger.debug(
|
|
260
|
+
"SOME/IP dropping unrelated message client=%04X session=%d",
|
|
261
|
+
response.client_id, response.session_id,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _read_message(self) -> bytes:
|
|
265
|
+
"""One whole message, using the length field rather than hoping.
|
|
266
|
+
|
|
267
|
+
UDP delivers a datagram at a time, but a truncated or padded datagram
|
|
268
|
+
still has to agree with its own length field, so both transports are
|
|
269
|
+
validated the same way.
|
|
270
|
+
"""
|
|
271
|
+
sock = self._require_socket()
|
|
272
|
+
if self.config.transport == "udp":
|
|
273
|
+
datagram = sock.recv(65535)
|
|
274
|
+
declared = self._declared_length(datagram)
|
|
275
|
+
if len(datagram) < LEN_PREFIX + declared:
|
|
276
|
+
raise ProtocolError(
|
|
277
|
+
f"SOME/IP datagram is {len(datagram)} bytes but declares "
|
|
278
|
+
f"{LEN_PREFIX + declared}"
|
|
279
|
+
)
|
|
280
|
+
return datagram[: LEN_PREFIX + declared]
|
|
281
|
+
|
|
282
|
+
head = self._read_exactly(LEN_PREFIX)
|
|
283
|
+
declared = self._declared_length(head)
|
|
284
|
+
return head + self._read_exactly(declared)
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _declared_length(data: bytes) -> int:
|
|
288
|
+
if len(data) < LEN_PREFIX:
|
|
289
|
+
raise ProtocolError(f"SOME/IP message truncated: {len(data)} bytes")
|
|
290
|
+
declared = int.from_bytes(data[4:LEN_PREFIX], "big")
|
|
291
|
+
if declared < HEADER_LEN - LEN_PREFIX:
|
|
292
|
+
raise ProtocolError(f"SOME/IP length field {declared} is shorter than the header")
|
|
293
|
+
return declared
|
|
294
|
+
|
|
295
|
+
def _read_exactly(self, count: int) -> bytes:
|
|
296
|
+
sock = self._require_socket()
|
|
297
|
+
chunks = []
|
|
298
|
+
remaining = count
|
|
299
|
+
while remaining > 0:
|
|
300
|
+
chunk = sock.recv(remaining)
|
|
301
|
+
if not chunk:
|
|
302
|
+
raise ProtocolError(
|
|
303
|
+
f"SOME/IP peer closed after {count - remaining} of {count} bytes"
|
|
304
|
+
)
|
|
305
|
+
chunks.append(chunk)
|
|
306
|
+
remaining -= len(chunk)
|
|
307
|
+
return b"".join(chunks)
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _parse(raw: bytes) -> SomeIpResponse:
|
|
311
|
+
from scapy.contrib.automotive.someip import SOMEIP
|
|
312
|
+
|
|
313
|
+
packet = SOMEIP(raw)
|
|
314
|
+
return SomeIpResponse(
|
|
315
|
+
service_id=packet.srv_id,
|
|
316
|
+
method_id=packet.sub_id,
|
|
317
|
+
client_id=packet.client_id,
|
|
318
|
+
session_id=packet.session_id,
|
|
319
|
+
message_type=int(packet.msg_type),
|
|
320
|
+
return_code=int(packet.retcode),
|
|
321
|
+
payload=application_payload(packet),
|
|
322
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Compatibility helpers for Scapy's SOME/IP packet representations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def application_payload(packet) -> bytes:
|
|
7
|
+
"""Return application bytes from Scapy 2.6 and 2.7 SOME/IP packets."""
|
|
8
|
+
data = getattr(packet, "data", None)
|
|
9
|
+
if data is not None:
|
|
10
|
+
return b"".join(bytes(item) for item in data)
|
|
11
|
+
return bytes(packet.payload) if packet.payload else b""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The SOME/IP facet, shipped beside the client that consumes it.
|
|
2
|
+
|
|
3
|
+
Core registers no protocol facets (``iotsploit_core.domain.facet``), so this
|
|
4
|
+
lives with ``SomeIpClient`` rather than in core, exactly like ``doip_facet`` and
|
|
5
|
+
``can_facet``.
|
|
6
|
+
|
|
7
|
+
Three fields, and the omissions are the interesting part.
|
|
8
|
+
|
|
9
|
+
**No host.** A component already carries an address, and ``target.py`` already
|
|
10
|
+
resolves one three different ways -- the wart that facets exist to remove. A
|
|
11
|
+
fourth place to write an address would not add capability, it would add a
|
|
12
|
+
"which one wins" question. Address resolution lives in exactly one function, in
|
|
13
|
+
the Django binding layer.
|
|
14
|
+
|
|
15
|
+
**No service catalogue.** A list of services and their methods is a *vendor
|
|
16
|
+
description* (ARXML/FIBEX), and per ``can_facet``'s docstring that belongs in
|
|
17
|
+
the reference catalog, not in a JSON column that gets rewritten on every target
|
|
18
|
+
save. The same argument that keeps a 2000-signal DBC out of ``CanFacet`` keeps
|
|
19
|
+
a service catalogue out of this one.
|
|
20
|
+
|
|
21
|
+
**No SD multicast group.** That describes an Ethernet segment, not a component:
|
|
22
|
+
every component on the segment repeats the same value. It belongs on a ``Bus``
|
|
23
|
+
once buses can carry facets. Until then it is a plugin parameter, which is
|
|
24
|
+
honest about being segment-wide rather than pretending to be per-component.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from pydantic import Field
|
|
32
|
+
|
|
33
|
+
from iotsploit_core.domain.facet import Facet, register_facet
|
|
34
|
+
|
|
35
|
+
FACET_KEY = "someip"
|
|
36
|
+
|
|
37
|
+
# Same convention as the DoIP and CAN facets: the number is stored as an int so
|
|
38
|
+
# "0x1234", "1234" and 4660 cannot become three different keys, and the facet --
|
|
39
|
+
# not core, and not the UI -- declares that humans read it as hex.
|
|
40
|
+
HEX = {"format": "hex"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register_facet(FACET_KEY)
|
|
44
|
+
class SomeipFacet(Facet):
|
|
45
|
+
"""How to reach one component's SOME/IP endpoint.
|
|
46
|
+
|
|
47
|
+
``client_id`` identifies *this tester* to the ECU. It is configuration
|
|
48
|
+
rather than a constant because two testers on one bus must not share one,
|
|
49
|
+
and a collision shows up as responses being delivered to the wrong client.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
port: Optional[int] = None
|
|
53
|
+
# "tcp" or "udp". Spelled out rather than a bool, because a third transport
|
|
54
|
+
# is not unthinkable and a bool would have to be renamed to add one.
|
|
55
|
+
transport: str = "tcp"
|
|
56
|
+
client_id: Optional[int] = Field(default=None, json_schema_extra=HEX)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def canonical_service_id(service_id: int, instance_id: int) -> str:
|
|
60
|
+
"""The string form of a service instance, as an observation's ``subject_id``.
|
|
61
|
+
|
|
62
|
+
Fixed here rather than at each call site for the reason ``canonical_frame_id``
|
|
63
|
+
gives in ``can_facet``: reconciliation joins on this string, and a mismatch
|
|
64
|
+
does not fail loudly -- it silently matches nothing, which reads as "the
|
|
65
|
+
catalog knows nothing about this service".
|
|
66
|
+
|
|
67
|
+
Uppercase hex, four digits each, service first:
|
|
68
|
+
``canonical_service_id(0x1234, 1) == "1234:0001"``. That is the form
|
|
69
|
+
``docs/target_data_model_plan.md`` uses for SOME/IP subject ids.
|
|
70
|
+
"""
|
|
71
|
+
return f"{service_id:04X}:{instance_id:04X}"
|