iotsploit-priv 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_priv/__init__.py +19 -0
- iotsploit_priv/client.py +154 -0
- iotsploit_priv/native.py +207 -0
- iotsploit_priv-0.0.9.dist-info/METADATA +16 -0
- iotsploit_priv-0.0.9.dist-info/RECORD +6 -0
- iotsploit_priv-0.0.9.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from iotsploit_priv.client import (
|
|
2
|
+
INSTALL_HINT,
|
|
3
|
+
VERB_TABLE_HASH,
|
|
4
|
+
PrivilegedHelperError,
|
|
5
|
+
PrivilegedHelperProtocolError,
|
|
6
|
+
PrivilegedHelperUnavailable,
|
|
7
|
+
PrivilegedResult,
|
|
8
|
+
call,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"INSTALL_HINT",
|
|
13
|
+
"VERB_TABLE_HASH",
|
|
14
|
+
"PrivilegedHelperError",
|
|
15
|
+
"PrivilegedHelperProtocolError",
|
|
16
|
+
"PrivilegedHelperUnavailable",
|
|
17
|
+
"PrivilegedResult",
|
|
18
|
+
"call",
|
|
19
|
+
]
|
iotsploit_priv/client.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_SOCKET_PATH = Path("/run/iotsploit/priv.sock")
|
|
13
|
+
MAX_REQUEST_BYTES = 4_096
|
|
14
|
+
MAX_RESPONSE_BYTES = 24_576
|
|
15
|
+
# A VLAN edit can run four commands, each capped at 10 seconds, so a shorter
|
|
16
|
+
# patience here reports a working helper as missing.
|
|
17
|
+
DEFAULT_TIMEOUT_SECONDS = 45.0
|
|
18
|
+
INSTALL_HINT = "Install the IoTSploit privileged helper with `priv install`."
|
|
19
|
+
|
|
20
|
+
VERB_SCHEMAS = {
|
|
21
|
+
"can-fd-up": {
|
|
22
|
+
"iface": "can",
|
|
23
|
+
"bitrate": "integer",
|
|
24
|
+
"sample_point": "ratio",
|
|
25
|
+
"dbitrate": "integer",
|
|
26
|
+
"dsample_point": "ratio",
|
|
27
|
+
},
|
|
28
|
+
"can-link-state": {"iface": "can", "state": ["up", "down"]},
|
|
29
|
+
"can-up": {"iface": "can", "bitrate": "integer-or-null"},
|
|
30
|
+
"doip-config": {"iface": "network"},
|
|
31
|
+
"route-via": {"action": ["add", "delete"], "cidr": "ipv4-/16", "gateway": "ipv4"},
|
|
32
|
+
"vlan-add": {
|
|
33
|
+
"parent": "network",
|
|
34
|
+
"vlan_id": "1-4094",
|
|
35
|
+
"address": "ipv4-interface",
|
|
36
|
+
"local_mac": "mac-or-null",
|
|
37
|
+
"peer_ip": "ipv4-or-null",
|
|
38
|
+
"peer_mac": "mac-or-null",
|
|
39
|
+
},
|
|
40
|
+
"vlan-edit": {
|
|
41
|
+
"parent": "network",
|
|
42
|
+
"vlan_id": "1-4094",
|
|
43
|
+
"address": "ipv4-interface",
|
|
44
|
+
"local_mac": "mac-or-null",
|
|
45
|
+
"peer_ip": "ipv4-or-null",
|
|
46
|
+
"peer_mac": "mac-or-null",
|
|
47
|
+
},
|
|
48
|
+
"vlan-delete": {"parent": "network", "vlan_id": "1-4094"},
|
|
49
|
+
}
|
|
50
|
+
VERB_TABLE_HASH = hashlib.sha256(
|
|
51
|
+
json.dumps(VERB_SCHEMAS, sort_keys=True, separators=(",", ":")).encode()
|
|
52
|
+
).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PrivilegedHelperError(RuntimeError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PrivilegedHelperUnavailable(PrivilegedHelperError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PrivilegedHelperProtocolError(PrivilegedHelperError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class PrivilegedResult:
|
|
69
|
+
ok: bool
|
|
70
|
+
exit: int
|
|
71
|
+
stdout: str
|
|
72
|
+
stderr: str
|
|
73
|
+
output_truncated: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
77
|
+
result: dict[str, Any] = {}
|
|
78
|
+
for key, value in pairs:
|
|
79
|
+
if key in result:
|
|
80
|
+
raise PrivilegedHelperProtocolError(f"duplicate response key: {key}")
|
|
81
|
+
result[key] = value
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _decode_response(data: bytes) -> PrivilegedResult:
|
|
86
|
+
if len(data) > MAX_RESPONSE_BYTES:
|
|
87
|
+
raise PrivilegedHelperProtocolError("privileged helper response exceeds 24 KiB")
|
|
88
|
+
if not data.endswith(b"\n") or data.count(b"\n") != 1:
|
|
89
|
+
raise PrivilegedHelperProtocolError("privileged helper returned an incomplete or trailing response")
|
|
90
|
+
try:
|
|
91
|
+
payload = json.loads(data, object_pairs_hook=_unique_object)
|
|
92
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
93
|
+
raise PrivilegedHelperProtocolError("privileged helper returned invalid JSON") from exc
|
|
94
|
+
if not isinstance(payload, dict):
|
|
95
|
+
raise PrivilegedHelperProtocolError("privileged helper response must be an object")
|
|
96
|
+
|
|
97
|
+
required = {"ok", "exit", "stdout", "stderr"}
|
|
98
|
+
allowed = required | {"output_truncated"}
|
|
99
|
+
if set(payload) - allowed or not required.issubset(payload):
|
|
100
|
+
raise PrivilegedHelperProtocolError("privileged helper returned an unexpected response schema")
|
|
101
|
+
if type(payload["ok"]) is not bool or type(payload["exit"]) is not int:
|
|
102
|
+
raise PrivilegedHelperProtocolError("privileged helper returned invalid status fields")
|
|
103
|
+
if not isinstance(payload["stdout"], str) or not isinstance(payload["stderr"], str):
|
|
104
|
+
raise PrivilegedHelperProtocolError("privileged helper returned non-string output")
|
|
105
|
+
truncated = payload.get("output_truncated", False)
|
|
106
|
+
if type(truncated) is not bool:
|
|
107
|
+
raise PrivilegedHelperProtocolError("privileged helper returned an invalid truncation flag")
|
|
108
|
+
return PrivilegedResult(
|
|
109
|
+
ok=payload["ok"],
|
|
110
|
+
exit=payload["exit"],
|
|
111
|
+
stdout=payload["stdout"],
|
|
112
|
+
stderr=payload["stderr"],
|
|
113
|
+
output_truncated=truncated,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def call(
|
|
118
|
+
verb: str,
|
|
119
|
+
args: dict[str, Any],
|
|
120
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
121
|
+
*,
|
|
122
|
+
socket_path: str | os.PathLike[str] = DEFAULT_SOCKET_PATH,
|
|
123
|
+
) -> PrivilegedResult:
|
|
124
|
+
if not isinstance(verb, str) or not verb:
|
|
125
|
+
raise ValueError("verb must be a non-empty string")
|
|
126
|
+
if not isinstance(args, dict):
|
|
127
|
+
raise ValueError("args must be an object")
|
|
128
|
+
request = json.dumps({"verb": verb, "args": args}, separators=(",", ":"), ensure_ascii=False).encode() + b"\n"
|
|
129
|
+
if len(request) > MAX_REQUEST_BYTES:
|
|
130
|
+
raise ValueError("privileged helper request exceeds 4 KiB")
|
|
131
|
+
if not hasattr(socket, "AF_UNIX"):
|
|
132
|
+
raise PrivilegedHelperUnavailable("Privileged helper is Linux-only.")
|
|
133
|
+
|
|
134
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
135
|
+
client.settimeout(float(timeout))
|
|
136
|
+
try:
|
|
137
|
+
client.connect(os.fspath(socket_path))
|
|
138
|
+
client.sendall(request)
|
|
139
|
+
client.shutdown(socket.SHUT_WR)
|
|
140
|
+
response = bytearray()
|
|
141
|
+
while True:
|
|
142
|
+
chunk = client.recv(4_096)
|
|
143
|
+
if not chunk:
|
|
144
|
+
break
|
|
145
|
+
response.extend(chunk)
|
|
146
|
+
if len(response) > MAX_RESPONSE_BYTES:
|
|
147
|
+
raise PrivilegedHelperProtocolError("privileged helper response exceeds 24 KiB")
|
|
148
|
+
except PrivilegedHelperProtocolError:
|
|
149
|
+
raise
|
|
150
|
+
except (OSError, TimeoutError) as exc:
|
|
151
|
+
raise PrivilegedHelperUnavailable(f"Privileged helper unavailable. {INSTALL_HINT}") from exc
|
|
152
|
+
finally:
|
|
153
|
+
client.close()
|
|
154
|
+
return _decode_response(bytes(response))
|
iotsploit_priv/native.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import stat
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .client import DEFAULT_SOCKET_PATH, VERB_SCHEMAS, VERB_TABLE_HASH
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SOURCE_ROOT = Path(__file__).resolve().parents[2]
|
|
16
|
+
INSTALLER = SOURCE_ROOT / "install/iotsploit-priv-install"
|
|
17
|
+
DAEMON_SOURCE = SOURCE_ROOT / "privd/iotsploit-privd"
|
|
18
|
+
SYSTEMD_SOURCE = SOURCE_ROOT / "systemd"
|
|
19
|
+
DAEMON_DESTINATION = Path("/usr/local/libexec/iotsploit-privd")
|
|
20
|
+
UNIT_DESTINATIONS = (
|
|
21
|
+
Path("/etc/systemd/system/iotsploit-privd.socket"),
|
|
22
|
+
Path("/etc/systemd/system/iotsploit-privd.service"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class NativeStatus:
|
|
28
|
+
code: int
|
|
29
|
+
lines: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sha256_file(path: Path) -> str:
|
|
33
|
+
digest = hashlib.sha256()
|
|
34
|
+
with path.open("rb") as handle:
|
|
35
|
+
for chunk in iter(lambda: handle.read(65_536), b""):
|
|
36
|
+
digest.update(chunk)
|
|
37
|
+
return digest.hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def install_manifest() -> tuple[tuple[Path, Path, int], ...]:
|
|
41
|
+
return (
|
|
42
|
+
(DAEMON_SOURCE, DAEMON_DESTINATION, 0o755),
|
|
43
|
+
(SYSTEMD_SOURCE / "iotsploit-privd.socket", UNIT_DESTINATIONS[0], 0o644),
|
|
44
|
+
(SYSTEMD_SOURCE / "iotsploit-privd.service", UNIT_DESTINATIONS[1], 0o644),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def current_user() -> str:
|
|
49
|
+
"""The account this process runs as -- the one that must reach the socket."""
|
|
50
|
+
return os.environ.get("SUDO_USER") or getpass.getuser()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _service_identity(service_user: str) -> tuple[int, set[int]]:
|
|
54
|
+
import pwd
|
|
55
|
+
|
|
56
|
+
account = pwd.getpwnam(service_user)
|
|
57
|
+
return account.pw_uid, set(os.getgrouplist(service_user, account.pw_gid))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _writable_by(path: Path, uid: int, gids: set[int]) -> bool:
|
|
61
|
+
metadata = path.stat()
|
|
62
|
+
mode = stat.S_IMODE(metadata.st_mode)
|
|
63
|
+
if metadata.st_uid == uid:
|
|
64
|
+
return bool(mode & stat.S_IWUSR)
|
|
65
|
+
if metadata.st_gid in gids:
|
|
66
|
+
return bool(mode & stat.S_IWGRP)
|
|
67
|
+
return bool(mode & stat.S_IWOTH)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _permission_diagnosis(caller: str, helper_group: "grp.struct_group") -> str:
|
|
71
|
+
"""Say which of the two permission failures this is.
|
|
72
|
+
|
|
73
|
+
Group membership is fixed when a session starts, so the install can have
|
|
74
|
+
succeeded while the shell that ran it still cannot reach the socket. That
|
|
75
|
+
reads as a failed install unless it is named.
|
|
76
|
+
"""
|
|
77
|
+
if caller in helper_group.gr_mem and helper_group.gr_gid not in os.getgroups():
|
|
78
|
+
return (
|
|
79
|
+
f"{caller} is in the iotsploit group, but this session started before that "
|
|
80
|
+
f"and still carries the old group set -- log out and back in, or run: newgrp iotsploit"
|
|
81
|
+
)
|
|
82
|
+
if caller not in helper_group.gr_mem:
|
|
83
|
+
return (
|
|
84
|
+
f"{caller} is not in the iotsploit group and cannot reach the socket "
|
|
85
|
+
f"-- run: priv install --service-user {caller}"
|
|
86
|
+
)
|
|
87
|
+
return f"{caller} cannot open the helper socket: permission denied"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _health_probe(socket_path: Path) -> tuple[str, str] | None:
|
|
91
|
+
"""Return None when the daemon answers correctly, else (kind, detail).
|
|
92
|
+
|
|
93
|
+
The probe runs as the invoking account, so a permission error here is a
|
|
94
|
+
statement about the caller's group membership, not about the daemon.
|
|
95
|
+
"""
|
|
96
|
+
caller = current_user()
|
|
97
|
+
request = b'{"verb":"status","args":{}}\n'
|
|
98
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
99
|
+
client.settimeout(2)
|
|
100
|
+
try:
|
|
101
|
+
client.connect(os.fspath(socket_path))
|
|
102
|
+
client.sendall(request)
|
|
103
|
+
client.shutdown(socket.SHUT_WR)
|
|
104
|
+
response = client.recv(4_096)
|
|
105
|
+
except PermissionError:
|
|
106
|
+
return ("permission", f"{caller} cannot open {socket_path}: permission denied")
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
return ("unreachable", f"{caller} cannot reach {socket_path}: {exc}")
|
|
109
|
+
finally:
|
|
110
|
+
client.close()
|
|
111
|
+
try:
|
|
112
|
+
payload = json.loads(response)
|
|
113
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
114
|
+
return ("bad-response", "daemon did not return the bounded unknown-verb response")
|
|
115
|
+
answered = (
|
|
116
|
+
isinstance(payload, dict)
|
|
117
|
+
and payload.get("ok") is False
|
|
118
|
+
and payload.get("exit") == 2
|
|
119
|
+
and "unknown verb" in payload.get("stderr", "")
|
|
120
|
+
)
|
|
121
|
+
if answered:
|
|
122
|
+
return None
|
|
123
|
+
return ("bad-response", "daemon did not return the bounded unknown-verb response")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def native_status(
|
|
127
|
+
service_user: str | None = None,
|
|
128
|
+
*,
|
|
129
|
+
socket_path: Path = DEFAULT_SOCKET_PATH,
|
|
130
|
+
) -> NativeStatus:
|
|
131
|
+
if not hasattr(socket, "AF_UNIX"):
|
|
132
|
+
return NativeStatus(1, ("privileged helper is Linux-only",))
|
|
133
|
+
|
|
134
|
+
import grp
|
|
135
|
+
|
|
136
|
+
service_user = service_user or current_user()
|
|
137
|
+
destinations = (DAEMON_DESTINATION, *UNIT_DESTINATIONS)
|
|
138
|
+
if not any(path.exists() or path.is_symlink() for path in (*destinations, socket_path)):
|
|
139
|
+
return NativeStatus(1, ("privileged helper is not installed",))
|
|
140
|
+
|
|
141
|
+
problems: list[str] = []
|
|
142
|
+
try:
|
|
143
|
+
service_uid, service_gids = _service_identity(service_user)
|
|
144
|
+
helper_group = grp.getgrnam("iotsploit")
|
|
145
|
+
except KeyError as exc:
|
|
146
|
+
return NativeStatus(2, (f"missing account or group: {exc}",))
|
|
147
|
+
|
|
148
|
+
for source, destination, expected_mode in install_manifest():
|
|
149
|
+
try:
|
|
150
|
+
metadata = destination.lstat()
|
|
151
|
+
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
|
152
|
+
problems.append(f"{destination} is not a regular file")
|
|
153
|
+
continue
|
|
154
|
+
if (metadata.st_uid, metadata.st_gid) != (0, 0):
|
|
155
|
+
problems.append(f"{destination} is not root:root")
|
|
156
|
+
if stat.S_IMODE(metadata.st_mode) != expected_mode:
|
|
157
|
+
problems.append(f"{destination} mode is not {expected_mode:04o}")
|
|
158
|
+
if sha256_file(source) != sha256_file(destination):
|
|
159
|
+
problems.append(f"{destination} checksum differs from packaged source")
|
|
160
|
+
parent = destination.parent
|
|
161
|
+
while True:
|
|
162
|
+
if _writable_by(parent, service_uid, service_gids):
|
|
163
|
+
problems.append(f"{parent} is writable by {service_user}")
|
|
164
|
+
if parent == parent.parent:
|
|
165
|
+
break
|
|
166
|
+
parent = parent.parent
|
|
167
|
+
except (FileNotFoundError, OSError) as exc:
|
|
168
|
+
problems.append(f"{destination}: {exc}")
|
|
169
|
+
|
|
170
|
+
if helper_group.gr_gid not in service_gids:
|
|
171
|
+
problems.append(f"{service_user} is not a member of iotsploit")
|
|
172
|
+
try:
|
|
173
|
+
metadata = socket_path.lstat()
|
|
174
|
+
if not stat.S_ISSOCK(metadata.st_mode):
|
|
175
|
+
problems.append(f"{socket_path} is not a socket")
|
|
176
|
+
if metadata.st_uid != 0 or metadata.st_gid != helper_group.gr_gid:
|
|
177
|
+
problems.append(f"{socket_path} is not root:iotsploit")
|
|
178
|
+
if stat.S_IMODE(metadata.st_mode) != 0o660:
|
|
179
|
+
problems.append(f"{socket_path} mode is not 0660")
|
|
180
|
+
except OSError as exc:
|
|
181
|
+
problems.append(f"{socket_path}: {exc}")
|
|
182
|
+
if not problems:
|
|
183
|
+
probe_failure = _health_probe(socket_path)
|
|
184
|
+
if probe_failure:
|
|
185
|
+
kind, detail = probe_failure
|
|
186
|
+
problems.append(
|
|
187
|
+
_permission_diagnosis(current_user(), helper_group) if kind == "permission" else detail
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if problems:
|
|
191
|
+
return NativeStatus(2, tuple(problems))
|
|
192
|
+
members = ", ".join(sorted(set(helper_group.gr_mem))) or "none"
|
|
193
|
+
return NativeStatus(
|
|
194
|
+
0,
|
|
195
|
+
(
|
|
196
|
+
"privileged helper is healthy",
|
|
197
|
+
f"iotsploit group members: {members}",
|
|
198
|
+
f"verb table sha256: {VERB_TABLE_HASH}",
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def verb_lines() -> tuple[str, ...]:
|
|
204
|
+
return tuple(
|
|
205
|
+
f"{verb}: {json.dumps(schema, sort_keys=True, separators=(',', ':'))}"
|
|
206
|
+
for verb, schema in sorted(VERB_SCHEMAS.items())
|
|
207
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iotsploit-priv
|
|
3
|
+
Version: 0.0.9
|
|
4
|
+
Summary: Bounded privileged-operation client for IoTSploit
|
|
5
|
+
License: GPL-3.0-or-later
|
|
6
|
+
Author: IoTSploit Team
|
|
7
|
+
Author-email: support@iotsploit.org
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
iotsploit_priv/__init__.py,sha256=DxgARFDlOaGAqaKBiOIIFLPNQteKHc_IMYi7DhMuXlg,399
|
|
2
|
+
iotsploit_priv/client.py,sha256=kQWrpQU2AMkinStCVllMyAT5wU5avuYoXg874kGfQ3E,5518
|
|
3
|
+
iotsploit_priv/native.py,sha256=RRTnMQSKCKL5wMkuyjgsWuLUsdGMNgZAGbDTpJFyX3M,7670
|
|
4
|
+
iotsploit_priv-0.0.9.dist-info/METADATA,sha256=t_ZNnlJXbkbcMpkMUURmGKxfDnr5xGcPM1nPKUKS1IU,672
|
|
5
|
+
iotsploit_priv-0.0.9.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
6
|
+
iotsploit_priv-0.0.9.dist-info/RECORD,,
|