etc.git


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