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())
| | | | | |