etc~ scripts and random things |
| git clone https://github.com/djnnvx/etc.git |
| log | files | refs | archive |
doipstate.py (8351B)
1 #!/usr/bin/env python3 2 """ 3 Stateful DoIP / UDS fuzzer. 4 5 ./doipstate.py -t 127.0.0.1 -n 5000 --seed 1 6 ./doipstate.py replay sequences/000123.json 7 8 Never emits UDS 0x2E, 0x31, 0x11, 0x34-0x37, 0x85, 0x28 or 0x27. 9 """ 10 import argparse, json, os, random, socket, struct, sys, time 11 12 V, TESTER, ECU = 0x03, 0x0EF4, 0x0063 13 SAS = [0x0EF3, 0x0EF4, 0x0EF5, 0x0E80, 0x0F00] 14 READ_DIDS = [0xF186, 0xF190, 0xF187, 0xF18C, 0xF1A0, 0x1234] 15 16 17 def hdr(ptype, payload=b''): 18 return struct.pack('>BBHI', V, 0xFF ^ V, ptype, len(payload)) + payload 19 20 21 def diag(sa, ta, uds): 22 return hdr(0x8001, struct.pack('>HH', sa, ta) + uds) 23 24 25 class Conn: 26 def __init__(self, target, sa): 27 self.sa, self.activated, self.dead = sa, False, False 28 self.sock = socket.socket() 29 self.sock.settimeout(3) 30 self.sock.connect((target, 13400)) 31 32 def send(self, data): 33 try: 34 self.sock.sendall(data) 35 except OSError: 36 self.dead = True 37 38 def recv(self, answer_alive=True): 39 """Answers AliveCheckRequest unless told not to.""" 40 try: 41 head = self.sock.recv(8) 42 if len(head) < 8: 43 self.dead = True 44 return None 45 ptype, n = struct.unpack('>H', head[2:4])[0], struct.unpack('>I', head[4:8])[0] 46 body = b'' 47 while len(body) < n: 48 chunk = self.sock.recv(n - len(body)) 49 if not chunk: 50 break 51 body += chunk 52 if ptype == 0x0007 and answer_alive: 53 self.send(hdr(0x0008, struct.pack('>H', self.sa))) 54 return ptype, body 55 except OSError: 56 self.dead = True 57 return None 58 59 def close(self, rst=False): 60 try: 61 if rst: 62 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, 63 struct.pack('ii', 1, 0)) 64 self.sock.close() 65 except OSError: 66 pass 67 self.dead = True 68 69 70 def safe_uds(rnd): 71 """A UDS request that reads or changes session state but never writes.""" 72 pick = rnd.randrange(6) 73 if pick == 0: 74 return bytes([0x10, rnd.choice([0x01, 0x02, 0x03, 0x04, 0x60])]) 75 if pick == 1: 76 return bytes([0x3E, rnd.choice([0x00, 0x80])]) 77 if pick == 2: 78 return bytes([0x22]) + struct.pack('>H', rnd.choice(READ_DIDS)) 79 if pick == 3: 80 return bytes([0x27, rnd.choice([0x01, 0x03, 0x05, 0x11])]) 81 if pick == 4: 82 return bytes([0x29, rnd.choice([0x00, 0x01, 0x03, 0x08])]) 83 return bytes([0x19, rnd.choice([0x01, 0x02, 0x06]), 0xFF]) 84 85 86 def gen_sequence(rnd, length): 87 ops = [] 88 for _ in range(length): 89 k = rnd.randrange(10) 90 if k <= 1: 91 ops.append({'op': 'open', 'sa': rnd.choice(SAS)}) 92 elif k == 2: 93 ops.append({'op': 'activate', 'conn': rnd.randrange(4), 94 'type': rnd.choice([0x00, 0x01, 0xE0])}) 95 elif k <= 5: 96 ops.append({'op': 'uds', 'conn': rnd.randrange(4), 97 'uds': safe_uds(rnd).hex()}) 98 elif k == 6: 99 ops.append({'op': 'close', 'conn': rnd.randrange(4), 100 'rst': rnd.random() < 0.5}) 101 elif k == 7: 102 ops.append({'op': 'abandon', 'conn': rnd.randrange(4)}) 103 elif k == 8: 104 ops.append({'op': 'idle', 'secs': rnd.choice([0.1, 0.5, 2])}) 105 else: 106 ops.append({'op': 'uds_no_read', 'conn': rnd.randrange(4), 107 'uds': safe_uds(rnd).hex()}) 108 return ops 109 110 111 def run_sequence(target, ops, leaked): 112 conns = [] 113 for o in ops: 114 try: 115 if o['op'] == 'open': 116 if len(conns) < 8: 117 conns.append(Conn(target, o['sa'])) 118 elif not conns: 119 continue 120 elif o['op'] == 'activate': 121 c = conns[o['conn'] % len(conns)] 122 c.send(hdr(0x0005, struct.pack('>HB', c.sa, o['type']) + b'\0' * 4)) 123 r = c.recv() 124 c.activated = bool(r and r[0] == 0x0006 and len(r[1]) > 4 and r[1][4] == 0x10) 125 elif o['op'] == 'uds': 126 c = conns[o['conn'] % len(conns)] 127 c.send(diag(c.sa, ECU, bytes.fromhex(o['uds']))) 128 c.recv() 129 elif o['op'] == 'uds_no_read': 130 c = conns[o['conn'] % len(conns)] 131 c.send(diag(c.sa, ECU, bytes.fromhex(o['uds']))) 132 elif o['op'] == 'close': 133 conns.pop(o['conn'] % len(conns)).close(rst=o['rst']) 134 elif o['op'] == 'abandon': 135 leaked.append(conns.pop(o['conn'] % len(conns))) 136 elif o['op'] == 'idle': 137 time.sleep(o['secs']) 138 except (OSError, IndexError): 139 pass 140 for c in conns: 141 c.close() 142 143 144 def health(target): 145 out = {} 146 u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 147 u.settimeout(3) 148 try: 149 u.sendto(hdr(0x0001), (target, 13400)) 150 out['vir'] = u.recvfrom(2048)[0].hex()[:40] 151 except OSError as e: 152 out['vir'] = 'ERR:' + type(e).__name__ 153 finally: 154 u.close() 155 156 s = socket.socket() 157 s.settimeout(4) 158 try: 159 s.connect((target, 13400)) 160 s.sendall(hdr(0x0005, struct.pack('>HB', TESTER, 0) + b'\0' * 4)) 161 d = s.recv(64) 162 out['act'] = 'code=0x%02x' % d[12] if len(d) > 12 else 'SHORT:' + d.hex() 163 s.sendall(diag(TESTER, ECU, bytes([0x22, 0xF1, 0x86]))) 164 r = s.recv(64) 165 out['uds'] = r.hex() if r else 'EMPTY' 166 except OSError as e: 167 out['act'] = out.get('act', 'ERR:' + type(e).__name__) 168 out.setdefault('uds', 'ERR:' + type(e).__name__) 169 finally: 170 s.close() 171 return out 172 173 174 def main(): 175 a = argparse.ArgumentParser() 176 a.add_argument('cmd', nargs='?', default='fuzz', choices=['fuzz', 'replay']) 177 a.add_argument('arg', nargs='?') 178 a.add_argument('-t', '--target', default='127.0.0.1') 179 a.add_argument('-n', '--count', type=int, default=2000) 180 a.add_argument('--seed', type=int, default=None) 181 a.add_argument('--len', type=int, default=12) 182 a.add_argument('--outdir', default='sequences') 183 o = a.parse_args() 184 185 if o.cmd == 'replay': 186 ops = json.load(open(o.arg))['ops'] 187 leaked = [] 188 print(f'[*] replaying {len(ops)} ops') 189 print(' before:', health(o.target)) 190 run_sequence(o.target, ops, leaked) 191 print(' after: ', health(o.target)) 192 time.sleep(3) 193 print(' +3s: ', health(o.target)) 194 return 0 195 196 os.makedirs(o.outdir, exist_ok=True) 197 seed = o.seed if o.seed is not None else random.randrange(1 << 30) 198 rnd = random.Random(seed) 199 print(f'[*] seed {seed}, {o.count} sequences of ~{o.len} ops, target {o.target}') 200 201 base = health(o.target) 202 print('[*] baseline', base) 203 if base.get('act') != 'code=0x10': 204 print('[!] not healthy at baseline, fix that first') 205 return 1 206 207 leaked = [] 208 for i in range(o.count): 209 ops = gen_sequence(rnd, o.len) 210 # written before the run, so a sequence that kills the ECU survives it 211 cur = os.path.join(o.outdir, 'current.json') 212 json.dump({'i': i, 'seed': seed, 'ops': ops}, open(cur, 'w')) 213 214 run_sequence(o.target, ops, leaked) 215 now = health(o.target) 216 217 if now != base: 218 time.sleep(3) 219 again = health(o.target) 220 state = 'PERSISTED' if again != base else 'RECOVERED' 221 path = os.path.join(o.outdir, f'{i:06d}_{state}.json') 222 json.dump({'i': i, 'seed': seed, 'ops': ops, 'baseline': base, 223 'after': now, 'after_3s': again, 'leaked_conns': len(leaked)}, 224 open(path, 'w'), indent=1) 225 print(f' [{i}] HEALTH CHANGED {state} -> {path}') 226 for k in base: 227 if base[k] != now.get(k): 228 print(f' {k}: {base[k]} -> {now.get(k)}') 229 if state == 'PERSISTED': 230 print(' stopping so the state is preserved for inspection') 231 return 2 232 if i % 100 == 0: 233 print(f' [{i}] ok, {len(leaked)} leaked conns held') 234 print('[*] done, no persistent health change') 235 return 0 236 237 238 if __name__ == '__main__': 239 sys.exit(main())