etc.git


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