evocode 0.1.0__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.
- evocode/__init__.py +13 -0
- evocode/caf.py +86 -0
- evocode/catalog.py +127 -0
- evocode/cli.py +680 -0
- evocode/coding.py +106 -0
- evocode/consent.py +32 -0
- evocode/fsc.py +274 -0
- evocode/hsfz.py +193 -0
- evocode/keys.py +137 -0
- evocode/l3.py +39 -0
- evocode/psdz.py +171 -0
- evocode/root.py +76 -0
- evocode/srec.py +98 -0
- evocode/swdl.py +423 -0
- evocode/uds.py +154 -0
- evocode-0.1.0.dist-info/METADATA +353 -0
- evocode-0.1.0.dist-info/RECORD +22 -0
- evocode-0.1.0.dist-info/WHEEL +5 -0
- evocode-0.1.0.dist-info/entry_points.txt +2 -0
- evocode-0.1.0.dist-info/licenses/LICENSE +201 -0
- evocode-0.1.0.dist-info/licenses/NOTICE +13 -0
- evocode-0.1.0.dist-info/top_level.txt +1 -0
evocode/coding.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The NBT EVO coding signature (BMWSigVerify) — the maths the head unit runs at every boot.
|
|
3
|
+
|
|
4
|
+
A signature covers the content datagroups, is computed with the key the bootloader (BTLD) selects,
|
|
5
|
+
and lives in its own datagroup. Edit any coding byte without recomputing it and the change reverts
|
|
6
|
+
on the next boot. Verifying needs only the public modulus; producing a signature needs the private
|
|
7
|
+
exponent (recover it from your own PSdZData — never shipped here).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Optional, Tuple
|
|
15
|
+
|
|
16
|
+
from . import srec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Scheme:
|
|
21
|
+
"""Which datagroups are signed, with which digest, and where the signature is stored."""
|
|
22
|
+
name: str
|
|
23
|
+
content_dgs: Tuple[int, ...]
|
|
24
|
+
sig_dg: int
|
|
25
|
+
digest: str # "md5" or "sha256"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def digest_len(self) -> int:
|
|
29
|
+
return hashlib.new(self.digest).digest_size
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# NBT EVO (QNX): datagroups 0x3000..0x300F signed with MD5, signature in 0x3010.
|
|
33
|
+
NBTEVO = Scheme("nbtevo", tuple(range(0x3000, 0x3010)), 0x3010, "md5")
|
|
34
|
+
SCHEMES = {NBTEVO.name: NBTEVO}
|
|
35
|
+
DEFAULT_SCHEME = NBTEVO.name
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Key:
|
|
40
|
+
modulus: int
|
|
41
|
+
public_exponent: int = 3
|
|
42
|
+
private_exponent: Optional[int] = None
|
|
43
|
+
btld: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def size(self) -> int:
|
|
47
|
+
return (self.modulus.bit_length() + 7) // 8
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_key(path: str) -> Key:
|
|
51
|
+
d = json.loads(open(path, encoding="utf-8").read())
|
|
52
|
+
priv = d.get("private_exponent_hex")
|
|
53
|
+
return Key(
|
|
54
|
+
modulus=int(d["modulus_hex"], 16),
|
|
55
|
+
public_exponent=int(d.get("public_exponent", 3)),
|
|
56
|
+
private_exponent=int(priv, 16) if priv else None,
|
|
57
|
+
btld=d.get("btld"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _wordswap(b: bytes) -> bytes:
|
|
62
|
+
"""Reverse the bytes within each 32-bit word — BMW's big-number storage order."""
|
|
63
|
+
if len(b) % 4:
|
|
64
|
+
raise ValueError("length must be a multiple of 4")
|
|
65
|
+
return b"".join(b[i:i + 4][::-1] for i in range(0, len(b), 4))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def content_digest(mem: srec.Mem, scheme: Scheme = NBTEVO) -> bytes:
|
|
69
|
+
pages = srec.pages(mem, scheme.content_dgs)
|
|
70
|
+
content = b"".join(pages[dg] for dg in scheme.content_dgs if dg in pages)
|
|
71
|
+
return hashlib.new(scheme.digest, content).digest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sign(mem: srec.Mem, key: Key, scheme: Scheme = NBTEVO) -> bytes:
|
|
75
|
+
"""The raw signature bytes over the current content (does not modify ``mem``)."""
|
|
76
|
+
if key.private_exponent is None:
|
|
77
|
+
raise ValueError("key has no private exponent; recover it from your own PSdZData")
|
|
78
|
+
m = int.from_bytes(content_digest(mem, scheme), "little")
|
|
79
|
+
s = pow(m, key.private_exponent, key.modulus)
|
|
80
|
+
return _wordswap(s.to_bytes(key.size, "little"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def signature_page(sig: bytes, key: Key) -> bytes:
|
|
84
|
+
"""Signature datagroup payload: big-endian 32-bit word count, then the signature."""
|
|
85
|
+
return (key.size // 4).to_bytes(4, "big") + sig
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resign(mem: srec.Mem, key: Key, scheme: Scheme = NBTEVO) -> bytes:
|
|
89
|
+
"""Recompute the signature and write it into the signature datagroup, in place."""
|
|
90
|
+
sig = sign(mem, key, scheme)
|
|
91
|
+
base = scheme.sig_dg << 8
|
|
92
|
+
for i, b in enumerate(signature_page(sig, key)):
|
|
93
|
+
mem[base + i] = b
|
|
94
|
+
return sig
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def verify(mem: srec.Mem, key: Key, scheme: Scheme = NBTEVO) -> bool:
|
|
98
|
+
"""Reproduce the ECU's check: raise the stored signature to ``e``, compare to the content digest."""
|
|
99
|
+
digest = content_digest(mem, scheme)
|
|
100
|
+
page = srec.pages(mem, [scheme.sig_dg]).get(scheme.sig_dg, b"")
|
|
101
|
+
if len(page) < 4 + key.size:
|
|
102
|
+
return False
|
|
103
|
+
sig = page[4:4 + key.size]
|
|
104
|
+
recovered = pow(int.from_bytes(_wordswap(sig), "little"), key.public_exponent, key.modulus)
|
|
105
|
+
return (recovered.to_bytes(scheme.digest_len, "little") == digest
|
|
106
|
+
and recovered.bit_length() <= 8 * scheme.digest_len)
|
evocode/consent.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Owner-consent gate for anything that writes to a vehicle.
|
|
3
|
+
|
|
4
|
+
A deliberate speed bump: a write refuses unless you have set ``NBTEVO_OWN_VIN`` to the VIN of the
|
|
5
|
+
car you own, and that VIN matches the one the tool reads back from the connected car. This is not
|
|
6
|
+
DRM — it is a guard against writing to the wrong vehicle. Do not route around it.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
ENV_VIN = "NBTEVO_OWN_VIN"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConsentError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def owned_vin() -> str:
|
|
20
|
+
vin = os.environ.get(ENV_VIN, "").strip()
|
|
21
|
+
if not vin:
|
|
22
|
+
raise ConsentError("set %s to the VIN of the car you own before any write." % ENV_VIN)
|
|
23
|
+
return vin
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def assert_owned(connected_vin: str) -> None:
|
|
27
|
+
want = owned_vin()
|
|
28
|
+
if connected_vin.strip() != want:
|
|
29
|
+
raise ConsentError(
|
|
30
|
+
"connected VIN %r != %s (%r) — refusing to write to a car you have not confirmed you own."
|
|
31
|
+
% (connected_vin, ENV_VIN, want)
|
|
32
|
+
)
|
evocode/fsc.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""FSC activations — the owner-CA path: your own e=3 trust anchor, and FSCs you mint under it.
|
|
3
|
+
|
|
4
|
+
The NBT EVO verifies every "long" FSC against ONE RSA modulus — **blobB** — compiled into NBTCarHU
|
|
5
|
+
(``e=3``, PKCS#1 v1.5 / MD5). There is no per-feature key and no certificate lookup: an FSC is
|
|
6
|
+
accepted iff its signature verifies under whatever modulus is installed as blobB. This is a separate
|
|
7
|
+
mechanism from coding (which verifies against BMW's fixed Codier-Sign key you can only *recover*).
|
|
8
|
+
|
|
9
|
+
The owner path, on a car you own: generate your **own** e=3 key, install its modulus as blobB (a
|
|
10
|
+
length-preserving firmware patch — see ``find_blobb``/``patch_blobb``), and mint FSCs with your
|
|
11
|
+
private key. No BMW key is forged; you move the unit's trust to a key you hold.
|
|
12
|
+
|
|
13
|
+
``keygen``/``mint``/``verify``/``parse``/``recover_modulus`` are pure stdlib. ``recover_modulus``
|
|
14
|
+
(the e=3 GCD attack) identifies the anchor already on a used unit from the FSCs you read off it.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import math
|
|
20
|
+
import secrets
|
|
21
|
+
import struct
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from math import gcd
|
|
24
|
+
from typing import List, Tuple
|
|
25
|
+
|
|
26
|
+
MD5_DIGESTINFO = bytes.fromhex("3020300c06082a864886f70d020505000410")
|
|
27
|
+
BODY_LEN = 60
|
|
28
|
+
SIG_LEN = 128
|
|
29
|
+
|
|
30
|
+
# the application numbers this tooling was built around
|
|
31
|
+
APPNOS = {0x0143: "CarPlay", 0x00A0: "Navi Pro ECE", 0x0120: "Map Europe", 0x009C: "A4A / BMW Apps"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---- the 60-byte signed body (byte-exact to the EVO FSC layout) ----
|
|
35
|
+
|
|
36
|
+
def build_body(appno: int, upgrade_idx: int = 1, supplier_id: bytes = b" 1",
|
|
37
|
+
requester_type: bytes = b" 1", counter: int = 0x0001,
|
|
38
|
+
fscs_id: bytes = b"C329361", vin=None, date: bytes = b"201608242124") -> bytes:
|
|
39
|
+
b = bytearray(b"\x01\x01")
|
|
40
|
+
b += struct.pack(">H", appno)
|
|
41
|
+
b += struct.pack(">H", upgrade_idx)
|
|
42
|
+
assert len(supplier_id) == 8
|
|
43
|
+
b += supplier_id + b"\x01"
|
|
44
|
+
assert len(requester_type) == 8
|
|
45
|
+
b += requester_type
|
|
46
|
+
b += struct.pack(">H", counter) + b"\x01"
|
|
47
|
+
assert len(fscs_id) == 7
|
|
48
|
+
b += fscs_id + b"\x01" # VIN-type 1 => a 9-byte VIN follows
|
|
49
|
+
b += (b"\x00" * 9) if vin is None else (vin if isinstance(vin, bytes) else vin.encode())
|
|
50
|
+
b += b"\x04\x02" # date tag/len
|
|
51
|
+
assert len(date) == 12
|
|
52
|
+
b += date + b"Z\x00\x00"
|
|
53
|
+
assert len(b) == BODY_LEN, "body len %d" % len(b)
|
|
54
|
+
return bytes(b)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _emsa(digest: bytes, modlen: int = SIG_LEN) -> bytes:
|
|
58
|
+
"""PKCS#1 v1.5 envelope over an MD5 digest, zero-slack (what the device expects)."""
|
|
59
|
+
t = MD5_DIGESTINFO + digest
|
|
60
|
+
return b"\x00\x01" + b"\xff" * (modlen - 3 - len(t)) + b"\x00" + t
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _emsa_int(body: bytes) -> int:
|
|
64
|
+
return int.from_bytes(_emsa(hashlib.md5(body).digest()), "big")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- mint / resign / verify / parse ----
|
|
68
|
+
|
|
69
|
+
def mint(n: int, d: int, appno: int, **body_kw) -> bytes:
|
|
70
|
+
"""A 188-byte FSC (60-byte body + 128-byte signature) signed by private (n, d), e=3."""
|
|
71
|
+
body = build_body(appno, **body_kw)
|
|
72
|
+
return body + pow(_emsa_int(body), d, n).to_bytes(SIG_LEN, "big")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resign_body(body: bytes, n: int, d: int) -> bytes:
|
|
76
|
+
"""Re-sign an existing 60-byte body under (n, d) — reuse a real FSC's fields exactly."""
|
|
77
|
+
return bytes(body[:BODY_LEN]) + pow(_emsa_int(body[:BODY_LEN]), d, n).to_bytes(SIG_LEN, "big")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def verify(fsc: bytes, n: int, e: int = 3):
|
|
81
|
+
"""Reproduce the on-device check: m = sig^e mod N, strip PKCS#1, compare MD5(body)."""
|
|
82
|
+
body, sigb = fsc[:-SIG_LEN], fsc[-SIG_LEN:]
|
|
83
|
+
sig = int.from_bytes(sigb, "big")
|
|
84
|
+
if sig >= n:
|
|
85
|
+
return False, "sig>=N"
|
|
86
|
+
m = pow(sig, e, n).to_bytes(SIG_LEN, "big")
|
|
87
|
+
if m[:2] != b"\x00\x01":
|
|
88
|
+
return False, "bad 00 01"
|
|
89
|
+
i = 2
|
|
90
|
+
while i < SIG_LEN and m[i] == 0xFF:
|
|
91
|
+
i += 1
|
|
92
|
+
if m[i] != 0x00:
|
|
93
|
+
return False, "FF run not 00-terminated"
|
|
94
|
+
i += 1
|
|
95
|
+
if m[i:i + 18] != MD5_DIGESTINFO:
|
|
96
|
+
return False, "bad DigestInfo"
|
|
97
|
+
if i + 34 != SIG_LEN:
|
|
98
|
+
return False, "envelope not exactly 128 bytes"
|
|
99
|
+
if m[i + 18:i + 34] != hashlib.md5(body).digest():
|
|
100
|
+
return False, "hash mismatch"
|
|
101
|
+
return True, "OK"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse(fsc: bytes) -> dict:
|
|
105
|
+
b = fsc[:-SIG_LEN]
|
|
106
|
+
vin = b[34:43]
|
|
107
|
+
return {
|
|
108
|
+
"version": b[0:2].hex(),
|
|
109
|
+
"appno": "0x%04X" % struct.unpack(">H", b[2:4])[0],
|
|
110
|
+
"upgrade_index": struct.unpack(">H", b[4:6])[0],
|
|
111
|
+
"fscs_id": b[26:33].decode("latin1"),
|
|
112
|
+
"vin": "(VIN-generic)" if vin == b"\x00" * 9 else vin.decode("latin1"),
|
|
113
|
+
"creation_date": b[45:57].decode("latin1"),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---- whose anchor signed these? (e=3 modulus recovery) ----
|
|
118
|
+
|
|
119
|
+
def recover_modulus(fscs: List[bytes]) -> int:
|
|
120
|
+
"""Recover the signing modulus from >=2 FSCs signed by the SAME anchor (e=3 GCD attack)."""
|
|
121
|
+
if len(fscs) < 2:
|
|
122
|
+
raise ValueError("need >=2 FSCs signed by the same anchor to recover a modulus")
|
|
123
|
+
g = 0
|
|
124
|
+
for f in fscs:
|
|
125
|
+
body, sigb = f[:-SIG_LEN], f[-SIG_LEN:]
|
|
126
|
+
g = gcd(g, abs(int.from_bytes(sigb, "big") ** 3 - _emsa_int(body)))
|
|
127
|
+
p = 2
|
|
128
|
+
while p < 100000: # strip tiny cofactors, keep the ~1024-bit N
|
|
129
|
+
while g % p == 0 and (g // p).bit_length() >= 1000:
|
|
130
|
+
g //= p
|
|
131
|
+
p += 1
|
|
132
|
+
return g
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def blobB_le(n: int) -> bytes:
|
|
136
|
+
"""The 128-byte blobB form of a modulus: little-endian (as it sits in NBTCarHU)."""
|
|
137
|
+
return n.to_bytes(SIG_LEN, "little")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def fingerprint(n: int) -> str:
|
|
141
|
+
return hashlib.sha256(blobB_le(n)).hexdigest()[:16]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---- generate your own e=3 anchor ----
|
|
145
|
+
|
|
146
|
+
def _probable_prime(num: int, rounds: int = 24) -> bool:
|
|
147
|
+
for sp in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
|
|
148
|
+
if num % sp == 0:
|
|
149
|
+
return num == sp
|
|
150
|
+
if num < 2:
|
|
151
|
+
return False
|
|
152
|
+
d, r = num - 1, 0
|
|
153
|
+
while d % 2 == 0:
|
|
154
|
+
d //= 2
|
|
155
|
+
r += 1
|
|
156
|
+
for _ in range(rounds):
|
|
157
|
+
a = 2 + secrets.randbelow(num - 3)
|
|
158
|
+
x = pow(a, d, num)
|
|
159
|
+
if x in (1, num - 1):
|
|
160
|
+
continue
|
|
161
|
+
for _ in range(r - 1):
|
|
162
|
+
x = pow(x, 2, num)
|
|
163
|
+
if x == num - 1:
|
|
164
|
+
break
|
|
165
|
+
else:
|
|
166
|
+
return False
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _prime_e3(bits: int) -> int:
|
|
171
|
+
"""A prime p with (p-1) coprime to 3, so e=3 is invertible."""
|
|
172
|
+
while True:
|
|
173
|
+
cand = secrets.randbits(bits) | (1 << (bits - 1)) | 1
|
|
174
|
+
while not _probable_prime(cand):
|
|
175
|
+
cand += 2
|
|
176
|
+
if (cand - 1) % 3 != 0:
|
|
177
|
+
return cand
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def keygen(bits: int = 1024) -> dict:
|
|
181
|
+
"""Your owner trust anchor: RSA with e=3 (what the FSC verifier uses)."""
|
|
182
|
+
p = _prime_e3(bits // 2)
|
|
183
|
+
q = _prime_e3(bits // 2)
|
|
184
|
+
while q == p:
|
|
185
|
+
q = _prime_e3(bits // 2)
|
|
186
|
+
n = p * q
|
|
187
|
+
phi = (p - 1) * (q - 1)
|
|
188
|
+
return {"n_hex": "%x" % n, "e": 3, "d_hex": "%x" % pow(3, -1, phi), "bits": n.bit_length()}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---- locating / swapping blobB in a NBTCarHU firmware dump ----
|
|
192
|
+
|
|
193
|
+
def _entropy(b: bytes) -> float:
|
|
194
|
+
c = Counter(b)
|
|
195
|
+
n = len(b)
|
|
196
|
+
return -sum((v / n) * math.log2(v / n) for v in c.values())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _small_primes(limit: int = 50000) -> List[int]:
|
|
200
|
+
s = bytearray([1]) * limit
|
|
201
|
+
s[0] = s[1] = 0
|
|
202
|
+
for i in range(2, int(limit ** 0.5) + 1):
|
|
203
|
+
if s[i]:
|
|
204
|
+
s[i * i::i] = bytearray(len(s[i * i::i]))
|
|
205
|
+
return [i for i in range(limit) if s[i]]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def find_blobb(nbtcarhu: bytes, bits: int = 1024) -> Tuple[int, int]:
|
|
209
|
+
"""Locate the installed FSC anchor in a NBTCarHU ELF dump → (file_offset, modulus).
|
|
210
|
+
|
|
211
|
+
The verifier loads blobB's address from a PC-relative literal pool, so the real modulus's
|
|
212
|
+
virtual address appears verbatim as a 4-byte little-endian pointer in the code. Among the
|
|
213
|
+
high-entropy ~1024-bit candidates in the binary, exactly the installed one is code-referenced —
|
|
214
|
+
which pins it with no stock image or disassembler.
|
|
215
|
+
"""
|
|
216
|
+
d = nbtcarhu
|
|
217
|
+
if d[:4] != b"\x7fELF" or d[4] != 1:
|
|
218
|
+
raise ValueError("not an ELF32 image (expected a NBTCarHU dump)")
|
|
219
|
+
phoff = struct.unpack("<I", d[28:32])[0]
|
|
220
|
+
phentsize = struct.unpack("<H", d[42:44])[0]
|
|
221
|
+
phnum = struct.unpack("<H", d[44:46])[0]
|
|
222
|
+
segs = []
|
|
223
|
+
for i in range(phnum):
|
|
224
|
+
o = phoff + i * phentsize
|
|
225
|
+
ptype, poff, pvaddr, _ppaddr, pfilesz = struct.unpack("<5I", d[o:o + 20])
|
|
226
|
+
if ptype == 1: # PT_LOAD
|
|
227
|
+
segs.append((poff, pvaddr, pfilesz))
|
|
228
|
+
|
|
229
|
+
def off2va(o):
|
|
230
|
+
for poff, pvaddr, pfilesz in segs:
|
|
231
|
+
if poff <= o < poff + pfilesz:
|
|
232
|
+
return pvaddr + (o - poff)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
L = len(d)
|
|
236
|
+
nbytes = bits // 8
|
|
237
|
+
|
|
238
|
+
# This build wraps the anchor in a little key struct { ver(u32)=1, e(u32)=3, len_words(u32),
|
|
239
|
+
# modulus }, and the code references the STRUCT address — so find a code-referenced struct of the
|
|
240
|
+
# wanted width and take the modulus at +12.
|
|
241
|
+
want_words = bits // 32
|
|
242
|
+
o = 0
|
|
243
|
+
while o + 12 + nbytes <= L:
|
|
244
|
+
ver, e3, lw = struct.unpack("<3I", d[o:o + 12])
|
|
245
|
+
if ver == 1 and e3 == 3 and lw == want_words:
|
|
246
|
+
va = off2va(o)
|
|
247
|
+
if va is not None and d.find(struct.pack("<I", va)) >= 0:
|
|
248
|
+
moff = o + 12
|
|
249
|
+
n = int.from_bytes(d[moff:moff + nbytes], "little")
|
|
250
|
+
if (n & 1) and n.bit_length() >= bits - 8:
|
|
251
|
+
return moff, n
|
|
252
|
+
o += 4
|
|
253
|
+
|
|
254
|
+
# Fallback: a build that stores the modulus unwrapped — find the single code-referenced candidate.
|
|
255
|
+
primes = _small_primes()
|
|
256
|
+
o = 0
|
|
257
|
+
while o + nbytes <= L:
|
|
258
|
+
if (d[o] & 1) and (d[o + nbytes - 1] & 0x80):
|
|
259
|
+
w = d[o:o + nbytes]
|
|
260
|
+
if _entropy(w) > 6.4:
|
|
261
|
+
n = int.from_bytes(w, "little")
|
|
262
|
+
if n.bit_length() >= bits - 7 and all(n % p for p in primes):
|
|
263
|
+
va = off2va(o)
|
|
264
|
+
if va is not None and d.find(struct.pack("<I", va)) >= 0:
|
|
265
|
+
return o, n
|
|
266
|
+
o += 4
|
|
267
|
+
raise LookupError("blobB not located by code reference (PIC/GOT? pass an explicit offset)")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def patch_blobb(nbtcarhu: bytes, offset: int, modulus: int) -> bytes:
|
|
271
|
+
"""A copy of the dump with the 128 bytes at ``offset`` replaced by ``modulus`` (length-preserving)."""
|
|
272
|
+
d = bytearray(nbtcarhu)
|
|
273
|
+
d[offset:offset + SIG_LEN] = blobB_le(modulus)
|
|
274
|
+
return bytes(d)
|
evocode/hsfz.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""HSFZ transport for BMW F/early-G ENET diagnostics (the NBT EVO host).
|
|
3
|
+
|
|
4
|
+
These cars speak **HSFZ**, not DoIP, over TCP 6801. A frame is::
|
|
5
|
+
|
|
6
|
+
u32 payload_len | u16 control | u8 source | u8 target | UDS bytes...
|
|
7
|
+
|
|
8
|
+
where ``payload_len`` counts the bytes after the control word (so ``2 + len(UDS)`` for a data
|
|
9
|
+
frame). control 0x0001 = data, 0x0002 = ack. Discovery is a UDP broadcast of control 0x0011 to
|
|
10
|
+
port 6811; each gateway answers, and the reply's source IP is the endpoint. Routing is the target
|
|
11
|
+
byte alone — connect once and set ``target`` per ECU; the gateway forwards it.
|
|
12
|
+
|
|
13
|
+
Addresses: tester source 0xF4, FEM gateway 0x10, NBT EVO head unit 0x63.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import socket
|
|
18
|
+
import struct
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Callable, List, Optional
|
|
23
|
+
|
|
24
|
+
IDENT_PORT = 6811
|
|
25
|
+
DIAG_PORT = 6801
|
|
26
|
+
|
|
27
|
+
CTRL_DATA = 0x0001
|
|
28
|
+
CTRL_ACK = 0x0002
|
|
29
|
+
CTRL_IDENT = 0x0011
|
|
30
|
+
|
|
31
|
+
SRC_TESTER = 0xF4
|
|
32
|
+
TGT_GATEWAY = 0x10
|
|
33
|
+
TGT_HU = 0x63
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HsfzError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Endpoint:
|
|
42
|
+
ip: str
|
|
43
|
+
diag_addr: Optional[int]
|
|
44
|
+
vin: Optional[str]
|
|
45
|
+
raw_ident: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_ident(payload: bytes, ip: str) -> Endpoint:
|
|
49
|
+
text = payload.decode("latin1", "replace")
|
|
50
|
+
|
|
51
|
+
def field(tag: str, n: int) -> Optional[str]:
|
|
52
|
+
i = text.find(tag)
|
|
53
|
+
return text[i + len(tag): i + len(tag) + n] if i >= 0 else None
|
|
54
|
+
|
|
55
|
+
da = field("DIAGADR", 2)
|
|
56
|
+
vin = field("BMWVIN", 17)
|
|
57
|
+
return Endpoint(ip, int(da, 16) if da else None, vin.strip("\x00 ") if vin else None, text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def discover(bind_ip: str, broadcast: str = "169.254.255.255", timeout: float = 3.0) -> List[Endpoint]:
|
|
61
|
+
"""Broadcast the HSFZ ident request on the ENET segment and collect endpoint replies.
|
|
62
|
+
|
|
63
|
+
``bind_ip`` is your host's address on that segment (e.g. a 169.254.x.x link-local), so the probe
|
|
64
|
+
leaves the right interface.
|
|
65
|
+
"""
|
|
66
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
67
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
68
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
69
|
+
sock.bind((bind_ip, 0))
|
|
70
|
+
sock.settimeout(timeout)
|
|
71
|
+
sock.sendto(struct.pack(">IH", 0, CTRL_IDENT), (broadcast, IDENT_PORT))
|
|
72
|
+
found: dict[str, Endpoint] = {}
|
|
73
|
+
deadline = time.time() + timeout
|
|
74
|
+
while time.time() < deadline:
|
|
75
|
+
try:
|
|
76
|
+
data, (ip, _) = sock.recvfrom(4096)
|
|
77
|
+
except socket.timeout:
|
|
78
|
+
break
|
|
79
|
+
if len(data) >= 6 and ip not in found:
|
|
80
|
+
found[ip] = _parse_ident(data[6:], ip)
|
|
81
|
+
sock.close()
|
|
82
|
+
return list(found.values())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Hsfz:
|
|
86
|
+
"""One TCP/6801 HSFZ session. Set the target per request; the gateway routes by it."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, ip: str, source: int = SRC_TESTER, timeout: float = 5.0):
|
|
89
|
+
self.ip = ip
|
|
90
|
+
self.source = source
|
|
91
|
+
self._timeout = timeout
|
|
92
|
+
self.sock = socket.create_connection((ip, DIAG_PORT), timeout=timeout)
|
|
93
|
+
self.sock.settimeout(timeout)
|
|
94
|
+
self._tp_stop: Optional[threading.Event] = None
|
|
95
|
+
self._tp_thread: Optional[threading.Thread] = None
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
self.stop_tester_present()
|
|
99
|
+
try:
|
|
100
|
+
self.sock.close()
|
|
101
|
+
except OSError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
def reconnect(self, timeout: Optional[float] = None) -> "Hsfz":
|
|
105
|
+
try:
|
|
106
|
+
self.sock.close()
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
t = timeout or self._timeout
|
|
110
|
+
self.sock = socket.create_connection((self.ip, DIAG_PORT), timeout=t)
|
|
111
|
+
self.sock.settimeout(t)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> "Hsfz":
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *exc) -> None:
|
|
118
|
+
self.close()
|
|
119
|
+
|
|
120
|
+
def send_only(self, target: int, uds: bytes) -> None:
|
|
121
|
+
self._send(target, uds)
|
|
122
|
+
|
|
123
|
+
def recv_frame(self, timeout: Optional[float] = None) -> tuple[int, bytes]:
|
|
124
|
+
if timeout is not None:
|
|
125
|
+
self.sock.settimeout(timeout)
|
|
126
|
+
return self._recv_frame()
|
|
127
|
+
|
|
128
|
+
def start_tester_present(self, target: int = 0xDF, period: float = 1.0,
|
|
129
|
+
log: Optional[Callable] = None) -> None:
|
|
130
|
+
if self._tp_thread is not None:
|
|
131
|
+
return
|
|
132
|
+
self._tp_stop = threading.Event()
|
|
133
|
+
tp = bytes([0x3E, 0x80])
|
|
134
|
+
|
|
135
|
+
def _loop() -> None:
|
|
136
|
+
assert self._tp_stop is not None
|
|
137
|
+
while not self._tp_stop.wait(period):
|
|
138
|
+
try:
|
|
139
|
+
self._send(target, tp)
|
|
140
|
+
except Exception:
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
self._tp_thread = threading.Thread(target=_loop, name="HSFZ-TP", daemon=True)
|
|
144
|
+
self._tp_thread.start()
|
|
145
|
+
if log:
|
|
146
|
+
log(" [tp] TesterPresent 3E 80 -> 0x%02X every %.1fs" % (target, period))
|
|
147
|
+
|
|
148
|
+
def stop_tester_present(self) -> None:
|
|
149
|
+
if self._tp_stop is not None:
|
|
150
|
+
self._tp_stop.set()
|
|
151
|
+
if self._tp_thread is not None:
|
|
152
|
+
self._tp_thread.join(timeout=1.0)
|
|
153
|
+
self._tp_thread = None
|
|
154
|
+
self._tp_stop = None
|
|
155
|
+
|
|
156
|
+
def _send(self, target: int, uds: bytes) -> None:
|
|
157
|
+
frame = struct.pack(">IH", 2 + len(uds), CTRL_DATA) + bytes([self.source, target]) + uds
|
|
158
|
+
self.sock.sendall(frame)
|
|
159
|
+
|
|
160
|
+
def _recv_exact(self, n: int) -> bytes:
|
|
161
|
+
buf = b""
|
|
162
|
+
while len(buf) < n:
|
|
163
|
+
chunk = self.sock.recv(n - len(buf))
|
|
164
|
+
if not chunk:
|
|
165
|
+
raise HsfzError("HSFZ peer closed the connection")
|
|
166
|
+
buf += chunk
|
|
167
|
+
return buf
|
|
168
|
+
|
|
169
|
+
def _recv_frame(self) -> tuple[int, bytes]:
|
|
170
|
+
length, ctrl = struct.unpack(">IH", self._recv_exact(6))
|
|
171
|
+
body = self._recv_exact(length) if length else b""
|
|
172
|
+
return ctrl, body
|
|
173
|
+
|
|
174
|
+
def request(self, target: int, uds: bytes, p2_star: float = 6.0) -> bytes:
|
|
175
|
+
"""Send a UDS request and return the raw UDS response, skipping acks/echoes and riding the
|
|
176
|
+
extended P2* timer across ``7F xx 78`` responsePending frames."""
|
|
177
|
+
self._send(target, uds)
|
|
178
|
+
deadline = time.time() + p2_star
|
|
179
|
+
while time.time() < deadline:
|
|
180
|
+
try:
|
|
181
|
+
ctrl, body = self._recv_frame()
|
|
182
|
+
except socket.timeout:
|
|
183
|
+
break
|
|
184
|
+
if ctrl != CTRL_DATA or len(body) < 2:
|
|
185
|
+
continue # ack / status frame
|
|
186
|
+
resp = body[2:] # strip source, target
|
|
187
|
+
if not resp or resp == uds:
|
|
188
|
+
continue # some gateways echo the request
|
|
189
|
+
if len(resp) >= 3 and resp[0] == 0x7F and resp[2] == 0x78:
|
|
190
|
+
deadline = time.time() + p2_star # responsePending: keep waiting
|
|
191
|
+
continue
|
|
192
|
+
return resp
|
|
193
|
+
raise HsfzError("no UDS response from 0x%02X for %s" % (target, uds.hex()))
|