From 9b2a6552d69d8dcb32dcc384aaf55a71954df3b3 Mon Sep 17 00:00:00 2001 From: David Emeis Date: Mon, 5 Aug 2024 18:02:06 +0200 Subject: [PATCH 1/5] feat: SecurityAccess key length scanner --- src/gallia/commands/__init__.py | 39 ++--- src/gallia/commands/scan/uds/sa_keylen.py | 181 ++++++++++++++++++++++ 2 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 src/gallia/commands/scan/uds/sa_keylen.py diff --git a/src/gallia/commands/__init__.py b/src/gallia/commands/__init__.py index c6f7c1252..79bdeeda0 100644 --- a/src/gallia/commands/__init__.py +++ b/src/gallia/commands/__init__.py @@ -22,50 +22,53 @@ from gallia.commands.scan.uds.memory import MemoryFunctionsScanner from gallia.commands.scan.uds.reset import ResetScanner from gallia.commands.scan.uds.sa_dump_seeds import SASeedsDumper +from gallia.commands.scan.uds.sa_keylen import SAKeylenDetector from gallia.commands.scan.uds.services import ServicesScanner from gallia.commands.scan.uds.sessions import SessionsScanner registry: list[type[BaseCommand]] = [ + DTCPrimitive, DoIPDiscoverer, + ECUResetPrimitive, + GenericPDUPrimitive, + IOCBIPrimitive, MemoryFunctionsScanner, + PingPrimitive, + RMBAPrimitive, + RTCLPrimitive, ReadByIdentifierPrimitive, ResetScanner, + SAKeylenDetector, SASeedsDumper, ScanIdentifiers, - SessionsScanner, + SendPDUPrimitive, ServicesScanner, - DTCPrimitive, - ECUResetPrimitive, + SessionsScanner, VINPrimitive, - IOCBIPrimitive, - PingPrimitive, - RMBAPrimitive, - RTCLPrimitive, - GenericPDUPrimitive, - SendPDUPrimitive, WMBAPrimitive, WriteByIdentifierPrimitive, ] # TODO: Investigate why linters didn't catch faulty strings in here. __all__ = [ + "DTCPrimitive", "DoIPDiscoverer", + "ECUResetPrimitive", + "GenericPDUPrimitive", + "IOCBIPrimitive", "MemoryFunctionsScanner", + "PingPrimitive", + "RMBAPrimitive", + "RTCLPrimitive", "ReadByIdentifierPrimitive", "ResetScanner", + "SAKeylenDetector", "SASeedsDumper", "ScanIdentifiers", - "SessionsScanner", + "SendPDUPrimitive", "ServicesScanner", - "DTCPrimitive", - "ECUResetPrimitive", + "SessionsScanner", "VINPrimitive", - "IOCBIPrimitive", - "PingPrimitive", - "RMBAPrimitive", - "RTCLPrimitive", - "GenericPDUPrimitive", - "SendPDUPrimitive", "WMBAPrimitive", "WriteByIdentifierPrimitive", ] diff --git a/src/gallia/commands/scan/uds/sa_keylen.py b/src/gallia/commands/scan/uds/sa_keylen.py new file mode 100644 index 000000000..deb0d59cb --- /dev/null +++ b/src/gallia/commands/scan/uds/sa_keylen.py @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: AISEC Pentesting Team +# +# SPDX-License-Identifier: Apache-2.0 + +import binascii +import sys +import time +from argparse import ArgumentParser, Namespace + +from gallia.command import UDSScanner +from gallia.config import Config +from gallia.log import get_logger +from gallia.services.uds import NegativeResponse, UDSRequestConfig +from gallia.services.uds.core.service import SecurityAccessResponse, UDSErrorCodes +from gallia.services.uds.core.utils import g_repr +from gallia.utils import auto_int + +logger = get_logger(__name__) + + +class SAKeylenDetector(UDSScanner): + """This scanner tries to determine the key length expected by SecurityAccess.""" + + COMMAND = "key-length" + SHORT_HELP = "determine key length expected by SecurityAccess" + + def __init__(self, parser: ArgumentParser, config: Config = Config()) -> None: + super().__init__(parser, config) + + self.implicit_logging = False + + def configure_parser(self) -> None: + self.parser.add_argument( + "--session", + metavar="INT", + type=auto_int, + default=0x02, + help="Set diagnostic session to perform test in", + ) + self.parser.add_argument( + "--check-session", + action="store_true", + default=False, + help="Check current session with read DID", + ) + self.parser.add_argument( + "--level", + default=0x11, + metavar="INT", + type=auto_int, + help="Set security access level for which the seed for calculating the key would be returned.", + ) + self.parser.add_argument( + "--request-seed", + action="store_true", + default=False, + help="Request a seed before sending a key. The default is to just send the key.", + ) + self.parser.add_argument( + "--reset", + nargs="?", + const=1, + default=None, + type=int, + help="Attempt to fool brute force protection by resetting the ECU after every nth sent key.", + ) + self.parser.add_argument( + "--max-length", + default=1000, + type=int, + metavar="INT", + help="Test key lengths from 1 up to N bytes. The default is N = 1000.", + ) + self.parser.add_argument( + "--data-record", + metavar="HEXSTRING", + type=binascii.unhexlify, + default=b"", + help="Append an optional data record to seed requests. Only has an effect when combined with '--request-seed'.", + ) + self.parser.add_argument( + "--sleep", + default=0, + type=float, + metavar="FLOAT", + help="Attempt to fool brute force protection by sleeping for N seconds between sending keys.", + ) + + async def request_seed(self, level: int, data: bytes) -> bytes | None: + resp = await self.ecu.security_access_request_seed(level, data) + if isinstance(resp, NegativeResponse): + logger.warning(f"Requesting seed failed with: {resp}") + return None + return resp.security_seed + + async def main(self, args: Namespace) -> None: + session = args.session + logger.info(f"scanning in session: {g_repr(session)}") + + resp = await self.ecu.set_session(session) + if isinstance(resp, NegativeResponse): + logger.critical(f"could not change to session: {resp}") + return + + key = bytes([0x00]) + reset = False + runs_since_last_reset = 0 + length_identified = False + + while len(key) <= args.max_length: + logger.info(f"Testing key length {len(key)}...") + + if args.check_session or reset: + if not await self.ecu.check_and_set_session(args.session): + logger.error(f"ECU persistently lost session {g_repr(args.session)}") + sys.exit(1) + + reset = False + + if args.request_seed: + try: + await self.request_seed(args.level, args.data_record) + except Exception as e: + logger.critical(f"Error while requesting seed: {g_repr(e)}") + sys.exit(1) + + resp = await self.ecu.security_access_send_key( + args.level + 1, key, config=UDSRequestConfig(tags=["ANALYZE"]) + ) + if isinstance(resp, SecurityAccessResponse): + logger.result( + f"That's unexpected: Unlocked SA level {g_repr(args.level)} with all-zero key of length {len(key)}." + ) + length_identified = True + break + elif isinstance(resp, NegativeResponse): + if ( + not args.request_seed + and resp.response_code == UDSErrorCodes.requestSequenceError + ) or ( + args.request_seed and resp.response_code == UDSErrorCodes.conditionsNotCorrect + ): + logger.result(f"The ECU seems to be expecting keys of length {len(key)}.") + length_identified = True + break + + key += bytes([0x00]) + + runs_since_last_reset += 1 + + if runs_since_last_reset == args.reset: + reset = True + runs_since_last_reset = 0 + + try: + logger.info("Resetting the ECU") + await self.ecu.ecu_reset(0x01) + logger.info("Waiting for the ECU to recover…") + await self.ecu.wait_for_ecu() + except TimeoutError: + logger.error("ECU did not respond after reset; exiting…") + sys.exit(1) + except ConnectionError: + logger.warning( + "Lost connection to the ECU after performing a reset. " + "Attempting to reconnect…" + ) + await self.ecu.reconnect() + + # Re-enter session. Checking/logging will be done at the beginning of next iteration + await self.ecu.set_session(session) + + if args.sleep > 0: + logger.info(f"Sleeping for {args.sleep} seconds between sending keys...") + time.sleep(args.sleep) + + if not length_identified: + logger.result( + f"Unable to identify valid key length for SecurityAccess between 1 and {args.max_length}." + ) + await self.ecu.leave_session(session, sleep=args.power_cycle_sleep) From e44030c437bd0c32d5d0de7ec0e5327072ed68fb Mon Sep 17 00:00:00 2001 From: David Emeis Date: Mon, 5 Aug 2024 18:29:41 +0200 Subject: [PATCH 2/5] fix(sa_keylen): Correctly import UDSErrorCodes, make linters happy --- src/gallia/commands/scan/uds/sa_keylen.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/gallia/commands/scan/uds/sa_keylen.py b/src/gallia/commands/scan/uds/sa_keylen.py index deb0d59cb..409a25134 100644 --- a/src/gallia/commands/scan/uds/sa_keylen.py +++ b/src/gallia/commands/scan/uds/sa_keylen.py @@ -11,7 +11,8 @@ from gallia.config import Config from gallia.log import get_logger from gallia.services.uds import NegativeResponse, UDSRequestConfig -from gallia.services.uds.core.service import SecurityAccessResponse, UDSErrorCodes +from gallia.services.uds.core.constants import UDSErrorCodes +from gallia.services.uds.core.service import SecurityAccessResponse from gallia.services.uds.core.utils import g_repr from gallia.utils import auto_int @@ -97,9 +98,9 @@ async def main(self, args: Namespace) -> None: session = args.session logger.info(f"scanning in session: {g_repr(session)}") - resp = await self.ecu.set_session(session) - if isinstance(resp, NegativeResponse): - logger.critical(f"could not change to session: {resp}") + sess_resp = await self.ecu.set_session(session) + if isinstance(sess_resp, NegativeResponse): + logger.critical(f"could not change to session: {sess_resp}") return key = bytes([0x00]) @@ -124,21 +125,22 @@ async def main(self, args: Namespace) -> None: logger.critical(f"Error while requesting seed: {g_repr(e)}") sys.exit(1) - resp = await self.ecu.security_access_send_key( + key_resp = await self.ecu.security_access_send_key( args.level + 1, key, config=UDSRequestConfig(tags=["ANALYZE"]) ) - if isinstance(resp, SecurityAccessResponse): + if isinstance(key_resp, SecurityAccessResponse): logger.result( f"That's unexpected: Unlocked SA level {g_repr(args.level)} with all-zero key of length {len(key)}." ) length_identified = True break - elif isinstance(resp, NegativeResponse): + elif isinstance(key_resp, NegativeResponse): if ( not args.request_seed - and resp.response_code == UDSErrorCodes.requestSequenceError + and key_resp.response_code == UDSErrorCodes.requestSequenceError ) or ( - args.request_seed and resp.response_code == UDSErrorCodes.conditionsNotCorrect + args.request_seed + and key_resp.response_code == UDSErrorCodes.conditionsNotCorrect ): logger.result(f"The ECU seems to be expecting keys of length {len(key)}.") length_identified = True From 240cdbd9a73ef693f038618dc4c87ca1c96e06dd Mon Sep 17 00:00:00 2001 From: David Emeis Date: Tue, 6 Aug 2024 14:49:27 +0200 Subject: [PATCH 3/5] fix(sa_keylen): Non-blocking sleep and cosmetic changes in output --- src/gallia/commands/scan/uds/sa_keylen.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gallia/commands/scan/uds/sa_keylen.py b/src/gallia/commands/scan/uds/sa_keylen.py index 409a25134..634c34acb 100644 --- a/src/gallia/commands/scan/uds/sa_keylen.py +++ b/src/gallia/commands/scan/uds/sa_keylen.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 +import asyncio import binascii import sys import time @@ -173,8 +174,8 @@ async def main(self, args: Namespace) -> None: await self.ecu.set_session(session) if args.sleep > 0: - logger.info(f"Sleeping for {args.sleep} seconds between sending keys...") - time.sleep(args.sleep) + logger.info(f"Sleeping for {args.sleep} seconds between sending keys…") + await asyncio.sleep(args.sleep) if not length_identified: logger.result( From b3422c8e43d9cdb34baecc14726337113acd2723 Mon Sep 17 00:00:00 2001 From: David Emeis Date: Tue, 6 Aug 2024 14:52:54 +0200 Subject: [PATCH 4/5] fix(sa_keylen): Drop unused import --- src/gallia/commands/scan/uds/sa_keylen.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gallia/commands/scan/uds/sa_keylen.py b/src/gallia/commands/scan/uds/sa_keylen.py index 634c34acb..07ee7495a 100644 --- a/src/gallia/commands/scan/uds/sa_keylen.py +++ b/src/gallia/commands/scan/uds/sa_keylen.py @@ -5,7 +5,6 @@ import asyncio import binascii import sys -import time from argparse import ArgumentParser, Namespace from gallia.command import UDSScanner From 97c09e7444a4159511b74a22e5adbe16fde246f6 Mon Sep 17 00:00:00 2001 From: David Emeis Date: Mon, 19 Aug 2024 11:08:36 +0200 Subject: [PATCH 5/5] refactor(sa_keylen): remove default for sleep parameter --- src/gallia/commands/scan/uds/sa_keylen.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/gallia/commands/scan/uds/sa_keylen.py b/src/gallia/commands/scan/uds/sa_keylen.py index 07ee7495a..aec588ea3 100644 --- a/src/gallia/commands/scan/uds/sa_keylen.py +++ b/src/gallia/commands/scan/uds/sa_keylen.py @@ -81,7 +81,6 @@ def configure_parser(self) -> None: ) self.parser.add_argument( "--sleep", - default=0, type=float, metavar="FLOAT", help="Attempt to fool brute force protection by sleeping for N seconds between sending keys.", @@ -172,7 +171,7 @@ async def main(self, args: Namespace) -> None: # Re-enter session. Checking/logging will be done at the beginning of next iteration await self.ecu.set_session(session) - if args.sleep > 0: + if args.sleep is not None: logger.info(f"Sleeping for {args.sleep} seconds between sending keys…") await asyncio.sleep(args.sleep)