etc~ scripts and random things |
| git clone https://github.com/djnnvx/etc.git |
| log | files | refs | archive |
doipfuzz.py (12247B)
1 #!/usr/bin/env python3 2 """ 3 DoIP / UDS fuzzer. One-shot cases on a fresh connection each time. 4 5 ./doipfuzz.py health 6 ./doipfuzz.py fuzz --stage hdr -n 500 7 ./doipfuzz.py fuzz --stage all -n 5000 --seed 1234 8 ./doipfuzz.py replay cases/000123.bin 9 10 Never emits UDS 0x2E, 0x31, 0x11, 0x34, 0x35, 0x36, 0x37, 0x85, 0x28, or 0x27 11 with subfunction 02. Those write, reset, or route. --unsafe lifts it. 12 """ 13 import argparse, collections, json, os, random, socket, struct, sys, time 14 15 V = 0x03 16 TESTER = 0x0EF4 17 TARGET_TA = 0x0010 18 WRITE_SERVICES = {0x2E, 0x31, 0x11, 0x34, 0x35, 0x36, 0x37, 0x85, 0x28} 19 20 NACK = {0x00: 'incorrect pattern', 0x01: 'unknown payload type', 0x02: 'message too large', 21 0x03: 'out of memory', 0x04: 'invalid payload length'} 22 23 PT_NAMES = {0x0000: 'GenericNack', 0x0001: 'VIR', 0x0004: 'VehicleAnnounce', 24 0x0005: 'RoutingActReq', 0x0006: 'RoutingActResp', 0x0007: 'AliveReq', 25 0x0008: 'AliveResp', 0x4001: 'EntityStatusReq', 0x4002: 'EntityStatusResp', 26 0x4003: 'PowerReq', 0x4004: 'PowerResp', 0x8001: 'DiagMsg', 27 0x8002: 'DiagAck', 0x8003: 'DiagNack'} 28 29 30 def hdr(ptype, payload=b'', ver=V, inv=None, length=None): 31 inv = (0xFF ^ ver) if inv is None else inv 32 ln = len(payload) if length is None else length 33 return struct.pack('>BBHI', ver, inv, ptype, ln) + payload 34 35 36 def udp_once(host, payload, timeout=2.0): 37 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 38 s.settimeout(timeout) 39 try: 40 s.sendto(payload, (host, 13400)) 41 d, _ = s.recvfrom(4096) 42 return d 43 except socket.timeout: 44 return None 45 finally: 46 s.close() 47 48 49 class Health: 50 def __init__(self, host, timeout=3.0): 51 self.host, self.timeout = host, timeout 52 self.base = None 53 54 def sample(self): 55 out = {} 56 r = udp_once(self.host, hdr(0x0001), self.timeout) 57 out['vir'] = r.hex() if r else None 58 r = udp_once(self.host, hdr(0x4001), self.timeout) 59 # byte 10 is the open-socket count, which our own connections move 60 if r and len(r) >= 11: 61 r = bytearray(r); r[10] = 0; r = bytes(r) 62 out['status'] = r.hex() if r else None 63 try: 64 t = socket.create_connection((self.host, 13400), self.timeout) 65 t.settimeout(self.timeout) 66 t.sendall(hdr(0x0005, struct.pack('>HB', TESTER, 0) + b'\0' * 4)) 67 act = t.recv(4096) 68 out['routing'] = act.hex() if act else None 69 t.sendall(hdr(0x8001, struct.pack('>HH', TESTER, TARGET_TA) + b'\x22\xf1\x86')) 70 got = b'' 71 end = time.time() + self.timeout 72 while time.time() < end: 73 try: 74 d = t.recv(4096) 75 except socket.timeout: 76 break 77 if not d: 78 break 79 got += d 80 if b'\x62\xf1\x86' in got: 81 break 82 out['uds'] = got.hex() if got else None 83 t.close() 84 except OSError as e: 85 out['routing'] = out['uds'] = f'ERR:{type(e).__name__}' 86 return out 87 88 def baseline(self): 89 self.base = self.sample() 90 return self.base 91 92 def check(self): 93 now = self.sample() 94 diffs = [] 95 for k in ('vir', 'status', 'routing', 'uds'): 96 if now.get(k) != self.base.get(k): 97 diffs.append(f'{k}: {self.base.get(k)} -> {now.get(k)}') 98 return diffs, now 99 100 101 def gen_hdr_cases(rnd): 102 body = struct.pack('>HB', TESTER, 0) + b'\0' * 4 103 yield 'ver_bad', hdr(0x0005, body, ver=rnd.choice([0x00, 0x01, 0x02, 0x04, 0xFE, 0xFF])) 104 yield 'inv_mismatch', hdr(0x0005, body, inv=rnd.randrange(256)) 105 yield 'len_over', hdr(0x0005, body, length=rnd.choice([0xFFFFFFFF, 0x7FFFFFFF, 0x10000, len(body) + 1000])) 106 yield 'len_under', hdr(0x0005, body, length=rnd.randrange(0, len(body))) 107 yield 'len_zero_typed', hdr(0x0005, b'', length=0) 108 yield 'ptype_unknown', hdr(rnd.randrange(0x10000), bytes(rnd.randrange(0, 64))) 109 yield 'ptype_reserved', hdr(rnd.choice([0x0002, 0x0003, 0x0009, 0x4000, 0x8004, 0xF000]), bytes(8)) 110 n = rnd.choice([1, 2, 3, 7]) 111 yield 'truncated', hdr(0x4001)[:n] 112 yield 'hdr_only_huge', hdr(0x8001, b'', length=0xFFFFFF) 113 yield 'oversize_body', hdr(0x8001, struct.pack('>HH', TESTER, TARGET_TA) + bytes(rnd.randrange(16384, 40000))) 114 115 116 def gen_routing_cases(rnd): 117 yield 'act_type_sweep', hdr(0x0005, struct.pack('>HB', TESTER, rnd.randrange(256)) + b'\0' * 4) 118 yield 'sa_sweep', hdr(0x0005, struct.pack('>HB', rnd.randrange(0x10000), 0) + b'\0' * 4) 119 yield 'act_short', hdr(0x0005, struct.pack('>HB', TESTER, 0)[:rnd.randrange(1, 3)]) 120 yield 'act_long', hdr(0x0005, struct.pack('>HB', TESTER, 0) + bytes(rnd.randrange(4, 200))) 121 yield 'act_oem', hdr(0x0005, struct.pack('>HB', TESTER, 0) + b'\0' * 4 + os.urandom(4)) 122 123 124 def gen_uds_cases(rnd, unsafe): 125 def diag(pl, sa=TESTER, ta=TARGET_TA): 126 return hdr(0x8001, struct.pack('>HH', sa, ta) + pl) 127 svc = rnd.randrange(256) 128 if not unsafe and svc in WRITE_SERVICES: 129 svc = 0x22 130 yield 'uds_svc_sweep', diag(bytes([svc]) + bytes(rnd.randrange(0, 6))) 131 yield 'uds_did_sweep', diag(b'\x22' + struct.pack('>H', rnd.randrange(0x10000))) 132 yield 'uds_did_many', diag(b'\x22' + b''.join(struct.pack('>H', rnd.randrange(0x10000)) 133 for _ in range(rnd.randrange(2, 300)))) 134 yield 'uds_trunc', diag(b'\x22\xf1') 135 yield 'uds_empty', diag(b'') 136 yield 'uds_dtc_mask', diag(bytes([0x19, rnd.randrange(0x20), rnd.randrange(256)])) 137 yield 'uds_addr_swap', diag(b'\x3e\x00', sa=rnd.randrange(0x10000), ta=rnd.randrange(0x10000)) 138 yield 'uds_huge_did', diag(b'\x22' + os.urandom(rnd.randrange(200, 4096))) 139 yield 'uds_before_routing', None # sent without routing activation 140 141 142 STAGES = {'hdr': gen_hdr_cases, 'routing': gen_routing_cases, 'uds': lambda r: gen_uds_cases(r, False)} 143 144 145 def send_case(host, data, pre_routing=True, timeout=3.0): 146 try: 147 t = socket.create_connection((host, 13400), timeout) 148 except OSError as e: 149 return f'CONNECT_FAIL:{type(e).__name__}' 150 t.settimeout(timeout) 151 try: 152 if pre_routing: 153 t.sendall(hdr(0x0005, struct.pack('>HB', TESTER, 0) + b'\0' * 4)) 154 try: 155 t.recv(4096) 156 except socket.timeout: 157 return 'NO_ROUTING_RESP' 158 t.sendall(data) 159 got = b'' 160 try: 161 while True: 162 d = t.recv(4096) 163 if not d: 164 break 165 got += d 166 if len(got) > 65536: 167 break 168 except socket.timeout: 169 pass 170 except ConnectionResetError: 171 if len(got) >= 9 and struct.unpack('>H', got[2:4])[0] == 0x0000: 172 return f'NACK:{NACK.get(got[8], got[8])}' 173 return 'RST_NO_REJECT' + (f' after {len(got)}B' if got else '') 174 if not got: 175 return 'SILENT' 176 pt = struct.unpack('>H', got[2:4])[0] if len(got) >= 4 else -1 177 if pt == 0x0000 and len(got) >= 9: 178 return f'NACK:{NACK.get(got[8], got[8])}' 179 return f'{PT_NAMES.get(pt, hex(pt))} {len(got)}B' 180 except BrokenPipeError: 181 return 'BROKEN_PIPE' 182 except OSError as e: 183 return f'ERR:{type(e).__name__}' 184 finally: 185 try: 186 t.close() 187 except OSError: 188 pass 189 190 191 def main(): 192 a = argparse.ArgumentParser() 193 a.add_argument('cmd', choices=['health', 'fuzz', 'replay']) 194 a.add_argument('arg', nargs='?') 195 a.add_argument('-t', '--target', default='127.0.0.1') 196 a.add_argument('-n', '--count', type=int, default=200) 197 a.add_argument('--stage', default='all', choices=['hdr', 'routing', 'uds', 'all']) 198 a.add_argument('--seed', type=int, default=None) 199 a.add_argument('--outdir', default='fuzz-cases') 200 a.add_argument('--delay', type=float, default=0.05) 201 a.add_argument('--check-every', type=int, default=25) 202 a.add_argument('--unsafe', action='store_true') 203 o = a.parse_args() 204 205 h = Health(o.target) 206 207 if o.cmd == 'health': 208 b = h.baseline() 209 for k, v in b.items(): 210 print(f' {k:8} {v}') 211 return 0 212 213 if o.cmd == 'replay': 214 data = open(o.arg, 'rb').read() 215 print(f'[*] baseline'); h.baseline() 216 print(f'[*] replaying {o.arg} ({len(data)}B): {data.hex()}') 217 print(f' verdict: {send_case(o.target, data)}') 218 diffs, _ = h.check() 219 print(' health: ' + ('OK' if not diffs else 'CHANGED -> ' + '; '.join(diffs))) 220 return 0 221 222 seed = o.seed if o.seed is not None else random.randrange(1 << 30) 223 rnd = random.Random(seed) 224 os.makedirs(o.outdir, exist_ok=True) 225 print(f'[*] seed {seed}, stage {o.stage}, {o.count} cases, target {o.target}') 226 227 print('[*] baseline') 228 base = h.baseline() 229 if not base.get('vir'): 230 print('[!] no VehicleIdentificationResponse at baseline. Fix the link first.') 231 return 1 232 for k, v in base.items(): 233 print(f' {k:8} {(v or "")[:70]}') 234 235 gens = list(STAGES.values()) if o.stage == 'all' else [STAGES[o.stage]] 236 anomalies = 0 237 seen = collections.Counter() 238 # health runs every --check-every cases, so the case that broke it is 239 # usually not the one in hand when the change is noticed 240 recent = collections.deque(maxlen=max(200, o.check_every * 4)) 241 for i in range(o.count): 242 g = rnd.choice(gens) 243 cases = [c for c in g(rnd) if c[1] is not None] 244 name, data = rnd.choice(cases) 245 verdict = send_case(o.target, data, pre_routing=(name != 'uds_before_routing')) 246 recent.append({'i': i, 'case': name, 'hex': data.hex(), 'verdict': verdict}) 247 248 benign = verdict.startswith('NACK:') or (verdict == 'SILENT' and name == 'truncated') 249 interesting = (not benign) and verdict.startswith( 250 ('CONNECT_FAIL', 'RST_NO_REJECT', 'SILENT', 'BROKEN_PIPE', 'ERR', 'NO_ROUTING')) 251 if interesting: 252 anomalies += 1 253 key = (name, verdict.split(' after ')[0]) 254 seen[key] += 1 255 if seen[key] == 1: 256 path = os.path.join(o.outdir, f'{i:06d}_{name}.bin') 257 open(path, 'wb').write(data) 258 with open(os.path.join(o.outdir, 'log.jsonl'), 'a') as f: 259 f.write(json.dumps({'i': i, 'seed': seed, 'case': name, 260 'verdict': verdict, 'hex': data.hex(), 261 'file': path}) + '\n') 262 print(f' [{i}] NEW {name:20} {verdict:28} saved {path}') 263 264 if i and i % o.check_every == 0: 265 diffs, now = h.check() 266 if diffs: 267 time.sleep(3) 268 diffs2, now2 = h.check() 269 state = 'PERSISTED' if diffs2 else 'RECOVERED' 270 print(f' [{i}] HEALTH CHANGED after {name} -> {state}') 271 for d in diffs: 272 print(f' {d}') 273 with open(os.path.join(o.outdir, 'health.jsonl'), 'a') as f: 274 f.write(json.dumps({'i': i, 'seed': seed, 'after_case': name, 275 'case_hex': data.hex(), 'diffs': diffs, 276 'recheck': state, 'diffs_after_3s': diffs2, 277 'now': now2 if diffs2 else now}) + '\n') 278 open(os.path.join(o.outdir, f'{i:06d}_HEALTH_{state}_{name}.bin'), 'wb').write(data) 279 win = os.path.join(o.outdir, f'{i:06d}_WINDOW_{state}.jsonl') 280 with open(win, 'w') as f: 281 for r in recent: 282 f.write(json.dumps(r) + '\n') 283 print(f' {len(recent)} preceding cases -> {win}') 284 h.baseline() 285 time.sleep(o.delay) 286 287 print(f'[*] done. {anomalies} transport anomalies, {len(seen)} distinct.') 288 for (nm, vd), c in seen.most_common(): 289 print(f' {c:7d} {nm:20} {vd}') 290 print(f'[*] check {o.outdir}/health.jsonl first, that is the real signal.') 291 return 0 292 293 294 if __name__ == '__main__': 295 sys.exit(main())