etc.git


etc

~ scripts and random things
git clone https://github.com/djnnvx/etc.git
log | files | refs | archive

commit dfafd1909a887588eebe34e8d7c1e8a19e296f62
parent 6690c8dfa3075bab217cac7623f671a5fc6d3479
Author: djnn <[email protected]>
Date: Wed, 16 Sep 2026 11:19:30 +0200

doip uds fuzzer:D

Diffstat:
Afuzzing/doip-uds/README.md | 23+++++++++++++++++++++++
Afuzzing/doip-uds/doip.py | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/doip-uds/doip_tls.py | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/doip-uds/doipfuzz.py | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/doip-uds/doipstate.py | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/doip-uds/uds_protection_map.py | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

6 files changed, 1069 insertions(+), 0 deletions(-)

diff --git a/fuzzing/doip-uds/README.md b/fuzzing/doip-uds/README.md
@@ -0,0 +1,23 @@
+# doip-uds
+
+Fuzzers and clients for DoIP (ISO 13400) and UDS (ISO 14229) over Ethernet.
+
+| File | What it does |
+|---|---|
+| `doipstate.py` | Sequence fuzzer. Holds several live connections and mutates the order of operations rather than the bytes. |
+| `doipfuzz.py` | One-shot case fuzzer over DoIP headers, routing activation and UDS. |
+| `doip.py` | Client. discover, info, send, scan, shell. |
+| `doip_tls.py` | Client for DoIP over TLS on 3496. |
+| `uds_protection_map.py` | Sweeps all 256 service IDs and classifies each by its negative response code. |
+
+Prefer `doipstate.py`. One-shot cases on fresh connections cannot reach state
+that takes a long session to build, and that is where the interesting behaviour
+tends to be.
+
+    python3 doipstate.py -t 127.0.0.1 -n 20000 --seed 1 --len 12
+    python3 doipstate.py replay sequences/000123_PERSISTED.json
+
+Both fuzzers exclude the UDS services that write, reset or route: `0x2E`,
+`0x31`, `0x11`, `0x34`-`0x37`, `0x85`, `0x28`, and `0x27` with an even
+subfunction. `doipfuzz.py --unsafe` lifts that. Do not lift it against hardware
+you care about.
diff --git a/fuzzing/doip-uds/doip.py b/fuzzing/doip-uds/doip.py
@@ -0,0 +1,232 @@
+#!/usr/bin/env python3
+"""
+Minimal DoIP / UDS client (ISO 13400, ISO 14229).
+
+  ./doip.py discover
+  ./doip.py info
+  ./doip.py send 22 F1 90
+  ./doip.py scan
+  ./doip.py shell
+
+In shell: type hex to send UDS, .ta <addr> to retarget an ECU behind the
+gateway, .scan to enumerate them, .info, .q to quit.
+Raw hex is raw. 0x11 resets, 0x2E writes, 0x31 runs routines.
+"""
+import argparse, socket, struct, sys
+
+V = 0x03
+ACT = {0x00: 'unknown source address', 0x01: 'all sockets active', 0x02: 'SA differs from activated',
+       0x03: 'SA registered elsewhere', 0x04: 'missing authentication', 0x05: 'rejected confirmation',
+       0x06: 'unsupported activation type', 0x07: 'encrypted link required',
+       0x10: 'activated', 0x11: 'confirmation required'}
+NRC = {0x10: 'generalReject', 0x11: 'serviceNotSupported', 0x12: 'subFunctionNotSupported',
+       0x13: 'incorrectMessageLengthOrInvalidFormat', 0x14: 'responseTooLong', 0x21: 'busyRepeatRequest',
+       0x22: 'conditionsNotCorrect', 0x24: 'requestSequenceError', 0x25: 'noResponseFromSubnetComponent',
+       0x31: 'requestOutOfRange', 0x33: 'securityAccessDenied', 0x34: 'authenticationRequired',
+       0x35: 'invalidKey', 0x36: 'exceedNumberOfAttempts', 0x37: 'requiredTimeDelayNotExpired',
+       0x70: 'uploadDownloadNotAccepted', 0x72: 'generalProgrammingFailure', 0x78: 'responsePending',
+       0x7E: 'subFunctionNotSupportedInActiveSession', 0x7F: 'serviceNotSupportedInActiveSession'}
+DIDS = {0xF190: 'VIN', 0xF191: 'vehicleManufacturerECUHardwareNumber', 0xF193: 'hardwareVersion',
+        0xF194: 'softwareIdentification', 0xF195: 'softwareVersion', 0xF197: 'systemNameOrEngineType',
+        0xF18C: 'ECUSerialNumber', 0xF1A0: 'vehicleManufacturerSpecific', 0xF186: 'activeDiagnosticSession'}
+
+
+def hdr(pt, pl=b''):
+    return struct.pack('>BBHI', V, 0xFF ^ V, pt, len(pl)) + pl
+
+
+def rd(sock):
+    h = sock.recv(8)
+    if len(h) < 8:
+        return None, b''
+    _, _, pt, ln = struct.unpack('>BBHI', h)
+    b = b''
+    while len(b) < ln:
+        c = sock.recv(ln - len(b))
+        if not c:
+            break
+        b += c
+    return pt, b
+
+
+def udp(target, ptype, timeout=3):
+    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    s.settimeout(timeout)
+    s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+    s.sendto(hdr(ptype), (target, 13400))
+    try:
+        d, a = s.recvfrom(4096)
+    except socket.timeout:
+        return None, b'', None
+    _, _, pt, ln = struct.unpack('>BBHI', d[:8])
+    return pt, d[8:8 + ln], a[0]
+
+
+def discover(bcast):
+    pt, b, ip = udp(bcast, 0x0001)
+    if pt != 0x0004:
+        print('no vehicle announcement'); return None
+    print(f'entity          : {ip}')
+    print(f'VIN             : {b[0:17].decode("ascii", "replace")}')
+    print(f'logical address : 0x{int.from_bytes(b[17:19], "big"):04X}')
+    print(f'EID / GID       : {b[19:25].hex(":")} / {b[25:31].hex(":")}')
+    fa = b[31]
+    print(f'further action  : 0x{fa:02X}' + (' (routing activation required)' if fa else ' (none)'))
+    return ip
+
+
+def info(target):
+    for pt, name in ((0x4001, 'entity status'), (0x4003, 'power mode')):
+        rt, b, _ = udp(target, pt)
+        print(f'{name:15} : {b.hex() if b else "(no reply)"}')
+        if rt == 0x4002 and len(b) >= 7:
+            print(f'                  node_type=0x{b[0]:02X} max_sockets={b[1]} open={b[2]} '
+                  f'max_data={int.from_bytes(b[3:7], "big")}')
+
+
+class Conn:
+    def __init__(self, target, sa, timeout):
+        self.sa, self.timeout = sa, timeout
+        self.s = socket.create_connection((target, 13400), timeout)
+        self.s.settimeout(timeout)
+        self.s.sendall(hdr(0x0005, struct.pack('>HB', sa, 0) + b'\x00\x00\x00\x00'))
+        pt, b = rd(self.s)
+        if pt != 0x0006 or len(b) < 5:
+            raise SystemExit(f'routing activation failed: ptype=0x{pt:04x} {b.hex()}')
+        ta, ea, code = struct.unpack('>HHB', b[:5])
+        print(f'[+] routing activation: tester=0x{ta:04X} entity=0x{ea:04X} code=0x{code:02X} ({ACT.get(code, "?")})')
+        if code != 0x10:
+            raise SystemExit('not activated')
+
+    def uds(self, ta, payload, quiet=False):
+        self.s.sendall(hdr(0x8001, struct.pack('>HH', self.sa, ta) + payload))
+        while True:
+            try:
+                pt, b = rd(self.s)
+            except socket.timeout:
+                return None
+            if pt == 0x0007:                      # AliveCheckRequest
+                self.s.sendall(hdr(0x0008, struct.pack('>H', self.sa)))
+                continue
+            if pt == 0x8002:
+                continue
+            if pt == 0x8003:
+                if not quiet:
+                    print(f'    diag NACK 0x{b[4]:02X}' if len(b) > 4 else f'    diag NACK {b.hex()}')
+                return None
+            if pt != 0x8001:
+                if not quiet:
+                    print(f'    ptype=0x{pt:04x} {b.hex()}')
+                return None
+            r = b[4:]
+            if len(r) >= 3 and r[0] == 0x7F and r[2] == 0x78:
+                continue
+            return r
+
+
+def show(r):
+    if r is None:
+        print('    (no response)')
+    elif len(r) >= 3 and r[0] == 0x7F:
+        print(f'    NEGATIVE svc=0x{r[1]:02X} nrc=0x{r[2]:02X} {NRC.get(r[2], "?")}')
+    else:
+        txt = ''.join(chr(c) if 32 <= c < 127 else '.' for c in r)
+        print(f'    {r.hex()}\n    "{txt}"')
+
+
+def scan(c, lo=0x0000, hi=0x0FFF):
+    """Pipelined: blast a window of TesterPresent then drain."""
+    print(f'[*] scanning 0x{lo:04X}-0x{hi:04X} with 3E00 (TesterPresent)')
+    found = set()
+    for base in range(lo, hi + 1, 64):
+        for ta in range(base, min(base + 64, hi + 1)):
+            c.s.sendall(hdr(0x8001, struct.pack('>HH', c.sa, ta) + b'\x3e\x00'))
+        c.s.settimeout(0.6)
+        while True:
+            try:
+                pt, b = rd(c.s)
+            except socket.timeout:
+                break
+            if pt == 0x8001 and len(b) >= 5:
+                src, r = int.from_bytes(b[0:2], 'big'), b[4:]
+                if r[:1] == b'\x7e':
+                    found.add((src, 'positive'))
+                elif r[:1] == b'\x7f':
+                    found.add((src, f'nrc=0x{r[2]:02X} {NRC.get(r[2], "?")}'))
+    c.s.settimeout(c.timeout)
+    print(f'[+] {len(found)} responding logical addresses:')
+    for a, k in sorted(found):
+        print(f'   0x{a:04X}  {k}')
+    return [a for a, _ in sorted(found)]
+
+
+def ident(c, addrs):
+    for ta in addrs:
+        print(f'\n=== ECU 0x{ta:04X}')
+        for d, n in DIDS.items():
+            r = c.uds(ta, b'\x22' + struct.pack('>H', d), quiet=True)
+            if r and r[0] == 0x62:
+                v = r[3:]
+                t = ''.join(chr(x) if 32 <= x < 127 else '.' for x in v)
+                print(f'  {d:04X} {n:34} "{t[:48]}"')
+
+
+def main():
+    a = argparse.ArgumentParser()
+    a.add_argument('cmd', choices=['discover', 'info', 'send', 'scan', 'ident', 'shell'])
+    a.add_argument('data', nargs='*')
+    a.add_argument('-t', '--target', default='127.0.0.1')
+    a.add_argument('-b', '--broadcast', default='169.254.255.255')
+    a.add_argument('-s', '--sa', default='0x0EF4')
+    a.add_argument('-d', '--ta', default='0x0010')
+    a.add_argument('--timeout', type=float, default=4.0)
+    o = a.parse_args()
+    sa, ta = int(o.sa, 0), int(o.ta, 0)
+
+    if o.cmd == 'discover':
+        discover(o.broadcast); return
+    if o.cmd == 'info':
+        info(o.target); return
+
+    c = Conn(o.target, sa, o.timeout)
+    if o.cmd == 'send':
+        show(c.uds(ta, bytes.fromhex(''.join(o.data))))
+    elif o.cmd == 'scan':
+        scan(c)
+    elif o.cmd == 'ident':
+        ident(c, [int(x, 0) for x in o.data] or scan(c))
+    else:
+        print('hex to send, .ta <addr>, .scan, .ident, .info, .dids, .q')
+        while True:
+            try:
+                line = input(f'0x{ta:04X}> ').strip()
+            except (EOFError, KeyboardInterrupt):
+                break
+            if not line:
+                continue
+            if line in ('.q', '.quit'):
+                break
+            if line.startswith('.ta'):
+                ta = int(line.split()[1], 0); continue
+            if line == '.scan':
+                scan(c); continue
+            if line == '.ident':
+                ident(c, [ta]); continue
+            if line == '.info':
+                info(o.target); continue
+            if line == '.dids':
+                for d, n in DIDS.items():
+                    r = c.uds(ta, b'\x22' + struct.pack('>H', d), quiet=True)
+                    if r and r[0] == 0x62:
+                        v = r[3:]
+                        txt = ''.join(chr(x) if 32 <= x < 127 else '.' for x in v)
+                        print(f'  {d:04X} {n:42} {txt}')
+                continue
+            try:
+                show(c.uds(ta, bytes.fromhex(line.replace(' ', ''))))
+            except ValueError:
+                print('  not hex')
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/fuzzing/doip-uds/doip_tls.py b/fuzzing/doip-uds/doip_tls.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+"""
+DoIP over TLS client (ISO 13400 assigns port 3496). Does not verify the server
+chain. Use `map` to compare the UDS request filter against plaintext 13400.
+
+  ./doip_tls.py info
+  ./doip_tls.py map
+  ./doip_tls.py send 22 F1 86
+"""
+import argparse, socket, ssl, struct, sys
+
+V = 0x03
+NRC = {0x10: 'generalReject', 0x11: 'serviceNotSupported', 0x12: 'subFunctionNotSupported',
+       0x13: 'incorrectMessageLength', 0x22: 'conditionsNotCorrect', 0x24: 'requestSequenceError',
+       0x31: 'requestOutOfRange', 0x33: 'securityAccessDenied', 0x34: 'authenticationRequired',
+       0x35: 'invalidKey', 0x36: 'exceedNumberOfAttempts', 0x37: 'requiredTimeDelayNotExpired',
+       0x70: 'uploadDownloadNotAccepted', 0x7E: 'subFuncNotSupportedInSession',
+       0x7F: 'serviceNotSupportedInSession', 0xF0: 'vendor-specific'}
+NAMES = {0x10: 'DiagnosticSessionControl', 0x11: 'ECUReset', 0x14: 'ClearDiagnosticInformation',
+         0x19: 'ReadDTCInformation', 0x22: 'ReadDataByIdentifier', 0x23: 'ReadMemoryByAddress',
+         0x27: 'SecurityAccess', 0x28: 'CommunicationControl', 0x29: 'Authentication',
+         0x2E: 'WriteDataByIdentifier', 0x2F: 'InputOutputControlByIdentifier',
+         0x31: 'RoutineControl', 0x34: 'RequestDownload', 0x35: 'RequestUpload',
+         0x36: 'TransferData', 0x37: 'RequestTransferExit', 0x3D: 'WriteMemoryByAddress',
+         0x3E: 'TesterPresent', 0x85: 'ControlDTCSetting', 0x87: 'LinkControl'}
+
+
+def hdr(pt, pl=b''):
+    return struct.pack('>BBHI', V, 0xFF ^ V, pt, len(pl)) + pl
+
+
+class TlsDoIP:
+    def __init__(self, host, port, sa, ta, timeout=6.0):
+        self.sa, self.ta = sa, ta
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+        ctx.check_hostname = False
+        ctx.verify_mode = ssl.CERT_NONE
+        raw = socket.create_connection((host, port), timeout)
+        self.s = ctx.wrap_socket(raw, server_hostname=None)
+        self.s.settimeout(timeout)
+        c = self.s.getpeercert(binary_form=False)
+        print(f'[+] TLS {self.s.version()} {self.s.cipher()[0]}')
+        self.peercert = c
+
+    def _rd(self):
+        h = self.s.recv(8)
+        if len(h) < 8:
+            return None, b''
+        _, _, pt, ln = struct.unpack('>BBHI', h)
+        b = b''
+        while len(b) < ln:
+            c = self.s.recv(ln - len(b))
+            if not c:
+                break
+            b += c
+        return pt, b
+
+    def activate(self):
+        self.s.sendall(hdr(0x0005, struct.pack('>HB', self.sa, 0) + b'\0' * 4))
+        pt, b = self._rd()
+        if pt == 0x0006 and len(b) >= 5:
+            ta, ea, code = struct.unpack('>HHB', b[:5])
+            print(f'[+] routing activation over TLS: entity=0x{ea:04X} code=0x{code:02X}'
+                  + ('  ACTIVATED' if code == 0x10 else ''))
+            return code == 0x10
+        print(f'[-] routing activation failed: ptype={pt} {b.hex()}')
+        return False
+
+    def uds(self, payload):
+        self.s.sendall(hdr(0x8001, struct.pack('>HH', self.sa, self.ta) + payload))
+        while True:
+            try:
+                pt, b = self._rd()
+            except (socket.timeout, ssl.SSLError):
+                return None
+            if pt == 0x0007:                      # AliveCheckRequest
+                self.s.sendall(hdr(0x0008, struct.pack('>H', self.sa)))
+                continue
+            if pt == 0x8002:
+                continue
+            if pt == 0x8003 or pt is None:
+                return None
+            if pt != 0x8001:
+                continue
+            r = b[4:]
+            if len(r) >= 3 and r[0] == 0x7F and r[2] == 0x78:
+                continue
+            return r
+
+
+def show(r):
+    if r is None:
+        print('    (no response)')
+    elif len(r) >= 3 and r[0] == 0x7F:
+        print(f'    NEG svc=0x{r[1]:02X} nrc=0x{r[2]:02X} {NRC.get(r[2], "?")}')
+    else:
+        t = ''.join(chr(c) if 32 <= c < 127 else '.' for c in r)
+        print(f'    POS {r.hex()}  "{t}"')
+
+
+def main():
+    a = argparse.ArgumentParser()
+    a.add_argument('cmd', choices=['info', 'map', 'send'])
+    a.add_argument('data', nargs='*')
+    a.add_argument('-t', '--target', default='127.0.0.1')
+    a.add_argument('-p', '--port', type=int, default=3496)
+    a.add_argument('-s', '--sa', default='0x0EF4')
+    a.add_argument('-d', '--ta', default='0x0010')
+    o = a.parse_args()
+
+    c = TlsDoIP(o.target, o.port, int(o.sa, 0), int(o.ta, 0))
+    if o.cmd == 'info':
+        return 0
+    if not c.activate():
+        print('[-] cannot proceed without routing activation')
+        return 1
+
+    if o.cmd == 'send':
+        show(c.uds(bytes.fromhex(''.join(o.data))))
+        return 0
+
+    print('\n[*] bare-SID sweep over TLS, compare against plaintext 13400')
+    inter = []
+    for sid in range(0x100):
+        r = c.uds(bytes([sid]))
+        if r is None:
+            continue
+        if r[0] == 0x7F and len(r) >= 3 and r[2] == 0xF0:
+            continue                                  # 0xF0: vendor filter
+        inter.append(sid)
+        if r[0] == 0x7F:
+            print(f'  0x{sid:02X} {NAMES.get(sid, ""):30} NEG 0x{r[2]:02X} {NRC.get(r[2], "?")}')
+        else:
+            print(f'  0x{sid:02X} {NAMES.get(sid, ""):30} POS {r.hex()[:40]}')
+    print(f'\n[*] {len(inter)} services bypass the filter over TLS: '
+          + ' '.join(f'{x:02X}' for x in inter))
+    print('    plaintext 13400 gave exactly three: 29 36 37')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/fuzzing/doip-uds/doipfuzz.py b/fuzzing/doip-uds/doipfuzz.py
@@ -0,0 +1,295 @@
+#!/usr/bin/env python3
+"""
+DoIP / UDS fuzzer. One-shot cases on a fresh connection each time.
+
+  ./doipfuzz.py health
+  ./doipfuzz.py fuzz --stage hdr -n 500
+  ./doipfuzz.py fuzz --stage all -n 5000 --seed 1234
+  ./doipfuzz.py replay cases/000123.bin
+
+Never emits UDS 0x2E, 0x31, 0x11, 0x34, 0x35, 0x36, 0x37, 0x85, 0x28, or 0x27
+with subfunction 02. Those write, reset, or route. --unsafe lifts it.
+"""
+import argparse, collections, json, os, random, socket, struct, sys, time
+
+V = 0x03
+TESTER = 0x0EF4
+TARGET_TA = 0x0010
+WRITE_SERVICES = {0x2E, 0x31, 0x11, 0x34, 0x35, 0x36, 0x37, 0x85, 0x28}
+
+NACK = {0x00: 'incorrect pattern', 0x01: 'unknown payload type', 0x02: 'message too large',
+        0x03: 'out of memory', 0x04: 'invalid payload length'}
+
+PT_NAMES = {0x0000: 'GenericNack', 0x0001: 'VIR', 0x0004: 'VehicleAnnounce',
+            0x0005: 'RoutingActReq', 0x0006: 'RoutingActResp', 0x0007: 'AliveReq',
+            0x0008: 'AliveResp', 0x4001: 'EntityStatusReq', 0x4002: 'EntityStatusResp',
+            0x4003: 'PowerReq', 0x4004: 'PowerResp', 0x8001: 'DiagMsg',
+            0x8002: 'DiagAck', 0x8003: 'DiagNack'}
+
+
+def hdr(ptype, payload=b'', ver=V, inv=None, length=None):
+    inv = (0xFF ^ ver) if inv is None else inv
+    ln = len(payload) if length is None else length
+    return struct.pack('>BBHI', ver, inv, ptype, ln) + payload
+
+
+def udp_once(host, payload, timeout=2.0):
+    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    s.settimeout(timeout)
+    try:
+        s.sendto(payload, (host, 13400))
+        d, _ = s.recvfrom(4096)
+        return d
+    except socket.timeout:
+        return None
+    finally:
+        s.close()
+
+
+class Health:
+    def __init__(self, host, timeout=3.0):
+        self.host, self.timeout = host, timeout
+        self.base = None
+
+    def sample(self):
+        out = {}
+        r = udp_once(self.host, hdr(0x0001), self.timeout)
+        out['vir'] = r.hex() if r else None
+        r = udp_once(self.host, hdr(0x4001), self.timeout)
+        # byte 10 is the open-socket count, which our own connections move
+        if r and len(r) >= 11:
+            r = bytearray(r); r[10] = 0; r = bytes(r)
+        out['status'] = r.hex() if r else None
+        try:
+            t = socket.create_connection((self.host, 13400), self.timeout)
+            t.settimeout(self.timeout)
+            t.sendall(hdr(0x0005, struct.pack('>HB', TESTER, 0) + b'\0' * 4))
+            act = t.recv(4096)
+            out['routing'] = act.hex() if act else None
+            t.sendall(hdr(0x8001, struct.pack('>HH', TESTER, TARGET_TA) + b'\x22\xf1\x86'))
+            got = b''
+            end = time.time() + self.timeout
+            while time.time() < end:
+                try:
+                    d = t.recv(4096)
+                except socket.timeout:
+                    break
+                if not d:
+                    break
+                got += d
+                if b'\x62\xf1\x86' in got:
+                    break
+            out['uds'] = got.hex() if got else None
+            t.close()
+        except OSError as e:
+            out['routing'] = out['uds'] = f'ERR:{type(e).__name__}'
+        return out
+
+    def baseline(self):
+        self.base = self.sample()
+        return self.base
+
+    def check(self):
+        now = self.sample()
+        diffs = []
+        for k in ('vir', 'status', 'routing', 'uds'):
+            if now.get(k) != self.base.get(k):
+                diffs.append(f'{k}: {self.base.get(k)} -> {now.get(k)}')
+        return diffs, now
+
+
+def gen_hdr_cases(rnd):
+    body = struct.pack('>HB', TESTER, 0) + b'\0' * 4
+    yield 'ver_bad', hdr(0x0005, body, ver=rnd.choice([0x00, 0x01, 0x02, 0x04, 0xFE, 0xFF]))
+    yield 'inv_mismatch', hdr(0x0005, body, inv=rnd.randrange(256))
+    yield 'len_over', hdr(0x0005, body, length=rnd.choice([0xFFFFFFFF, 0x7FFFFFFF, 0x10000, len(body) + 1000]))
+    yield 'len_under', hdr(0x0005, body, length=rnd.randrange(0, len(body)))
+    yield 'len_zero_typed', hdr(0x0005, b'', length=0)
+    yield 'ptype_unknown', hdr(rnd.randrange(0x10000), bytes(rnd.randrange(0, 64)))
+    yield 'ptype_reserved', hdr(rnd.choice([0x0002, 0x0003, 0x0009, 0x4000, 0x8004, 0xF000]), bytes(8))
+    n = rnd.choice([1, 2, 3, 7])
+    yield 'truncated', hdr(0x4001)[:n]
+    yield 'hdr_only_huge', hdr(0x8001, b'', length=0xFFFFFF)
+    yield 'oversize_body', hdr(0x8001, struct.pack('>HH', TESTER, TARGET_TA) + bytes(rnd.randrange(16384, 40000)))
+
+
+def gen_routing_cases(rnd):
+    yield 'act_type_sweep', hdr(0x0005, struct.pack('>HB', TESTER, rnd.randrange(256)) + b'\0' * 4)
+    yield 'sa_sweep', hdr(0x0005, struct.pack('>HB', rnd.randrange(0x10000), 0) + b'\0' * 4)
+    yield 'act_short', hdr(0x0005, struct.pack('>HB', TESTER, 0)[:rnd.randrange(1, 3)])
+    yield 'act_long', hdr(0x0005, struct.pack('>HB', TESTER, 0) + bytes(rnd.randrange(4, 200)))
+    yield 'act_oem', hdr(0x0005, struct.pack('>HB', TESTER, 0) + b'\0' * 4 + os.urandom(4))
+
+
+def gen_uds_cases(rnd, unsafe):
+    def diag(pl, sa=TESTER, ta=TARGET_TA):
+        return hdr(0x8001, struct.pack('>HH', sa, ta) + pl)
+    svc = rnd.randrange(256)
+    if not unsafe and svc in WRITE_SERVICES:
+        svc = 0x22
+    yield 'uds_svc_sweep', diag(bytes([svc]) + bytes(rnd.randrange(0, 6)))
+    yield 'uds_did_sweep', diag(b'\x22' + struct.pack('>H', rnd.randrange(0x10000)))
+    yield 'uds_did_many', diag(b'\x22' + b''.join(struct.pack('>H', rnd.randrange(0x10000))
+                                                  for _ in range(rnd.randrange(2, 300))))
+    yield 'uds_trunc', diag(b'\x22\xf1')
+    yield 'uds_empty', diag(b'')
+    yield 'uds_dtc_mask', diag(bytes([0x19, rnd.randrange(0x20), rnd.randrange(256)]))
+    yield 'uds_addr_swap', diag(b'\x3e\x00', sa=rnd.randrange(0x10000), ta=rnd.randrange(0x10000))
+    yield 'uds_huge_did', diag(b'\x22' + os.urandom(rnd.randrange(200, 4096)))
+    yield 'uds_before_routing', None   # sent without routing activation
+
+
+STAGES = {'hdr': gen_hdr_cases, 'routing': gen_routing_cases, 'uds': lambda r: gen_uds_cases(r, False)}
+
+
+def send_case(host, data, pre_routing=True, timeout=3.0):
+    try:
+        t = socket.create_connection((host, 13400), timeout)
+    except OSError as e:
+        return f'CONNECT_FAIL:{type(e).__name__}'
+    t.settimeout(timeout)
+    try:
+        if pre_routing:
+            t.sendall(hdr(0x0005, struct.pack('>HB', TESTER, 0) + b'\0' * 4))
+            try:
+                t.recv(4096)
+            except socket.timeout:
+                return 'NO_ROUTING_RESP'
+        t.sendall(data)
+        got = b''
+        try:
+            while True:
+                d = t.recv(4096)
+                if not d:
+                    break
+                got += d
+                if len(got) > 65536:
+                    break
+        except socket.timeout:
+            pass
+        except ConnectionResetError:
+            if len(got) >= 9 and struct.unpack('>H', got[2:4])[0] == 0x0000:
+                return f'NACK:{NACK.get(got[8], got[8])}'
+            return 'RST_NO_REJECT' + (f' after {len(got)}B' if got else '')
+        if not got:
+            return 'SILENT'
+        pt = struct.unpack('>H', got[2:4])[0] if len(got) >= 4 else -1
+        if pt == 0x0000 and len(got) >= 9:
+            return f'NACK:{NACK.get(got[8], got[8])}'
+        return f'{PT_NAMES.get(pt, hex(pt))} {len(got)}B'
+    except BrokenPipeError:
+        return 'BROKEN_PIPE'
+    except OSError as e:
+        return f'ERR:{type(e).__name__}'
+    finally:
+        try:
+            t.close()
+        except OSError:
+            pass
+
+
+def main():
+    a = argparse.ArgumentParser()
+    a.add_argument('cmd', choices=['health', 'fuzz', 'replay'])
+    a.add_argument('arg', nargs='?')
+    a.add_argument('-t', '--target', default='127.0.0.1')
+    a.add_argument('-n', '--count', type=int, default=200)
+    a.add_argument('--stage', default='all', choices=['hdr', 'routing', 'uds', 'all'])
+    a.add_argument('--seed', type=int, default=None)
+    a.add_argument('--outdir', default='fuzz-cases')
+    a.add_argument('--delay', type=float, default=0.05)
+    a.add_argument('--check-every', type=int, default=25)
+    a.add_argument('--unsafe', action='store_true')
+    o = a.parse_args()
+
+    h = Health(o.target)
+
+    if o.cmd == 'health':
+        b = h.baseline()
+        for k, v in b.items():
+            print(f'  {k:8} {v}')
+        return 0
+
+    if o.cmd == 'replay':
+        data = open(o.arg, 'rb').read()
+        print(f'[*] baseline'); h.baseline()
+        print(f'[*] replaying {o.arg} ({len(data)}B): {data.hex()}')
+        print(f'    verdict: {send_case(o.target, data)}')
+        diffs, _ = h.check()
+        print('    health: ' + ('OK' if not diffs else 'CHANGED -> ' + '; '.join(diffs)))
+        return 0
+
+    seed = o.seed if o.seed is not None else random.randrange(1 << 30)
+    rnd = random.Random(seed)
+    os.makedirs(o.outdir, exist_ok=True)
+    print(f'[*] seed {seed}, stage {o.stage}, {o.count} cases, target {o.target}')
+
+    print('[*] baseline')
+    base = h.baseline()
+    if not base.get('vir'):
+        print('[!] no VehicleIdentificationResponse at baseline. Fix the link first.')
+        return 1
+    for k, v in base.items():
+        print(f'    {k:8} {(v or "")[:70]}')
+
+    gens = list(STAGES.values()) if o.stage == 'all' else [STAGES[o.stage]]
+    anomalies = 0
+    seen = collections.Counter()
+    # health runs every --check-every cases, so the case that broke it is
+    # usually not the one in hand when the change is noticed
+    recent = collections.deque(maxlen=max(200, o.check_every * 4))
+    for i in range(o.count):
+        g = rnd.choice(gens)
+        cases = [c for c in g(rnd) if c[1] is not None]
+        name, data = rnd.choice(cases)
+        verdict = send_case(o.target, data, pre_routing=(name != 'uds_before_routing'))
+        recent.append({'i': i, 'case': name, 'hex': data.hex(), 'verdict': verdict})
+
+        benign = verdict.startswith('NACK:') or (verdict == 'SILENT' and name == 'truncated')
+        interesting = (not benign) and verdict.startswith(
+            ('CONNECT_FAIL', 'RST_NO_REJECT', 'SILENT', 'BROKEN_PIPE', 'ERR', 'NO_ROUTING'))
+        if interesting:
+            anomalies += 1
+            key = (name, verdict.split(' after ')[0])
+            seen[key] += 1
+            if seen[key] == 1:
+                path = os.path.join(o.outdir, f'{i:06d}_{name}.bin')
+                open(path, 'wb').write(data)
+                with open(os.path.join(o.outdir, 'log.jsonl'), 'a') as f:
+                    f.write(json.dumps({'i': i, 'seed': seed, 'case': name,
+                                        'verdict': verdict, 'hex': data.hex(),
+                                        'file': path}) + '\n')
+                print(f'  [{i}] NEW {name:20} {verdict:28} saved {path}')
+
+        if i and i % o.check_every == 0:
+            diffs, now = h.check()
+            if diffs:
+                time.sleep(3)
+                diffs2, now2 = h.check()
+                state = 'PERSISTED' if diffs2 else 'RECOVERED'
+                print(f'  [{i}] HEALTH CHANGED after {name} -> {state}')
+                for d in diffs:
+                    print(f'        {d}')
+                with open(os.path.join(o.outdir, 'health.jsonl'), 'a') as f:
+                    f.write(json.dumps({'i': i, 'seed': seed, 'after_case': name,
+                                        'case_hex': data.hex(), 'diffs': diffs,
+                                        'recheck': state, 'diffs_after_3s': diffs2,
+                                        'now': now2 if diffs2 else now}) + '\n')
+                open(os.path.join(o.outdir, f'{i:06d}_HEALTH_{state}_{name}.bin'), 'wb').write(data)
+                win = os.path.join(o.outdir, f'{i:06d}_WINDOW_{state}.jsonl')
+                with open(win, 'w') as f:
+                    for r in recent:
+                        f.write(json.dumps(r) + '\n')
+                print(f'        {len(recent)} preceding cases -> {win}')
+                h.baseline()
+        time.sleep(o.delay)
+
+    print(f'[*] done. {anomalies} transport anomalies, {len(seen)} distinct.')
+    for (nm, vd), c in seen.most_common():
+        print(f'    {c:7d}  {nm:20} {vd}')
+    print(f'[*] check {o.outdir}/health.jsonl first, that is the real signal.')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/fuzzing/doip-uds/doipstate.py b/fuzzing/doip-uds/doipstate.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+"""
+Stateful DoIP / UDS fuzzer.
+
+  ./doipstate.py -t 127.0.0.1 -n 5000 --seed 1
+  ./doipstate.py replay sequences/000123.json
+
+Never emits UDS 0x2E, 0x31, 0x11, 0x34-0x37, 0x85, 0x28 or 0x27.
+"""
+import argparse, json, os, random, socket, struct, sys, time
+
+V, TESTER, ECU = 0x03, 0x0EF4, 0x0063
+SAS = [0x0EF3, 0x0EF4, 0x0EF5, 0x0E80, 0x0F00]
+READ_DIDS = [0xF186, 0xF190, 0xF187, 0xF18C, 0xF1A0, 0x1234]
+
+
+def hdr(ptype, payload=b''):
+    return struct.pack('>BBHI', V, 0xFF ^ V, ptype, len(payload)) + payload
+
+
+def diag(sa, ta, uds):
+    return hdr(0x8001, struct.pack('>HH', sa, ta) + uds)
+
+
+class Conn:
+    def __init__(self, target, sa):
+        self.sa, self.activated, self.dead = sa, False, False
+        self.sock = socket.socket()
+        self.sock.settimeout(3)
+        self.sock.connect((target, 13400))
+
+    def send(self, data):
+        try:
+            self.sock.sendall(data)
+        except OSError:
+            self.dead = True
+
+    def recv(self, answer_alive=True):
+        """Answers AliveCheckRequest unless told not to."""
+        try:
+            head = self.sock.recv(8)
+            if len(head) < 8:
+                self.dead = True
+                return None
+            ptype, n = struct.unpack('>H', head[2:4])[0], struct.unpack('>I', head[4:8])[0]
+            body = b''
+            while len(body) < n:
+                chunk = self.sock.recv(n - len(body))
+                if not chunk:
+                    break
+                body += chunk
+            if ptype == 0x0007 and answer_alive:
+                self.send(hdr(0x0008, struct.pack('>H', self.sa)))
+            return ptype, body
+        except OSError:
+            self.dead = True
+            return None
+
+    def close(self, rst=False):
+        try:
+            if rst:
+                self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
+                                     struct.pack('ii', 1, 0))
+            self.sock.close()
+        except OSError:
+            pass
+        self.dead = True
+
+
+def safe_uds(rnd):
+    """A UDS request that reads or changes session state but never writes."""
+    pick = rnd.randrange(6)
+    if pick == 0:
+        return bytes([0x10, rnd.choice([0x01, 0x02, 0x03, 0x04, 0x60])])
+    if pick == 1:
+        return bytes([0x3E, rnd.choice([0x00, 0x80])])
+    if pick == 2:
+        return bytes([0x22]) + struct.pack('>H', rnd.choice(READ_DIDS))
+    if pick == 3:
+        return bytes([0x27, rnd.choice([0x01, 0x03, 0x05, 0x11])])
+    if pick == 4:
+        return bytes([0x29, rnd.choice([0x00, 0x01, 0x03, 0x08])])
+    return bytes([0x19, rnd.choice([0x01, 0x02, 0x06]), 0xFF])
+
+
+def gen_sequence(rnd, length):
+    ops = []
+    for _ in range(length):
+        k = rnd.randrange(10)
+        if k <= 1:
+            ops.append({'op': 'open', 'sa': rnd.choice(SAS)})
+        elif k == 2:
+            ops.append({'op': 'activate', 'conn': rnd.randrange(4),
+                        'type': rnd.choice([0x00, 0x01, 0xE0])})
+        elif k <= 5:
+            ops.append({'op': 'uds', 'conn': rnd.randrange(4),
+                        'uds': safe_uds(rnd).hex()})
+        elif k == 6:
+            ops.append({'op': 'close', 'conn': rnd.randrange(4),
+                        'rst': rnd.random() < 0.5})
+        elif k == 7:
+            ops.append({'op': 'abandon', 'conn': rnd.randrange(4)})
+        elif k == 8:
+            ops.append({'op': 'idle', 'secs': rnd.choice([0.1, 0.5, 2])})
+        else:
+            ops.append({'op': 'uds_no_read', 'conn': rnd.randrange(4),
+                        'uds': safe_uds(rnd).hex()})
+    return ops
+
+
+def run_sequence(target, ops, leaked):
+    conns = []
+    for o in ops:
+        try:
+            if o['op'] == 'open':
+                if len(conns) < 8:
+                    conns.append(Conn(target, o['sa']))
+            elif not conns:
+                continue
+            elif o['op'] == 'activate':
+                c = conns[o['conn'] % len(conns)]
+                c.send(hdr(0x0005, struct.pack('>HB', c.sa, o['type']) + b'\0' * 4))
+                r = c.recv()
+                c.activated = bool(r and r[0] == 0x0006 and len(r[1]) > 4 and r[1][4] == 0x10)
+            elif o['op'] == 'uds':
+                c = conns[o['conn'] % len(conns)]
+                c.send(diag(c.sa, ECU, bytes.fromhex(o['uds'])))
+                c.recv()
+            elif o['op'] == 'uds_no_read':
+                c = conns[o['conn'] % len(conns)]
+                c.send(diag(c.sa, ECU, bytes.fromhex(o['uds'])))
+            elif o['op'] == 'close':
+                conns.pop(o['conn'] % len(conns)).close(rst=o['rst'])
+            elif o['op'] == 'abandon':
+                leaked.append(conns.pop(o['conn'] % len(conns)))
+            elif o['op'] == 'idle':
+                time.sleep(o['secs'])
+        except (OSError, IndexError):
+            pass
+    for c in conns:
+        c.close()
+
+
+def health(target):
+    out = {}
+    u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    u.settimeout(3)
+    try:
+        u.sendto(hdr(0x0001), (target, 13400))
+        out['vir'] = u.recvfrom(2048)[0].hex()[:40]
+    except OSError as e:
+        out['vir'] = 'ERR:' + type(e).__name__
+    finally:
+        u.close()
+
+    s = socket.socket()
+    s.settimeout(4)
+    try:
+        s.connect((target, 13400))
+        s.sendall(hdr(0x0005, struct.pack('>HB', TESTER, 0) + b'\0' * 4))
+        d = s.recv(64)
+        out['act'] = 'code=0x%02x' % d[12] if len(d) > 12 else 'SHORT:' + d.hex()
+        s.sendall(diag(TESTER, ECU, bytes([0x22, 0xF1, 0x86])))
+        r = s.recv(64)
+        out['uds'] = r.hex() if r else 'EMPTY'
+    except OSError as e:
+        out['act'] = out.get('act', 'ERR:' + type(e).__name__)
+        out.setdefault('uds', 'ERR:' + type(e).__name__)
+    finally:
+        s.close()
+    return out
+
+
+def main():
+    a = argparse.ArgumentParser()
+    a.add_argument('cmd', nargs='?', default='fuzz', choices=['fuzz', 'replay'])
+    a.add_argument('arg', nargs='?')
+    a.add_argument('-t', '--target', default='127.0.0.1')
+    a.add_argument('-n', '--count', type=int, default=2000)
+    a.add_argument('--seed', type=int, default=None)
+    a.add_argument('--len', type=int, default=12)
+    a.add_argument('--outdir', default='sequences')
+    o = a.parse_args()
+
+    if o.cmd == 'replay':
+        ops = json.load(open(o.arg))['ops']
+        leaked = []
+        print(f'[*] replaying {len(ops)} ops')
+        print('    before:', health(o.target))
+        run_sequence(o.target, ops, leaked)
+        print('    after: ', health(o.target))
+        time.sleep(3)
+        print('    +3s:   ', health(o.target))
+        return 0
+
+    os.makedirs(o.outdir, exist_ok=True)
+    seed = o.seed if o.seed is not None else random.randrange(1 << 30)
+    rnd = random.Random(seed)
+    print(f'[*] seed {seed}, {o.count} sequences of ~{o.len} ops, target {o.target}')
+
+    base = health(o.target)
+    print('[*] baseline', base)
+    if base.get('act') != 'code=0x10':
+        print('[!] not healthy at baseline, fix that first')
+        return 1
+
+    leaked = []
+    for i in range(o.count):
+        ops = gen_sequence(rnd, o.len)
+        # written before the run, so a sequence that kills the ECU survives it
+        cur = os.path.join(o.outdir, 'current.json')
+        json.dump({'i': i, 'seed': seed, 'ops': ops}, open(cur, 'w'))
+
+        run_sequence(o.target, ops, leaked)
+        now = health(o.target)
+
+        if now != base:
+            time.sleep(3)
+            again = health(o.target)
+            state = 'PERSISTED' if again != base else 'RECOVERED'
+            path = os.path.join(o.outdir, f'{i:06d}_{state}.json')
+            json.dump({'i': i, 'seed': seed, 'ops': ops, 'baseline': base,
+                       'after': now, 'after_3s': again, 'leaked_conns': len(leaked)},
+                      open(path, 'w'), indent=1)
+            print(f'  [{i}] HEALTH CHANGED {state} -> {path}')
+            for k in base:
+                if base[k] != now.get(k):
+                    print(f'        {k}: {base[k]} -> {now.get(k)}')
+            if state == 'PERSISTED':
+                print('        stopping so the state is preserved for inspection')
+                return 2
+        if i % 100 == 0:
+            print(f'  [{i}] ok, {len(leaked)} leaked conns held')
+    print('[*] done, no persistent health change')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/fuzzing/doip-uds/uds_protection_map.py b/fuzzing/doip-uds/uds_protection_map.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""
+  0x33 securityAccessDenied          -> SecurityAccess 0x27
+  0x34 authenticationRequired        -> Authentication 0x29
+  0x7F serviceNotSupportedInSession  -> session gated
+  0x7E subFunctionNotSupportedInSess -> session gated (subfunction)
+  0x13 incorrectMessageLength        -> REACHABLE, no gate hit
+  0x22 conditionsNotCorrect          -> reached handler, precondition unmet
+  0x11 serviceNotSupported           -> not implemented
+  non-standard codes                 -> vendor filter, never reaches the handler
+
+  sudo ./uds_protection_map.py -t 127.0.0.1
+  sudo ./uds_protection_map.py -t 127.0.0.1 --ta 0x0040 --all-sessions
+"""
+import argparse, socket, struct, sys
+
+V = 0x03
+GATE = {
+    0x33: ('SecurityAccess 0x27', 'gated'),
+    0x34: ('Authentication 0x29', 'gated'),
+    0x7F: ('session', 'gated'),
+    0x7E: ('session (subfunction)', 'gated'),
+    0x13: ('none, reachable', 'OPEN'),
+    0x12: ('none, subfunc invalid', 'OPEN'),
+    0x31: ('none, out of range', 'OPEN'),
+    0x22: ('precondition', 'reached'),
+    0x24: ('request sequence', 'reached'),
+    0x11: ('not implemented', 'absent'),
+    0xF0: ('vendor-specific', 'FILTERED'),
+}
+NAMES = {0x10: 'DiagnosticSessionControl', 0x11: 'ECUReset', 0x14: 'ClearDiagnosticInformation',
+         0x19: 'ReadDTCInformation', 0x22: 'ReadDataByIdentifier', 0x23: 'ReadMemoryByAddress',
+         0x24: 'ReadScalingDataByIdentifier', 0x27: 'SecurityAccess', 0x28: 'CommunicationControl',
+         0x29: 'Authentication', 0x2A: 'ReadDataByPeriodicIdentifier',
+         0x2C: 'DynamicallyDefineDataIdentifier', 0x2E: 'WriteDataByIdentifier',
+         0x2F: 'InputOutputControlByIdentifier', 0x31: 'RoutineControl', 0x34: 'RequestDownload',
+         0x35: 'RequestUpload', 0x36: 'TransferData', 0x37: 'RequestTransferExit',
+         0x38: 'RequestFileTransfer', 0x3D: 'WriteMemoryByAddress', 0x3E: 'TesterPresent',
+         0x83: 'AccessTimingParameter', 0x84: 'SecuredDataTransmission',
+         0x85: 'ControlDTCSetting', 0x86: 'ResponseOnEvent', 0x87: 'LinkControl'}
+
+
+class Session:
+    def __init__(self, host, sa, ta, timeout):
+        self.sa, self.ta, self.timeout = sa, ta, timeout
+        self.s = socket.create_connection((host, 13400), timeout)
+        self.s.settimeout(timeout)
+        self.s.sendall(self._h(0x0005, struct.pack('>HB', sa, 0) + b'\0' * 4))
+        self.s.recv(4096)
+
+    @staticmethod
+    def _h(pt, pl=b''):
+        return struct.pack('>BBHI', V, 0xFF ^ V, pt, len(pl)) + pl
+
+    def _rd(self):
+        h = self.s.recv(8)
+        if len(h) < 8:
+            return None, b''
+        _, _, pt, ln = struct.unpack('>BBHI', h)
+        b = b''
+        while len(b) < ln:
+            c = self.s.recv(ln - len(b))
+            if not c:
+                break
+            b += c
+        return pt, b
+
+    def uds(self, payload):
+        self.s.sendall(self._h(0x8001, struct.pack('>HH', self.sa, self.ta) + payload))
+        while True:
+            try:
+                pt, b = self._rd()
+            except socket.timeout:
+                return None
+            if pt == 0x0007:                      # AliveCheckRequest
+                self.s.sendall(self._h(0x0008, struct.pack('>H', self.sa)))
+                continue
+            if pt == 0x8002:
+                continue
+            if pt == 0x8003:
+                return None
+            if pt != 0x8001:
+                continue
+            r = b[4:]
+            if len(r) >= 3 and r[0] == 0x7F and r[2] == 0x78:
+                continue
+            return r
+
+
+def sweep(sess, label):
+    print(f'\n=== {label}')
+    print(f'  {"SID":5} {"service":34} {"NRC":5} {"verdict":10} gate')
+    buckets = {}
+    for sid in range(0x100):
+        r = sess.uds(bytes([sid]))
+        if r is None:
+            continue
+        if r[0] == 0x7F and len(r) >= 3:
+            nrc = r[2]
+            gate, verdict = GATE.get(nrc, (f'unknown 0x{nrc:02X}', '?'))
+        else:
+            nrc, gate, verdict = 0, 'accepted bare SID', 'OPEN'
+        if verdict == 'absent':
+            continue
+        buckets.setdefault(verdict, []).append(sid)
+        print(f'  0x{sid:02X}  {NAMES.get(sid, ""):34} 0x{nrc:02X}  {verdict:10} {gate}')
+    print(f'\n  summary for {label}:')
+    for v in ('OPEN', 'reached', 'gated', 'FILTERED'):
+        if v in buckets:
+            print(f'    {v:9} {len(buckets[v]):3}  ' + ' '.join(f'{x:02X}' for x in buckets[v]))
+    return buckets
+
+
+def main():
+    a = argparse.ArgumentParser()
+    a.add_argument('-t', '--target', default='127.0.0.1')
+    a.add_argument('-s', '--sa', default='0x0EF4')
+    a.add_argument('-d', '--ta', default='0x0010')
+    a.add_argument('--timeout', type=float, default=3.0)
+    a.add_argument('--all-sessions', action='store_true', help='also probe in extended session')
+    o = a.parse_args()
+
+    sess = Session(o.target, int(o.sa, 0), int(o.ta, 0), o.timeout)
+    print(f'[*] target {o.target} ta={o.ta}, bare-SID probe (always invalid length, never executes)')
+    sweep(sess, 'default session (10 01)')
+
+    if o.all_sessions:
+        r = sess.uds(b'\x10\x03')
+        if r and r[0] == 0x50:
+            sweep(sess, 'extended session (10 03)')
+            sess.uds(b'\x10\x01')
+        else:
+            print('\n[-] could not enter extended session')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())