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,355 @@
|
|
|
1
|
+
"""SOME/IP Service Discovery: what a segment announces, and what a host admits to.
|
|
2
|
+
|
|
3
|
+
Two ways round, because one of them does not always work:
|
|
4
|
+
|
|
5
|
+
``listen()`` is passive. ECUs announce OfferService on the SD multicast group by
|
|
6
|
+
themselves, so it enumerates a bus without putting anything on it. It is the
|
|
7
|
+
right tool on a bench cabled to the segment.
|
|
8
|
+
|
|
9
|
+
``find()`` is active: it sends a FindService and reads the OfferService replies.
|
|
10
|
+
It exists because multicast does not always reach you. Across a router or a VPN
|
|
11
|
+
the group is usually not forwarded, so a passive listen sits silent and reports
|
|
12
|
+
"no services" about a bus full of them -- a wrong answer rather than an error.
|
|
13
|
+
Sent to one host, ``find()`` needs no multicast at all.
|
|
14
|
+
|
|
15
|
+
No root is needed either way. Joining a multicast group is an unprivileged
|
|
16
|
+
socket option; it is *raw* sockets that need privileges, and this uses none.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ipaddress
|
|
22
|
+
import logging
|
|
23
|
+
import socket
|
|
24
|
+
import struct
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
from iotsploit_protocols.errors import NotConfigured
|
|
30
|
+
from iotsploit_protocols.someip.codec import application_payload
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
#: The SD port assigned by the SOME/IP specification. A standard number, not a
|
|
35
|
+
#: description of any particular vehicle, so it may carry a default.
|
|
36
|
+
DEFAULT_SD_PORT = 30490
|
|
37
|
+
|
|
38
|
+
#: The multicast group the specification names for service discovery. Also a
|
|
39
|
+
#: constant of the protocol rather than of a vehicle -- and unlike a host
|
|
40
|
+
#: address, a wrong group discovers nothing rather than probing a stranger.
|
|
41
|
+
DEFAULT_SD_GROUP = "224.244.224.245"
|
|
42
|
+
|
|
43
|
+
#: SD entry type for OfferService. FindService (0x00) shares the same layout.
|
|
44
|
+
ENTRY_TYPE_OFFER = 0x01
|
|
45
|
+
|
|
46
|
+
#: SD option types carrying a reachable endpoint.
|
|
47
|
+
OPTION_IP4_ENDPOINT = 0x04
|
|
48
|
+
|
|
49
|
+
L4_PROTO_NAMES = {6: "tcp", 17: "udp"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SdConfig:
|
|
54
|
+
"""Which segment to listen on.
|
|
55
|
+
|
|
56
|
+
``group`` and ``interface`` describe an Ethernet segment rather than a
|
|
57
|
+
component -- every ECU on the segment shares them -- so they are supplied by
|
|
58
|
+
the caller rather than read from any one component's facet.
|
|
59
|
+
|
|
60
|
+
The two fields are easy to confuse, and the kernel is no help: joining a
|
|
61
|
+
group at a unicast address fails with a bare ``EINVAL``. So the confusion is
|
|
62
|
+
diagnosed here, where the two values are side by side and the message can
|
|
63
|
+
say which one was probably meant.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
group: str
|
|
67
|
+
interface: Optional[str] = None
|
|
68
|
+
port: int = DEFAULT_SD_PORT
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
if not self.group:
|
|
72
|
+
raise NotConfigured("SD multicast group is required; there is no default")
|
|
73
|
+
if not 0 < self.port < 65536:
|
|
74
|
+
raise NotConfigured(f"SD port {self.port!r} is out of range")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
address = ipaddress.ip_address(self.group)
|
|
78
|
+
except ValueError:
|
|
79
|
+
raise NotConfigured(
|
|
80
|
+
f"SD group {self.group!r} is not an IP address. Service discovery "
|
|
81
|
+
f"listens on an IPv4 multicast group such as {DEFAULT_SD_GROUP}."
|
|
82
|
+
) from None
|
|
83
|
+
if not isinstance(address, ipaddress.IPv4Address):
|
|
84
|
+
raise NotConfigured(
|
|
85
|
+
f"SD group {self.group!r} is IPv6; this listener is IPv4 only."
|
|
86
|
+
)
|
|
87
|
+
if not address.is_multicast:
|
|
88
|
+
raise NotConfigured(
|
|
89
|
+
f"SD group {self.group!r} is a unicast address, and a multicast "
|
|
90
|
+
f"group must be in 224.0.0.0/4 (the SOME/IP default is "
|
|
91
|
+
f"{DEFAULT_SD_GROUP}). If {self.group!r} is the local address of "
|
|
92
|
+
f"the interface you want to listen on, pass it as 'interface' "
|
|
93
|
+
f"instead of 'sd_group'."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class ServiceOffer:
|
|
99
|
+
"""One announced service instance.
|
|
100
|
+
|
|
101
|
+
``endpoints`` are plain tuples of ``(ip, port, "tcp"|"udp")`` so a caller
|
|
102
|
+
never has to import scapy to read a result.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
service_id: int
|
|
106
|
+
instance_id: int
|
|
107
|
+
major_version: int
|
|
108
|
+
minor_version: int
|
|
109
|
+
ttl: int
|
|
110
|
+
endpoints: List[Tuple[str, int, str]] = field(default_factory=list)
|
|
111
|
+
source: Optional[str] = None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def canonical_id(self) -> str:
|
|
115
|
+
from iotsploit_protocols.someip.facet import canonical_service_id
|
|
116
|
+
|
|
117
|
+
return canonical_service_id(self.service_id, self.instance_id)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ServiceDiscovery:
|
|
121
|
+
"""Listens on the SD multicast group and reports what it heard."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, config: SdConfig) -> None:
|
|
124
|
+
self.config = config
|
|
125
|
+
|
|
126
|
+
def listen(self, timeout: float = 10.0) -> List[ServiceOffer]:
|
|
127
|
+
"""Collect offers for ``timeout`` seconds, announced by anyone.
|
|
128
|
+
|
|
129
|
+
Returns one entry per distinct (service, instance), latest wins: an ECU
|
|
130
|
+
re-announcing the same service during the window is one service, not
|
|
131
|
+
several. Deduplicating here rather than in the caller keeps the "what is
|
|
132
|
+
on this segment" answer stable regardless of how long you listened.
|
|
133
|
+
"""
|
|
134
|
+
with self._socket(join_group=True) as sock:
|
|
135
|
+
offers = self._collect(sock, timeout)
|
|
136
|
+
logger.info("SD heard %d service instance(s) on %s", len(offers), self.config.group)
|
|
137
|
+
return offers
|
|
138
|
+
|
|
139
|
+
def find(
|
|
140
|
+
self,
|
|
141
|
+
host: Optional[str] = None,
|
|
142
|
+
timeout: float = 3.0,
|
|
143
|
+
service_id: int = 0xFFFF,
|
|
144
|
+
instance_id: int = 0xFFFF,
|
|
145
|
+
) -> List[ServiceOffer]:
|
|
146
|
+
"""Ask, rather than wait. Returns the OfferService replies.
|
|
147
|
+
|
|
148
|
+
``host`` unset sends the FindService to the multicast group; set, it goes
|
|
149
|
+
to that one host and no multicast is involved at any point, which is what
|
|
150
|
+
makes this work across a router or a VPN.
|
|
151
|
+
|
|
152
|
+
The defaults are the specification's wildcards -- any service, any
|
|
153
|
+
instance -- so the bare call is "tell me everything you offer".
|
|
154
|
+
"""
|
|
155
|
+
destination = host or self.config.group
|
|
156
|
+
with self._socket(join_group=host is None) as sock:
|
|
157
|
+
datagram = self._find_datagram(service_id, instance_id, ttl=int(timeout) + 1)
|
|
158
|
+
logger.debug("SD FindService -> %s:%d", destination, self.config.port)
|
|
159
|
+
try:
|
|
160
|
+
sock.sendto(datagram, (destination, self.config.port))
|
|
161
|
+
except OSError as exc:
|
|
162
|
+
raise NotConfigured(
|
|
163
|
+
f"cannot send FindService to {destination}:{self.config.port}: "
|
|
164
|
+
f"{exc.strerror or exc}"
|
|
165
|
+
) from None
|
|
166
|
+
offers = self._collect(sock, timeout)
|
|
167
|
+
logger.info("SD FindService to %s answered with %d instance(s)", destination, len(offers))
|
|
168
|
+
return offers
|
|
169
|
+
|
|
170
|
+
# ── internals ─────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def _collect(self, sock: socket.socket, timeout: float) -> List[ServiceOffer]:
|
|
173
|
+
"""Read offers until ``timeout`` expires, deduplicated by (service, instance)."""
|
|
174
|
+
deadline = time.monotonic() + timeout
|
|
175
|
+
offers: dict[tuple[int, int], ServiceOffer] = {}
|
|
176
|
+
while True:
|
|
177
|
+
remaining = deadline - time.monotonic()
|
|
178
|
+
if remaining <= 0:
|
|
179
|
+
break
|
|
180
|
+
sock.settimeout(remaining)
|
|
181
|
+
try:
|
|
182
|
+
datagram, sender = sock.recvfrom(65535)
|
|
183
|
+
except (TimeoutError, socket.timeout):
|
|
184
|
+
break
|
|
185
|
+
except OSError:
|
|
186
|
+
logger.debug("SD receive failed", exc_info=True)
|
|
187
|
+
break
|
|
188
|
+
for offer in self._parse(datagram, sender[0]):
|
|
189
|
+
offers[(offer.service_id, offer.instance_id)] = offer
|
|
190
|
+
return list(offers.values())
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _find_datagram(service_id: int, instance_id: int, ttl: int) -> bytes:
|
|
194
|
+
from scapy.contrib.automotive.someip import SD, SDEntry_Service, SOMEIP
|
|
195
|
+
|
|
196
|
+
entry = SDEntry_Service(
|
|
197
|
+
type=0x00, # FindService
|
|
198
|
+
srv_id=service_id,
|
|
199
|
+
inst_id=instance_id,
|
|
200
|
+
# 0xFF / 0xFFFFFFFF are the spec's "any version" wildcards.
|
|
201
|
+
major_ver=0xFF,
|
|
202
|
+
minor_ver=0xFFFFFFFF,
|
|
203
|
+
ttl=ttl,
|
|
204
|
+
n_opt_1=0,
|
|
205
|
+
n_opt_2=0,
|
|
206
|
+
)
|
|
207
|
+
# UNICAST says we accept unicast replies, which is the entire point when
|
|
208
|
+
# the request did not go to a group.
|
|
209
|
+
return bytes(SOMEIP() / SD(flags="UNICAST", entry_array=[entry]))
|
|
210
|
+
|
|
211
|
+
def _socket(self, join_group: bool) -> socket.socket:
|
|
212
|
+
"""A UDP socket bound to the SD port, optionally joined to the group.
|
|
213
|
+
|
|
214
|
+
Binding to the SD port matters even for a unicast find: the ECU replies
|
|
215
|
+
to the port it was asked from, so an ephemeral source port would send
|
|
216
|
+
the answer somewhere nobody is listening.
|
|
217
|
+
"""
|
|
218
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
219
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
220
|
+
sock.bind(("", self.config.port))
|
|
221
|
+
if not join_group:
|
|
222
|
+
return sock
|
|
223
|
+
interface = self._interface_address()
|
|
224
|
+
try:
|
|
225
|
+
# Egress selection for anything we send to the group. Without it the
|
|
226
|
+
# kernel picks by route, which on a multi-NIC host is a different
|
|
227
|
+
# NIC from the one we joined on.
|
|
228
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, interface)
|
|
229
|
+
sock.setsockopt(
|
|
230
|
+
socket.IPPROTO_IP,
|
|
231
|
+
socket.IP_ADD_MEMBERSHIP,
|
|
232
|
+
socket.inet_aton(self.config.group) + interface,
|
|
233
|
+
)
|
|
234
|
+
except OSError as exc:
|
|
235
|
+
sock.close()
|
|
236
|
+
# ENODEV/EADDRNOTAVAIL here mean the interface is not one of this
|
|
237
|
+
# host's own. The errno alone sends people looking at the ECU, so
|
|
238
|
+
# say what was wrong and what this host actually has.
|
|
239
|
+
raise NotConfigured(
|
|
240
|
+
f"cannot join {self.config.group} on interface "
|
|
241
|
+
f"{self.config.interface!r}: {exc.strerror or exc}. "
|
|
242
|
+
f"'interface' must name a NIC on this host, or carry that NIC's "
|
|
243
|
+
f"own address. Available here: {_local_interfaces() or 'unknown'}"
|
|
244
|
+
) from None
|
|
245
|
+
return sock
|
|
246
|
+
|
|
247
|
+
def _interface_address(self) -> bytes:
|
|
248
|
+
"""Which local interface joins the group.
|
|
249
|
+
|
|
250
|
+
INADDR_ANY lets the kernel choose, which is wrong on a bench with
|
|
251
|
+
several NICs -- the join silently lands on the wrong one and the listen
|
|
252
|
+
returns nothing, looking exactly like "no services on this bus".
|
|
253
|
+
"""
|
|
254
|
+
if not self.config.interface:
|
|
255
|
+
logger.warning(
|
|
256
|
+
"SD joining %s on the default interface. On a host with several "
|
|
257
|
+
"NICs the kernel's choice is often the wrong segment, and the "
|
|
258
|
+
"result is an empty sweep that reads as 'no services'. Pass an "
|
|
259
|
+
"interface name or local IP to be sure.",
|
|
260
|
+
self.config.group,
|
|
261
|
+
)
|
|
262
|
+
return struct.pack("=I", socket.INADDR_ANY)
|
|
263
|
+
try:
|
|
264
|
+
return socket.inet_aton(socket.gethostbyname(self.config.interface))
|
|
265
|
+
except OSError:
|
|
266
|
+
pass
|
|
267
|
+
address = _interface_ipv4(self.config.interface)
|
|
268
|
+
if address is None:
|
|
269
|
+
raise NotConfigured(
|
|
270
|
+
f"interface {self.config.interface!r} has no IPv4 address to join the group with"
|
|
271
|
+
)
|
|
272
|
+
return socket.inet_aton(address)
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _parse(datagram: bytes, sender: str) -> List[ServiceOffer]:
|
|
276
|
+
from scapy.contrib.automotive.someip import SD, SOMEIP
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
packet = SOMEIP(datagram)
|
|
280
|
+
payload = application_payload(packet)
|
|
281
|
+
if not payload:
|
|
282
|
+
return []
|
|
283
|
+
sd = SD(payload)
|
|
284
|
+
except Exception:
|
|
285
|
+
logger.debug("SD datagram from %s did not parse", sender, exc_info=True)
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
options = list(getattr(sd, "option_array", []) or [])
|
|
289
|
+
offers = []
|
|
290
|
+
for entry in getattr(sd, "entry_array", []) or []:
|
|
291
|
+
if int(getattr(entry, "type", -1)) != ENTRY_TYPE_OFFER:
|
|
292
|
+
continue
|
|
293
|
+
ttl = int(getattr(entry, "ttl", 0))
|
|
294
|
+
if ttl == 0:
|
|
295
|
+
# TTL 0 is StopOffer: the service is going away, not appearing.
|
|
296
|
+
continue
|
|
297
|
+
offers.append(
|
|
298
|
+
ServiceOffer(
|
|
299
|
+
service_id=int(entry.srv_id),
|
|
300
|
+
instance_id=int(entry.inst_id),
|
|
301
|
+
major_version=int(getattr(entry, "major_ver", 0)),
|
|
302
|
+
minor_version=int(getattr(entry, "minor_ver", 0)),
|
|
303
|
+
ttl=ttl,
|
|
304
|
+
endpoints=_entry_endpoints(entry, options),
|
|
305
|
+
source=sender,
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
return offers
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _entry_endpoints(entry, options) -> List[Tuple[str, int, str]]:
|
|
312
|
+
"""The endpoint options an entry points at.
|
|
313
|
+
|
|
314
|
+
An SD entry does not carry its endpoint; it carries two (index, count) runs
|
|
315
|
+
into the message-wide option array. Resolving that here is why callers get
|
|
316
|
+
an address instead of a pair of integers.
|
|
317
|
+
"""
|
|
318
|
+
endpoints = []
|
|
319
|
+
runs = (
|
|
320
|
+
(int(getattr(entry, "index_1", 0)), int(getattr(entry, "n_opt_1", 0))),
|
|
321
|
+
(int(getattr(entry, "index_2", 0)), int(getattr(entry, "n_opt_2", 0))),
|
|
322
|
+
)
|
|
323
|
+
for index, count in runs:
|
|
324
|
+
for option in options[index : index + count]:
|
|
325
|
+
if int(getattr(option, "type", -1)) != OPTION_IP4_ENDPOINT:
|
|
326
|
+
continue
|
|
327
|
+
proto = L4_PROTO_NAMES.get(int(getattr(option, "l4_proto", 0)), "unknown")
|
|
328
|
+
endpoints.append((str(option.addr), int(option.port), proto))
|
|
329
|
+
return endpoints
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _local_interfaces() -> str:
|
|
333
|
+
"""``name=address`` for every IPv4 NIC on this host, for error messages."""
|
|
334
|
+
import psutil
|
|
335
|
+
|
|
336
|
+
entries = []
|
|
337
|
+
for name, addresses in psutil.net_if_addrs().items():
|
|
338
|
+
for address in addresses:
|
|
339
|
+
if address.family == socket.AF_INET:
|
|
340
|
+
entries.append(f"{name}={address.address}")
|
|
341
|
+
return ", ".join(entries)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _interface_ipv4(name: str) -> Optional[str]:
|
|
345
|
+
"""The IPv4 address of a named NIC, or None.
|
|
346
|
+
|
|
347
|
+
Uses psutil so interface enumeration remains portable.
|
|
348
|
+
"""
|
|
349
|
+
import psutil
|
|
350
|
+
|
|
351
|
+
addresses = psutil.net_if_addrs().get(name, [])
|
|
352
|
+
for address in addresses:
|
|
353
|
+
if address.family == socket.AF_INET:
|
|
354
|
+
return address.address
|
|
355
|
+
return None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iotsploit-protocols
|
|
3
|
+
Version: 0.0.9
|
|
4
|
+
Summary: Automotive protocol clients and description parsers for IoTSploit
|
|
5
|
+
License: GPL-3.0-or-later
|
|
6
|
+
Keywords: someip,doip,uds,automotive,security,testing
|
|
7
|
+
Author: IoTSploit Team
|
|
8
|
+
Author-email: support@iotsploit.org
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Requires-Dist: cantools (==42.0.3)
|
|
23
|
+
Requires-Dist: iotsploit-core
|
|
24
|
+
Requires-Dist: psutil (>=5.9)
|
|
25
|
+
Requires-Dist: pydantic (>=2.0)
|
|
26
|
+
Requires-Dist: python-can (>=4.5)
|
|
27
|
+
Requires-Dist: scapy (>=2.6,<3)
|
|
28
|
+
Project-URL: Homepage, https://www.iotsploit.org/
|
|
29
|
+
Project-URL: Repository, https://github.com/TKXB/iotsploit
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# iotsploit-protocols
|
|
33
|
+
|
|
34
|
+
Automotive protocol clients and description parsers for IoTSploit: SOME/IP,
|
|
35
|
+
DoIP/UDS, and AUTOSAR ARXML.
|
|
36
|
+
|
|
37
|
+
Nothing in this package reads the IoTSploit database, runtime configuration,
|
|
38
|
+
environment variables, or the current target, and nothing runs `sudo` or
|
|
39
|
+
prompts a human. Clients receive explicit config objects and description
|
|
40
|
+
parsers receive caller-selected files. That is what lets them run under
|
|
41
|
+
Celery, under MCP, in a test, or from a plugin — and what keeps one lab bench's
|
|
42
|
+
IP addresses out of a library.
|
|
43
|
+
|
|
44
|
+
scapy is used as a **codec, not as a socket**: packets are built and parsed with
|
|
45
|
+
`scapy.contrib.automotive`, but I/O happens on ordinary sockets, so calling a
|
|
46
|
+
method needs no root.
|
|
47
|
+
|
|
48
|
+
## SOME/IP
|
|
49
|
+
|
|
50
|
+
Call a method:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from iotsploit_protocols.someip import SomeIpClient, SomeIpConfig
|
|
54
|
+
|
|
55
|
+
with SomeIpClient(SomeIpConfig(host="198.18.34.10", port=30509)) as client:
|
|
56
|
+
response = client.call(service=0x1234, instance=0x0001, method=0x0002, payload=b"\x01")
|
|
57
|
+
if response.ok:
|
|
58
|
+
print(response.payload.hex())
|
|
59
|
+
else:
|
|
60
|
+
print("refused:", response.return_code_name)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Fire-and-forget:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
client.notify(service=0x1234, instance=0x0001, method=0x0003, payload=b"\x07")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Hear what a segment announces — passive, no traffic generated, no root:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from iotsploit_protocols.someip import ServiceDiscovery, SdConfig
|
|
73
|
+
|
|
74
|
+
sd = ServiceDiscovery(SdConfig(group="224.244.224.245", interface="eth0"))
|
|
75
|
+
for offer in sd.listen(timeout=10.0):
|
|
76
|
+
print(offer.canonical_id, offer.endpoints) # "1234:0001" [('198.18.34.10', 30509, 'tcp')]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`canonical_id` is the form observations store as `subject_id`, so a discovered
|
|
80
|
+
service joins against the reference catalog without any string munging at the
|
|
81
|
+
call site.
|
|
82
|
+
|
|
83
|
+
## Configuration from a target
|
|
84
|
+
|
|
85
|
+
Inside the IoTSploit app, don't build the config by hand — the Django binding
|
|
86
|
+
layer resolves it from the current target's `someip` facet and component
|
|
87
|
+
address:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from iotsploit_django.adapters.django.protocol_binding import someip_client_for
|
|
91
|
+
|
|
92
|
+
with someip_client_for("TCAM") as client:
|
|
93
|
+
...
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
An unconfigured target raises `NotConfigured` rather than falling back to a
|
|
97
|
+
built-in address, because a helper that guesses a host probes whatever is at
|
|
98
|
+
that address and reports its silence as a finding.
|
|
99
|
+
|
|
100
|
+
## Errors
|
|
101
|
+
|
|
102
|
+
| exception | meaning |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `NotConfigured` | settings are missing — fix the target, not the code |
|
|
105
|
+
| `NegativeResponse` | the peer answered and said no |
|
|
106
|
+
| `ProtocolError` | malformed or unmatchable traffic |
|
|
107
|
+
|
|
108
|
+
Transport failures are left as stdlib `ConnectionError` / `TimeoutError` rather
|
|
109
|
+
than re-spelled in a new namespace.
|
|
110
|
+
|
|
111
|
+
## Tests
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
poetry run pytest iotsploit-protocols/tests
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
No hardware and no network: loopback servers stand in for ECUs.
|
|
118
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
iotsploit_protocols/__init__.py,sha256=KToyVubjOQC36bJf2KRQKhJqlX6lGI_zOQeo10o14ZI,872
|
|
2
|
+
iotsploit_protocols/autosar/__init__.py,sha256=LoVoEQ_B-WQa8ygtgJeMSRA9pR5_5KggQNsoWnnws1g,300
|
|
3
|
+
iotsploit_protocols/autosar/arxml.py,sha256=gSuRDCb0uephE-8s0arnTpAPKf1K0uolFgaL6-SdP1Q,30191
|
|
4
|
+
iotsploit_protocols/canbus/__init__.py,sha256=cDAkv9tn2MOlq3dIsK-1g3xi-s340fmXvlkJkQCCMZE,1804
|
|
5
|
+
iotsploit_protocols/canbus/bus_match.py,sha256=P8QO1_2dzJc01Gp9TtiEjithW0BpphxsiZM57-hjDfU,4119
|
|
6
|
+
iotsploit_protocols/canbus/catalog.py,sha256=UcXes-Hv1E0p--dwwKcpcpmNqKdWLYL0wllA87xgnGw,16540
|
|
7
|
+
iotsploit_protocols/canbus/codec.py,sha256=l2DmZKJ3cj5sKNXkqESz0s3iFGYDIsLFHPQaUXa_lMc,17946
|
|
8
|
+
iotsploit_protocols/canbus/definitions.py,sha256=PEA_LQySQeOfq_uqgaa2lTXK2JuWbF9FAr6M77KZjYs,9232
|
|
9
|
+
iotsploit_protocols/canbus/errorframes.py,sha256=3lI8mOJyoQN7-9Cbfawn1ml-dwfMwY4FfJQXjD6Ll3M,6019
|
|
10
|
+
iotsploit_protocols/canbus/errors.py,sha256=sT4RumLub70llBiNQiKm0GzpqYhxah2q2D7ZgOM9Vjo,1966
|
|
11
|
+
iotsploit_protocols/canbus/logfile.py,sha256=mBAPv0g9E58jzQAX3sfjljxpfbkhSC0e_F9taKI77kc,28640
|
|
12
|
+
iotsploit_protocols/canbus/socketcan.py,sha256=ltABu9eoDb-YAdAOsH8JIPoNN3nMxVXe25B318gx0EM,14596
|
|
13
|
+
iotsploit_protocols/doip/__init__.py,sha256=QT1q0pA4sxmF9bO69sNxOE2EyY6g0GI1muKbSQi8Qg4,504
|
|
14
|
+
iotsploit_protocols/doip/client.py,sha256=5ednrJXsQgiwguU2AR5f-SeSqCLoumVzc-FF0A5cBqM,10711
|
|
15
|
+
iotsploit_protocols/doip/facet.py,sha256=RFk5W7hQhaaw8HY0QQwtzeuI0lNiNNoMrnTb8MpZF4I,2080
|
|
16
|
+
iotsploit_protocols/doip/uds.py,sha256=hVJHWhb9XEoQ5xAL2DAitsZObEopGj367buKdVtn4hk,9752
|
|
17
|
+
iotsploit_protocols/errors.py,sha256=Sx2bcMSITm9W_iwxcoMoi3KRTXxNDRPqIXPL4PgKPUI,1638
|
|
18
|
+
iotsploit_protocols/someip/__init__.py,sha256=IN7fj26H7fpPicaoEwtO1w6viMT9NCbOW4EN2TNh5Vw,572
|
|
19
|
+
iotsploit_protocols/someip/client.py,sha256=XgiI_68GuuI_kM2aKqhj9Famgoz4TLaqpwEEYr1czK0,11903
|
|
20
|
+
iotsploit_protocols/someip/codec.py,sha256=bP9nwJ7p6KwjT5Che1rk0GnM-Xx1mpYwMRrRM0toYDM,407
|
|
21
|
+
iotsploit_protocols/someip/facet.py,sha256=_15kqOqD0FwL5wCxr3bGKr_yNhf-3OANxVMXAj0lR5g,3061
|
|
22
|
+
iotsploit_protocols/someip/sd.py,sha256=-p9kFHUYZSwjDuBX0m8zeCHn4flpUOkud__uLDW6rfk,14424
|
|
23
|
+
iotsploit_protocols-0.0.9.dist-info/METADATA,sha256=qKt0SBrevaBCbcMK8lhwW6Yc4Sj9Yw_fQZYA0uYeXyI,4057
|
|
24
|
+
iotsploit_protocols-0.0.9.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
25
|
+
iotsploit_protocols-0.0.9.dist-info/RECORD,,
|