#! /usr/bin/env python # This file is part of Scapy # See http://www.secdev.org/projects/scapy for more information # Copyright (C) Nils Weiss # This program is published under a GPLv2 license # scapy.contrib.description = Helper class for tracking ECU states (ECU) # scapy.contrib.status = loads import time import random from collections import defaultdict from scapy.packet import Raw, Packet from scapy.plist import PacketList from scapy.error import Scapy_Exception from scapy.sessions import DefaultSession from scapy.ansmachine import AnsweringMachine __all__ = ["ECU", "ECUResponse", "ECUSession", "ECU_am"] class ECU(object): """A ECU object can be used to - track the states of an ECU. - to log all modification to an ECU - to extract supported responses of a real ECU Usage: >>> print("This ecu logs, tracks and creates supported responses") >>> my_virtual_ecu = ECU() >>> my_virtual_ecu.update(PacketList([...])) >>> my_virtual_ecu.supported_responses >>> print("Another ecu just tracks") >>> my_tracking_ecu = ECU(logging=False, store_supported_responses=False) # noqa: E501 >>> my_tracking_ecu.update(PacketList([...])) >>> print("Another ecu just logs all modifications to it") >>> my_logging_ecu = ECU(verbose=False, store_supported_responses=False) # noqa: E501 >>> my_logging_ecu.update(PacketList([...])) >>> my_logging_ecu.log >>> print("Another ecu just creates supported responses") >>> my_response_ecu = ECU(verbose=False, logging=False) >>> my_response_ecu.update(PacketList([...])) >>> my_response_ecu.supported_responses """ def __init__(self, init_session=None, init_security_level=None, init_communication_control=None, logging=True, verbose=True, store_supported_responses=True): """ Initialize an ECU object :param init_session: An initial session :param init_security_level: An initial security level :param init_communication_control: An initial communication control setting :param logging: Turn logging on or off. Default is on. :param verbose: Turn tracking on or off. Default is on. :param store_supported_responses: Turn creation of supported responses on or off. Default is on. """ self.current_session = init_session or 1 self.current_security_level = init_security_level or 0 self.communication_control = init_communication_control or 0 self.verbose = verbose self.logging = logging self.store_supported_responses = store_supported_responses self.log = defaultdict(list) self._supported_responses = list() self._unanswered_packets = PacketList() def reset(self): self.current_session = 1 self.current_security_level = 0 self.communication_control = 0 def update(self, p): if isinstance(p, PacketList): for pkt in p: self._update(pkt) elif not isinstance(p, Packet): raise Scapy_Exception("Provide a Packet object for an update") else: self._update(p) def _update(self, pkt): if self.verbose: print(repr(self), repr(pkt)) if self.store_supported_responses: self._update_supported_responses(pkt) if self.logging: self._update_log(pkt) self._update_internal_state(pkt) def _update_log(self, pkt): for l in pkt.layers(): if hasattr(l, "get_log"): log_key, log_value = l.get_log(pkt) self.log[log_key].append((pkt.time, log_value)) def _update_internal_state(self, pkt): for l in pkt.layers(): if hasattr(l, "modifies_ecu_state"): l.modifies_ecu_state(pkt, self) def _update_supported_responses(self, pkt): self._unanswered_packets += PacketList([pkt]) answered, unanswered = self._unanswered_packets.sr() for _, resp in answered: ecu_resp = ECUResponse(session=self.current_session, security_level=self.current_security_level, responses=resp) if ecu_resp not in self._supported_responses: if self.verbose: print("[+] ", repr(ecu_resp)) self._supported_responses.append(ecu_resp) else: if self.verbose: print("[-] ", repr(ecu_resp)) self._unanswered_packets = unanswered @property def supported_responses(self): # This sorts responses in the following order: # 1. Positive responses first # 2. Lower ServiceID first # 3. Longer (more specific) responses first self._supported_responses.sort( key=lambda x: (x.responses[0].service == 0x7f, x.responses[0].service, 0xffffffff - len(x.responses[0]))) return self._supported_responses @property def unanswered_packets(self): return self._unanswered_packets def __repr__(self): return "ses: %03d sec: %03d cc: %d" % (self.current_session, self.current_security_level, self.communication_control) class ECUSession(DefaultSession): """Tracks modification to an ECU 'on-the-flow'. Usage: >>> sniff(session=ECUSession) """ def __init__(self, *args, **kwargs): DefaultSession.__init__(self, *args, **kwargs) self.ecu = ECU(init_session=kwargs.pop("init_session", None), init_security_level=kwargs.pop("init_security_level", None), # noqa: E501 init_communication_control=kwargs.pop("init_communication_control", None), # noqa: E501 logging=kwargs.pop("logging", True), verbose=kwargs.pop("verbose", True), store_supported_responses=kwargs.pop("store_supported_responses", True)) # noqa: E501 def on_packet_received(self, pkt): if not pkt: return if isinstance(pkt, list): for p in pkt: ECUSession.on_packet_received(self, p) return self.ecu.update(pkt) DefaultSession.on_packet_received(self, pkt) class ECUResponse: """Encapsulates a response and the according ECU state. A list of this objects can be used to configure a ECU Answering Machine. This is useful, if you want to clone the behaviour of a real ECU on a bus. Usage: >>> print("Generates a ECUResponse which answers on UDS()/UDS_RDBI(identifiers=[2]) if ECU is in session 2 and has security_level 2") # noqa: E501 >>> ECUResponse(session=2, security_level=2, responses=UDS()/UDS_RDBIPR(dataIdentifier=2)/Raw(b"deadbeef1")) # noqa: E501 >>> print("Further examples") >>> ECUResponse(session=range(3,5), security_level=[3,4], responses=UDS()/UDS_RDBIPR(dataIdentifier=3)/Raw(b"deadbeef2")) # noqa: E501 >>> ECUResponse(session=[5,6,7], security_level=range(5,7), responses=UDS()/UDS_RDBIPR(dataIdentifier=5)/Raw(b"deadbeef3")) # noqa: E501 >>> ECUResponse(session=lambda x: 8 < x <= 10, security_level=lambda x: x > 10, responses=UDS()/UDS_RDBIPR(dataIdentifier=9)/Raw(b"deadbeef4")) # noqa: E501 """ def __init__(self, session=1, security_level=0, responses=Raw(b"\x7f\x10"), answers=None): """ Initialize an ECUResponse capsule :param session: Defines the session in which this response is valid. A integer, a callable or any iterable object can be provided. :param security_level: Defines the security_level in which this response is valid. A integer, a callable or any iterable object can be provided. :param responses: A Packet or a list of Packet objects. By default the last packet is asked if it answers a incoming packet. This allows to send for example `requestCorrectlyReceived-ResponsePending` packets. :param answers: Optional argument to provide a custom answer here: `lambda resp, req: return resp.answers(req)` This allows the modification of a response depending on a request. Custom SecurityAccess mechanisms can be implemented in this way or generic NegativeResponse messages which answers to everything can be realized in this way. """ self.__session = session \ if hasattr(session, "__iter__") or callable(session) else [session] self.__security_level = security_level \ if hasattr(security_level, "__iter__") or callable(security_level)\ else [security_level] if isinstance(responses, PacketList): self.responses = responses elif isinstance(responses, Packet): self.responses = PacketList([responses]) elif hasattr(responses, "__iter__"): self.responses = PacketList(responses) else: self.responses = PacketList([responses]) self.__custom_answers = answers def in_correct_session(self, current_session): if callable(self.__session): return self.__session(current_session) else: return current_session in self.__session def has_security_access(self, current_security_level): if callable(self.__security_level): return self.__security_level(current_security_level) else: return current_security_level in self.__security_level def answers(self, other): if self.__custom_answers is not None: return self.__custom_answers(self.responses[-1], other) else: return self.responses[-1].answers(other) def __repr__(self): return "session=%s, security_level=%s, responses=%s" % \ (self.__session, self.__security_level, [resp.summary() for resp in self.responses]) def __eq__(self, other): return \ self.__class__ == other.__class__ and \ self.__session == other.__session and \ self.__security_level == other.__security_level and \ len(self.responses) == len(other.responses) and \ all(bytes(x) == bytes(y) for x, y in zip(self.responses, other.responses)) def __ne__(self, other): # Python 2.7 compat return not self == other __hash__ = None class ECU_am(AnsweringMachine): """AnsweringMachine which emulates the basic behaviour of a real world ECU. Provide a list of ``ECUResponse`` objects to configure the behaviour of this AnsweringMachine. :param supported_responses: List of ``ECUResponse`` objects to define the behaviour. The default response is ``generalReject``. :param main_socket: Defines the object of the socket to send and receive packets. :param broadcast_socket: Defines the object of the broadcast socket. Listen-only, responds with the main_socket. `None` to disable broadcast capabilities. :param basecls: Provide a basecls of the used protocol Usage: >>> resp = ECUResponse(session=range(0,255), security_level=0, responses=UDS() / UDS_NR(negativeResponseCode=0x7f, requestServiceId=0x10)) # noqa: E501 >>> sock = ISOTPSocket(can_iface, sid=0x700, did=0x600, basecls=UDS) # noqa: E501 >>> answering_machine = ECU_am(supported_responses=[resp], main_socket=sock, basecls=UDS) # noqa: E501 >>> sim = threading.Thread(target=answering_machine, kwargs={'count': 4, 'timeout':5}) # noqa: E501 >>> sim.start() """ function_name = "ECU_am" sniff_options_list = ["store", "opened_socket", "count", "filter", "prn", "stop_filter", "timeout"] # noqa: E501 def parse_options(self, supported_responses=None, main_socket=None, broadcast_socket=None, basecls=Raw, timeout=None): self.main_socket = main_socket self.sockets = [self.main_socket] if broadcast_socket is not None: self.sockets.append(broadcast_socket) self.ecu_state = ECU(logging=False, verbose=False, store_supported_responses=False) self.basecls = basecls self.supported_responses = supported_responses self.sniff_options["timeout"] = timeout self.sniff_options["opened_socket"] = self.sockets def is_request(self, req): return req.__class__ == self.basecls def print_reply(self, req, reply): print("%s ==> %s" % (req.summary(), [res.summary() for res in reply])) def make_reply(self, req): if self.supported_responses is not None: for resp in self.supported_responses: if not isinstance(resp, ECUResponse): raise Scapy_Exception("Unsupported type for response. " "Please use `ECUResponse` objects. ") if not resp.in_correct_session(self.ecu_state.current_session): continue if not resp.has_security_access( self.ecu_state.current_security_level): continue if not resp.answers(req): continue for r in resp.responses: for l in r.layers(): if hasattr(l, "modifies_ecu_state"): l.modifies_ecu_state(r, self.ecu_state) return resp.responses return PacketList([self.basecls(b"\x7f" + bytes(req)[0:1] + b"\x10")]) def send_reply(self, reply): for p in reply: if len(reply) > 1: time.sleep(random.uniform(0.01, 0.5)) self.main_socket.send(p)