import binascii import glob import math import os import tempfile import re import subprocess import argparse import timeit import sys import time import serial.tools.list_ports import threading import random import struct import queue from enum import Enum from multiprocessing import Pool, cpu_count from typing import Union from pathlib import Path from platform import uname from datetime import datetime import hardnested_utils import chameleon_com import chameleon_cmd from chameleon_utils import ( ArgumentParserNoExit, ArgsParserError, UnexpectedResponseError, execute_tool, tqdm_if_exists, print_key_table, odd_parity_byte, default_cwd ) from chameleon_utils import CLITree from chameleon_utils import CR, CG, CB, CC, CY, C0, color_string from chameleon_utils import print_mem_dump from chameleon_enum import Command, Status, SlotNumber, TagSenseType, TagSpecificType from chameleon_enum import ( MifareClassicWriteMode, MifareClassicPrngType, MifareClassicDarksideStatus, MfcKeyType, ) from chameleon_enum import MifareUltralightWriteMode from chameleon_enum import ( AnimationMode, ButtonPressFunction, ButtonType, MfcValueBlockOperator, ) from chameleon_enum import HIDFormat from crypto1 import Crypto1 # NXP IDs based on https://www.nxp.com/docs/en/application-note/AN10833.pdf type_id_SAK_dict = { 0x00: "MIFARE Ultralight Classic/C/EV1/Nano | NTAG 2xx", 0x08: "MIFARE Classic 1K | Plus SE 1K | Plug S 2K | Plus X 2K", 0x09: "MIFARE Mini 0.3k", 0x10: "MIFARE Plus 2K", 0x11: "MIFARE Plus 4K", 0x18: "MIFARE Classic 4K | Plus S 4K | Plus X 4K", 0x19: "MIFARE Classic 2K", 0x20: "MIFARE Plus EV1/EV2 | DESFire EV1/EV2/EV3 | DESFire Light | NTAG 4xx | " "MIFARE Plus S 2/4K | MIFARE Plus X 2/4K | MIFARE Plus SE 1K | SEOS", 0x28: "SmartMX with MIFARE Classic 1K", 0x38: "SmartMX with MIFARE Classic 4K", } def load_key_file(import_key, keys): """ Load binary key file and append its content to the provided set of keys. Each key is 6 bytes concatenated. """ with open(import_key.name, "rb") as file: data = file.read() for i in range(0, len(data), 6): key = data[i:i+6] if len(key) == 6: keys.add(key) return keys def load_dic_file(import_dic, keys): """ Load dictionary file and append its content to the provided set of keys. Each key is a 12-char hex string on a new line. """ with open(import_dic.name, "r") as file: for line in file: line = line.strip() if line: keys.add(bytes.fromhex(line)) return keys def check_tools(): missing_tools = [] for tool in ( "staticnested", "nested", "darkside", "mfkey32v2", "mfkey64", "staticnested_1nt", "staticnested_2x1nt_rf08s", "staticnested_2x1nt_rf08s_1key", ): if any(default_cwd.glob(f"{tool}*")): continue else: missing_tools.append(tool) if missing_tools: missing_tool_str = ", ".join(missing_tools) warn_str = f"Note: optional Mifare tools not found: {missing_tool_str}. Mifare attack commands will not work." print(color_string((CY, warn_str))) class BaseCLIUnit: def __init__(self): # new a device command transfer and receiver instance(Send cmd and receive response) self._device_com: Union[chameleon_com.ChameleonCom, None] = None self._device_cmd: Union[chameleon_cmd.ChameleonCMD, None] = None @property def device_com(self) -> chameleon_com.ChameleonCom: assert self._device_com is not None return self._device_com @device_com.setter def device_com(self, com): self._device_com = com self._device_cmd = chameleon_cmd.ChameleonCMD(self._device_com) @property def cmd(self) -> chameleon_cmd.ChameleonCMD: assert self._device_cmd is not None return self._device_cmd def args_parser(self) -> ArgumentParserNoExit: """ CMD unit args. :return: """ raise NotImplementedError("Please implement this") def before_exec(self, args: argparse.Namespace): """ Call a function before exec cmd. :return: function references """ return True def on_exec(self, args: argparse.Namespace): """ Call a function on cmd match. :return: function references """ raise NotImplementedError("Please implement this") def after_exec(self, args: argparse.Namespace): """ Call a function after exec cmd. :return: function references """ return True @staticmethod def sub_process(cmd, cwd=default_cwd): class ShadowProcess: def __init__(self): self.output = "" self.time_start = timeit.default_timer() self._process = subprocess.Popen( cmd, cwd=cwd, shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE, ) threading.Thread(target=self.thread_read_output).start() def thread_read_output(self): while self._process.poll() is None: assert self._process.stdout is not None data = self._process.stdout.read(1024) if len(data) > 0: self.output += data.decode(encoding="utf-8") def get_time_distance(self, ms=True): if ms: return round((timeit.default_timer() - self.time_start) * 1000, 2) else: return round(timeit.default_timer() - self.time_start, 2) def is_running(self): return self._process.poll() is None def is_timeout(self, timeout_ms): time_distance = self.get_time_distance() if time_distance > timeout_ms: return True return False def get_output_sync(self): return self.output def get_ret_code(self): return self._process.poll() def stop_process(self): # noinspection PyBroadException try: self._process.kill() except Exception: pass def get_process(self): return self._process def wait_process(self): return self._process.wait() return ShadowProcess() class DeviceRequiredUnit(BaseCLIUnit): """ Make sure of device online """ def before_exec(self, args: argparse.Namespace): ret = self.device_com.isOpen() if ret: return True else: print("Please connect to chameleon device first (use 'hw connect').") return False class ReaderRequiredUnit(DeviceRequiredUnit): """ Make sure of device enter to reader mode. """ def before_exec(self, args: argparse.Namespace): if not super().before_exec(args): return False if self.cmd.is_device_reader_mode(): return True self.cmd.set_device_reader_mode(True) print("Switch to { Tag Reader } mode successfully.") return True class SlotIndexArgsUnit(DeviceRequiredUnit): @staticmethod def add_slot_args(parser: ArgumentParserNoExit, mandatory=False): slot_choices = [x.value for x in SlotNumber] help_str = f"Slot Index: {slot_choices} Default: active slot" parser.add_argument( "-s", "--slot", type=int, required=mandatory, help=help_str, metavar="<1-8>", choices=slot_choices, ) return parser class SlotIndexArgsAndGoUnit(SlotIndexArgsUnit): def before_exec(self, args: argparse.Namespace): if super().before_exec(args): self.prev_slot_num = SlotNumber.from_fw(self.cmd.get_active_slot()) if args.slot is not None: self.slot_num = args.slot if self.slot_num != self.prev_slot_num: self.cmd.set_active_slot(self.slot_num) else: self.slot_num = self.prev_slot_num return True return False def after_exec(self, args: argparse.Namespace): if self.prev_slot_num != self.slot_num: self.cmd.set_active_slot(self.prev_slot_num) class SenseTypeArgsUnit(DeviceRequiredUnit): @staticmethod def add_sense_type_args(parser: ArgumentParserNoExit): sense_group = parser.add_mutually_exclusive_group(required=True) sense_group.add_argument("--hf", action="store_true", help="HF type") sense_group.add_argument("--lf", action="store_true", help="LF type") return parser class MF1AuthArgsUnit(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.add_argument( "--blk", "--block", type=int, required=True, metavar="", help="The block where the key of the card is known", ) type_group = parser.add_mutually_exclusive_group() type_group.add_argument( "-a", "-A", action="store_true", help="Known key is A key (default)" ) type_group.add_argument( "-b", "-B", action="store_true", help="Known key is B key" ) parser.add_argument( "-k", "--key", type=str, required=True, metavar="", help="tag sector key", ) return parser def get_param(self, args): class Param: def __init__(self): self.block = args.blk self.type = MfcKeyType.B if args.b else MfcKeyType.A key: str = args.key if not re.match(r"^[a-fA-F0-9]{12}$", key): raise ArgsParserError("key must include 12 HEX symbols") self.key: bytearray = bytearray.fromhex(key) return Param() class HF14AAntiCollArgsUnit(DeviceRequiredUnit): @staticmethod def add_hf14a_anticoll_args(parser: ArgumentParserNoExit): parser.add_argument("--uid", type=str, metavar="", help="Unique ID") parser.add_argument( "--atqa", type=str, metavar="", help="Answer To Request" ) parser.add_argument( "--sak", type=str, metavar="", help="Select AcKnowledge" ) ats_group = parser.add_mutually_exclusive_group() ats_group.add_argument( "--ats", type=str, metavar="", help="Answer To Select" ) ats_group.add_argument( "--delete-ats", action="store_true", help="Delete Answer To Select" ) return parser def update_hf14a_anticoll(self, args, uid, atqa, sak, ats): anti_coll_data_changed = False change_requested = False if args.uid is not None: change_requested = True uid_str: str = args.uid.strip() if re.match(r"[a-fA-F0-9]+", uid_str) is not None: new_uid = bytes.fromhex(uid_str) if len(new_uid) not in [4, 7, 10]: raise Exception("UID length error") else: raise Exception("UID must be hex") if new_uid != uid: uid = new_uid anti_coll_data_changed = True else: print(color_string((CY, "Requested UID already set"))) if args.atqa is not None: change_requested = True atqa_str: str = args.atqa.strip() if re.match(r"[a-fA-F0-9]{4}", atqa_str) is not None: new_atqa = bytes.fromhex(atqa_str) else: raise Exception("ATQA must be 4-byte hex") if new_atqa != atqa: atqa = new_atqa anti_coll_data_changed = True else: print(color_string((CY, "Requested ATQA already set"))) if args.sak is not None: change_requested = True sak_str: str = args.sak.strip() if re.match(r"[a-fA-F0-9]{2}", sak_str) is not None: new_sak = bytes.fromhex(sak_str) else: raise Exception("SAK must be 2-byte hex") if new_sak != sak: sak = new_sak anti_coll_data_changed = True else: print(color_string((CY, "Requested SAK already set"))) if (args.ats is not None) or args.delete_ats: change_requested = True if args.delete_ats: new_ats = b"" else: ats_str: str = args.ats.strip() if re.match(r"[a-fA-F0-9]+", ats_str) is not None: new_ats = bytes.fromhex(ats_str) else: raise Exception("ATS must be hex") if new_ats != ats: ats = new_ats anti_coll_data_changed = True else: print(color_string((CY, "Requested ATS already set"))) if anti_coll_data_changed: self.cmd.hf14a_set_anti_coll_data(uid, atqa, sak, ats) return change_requested, anti_coll_data_changed, uid, atqa, sak, ats class MFUAuthArgsUnit(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() def key_parser(key: str) -> bytes: try: key = bytes.fromhex(key) except ValueError: raise ValueError("Key should be a hex string") if len(key) not in [4, 16]: raise ValueError("Key should either be 4 or 16 bytes long") return key parser.add_argument( "-k", "--key", type=key_parser, metavar="", help="Authentication key: 4 bytes (UL EV1/NTAG password) " "or 16 bytes (Ultralight-C 3DES key, cipher order).", ) parser.add_argument( "-l", action="store_true", dest="swap_endian", help="Swap endianness of the key.", ) return parser def get_param(self, args): key = args.key if key is not None and args.swap_endian: key = bytearray(key) for i in range(len(key)): key[i] = key[len(key) - 1 - i] key = bytes(key) class Param: def __init__(self, key): self.key = key return Param(key) def on_exec(self, args: argparse.Namespace): raise NotImplementedError("Please implement this") class LFEMIdArgsUnit(DeviceRequiredUnit): @staticmethod def add_card_arg(parser: ArgumentParserNoExit, required=False): parser.add_argument( "--id", type=str, required=required, help="EM410x tag id", metavar="" ) return parser def before_exec(self, args: argparse.Namespace): if not super().before_exec(args): return False if args.id is None or not re.match( r"^([a-fA-F0-9]{10}|[a-fA-F0-9]{26})$", args.id ): raise ArgsParserError("ID must include 10 or 26 HEX symbols") return True def args_parser(self) -> ArgumentParserNoExit: raise NotImplementedError("Please implement this") def on_exec(self, args: argparse.Namespace): raise NotImplementedError("Please implement this") class LFHIDIdArgsUnit(DeviceRequiredUnit): @staticmethod def add_card_arg(parser: ArgumentParserNoExit, required=False): formats = [x.name for x in HIDFormat] parser.add_argument( "-f", "--format", type=str, required=required, help="HIDProx card format", metavar="", choices=formats, ) parser.add_argument( "--fc", type=int, required=False, help="HIDProx tag facility code", metavar="", ) parser.add_argument( "--cn", type=int, required=required, help="HIDProx tag card number", metavar="", ) parser.add_argument( "--il", type=int, required=False, help="HIDProx tag issue level", metavar="", ) parser.add_argument( "--oem", type=int, required=False, help="HIDProx tag OEM", metavar="" ) return parser @staticmethod def check_limits( format: int, fc: Union[int, None], cn: Union[int, None], il: Union[int, None], oem: Union[int, None], ): limits = { HIDFormat.H10301: [0xFF, 0xFFFF, 0, 0], HIDFormat.IND26: [0xFFF, 0xFFF, 0, 0], HIDFormat.IND27: [0x1FFF, 0x3FFF, 0, 0], HIDFormat.INDASC27: [0x1FFF, 0x3FFF, 0, 0], HIDFormat.TECOM27: [0x7FF, 0xFFFF, 0, 0], HIDFormat.W2804: [0xFF, 0x7FFF, 0, 0], HIDFormat.IND29: [0x1FFF, 0xFFFF, 0, 0], HIDFormat.ATSW30: [0xFFF, 0xFFFF, 0, 0], HIDFormat.ADT31: [0xF, 0x7FFFFF, 0, 0], HIDFormat.HCP32: [0, 0x3FFF, 0, 0], HIDFormat.HPP32: [0xFFF, 0x7FFFF, 0, 0], HIDFormat.KASTLE: [0xFF, 0xFFFF, 0x1F, 0], HIDFormat.KANTECH: [0xFF, 0xFFFF, 0, 0], HIDFormat.WIE32: [0xFFF, 0xFFFF, 0, 0], HIDFormat.D10202: [0x7F, 0xFFFFFF, 0, 0], HIDFormat.H10306: [0xFFFF, 0xFFFF, 0, 0], HIDFormat.N10002: [0xFFFF, 0xFFFF, 0, 0], HIDFormat.OPTUS34: [0x3FF, 0xFFFF, 0, 0], HIDFormat.SMP34: [0x3FF, 0xFFFF, 0x7, 0], HIDFormat.BQT34: [0xFF, 0xFFFFFF, 0, 0], HIDFormat.C1K35S: [0xFFF, 0xFFFFF, 0, 0], HIDFormat.C15001: [0xFF, 0xFFFF, 0, 0x3FF], HIDFormat.S12906: [0xFF, 0xFFFFFF, 0x3, 0], HIDFormat.ACTPHID: [0xFF, 0xFFFFFF, 0, 0x3FF], HIDFormat.SIE36: [0x3FFFF, 0xFFFF, 0, 0], HIDFormat.H10320: [0, 99999999, 0, 0], HIDFormat.H10302: [0, 0x7FFFFFFFF, 0, 0], HIDFormat.H10304: [0xFFFF, 0x7FFFF, 0, 0], HIDFormat.P10004: [0x1FFF, 0x3FFFF, 0, 0], HIDFormat.HGEN37: [0, 0xFFFFFFFF, 0, 0], HIDFormat.MDI37: [0xF, 0x1FFFFFFF, 0, 0], } limit = limits.get(HIDFormat(format)) if limit is None: return True if fc is not None and fc > limit[0]: raise ArgsParserError( f"{HIDFormat(format)}: Facility Code must between 0 to {limit[0]}" ) if cn is not None and cn > limit[1]: raise ArgsParserError( f"{HIDFormat(format)}: Card Number must between 0 to {limit[1]}" ) if il is not None and il > limit[2]: raise ArgsParserError( f"{HIDFormat(format)}: Issue Level must between 0 to {limit[2]}" ) if oem is not None and oem > limit[3]: raise ArgsParserError( f"{HIDFormat(format)}: OEM must between 0 to {limit[3]}" ) def before_exec(self, args: argparse.Namespace): if super().before_exec(args): format = HIDFormat.H10301.value if args.format is not None: format = HIDFormat[args.format].value LFHIDIdArgsUnit.check_limits(format, args.fc, args.cn, args.il, args.oem) return True return False def args_parser(self) -> ArgumentParserNoExit: raise NotImplementedError() def on_exec(self, args: argparse.Namespace): raise NotImplementedError() class LFHIDIdReadArgsUnit(DeviceRequiredUnit): @staticmethod def add_card_arg(parser: ArgumentParserNoExit, required=False): formats = [x.name for x in HIDFormat] parser.add_argument( "-f", "--format", type=str, required=False, help="HIDProx card format hint", metavar="", choices=formats, ) return parser def args_parser(self) -> ArgumentParserNoExit: raise NotImplementedError() def on_exec(self, args: argparse.Namespace): raise NotImplementedError() class LFIOProxIdArgsUnit(DeviceRequiredUnit): """ IOProx identity arguments: --ver version (0-255) --fc facility (0-255) --cn card number (0-65535) --raw8 raw 8 bytes hex, e.g. 007854E03A5D65AB """ @staticmethod def add_card_arg(parser: ArgumentParserNoExit, required=False): parser.add_argument("--ver", type=int, required=False, help="ioProx version", metavar="") parser.add_argument("--fc", type=str, required=False, help="ioProx facility code, e.g., 83 or 0x53", metavar="") parser.add_argument("--cn", type=int, required=required, help="ioProx card number", metavar="") parser.add_argument("--raw8", type=str, required=False, help="ioProx raw 8 bytes hex (e.g. 00AABBCCDDEEFF55)", metavar="") return parser @staticmethod def _check_u8(name: str, v: int): if v < 0 or v > 0xFF: raise ArgsParserError(f"{name} must be 0..255") @staticmethod def _check_u16(name: str, v: int): if v < 0 or v > 0xFFFF: raise ArgsParserError(f"{name} must be 0..65535") @staticmethod def parse_raw8(raw8: str) -> bytes: s = raw8.replace(" ", "").replace("0x", "").strip() b = bytes.fromhex(s) if len(b) != 8: raise ArgsParserError("ioProx --raw must be exactly 8 bytes (16 hex chars), e.g. 007854E03A5D65AB") return b @staticmethod def checksum5(b1, b2, b3, b4, b5) -> int: return (0xFF - ((b1 + b2 + b3 + b4 + b5) & 0xFF)) & 0xFF def before_exec(self, args: argparse.Namespace): if not super().before_exec(args): return False # validate if provided if args.ver is not None: self._check_u8("version", args.ver) if args.fc is not None: val = int(args.fc, 0) self._check_u8("facility", val) args.fc = val if args.cn is not None: self._check_u16("card number", args.cn) # if raw is present, validate it if args.raw8 is not None: self.parse_raw8(args.raw8) return True class LFIOProxReadArgsUnit(DeviceRequiredUnit): @staticmethod def add_card_arg(parser: ArgumentParserNoExit, required=False): parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") return parser class LFVikingIdArgsUnit(DeviceRequiredUnit): @staticmethod def add_card_arg(parser: ArgumentParserNoExit, required=False): parser.add_argument( "--id", type=str, required=required, help="Viking tag id", metavar="" ) return parser def before_exec(self, args: argparse.Namespace): if not super().before_exec(args): return False if args.id is None or not re.match(r"^[a-fA-F0-9]{8}$", args.id): raise ArgsParserError("ID must include 8 HEX symbols") return True def args_parser(self) -> ArgumentParserNoExit: raise NotImplementedError("Please implement this") def on_exec(self, args: argparse.Namespace): raise NotImplementedError("Please implement this") class LFJablotronIdArgsUnit(DeviceRequiredUnit): @staticmethod def add_card_arg(parser: ArgumentParserNoExit, required=False): parser.add_argument( "--id", type=str, required=required, help="Jablotron tag id (5 bytes hex)", metavar="" ) return parser def before_exec(self, args: argparse.Namespace): if not super().before_exec(args): return False if args.id is None or not re.match(r"^[a-fA-F0-9]{10}$", args.id): raise ArgsParserError("ID must include 10 HEX symbols (5 bytes)") return True def args_parser(self) -> ArgumentParserNoExit: raise NotImplementedError("Please implement this") def on_exec(self, args: argparse.Namespace): raise NotImplementedError("Please implement this") IDTECK_PREAMBLE_HEX = "4944544B" IDTECK_PREAMBLE_INT = 0x4944544B def _idteck_compute_checksum(payload_lo3: int) -> int: """Compute the IDTECK checksum byte from the low 3 bytes of the 4-byte payload. Matches the formula used by Proxmark3 (cmdlfidteck.c): the checksum is the sum of the three non-checksum payload bytes, taken modulo 256. """ return ((payload_lo3 >> 16) + (payload_lo3 >> 8) + payload_lo3) & 0xFF def _idteck_compose_frame(card_id: int) -> bytes: """Compose an 8-byte IDTECK frame from a 24-bit card ID. The frame is preamble + [checksum][card_id bytes reversed] where the reversal and checksum placement match the layout observed on real IDTECK cards (see cmdlfidteck.c in the Proxmark3 client). This helper is exposed for future CLI use (e.g. `lf idteck compose --cn`); it is not wired into any command yet. """ card_id &= 0xFFFFFF # The card ID is stored with bytes reversed in the payload; mirror PM3. reversed_id = ((card_id & 0xFF) << 16) | ((card_id >> 8) & 0xFF) << 8 | ((card_id >> 16) & 0xFF) chksum = _idteck_compute_checksum(reversed_id) payload = (chksum << 24) | reversed_id return bytes.fromhex(IDTECK_PREAMBLE_HEX) + payload.to_bytes(4, "big") def _idteck_frame_info(frame: bytes) -> dict: """Parse an 8-byte IDTECK frame into its components. Returns a dict with: preamble_hex, preamble_valid, payload_hex, checksum, checksum_expected, checksum_valid, card_id (24-bit extracted from payload bytes 1-3 with the byte-reversal convention used by PM3). """ if len(frame) != 8: raise ValueError("IDTECK frame must be exactly 8 bytes") preamble = int.from_bytes(frame[:4], "big") payload = int.from_bytes(frame[4:], "big") chksum = (payload >> 24) & 0xFF lo3 = payload & 0xFFFFFF expected = _idteck_compute_checksum(lo3) card_id = ((lo3 >> 16) & 0xFF) | ((lo3 >> 8) & 0xFF) << 8 | (lo3 & 0xFF) << 16 return { "preamble_hex": f"{preamble:08X}", "preamble_valid": preamble == IDTECK_PREAMBLE_INT, "payload_hex": f"{payload:08X}", "checksum": chksum, "checksum_expected": expected, "checksum_valid": chksum == expected, "card_id": card_id, } class LFIdteckIdArgsUnit(DeviceRequiredUnit): """Argument parser for IDTECK: 16-hex = full 64-bit frame (preamble + payload).""" @staticmethod def add_card_arg(parser: ArgumentParserNoExit, required=False): parser.add_argument( "--id", type=str, required=required, help="IDTECK frame in hex: 16 chars for the full 64-bit frame, or " "8 chars for the 32-bit payload only (preamble 4944544B is auto-prepended).", metavar="" ) return parser def before_exec(self, args: argparse.Namespace): if not super().before_exec(args): return False if args.id is None: # No id provided: the caller (e.g. econfig in readback form) is # allowed to proceed without one. Subcommands that require an id # declare it with required=True on the parser argument. return True if re.match(r"^[a-fA-F0-9]{16}$", args.id): pass elif re.match(r"^[a-fA-F0-9]{8}$", args.id): args.id = IDTECK_PREAMBLE_HEX + args.id else: raise ArgsParserError("ID must be 8 or 16 HEX symbols") # Informational checksum check: some readers validate a checksum on # the payload; emit a warning when it does not match the computed # value but do not block the operation, since not all IDTECK readers # enforce it. info = _idteck_frame_info(bytes.fromhex(args.id)) if not info["preamble_valid"]: print(f"{color_string((CR, 'WARNING'))}: frame preamble {info['preamble_hex']} " f"is not the IDTECK {IDTECK_PREAMBLE_HEX} — reader will likely reject it") if not info["checksum_valid"]: print(f"{color_string((CY, 'note'))}: payload checksum 0x{info['checksum']:02X} " f"does not match computed 0x{info['checksum_expected']:02X} " f"(some readers ignore this, some may reject)") return True def args_parser(self) -> ArgumentParserNoExit: raise NotImplementedError("Please implement this") def on_exec(self, args: argparse.Namespace): raise NotImplementedError("Please implement this") class TagTypeArgsUnit(DeviceRequiredUnit): @staticmethod def add_type_args(parser: ArgumentParserNoExit): type_names = [t.name for t in TagSpecificType.list()] help_str = "Tag Type: " + ", ".join(type_names) parser.add_argument( "-t", "--type", type=str, required=True, metavar="TAG_TYPE", help=help_str, choices=type_names, ) return parser def args_parser(self) -> ArgumentParserNoExit: raise NotImplementedError() def on_exec(self, args: argparse.Namespace): raise NotImplementedError() root = CLITree(root=True) hw = root.subgroup("hw", "Hardware-related commands") hw_slot = hw.subgroup("slot", "Emulation slots commands") hw_settings = hw.subgroup("settings", "Chameleon settings commands") hf = root.subgroup("hf", "High Frequency commands") hf_14a = hf.subgroup("14a", "ISO14443-a commands") hf_mf = hf.subgroup("mf", "MIFARE Classic commands") hf_mfu = hf.subgroup("mfu", "MIFARE Ultralight / NTAG commands") hf_des = hf.subgroup("des", "MIFARE DESFire commands") hf_seos = hf.subgroup("seos", "SEOS commands") lf = root.subgroup("lf", "Low Frequency commands") lf_em = lf.subgroup("em", "EM commands") lf_em_4x05 = lf_em.subgroup("4x05", "EM4x05/EM4x69 commands") data = root.subgroup('data', 'Data analysis and visualization commands') emv = root.subgroup('emv', 'EMV contactless payment card commands') lf_em_410x = lf_em.subgroup("410x", "EM410x commands") lf_hid = lf.subgroup("hid", "HID commands") lf_hid_prox = lf_hid.subgroup("prox", "HID Prox commands") lf_ioprox = lf.subgroup("ioprox", "ioProx commands") lf_pac = lf.subgroup("pac", "PAC/Stanley commands") lf_viking = lf.subgroup("viking", "Viking commands") lf_jablotron = lf.subgroup("jablotron", "Jablotron commands") lf_generic = lf.subgroup("generic", "Generic commands") lf_idteck = lf.subgroup("idteck", "IDTECK commands") @root.command("clear") class RootClear(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Clear screen" return parser def on_exec(self, args: argparse.Namespace): os.system("clear" if os.name == "posix" else "cls") @root.command("rem") class RootRem(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Timestamped comment" parser.add_argument("comment", nargs="*", help="Your comment") return parser def on_exec(self, args: argparse.Namespace): # precision: second # iso_timestamp = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') # precision: nanosecond (note that the comment will take some time too, ~75ns, check your system) iso_timestamp = datetime.utcnow().isoformat() + "Z" comment = " ".join(args.comment) print(f"{iso_timestamp} remark: {comment}") @root.command("exit") class RootExit(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Exit client" return parser def on_exec(self, args: argparse.Namespace): print("Bye, thank you. ^.^ ") self.device_com.close() sys.exit(996) @root.command("dump_help") class RootDumpHelp(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Dump available commands" parser.add_argument( "-d", "--show-desc", action="store_true", help="Dump full command description", ) parser.add_argument( "-g", "--show-groups", action="store_true", help="Dump command groups as well", ) return parser @staticmethod def dump_help(cmd_node, depth=0, dump_cmd_groups=False, dump_description=False): visual_col1_width = 28 col1_width = visual_col1_width + len(f"{CG}{C0}") if cmd_node.cls: p = cmd_node.cls().args_parser() assert p is not None if dump_description: p.print_help() else: cmd_title = color_string((CG, cmd_node.fullname)) print(f"{cmd_title}".ljust(col1_width), end="") p.prog = " " * (visual_col1_width - len("usage: ") - 1) usage = p.format_usage().removeprefix("usage: ").strip() print(color_string((CY, usage))) else: if dump_cmd_groups and not cmd_node.root: if dump_description: print("=" * 80) print(color_string((CR, cmd_node.fullname))) print(color_string((CC, cmd_node.help_text))) else: print(color_string((CB, f"== {cmd_node.fullname} =="))) for child in cmd_node.children: RootDumpHelp.dump_help( child, depth + 1, dump_cmd_groups, dump_description ) def on_exec(self, args: argparse.Namespace): self.dump_help( root, dump_cmd_groups=args.show_groups, dump_description=args.show_desc ) @hw.command("connect") class HWConnect(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Connect to chameleon by serial port" parser.add_argument("-p", "--port", type=str, required=False) return parser def on_exec(self, args: argparse.Namespace): try: if args.port is None: # Chameleon auto-detect if no port is supplied platform_name = uname().release if "Microsoft" in platform_name: path = os.environ["PATH"].split(os.pathsep) path.append("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/") powershell_path = None for prefix in path: fn = os.path.join(prefix, "powershell.exe") if not os.path.isdir(fn) and os.access(fn, os.X_OK): powershell_path = fn break if powershell_path: process = subprocess.Popen( [ powershell_path, "Get-PnPDevice -Class Ports -PresentOnly |" " where {$_.DeviceID -like '*VID_6868&PID_8686*'} |" " Select-Object -First 1 FriendlyName |" " % FriendlyName |" " select-string COM\\d+ |" "% { $_.matches.value }", ], stdout=subprocess.PIPE, ) res = process.communicate()[0] _comport = res.decode("utf-8").strip() if _comport: args.port = _comport.replace("COM", "/dev/ttyS") else: # loop through all ports and find chameleon for port in serial.tools.list_ports.comports(): if port.vid == 0x6868: args.port = port.device break if args.port is None: # If no chameleon was found, exit print( "Chameleon not found, please connect the device or try connecting manually with the -p flag." ) return self.device_com.open(args.port) self.device_com.commands = self.cmd.get_device_capabilities() major, minor = self.cmd.get_app_version() model = ["Ultra", "Lite"][self.cmd.get_device_model()] print(f" {{ Chameleon {model} connected: v{major}.{minor} }}") except Exception as e: print(color_string((CR, f"Chameleon Connect fail: {str(e)}"))) self.device_com.close() @hw.command("disconnect") class HWDisconnect(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Disconnect chameleon" return parser def on_exec(self, args: argparse.Namespace): self.device_com.close() @hw.command("mode") class HWMode(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get or change device mode: tag reader or tag emulator" mode_group = parser.add_mutually_exclusive_group() mode_group.add_argument( "-r", "--reader", action="store_true", help="Set reader mode" ) mode_group.add_argument( "-e", "--emulator", action="store_true", help="Set emulator mode" ) return parser def on_exec(self, args: argparse.Namespace): if args.reader: self.cmd.set_device_reader_mode(True) print("Switch to { Tag Reader } mode successfully.") elif args.emulator: self.cmd.set_device_reader_mode(False) print("Switch to { Tag Emulator } mode successfully.") else: print( f"- Device Mode ( Tag {'Reader' if self.cmd.is_device_reader_mode() else 'Emulator'} )" ) @hw.command("chipid") class HWChipId(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get device chipset ID" return parser def on_exec(self, args: argparse.Namespace): print(" - Device chip ID: " + self.cmd.get_device_chip_id()) @hw.command("address") class HWAddress(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get device address (used with Bluetooth)" return parser def on_exec(self, args: argparse.Namespace): print(" - Device address: " + self.cmd.get_device_address()) @hw.command("version") class HWVersion(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get current device firmware version" return parser def on_exec(self, args: argparse.Namespace): fw_version_tuple = self.cmd.get_app_version() fw_version = f"v{fw_version_tuple[0]}.{fw_version_tuple[1]}" git_version = self.cmd.get_git_version() model = ["Ultra", "Lite"][self.cmd.get_device_model()] print(f" - Chameleon {model}, Version: {fw_version} ({git_version})") @hf_14a.command("config") class HF14AConfig(DeviceRequiredUnit): class Config(Enum): def __new__(cls, value, desc): obj = object.__new__(cls) obj._value_ = value obj.desc = desc return obj @classmethod def choices(cls): return [elem.name for elem in cls] @classmethod def format(cls, index): item = cls(index) color = CG if index == 0 else CR return f" - {cls.__name__.upper()} override: {color_string((color, item.name))} ( {item.desc} )" @classmethod def help(cls): return " / ".join([f"{elem.desc}" for elem in cls]) class Bcc(Config): std = (0, "follow standard") fix = (1, "fix bad BCC") ignore = (2, "ignore bad BCC, always use card BCC") class Cl2(Config): std = (0, "follow standard") force = (1, "always do CL2") skip = (2, "always skip CL2") class Cl3(Config): std = (0, "follow standard") force = (1, "always do CL3") skip = (2, "always skip CL3") class Rats(Config): std = (0, "follow standard") force = (1, "always do RATS") skip = (2, "always skip RATS") def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Configure 14a settings (use with caution)" parser.add_argument( "--std", action="store_true", help="Reset default configuration (follow standard)", ) parser.add_argument( "--bcc", type=str, choices=self.Bcc.choices(), help=self.Bcc.help() ) parser.add_argument( "--cl2", type=str, choices=self.Cl2.choices(), help=self.Cl2.help() ) parser.add_argument( "--cl3", type=str, choices=self.Cl3.choices(), help=self.Cl3.help() ) parser.add_argument( "--rats", type=str, choices=self.Rats.choices(), help=self.Rats.help() ) return parser def on_exec(self, args: argparse.Namespace): change_requested = False if args.std: config = {"bcc": 0, "cl2": 0, "cl3": 0, "rats": 0} change_requested = True else: config = self.cmd.hf14a_get_config() if args.bcc: config["bcc"] = self.Bcc[args.bcc].value change_requested = True if args.cl2: config["cl2"] = self.Cl2[args.cl2].value change_requested = True if args.cl3: config["cl3"] = self.Cl3[args.cl3].value change_requested = True if args.rats: config["rats"] = self.Rats[args.rats].value change_requested = True if change_requested: self.cmd.hf14a_set_config(config) config = self.cmd.hf14a_get_config() print("HF 14a config") print(self.Bcc.format(config["bcc"])) print(self.Cl2.format(config["cl2"])) print(self.Cl3.format(config["cl3"])) print(self.Rats.format(config["rats"])) @hf_14a.command("scan") class HF14AScan(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Scan 14a tag, and print basic information" return parser def check_mf1_nt(self): # detect mf1 support if self.cmd.mf1_detect_support(): # detect prng print("- Mifare Classic technology") prng_type = self.cmd.mf1_detect_prng() print(f" # Prng: {MifareClassicPrngType(prng_type)}") def sak_info(self, data_tag): # detect the technology in use based on SAK int_sak = data_tag["sak"][0] if int_sak in type_id_SAK_dict: print(f"- Guessed type(s) from SAK: {type_id_SAK_dict[int_sak]}") def scan(self, deep=False): resp = self.cmd.hf14a_scan() if resp is not None: for data_tag in resp: print(f"- UID : {data_tag['uid'].hex().upper()}") print( f"- ATQA : {data_tag['atqa'].hex().upper()} " f"(0x{int.from_bytes(data_tag['atqa'], byteorder='little'):04x})" ) print(f"- SAK : {data_tag['sak'].hex().upper()}") if len(data_tag["ats"]) > 0: print(f"- ATS : {data_tag['ats'].hex().upper()}") if deep: self.sak_info(data_tag) # TODO: following checks cannot be done yet if multiple cards are present if len(resp) == 1: self.check_mf1_nt() # TODO: check for ATS support on 14A3 tags else: print("Multiple tags detected, skipping deep tests...") else: print("ISO14443-A Tag no found") def on_exec(self, args: argparse.Namespace): self.scan() @hf_14a.command("info") class HF14AInfo(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Scan 14a tag, and print detail information" return parser def on_exec(self, args: argparse.Namespace): scan = HF14AScan() scan.device_com = self.device_com scan.scan(deep=True) @hf_mf.command("nested") class HFMFNested(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Mifare Classic nested recover key" parser.add_argument( "--blk", "--known-block", type=int, required=True, metavar="", help="Known key block number", ) srctype_group = parser.add_mutually_exclusive_group() srctype_group.add_argument( "-a", "-A", action="store_true", help="Known key is A key (default)" ) srctype_group.add_argument( "-b", "-B", action="store_true", help="Known key is B key" ) parser.add_argument( "-k", "--key", type=str, required=True, metavar="", help="Known key" ) # tblk required because only single block mode is supported for now parser.add_argument( "--tblk", "--target-block", type=int, required=True, metavar="", help="Target key block number", ) dsttype_group = parser.add_mutually_exclusive_group() dsttype_group.add_argument( "--ta", "--tA", action="store_true", help="Target A key (default)" ) dsttype_group.add_argument( "--tb", "--tB", action="store_true", help="Target B key" ) return parser def from_nt_level_code_to_str(self, nt_level): if nt_level == 0: return "StaticNested" if nt_level == 1: return "Nested" if nt_level == 2: return "HardNested" def recover_a_key( self, block_known, type_known, key_known, block_target, type_target ) -> Union[str, None]: """ recover a key from key known. :param block_known: :param type_known: :param key_known: :param block_target: :param type_target: :return: """ # check nt level, we can run static or nested auto... nt_level = self.cmd.mf1_detect_prng() print( f" - NT vulnerable: {color_string((CY, self.from_nt_level_code_to_str(nt_level)))}" ) if nt_level == 2: print(" [!] Use hf mf hardnested") return None # acquire if nt_level == 0: # It's a staticnested tag? nt_uid_obj = self.cmd.mf1_static_nested_acquire( block_known, type_known, key_known, block_target, type_target ) cmd_param = f"{nt_uid_obj['uid']} {int(type_target)}" for nt_item in nt_uid_obj["nts"]: cmd_param += f" {nt_item['nt']} {nt_item['nt_enc']}" tool_name = "staticnested" else: dist_obj = self.cmd.mf1_detect_nt_dist(block_known, type_known, key_known) nt_obj = self.cmd.mf1_nested_acquire( block_known, type_known, key_known, block_target, type_target ) # create cmd cmd_param = f"{dist_obj['uid']} {dist_obj['dist']}" for nt_item in nt_obj: cmd_param += f" {nt_item['nt']} {nt_item['nt_enc']} {nt_item['par']}" tool_name = "nested" # Cross-platform compatibility if sys.platform == "win32": cmd_recover = f"{tool_name}.exe {cmd_param}" else: cmd_recover = f"./{tool_name} {cmd_param}" print(f" Executing {cmd_recover}") # start a decrypt process process = self.sub_process(cmd_recover) # wait end while process.is_running(): msg = f" [ Time elapsed {process.get_time_distance()/1000:#.1f}s ]\r" print(msg, end="") time.sleep(0.1) # clear \r print() if process.get_ret_code() == 0: output_str = process.get_output_sync() key_list = [] for line in output_str.split("\n"): sea_obj = re.search(r"([a-fA-F0-9]{12})", line) if sea_obj is not None: key_list.append(sea_obj[1]) # Here you have to verify the password first, and then get the one that is successfully verified # If there is no verified password, it means that the recovery failed, you can try again print(f" - [{len(key_list)} candidate key(s) found ]") for key in key_list: key_bytes = bytearray.fromhex(key) if self.cmd.mf1_auth_one_key_block( block_target, type_target, key_bytes ): return key else: # No keys recover, and no errors. return None def on_exec(self, args: argparse.Namespace): block_known = args.blk # default to A type_known = MfcKeyType.B if args.b else MfcKeyType.A key_known: str = args.key if not re.match(r"^[a-fA-F0-9]{12}$", key_known): print("key must include 12 HEX symbols") return key_known_bytes = bytes.fromhex(key_known) block_target = args.tblk # default to A type_target = MfcKeyType.B if args.tb else MfcKeyType.A if block_known == block_target and type_known == type_target: print(color_string((CR, "Target key already known"))) return print(" - Nested recover one key running...") key = self.recover_a_key( block_known, type_known, key_known_bytes, block_target, type_target ) if key is None: print(color_string((CY, "\nNo key found, you can retry."))) else: print( f"\n - Block {block_target} Type {type_target.name} Key Found: {color_string((CG, key))}" ) return @hf_mf.command("darkside") class HFMFDarkside(ReaderRequiredUnit): def __init__(self): super().__init__() self.darkside_list = [] def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Mifare Classic darkside recover key" return parser def recover_key(self, block_target, type_target): """ Execute darkside acquisition and decryption. :param block_target: :param type_target: :return: """ first_recover = True retry_count = 0 while retry_count < 0xFF: darkside_resp = self.cmd.mf1_darkside_acquire( block_target, type_target, first_recover, 30 ) first_recover = False # not first run. if darkside_resp[0] != MifareClassicDarksideStatus.OK: print( f"Darkside error: {MifareClassicDarksideStatus(darkside_resp[0])}" ) break darkside_obj = darkside_resp[1] if darkside_obj["par"] != 0: # NXP tag workaround. self.darkside_list.clear() self.darkside_list.append(darkside_obj) recover_params = f"{darkside_obj['uid']}" for darkside_item in self.darkside_list: recover_params += f" {darkside_item['nt1']} {darkside_item['ks1']} {darkside_item['par']}" recover_params += f" {darkside_item['nr']} {darkside_item['ar']}" if sys.platform == "win32": cmd_recover = f"darkside.exe {recover_params}" else: cmd_recover = f"./darkside {recover_params}" # subprocess.run(cmd_recover, cwd=os.path.abspath("../bin/"), shell=True) # print(f" Executing {cmd_recover}") # start a decrypt process process = self.sub_process(cmd_recover) # wait end process.wait_process() # get output output_str = process.get_output_sync() if "key not found" in output_str: print(f" - No key found, retrying({retry_count})...") retry_count += 1 continue # retry else: key_list = [] for line in output_str.split("\n"): sea_obj = re.search(r"([a-fA-F0-9]{12})", line) if sea_obj is not None: key_list.append(sea_obj[1]) # auth key for key in key_list: key_bytes = bytearray.fromhex(key) if self.cmd.mf1_auth_one_key_block( block_target, type_target, key_bytes ): return key return None def on_exec(self, args: argparse.Namespace): key = self.recover_key(0x03, MfcKeyType.A) if key is not None: print(f" - Key Found: {key}") else: print(" - Key recover fail.") return @hf_mf.command("hardnested") class HFMFHardNested(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Mifare Classic hardnested recover key " parser.add_argument( "--blk", "--known-block", type=int, required=True, metavar="", help="Known key block number", ) srctype_group = parser.add_mutually_exclusive_group() srctype_group.add_argument( "-a", "-A", action="store_true", help="Known key is A key (default)" ) srctype_group.add_argument( "-b", "-B", action="store_true", help="Known key is B key" ) parser.add_argument( "-k", "--key", type=str, required=True, metavar="", help="Known key" ) parser.add_argument( "--tblk", "--target-block", type=int, required=True, metavar="", help="Target key block number", ) dsttype_group = parser.add_mutually_exclusive_group() dsttype_group.add_argument( "--ta", "--tA", action="store_true", help="Target A key (default)" ) dsttype_group.add_argument( "--tb", "--tB", action="store_true", help="Target B key" ) parser.add_argument( "--slow", action="store_true", help="Use slower acquisition mode (more nonces)", ) parser.add_argument( "--keep-nonce-file", action="store_true", help="Keep the generated nonce file (nonces.bin)", ) parser.add_argument( "--max-runs", type=int, default=200, metavar="", help="Maximum acquisition runs per attempt before giving up (default: 200)", ) # Add max acquisition attempts parser.add_argument( "--max-attempts", type=int, default=3, metavar="", help="Maximum acquisition attempts if MSB sum is invalid (default: 3)", ) return parser def recover_key( self, slow_mode, block_known, type_known, key_known, block_target, type_target, keep_nonce_file, max_runs, max_attempts, ): """ Recover a key using the HardNested attack via a nonce file, with dynamic MSB-based acquisition and restart on invalid sum. :param slow_mode: Boolean indicating if slow mode should be used. :param block_known: Known key block number. :param type_known: Known key type (A or B). :param key_known: Known key bytes. :param block_target: Target key block number. :param type_target: Target key type (A or B). :param keep_nonce_file: Boolean indicating whether to keep the nonce file. :param max_runs: Maximum number of acquisition runs per attempt. :param max_attempts: Maximum number of full acquisition attempts. :return: Recovered key as a hex string, or None if not found. """ print(" - Starting HardNested attack...") nonces_buffer = bytearray() # This will hold the final data for the file uid_bytes = b"" # To store UID from the successful attempt # --- Outer loop for acquisition attempts --- acquisition_success = False # Flag to indicate if any attempt was successful for attempt in range(max_attempts): print( f"\n--- Starting Acquisition Attempt {attempt + 1}/{max_attempts} ---" ) total_raw_nonces_bytes = ( bytearray() ) # Accumulator for raw nonces for THIS attempt nonces_buffer.clear() # Clear buffer for each new attempt # --- MSB Tracking Initialization (Reset for each attempt) --- seen_msbs = [False] * 256 unique_msb_count = 0 msb_parity_sum = 0 # --- End MSB Tracking Initialization --- run_count = 0 acquisition_goal_met = False # 1. Scan for the tag to get UID and prepare file header (Done ONCE per attempt) print(" Scanning for tag...") try: scan_resp = self.cmd.hf14a_scan() except Exception as e: print(color_string((CR, f" Error scanning tag: {e}"))) # Decide if we should retry or fail completely. Let's fail for now. print( color_string((CR, " Attack failed due to error during scanning.")) ) return None if scan_resp is None or len(scan_resp) == 0: print(color_string((CR, "Error: No tag found."))) if attempt + 1 < max_attempts: print(color_string((CY, " Retrying scan in 1 second..."))) time.sleep(1) continue # Retry the outer loop (next attempt) else: print( color_string( ( CR, " Maximum attempts reached without finding tag. Attack failed.", ) ) ) return None if len(scan_resp) > 1: print( color_string( ( CR, " Error: Multiple tags found. Please present only one tag.", ) ) ) # Fail immediately if multiple tags are present return None tag_info = scan_resp[0] uid_bytes = tag_info["uid"] # Store UID for later verification uid_len = len(uid_bytes) uid_for_file = b"" if uid_len == 4: uid_for_file = uid_bytes[0:4] elif uid_len == 7: uid_for_file = uid_bytes[3:7] elif uid_len == 10: uid_for_file = uid_bytes[6:10] else: print( color_string( ( CR, f" Error: Unexpected UID length ({uid_len} bytes). Cannot create nonce file header.", ) ) ) return None # Fail if UID length is unexpected print(f" Tag found with UID: {uid_bytes.hex().upper()}") # Prepare header in the main buffer for this attempt nonces_buffer.extend(uid_for_file) nonces_buffer.extend( struct.pack("!BB", block_target, type_target.value & 0x01) ) print(f" Nonce file header prepared: {nonces_buffer.hex().upper()}") # 2. Acquire nonces dynamically based on MSB criteria (Inner loop for runs) print( f" Acquiring nonces (slow mode: {slow_mode}, max runs: {max_runs}). This may take a while..." ) while run_count < max_runs: run_count += 1 print(f" Starting acquisition run {run_count}/{max_runs}...") try: # Check if tag is still present before each run current_scan = self.cmd.hf14a_scan() if ( current_scan is None or len(current_scan) == 0 or current_scan[0]["uid"] != uid_bytes ): print( color_string( ( CY, f" Error: Tag lost or changed before run {run_count}. Stopping acquisition attempt.", ) ) ) acquisition_goal_met = False # Mark as failed break # Exit inner run loop for this attempt # Acquire nonces for this run raw_nonces_bytes_this_run = self.cmd.mf1_hard_nested_acquire( slow_mode, block_known, type_known, key_known, block_target, type_target, ) if not raw_nonces_bytes_this_run: print( color_string( ( CY, f" Run {run_count}: No nonces acquired in this run. Continuing...", ) ) ) time.sleep(0.1) # Small delay before retrying continue # Append successfully acquired nonces to the total buffer for this attempt total_raw_nonces_bytes.extend(raw_nonces_bytes_this_run) # --- Process acquired nonces for MSB tracking --- num_pairs_this_run = len(raw_nonces_bytes_this_run) // 9 print( f" Run {run_count}: Acquired {num_pairs_this_run * 2} nonces ({len(raw_nonces_bytes_this_run)} bytes raw). Processing MSBs..." ) new_msbs_found_this_run = 0 for i in range(num_pairs_this_run): offset = i * 9 try: nt, nt_enc, par = struct.unpack_from( "!IIB", raw_nonces_bytes_this_run, offset ) except struct.error as unpack_err: print( color_string( ( CR, f" Error unpacking nonce data at offset {offset}: {unpack_err}. Skipping pair.", ) ) ) continue msb = (nt_enc >> 24) & 0xFF if not seen_msbs[msb]: seen_msbs[msb] = True unique_msb_count += 1 new_msbs_found_this_run += 1 parity_bit = hardnested_utils.evenparity32( (nt_enc & 0xFF000000) | (par & 0x08) ) msb_parity_sum += parity_bit print( f"\r Unique MSBs: {unique_msb_count}/256 | Current Sum: {msb_parity_sum} ", end="", ) if new_msbs_found_this_run > 0: print() # Print a newline after progress update # --- Check termination condition --- if unique_msb_count == 256: print() print( f"{color_string((CG, ' All 256 unique MSBs found.'))} Final parity sum: {msb_parity_sum}" ) if msb_parity_sum in hardnested_utils.hardnested_sums: print( color_string( ( CG, f" Parity sum {msb_parity_sum} is VALID. Stopping acquisition runs.", ) ) ) acquisition_goal_met = True acquisition_success = True # Mark attempt as successful break # Exit the inner run loop successfully else: print( color_string( ( CR, f" Parity sum {msb_parity_sum} is INVALID (Expected one of {hardnested_utils.hardnested_sums}).", ) ) ) acquisition_goal_met = False # Mark as failed acquisition_success = False break # Exit the inner run loop to restart the attempt except chameleon_com.CMDInvalidException: print( color_string( ( CR, " Error: Hardnested command not supported by this firmware version.", ) ) ) return None # Cannot proceed at all except UnexpectedResponseError as e: print( color_string( ( CR, f" Error acquiring nonces during run {run_count}: {e}", ) ) ) print( color_string( (CY, " Stopping acquisition runs for this attempt...") ) ) acquisition_goal_met = False break # Exit inner run loop except TimeoutError: print( color_string( ( CR, f" Error: Timeout during nonce acquisition run {run_count}.", ) ) ) print( color_string( (CY, " Stopping acquisition runs for this attempt...") ) ) acquisition_goal_met = False break # Exit inner run loop except Exception as e: print( color_string( ( CR, f" Unexpected error during acquisition run {run_count}: {e}", ) ) ) print( color_string( (CY, " Stopping acquisition runs for this attempt...") ) ) acquisition_goal_met = False break # Exit inner run loop # --- End of inner run loop (while run_count < max_runs) --- # --- Post-Acquisition Summary for this attempt --- print(f"\n Finished acquisition phase for attempt {attempt + 1}.") if acquisition_success: print( color_string( ( CG, f" Successfully acquired nonces meeting the MSB sum criteria in {run_count} runs.", ) ) ) # Append collected raw nonces to the main buffer for the file nonces_buffer.extend(total_raw_nonces_bytes) break # Exit the outer attempt loop successfully elif unique_msb_count == 256 and not acquisition_goal_met: print( color_string( (CR, " Found all 256 MSBs, but the parity sum was invalid.") ) ) if attempt + 1 < max_attempts: print(color_string((CY, " Restarting acquisition process..."))) time.sleep(1) # Small delay before restarting continue # Continue to the next iteration of the outer attempt loop else: print( color_string( ( CR, f" Maximum attempts ({max_attempts}) reached with invalid sum. Attack failed.", ) ) ) return None # Failed after max attempts elif run_count >= max_runs: print( color_string( ( CY, f" Warning: Reached max runs ({max_runs}) for attempt {attempt + 1}. Found {unique_msb_count}/256 unique MSBs.", ) ) ) if attempt + 1 < max_attempts: print(color_string((CY, " Restarting acquisition process..."))) time.sleep(1) continue # Continue to the next iteration of the outer attempt loop else: print( color_string( ( CR, f" Maximum attempts ({max_attempts}) reached without meeting criteria. Attack failed.", ) ) ) return None # Failed after max attempts else: # Acquisition stopped due to error or tag loss print( color_string( ( CR, f"Acquisition attempt {attempt + 1} stopped prematurely due to an error after {run_count} runs.", ) ) ) # Decide if we should retry or fail completely. Let's fail for now. print( color_string((CR, "Attack failed due to error during acquisition.")) ) return None # Failed due to error # --- End of outer attempt loop --- # If we exited the loop successfully (acquisition_success is True) if not acquisition_success: # This case should ideally be caught within the loop, but as a safeguard: print( color_string( (CR, f" Error: Acquisition failed after {max_attempts} attempts.") ) ) return None # --- Proceed with the rest of the attack using the successfully collected nonces --- total_nonce_pairs = ( len(total_raw_nonces_bytes) // 9 ) # Use data from the successful attempt print( f"\n Proceeding with attack using {total_nonce_pairs * 2} nonces ({len(total_raw_nonces_bytes)} bytes raw)." ) print(f" Total nonce file size will be {len(nonces_buffer)} bytes.") if total_nonce_pairs == 0: print( color_string( ( CR, " Error: No nonces were successfully acquired in the final attempt.", ) ) ) return None # 3. Save nonces to a temporary file nonce_file_path = None temp_nonce_file = None output_str = "" # To store the output read from the file try: # --- Nonce File Handling --- delete_nonce_on_close = not keep_nonce_file # Use delete_on_close=False to manage deletion manually in finally block temp_nonce_file = tempfile.NamedTemporaryFile( suffix=".bin", prefix="hardnested_nonces_", delete=False, mode="wb", dir=".", ) temp_nonce_file.write( nonces_buffer ) # Write the buffer from the successful attempt temp_nonce_file.flush() nonce_file_path = temp_nonce_file.name temp_nonce_file.close() # Close it so hardnested can access it temp_nonce_file = None # Clear variable after closing print( f" Nonces saved to {'temporary ' if delete_nonce_on_close else ''}file: {os.path.abspath(nonce_file_path)}" ) # 4. Prepare and run the external hardnested tool, redirecting output print( color_string( (CC, "--- Running Hardnested Tool (Output redirected) ---") ) ) output_str = execute_tool("hardnested", [os.path.abspath(nonce_file_path)]) print(color_string((CC, "--- Hardnested Tool Finished ---"))) # 5. Read the output from the temporary log file # 6. Process the result (using output_str read from the file) key_list = [] key_prefix = "Key found: " # Define the specific prefix to look for for line in output_str.splitlines(): line_stripped = line.strip() # Remove leading/trailing whitespace if line_stripped.startswith(key_prefix): # Found the target line, now extract the key using regex # Regex now looks for 12 hex chars specifically after the prefix sea_obj = re.search( r"([a-fA-F0-9]{12})", line_stripped[len(key_prefix):] ) if sea_obj: key_list.append(sea_obj.group(1)) # Optional: Break if you only expect one "Key found:" line # break if not key_list: print( color_string( ( CY, f" No line starting with '{key_prefix}' found in the output file.", ) ) ) return None # 7. Verify Keys (Same as before) print( f" [{len(key_list)} candidate key(s) found in output. Verifying...]" ) # Use the UID from the successful acquisition attempt uid_bytes_for_verify = ( uid_bytes # From the last successful scan in the outer loop ) for key_hex in key_list: key_bytes = bytes.fromhex(key_hex) print(f" Trying key: {key_hex.upper()}...", end="") try: # Check tag presence before auth attempt scan_check = self.cmd.hf14a_scan() if ( scan_check is None or len(scan_check) == 0 or scan_check[0]["uid"] != uid_bytes_for_verify ): print( color_string( ( CR, " Tag lost or changed during verification. Cannot verify.", ) ) ) return None # Stop verification if tag is gone if self.cmd.mf1_auth_one_key_block( block_target, type_target, key_bytes ): print(color_string((CG, " Success!"))) return key_hex # Return the verified key else: print(color_string((CR, "Auth failed."))) except UnexpectedResponseError as e: print(color_string((CR, f" Verification error: {e}"))) # Consider if we should continue trying other keys or stop except Exception as e: print( color_string( (CR, f" Unexpected error during verification: {e}") ) ) # Consider stopping here print(color_string((CY, " Verification failed for all candidate keys."))) return None finally: # 8. Clean up nonce file if nonce_file_path and os.path.exists(nonce_file_path): if keep_nonce_file: final_nonce_filename = "nonces.bin" try: if os.path.exists(final_nonce_filename): os.remove(final_nonce_filename) # Use replace for atomicity if possible os.replace(nonce_file_path, final_nonce_filename) print( f" Nonce file kept as: {os.path.abspath(final_nonce_filename)}" ) except OSError as e: print( color_string( ( CR, f" Error renaming/replacing temporary nonce file to {final_nonce_filename}: {e}", ) ) ) print(f" Temporary file might remain: {nonce_file_path}") else: try: os.remove(nonce_file_path) # print(f" Temporary nonce file deleted: {nonce_file_path}") # Optional confirmation except OSError as e: print( color_string( ( CR, f" Error deleting temporary nonce file {nonce_file_path}: {e}", ) ) ) def on_exec(self, args: argparse.Namespace): block_known = args.blk type_known = MfcKeyType.B if args.b else MfcKeyType.A key_known_str: str = args.key if not re.match(r"^[a-fA-F0-9]{12}$", key_known_str): raise ArgsParserError("Known key must include 12 HEX symbols") key_known_bytes = bytes.fromhex(key_known_str) block_target = args.tblk type_target = MfcKeyType.B if args.tb else MfcKeyType.A if block_known == block_target and type_known == type_target: print(color_string((CR, "Target key is the same as the known key."))) return # Pass the max_runs and max_attempts arguments recovered_key = self.recover_key( args.slow, block_known, type_known, key_known_bytes, block_target, type_target, args.keep_nonce_file, args.max_runs, args.max_attempts, ) if recovered_key: print( f" - Key Found: Block {block_target} Type {type_target.name} Key = {color_string((CG, recovered_key.upper()))}" ) else: print(color_string((CR, " - HardNested attack failed to recover the key."))) @hf_mf.command("senested") class HFMFStaticEncryptedNested(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Mifare Classic static encrypted recover key via backdoor" parser.add_argument( "--key", "-k", help="Backdoor key (as hex[12] format), currently known: A396EFA4E24F (default), A31667A8CEC1, 518B3354E760. See https://eprint.iacr.org/2024/1275", metavar="", type=str, ) parser.add_argument( "--sectors", "-s", type=int, metavar="", help="Sector count" ) parser.add_argument( "--starting-sector", type=int, metavar="", help="Start recovery from this sector", ) parser.set_defaults(sectors=16) parser.set_defaults(starting_sector=0) parser.set_defaults(key="A396EFA4E24F") return parser def on_exec(self, args: argparse.Namespace): key_map = self.senested( args.key, args.starting_sector, args.sectors, args.sectors ) print_key_table(key_map) def senested(self, key, starting_sector, stopping_sector, sectors): acquire_datas = self.cmd.mf1_static_encrypted_nested_acquire( bytes.fromhex(key), sectors, starting_sector ) if not acquire_datas: print("Failed to collect nonces, is card present and has backdoor?") uid = format(acquire_datas["uid"], "x") key_map = {"A": {}, "B": {}} check_speed = 1.95 # sec per 64 keys for sector in range(starting_sector, stopping_sector): sector_name = str(sector).zfill(2) print("Recovering", sector, "sector...") execute_tool( "staticnested_1nt", [ uid, sector_name, format(acquire_datas["nts"]["a"][sector]["nt"], "x").zfill(8), format(acquire_datas["nts"]["a"][sector]["nt_enc"], "x").zfill(8), str(acquire_datas["nts"]["a"][sector]["parity"]).zfill(4), ], ) execute_tool( "staticnested_1nt", [ uid, sector_name, format(acquire_datas["nts"]["b"][sector]["nt"], "x").zfill(8), format(acquire_datas["nts"]["b"][sector]["nt_enc"], "x").zfill(8), str(acquire_datas["nts"]["b"][sector]["parity"]).zfill(4), ], ) a_key_dic = f"keys_{uid}_{sector_name}_{format(acquire_datas['nts']['a'][sector]['nt'], 'x').zfill(8)}.dic" b_key_dic = f"keys_{uid}_{sector_name}_{format(acquire_datas['nts']['b'][sector]['nt'], 'x').zfill(8)}.dic" execute_tool("staticnested_2x1nt_rf08s", [a_key_dic, b_key_dic]) keys = open( os.path.join( tempfile.gettempdir(), b_key_dic.replace(".dic", "_filtered.dic") ) ).readlines() keys_bytes = [] for key in keys: keys_bytes.append(bytes.fromhex(key.strip())) key = None print( "Start checking possible B keys, will take up to", math.floor(len(keys_bytes) / 64 * check_speed), "seconds for", len(keys_bytes), "keys", ) for i in tqdm_if_exists(range(0, len(keys_bytes), 64)): data = self.cmd.mf1_check_keys_on_block( sector * 4 + 3, 0x61, keys_bytes[i: i + 64] ) if data: key = data.hex().zfill(12) key_map["B"][sector] = key print("Found B key", key) break if key: a_key = execute_tool( "staticnested_2x1nt_rf08s_1key", [ format(acquire_datas["nts"]["b"][sector]["nt"], "x").zfill(8), key, a_key_dic, ], ) keys_bytes = [] for key in a_key.split("\n"): keys_bytes.append(bytes.fromhex(key.strip())) data = self.cmd.mf1_check_keys_on_block( sector * 4 + 3, 0x60, keys_bytes ) if data: key = data.hex().zfill(12) print("Found A key", key) key_map["A"][sector] = key continue else: print( "Failed to find A key by fast method, trying all possible keys" ) keys = open( os.path.join( tempfile.gettempdir(), a_key_dic.replace(".dic", "_filtered.dic"), ) ).readlines() keys_bytes = [] for key in keys: keys_bytes.append(bytes.fromhex(key.strip())) print( "Start checking possible A keys, will take up to", math.floor(len(keys_bytes) / 64 * check_speed), "seconds for", len(keys_bytes), "keys", ) for i in tqdm_if_exists(range(0, len(keys_bytes), 64)): data = self.cmd.mf1_check_keys_on_block( sector * 4 + 3, 0x60, keys_bytes[i: i + 64] ) if data: key = data.hex().zfill(12) print("Found A key", key) key_map["A"][sector] = key break else: print("Failed to find key") for file in glob.glob(tempfile.gettempdir() + "/keys_*.dic"): os.remove(file) return key_map @hf_mf.command("autopwn") class HFMFAutopwn(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Mifare Classic auto recovery tool" parser.add_argument( "-k", "--key", type=str, required=False, metavar="", help="Known key" ) return parser def get_mf_size(self, sak): sizes = { b"\x18": ("4k", 40), b"\x08": ("1k", 16), b"\x09": ("mini", 5), b"\x10": ("2k", 32), b"\x01": ("1k", 16), } if sak not in sizes: print("Unknown SAK, defaulting to 16 sectors") return sizes.get(sak, ("1k", 16)) def getsak(self, deep=False): return self.scan(deep, "sak") def getuid(self, deep=False): return self.scan(deep, "uid") def from_nt_level_code_to_str(self, nt_level): return {0: "StaticNested", 1: "Nested", 2: "HardNested"}.get( nt_level, "Unknown" ) def scan(self, deep=False, scanitem="uid"): resp = self.cmd.hf14a_scan() if resp is None: print("ISO14443-A tag not found") return None for data_tag in resp: if deep: self.sak_info(data_tag) if len(resp) == 1: self.check_mf1_nt() else: print("Multiple tags detected, skipping deep tests...") return data_tag[scanitem].hex().upper() def bits_to_10byte_mask(self, bits=0): return bytes.fromhex(f"{(1 << (80 - bits)) - 1:020X}") def try_key(self, key: bytes, mask: bytes): return self.cmd.mf1_check_keys_of_sectors(mask, [key]) def neg_bytes(self, data: bytes) -> bytes: return (~int.from_bytes(data, "big") & ((1 << (len(data) * 8)) - 1)).to_bytes( len(data), "big" ) def merge_found_sector_keys(self, existing, response, overwrite=False): for idx, key in response.get("sectorKeys", {}).items(): if overwrite or idx not in existing: existing[idx] = key return existing def print_key_table(self, keymap, max_sectors): def fmt(k): text = ( k.hex().upper().ljust(12) if isinstance(k, (bytes, bytearray)) else "------------" ) color = CG if isinstance(k, (bytes, bytearray)) else CR return f"{color}{text}{C0}" sep = "╠══════╬══════════════╬══════════════╣" print("╔══════╦══════════════╦══════════════╗") print("║ Sec ║ key A ║ key B ║") for sector in range(max_sectors): print(sep) print( f"║ {sector:03d} ║ {fmt(keymap.get(sector * 2))} ║ {fmt(keymap.get(sector * 2 + 1))} ║" ) print("╚══════╩══════════════╩══════════════╝") def find_missing_keys(self, existing_keys: dict, max_num: int): return { i: (MfcKeyType.A if i % 2 == 0 else MfcKeyType.B) for i in range(max_num) if i not in existing_keys } def choose_random_known_key(self, keys_dict): index = random.choice(list(keys_dict.keys())) return ( (index // 2) * 4, keys_dict[index], MfcKeyType.B if index % 2 else MfcKeyType.A, ) def mask_from_keys( self, pos_container, total_bits=80, one_indexed=False, msb_left=True ): positions = ( pos_container.keys() if isinstance(pos_container, dict) else pos_container ) field = ["0"] * total_bits for p in positions: try: p = int(p) except Exception: continue idx = p - 1 if one_indexed else p if 0 <= idx < total_bits: field[idx if msb_left else total_bits - 1 - idx] = "1" bstr = "".join(field) return bstr, bytes(int(bstr[i: i + 8], 2) for i in range(0, total_bits, 8)) def run_senested(self, current_keys_found, max_sectors_num): print( f" {CY}[+]{C0} This card could be cracked using static nested attack (May take a few minutes)" ) if input(f" {C0}[+]{C0} Would you like to proceed? [y/n]: ").lower() != "y": return current_keys_found backdoor_key = "A396EFA4E24F" if ( input( f" {C0}[+]{C0} Would you like to use default backdoor key? [y/n]: " ).lower() == "n" ): while True: backdoor_key = input( f" {C0}[+]{C0} Backdoor key (known: A396EFA4E24F, A31667A8CEC1, 518B3354E760): " ).upper() if re.fullmatch(r"[A-F0-9]{12}", backdoor_key): print(f" {CG}[+]{C0} Valid key") break print(f" {CR}[!]{C0} Invalid format for key") else: print(f" {CY}[+]{C0} Using default key A396EFA4E24F") print(f" {CG}[+]{C0} Running static nested..") snested = HFMFStaticEncryptedNested.__new__(HFMFStaticEncryptedNested) snested._device_cmd = self.cmd missing_keys = self.find_missing_keys(current_keys_found, max_sectors_num * 2) keys = list(missing_keys.items()) for i, (key_num, key_type) in enumerate(keys): if current_keys_found.get(key_num) is not None: print(f" {CG}[+]{C0} Key {key_num} found by reuse") continue sector_num = key_num // 2 found_keymap = snested.senested( backdoor_key, sector_num, sector_num + 1, max_sectors_num ) next_key_num = keys[i + 1][0] if i + 1 < len(keys) else -1 if next_key_num == key_num + 1 and key_type == MfcKeyType.A: current_keys_found[key_num] = bytes.fromhex( found_keymap["A"][sector_num] ) current_keys_found[key_num + 1] = bytes.fromhex( found_keymap["B"][sector_num] ) elif key_type == MfcKeyType.A: current_keys_found[key_num] = bytes.fromhex( found_keymap["A"][sector_num] ) else: current_keys_found[key_num] = bytes.fromhex( found_keymap["B"][sector_num] ) current_keys_found = dict(sorted(current_keys_found.items())) _, mask_bytes = self.mask_from_keys(missing_keys) neg = self.neg_bytes(mask_bytes) current_keys_found = self.merge_found_sector_keys( current_keys_found, self.try_key(bytes.fromhex(found_keymap["A"][sector_num]), neg), ) current_keys_found = self.merge_found_sector_keys( current_keys_found, self.try_key(bytes.fromhex(found_keymap["B"][sector_num]), neg), ) return current_keys_found def autopwn(self, key_known): uid = self.getuid() sak = self.getsak() mf_size, max_sectors_num = self.get_mf_size(bytes.fromhex(sak)) print(f" {CG}[+]{C0} Type: MIFARE Classic {CY}{mf_size}{C0}") print(f" {CG}[+]{C0} UID: {uid}") print(f" {CG}[+]{C0} SAK: {sak}") nt_level = self.cmd.mf1_detect_prng() print( f" {CG}[+]{C0} NT vulnerable: {CY}{self.from_nt_level_code_to_str(nt_level)}{C0}" ) current_keys_found = {} full_mask = self.bits_to_10byte_mask(max_sectors_num * 2) if key_known is not None: current_keys_found = self.merge_found_sector_keys( current_keys_found, self.try_key(bytes.fromhex(key_known), full_mask) ) current_keys_found = self.merge_found_sector_keys( current_keys_found, self.try_key(bytes.fromhex("FFFFFFFFFFFF"), bytes(10)) ) if not current_keys_found: print(f" {CR}[!]{C0} No keys found yet, trying darkside..") darkside = HFMFDarkside.__new__(HFMFDarkside) HFMFDarkside.__init__(darkside) darkside._device_cmd = self.cmd darkside_key = darkside.recover_key(0x03, MfcKeyType.A) if darkside_key is not None: print(f" {CG}[+]{C0} Darkside key found: {darkside_key}") current_keys_found[0] = bytes.fromhex(darkside_key) current_keys_found = dict(sorted(current_keys_found.items())) print(f" {CG}[+]{C0} Reuse key check..") current_keys_found = self.merge_found_sector_keys( current_keys_found, self.try_key(bytes.fromhex(darkside_key), full_mask), ) else: print(f" {CR}[!]{C0} Darkside failed!") total = max_sectors_num * 2 if len(current_keys_found) == total: print(f" {CG}[+]{C0} All keys found") return current_keys_found, max_sectors_num if not current_keys_found: return ( self.run_senested(current_keys_found, max_sectors_num), max_sectors_num, ) print(f" {CG}[+]{C0} Some keys found, recovering remaining..") if nt_level == 2: missing_keys = self.find_missing_keys(current_keys_found, total) hardnested = HFMFHardNested.__new__(HFMFHardNested) BaseCLIUnit.__init__(hardnested) hardnested._device_cmd = self.cmd for missing_key_num, key_type_target in missing_keys.items(): if current_keys_found.get(missing_key_num) is not None: print(f" {CG}[+]{C0} Key {missing_key_num} found by reuse") continue block_known, key_known_bytes, type_known = self.choose_random_known_key( current_keys_found ) block_target = (missing_key_num // 2) * 4 hn_key = hardnested.recover_key( False, block_known, type_known, key_known_bytes, block_target, key_type_target, False, 200, 3, ) if hn_key is None: continue print(f" {CG}[+]{C0} Found key {missing_key_num}: {hn_key.upper()}") current_keys_found[missing_key_num] = bytes.fromhex(hn_key) current_keys_found = dict(sorted(current_keys_found.items())) _, mask_bytes = self.mask_from_keys(missing_keys) current_keys_found = self.merge_found_sector_keys( current_keys_found, self.try_key(bytes.fromhex(hn_key), self.neg_bytes(mask_bytes)), ) if len(current_keys_found) < total: current_keys_found = self.run_senested( current_keys_found, max_sectors_num ) elif nt_level == 0: current_keys_found = self.run_senested(current_keys_found, max_sectors_num) else: block_known, key_known_bytes, type_known = self.choose_random_known_key( current_keys_found ) missing_keys = self.find_missing_keys(current_keys_found, total) nested = HFMFNested.__new__(HFMFNested) BaseCLIUnit.__init__(nested) nested._device_cmd = self.cmd hardnested = None for missing_key_num, key_type_target in missing_keys.items(): if current_keys_found.get(missing_key_num) is not None: print(f" {CG}[+]{C0} Key {missing_key_num} found by reuse") continue nested_key = nested.recover_a_key( block_known, type_known, key_known_bytes, (missing_key_num // 2) * 4, key_type_target, ) if nested_key is None: if hardnested is None: hardnested = HFMFHardNested.__new__(HFMFHardNested) BaseCLIUnit.__init__(hardnested) hardnested._device_cmd = self.cmd hn_key = hardnested.recover_key( False, block_known, type_known, key_known_bytes, (missing_key_num // 2) * 4, key_type_target, False, 200, 3, ) if hn_key is None: continue current_keys_found[missing_key_num] = bytes.fromhex(hn_key) print( f" {CG}[+]{C0} Found key {missing_key_num}: {hn_key.upper()}" ) else: print( f" {CG}[+]{C0} Found key {missing_key_num}: {nested_key.upper()}" ) current_keys_found[missing_key_num] = bytes.fromhex(nested_key) current_keys_found = dict(sorted(current_keys_found.items())) _, mask_bytes = self.mask_from_keys(missing_keys) new_key = current_keys_found[missing_key_num] current_keys_found = self.merge_found_sector_keys( current_keys_found, self.try_key(new_key, self.neg_bytes(mask_bytes)), ) if len(current_keys_found) < total: current_keys_found = self.run_senested( current_keys_found, max_sectors_num ) if len(current_keys_found) == total: print(f" {CG}[+]{C0} All keys found") return current_keys_found, max_sectors_num def save_keys_to_file(self, extracted_keys, max_sectors_num): if input(f" {CY}[?]{C0} Save keys to file? [y/n]: ").lower() != "y": return filename = input( f" {C0}[+]{C0} Enter base filename (without extension): " ).strip() if not filename: print(f" {CR}[!]{C0} No filename provided, skipping.") return dic_path = filename + ".dic" uniq_keys = set( v for v in extracted_keys.values() if isinstance(v, (bytes, bytearray)) ) with open(dic_path, "w") as f: for key in uniq_keys: f.write(key.hex().upper() + "\n") print(f" {CG}[+]{C0} Keys saved to {dic_path} (as .dic format)") key_path = filename + ".key" unknownkey = bytes(6) with open(key_path, "wb") as f: for sector_no in range(max_sectors_num): f.write(extracted_keys.get(sector_no * 2, unknownkey)) f.write(extracted_keys.get(sector_no * 2 + 1, unknownkey)) print(f" {CG}[+]{C0} Keys saved to {key_path} (as .key format)") def dump_card_to_file(self, extracted_keys, max_sectors_num): if input(f" {CY}[?]{C0} Dump card to file? [y/n]: ").lower() != "y": return filename = input( f" {C0}[+]{C0} Enter dump filename (without extension): " ).strip() if not filename: print(f" {CR}[!]{C0} No filename provided, skipping.") return dump_path = filename + ".bin" buffer = bytearray() for s in range(max_sectors_num): key_a = extracted_keys.get(s * 2) key_b = extracted_keys.get(s * 2 + 1) num_blocks, first_block = ( (4, s * 4) if s < 32 else (16, 128 + (s - 32) * 16) ) for b in range(num_blocks): block_num = first_block + b block_data = None if key_b is not None: try: block_data = self.cmd.mf1_read_one_block( block_num, MfcKeyType.B, key_b ) except Exception: pass if block_data is None and key_a is not None: try: block_data = self.cmd.mf1_read_one_block( block_num, MfcKeyType.A, key_a ) except Exception: pass if block_data is None: print( f" {CR}[!]{C0} Block {block_num} unreadable, filling with zeros" ) block_data = bytes(16) buffer.extend(block_data) with open(dump_path, "wb") as f: f.write(buffer) print(f" {CG}[+]{C0} Card dumped to {dump_path}") def on_exec(self, args: argparse.Namespace): key_known: str = args.key if key_known is not None and not re.match(r"^[a-fA-F0-9]{12}$", key_known): print("key must include 12 HEX symbols") return extracted_keys, max_sectors_num = self.autopwn(key_known) self.print_key_table(extracted_keys, max_sectors_num) self.save_keys_to_file(extracted_keys, max_sectors_num) self.dump_card_to_file(extracted_keys, max_sectors_num) @hf_mf.command("fchk") class HFMFFCHK(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Mifare Classic fast key check on sectors" mifare_type_group = parser.add_mutually_exclusive_group() mifare_type_group.add_argument( "--mini", help="MIFARE Classic Mini / S20", action="store_const", dest="maxSectors", const=5, ) mifare_type_group.add_argument( "--1k", help="MIFARE Classic 1k / S50 (default)", action="store_const", dest="maxSectors", const=16, ) mifare_type_group.add_argument( "--2k", help="MIFARE Classic/Plus 2k", action="store_const", dest="maxSectors", const=32, ) mifare_type_group.add_argument( "--4k", help="MIFARE Classic 4k / S70", action="store_const", dest="maxSectors", const=40, ) parser.add_argument( dest="keys", help="Key (as hex[12] format)", metavar="", type=str, nargs="*", ) parser.add_argument( "--key", dest="import_key", type=argparse.FileType("rb"), help="Read keys from .key format file", ) parser.add_argument( "--dic", dest="import_dic", type=argparse.FileType("r", encoding="utf8"), help="Read keys from .dic format file", ) parser.add_argument( "--export-key", type=argparse.FileType("wb"), help=f'Export result as .key format, file will be {color_string((CR, "OVERWRITTEN"))} if exists', ) parser.add_argument( "--export-dic", type=argparse.FileType("w", encoding="utf8"), help=f'Export result as .dic format, file will be {color_string((CR, "OVERWRITTEN"))} if exists', ) parser.add_argument( "-m", "--mask", help="Which sectorKey to be skip, 1 bit per sectorKey. `0b1` represent to skip to check. (in hex[20] format)", type=str, default="00000000000000000000", metavar="", ) parser.set_defaults(maxSectors=16) return parser def check_keys(self, mask: bytearray, keys: list[bytes], chunkSize=20): sectorKeys = dict() for i in range(0, len(keys), chunkSize): # print("mask = {}".format(mask.hex(sep=' ', bytes_per_sep=1))) chunkKeys = keys[i: i + chunkSize] print( f' - progress of checking keys... {color_string((CY, i))} / {len(keys)} ({color_string((CY, f"{100 * i / len(keys):.1f}"))} %)' ) resp = self.cmd.mf1_check_keys_of_sectors(mask, chunkKeys) # print(resp) if resp["status"] != Status.HF_TAG_OK: print( f' - check interrupted, reason: {color_string((CR, Status(resp["status"])))}' ) break elif "sectorKeys" not in resp: print( f' - check interrupted, reason: {color_string((CG, "All sectorKey is found or masked"))}' ) break for j in range(10): mask[j] |= resp["found"][j] sectorKeys.update(resp["sectorKeys"]) return sectorKeys def on_exec(self, args: argparse.Namespace): # print(args) keys = set() # keys from args for key in args.keys: if not re.match(r"^[a-fA-F0-9]{12}$", key): print( f' - {color_string((CR, "Key should in hex[12] format, invalid key is ignored"))}, key = "{key}"' ) continue keys.add(bytes.fromhex(key)) # read keys from key format file if args.import_key is not None: if not load_key_file(args.import_key, keys): return if args.import_dic is not None: if not load_dic_file(args.import_dic, keys): return if len(keys) == 0: print(f' - {color_string((CR, "No keys"))}') return print(f" - loaded {color_string((CG, len(keys)))} keys") # mask if not re.match(r"^[a-fA-F0-9]{1,20}$", args.mask): print( f' - {color_string((CR, "mask should in hex[20] format"))}, mask = "{args.mask}"' ) return mask = bytearray.fromhex(f"{args.mask:0<20}") for i in range(args.maxSectors, 40): mask[i // 4] |= 3 << (6 - i % 4 * 2) # check keys startedAt = datetime.now() sectorKeys = self.check_keys(mask, list(keys)) endedAt = datetime.now() duration = endedAt - startedAt print( f" - elapsed time: {color_string((CY, f'{duration.total_seconds():.3f}s'))}" ) if args.export_key is not None: unknownkey = bytes(6) for sectorNo in range(args.maxSectors): args.export_key.write(sectorKeys.get(2 * sectorNo, unknownkey)) args.export_key.write(sectorKeys.get(2 * sectorNo + 1, unknownkey)) print( f" - result exported to: {color_string((CG, args.export_key.name))} (as .key format)" ) if args.export_dic is not None: uniq_result = set(sectorKeys.values()) for key in uniq_result: args.export_dic.write(key.hex().upper() + "\n") print( f" - result exported to: {color_string((CG, args.export_dic.name))} (as .dic format)" ) # print sectorKeys print(f"\n - {color_string((CG, 'result of key checking:'))}\n") print("-----+-----+--------------+---+--------------+----") print(" Sec | Blk | key A |res| key B |res ") print("-----+-----+--------------+---+--------------+----") for sectorNo in range(args.maxSectors): blk = (sectorNo * 4 + 3) if sectorNo < 32 else (sectorNo * 16 - 369) keyA = sectorKeys.get(2 * sectorNo, None) if keyA: keyA = f"{color_string((CG, keyA.hex().upper()))} | {color_string((CG, '1'))}" else: keyA = ( f"{color_string((CR, '------------'))} | {color_string((CR, '0'))}" ) keyB = sectorKeys.get(2 * sectorNo + 1, None) if keyB: keyB = f"{color_string((CG, keyB.hex().upper()))} | {color_string((CG, '1'))}" else: keyB = ( f"{color_string((CR, '------------'))} | {color_string((CR, '0'))}" ) print( f" {color_string((CY, f'{sectorNo:03d}'))} | {blk:03d} | {keyA} | {keyB} " ) print("-----+-----+--------------+---+--------------+----") print( f"( {color_string((CR, '0'))}: Failed, {color_string((CG, '1'))}: Success )\n\n" ) @hf_mf.command("rdbl") class HFMFRDBL(MF1AuthArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = super().args_parser() parser.description = "Mifare Classic read one block" return parser def on_exec(self, args: argparse.Namespace): param = self.get_param(args) resp = self.cmd.mf1_read_one_block(param.block, param.type, param.key) print(f" - Data: {resp.hex()}") @hf_mf.command("wrbl") class HFMFWRBL(MF1AuthArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = super().args_parser() parser.description = "Mifare Classic write one block" parser.add_argument( "-d", "--data", type=str, required=True, metavar="", help="Your block data, as hex string.", ) return parser def on_exec(self, args: argparse.Namespace): param = self.get_param(args) if not re.match(r"^[a-fA-F0-9]{32}$", args.data): raise ArgsParserError("Data must include 32 HEX symbols") data = bytearray.fromhex(args.data) resp = self.cmd.mf1_write_one_block(param.block, param.type, param.key, data) if resp: print(f" - {color_string((CG, 'Write done.'))}") else: print(f" - {color_string((CR, 'Write fail.'))}") @hf_mf.command("view") class HFMFView(MF1AuthArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Display content from tag memory or dump file" mifare_type_group = parser.add_mutually_exclusive_group() mifare_type_group.add_argument( "--mini", help="MIFARE Classic Mini / S20", action="store_const", dest="maxSectors", const=5, ) mifare_type_group.add_argument( "--1k", help="MIFARE Classic 1k / S50 (default)", action="store_const", dest="maxSectors", const=16, ) mifare_type_group.add_argument( "--2k", help="MIFARE Classic/Plus 2k", action="store_const", dest="maxSectors", const=32, ) mifare_type_group.add_argument( "--4k", help="MIFARE Classic 4k / S70", action="store_const", dest="maxSectors", const=40, ) parser.add_argument( "-d", "--dump-file", required=False, type=argparse.FileType("rb"), help="Dump file to read", ) parser.add_argument( "-k", "--key-file", required=False, type=argparse.FileType("r"), help="File containing keys of tag to write (exported with fchk --export)", ) parser.set_defaults(maxSectors=16) return parser def on_exec(self, args: argparse.Namespace): data = bytearray(0) if args.dump_file is not None: print("Reading dump file") data = args.dump_file.read() elif args.key_file is not None: print("Reading tag memory") # read keys from file keys = list() for line in args.key_file.readlines(): a, b = [bytes.fromhex(h) for h in line[:-1].split(":")] keys.append((a, b)) if len(keys) != args.maxSectors: raise ArgsParserError( f"Invalid key file. Found {len(keys)}, expected {args.maxSectors}" ) # iterate over blocks for blk in range(0, args.maxSectors * 4): resp = None try: # first try with key B resp = self.cmd.mf1_read_one_block( blk, MfcKeyType.B, keys[blk // 4][1] ) except UnexpectedResponseError: # ignore read errors at this stage as we want to try key A pass if not resp: # try with key A if B was unsuccessful # this will raise an exception if key A fails too resp = self.cmd.mf1_read_one_block( blk, MfcKeyType.A, keys[blk // 4][0] ) data.extend(resp) else: raise ArgsParserError( "Missing args. Specify --dump-file (-d) or --key-file (-k)" ) print_mem_dump(data, 16) @hf_mf.command("dump") class HFMFDump(MF1AuthArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Mifare Classic dump tag" parser.add_argument( "-t", "--dump-file-type", type=str, required=False, help="Dump file content type", choices=["bin", "hex"], ) parser.add_argument( "-f", "--dump-file", type=argparse.FileType("wb"), required=True, help="Dump file to write data from tag", ) parser.add_argument( "-d", "--dic", type=argparse.FileType("r"), required=True, help="Read keys (to communicate with tag to dump) from .dic format file", ) return parser def on_exec(self, args: argparse.Namespace): # check dump type if args.dump_file_type is None: if args.dump_file.name.endswith(".bin"): content_type = "bin" elif args.dump_file.name.endswith(".eml"): content_type = "hex" else: raise Exception( "Unknown file format, Specify content type with -t option" ) else: content_type = args.dump_file_type # read keys from file keys = [bytes.fromhex(line[:-1]) for line in args.dic.readlines()] # data to write from dump file buffer = bytearray() # iterate over sectors for s in range(16): # find working keys for this sector (both A and B) type_a = type_b = None key_a = key_b = None for key in keys: if type_b is None: try: self.cmd.mf1_read_one_block(4 * s, MfcKeyType.B, key) type_b = MfcKeyType.B key_b = key except UnexpectedResponseError: pass if type_a is None: try: self.cmd.mf1_read_one_block(4 * s, MfcKeyType.A, key) type_a = MfcKeyType.A key_a = key except UnexpectedResponseError: pass if type_a is not None and type_b is not None: break if type_a is None and type_b is None: raise Exception(f"No key found for sector {s}") typ = type_a if type_a is not None else type_b key = key_a if type_a is not None else key_b # iterate over blocks for b in range(3): block_data = self.cmd.mf1_read_one_block(4 * s + b, typ, key) if content_type == "bin": buffer.extend(block_data) elif content_type == "hex": buffer.extend(block_data.hex().encode("utf-8")) # sector trailer: fill key bytes from known keys trailer = bytearray(self.cmd.mf1_read_one_block(4 * s + 3, typ, key)) if key_a is not None: trailer[0:6] = key_a if key_b is not None: trailer[10:16] = key_b trailer = bytes(trailer) if content_type == "bin": buffer.extend(trailer) elif content_type == "hex": buffer.extend(trailer.hex().encode("utf-8")) # write buffer to file args.dump_file.write(buffer) @hf_mf.command("clone") class HFMFClone(MF1AuthArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Mifare Classic clone tag from dump" parser.add_argument( "-t", "--dump-file-type", type=str, required=False, help="Dump file content type", choices=["bin", "hex"], ) parser.add_argument( "-a", "--clone-access", type=bool, default=False, help="Write ACL from original dump too (! could brick your tag)", ) parser.add_argument( "-f", "--dump-file", type=argparse.FileType("rb"), required=True, help="Dump file containing data to write on new tag", ) parser.add_argument( "-d", "--dic", type=argparse.FileType("r"), required=True, help="Read keys (to communicate with tag to write) from .dic format file", ) return parser def on_exec(self, args: argparse.Namespace): if args.dump_file_type is None: if args.dump_file.name.endswith(".bin"): content_type = "bin" elif args.dump_file.name.endswith(".eml"): content_type = "hex" else: raise Exception( "Unknown file format, Specify content type with -t option" ) else: content_type = args.dump_file_type # data to write from dump file buffer = bytearray() if content_type == "bin": buffer.extend(args.dump_file.read()) if content_type == "hex": buffer.extend(bytearray.fromhex(args.dump_file.read().decode())) if len(buffer) % 16 != 0: raise Exception("Data block not align for 16 bytes") if len(buffer) / 16 > 256: raise Exception("Data block memory overflow") # keys to use from file keys = [bytes.fromhex(line[:-1]) for line in args.dic.readlines()] # iterate over sectors for s in range(16): # try all keys for this sector keyA, keyB = None, None for key in keys: # first try key B try: self.cmd.mf1_read_one_block(4 * s, MfcKeyType.B, key) keyB = key except UnexpectedResponseError: # ignore read errors at this stage as we want to try key A pass # try with key A if B was unsuccessful try: self.cmd.mf1_read_one_block(4 * s, MfcKeyType.A, key) keyA = key except UnexpectedResponseError: pass # both keys were found, no need to continue iterating if keyA and keyB: break # neither A or B key was found if not keyA and not keyB: raise Exception(f"No key found for sector {s}") # iterate over blocks for b in range(4): block_data = buffer[(4 * s + b) * 16: (4 * s + b + 1) * 16] # special case for last block of each sector if b == 3: # check ACL option if not args.clone_access: # if option is not specified, use generic ACL to be able to write again block_data = ( block_data[:6] + bytes.fromhex("ff0780") + block_data[9:] ) try: # try B key first self.cmd.mf1_write_one_block( 4 * s + b, MfcKeyType.B, keyB, block_data ) continue except UnexpectedResponseError: pass self.cmd.mf1_write_one_block(4 * s + b, MfcKeyType.A, keyA, block_data) @hf_mf.command("value") class HFMFVALUE(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "MIFARE Classic value block commands" operator_group = parser.add_mutually_exclusive_group() operator_group.add_argument( "--get", action="store_true", help="get value from src block" ) operator_group.add_argument( "--set", type=int, required=False, metavar="", help="set value X (-2147483647 ~ 2147483647) to src block", ) operator_group.add_argument( "--inc", type=int, required=False, metavar="", help="increment value by X (0 ~ 2147483647) from src to dst", ) operator_group.add_argument( "--dec", type=int, required=False, metavar="", help="decrement value by X (0 ~ 2147483647) from src to dst", ) operator_group.add_argument( "--res", "--cp", action="store_true", help="copy value from src to dst (Restore and Transfer)", ) parser.add_argument( "--blk", "--src-block", type=int, required=True, metavar="", help="block number of src", ) srctype_group = parser.add_mutually_exclusive_group() srctype_group.add_argument( "-a", "-A", action="store_true", help="key of src is A key (default)" ) srctype_group.add_argument( "-b", "-B", action="store_true", help="key of src is B key" ) parser.add_argument( "-k", "--src-key", type=str, required=True, metavar="", help="key of src", ) parser.add_argument( "--tblk", "--dst-block", type=int, metavar="", help="block number of dst (default to src)", ) dsttype_group = parser.add_mutually_exclusive_group() dsttype_group.add_argument( "--ta", "--tA", action="store_true", help="key of dst is A key (default to src)", ) dsttype_group.add_argument( "--tb", "--tB", action="store_true", help="key of dst is B key (default to src)", ) parser.add_argument( "--tkey", "--dst-key", type=str, metavar="", help="key of dst (default to src)", ) return parser def on_exec(self, args: argparse.Namespace): # print(args) # src src_blk = args.blk src_type = MfcKeyType.B if args.b is not False else MfcKeyType.A src_key = args.src_key if not re.match(r"^[a-fA-F0-9]{12}$", src_key): print("src_key must include 12 HEX symbols") return src_key = bytearray.fromhex(src_key) # print(src_blk, src_type, src_key) if args.get is not False: self.get_value(src_blk, src_type, src_key) return elif args.set is not None: self.set_value(src_blk, src_type, src_key, args.set) return # dst dst_blk = args.tblk if args.tblk is not None else src_blk dst_type = ( MfcKeyType.A if args.ta is not False else (MfcKeyType.B if args.tb is not False else src_type) ) dst_key = args.tkey if args.tkey is not None else args.src_key if not re.match(r"^[a-fA-F0-9]{12}$", dst_key): print("dst_key must include 12 HEX symbols") return dst_key = bytearray.fromhex(dst_key) # print(dst_blk, dst_type, dst_key) if args.inc is not None: self.inc_value( src_blk, src_type, src_key, args.inc, dst_blk, dst_type, dst_key ) return elif args.dec is not None: self.dec_value( src_blk, src_type, src_key, args.dec, dst_blk, dst_type, dst_key ) return elif args.res is not False: self.res_value(src_blk, src_type, src_key, dst_blk, dst_type, dst_key) return else: raise ArgsParserError("Please specify a value command") def get_value(self, block, type, key): resp = self.cmd.mf1_read_one_block(block, type, key) val1, val2, val3, adr1, adr2, adr3, adr4 = struct.unpack(" 2147483647: raise ArgsParserError( f"Set value must be between -2147483647 and 2147483647. Got {value}" ) adr_inverted = 0xFF - block data = struct.pack( " 2147483647: raise ArgsParserError( f"Increment value must be between 0 and 2147483647. Got {value}" ) resp = self.cmd.mf1_manipulate_value_block( src_blk, src_type, src_key, MfcValueBlockOperator.INCREMENT, value, dst_blk, dst_type, dst_key, ) if resp: print(f" - {color_string((CG, 'Increment done.'))}") self.get_value(dst_blk, dst_type, dst_key) else: print(f" - {color_string((CR, 'Increment fail.'))}") def dec_value(self, src_blk, src_type, src_key, value, dst_blk, dst_type, dst_key): if value < 0 or value > 2147483647: raise ArgsParserError( f"Decrement value must be between 0 and 2147483647. Got {value}" ) resp = self.cmd.mf1_manipulate_value_block( src_blk, src_type, src_key, MfcValueBlockOperator.DECREMENT, value, dst_blk, dst_type, dst_key, ) if resp: print(f" - {color_string((CG, 'Decrement done.'))}") self.get_value(dst_blk, dst_type, dst_key) else: print(f" - {color_string((CR, 'Decrement fail.'))}") def res_value(self, src_blk, src_type, src_key, dst_blk, dst_type, dst_key): resp = self.cmd.mf1_manipulate_value_block( src_blk, src_type, src_key, MfcValueBlockOperator.RESTORE, 0, dst_blk, dst_type, dst_key, ) if resp: print(f" - {color_string((CG, 'Restore done.'))}") self.get_value(dst_blk, dst_type, dst_key) else: print(f" - {color_string((CR, 'Restore fail.'))}") _KEY = re.compile("[a-fA-F0-9]{12}", flags=re.MULTILINE) # Sentinel values returned by _run_mfkey64 / _run_mfkey32v2_sniff # to distinguish "tool unavailable" from "tool ran but found no key". _TOOL_MISSING = "MISSING" # binary not found on disk _TOOL_BLOCKED = "BLOCKED" # binary exists but OS/AV prevented execution _TOOL_NO_KEY = "NO_KEY" # binary ran cleanly, no key found for these nonces def _sniff_tool_path(name): """Return the Path to a cracking binary, or None if not present.""" suffix = ".exe" if sys.platform == "win32" else "" p = default_cwd / (name + suffix) return p if p.exists() else None def _run_mfkey64(uid, nt, nr, ar, at): """ Run mfkey64 and return the recovered key string (12 hex chars), or one of _TOOL_MISSING / _TOOL_BLOCKED / _TOOL_NO_KEY. mfkey64 requires 5 args: uid nt {nr} {ar} {at} When cracking a sniff pair (both at==\'\'): at = nonce[1].nt (the CU sent this as {at} after nonce[0]; the reader treated it as a fresh nt for the next auth round) When cracking a single complete auth: at = the directly captured {at} frame """ path = _sniff_tool_path("mfkey64") if path is None: return _TOOL_MISSING try: result = subprocess.run( [str(path), uid, nt, nr, ar, at], capture_output=True, timeout=30, encoding="ascii", ) except FileNotFoundError: return _TOOL_MISSING except PermissionError: return _TOOL_BLOCKED except OSError: # Covers antivirus quarantine, wrong arch, etc. return _TOOL_BLOCKED except subprocess.TimeoutExpired: return _TOOL_BLOCKED if result.returncode not in (0, 1): # Non-zero exit other than 1 (usage error) usually means OS blocked it return _TOOL_BLOCKED sea_obj = _KEY.search(result.stdout) return sea_obj[0] if sea_obj is not None else _TOOL_NO_KEY def _run_mfkey32v2(items): """ Used by HFMFELog (detection-log path) via multiprocessing Pool. Returns (key_str, items) on success, None if not found, raises on binary errors so the pool can propagate them. """ output_str = subprocess.run( [ default_cwd / ("mfkey32v2.exe" if sys.platform == "win32" else "mfkey32v2"), items[0]["uid"], items[0]["nt"], items[0]["nr"], items[0]["ar"], items[1]["nt"], items[1]["nr"], items[1]["ar"], ], capture_output=True, check=True, encoding="ascii", ).stdout sea_obj = _KEY.search(output_str) if sea_obj is not None: return sea_obj[0], items return None def _run_mfkey32v2_sniff(n0, n1): """ Sniff-path wrapper for mfkey32v2. Returns a key string, or one of _TOOL_MISSING / _TOOL_BLOCKED / _TOOL_NO_KEY — never raises. """ path = _sniff_tool_path("mfkey32v2") if path is None: return _TOOL_MISSING try: result = subprocess.run( [ str(path), n0["uid"], n0["nt"], n0["nr"], n0["ar"], n1["nt"], n1["nr"], n1["ar"], ], capture_output=True, timeout=30, encoding="ascii", ) except FileNotFoundError: return _TOOL_MISSING except PermissionError: return _TOOL_BLOCKED except OSError: return _TOOL_BLOCKED except subprocess.TimeoutExpired: return _TOOL_BLOCKED if result.returncode not in (0, 1): return _TOOL_BLOCKED sea_obj = _KEY.search(result.stdout) return sea_obj[0] if sea_obj is not None else _TOOL_NO_KEY class ItemGenerator: def __init__(self, rs, uid_found_keys=set()): self.rs: list = rs self.progress = 0 self.i = 0 self.j = 1 self.found = set() self.keys = set() for known_key in uid_found_keys: self.test_key(known_key) def __iter__(self): return self def __next__(self): size = len(self.rs) if self.j >= size: self.i += 1 if self.i >= size - 1: raise StopIteration self.j = self.i + 1 item_i, item_j = self.rs[self.i], self.rs[self.j] self.progress += 1 self.j += 1 if self.key_from_item(item_i) in self.found: self.progress += max(0, size - self.j) self.i += 1 self.j = self.i + 1 return next(self) if self.key_from_item(item_j) in self.found: return next(self) return item_i, item_j @staticmethod def key_from_item(item): return "{uid}-{nt}-{nr}-{ar}".format(**item) def test_key(self, key, items=list()): for item in self.rs: item_key = self.key_from_item(item) if item_key in self.found: continue if (item in items) or ( Crypto1.mfkey32_is_reader_has_key( int(item["uid"], 16), int(item["nt"], 16), int(item["nr"], 16), int(item["ar"], 16), key, ) ): self.keys.add(key) self.found.add(item_key) @hf_mf.command("elog") class HFMFELog(DeviceRequiredUnit): detection_log_size = 18 def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "MF1 Detection log count/decrypt" parser.add_argument( "--decrypt", action="store_true", help="Decrypt key from MF1 log list" ) return parser def decrypt_by_list(self, rs: list, uid_found_keys: set = set()): """ Decrypt key from reconnaissance log list :param rs: :return: """ msg1 = f" > {len(rs)} records => " msg2 = f"/{(len(rs)*(len(rs)-1))//2} combinations. " msg3 = " key(s) found" gen = ItemGenerator(rs, uid_found_keys) print(f"{msg1}{gen.progress}{msg2}{len(gen.keys)}{msg3}\r", end="") with Pool(cpu_count()) as pool: for result in pool.imap(_run_mfkey32v2, gen): if result is not None: gen.test_key(*result) print(f"{msg1}{gen.progress}{msg2}{len(gen.keys)}{msg3}\r", end="") print(f"{msg1}{gen.progress}{msg2}{len(gen.keys)}{msg3}") return gen.keys def on_exec(self, args: argparse.Namespace): if not args.decrypt: count = self.cmd.mf1_get_detection_count() print(f" - MF1 detection log count = {count}") return index = 0 count = self.cmd.mf1_get_detection_count() if count == 0: print(" - No detection log to download") return print(f" - MF1 detection log count = {count}, start download", end="") result_list = [] while index < count: tmp = self.cmd.mf1_get_detection_log(index) recv_count = len(tmp) index += recv_count result_list.extend(tmp) print("." * recv_count, end="") print() print(f" - Download done ({len(result_list)} records), start parse and decrypt") # classify result_maps = {} for item in result_list: uid = item["uid"] if uid not in result_maps: result_maps[uid] = {} block = item["block"] if block not in result_maps[uid]: result_maps[uid][block] = {} type = item["type"] if type not in result_maps[uid][block]: result_maps[uid][block][type] = [] result_maps[uid][block][type].append(item) for uid in result_maps.keys(): print(f" - Detection log for uid [{uid.upper()}]") result_maps_for_uid = result_maps[uid] uid_found_keys = set() for block in result_maps_for_uid: for keyType in "AB": records = ( result_maps_for_uid[block][keyType] if keyType in result_maps_for_uid[block] else [] ) if len(records) < 1: continue print(f" > Decrypting block {block} key {keyType} detect log...") result_maps[uid][block][keyType] = self.decrypt_by_list( records, uid_found_keys ) uid_found_keys.update(result_maps[uid][block][keyType]) print(" > Result ---------------------------") for block in result_maps_for_uid.keys(): if "A" in result_maps_for_uid[block]: print( f" > Block {block}, A key result: {result_maps_for_uid[block]['A']}" ) if "B" in result_maps_for_uid[block]: print( f" > Block {block}, B key result: {result_maps_for_uid[block]['B']}" ) return @hf_mf.command("eload") class HFMFELoad(SlotIndexArgsAndGoUnit, DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Load data to emulator memory" self.add_slot_args(parser) parser.add_argument("-f", "--file", type=str, required=True, help="file path") parser.add_argument( "-t", "--type", type=str, required=False, help="content type", choices=["bin", "hex"], ) return parser def on_exec(self, args: argparse.Namespace): file = args.file if args.type is None: if file.endswith(".bin"): content_type = "bin" elif file.endswith(".eml"): content_type = "hex" else: raise Exception( "Unknown file format, Specify content type with -t option" ) else: content_type = args.type buffer = bytearray() with open(file, mode="rb") as fd: if content_type == "bin": buffer.extend(fd.read()) if content_type == "hex": buffer.extend(bytearray.fromhex(fd.read().decode())) if len(buffer) % 16 != 0: raise Exception("Data block not align for 16 bytes") if len(buffer) / 16 > 256: raise Exception("Data block memory overflow") index = 0 block = 0 max_blocks = (self.device_com.data_max_length - 1) // 16 while index + 16 < len(buffer): # split a block from buffer block_data = buffer[index: index + 16 * max_blocks] n_blocks = len(block_data) // 16 index += 16 * n_blocks # load to device self.cmd.mf1_write_emu_block_data(block, block_data) print("." * n_blocks, end="") block += n_blocks print("\n - Load success") @hf_mf.command("esave") class HFMFESave(SlotIndexArgsAndGoUnit, DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Read data from emulator memory" self.add_slot_args(parser) parser.add_argument("-f", "--file", type=str, required=True, help="file path") parser.add_argument( "-t", "--type", type=str, required=False, help="content type", choices=["bin", "hex"], ) return parser def on_exec(self, args: argparse.Namespace): file = args.file if args.type is None: if file.endswith(".bin"): content_type = "bin" elif file.endswith(".eml"): content_type = "hex" else: raise Exception( "Unknown file format, Specify content type with -t option" ) else: content_type = args.type selected_slot = self.cmd.get_active_slot() slot_info = self.cmd.get_slot_info() tag_type = TagSpecificType(slot_info[selected_slot]["hf"]) if tag_type == TagSpecificType.MIFARE_Mini: block_count = 20 elif tag_type == TagSpecificType.MIFARE_1024: block_count = 64 elif tag_type == TagSpecificType.MIFARE_2048: block_count = 128 elif tag_type == TagSpecificType.MIFARE_4096: block_count = 256 else: raise Exception( "Card in current slot is not Mifare Classic/Plus in SL1 mode" ) index = 0 data = bytearray(0) max_blocks = self.device_com.data_max_length // 16 while block_count > 0: chunk_count = min(block_count, max_blocks, 32) data.extend(self.cmd.mf1_read_emu_block_data(index, chunk_count)) index += chunk_count block_count -= chunk_count print("." * chunk_count, end="") with open(file, "wb") as fd: if content_type == "hex": for i in range(len(data) // 16): fd.write(binascii.hexlify(data[i * 16: (i + 1) * 16]) + b"\n") else: fd.write(data) print("\n - Read success") @hf_mf.command("eview") class HFMFEView(SlotIndexArgsAndGoUnit, DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "View data from emulator memory" self.add_slot_args(parser) return parser def on_exec(self, args: argparse.Namespace): selected_slot = self.cmd.get_active_slot() slot_info = self.cmd.get_slot_info() tag_type = TagSpecificType(slot_info[selected_slot]["hf"]) if tag_type == TagSpecificType.MIFARE_Mini: block_count = 20 elif tag_type == TagSpecificType.MIFARE_1024: block_count = 64 elif tag_type == TagSpecificType.MIFARE_2048: block_count = 128 elif tag_type == TagSpecificType.MIFARE_4096: block_count = 256 else: raise Exception( "Card in current slot is not Mifare Classic/Plus in SL1 mode" ) index = 0 data = bytearray(0) max_blocks = self.device_com.data_max_length // 16 while block_count > 0: # read all the blocks chunk_count = min(block_count, max_blocks, 32) data.extend(self.cmd.mf1_read_emu_block_data(index, chunk_count)) index += chunk_count block_count -= chunk_count print_mem_dump(data, 16) @hf_mf.command("econfig") class HFMFEConfig(SlotIndexArgsAndGoUnit, HF14AAntiCollArgsUnit, DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Settings of Mifare Classic emulator" self.add_slot_args(parser) self.add_hf14a_anticoll_args(parser) gen1a_group = parser.add_mutually_exclusive_group() gen1a_group.add_argument( "--enable-gen1a", action="store_true", help="Enable Gen1a magic mode" ) gen1a_group.add_argument( "--disable-gen1a", action="store_true", help="Disable Gen1a magic mode" ) gen2_group = parser.add_mutually_exclusive_group() gen2_group.add_argument( "--enable-gen2", action="store_true", help="Enable Gen2 magic mode" ) gen2_group.add_argument( "--disable-gen2", action="store_true", help="Disable Gen2 magic mode" ) block0_group = parser.add_mutually_exclusive_group() block0_group.add_argument( "--enable-block0", action="store_true", help="Use anti-collision data from block 0 for 4 byte UID tags", ) block0_group.add_argument( "--disable-block0", action="store_true", help="Use anti-collision data from settings", ) write_names = [w.name for w in MifareClassicWriteMode.list()] help_str = "Write Mode: " + ", ".join(write_names) parser.add_argument( "--write", type=str, help=help_str, metavar="MODE", choices=write_names ) log_group = parser.add_mutually_exclusive_group() log_group.add_argument( "--enable-log", action="store_true", help="Enable logging of MFC authentication data", ) log_group.add_argument( "--disable-log", action="store_true", help="Disable logging of MFC authentication data", ) field_off_reset_group = parser.add_mutually_exclusive_group() field_off_reset_group.add_argument( "--enable_field_off_do_reset", action="store_true", help="Enable FIELD_OFF_DO_RESET", ) field_off_reset_group.add_argument( "--disable_field_off_do_reset", action="store_true", help="Disable FIELD_OFF_DO_RESET", ) return parser def on_exec(self, args: argparse.Namespace): # collect current settings anti_coll_data = self.cmd.hf14a_get_anti_coll_data() if anti_coll_data is None or len(anti_coll_data) == 0: print( f"{color_string((CR, f'Slot {self.slot_num} does not contain any HF 14A config'))}" ) return uid = anti_coll_data["uid"] atqa = anti_coll_data["atqa"] sak = anti_coll_data["sak"] ats = anti_coll_data["ats"] slotinfo = self.cmd.get_slot_info() fwslot = SlotNumber.to_fw(self.slot_num) hf_tag_type = TagSpecificType(slotinfo[fwslot]["hf"]) if hf_tag_type not in [ TagSpecificType.MIFARE_Mini, TagSpecificType.MIFARE_1024, TagSpecificType.MIFARE_2048, TagSpecificType.MIFARE_4096, ]: print( f"{color_string((CR, f'Slot {self.slot_num} not configured as MIFARE Classic'))}" ) return mfc_config = self.cmd.mf1_get_emulator_config() gen1a_mode = mfc_config["gen1a_mode"] gen2_mode = mfc_config["gen2_mode"] block_anti_coll_mode = mfc_config["block_anti_coll_mode"] write_mode = MifareClassicWriteMode(mfc_config["write_mode"]) detection = mfc_config["detection"] change_requested, change_done, uid, atqa, sak, ats = self.update_hf14a_anticoll( args, uid, atqa, sak, ats ) field_off_do_reset = self.cmd.mf1_get_field_off_do_reset() if args.enable_gen1a: change_requested = True if not gen1a_mode: gen1a_mode = True self.cmd.mf1_set_gen1a_mode(gen1a_mode) change_done = True else: print(f'{color_string((CY, "Requested gen1a already enabled"))}') elif args.disable_gen1a: change_requested = True if gen1a_mode: gen1a_mode = False self.cmd.mf1_set_gen1a_mode(gen1a_mode) change_done = True else: print(f'{color_string((CY, "Requested gen1a already disabled"))}') if args.enable_gen2: change_requested = True if not gen2_mode: gen2_mode = True self.cmd.mf1_set_gen2_mode(gen2_mode) change_done = True else: print(f'{color_string((CY, "Requested gen2 already enabled"))}') elif args.disable_gen2: change_requested = True if gen2_mode: gen2_mode = False self.cmd.mf1_set_gen2_mode(gen2_mode) change_done = True else: print(f'{color_string((CY, "Requested gen2 already disabled"))}') if args.enable_block0: change_requested = True if not block_anti_coll_mode: block_anti_coll_mode = True self.cmd.mf1_set_block_anti_coll_mode(block_anti_coll_mode) change_done = True else: print( f'{color_string((CY, "Requested block0 anti-coll mode already enabled"))}' ) elif args.disable_block0: change_requested = True if block_anti_coll_mode: block_anti_coll_mode = False self.cmd.mf1_set_block_anti_coll_mode(block_anti_coll_mode) change_done = True else: print( f'{color_string((CY, "Requested block0 anti-coll mode already disabled"))}' ) if args.write is not None: change_requested = True new_write_mode = MifareClassicWriteMode[args.write] if new_write_mode != write_mode: write_mode = new_write_mode self.cmd.mf1_set_write_mode(write_mode) change_done = True else: print(f'{color_string((CY, "Requested write mode already set"))}') if args.enable_log: change_requested = True if not detection: detection = True self.cmd.mf1_set_detection_enable(detection) change_done = True else: print( f'{color_string((CY, "Requested logging of MFC authentication data already enabled"))}' ) elif args.disable_log: change_requested = True if detection: detection = False self.cmd.mf1_set_detection_enable(detection) change_done = True else: print( f'{color_string((CY, "Requested logging of MFC authentication data already disabled"))}' ) if args.enable_field_off_do_reset: change_requested = True if not field_off_do_reset: field_off_do_reset = True self.cmd.mf1_set_field_off_do_reset(field_off_do_reset) change_done = True else: print( f'{color_string((CY, "Requested FIELD_OFF_DO_RESET already enabled"))}' ) elif args.disable_field_off_do_reset: change_requested = True if field_off_do_reset: field_off_do_reset = False self.cmd.mf1_set_field_off_do_reset(field_off_do_reset) change_done = True else: print( f'{color_string((CY, "Requested FIELD_OFF_DO_RESET already disabled"))}' ) if change_done: print(" - MF1 Emulator settings updated") if not change_requested: enabled_str = color_string((CG, "enabled")) disabled_str = color_string((CR, "disabled")) atqa_string = f"{atqa.hex().upper()} (0x{int.from_bytes(atqa, byteorder='little'):04x})" print(f'- {"Type:":40}{color_string((CY, hf_tag_type))}') print(f'- {"UID:":40}{color_string((CY, uid.hex().upper()))}') print(f'- {"ATQA:":40}{color_string((CY, atqa_string))}') print(f'- {"SAK:":40}{color_string((CY, sak.hex().upper()))}') if len(ats) > 0: print(f'- {"ATS:":40}{color_string((CY, ats.hex().upper()))}') print( f'- {"Gen1A magic mode:":40}{f"{enabled_str}" if gen1a_mode else f"{disabled_str}"}' ) print( f'- {"Gen2 magic mode:":40}{f"{enabled_str}" if gen2_mode else f"{disabled_str}"}' ) print( f'- {"Use anti-collision data from block 0:":40}' f'{f"{enabled_str}" if block_anti_coll_mode else f"{disabled_str}"}' ) try: print( f'- {"Write mode:":40}{color_string((CY, MifareClassicWriteMode(write_mode)))}' ) except ValueError: print(f'- {"Write mode:":40}{color_string((CR, "invalid value!"))}') print( f'- {"Log (mfkey32) mode:":40}{f"{enabled_str}" if detection else f"{disabled_str}"}' ) print( f'- {"FIELD_OFF_DO_RESET:":40}{f"{enabled_str}" if field_off_do_reset else f"{disabled_str}"}' ) @hf_mfu.command("ercnt") class HFMFUERCNT(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Read MIFARE Ultralight / NTAG counter value." parser.add_argument( "-c", "--counter", type=int, required=True, help="Counter index." ) return parser def on_exec(self, args: argparse.Namespace): value, no_tearing = self.cmd.mfu_read_emu_counter_data(args.counter) print(f" - Value: {value:06x} ({value})") if no_tearing: print(f" - Tearing: {color_string((CG, 'not set'))}") else: print(f" - Tearing: {color_string((CR, 'set'))}") @hf_mfu.command("ewcnt") class HFMFUEWCNT(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Write MIFARE Ultralight / NTAG counter value." parser.add_argument( "-c", "--counter", type=int, required=True, help="Counter index." ) parser.add_argument( "-v", "--value", type=int, required=True, help="Counter value (24-bit)." ) parser.add_argument( "-t", "--reset-tearing", action="store_true", help="Reset tearing event flag.", ) return parser def on_exec(self, args: argparse.Namespace): if args.value > 0xFFFFFF: print(color_string((CR, f"Counter value {args.value:#x} is too large."))) return self.cmd.mfu_write_emu_counter_data( args.counter, args.value, args.reset_tearing ) print("- Ok") @hf_mfu.command("rdpg") class HFMFURDPG(MFUAuthArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = super().args_parser() parser.description = "MIFARE Ultralight / NTAG read one page" parser.add_argument( "-p", "--page", type=int, required=True, metavar="", help="The page where the key will be used against", ) return parser def on_exec(self, args: argparse.Namespace): param = self.get_param(args) if param.key is not None and len(param.key) == 16: try: data = self.cmd.mf0_ulc_read(param.key, args.page, 1) print(f" - Data: {bytes(data[:4]).hex()}") except UnexpectedResponseError: print(color_string((CR, " - Auth failed"))) return options = { "activate_rf_field": 0, "wait_response": 1, "append_crc": 1, "auto_select": 1, "keep_rf_field": 0, "check_response_crc": 1, } if param.key is not None: options["keep_rf_field"] = 1 try: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!B", 0x1B) + param.key, ) failed_auth = len(resp) < 2 if not failed_auth: print(f" - PACK: {resp[:2].hex()}") except Exception: # failed auth may cause tags to be lost failed_auth = True options["keep_rf_field"] = 0 options["auto_select"] = 0 else: failed_auth = False if not failed_auth: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x30, args.page), ) print(f" - Data: {resp[:4].hex()}") else: try: self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x30, args.page), ) except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): # we may lose the tag again here pass print(color_string((CR, " - Auth failed"))) @hf_mfu.command("wrpg") class HFMFUWRPG(MFUAuthArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = super().args_parser() parser.description = "MIFARE Ultralight / NTAG write one page" parser.add_argument( "-p", "--page", type=int, required=True, metavar="", help="The index of the page to write to.", ) parser.add_argument( "-d", "--data", type=bytes.fromhex, required=True, metavar="", help="Your page data, as a 4 byte (8 character) hex string.", ) return parser def on_exec(self, args: argparse.Namespace): param = self.get_param(args) data = args.data if len(data) != 4: print( color_string( (CR, "Page data should be a 4 byte (8 character) hex string") ) ) return if param.key is not None and len(param.key) == 16: try: self.cmd.mf0_ulc_write(param.key, args.page, data) print(" - Ok") except UnexpectedResponseError: print(color_string((CR, "Write failed."))) return options = { "activate_rf_field": 0, "wait_response": 1, "append_crc": 1, "auto_select": 1, "keep_rf_field": 0, "check_response_crc": 0, } if param.key is not None: options["keep_rf_field"] = 1 options["check_response_crc"] = 1 try: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!B", 0x1B) + param.key, ) failed_auth = len(resp) < 2 if not failed_auth: print(f" - PACK: {resp[:2].hex()}") except Exception: # failed auth may cause tags to be lost failed_auth = True options["keep_rf_field"] = 0 options["auto_select"] = 0 options["check_response_crc"] = 0 else: failed_auth = False if not failed_auth: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0xA2, args.page) + data, ) if resp[0] == 0x0A: print(" - Ok") else: print(color_string((CR, f"Write failed ({resp[0]:#04x})."))) else: # send a command just to disable the field. use read to avoid corrupting the data try: self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x30, args.page), ) except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): # we may lose the tag again here pass print(color_string((CR, " - Auth failed"))) @hf_mfu.command("setkey") class HFMFUSETKEY(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Change the MIFARE Ultralight C 3DES key (authenticate with the current key, then write the new key)" def key16(key: str) -> bytes: try: key = bytes.fromhex(key) except ValueError: raise ValueError("Key should be a hex string") if len(key) != 16: raise ValueError("Ultralight-C 3DES key must be 16 bytes") return key parser.add_argument( "-k", "--key", type=key16, required=True, metavar="", help="Current 16-byte 3DES key (cipher order).", ) parser.add_argument( "-n", "--new-key", type=key16, required=True, metavar="", help="New 16-byte 3DES key (cipher order).", ) return parser def on_exec(self, args: argparse.Namespace): try: self.cmd.mf0_ulc_set_key(args.key, args.new_key) print(" - Ok") except UnexpectedResponseError: print(color_string((CR, " - Failed (wrong current key or key page locked)"))) @hf_mfu.command("eview") class HFMFUEVIEW(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "MIFARE Ultralight / NTAG view emulator data" return parser def on_exec(self, args: argparse.Namespace): nr_pages = self.cmd.mfu_get_emu_pages_count() page = 0 while page < nr_pages: count = min(nr_pages - page, 16) data = self.cmd.mfu_read_emu_page_data(page, count) for i in range(0, len(data), 4): print(f"#{page+(i >> 2):02x}: {data[i:i+4].hex()}") page += count @hf_mfu.command("eload") class HFMFUELOAD(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "MIFARE Ultralight / NTAG load emulator data" parser.add_argument( "-f", "--file", required=True, type=str, help="File to load data from." ) parser.add_argument( "-t", "--type", type=str, required=False, help="Force writing as either raw binary or hex.", choices=["bin", "hex"], ) return parser def get_param(self, args): class Param: def __init__(self): pass return Param() def on_exec(self, args: argparse.Namespace): file_type = args.type if file_type is None: if args.file.endswith(".eml") or args.file.endswith(".txt"): file_type = "hex" else: file_type = "bin" if file_type == "hex": with open(args.file, "r") as f: data = f.read() data = re.sub("#.*$", "", data, flags=re.MULTILINE) data = bytes.fromhex(data) else: with open(args.file, "rb") as f: data = f.read() # this will throw an exception on incorrect slot type nr_pages = self.cmd.mfu_get_emu_pages_count() size = nr_pages * 4 if len(data) > size: print( color_string( ( CR, f"Dump file is too large for the current slot (expected {size} bytes).", ) ) ) return elif (len(data) % 4) > 0: print( color_string((CR, "Dump file's length is not a multiple of 4 bytes.")) ) return elif len(data) < size: print( color_string( ( CY, f"Dump file is smaller than the current slot's memory ({len(data)} < {size}).", ) ) ) nr_pages = len(data) >> 2 page = 0 while page < nr_pages: offset = page * 4 cur_count = min(16, nr_pages - page) if offset >= len(data): page_data = bytes.fromhex("00000000") * cur_count else: page_data = data[offset: offset + 4 * cur_count] self.cmd.mfu_write_emu_page_data(page, page_data) page += cur_count print(" - Ok") @hf_mfu.command("esave") class HFMFUESAVE(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "MIFARE Ultralight / NTAG save emulator data" parser.add_argument( "-f", "--file", required=True, type=str, help="File to save data to." ) parser.add_argument( "-t", "--type", type=str, required=False, help="Force writing as either raw binary or hex.", choices=["bin", "hex"], ) return parser def get_param(self, args): class Param: def __init__(self): pass return Param() def on_exec(self, args: argparse.Namespace): file_type = args.type fd = None save_as_eml = False if file_type is None: if args.file.endswith(".eml") or args.file.endswith(".txt"): file_type = "hex" else: file_type = "bin" if file_type == "hex": fd = open(args.file, "w+") save_as_eml = True else: fd = open(args.file, "wb+") with fd: # this will throw an exception on incorrect slot type nr_pages = self.cmd.mfu_get_emu_pages_count() fd.truncate(0) # write version and signature as comments if saving as .eml if save_as_eml: try: version = self.cmd.mf0_ntag_get_version_data() fd.write(f"# Version: {version.hex()}\n") except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): pass # slot does not have version data try: signature = self.cmd.mf0_ntag_get_signature_data() if signature != b"\x00" * 32: fd.write(f"# Signature: {signature.hex()}\n") except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): pass # slot does not have signature data page = 0 while page < nr_pages: cur_count = min(32, nr_pages - page) data = self.cmd.mfu_read_emu_page_data(page, cur_count) if save_as_eml: for i in range(0, len(data), 4): fd.write(data[i: i + 4].hex() + "\n") else: fd.write(data) page += cur_count print(" - Ok") @hf_mfu.command("rcnt") class HFMFURCNT(MFUAuthArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = super().args_parser() parser.description = "MIFARE Ultralight / NTAG read counter" parser.add_argument( "-c", "--counter", type=int, required=True, metavar="", help="Index of the counter to read (always 0 for NTAG, 0-2 for Ultralight EV1).", ) return parser def on_exec(self, args: argparse.Namespace): param = self.get_param(args) if param.key is not None and len(param.key) == 16: print(color_string((CR, "rcnt is not available for Ultralight-C (3DES) tags."))) return options = { "activate_rf_field": 0, "wait_response": 1, "append_crc": 1, "auto_select": 1, "keep_rf_field": 0, "check_response_crc": 1, } if param.key is not None: options["keep_rf_field"] = 1 try: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!B", 0x1B) + param.key, ) failed_auth = len(resp) < 2 if not failed_auth: print(f" - PACK: {resp[:2].hex()}") except Exception: # failed auth may cause tags to be lost failed_auth = True options["keep_rf_field"] = 0 options["auto_select"] = 0 else: failed_auth = False if not failed_auth: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x39, args.counter), ) print(f" - Data: {resp[:3].hex()}") else: try: self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x39, args.counter), ) except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): # we may lose the tag again here pass print(color_string((CR, " - Auth failed"))) @hf_mfu.command("dump") class HFMFUDUMP(MFUAuthArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = super().args_parser() parser.description = "MIFARE Ultralight dump pages" parser.add_argument( "-p", "--page", type=int, required=False, metavar="", default=0, help="Manually set number of pages to dump", ) parser.add_argument( "-q", "--qty", type=int, required=False, metavar="", help="Manually set number of pages to dump", ) parser.add_argument( "-f", "--file", type=str, required=False, default="", help="Specify a filename for dump file", ) parser.add_argument( "-t", "--type", type=str, required=False, choices=["bin", "hex"], help="Force writing as either raw binary or hex.", ) return parser def do_dump(self, args: argparse.Namespace, param, fd, save_as_eml): if args.qty is not None: stop_page = min(args.page + args.qty, 256) else: stop_page = None tags = self.cmd.hf14a_scan() if len(tags) > 1: print(f"- {color_string((CR, 'Collision detected, leave only one tag.'))}") return elif len(tags) == 0: print(f"- {color_string((CR, 'No tag detected.'))}") return elif tags[0]["atqa"] != b"\x44\x00" or tags[0]["sak"] != b"\x00": err = color_string( ( CR, f"Tag is not Mifare Ultralight compatible (ATQA {tags[0]['atqa'].hex()} SAK {tags[0]['sak'].hex()}).", ) ) print(f"- {err}") return if param.key is not None and len(param.key) == 16: print(" - Detected tag type as Mifare Ultralight C.") ulc_stop = stop_page if stop_page is not None else 0x2C count = min(ulc_stop - args.page, 48) if count > 0: try: data = bytes(self.cmd.mf0_ulc_read(param.key, args.page, count)) except UnexpectedResponseError: print(color_string((CR, " - Auth failed"))) data = b"" pages_read = len(data) // 4 for idx in range(pages_read): page_data = data[idx * 4: idx * 4 + 4] print(f" - Page {args.page + idx:2}: {page_data.hex()}") if fd is not None: if save_as_eml: fd.write(page_data.hex() + "\n") else: fd.write(page_data) if 0 < pages_read < count: print(f"- {color_string((CY, 'Dump stopped early (page not readable without/again auth).'))}") if fd is not None and args.file != "": print(f"- {color_string((CG, f'Dump written in {args.file}.'))}") return options = { "activate_rf_field": 0, "wait_response": 1, "append_crc": 1, "auto_select": 1, "keep_rf_field": 1, "check_response_crc": 1, } # if stop page isn't set manually, try autodetection if stop_page is None: tag_name = None # first try sending the GET_VERSION command try: version = self.cmd.hf14a_raw( options=options, resp_timeout_ms=100, data=struct.pack("!B", 0x60) ) if len(version) == 0: version = None except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): version = None # try sending AUTHENTICATE command and observe the result try: supports_auth = ( len( self.cmd.hf14a_raw( options=options, resp_timeout_ms=100, data=struct.pack("!B", 0x1A), ) ) != 0 ) except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): supports_auth = False if version is not None and not supports_auth: # either ULEV1 or NTAG assert len(version) == 8 is_mikron_ulev1 = version[1] == 0x34 and version[2] == 0x21 if ( (version[2] == 3 or is_mikron_ulev1) and version[4] == 1 and version[5] == 0 ): # Ultralight EV1 V0 size_map = { 0x0B: ("Mifare Ultralight EV1 48b", 20), 0x0E: ("Mifare Ultralight EV1 128b", 41), } elif version[2] == 4 and version[4] == 1 and version[5] == 0: # NTAG 210/212/213/215/216 V0 size_map = { 0x0B: ("NTAG 210", 20), 0x0E: ("NTAG 212", 41), 0x0F: ("NTAG 213", 45), 0x11: ("NTAG 215", 135), 0x13: ("NTAG 216", 231), } else: size_map = {} if version[6] in size_map: tag_name, stop_page = size_map[version[6]] elif version is None and supports_auth: # Ultralight C tag_name = "Mifare Ultralight C" stop_page = 48 elif version is None and not supports_auth: try: # Invalid command returning a NAK means that's some old type of NTAG. self.cmd.hf14a_raw( options=options, resp_timeout_ms=100, data=struct.pack("!B", 0xFF), ) print( color_string( (CY, "Tag is likely NTAG 20x, reading until first error.") ) ) stop_page = 256 except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): # Regular Ultralight tag_name = "Mifare Ultralight" stop_page = 16 else: # This is probably Ultralight AES, but we don't support this one yet. pass if tag_name is not None: print(f" - Detected tag type as {tag_name}.") if stop_page is None: err_str = "Couldn't autodetect the expected card size, reading until first error." print(f"- {color_string((CY, err_str))}") stop_page = 256 needs_stop = False if param.key is not None: try: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!B", 0x1B) + param.key, ) needs_stop = len(resp) < 2 if not needs_stop: print(f" - PACK: {resp[:2].hex()}") except Exception: # failed auth may cause tags to be lost needs_stop = True options["auto_select"] = 0 # this handles auth failure if needs_stop: print(color_string((CR, " - Auth failed"))) if fd is not None: fd.close() fd = None for i in range(args.page, stop_page): # this could be done once in theory but the command would need to be optimized properly if param.key is not None and not needs_stop: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!B", 0x1B) + param.key, ) options["auto_select"] = 0 # prevent resets # disable the rf field after the last command if i == (stop_page - 1) or needs_stop: options["keep_rf_field"] = 0 try: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x30, i), ) except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): # probably lost tag, but we still need to disable rf field resp = None if needs_stop: # break if this command was sent just to disable RF field break elif resp is None or len(resp) == 0: # we need to disable RF field if we reached the last valid page so send one more read command needs_stop = True continue # after the read we are sure we no longer need to select again options["auto_select"] = 0 # TODO: can be optimized as we get 4 pages at once but beware of wrapping # in case of end of memory or LOCK on ULC and no key provided data = resp[:4] print(f" - Page {i:2}: {data.hex()}") if fd is not None: if save_as_eml: fd.write(data.hex() + "\n") else: fd.write(data) if needs_stop and stop_page != 256: print(f"- {color_string((CY, 'Dump is shorter than expected.'))}") if args.file != "": print(f"- {color_string((CG, f'Dump written in {args.file}.'))}") def on_exec(self, args: argparse.Namespace): param = self.get_param(args) file_type = args.type fd = None save_as_eml = False if args.file != "": if file_type is None: if args.file.endswith(".eml") or args.file.endswith(".txt"): file_type = "hex" else: file_type = "bin" if file_type == "hex": fd = open(args.file, "w+") save_as_eml = True else: fd = open(args.file, "wb+") if fd is not None: with fd: fd.truncate(0) self.do_dump(args, param, fd, save_as_eml) else: self.do_dump(args, param, fd, save_as_eml) @hf_mfu.command("version") class HFMFUVERSION(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Request MIFARE Ultralight / NTAG version data." return parser def on_exec(self, args: argparse.Namespace): options = { "activate_rf_field": 0, "wait_response": 1, "append_crc": 1, "auto_select": 1, "keep_rf_field": 0, "check_response_crc": 1, } resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!B", 0x60) ) print(f" - Data: {resp[:8].hex()}") @hf_mfu.command("signature") class HFMFUSIGNATURE(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Request MIFARE Ultralight / NTAG ECC signature data." return parser def on_exec(self, args: argparse.Namespace): options = { "activate_rf_field": 0, "wait_response": 1, "append_crc": 1, "auto_select": 1, "keep_rf_field": 0, "check_response_crc": 1, } resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x3C, 0x00) ) print(f" - Data: {resp[:32].hex()}") @hf_mfu.command("authnonce") class HFMFUAUTHNONCE(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get authentication nonce from MIFARE Ultralight C tag." return parser def on_exec(self, args: argparse.Namespace): options = { "activate_rf_field": 0, "wait_response": 1, "append_crc": 1, "auto_select": 1, "keep_rf_field": 0, "check_response_crc": 1, } resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x1A, 0x00) ) # Response is 0xAF + 8 bytes nonce + 2 bytes CRC = 11 bytes # We want to display just the 8-byte nonce (skip 0xAF prefix) if len(resp) >= 9 and resp[0] == 0xAF: print(f" - Nonce: {resp[1:9].hex()}") else: print(f" - Error: Unexpected response: {resp.hex()}") class CrackEffect: """ A class to create a visual effect of cracking blocks of data. """ def __init__( self, num_blocks: int = 4, block_size: int = 8, scramble_delay: float = 0.01 ): """ Initialize the CrackEffect class with the given parameters. Args: num_blocks (int): Number of blocks to display. Default is 4. block_size (int): Size of each block in characters. Default is 8. scramble_delay (float): Delay between each scramble update in seconds. Default is 0.01. """ self.num_blocks = num_blocks self.block_size = block_size self.scramble_delay = scramble_delay self.message_queue = queue.Queue() self.revealed = [""] * num_blocks self.stop_event = threading.Event() self.cracked_blocks = set() self.display_lock = threading.Lock() self.output_enabled = True def generate_random_hex(self) -> str: """Generate a random hex string of block_size length.""" import random hex_chars = "0123456789ABCDEF" return "".join(random.choice(hex_chars) for _ in range(self.block_size)) def format_block(self, block: str, is_cracked: bool) -> str: """Format a block with appropriate color based on its state.""" if is_cracked: return f"\033[1;34m{block}\033[0m" # Bold blue return f"\033[96m{block}\033[0m" # Bright cyan def draw_static_box(self): """Draw the initial static box.""" if not self.output_enabled: return width = (self.block_size + 1) * self.num_blocks + 4 print("") # Add some padding above print("╔" + "═" * width + "╗") print("║" + " " * width + "║") print("║" + " " * width + "║") print("║" + " " * width + "║") print("╚" + "═" * width + "╝") # Move cursor to the middle line sys.stdout.write("\033[3A") # Move up 3 lines to middle row sys.stdout.flush() def print_above(self, data): """Print the given data above the box and redraws the box.""" if not self.output_enabled: print(data) return with self.display_lock: # Move cursor above the box and clean the line sys.stdout.write("\033[2A\033[1G\033[K" + data) self.draw_static_box() def display_current_state(self): """Display the current state of all blocks.""" if not self.output_enabled: return with self.display_lock: formatted_blocks = [ self.format_block(block, i in self.cracked_blocks) for i, block in enumerate(self.revealed) ] display_text = " ".join(formatted_blocks) # Update only the middle line sys.stdout.write(f"\r║ {display_text} ║") sys.stdout.flush() def scramble_effect(self): """Run the main loop for the scrambling effect.""" if not self.output_enabled: return while not self.stop_event.is_set(): # Update all non-cracked blocks with random values for block in range(self.num_blocks): if block not in self.cracked_blocks: self.revealed[block] = self.generate_random_hex() self.display_current_state() time.sleep(self.scramble_delay) def erase_key(self): """Erase random parts of the key.""" if not self.output_enabled: return for block in range(self.num_blocks): if block not in self.cracked_blocks: self.revealed[block] = "." * self.block_size self.display_current_state() def process_message_queue(self): """Process incoming cracked blocks from the queue.""" if not self.output_enabled: return while not self.stop_event.is_set(): try: block_idx, cracked_text = self.message_queue.get(timeout=0.1) self.revealed[block_idx] = cracked_text self.cracked_blocks.add(block_idx) self.display_current_state() # Check if all blocks are cracked if len(self.cracked_blocks) == self.num_blocks: self.stop_event.set() print("\n" * 3) # Add newlines after completion break except queue.Empty: continue except Exception as e: print(f"\nError processing message: {e}") break def add_cracked_block(self, block_idx: int, text: str): """Add a cracked block to the message queue.""" if not 0 <= block_idx < self.num_blocks: raise ValueError(f"Block index {block_idx} out of range") if len(text) != self.block_size: raise ValueError(f"Block text must be {self.block_size} characters") self.message_queue.put((block_idx, text)) def start(self): """Start the cracking effect.""" self.draw_static_box() # Create and start the worker threads scramble_thread = threading.Thread(target=self.scramble_effect) process_thread = threading.Thread(target=self.process_message_queue) scramble_thread.daemon = True process_thread.daemon = True scramble_thread.start() process_thread.start() # Wait for both threads to complete process_thread.join() self.stop_event.set() scramble_thread.join() @hf_mfu.command("ulcg") class HFMFUULCG(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Key recovery for Giantec ULCG and USCUID-UL cards (won't work on NXP cards!)" parser.add_argument( "-c", "--challenges", type=int, default=1000, help="Number of challenges to collect (default: 1000)", ) parser.add_argument( "-t", "--threads", type=int, default=1, help="Number of threads for key recovery (default: 1)", ) parser.add_argument( "-j", "--json", type=str, help="Path to JSON file to load or save challenges", ) parser.add_argument( "-o", "--offline", action="store_true", help="Use offline mode with pre-collected challenges", ) return parser def on_exec(self, args: argparse.Namespace): import json if not args.offline: challenges = self.collect_challenges(args.challenges) if challenges is None: return if args.json: with open(args.json, "w") as f: json.dump(challenges, f) print(f"[+] Challenges saved to {args.json}.") print("[!] Beware that the card key is now erased!") return else: if not args.json: print("[-] Error: --json required for offline mode") return with open(args.json, "r") as f: challenges = json.load(f) self.crack_key(challenges, args.threads, args.offline) def collect_challenges(self, num_challenges): """Collect challenges from the card and check if it is vulnerable.""" # Sanity check: make sure an Ultralight C is detected resp = self.cmd.hf14a_scan() if resp is None or len(resp) == 0: print("[-] Error: No tag detected") return None # Check SAK for Ultralight C (SAK should be 0x00) print("[+] Checking for Ultralight C...") # Check AUTH0 configuration options = { "activate_rf_field": 0, "wait_response": 1, "append_crc": 1, "auto_select": 1, "keep_rf_field": 0, "check_response_crc": 1, } # Read page 40-43 (config pages) resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x30, 0x28) ) # READ page 40 if len(resp) < 16: print( "[-] Error: Card not unlocked. Run relay attack in UNLOCK mode first." ) return None # Check AUTH0 (should be >= 0x30) minimum_auth_page = resp[8] if minimum_auth_page < 48: print( "[-] Error: Card not unlocked. Run relay attack in UNLOCK mode first." ) return None # Check lock bit is_locked_key = ((resp[1] & 0x80) >> 7) == 1 if is_locked_key: print("[-] Error: Card is not vulnerable (key is locked)") return None print( "[+] All sanity checks \033[1;32mpassed\033[0m. Checking if card is vulnerable.\033[?25l" ) # Collect 100 challenges to check for collision challenges_collected = 0 challenges_100 = set() challenges = {} collision = False while challenges_collected < num_challenges: resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x1A, 0x00), ) if len(resp) >= 9 and resp[0] == 0xAF: hex_challenge = resp[1:9].hex().upper() if hex_challenge in challenges_100: collision = True challenges["challenge_100"] = hex_challenge break else: challenges_100.add(hex_challenge) challenges_collected += 1 print(f"\r[+] Challenges collected: \033[96m{challenges_collected}\033[0m") if collision: print("[+] Status: \033[1;31mVulnerable\033[0m\033[?25h") else: print("[+] Status: \033[1;32mNot vulnerable\033[0m\033[?25h") return None # Card is vulnerable, proceed with attack print("[+] Collecting key-specific challenges...") # Overwrite block 47 and collect challenge_75 self.write_block(47, b"\x00\x00\x00\x00") resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x1A, 0x00) ) if len(resp) >= 9 and resp[0] == 0xAF: challenges["challenge_75"] = resp[1:9].hex().upper() print("[+] 75 collection complete") # Overwrite block 46 and collect challenge_50 self.write_block(46, b"\x00\x00\x00\x00") resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x1A, 0x00) ) if len(resp) >= 9 and resp[0] == 0xAF: challenges["challenge_50"] = resp[1:9].hex().upper() print("[+] 50 collection complete") # Overwrite block 45 and collect challenge_25 self.write_block(45, b"\x00\x00\x00\x00") resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x1A, 0x00) ) if len(resp) >= 9 and resp[0] == 0xAF: challenges["challenge_25"] = resp[1:9].hex().upper() print("[+] 25 collection complete") # Overwrite block 44 and collect challenge_0 self.write_block(44, b"\x00\x00\x00\x00") resp = self.cmd.hf14a_raw( options=options, resp_timeout_ms=200, data=struct.pack("!BB", 0x1A, 0x00) ) if len(resp) >= 9 and resp[0] == 0xAF: challenges["challenge_0"] = resp[1:9].hex().upper() print("[+] 0 collection complete") return challenges def write_block(self, block, data): """Write a block using hf14a_raw""" options = { "activate_rf_field": 0, "wait_response": 1, "append_crc": 1, "auto_select": 1, "keep_rf_field": 0, "check_response_crc": 1, } # WRITE command (0xA2) + block number + 4 bytes of data cmd_data = struct.pack("!BB4s", 0xA2, block, data) self.cmd.hf14a_raw(options=options, resp_timeout_ms=200, data=cmd_data) def crack_key(self, challenges, num_threads, offline): """Crack the key using collected challenges""" import signal import traceback key_segment_values = {0: "00" * 4, 1: "00" * 4, 2: "00" * 4, 3: "00" * 4} key_found = False print("[+] Cracking in progress...\033[?25l") # Create and start the cracking effect crack_effect = CrackEffect() effect_thread = threading.Thread(target=crack_effect.start) effect_thread.start() def signal_handler(sig, frame): print("\n\n\n[!] Interrupt received, stopping...\033[?25h") crack_effect.stop_event.set() sys.exit(0) signal.signal(signal.SIGINT, signal_handler) ciphertexts = { 1: challenges["challenge_25"], 0: challenges["challenge_50"], 3: challenges["challenge_75"], 2: challenges["challenge_100"], } try: for key_segment_idx in [1, 0, 3, 2]: ciphertext = ciphertexts[key_segment_idx] cmd = [ str(default_cwd / "mfulc_des_brute"), "-c", challenges["challenge_0"], ciphertext, "".join(key_segment_values.values()), str(key_segment_idx + 1), str(num_threads), ] try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=3600 ) if "Could not detect LFSR" in result.stderr: key_found = False crack_effect.stop_event.set() crack_effect.erase_key() print(f"\n\n\n[-] Error: {result.stderr}\033[?25h") break if "No matching key was found" in result.stdout: key_found = False crack_effect.stop_event.set() crack_effect.erase_key() print( f"\n\n\n[-] Error: No matching key found for segment {key_segment_idx + 1}\033[?25h" ) break if "Full key (hex): " not in result.stdout: key_found = False crack_effect.stop_event.set() crack_effect.erase_key() print( "\n\n\n[-] Error: Unexpected output from mfulc_des_brute\033[?25h" ) break # Extract the key segment from output full_key_line = [ line for line in result.stdout.split("\n") if "Full key (hex):" in line ][0] full_key = full_key_line.split("Full key (hex): ")[1].strip() key_segment_values[key_segment_idx] = full_key[ (8 * key_segment_idx): ][:8] key_found = True crack_effect.add_cracked_block( key_segment_idx, key_segment_values[key_segment_idx] ) except subprocess.TimeoutExpired: key_found = False crack_effect.stop_event.set() crack_effect.erase_key() print( f"\n\n\n[-] Error: Timeout cracking segment {key_segment_idx + 1}\033[?25h" ) break except Exception as e: key_found = False crack_effect.stop_event.set() crack_effect.erase_key() print(f"\n\n\n[-] Error: {e}\033[?25h") break except Exception as e: crack_effect.stop_event.set() print(f"\n\n\nAn error occurred: {e}\033[?25h") traceback.print_exc() finally: effect_thread.join() if key_found: result_key = "".join(key_segment_values.values()) formatted_key = f"\033[1;34m{result_key}\033[0m" print(f"[+] Found key: {formatted_key}\033[?25h") if offline: print( "You can restore found key on the card with appropriate write commands" ) else: # Restore the key on the card print("[+] Restoring key to card...") key_bytes = bytes.fromhex(result_key) # Need to swap endianness in 8-byte chunks before writing # UL-C stores key with swapped endianness key_swapped = bytearray(16) # Swap first 8 bytes for i in range(8): key_swapped[i] = key_bytes[7 - i] # Swap second 8 bytes for i in range(8): key_swapped[8 + i] = key_bytes[15 - i] # Write 4 blocks of 4 bytes each for i in range(4): block = 44 + i data = bytes(key_swapped[i * 4: (i + 1) * 4]) self.write_block(block, data) print("[+] Key restored on the card") @hf_mfu.command("econfig") class HFMFUEConfig(SlotIndexArgsAndGoUnit, HF14AAntiCollArgsUnit, DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Settings of Mifare Ultralight / NTAG emulator" self.add_slot_args(parser) self.add_hf14a_anticoll_args(parser) uid_magic_group = parser.add_mutually_exclusive_group() uid_magic_group.add_argument( "--enable-uid-magic", action="store_true", help="Enable UID magic mode" ) uid_magic_group.add_argument( "--disable-uid-magic", action="store_true", help="Disable UID magic mode" ) # Add this new write mode parameter write_names = [w.name for w in MifareUltralightWriteMode.list()] help_str = "Write Mode: " + ", ".join(write_names) parser.add_argument( "--write", type=str, help=help_str, metavar="MODE", choices=write_names ) parser.add_argument( "--set-version", type=bytes.fromhex, help="Set data to be returned by the GET_VERSION command.", ) parser.add_argument( "--set-signature", type=bytes.fromhex, help="Set data to be returned by the READ_SIG command.", ) parser.add_argument( "--reset-auth-cnt", action="store_true", help="Resets the counter of unsuccessful authentication attempts.", ) detection_group = parser.add_mutually_exclusive_group() detection_group.add_argument( "--enable-log", action="store_true", help="Enable password authentication logging", ) detection_group.add_argument( "--disable-log", action="store_true", help="Disable password authentication logging", ) return parser def on_exec(self, args: argparse.Namespace): aux_data_changed = False aux_data_change_requested = False if args.set_version is not None: aux_data_change_requested = True aux_data_changed = True if len(args.set_version) != 8: print(color_string((CR, "Version data should be 8 bytes long."))) return try: self.cmd.mf0_ntag_set_version_data(args.set_version) except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): print( color_string((CR, "Tag type does not support GET_VERSION command.")) ) return if args.set_signature is not None: aux_data_change_requested = True aux_data_changed = True if len(args.set_signature) != 32: print(color_string((CR, "Signature data should be 32 bytes long."))) return try: self.cmd.mf0_ntag_set_signature_data(args.set_signature) except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): print(color_string((CR, "Tag type does not support READ_SIG command."))) return if args.reset_auth_cnt: aux_data_change_requested = True old_value = self.cmd.mfu_reset_auth_cnt() if old_value != 0: aux_data_changed = True print( f"- Unsuccessful auth counter has been reset from {old_value} to 0." ) # collect current settings anti_coll_data = self.cmd.hf14a_get_anti_coll_data() if len(anti_coll_data) == 0: print( color_string( (CR, f"Slot {self.slot_num} does not contain any HF 14A config") ) ) return uid = anti_coll_data["uid"] atqa = anti_coll_data["atqa"] sak = anti_coll_data["sak"] ats = anti_coll_data["ats"] slotinfo = self.cmd.get_slot_info() fwslot = SlotNumber.to_fw(self.slot_num) hf_tag_type = TagSpecificType(slotinfo[fwslot]["hf"]) if hf_tag_type not in [ TagSpecificType.MF0ICU1, TagSpecificType.MF0ICU2, TagSpecificType.MF0UL11, TagSpecificType.MF0UL21, TagSpecificType.NTAG_210, TagSpecificType.NTAG_212, TagSpecificType.NTAG_213, TagSpecificType.NTAG_215, TagSpecificType.NTAG_216, ]: print( color_string( ( CR, f"Slot {self.slot_num} not configured as MIFARE Ultralight / NTAG", ) ) ) return change_requested, change_done, uid, atqa, sak, ats = self.update_hf14a_anticoll( args, uid, atqa, sak, ats ) if args.enable_uid_magic: change_requested = True self.cmd.mf0_ntag_set_uid_magic_mode(True) magic_mode = True elif args.disable_uid_magic: change_requested = True self.cmd.mf0_ntag_set_uid_magic_mode(False) magic_mode = False else: magic_mode = self.cmd.mf0_ntag_get_uid_magic_mode() # Add this new write mode handling write_mode = None if args.write is not None: change_requested = True new_write_mode = MifareUltralightWriteMode[args.write] try: current_write_mode = self.cmd.mf0_ntag_get_write_mode() if new_write_mode != current_write_mode: self.cmd.mf0_ntag_set_write_mode(new_write_mode) change_done = True write_mode = new_write_mode else: print(color_string((CY, "Requested write mode already set"))) except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): print( color_string( ( CR, "Failed to set write mode. Check if device firmware supports this feature.", ) ) ) detection = self.cmd.mf0_ntag_get_detection_enable() if args.enable_log: change_requested = True if detection is not None: if not detection: detection = True self.cmd.mf0_ntag_set_detection_enable(detection) change_done = True else: print( color_string( ( CY, "Requested logging of MFU authentication data already enabled", ) ) ) else: print( color_string( (CR, "Detection functionality not available in this firmware") ) ) elif args.disable_log: change_requested = True if detection is not None: if detection: detection = False self.cmd.mf0_ntag_set_detection_enable(detection) change_done = True else: print( color_string( ( CY, "Requested logging of MFU authentication data already disabled", ) ) ) else: print( color_string( (CR, "Detection functionality not available in this firmware") ) ) if change_done or aux_data_changed: print(" - MFU/NTAG Emulator settings updated") if not (change_requested or aux_data_change_requested): atqa_string = f"{atqa.hex().upper()} (0x{int.from_bytes(atqa, byteorder='little'):04x})" print(f'- {"Type:":40}{color_string((CY, hf_tag_type))}') print(f'- {"UID:":40}{color_string((CY, uid.hex().upper()))}') print(f'- {"ATQA:":40}{color_string((CY, atqa_string))}') print(f'- {"SAK:":40}{color_string((CY, sak.hex().upper()))}') if len(ats) > 0: print(f'- {"ATS:":40}{color_string((CY, ats.hex().upper()))}') # Display UID Magic status magic_status = "enabled" if magic_mode else "disabled" print(f'- {"UID Magic:":40}{color_string((CY, magic_status))}') # Add this to display write mode if available try: write_mode = MifareUltralightWriteMode( self.cmd.mf0_ntag_get_write_mode() ) print(f'- {"Write mode:":40}{color_string((CY, write_mode))}') except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): # Write mode not supported in current firmware pass # Existing version/signature display code try: version = self.cmd.mf0_ntag_get_version_data().hex().upper() print(f'- {"Version:":40}{color_string((CY, version))}') except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): pass try: signature = self.cmd.mf0_ntag_get_signature_data().hex().upper() print(f'- {"Signature:":40}{color_string((CY, signature))}') except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): pass try: detection = ( color_string((CG, "enabled")) if self.cmd.mf0_ntag_get_detection_enable() else color_string((CR, "disabled")) ) print(f'- {"Log (password) mode:":40}{f"{detection}"}') except (ValueError, chameleon_com.CMDInvalidException, TimeoutError): pass @hf_mfu.command("edetect") class HFMFUEDetect(SlotIndexArgsAndGoUnit, DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get Mifare Ultralight / NTAG emulator detection logs" self.add_slot_args(parser) parser.add_argument( "--count", type=int, help="Number of log entries to retrieve", metavar="COUNT", ) parser.add_argument( "--index", type=int, default=0, help="Starting index (default: 0)", metavar="INDEX", ) return parser def on_exec(self, args: argparse.Namespace): detection_enabled = self.cmd.mf0_ntag_get_detection_enable() if not detection_enabled: print(color_string((CY, "Detection logging is disabled for this slot"))) return total_count = self.cmd.mf0_ntag_get_detection_count() print(f"Total detection log entries: {total_count}") if total_count == 0: print(color_string((CY, "No detection logs available"))) return if args.count is not None: entries_to_get = min(args.count, total_count - args.index) else: entries_to_get = total_count - args.index if entries_to_get <= 0: print(color_string((CY, f"No entries available from index {args.index}"))) return logs = self.cmd.mf0_ntag_get_detection_log(args.index) print( f"\nPassword detection logs (showing {len(logs)} entries from index {args.index}):" ) print("-" * 50) for i, log_entry in enumerate(logs): actual_index = args.index + i password = log_entry["password"] print(f"{actual_index:3d}: {color_string((CY, password.upper()))}") @lf_em_410x.command("read") class LFEMRead(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Scan em410x tag and print id" return parser def on_exec(self, args: argparse.Namespace): data = self.cmd.em410x_scan() print(f"{TagSpecificType(data[0])}: {color_string((CG, data[1].hex()))}") @lf_em_410x.command("write") class LFEM410xWriteT55xx(LFEMIdArgsUnit, ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Write em410x id to t55xx" return self.add_card_arg(parser, required=True) def on_exec(self, args: argparse.Namespace): id_hex = args.id if len(id_hex) not in (10, 26): raise ArgsParserError( "Writing to T55xx supports 5-byte EM410X (10 hex) or 13-byte Electra (26 hex) IDs." ) id_bytes = bytes.fromhex(id_hex) self.cmd.em410x_write_to_t55xx(id_bytes) print(f" - EM410x ID write done: {id_hex}") @lf_hid_prox.command("read") class LFHIDProxRead(LFHIDIdReadArgsUnit, ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Scan hid prox tag and print card format, facility code, card number, issue level and OEM code" return self.add_card_arg(parser, required=True) def on_exec(self, args: argparse.Namespace): format = 0 if args.format is not None: format = HIDFormat[args.format].value (format, fc, cn1, cn2, il, oem) = self.cmd.hidprox_scan(format) cn = (cn1 << 32) + cn2 print(f"HIDProx/{HIDFormat(format)}") if fc > 0: print(f" FC: {color_string((CG, fc))}") if il > 0: print(f" IL: {color_string((CG, il))}") if oem > 0: print(f" OEM: {color_string((CG, oem))}") print(f" CN: {color_string((CG, cn))}") @lf_hid_prox.command("write") class LFHIDProxWriteT55xx(LFHIDIdArgsUnit, ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Write hidprox card data to t55xx" return self.add_card_arg(parser, required=True) def on_exec(self, args: argparse.Namespace): if args.fc is None: args.fc = 0 if args.il is None: args.il = 0 if args.oem is None: args.oem = 0 format = HIDFormat[args.format] id = struct.pack( ">BIBIBH", format.value, args.fc, (args.cn >> 32), args.cn & 0xFFFFFFFF, args.il, args.oem, ) self.cmd.hidprox_write_to_t55xx(id) print(f"HIDProx/{format}") if args.fc > 0: print(f" FC: {args.fc}") if args.il > 0: print(f" IL: {args.il}") if args.oem > 0: print(f" OEM: {args.oem}") print(f" CN: {args.cn}") print("write done.") @lf_hid_prox.command("econfig") class LFHIDProxEconfig(SlotIndexArgsAndGoUnit, LFHIDIdArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Set emulated hidprox card id" self.add_slot_args(parser) self.add_card_arg(parser) return parser def on_exec(self, args: argparse.Namespace): if args.cn is not None: slotinfo = self.cmd.get_slot_info() selected = SlotNumber.from_fw(self.cmd.get_active_slot()) lf_tag_type = TagSpecificType(slotinfo[selected - 1]["lf"]) if lf_tag_type != TagSpecificType.HIDProx: print(f"{color_string((CR, 'WARNING'))}: Slot type not set to HIDProx.") if args.fc is None: args.fc = 0 if args.il is None: args.il = 0 if args.oem is None: args.oem = 0 format = HIDFormat.H10301 if args.format is not None: format = HIDFormat[args.format] id = struct.pack( ">BIBIBH", format.value, args.fc, (args.cn >> 32), args.cn & 0xFFFFFFFF, args.il, args.oem, ) self.cmd.hidprox_set_emu_id(id) print(" - SET hidprox tag id success.") else: (format, fc, cn1, cn2, il, oem) = self.cmd.hidprox_get_emu_id() cn = (cn1 << 32) + cn2 print(" - GET hidprox tag id success.") print(f" - HIDProx/{HIDFormat(format)}") if fc > 0: print(f" FC: {color_string((CG, fc))}") if il > 0: print(f" IL: {color_string((CG, il))}") if oem > 0: print(f" OEM: {color_string((CG, oem))}") print(f" CN: {color_string((CG, cn))}") @lf_ioprox.command("read") class LFIOProxRead(LFIOProxReadArgsUnit, ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Scan ioProx tag and print version, facility, card number and raw" return self.add_card_arg(parser, required=False) def on_exec(self, args: argparse.Namespace): ver, fc, cn, raw8, *futureuse = self.cmd.ioprox_scan() print(f"ioProx XSF format") print(f" Version: {color_string((CG, ver))}") print(f" Facility: {color_string((CG, f'{fc} [0x{fc:02X}]'))}") print(f" ID: {color_string((CY, cn))}") print(f" Raw: {color_string((CY, raw8.hex().upper()))}") @lf_ioprox.command("write") class LFIOProxWriteT55xx(LFIOProxIdArgsUnit, ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Write ioProx card data to t55xx" return self.add_card_arg(parser, required=False) def on_exec(self, args: argparse.Namespace): # defaults ver = args.ver if args.ver is not None else 1 fc = args.fc if args.fc is not None else 0 cn = args.cn if args.cn is not None else 0 # raw8 priority if args.raw8 is not None: raw8 = self.parse_raw8(args.raw8) ver, fc, cn, raw8, *futureuse = self.cmd.ioprox_decode_raw(raw8) else: res = self.cmd.ioprox_compose_id(args.ver, args.fc, args.cn) raw8 = res[3] payload16 = struct.pack( ">BBH8s4x", ver & 0xFF, fc & 0xFF, cn & 0xFFFF, raw8 ) result = self.cmd.ioprox_write_to_t55xx(payload16) print(f"ioProx XSF format") print(f" Version: {color_string((CG, ver))}") print(f" Facility: {color_string((CG, f'{fc} [0x{fc:02X}]'))}") print(f" ID: {color_string((CY, cn))}") print(f" Raw: {color_string((CY, raw8.hex().upper()))}") print("Write done.") @lf_ioprox.command("econfig") class LFIOProxEconfig(SlotIndexArgsAndGoUnit, LFIOProxIdArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Set/Get emulated ioProx card id (stored in slot)" self.add_slot_args(parser) self.add_card_arg(parser, required=False) # SET when --cn or --raw present; GET otherwise return parser def on_exec(self, args: argparse.Namespace): do_set = (args.cn is not None) or (args.raw8 is not None) or (args.fc is not None) or (args.ver is not None) if do_set: # warn if slot isn't ioProx slotinfo = self.cmd.get_slot_info() selected = SlotNumber.from_fw(self.cmd.get_active_slot()) lf_tag_type = TagSpecificType(slotinfo[selected - 1]["lf"]) if lf_tag_type != TagSpecificType.ioProx: print(f"{color_string((CR, 'WARNING'))}: Slot type not set to IOProx.") # defaults ver = args.ver if args.ver is not None else 1 fc = args.fc if args.fc is not None else 0 cn = args.cn if args.cn is not None else 0 # raw8 priority if args.raw8 is not None: raw8 = self.parse_raw8(args.raw8) ver, fc, cn, raw8, *futureuse = self.cmd.ioprox_decode_raw(raw8) else: res = self.cmd.ioprox_compose_id(args.ver, args.fc, args.cn) raw8 = res[3] payload16 = struct.pack( ">BBH8s4x", ver & 0xFF, fc & 0xFF, cn & 0xFFFF, raw8 ) result = self.cmd.ioprox_set_emu_id(payload16) print(f"ioProx XSF format") print(f" Version: {color_string((CG, ver))}") print(f" Facility: {color_string((CG, f'{fc} [0x{fc:02X}]'))}") print(f" ID: {color_string((CY, cn))}") print(f" Raw: {color_string((CY, raw8.hex().upper()))}") else: # GET ver, fc, cn, raw8, *futureuse = self.cmd.ioprox_get_emu_id() print(f"ioProx XSF format") print(f" Version: {color_string((CG, ver))}") print(f" Facility: {color_string((CG, f'{fc} [0x{fc:02X}]'))}") print(f" ID: {color_string((CY, cn))}") print(f" Raw: {color_string((CY, raw8.hex().upper()))}") def jablotron_card_id(raw_bytes: bytes) -> int: """Convert 5 raw Jablotron bytes to decimal card number via BCD.""" card_id = 0 for b in raw_bytes: card_id = card_id * 100 + ((b >> 4) * 10) + (b & 0x0F) return card_id def pac_encode_raw(card_id: bytes) -> bytes: """Encode 8-byte card ID to 16-byte T55XX bitstream (128 bits). Frame: 0xFF sync (8 bits) + 12 × 10-bit UART frames. UART frame: start(0) + 7 data bits LSB-first + odd parity + stop(1). Payload: STX(0x02), '2', '0', card_id[0..7], XOR checksum. """ payload = [0x02, 0x32, 0x30] + list(card_id) xor_check = 0 for b in card_id: xor_check ^= b payload.append(xor_check) bits = [1] * 8 # sync marker 0xFF for byte_val in payload: bits.append(0) # start bit ones = 0 for i in range(7): bit = (byte_val >> i) & 1 bits.append(bit) ones += bit bits.append(0 if (ones & 1) else 1) # odd parity bits.append(1) # stop bit raw = bytearray(16) for i in range(128): if bits[i]: raw[i >> 3] |= 1 << (7 - (i & 7)) return bytes(raw) def pac_decode_raw(raw: bytes) -> bytes: """Decode 16-byte T55XX bitstream to 8-byte card ID. Validates sync, UART framing, header, and checksum. """ if len(raw) != 16: raise ValueError("Raw data must be exactly 16 bytes (128 bits)") def get_bit(pos): return (raw[pos >> 3] >> (7 - (pos & 7))) & 1 # Sync marker for i in range(8): if not get_bit(i): raise ValueError("Invalid sync marker (expected 0xFF)") decoded = [] for f in range(12): start = 8 + f * 10 if get_bit(start): raise ValueError(f"Invalid start bit in UART frame {f}") byte_val = 0 ones = 0 for i in range(7): if get_bit(start + 1 + i): byte_val |= 1 << i ones += 1 if get_bit(start + 8): ones += 1 if (ones & 1) == 0: raise ValueError(f"Parity error in UART frame {f}") if not get_bit(start + 9): raise ValueError(f"Invalid stop bit in UART frame {f}") decoded.append(byte_val) if decoded[0] != 0x02: raise ValueError(f"Invalid STX: 0x{decoded[0]:02X}") if decoded[1] != 0x32 or decoded[2] != 0x30: raise ValueError("Invalid header (expected '20')") card_id = bytes(decoded[3:11]) xor_check = 0 for b in card_id: xor_check ^= b if xor_check != decoded[11]: raise ValueError(f"Checksum error: expected 0x{xor_check:02X}, got 0x{decoded[11]:02X}") return card_id @lf_pac.command('read') class LFPacRead(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'Scan PAC/Stanley tag and print card ID' return parser def on_exec(self, args: argparse.Namespace): card_id = self.cmd.pac_scan() card_id_ascii = ''.join(chr(b) if 0x20 <= b < 0x7f else '.' for b in card_id) raw = pac_encode_raw(card_id) print(f" PAC/Stanley - CN: {color_string((CG, card_id_ascii))} | Raw: {raw.hex().upper()}") class LFPacIdArgsUnit(DeviceRequiredUnit): @staticmethod def add_card_arg(parser: ArgumentParserNoExit, required=False): group = parser.add_mutually_exclusive_group(required=required) group.add_argument("--cn", type=str, help="Card number (8 ASCII characters, e.g. CARD0001)", metavar="") group.add_argument("--raw", type=str, help="T55XX bitstream (32 hex chars, PM3 raw format)", metavar="") return parser def before_exec(self, args: argparse.Namespace): if not super().before_exec(args): return False if args.cn is not None: if len(args.cn) != 8: raise ArgsParserError("Card number must be exactly 8 characters") try: args.id = args.cn.encode('ascii').hex() except UnicodeEncodeError: raise ArgsParserError("Card number must be ASCII characters only") elif args.raw is not None: if not re.match(r"^[a-fA-F0-9]{32}$", args.raw): raise ArgsParserError("Raw must be exactly 32 hex characters (128-bit T55XX bitstream)") try: card_id = pac_decode_raw(bytes.fromhex(args.raw)) except ValueError as e: raise ArgsParserError(f"Invalid PAC raw data: {e}") args.id = card_id.hex() else: args.id = None return True # PAC uses 7-bit UART frames; MSB of each byte is not encoded id_bytes = bytes.fromhex(args.id) if any(b > 0x7F for b in id_bytes): raise ArgsParserError("PAC card IDs are 7-bit only (each byte must be 0x00-0x7F)") return True def args_parser(self) -> ArgumentParserNoExit: raise NotImplementedError("Please implement this") def on_exec(self, args: argparse.Namespace): raise NotImplementedError("Please implement this") @lf_pac.command('write') class LFPacWriteT55xx(LFPacIdArgsUnit, ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'Write PAC/Stanley id to T55xx' return self.add_card_arg(parser, required=True) def on_exec(self, args: argparse.Namespace): id_bytes = bytes.fromhex(args.id) self.cmd.pac_write_to_t55xx(id_bytes) id_ascii = ''.join(chr(b) if 0x20 <= b < 0x7f else '.' for b in id_bytes) raw = pac_encode_raw(id_bytes) print(f" - PAC/Stanley write done - CN: {id_ascii} | Raw: {raw.hex().upper()}") @lf_pac.command('econfig') class LFPacEconfig(SlotIndexArgsAndGoUnit, LFPacIdArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'Set emulated PAC/Stanley card ID' self.add_slot_args(parser) self.add_card_arg(parser) return parser def on_exec(self, args: argparse.Namespace): if args.id is not None: slotinfo = self.cmd.get_slot_info() selected = SlotNumber.from_fw(self.cmd.get_active_slot()) lf_tag_type = TagSpecificType(slotinfo[selected - 1]['lf']) if lf_tag_type != TagSpecificType.PAC: print(f"{color_string((CR, 'WARNING'))}: Slot type not set to PAC.") self.cmd.pac_set_emu_id(bytes.fromhex(args.id)) print(' - Set PAC/Stanley tag id success.') else: response = self.cmd.pac_get_emu_id() card_id_ascii = ''.join(chr(b) if 0x20 <= b < 0x7f else '.' for b in response) raw = pac_encode_raw(response) print(' - Get PAC/Stanley tag id success.') print(f'CN: {card_id_ascii} | Raw: {raw.hex().upper()}') @lf_viking.command("read") class LFVikingRead(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Scan Viking tag and print id" return parser def on_exec(self, args: argparse.Namespace): id = self.cmd.viking_scan() print(f" Viking: {color_string((CG, id.hex()))}") @lf_viking.command("write") class LFVikingWriteT55xx(LFVikingIdArgsUnit, ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Write Viking id to t55xx" return self.add_card_arg(parser, required=True) def on_exec(self, args: argparse.Namespace): id_hex = args.id id_bytes = bytes.fromhex(id_hex) self.cmd.viking_write_to_t55xx(id_bytes) print(f" - Viking ID(8H): {id_hex} write done.") @lf_idteck.command("write") class LFIdteckWriteT55xx(LFIdteckIdArgsUnit, ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Clone an IDTECK PSK1 frame onto a T55xx tag." return self.add_card_arg(parser, required=True) def on_exec(self, args: argparse.Namespace): id_hex = args.id id_bytes = bytes.fromhex(id_hex) self.cmd.idteck_write_to_t55xx(id_bytes) print(f" - IDTECK frame {id_hex} written to T55xx.") @lf_idteck.command("econfig") class LFIdteckEconfig(SlotIndexArgsAndGoUnit, LFIdteckIdArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = ( "Get or set the IDTECK emulated id on a slot. " "Provide --id to set; omit it to read back the current value." ) self.add_slot_args(parser) self.add_card_arg(parser) return parser def on_exec(self, args: argparse.Namespace): if args.id is not None: slotinfo = self.cmd.get_slot_info() selected = SlotNumber.from_fw(self.cmd.get_active_slot()) lf_tag_type = TagSpecificType(slotinfo[selected - 1]["lf"]) if lf_tag_type != TagSpecificType.IDTECK: print(f"{color_string((CR, 'WARNING'))}: Slot LF type is not IDTECK. " f"Set it with: hw slot type -s -t IDTECK") self.cmd.idteck_set_emu_id(bytes.fromhex(args.id)) print(f" - IDTECK emu id set to {args.id.upper()}.") else: response = self.cmd.idteck_get_emu_id() info = _idteck_frame_info(response) print(f" - IDTECK emu id: {response.hex().upper()}") print(f" Preamble : {info['preamble_hex']}" + ("" if info["preamble_valid"] else f" {color_string((CR, '(not IDTK)'))}")) print(f" Payload : {info['payload_hex']}") print(f" Card ID : {info['card_id']} (0x{info['card_id']:06X})") chk_tag = color_string((CG, "ok")) if info["checksum_valid"] else color_string((CY, "mismatch")) print(f" Checksum : 0x{info['checksum']:02X} (expected 0x{info['checksum_expected']:02X}, {chk_tag})") @lf.command("clone") class LFT55xxClone(ReaderRequiredUnit): """ Clone a scanned or manually-specified LF card ID onto a blank T55xx tag. Supported types and their required arguments: em410x --id <10 hex> e.g. --id DEADBEEF88 electra --id <26 hex> e.g. --id DEADBEEF880102030405060708 hid -f --cn e.g. -f H10301 --fc 10 --cn 1234 ioprox --ver --fc --cn OR --raw8 <16 hex> pac --id <8 ASCII> e.g. --id 11223344 viking --id <8 hex> e.g. --id DEADBEEF idteck --id <16 hex> e.g. --id 4944544BDEADBEEF (or 8 hex for payload only; preamble auto-prepended) Only supported on Chameleon Ultra (Lite has no LF writer). """ TYPES = ["em410x", "electra", "hid", "ioprox", "pac", "viking", "idteck"] def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = ( "Clone a LF card ID onto a blank T55xx tag.\n" "Supported types: em410x, electra, hid, ioprox, pac, viking, idteck.\n" "Only supported on Chameleon Ultra (Lite has no LF writer)." ) parser.add_argument( "-t", "--type", type=str, required=True, choices=self.TYPES, metavar="TYPE", help="Card type: " + ", ".join(self.TYPES), ) # EM410x / Electra / Viking parser.add_argument( "--id", type=str, required=False, metavar="HEX", help="Card ID in hex: 10 for em410x, 26 for electra, 8 for viking, 8 or 16 for idteck; 8 ASCII chars for pac", ) # HID Prox parser.add_argument( "-f", "--format", type=str, required=False, choices=[x.name for x in HIDFormat], metavar="FORMAT", help="HID Prox format, e.g. H10301 (required for hid type)", ) parser.add_argument( "--fc", type=int, required=False, metavar="INT", help="Facility code (HID / ioProx)", ) parser.add_argument( "--cn", type=int, required=False, metavar="INT", help="Card number (HID / ioProx)", ) parser.add_argument( "--il", type=int, required=False, metavar="INT", help="Issue level (HID, optional)", ) parser.add_argument( "--oem", type=int, required=False, metavar="INT", help="OEM code (HID, optional)", ) # ioProx parser.add_argument( "--ver", type=int, required=False, metavar="INT", help="Version byte (ioProx)", ) parser.add_argument( "--raw8", type=str, required=False, metavar="HEX", help="ioProx raw 8 bytes in hex, e.g. 007854E03A5D65AB", ) return parser def on_exec(self, args: argparse.Namespace): # Clone requires LF writer — only available on Chameleon Ultra (not Lite) if self.cmd.get_device_model() != 0: print(f" - Error: LF clone requires Chameleon Ultra. Lite has no LF writer.") return t = args.type if t in ("em410x", "electra"): if args.id is None: raise ArgsParserError("--id is required for em410x / electra") expected = 10 if t == "em410x" else 26 if not re.match(r"^[a-fA-F0-9]{" + str(expected) + r"}$", args.id): raise ArgsParserError( f"--id must be exactly {expected} hex characters for {t}" ) id_bytes = bytes.fromhex(args.id) self.cmd.em410x_write_to_t55xx(id_bytes) label = "EM410x Electra" if t == "electra" else "EM410x" print(f" - {label} ID cloned to T55xx: {args.id.upper()}") elif t == "hid": if args.format is None: raise ArgsParserError("-f/--format is required for hid") if args.cn is None: raise ArgsParserError("--cn is required for hid") fmt = HIDFormat[args.format] fc = args.fc if args.fc is not None else 0 il = args.il if args.il is not None else 0 oem = args.oem if args.oem is not None else 0 LFHIDIdArgsUnit.check_limits(fmt.value, fc, args.cn, il, oem) cn = args.cn id_bytes = struct.pack( ">BIBIBH", fmt.value, fc, (cn >> 32), cn & 0xFFFFFFFF, il, oem, ) self.cmd.hidprox_write_to_t55xx(id_bytes) print(f" - HID Prox cloned to T55xx") print(f" Format : {fmt.name}") if fc: print(f" FC : {fc}") if il: print(f" IL : {il}") if oem: print(f" OEM : {oem}") print(f" CN : {cn}") elif t == "ioprox": ver = args.ver if args.ver is not None else 1 fc = int(args.fc) if args.fc is not None else 0 cn = args.cn if args.cn is not None else 0 if args.raw8 is not None: raw8 = LFIOProxIdArgsUnit.parse_raw8(args.raw8) ver, fc, cn, raw8, *_ = self.cmd.ioprox_decode_raw(raw8) else: res = self.cmd.ioprox_compose_id(ver, fc, cn) raw8 = res[3] payload16 = struct.pack(">BBH8s4x", ver & 0xFF, fc & 0xFF, cn & 0xFFFF, raw8) self.cmd.ioprox_write_to_t55xx(payload16) print(f" - ioProx cloned to T55xx") print(f" Ver : {ver}") print(f" FC : {fc} [0x{fc:02X}]") print(f" CN : {cn}") print(f" Raw8 : {raw8.hex().upper()}") elif t == "pac": if args.id is None: raise ArgsParserError("--id is required for pac (8 ASCII characters)") if len(args.id) != 8: raise ArgsParserError("--id must be exactly 8 ASCII characters for pac") id_bytes = args.id.encode("ascii") self.cmd.pac_write_to_t55xx(id_bytes) print(f" - PAC/Stanley ID cloned to T55xx: {args.id}") elif t == "viking": if args.id is None: raise ArgsParserError("--id is required for viking") if not re.match(r"^[a-fA-F0-9]{8}$", args.id): raise ArgsParserError("--id must be exactly 8 hex characters for viking") id_bytes = bytes.fromhex(args.id) self.cmd.viking_write_to_t55xx(id_bytes) print(f" - Viking ID cloned to T55xx: {args.id.upper()}") elif t == "idteck": if args.id is None: raise ArgsParserError("--id is required for idteck (8 or 16 hex)") if re.match(r"^[a-fA-F0-9]{16}$", args.id): id_hex = args.id elif re.match(r"^[a-fA-F0-9]{8}$", args.id): id_hex = "4944544B" + args.id # prepend "IDTK" preamble else: raise ArgsParserError("--id must be 8 or 16 hex characters for idteck") id_bytes = bytes.fromhex(id_hex) self.cmd.idteck_write_to_t55xx(id_bytes) print(f" - IDTECK frame cloned to T55xx: {id_hex.upper()}") @lf_generic.command("adcread") class LFADCGenericRead(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Read ADC and return the array" return parser def on_exec(self, args: argparse.Namespace): resp = self.cmd.adc_generic_read() if resp is not None: print(f"generic read data[{len(resp)}]:") width = 50 for i in range(0, len(resp), width): chunk = resp[i: i + width] hexpart = " ".join(f"{b:02x}" for b in chunk) binpart = "".join("1" if b >= 0xBF else "0" for b in chunk) print(f"{i:04x} {hexpart:<{width * 3}} {binpart}") avg = 0 for val in resp: avg += val print(f"avg: {hex(round(avg / len(resp)))}") else: print("generic read error") @hw_slot.command("list") class HWSlotList(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get information about slots" parser.add_argument( "--short", action="store_true", help="Hide slot nicknames and Mifare Classic emulator settings", ) return parser def get_slot_name(self, slot, sense): try: name = self.cmd.get_slot_tag_nick(slot, sense) return { "baselen": len(name), "metalen": len(CC + C0), "name": color_string((CC, name)), } except UnexpectedResponseError: return {"baselen": 0, "metalen": 0, "name": ""} except UnicodeDecodeError: name = "UTF8 Err" return { "baselen": len(name), "metalen": len(CC + C0), "name": color_string((CC, name)), } def on_exec(self, args: argparse.Namespace): slotinfo = self.cmd.get_slot_info() selected = SlotNumber.from_fw(self.cmd.get_active_slot()) current = selected enabled = self.cmd.get_enabled_slots() maxnamelength = 0 slotnames = [] all_nicks = self.cmd.get_all_slot_nicks() for slot_data in all_nicks: hfn = { "baselen": len(slot_data["hf"]), "metalen": len(CC + C0), "name": color_string((CC, slot_data["hf"])), } lfn = { "baselen": len(slot_data["lf"]), "metalen": len(CC + C0), "name": color_string((CC, slot_data["lf"])), } m = max(hfn["baselen"], lfn["baselen"]) maxnamelength = m if m > maxnamelength else maxnamelength slotnames.append({"hf": hfn, "lf": lfn}) for slot in SlotNumber: fwslot = SlotNumber.to_fw(slot) status = f"({color_string((CG, 'active'))})" if slot == selected else "" try: hf_tag_type = TagSpecificType(slotinfo[fwslot]["hf"]) except ValueError: hf_tag_type = TagSpecificType.UNDEFINED hf_unknown = slotinfo[fwslot]["hf"] else: hf_unknown = None try: lf_tag_type = TagSpecificType(slotinfo[fwslot]["lf"]) except ValueError: lf_tag_type = TagSpecificType.UNDEFINED lf_unknown = slotinfo[fwslot]["lf"] else: lf_unknown = None print(f' - {f"Slot {slot}:":{4+maxnamelength+1}} {status}') # HF field_length = maxnamelength + slotnames[fwslot]["hf"]["metalen"] + 1 status = ( f"({color_string((CR, 'disabled'))})" if not enabled[fwslot]["hf"] else "" ) print( f" HF: " f'{slotnames[fwslot]["hf"]["name"]:{field_length}}', end="" ) print(status, end="") if hf_unknown is not None: print(color_string((CR, f"Unknown ({hf_unknown})"))) elif hf_tag_type != TagSpecificType.UNDEFINED: color = CY if enabled[fwslot]["hf"] else C0 print(color_string((color, hf_tag_type))) else: print("undef") if ( (not args.short) and enabled[fwslot]["hf"] and hf_tag_type != TagSpecificType.UNDEFINED and hf_unknown is None ): if current != slot: self.cmd.set_active_slot(slot) current = slot anti_coll_data = self.cmd.hf14a_get_anti_coll_data() uid = anti_coll_data["uid"] atqa = anti_coll_data["atqa"] sak = anti_coll_data["sak"] ats = anti_coll_data["ats"] # print(' - ISO14443A emulator settings:') atqa_hex_le = f"(0x{int.from_bytes(atqa, byteorder='little'):04x})" print(f' {"UID:":40}{color_string((CY, uid.hex().upper()))}') print( f' {"ATQA:":40}{color_string((CY, f"{atqa.hex().upper()} {atqa_hex_le}"))}' ) print(f' {"SAK:":40}{color_string((CY, sak.hex().upper()))}') if len(ats) > 0: print(f' {"ATS:":40}{color_string((CY, ats.hex().upper()))}') if hf_tag_type in [ TagSpecificType.MIFARE_Mini, TagSpecificType.MIFARE_1024, TagSpecificType.MIFARE_2048, TagSpecificType.MIFARE_4096, ]: config = self.cmd.mf1_get_emulator_config() # print(' - Mifare Classic emulator settings:') enabled_str = color_string((CG, "enabled")) disabled_str = color_string((CR, "disabled")) print( f' {"Gen1A magic mode:":40}' f'{enabled_str if config["gen1a_mode"] else disabled_str}' ) print( f' {"Gen2 magic mode:":40}' f'{enabled_str if config["gen2_mode"] else disabled_str}' ) print( f' {"Use anti-collision data from block 0:":40}' f'{enabled_str if config["block_anti_coll_mode"] else disabled_str}' ) try: print( f' {"Write mode:":40}' f'{color_string((CY, MifareClassicWriteMode(config["write_mode"])))}' ) except ValueError: print( f' {"Write mode:":40}{color_string((CR, "invalid value!"))}' ) print( f' {"Log (mfkey32) mode:":40}' f'{enabled_str if config["detection"] else disabled_str}' ) try: prng_resp = self.cmd.mf1_get_prng_type() prng_type = MifareClassicPrngType(prng_resp.parsed) print( f' {"PRNG type:":40}' f'{color_string((CY, str(prng_type)))}' ) except Exception: pass # LF field_length = maxnamelength + slotnames[fwslot]["lf"]["metalen"] + 1 status = ( f"({color_string((CR, 'disabled'))})" if not enabled[fwslot]["lf"] else "" ) print( f" LF: " f'{slotnames[fwslot]["lf"]["name"]:{field_length}}', end="" ) print(status, end="") if lf_unknown is not None: print(color_string((CR, f"Unknown ({lf_unknown})"))) elif lf_tag_type != TagSpecificType.UNDEFINED: color = CY if enabled[fwslot]["lf"] else C0 print(color_string((color, lf_tag_type))) else: print("undef") if ( (not args.short) and enabled[fwslot]["lf"] and lf_tag_type != TagSpecificType.UNDEFINED and lf_unknown is None ): if current != slot: self.cmd.set_active_slot(slot) current = slot if lf_tag_type == TagSpecificType.EM410X: id = self.cmd.em410x_get_emu_id() print(f' {"ID:":40}{color_string((CY, id.hex().upper()))}') if lf_tag_type == TagSpecificType.HIDProx: (format, fc, cn1, cn2, il, oem) = self.cmd.hidprox_get_emu_id() cn = (cn1 << 32) + cn2 print( f" {'Format:':40}{color_string((CY, HIDFormat(format)))}" ) if fc > 0: print(f" {'FC:':40}{color_string((CG, fc))}") if il > 0: print(f" {'IL:':40}{color_string((CG, il))}") if oem > 0: print(f" {'OEM:':40}{color_string((CG, oem))}") print(f" {'CN:':40}{color_string((CG, cn))}") if lf_tag_type == TagSpecificType.ioProx: ver, fc, cn, raw8, *futureuse = self.cmd.ioprox_get_emu_id() print(f" {'Version:':40}{color_string((CG, ver))}") print(f" {'Facility:':40}{color_string((CG, f'{fc} [0x{fc:02X}]'))}") print(f" {'ID:':40}{color_string((CY, cn))}") print(f" {'Raw:':40}{color_string((CY, raw8.hex().upper()))}") if lf_tag_type == TagSpecificType.Viking: id = self.cmd.viking_get_emu_id() print(f" {'ID:':40}{color_string((CY, id.hex().upper()))}") if lf_tag_type == TagSpecificType.Jablotron: id = self.cmd.jablotron_get_emu_id() card_id = jablotron_card_id(id) print(f" {'ID:':40}{color_string((CY, id.hex().upper()))}") print(f" {'Card:':40}{color_string((CG, str(card_id)))}") if lf_tag_type == TagSpecificType.PAC: id = self.cmd.pac_get_emu_id() id_ascii = ''.join(chr(b) if 0x20 <= b < 0x7f else '.' for b in id) raw = pac_encode_raw(id) print(f" {'CN:':40}{color_string((CY, id_ascii))}") print(f" {'Raw:':40}{color_string((CY, raw.hex().upper()))}") if lf_tag_type == TagSpecificType.IDTECK: frame = self.cmd.idteck_get_emu_id() info = _idteck_frame_info(frame) card_id_str = f"{info['card_id']} (0x{info['card_id']:06X})" print(f" {'Frame:':40}{color_string((CY, frame.hex().upper()))}") print(f" {'Card ID:':40}{color_string((CG, card_id_str))}") if current != selected: self.cmd.set_active_slot(selected) @hw_slot.command("prng") class HWSlotPrng(SlotIndexArgsAndGoUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = ( "Get or set the PRNG type for the MF1 emulator of a slot. " "PRNG controls the nonce used during MIFARE Classic authentication: " "0=Static (fixed), 1=Weak (LFSR/predictable), 2=Hard (unpredictable). " "Omit --type to read the current setting." ) self.add_slot_args(parser) parser.add_argument( "-t", "--type", type=int, choices=[0, 1, 2], metavar="<0|1|2>", help="PRNG type: 0=Static, 1=Weak, 2=Hard", default=None, ) return parser def on_exec(self, args: argparse.Namespace): if args.type is None: # Read current PRNG type resp = self.cmd.mf1_get_prng_type() try: prng_type = MifareClassicPrngType(resp.parsed) print(f" - Slot {self.slot_num} PRNG type: {color_string((CY, str(prng_type)))} ({resp.parsed})") except ValueError: print(f" - Slot {self.slot_num} PRNG type: {color_string((CR, f'unknown ({resp.parsed})'))} ") else: self.cmd.mf1_set_prng_type(args.type) prng_type = MifareClassicPrngType(args.type) print(f" - Slot {self.slot_num} PRNG type set to: {color_string((CG, str(prng_type)))} ({args.type})") @hw_slot.command("change") class HWSlotSet(SlotIndexArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Set emulation tag slot activated" return self.add_slot_args(parser, mandatory=True) def on_exec(self, args: argparse.Namespace): slot_index = args.slot self.cmd.set_active_slot(slot_index) print(f" - Set slot {slot_index} activated success.") @hw_slot.command("type") class HWSlotType(TagTypeArgsUnit, SlotIndexArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Set emulation tag type" self.add_slot_args(parser) self.add_type_args(parser) return parser def on_exec(self, args: argparse.Namespace): tag_type = TagSpecificType[args.type] if args.slot is not None: slot_num = args.slot else: slot_num = SlotNumber.from_fw(self.cmd.get_active_slot()) self.cmd.set_slot_tag_type(slot_num, tag_type) self.cmd.set_slot_data_default(slot_num, tag_type) print(f" - Set slot {slot_num} tag type success.") @hw_slot.command("delete") class HWDeleteSlotSense(SlotIndexArgsUnit, SenseTypeArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Delete sense type data for a specific slot" self.add_slot_args(parser) self.add_sense_type_args(parser) return parser def on_exec(self, args: argparse.Namespace): if args.slot is not None: slot_num = args.slot else: slot_num = SlotNumber.from_fw(self.cmd.get_active_slot()) if args.lf: sense_type = TagSenseType.LF else: sense_type = TagSenseType.HF self.cmd.delete_slot_sense_type(slot_num, sense_type) print(f" - Delete slot {slot_num} {sense_type.name} tag type success.") @hw_slot.command("init") class HWSlotInit(TagTypeArgsUnit, SlotIndexArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Set emulation tag data to default" self.add_slot_args(parser) self.add_type_args(parser) return parser def on_exec(self, args: argparse.Namespace): tag_type = TagSpecificType[args.type] if args.slot is not None: slot_num = args.slot else: slot_num = SlotNumber.from_fw(self.cmd.get_active_slot()) self.cmd.set_slot_data_default(slot_num, tag_type) print(" - Set slot tag data init success.") @hw_slot.command("enable") class HWSlotEnable(SlotIndexArgsUnit, SenseTypeArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Enable tag slot" self.add_slot_args(parser) self.add_sense_type_args(parser) return parser def on_exec(self, args: argparse.Namespace): if args.slot is not None: slot_num = args.slot else: slot_num = SlotNumber.from_fw(self.cmd.get_active_slot()) if args.lf: sense_type = TagSenseType.LF else: sense_type = TagSenseType.HF self.cmd.set_slot_enable(slot_num, sense_type, True) print(f" - Enable slot {slot_num} {sense_type.name} success.") @hw_slot.command("disable") class HWSlotDisable(SlotIndexArgsUnit, SenseTypeArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Disable tag slot" self.add_slot_args(parser) self.add_sense_type_args(parser) return parser def on_exec(self, args: argparse.Namespace): slot_num = args.slot if args.lf: sense_type = TagSenseType.LF else: sense_type = TagSenseType.HF self.cmd.set_slot_enable(slot_num, sense_type, False) print(f" - Disable slot {slot_num} {sense_type.name} success.") @lf_em_410x.command("econfig") class LFEM410xEconfig(SlotIndexArgsAndGoUnit, LFEMIdArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Set emulated em410x card id" self.add_slot_args(parser) self.add_card_arg(parser) return parser def on_exec(self, args: argparse.Namespace): if args.id is not None: self.cmd.em410x_set_emu_id(bytes.fromhex(args.id)) print(" - Set em410x tag id success.") else: response = self.cmd.em410x_get_emu_id() print(" - Get em410x tag id success.") print(f"ID: {response.hex()}") @lf_viking.command("econfig") class LFVikingEconfig(SlotIndexArgsAndGoUnit, LFVikingIdArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Set emulated Viking card id" self.add_slot_args(parser) self.add_card_arg(parser) return parser def on_exec(self, args: argparse.Namespace): if args.id is not None: slotinfo = self.cmd.get_slot_info() selected = SlotNumber.from_fw(self.cmd.get_active_slot()) lf_tag_type = TagSpecificType(slotinfo[selected - 1]["lf"]) if lf_tag_type != TagSpecificType.Viking: print(f"{color_string((CR, 'WARNING'))}: Slot type not set to Viking.") self.cmd.viking_set_emu_id(bytes.fromhex(args.id)) print(" - Set Viking tag id success.") else: response = self.cmd.viking_get_emu_id() print(" - Get Viking tag id success.") print(f"ID: {response.hex().upper()}") @lf_jablotron.command("read") class LFJablotronRead(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Scan Jablotron tag and print id" return parser def on_exec(self, args: argparse.Namespace): id = self.cmd.jablotron_scan() card_id = jablotron_card_id(id) print(f" Jablotron ID: {color_string((CG, id.hex().upper()))}") print(f" Card number: {color_string((CY, str(card_id)))}") @lf_jablotron.command("write") class LFJablotronWriteT55xx(LFJablotronIdArgsUnit, ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Write Jablotron id to t55xx" return self.add_card_arg(parser, required=True) def on_exec(self, args: argparse.Namespace): id_hex = args.id id_bytes = bytes.fromhex(id_hex) self.cmd.jablotron_write_to_t55xx(id_bytes) print(f" - Jablotron ID: {id_hex.upper()} write done.") @lf_jablotron.command("econfig") class LFJablotronEconfig(SlotIndexArgsAndGoUnit, LFJablotronIdArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Set emulated Jablotron card id" self.add_slot_args(parser) self.add_card_arg(parser) return parser def on_exec(self, args: argparse.Namespace): if args.id is not None: slotinfo = self.cmd.get_slot_info() selected = SlotNumber.from_fw(self.cmd.get_active_slot()) lf_tag_type = TagSpecificType(slotinfo[selected - 1]["lf"]) if lf_tag_type != TagSpecificType.Jablotron: print(f"{color_string((CR, 'WARNING'))}: Slot type not set to Jablotron.") self.cmd.jablotron_set_emu_id(bytes.fromhex(args.id)) print(" - Set Jablotron tag id success.") else: response = self.cmd.jablotron_get_emu_id() card_id = jablotron_card_id(response) print(" - Get Jablotron tag id success.") print(f"ID: {response.hex().upper()}") print(f"Card: {card_id}") @hw_slot.command("nick") class HWSlotNick(SlotIndexArgsUnit, SenseTypeArgsUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get/Set/Delete tag nick name for slot" self.add_slot_args(parser) self.add_sense_type_args(parser) action_group = parser.add_mutually_exclusive_group() action_group.add_argument( "-n", "--name", type=str, required=False, help="Set tag nick name for slot" ) action_group.add_argument( "-d", "--delete", action="store_true", help="Delete tag nick name for slot" ) return parser def on_exec(self, args: argparse.Namespace): if args.slot is not None: slot_num = args.slot else: slot_num = SlotNumber.from_fw(self.cmd.get_active_slot()) if args.lf: sense_type = TagSenseType.LF else: sense_type = TagSenseType.HF if args.name is not None: name: str = args.name self.cmd.set_slot_tag_nick(slot_num, sense_type, name) print(f" - Set tag nick name for slot {slot_num} {sense_type.name}: {name}") elif args.delete: self.cmd.delete_slot_tag_nick(slot_num, sense_type) print(f" - Delete tag nick name for slot {slot_num} {sense_type.name}") else: res = self.cmd.get_slot_tag_nick(slot_num, sense_type) print( f" - Get tag nick name for slot {slot_num} {sense_type.name}" f": {res}" ) @hw_slot.command("store") class HWSlotUpdate(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Store slots config & data to device flash" return parser def on_exec(self, args: argparse.Namespace): self.cmd.slot_data_config_save() print(" - Store slots config and data from device memory to flash success.") @hw_slot.command("openall") class HWSlotOpenAll(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Open all slot and set to default data" return parser def on_exec(self, args: argparse.Namespace): # what type you need set to default? hf_type = TagSpecificType.MIFARE_1024 lf_type = TagSpecificType.EM410X # set all slot for slot in SlotNumber: print(f" Slot {slot} setting...") # first to set tag type self.cmd.set_slot_tag_type(slot, hf_type) self.cmd.set_slot_tag_type(slot, lf_type) # to init default data self.cmd.set_slot_data_default(slot, hf_type) self.cmd.set_slot_data_default(slot, lf_type) # finally, we can enable this slot. self.cmd.set_slot_enable(slot, TagSenseType.HF, True) self.cmd.set_slot_enable(slot, TagSenseType.LF, True) print(f" Slot {slot} setting done.") # update config and save to flash self.cmd.slot_data_config_save() print(" - Succeeded opening all slots and setting data to default.") @hw.command("dfu") class HWDFU(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Restart application to bootloader/DFU mode" return parser def on_exec(self, args: argparse.Namespace): print("Application restarting...") self.cmd.enter_bootloader() # In theory, after the above command is executed, the dfu mode will enter, and then the USB will restart, # To judge whether to enter the USB successfully, we only need to judge whether the USB becomes the VID and PID # of the DFU device. # At the same time, we remember to confirm the information of the device, # it is the same device when it is consistent. print(" - Enter success @.@~") # let time for comm thread to send dfu cmd and close port time.sleep(0.1) @hw_settings.command("animation") class HWSettingsAnimation(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get or change current animation mode value" mode_names = [m.name for m in list(AnimationMode)] help_str = "Mode: " + ", ".join(mode_names) parser.add_argument( "-m", "--mode", type=str, required=False, help=help_str, metavar="MODE", choices=mode_names, ) return parser def on_exec(self, args: argparse.Namespace): if args.mode is not None: mode = AnimationMode[args.mode] self.cmd.set_animation_mode(mode) print("Animation mode change success.") print(color_string((CY, "Do not forget to store your settings in flash!"))) else: print(AnimationMode(self.cmd.get_animation_mode())) @hw_settings.command("sleeptimeout") class HWSettingsSleepTimeout(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get or set the wake timeout after a button press (5-60 seconds)" parser.add_argument( "-s", "--seconds", type=int, required=False, help="Wake timeout in seconds (5-60)", metavar="SECONDS", ) return parser def on_exec(self, args: argparse.Namespace): if args.seconds is not None: seconds = args.seconds if seconds < 5: print(color_string((CR, "Error: value is too low. Please enter a value between 5 and 60 seconds."))) return if seconds > 60: print(color_string((CR, "Error: value is too high. Please enter a value between 5 and 60 seconds."))) return if seconds >= 30: print(color_string((CY, "Warning: a long wake timeout will drain the battery faster."))) self.cmd.set_sleep_timeout(seconds) print(f"Wake timeout set to {seconds} seconds.") print(color_string((CY, "Do not forget to store your settings in flash!"))) else: current = self.cmd.get_sleep_timeout() print(f"Current wake timeout: {current} seconds") @hw_settings.command("bleclearbonds") class HWSettingsBleClearBonds(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Clear all BLE bindings. Warning: effect is immediate!" parser.add_argument( "--force", default=False, action="store_true", help="Just to be sure" ) return parser def on_exec(self, args: argparse.Namespace): if not args.force: print( "If you are you really sure, read the command documentation to see how to proceed." ) return self.cmd.delete_all_ble_bonds() print(" - Successfully clear all bonds") @hw_settings.command("store") class HWSettingsStore(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Store current settings to flash" return parser def on_exec(self, args: argparse.Namespace): print("Storing settings...") if self.cmd.save_settings(): print(" - Store success @.@~") else: print(" - Store failed") @hw_settings.command("reset") class HWSettingsReset(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Reset settings to default values" parser.add_argument( "--force", default=False, action="store_true", help="Just to be sure" ) return parser def on_exec(self, args: argparse.Namespace): if not args.force: print( "If you are you really sure, read the command documentation to see how to proceed." ) return print("Initializing settings...") if self.cmd.reset_settings(): print(" - Reset success @.@~") else: print(" - Reset failed") @hw.command("factory_reset") class HWFactoryReset(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = ( "Wipe all slot data and custom settings and return to factory settings" ) parser.add_argument( "--force", default=False, action="store_true", help="Just to be sure" ) return parser def on_exec(self, args: argparse.Namespace): if not args.force: print( "If you are you really sure, read the command documentation to see how to proceed." ) return if self.cmd.wipe_fds(): print(" - Reset successful! Please reconnect.") # let time for comm thread to close port time.sleep(0.1) else: print(" - Reset failed!") @hw.command("battery") class HWBatteryInfo(DeviceRequiredUnit): # How much remaining battery is considered low? BATTERY_LOW_LEVEL = 30 def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get battery information, voltage and level" return parser def on_exec(self, args: argparse.Namespace): voltage, percentage = self.cmd.get_battery_info() print(" - Battery information:") print(f" voltage -> {voltage} mV") print(f" percentage -> {percentage}%") if percentage < HWBatteryInfo.BATTERY_LOW_LEVEL: print(color_string((CR, "[!] Low battery, please charge."))) @hw_settings.command("btnpress") class HWButtonSettingsGet(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get or set button press function of Button A and Button B" button_group = parser.add_mutually_exclusive_group() button_group.add_argument("-a", "-A", action="store_true", help="Button A") button_group.add_argument("-b", "-B", action="store_true", help="Button B") duration_group = parser.add_mutually_exclusive_group() duration_group.add_argument( "-s", "--short", action="store_true", help="Short-press (default)" ) duration_group.add_argument( "-l", "--long", action="store_true", help="Long-press" ) function_names = [f.name for f in list(ButtonPressFunction)] function_descs = [f"{f.name} ({f})" for f in list(ButtonPressFunction)] help_str = "Function: " + ", ".join(function_descs) parser.add_argument( "-f", "--function", type=str, required=False, help=help_str, metavar="FUNCTION", choices=function_names, ) return parser def on_exec(self, args: argparse.Namespace): if args.function is not None: function = ButtonPressFunction[args.function] if not args.a and not args.b: print( color_string( (CR, "You must specify which button you want to change") ) ) return if args.a: button = ButtonType.A else: button = ButtonType.B if args.long: self.cmd.set_long_button_press_config(button, function) else: self.cmd.set_button_press_config(button, function) print( f" - Successfully set function '{function}'" f" to Button {button.name} {'long-press' if args.long else 'short-press'}" ) print(color_string((CY, "Do not forget to store your settings in flash!"))) else: if args.a: button_list = [ButtonType.A] elif args.b: button_list = [ButtonType.B] else: button_list = list(ButtonType) for button in button_list: if not args.long: resp = self.cmd.get_button_press_config(button) button_fn = ButtonPressFunction(resp) print(f"{color_string((CG, f'{button.name} short'))}: {button_fn}") if not args.short: resp_long = self.cmd.get_long_button_press_config(button) button_long_fn = ButtonPressFunction(resp_long) print( f"{color_string((CG, f'{button.name} long'))}: {button_long_fn}" ) print("") @hw_settings.command("blekey") class HWSettingsBLEKey(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get or set the ble connect key" parser.add_argument( "-k", "--key", required=False, help="Ble connect key for your device" ) return parser def on_exec(self, args: argparse.Namespace): key = self.cmd.get_ble_pairing_key() print(f" - The current key of the device(ascii): {color_string((CG, key))}") if args.key is not None: if len(args.key) != 6: print( f" - {color_string((CR, 'The ble connect key length must be 6'))}" ) return if re.match(r"[0-9]{6}", args.key): self.cmd.set_ble_connect_key(args.key) print( f" - Successfully set ble connect key to : {color_string((CG, args.key))}" ) print( color_string((CY, "Do not forget to store your settings in flash!")) ) else: print( f" - {color_string((CR, 'Only 6 ASCII characters from 0 to 9 are supported.'))}" ) @hw_settings.command("blepair") class HWBlePair(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Show or configure BLE pairing" set_group = parser.add_mutually_exclusive_group() set_group.add_argument( "-e", "--enable", action="store_true", help="Enable BLE pairing" ) set_group.add_argument( "-d", "--disable", action="store_true", help="Disable BLE pairing" ) return parser def on_exec(self, args: argparse.Namespace): is_pairing_enable = self.cmd.get_ble_pairing_enable() enabled_str = color_string((CG, "Enabled")) disabled_str = color_string((CR, "Disabled")) if not args.enable and not args.disable: if is_pairing_enable: print(f" - BLE pairing: {enabled_str}") else: print(f" - BLE pairing: {disabled_str}") elif args.enable: if is_pairing_enable: print(color_string((CY, "BLE pairing is already enabled."))) return self.cmd.set_ble_pairing_enable(True) print(f" - Successfully change ble pairing to {enabled_str}.") print(color_string((CY, "Do not forget to store your settings in flash!"))) elif args.disable: if not is_pairing_enable: print(color_string((CY, "BLE pairing is already disabled."))) return self.cmd.set_ble_pairing_enable(False) print(f" - Successfully change ble pairing to {disabled_str}.") print(color_string((CY, "Do not forget to store your settings in flash!"))) @hw.command("raw") class HWRaw(DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Send raw command" cmd_names = sorted([c.name for c in list(Command)]) help_str = "Command: " + ", ".join(cmd_names) command_group = parser.add_mutually_exclusive_group(required=True) command_group.add_argument( "-c", "--command", type=str, metavar="COMMAND", help=help_str, choices=cmd_names, ) command_group.add_argument( "-n", "--num_command", type=int, metavar="", help="Numeric command ID: ", ) parser.add_argument( "-d", "--data", type=str, help="Data to send", default="", metavar="" ) parser.add_argument( "-t", "--timeout", type=int, help="Timeout in seconds", default=3, metavar="", ) return parser def on_exec(self, args: argparse.Namespace): if args.command is not None: command = Command[args.command] else: # We accept not-yet-known command ids as "hw raw" is meant for debugging command = args.num_command response = self.cmd.device.send_cmd_sync( command, data=bytes.fromhex(args.data), status=0x0, timeout=args.timeout ) print(" - Received:") try: command = Command(response.cmd) print(f" Command: {response.cmd} {command.name}") except ValueError: print(f" Command: {response.cmd} (unknown)") status_string = f" Status: {response.status:#02x}" try: status = Status(response.status) status_string += f" {status.name}" status_string += f": {str(status)}" except ValueError: pass print(status_string) if response.data: print(f" Data (HEX): {response.data.hex()}") else: print(f" Data (HEX): (none)") @hf_14a.command("raw") class HF14ARaw(ReaderRequiredUnit): def bool_to_bit(self, value): return 1 if value else 0 def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.formatter_class = argparse.RawDescriptionHelpFormatter parser.description = "Send raw command" parser.add_argument( "-a", "--activate-rf", help="Active signal field ON without select", action="store_true", default=False, ) parser.add_argument( "-s", "--select-tag", help="Active signal field ON with select", action="store_true", default=False, ) # TODO: parser.add_argument('-3', '--type3-select-tag', # help="Active signal field ON with ISO14443-3 select (no RATS)", action='store_true', default=False,) parser.add_argument( "-d", "--data", type=str, metavar="", help="Data to be sent" ) parser.add_argument( "-b", "--bits", type=int, metavar="", help="Number of bits to send. Useful for send partial byte", ) parser.add_argument( "-c", "--crc", help="Calculate and append CRC", action="store_true", default=False, ) parser.add_argument( "-r", "--no-response", help="Do not read response", action="store_true", default=False, ) parser.add_argument( "-cc", "--crc-clear", help="Verify and clear CRC of received data", action="store_true", default=False, ) parser.add_argument( "-k", "--keep-rf", help="Keep signal field ON after receive", action="store_true", default=False, ) parser.add_argument( "-t", "--timeout", type=int, metavar="", help="Timeout in ms", default=100, ) parser.epilog = """ examples/notes: hf 14a raw -b 7 -d 40 -k hf 14a raw -d 43 -k hf 14a raw -d 3000 -c hf 14a raw -sc -d 6000 """ return parser def on_exec(self, args: argparse.Namespace): options = { "activate_rf_field": self.bool_to_bit(args.activate_rf), "wait_response": self.bool_to_bit(not args.no_response), "append_crc": self.bool_to_bit(args.crc), "auto_select": self.bool_to_bit(args.select_tag), "keep_rf_field": self.bool_to_bit(args.keep_rf), "check_response_crc": self.bool_to_bit(args.crc_clear), # 'auto_type3_select': self.bool_to_bit(args.type3-select-tag), } data: str = args.data if data is not None: data = data.replace(" ", "") if re.match(r"^[0-9a-fA-F]+$", data): if len(data) % 2 != 0: print( f" [!] {color_string((CR, 'The length of the data must be an integer multiple of 2.'))}" ) return else: data_bytes = bytes.fromhex(data) else: print(f" [!] {color_string((CR, 'The data must be a HEX string'))}") return else: data_bytes = [] if args.bits is not None and args.crc: print( f" [!] {color_string((CR, '--bits and --crc are mutually exclusive'))}" ) return # Exec 14a raw cmd. resp = self.cmd.hf14a_raw(options, args.timeout, data_bytes, args.bits) if len(resp) > 0: print( # print head " - " + # print data " ".join([hex(byte).replace("0x", "").rjust(2, "0") for byte in resp]) ) else: print(f" [*] {color_string((CY, 'No response'))}") @lf_em_4x05.command("read") class LFEm4x05Read(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = ( "Scan EM4x05 or EM4x69 tag (reader-talk-first) and print config, UID" ) return parser def on_exec(self, args: argparse.Namespace): try: pwd = int(args.pwd, 16) if hasattr(args, 'pwd') and args.pwd else 0 except ValueError: print(f"{CR}Invalid password, expected hex{C0}") return (config, uid, uid_hi, is_em4x69, uid_block) = self.cmd.em4x05_scan(pwd=pwd) tag_label = "EM4x69" if is_em4x69 else "EM4x05" rl = bool((config >> 6) & 1) print(f" Tag type : {CG}{tag_label}{C0}") print(f" Config : {CG}{config:#010x}{C0}") print(f" UID block: {CG}{uid_block}{C0}") if rl: print( f" Auth : {CG}LOGIN used (pwd={args.pwd.upper() if hasattr(args, 'pwd') and args.pwd else '00000000'}){C0}") if is_em4x69: uid64 = (uid_hi << 32) | uid print(f" UID (64) : {CG}{uid64:016x}{C0}") else: print(f" UID : {CG}{uid:08x}{C0}") @lf.command('sniff') class LFSniff(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = ( "Capture raw LF field ADC samples (125kHz, 8µs/sample). " "~0x80 = field on, lower values = gap or no field." ) parser.add_argument( '--timeout', type=int, default=2000, metavar='MS', help='Capture duration in milliseconds (default: 2000, max: 10000, firmware blocks for full duration)' ) parser.add_argument( '--out', type=str, default=None, metavar='FILE', help='Save raw samples to binary file (for offline analysis)' ) parser.add_argument( '--hex', action='store_true', help='Print hex dump of samples to screen' ) return parser def on_exec(self, args: argparse.Namespace): timeout = max(1, min(10000, args.timeout)) print(f" Capturing LF field for {timeout}ms at 125kHz (8µs/sample)...") resp = self.cmd.lf_sniff(timeout_ms=timeout) if resp.status != Status.LF_TAG_OK or not resp.data: print(f"{CR}No samples captured{C0}") return import chameleon_cli_unit as _self_mod data = bytes(resp.data) _self_mod._last_capture = data n = len(data) duration_ms = n * 8 / 1000 print(f" Captured : {CG}{n}{C0} bytes ({duration_ms:.1f}ms)") mn = min(data) mx = max(data) mean = sum(data) // len(data) print(f" Range : {CG}0x{mn:02x}{C0} – {CG}0x{mx:02x}{C0} mean: {CG}0x{mean:02x}{C0}") # Detect real field gaps — they drop to near zero (0x00-0x40), # well below the steady carrier (~0xb0). Use half of mean as threshold # to avoid false positives from the antenna startup transient. gap_threshold = mean // 2 # Skip first 200 samples (1.6ms) to ignore startup ringing steady_data = data[200:] gap_count = sum(1 for b in steady_data if b < gap_threshold) if gap_count > 0: print(f" Gaps : {CG}{gap_count}{C0} samples below 0x{gap_threshold:02x} (real field drops)") else: print(f" Gaps : {CR}none detected — flat carrier (no gap commands sent){C0}") if args.hex: print() print(f" addr {'hex bytes':47s} level") print(f" ---- {'-'*47} ----------------") for i in range(0, min(n, 256), 16): row = data[i:i+16] hex_part = ' '.join(f'{b:02x}' for b in row) bar = '' for b in row: if b < 0x10: bar += '_' # gap / field off elif b < 0x40: bar += '.' # ringing decay elif b < 0x80: bar += '-' # low elif b < 0xa0: bar += '+' # mid elif b < 0xc0: bar += 'o' # steady carrier elif b < 0xe0: bar += 'O' # high else: bar += '#' # clipped 0xff print(f" {i:04x} {hex_part:<47s} {bar}") if n > 256: print(f" ... ({n - 256} more bytes, use --out to save all)") print() print(" _ gap . ringing - low + mid o carrier O high # clipped") if args.out: try: with open(args.out, 'wb') as f: f.write(data) print(f" Saved : {CG}{args.out}{C0} ({n} bytes)") except Exception as e: print(f"{CR}Failed to save: {e}{C0}") @hf_14a.command("info") @hf_14a.command('sniff') class HF14ASniff(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = ( "Capture ISO14443A reader frames while CU acts as a tag. " "Place CU near a reader — all commands the reader sends are logged. " "Useful for understanding what a reader expects before configuring emulation." ) parser.add_argument( '--timeout', type=int, default=5000, metavar='MS', help='Listen duration in milliseconds (default: 5000, max: 30000, firmware blocks for full duration)' ) return parser def on_exec(self, args: argparse.Namespace): timeout = max(1, min(30000, args.timeout)) print(f" Listening for reader frames for {timeout}ms...") print(" Place CU near a reader now.") print() try: resp = self.cmd.hf14a_sniff(timeout_ms=timeout) except Exception as e: if 'CMDInvalid' in type(e).__name__ or '2020' in str(e): print(f"{CR}Command not supported — reflash firmware to enable hf 14a sniff{C0}") else: print(f"{CR}{e}{C0}") return if resp.status not in (Status.HF_TAG_OK, Status.SUCCESS): cb_count = 0 if resp.data and len(resp.data) >= 2: cb_count = (resp.data[0] << 8) | resp.data[1] if cb_count > 0: print(f"{CY} Callback fired {cb_count}x but no valid frames buffered{C0}") else: print(" No frames captured — no reader detected") return # Parse packed frame buffer: [2 bytes bits BE][N bytes data] ... # Bit 15 of szBits: 0 = reader→card, 1 = card→reader (new firmware). # Old firmware always sends bit15=0; parser is backward compatible. buf = bytes(resp.data) frames = [] # (szBits, data, is_tx, parity) i = 0 while i + 2 <= len(buf): hdr = (buf[i] << 8) | buf[i+1] i += 2 is_tx = bool(hdr & 0x8000) szBits = hdr & 0x7FFF if szBits == 0: break szBytes = (szBits + 7) // 8 if i + szBytes > len(buf): break raw = buf[i:i+szBytes] i += szBytes # parity array: parity for each byte of data array parity_bits = [] # ISO14443-A frames include one parity bit per byte. # Short frames (< 8 bits, e.g. REQA=7 bits) have no parity. # All other frames: szBits = data_bytes * 9, strip every 9th bit. if szBits >= 8 and szBits % 9 == 0: n_bytes = szBits // 9 all_bits = [] for byte in raw: for b in range(8): all_bits.append((byte >> b) & 1) stripped = [] for nb in range(n_bytes): val = 0 for b in range(8): val |= all_bits[nb * 9 + b] << b stripped.append(val) parity_bits.append(all_bits[nb * 9 + 8]) data = bytes(stripped) szBits = n_bytes * 8 else: data = raw frames.append((szBits, data, is_tx, parity_bits)) if not frames: print(f"{CR}No frames decoded{C0}") return rx_count = sum(1 for _, _, tx, _ in frames if not tx) tx_count = sum(1 for _, _, tx, _ in frames if tx) if tx_count > 0: print(f" Captured : {CG}{len(frames)}{C0} frame(s) " f"({CY}{rx_count}{C0} reader→card {CG}{tx_count}{C0} card→reader)") else: print(f" Captured : {CG}{len(frames)}{C0} frame(s) " f"{CY}(reader→card only — reflash for both directions){C0}") print() print(f" {'#':>3} {'dir':<3} {'bits':>4} {'hex data':<42} decoded") print(f" {'---':>3} {'---':<3} {'----':>4} {'-'*42} {'-'*35}") # Context for MIFARE Classic authentication decoding (NT / NR||AR) expect_nt = False expect_nr_ar = False last_auth_keytype = None last_auth_block = None for n, (szBits, data, is_tx, parity_bits) in enumerate(frames): if (len(data) == len(parity_bits)): hex_str = ' '.join(f"{b:02x}{'!' if odd_parity_byte(b)!= p else ' '}" for (b,p) in zip(data,parity_bits)) else: hex_str = ' '.join(f"{b:02x}" for b in data) # is_tx==True means CU transmitted (card -> reader). # is_tx==False means reader -> card. decoded_ctx = None col_ctx = None # Reader -> card: AUTH command (0x60/0x61 + block + CRC-A) if (not is_tx) and szBits == 32 and len(data) == 4 and data[0] in (0x60, 0x61): last_auth_keytype = 'A' if data[0] == 0x60 else 'B' last_auth_block = data[1] expect_nt = True expect_nr_ar = False decoded_ctx = f"MIFARE Classic AUTH Key{last_auth_keytype} block=0x{last_auth_block:02X} ({last_auth_block})" col_ctx = CG # Card -> reader: NT (32-bit) immediately after AUTH elif is_tx and expect_nt and szBits == 32 and len(data) == 4: nt = data.hex() decoded_ctx = f"AUTH: NT (card nonce) = {nt}" col_ctx = CG expect_nt = False expect_nr_ar = True # Reader -> card: NR||AR (64-bit) immediately after NT (encrypted) elif (not is_tx) and expect_nr_ar and szBits == 64 and len(data) == 8: nr = data[:4].hex() ar = data[4:].hex() decoded_ctx = f"AUTH continuation: NR||AR (enc) NR={nr} AR={ar}" col_ctx = CG expect_nr_ar = False # Fallback to generic frame decoder decoded, col = _decode_14a_frame_col(data, szBits) if decoded_ctx is not None: decoded, col = decoded_ctx, col_ctx dir_str = f'{CG}<<<{C0}' if is_tx else f'{CY}>>>{C0}' print(f" {CY}{n+1:>3}{C0} {dir_str} {szBits:>4} {hex_str:<42} {col}{decoded}{C0}") # Summary block (pass only reader→card frames for protocol decode) print() _print_14a_sniff_summary([(szBits, data, is_tx) for (szBits, data, is_tx,parity) in frames]) # full frames needed for nonce extraction @hf_14a.command("auth-trace") class HF14AAuthTrace(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.formatter_class = argparse.RawDescriptionHelpFormatter parser.description = ( "Run a full reader-side ISO14443A + MIFARE Classic Crypto1 auth " "against a real card and print every wire frame: REQA → ATQA → " "anticoll/SELECT → SAK → (RATS/ATS) → AUTH(0x60/0x61) → NT → " "NR||AR (enc) → AT (enc), with host-side Crypto1 decryption of " "the auth sub-frames for verification." ) parser.add_argument( "--blk", "--block", type=int, required=True, metavar="", help="Target block number" ) keytype_group = parser.add_mutually_exclusive_group() keytype_group.add_argument("-a", "-A", action="store_true", help="Use Key A (default)") keytype_group.add_argument("-b", "-B", action="store_true", help="Use Key B") parser.add_argument( "-k", "--key", type=str, required=True, metavar="", help="6-byte sector key (12 hex chars)" ) parser.add_argument( "-t", "--timeout", type=int, default=5000, metavar="", help="Tag-presence polling timeout in ms (1-30000, default 5000)" ) parser.epilog = """ examples: hf 14a auth-trace --blk 0 -k FFFFFFFFFFFF hf 14a auth-trace --blk 4 -b -k A0A1A2A3A4A5 hf 14a auth-trace --blk 0 -k FFFFFFFFFFFF -t 10000 # wait up to 10s for tag """ return parser def on_exec(self, args: argparse.Namespace): # Validate key key_hex = args.key.replace(" ", "") if not re.match(r"^[0-9a-fA-F]{12}$", key_hex): print(f" [!] {color_string((CR, 'Key must be exactly 12 hex characters'))}") return key_bytes = bytes.fromhex(key_hex) key_type = 0x61 if args.b else 0x60 block = args.blk timeout_ms = max(1, min(30000, int(args.timeout))) print(f" Running auth trace: block={block} keyType={'B' if args.b else 'A'} key={key_hex.upper()}") print(f" Waiting up to {timeout_ms} ms for a MIFARE Classic card... " f"({CY}place CU on a card now{C0})") print() try: resp = self.cmd.hf14a_auth_trace(block, key_type, key_bytes, timeout_ms=timeout_ms) except Exception as e: if 'CMDInvalid' in type(e).__name__ or '2017' in str(e): print(f"{CR}Command not supported — reflash firmware to enable hf 14a auth-trace{C0}") else: print(f"{CR}{e}{C0}") return # Status legend: # HF_TAG_OK — auth succeeded, full trace # HF_TAG_NO — no card / scan failed # MF_ERR_AUTH — auth failed (wrong key / wrong block), partial trace returned # HF_ERR_STAT — no NT received, partial trace returned if resp.status == Status.HF_TAG_NO: print(f"{CR} No 14443A tag in field — auth aborted{C0}") return if not resp.data: print(f"{CR} No frames returned (status={Status(resp.status).name}){C0}") return # Parse packed frame buffer — same format as hf 14a sniff. # [bits_be16][data...]; bit15 of bits = direction (1 = card→reader). buf = bytes(resp.data) frames = [] # (szBits, data, is_tx) i = 0 while i + 2 <= len(buf): hdr = (buf[i] << 8) | buf[i + 1] i += 2 is_tx = bool(hdr & 0x8000) szBits = hdr & 0x7FFF if szBits == 0: break szBytes = (szBits + 7) // 8 if i + szBytes > len(buf): break raw = buf[i:i + szBytes] i += szBytes # Auth-trace stores parity-stripped data (firmware byte/bits # transfer primitives strip parity automatically), so szBits is # always a multiple of 8 — no unwrap needed. frames.append((szBits, raw, is_tx)) if not frames: print(f"{CR}No frames decoded{C0}") return # Header rx_count = sum(1 for _, _, tx in frames if not tx) tx_count = sum(1 for _, _, tx in frames if tx) status_label = { Status.HF_TAG_OK: f"{CG}auth OK{C0}", Status.MF_ERR_AUTH: f"{CR}auth FAILED{C0}", Status.HF_ERR_STAT: f"{CR}no NT received{C0}", }.get(Status(resp.status), f"{CY}status={Status(resp.status).name}{C0}") print(f" Captured : {CG}{len(frames)}{C0} frame(s) " f"({CY}{rx_count}{C0} reader→card {CG}{tx_count}{C0} card→reader) " f"{status_label}") print() print(f" {'#':>3} {'dir':<3} {'bits':>4} {'hex data':<42} decoded") print(f" {'---':>3} {'---':<3} {'----':>4} {'-' * 42} {'-' * 35}") # Auth-state tracker — annotate AUTH cmd, NT, NR||AR, AT specifically. auth_state = 'idle' # idle → cmd_seen → nt_seen → nr_ar_seen → done last_auth_keytype = None last_auth_block = None nt_int = None nr_ar_enc = None at_enc = None uid_bytes = b'' for n, (szBits, data, is_tx) in enumerate(frames): hex_str = ' '.join(f'{b:02x}' for b in data) decoded_ctx = None col_ctx = None # Capture UID from the first anticoll RX (CL1 response): 5 bytes. # If first byte is 0x88 it's a cascading 7-byte UID — second segment # gives bytes 3..6. For 4-byte UID, all four bytes are here. if is_tx and szBits == 40 and len(data) == 5 and not uid_bytes: if data[0] == 0x88: uid_bytes = data[1:4] # CT|UID0|UID1|UID2|BCC else: uid_bytes = data[0:4] elif is_tx and szBits == 40 and len(data) == 5 and len(uid_bytes) == 3: uid_bytes = uid_bytes + data[0:4] # 7-byte UID complete # AUTH cmd: 0x60/0x61 + block + 2 CRC bytes, reader→card, 32 bits if (not is_tx) and szBits == 32 and len(data) == 4 and data[0] in (0x60, 0x61): last_auth_keytype = 'A' if data[0] == 0x60 else 'B' last_auth_block = data[1] auth_state = 'cmd_seen' decoded_ctx = f"AUTH Key{last_auth_keytype} block=0x{last_auth_block:02X} ({last_auth_block}) +CRC" col_ctx = CG # NT: 4 bytes, card→reader, immediately after AUTH cmd elif is_tx and auth_state == 'cmd_seen' and szBits == 32 and len(data) == 4: nt_int = int.from_bytes(data, 'big') auth_state = 'nt_seen' decoded_ctx = f"NT (card nonce, plaintext) = {data.hex().upper()}" col_ctx = CG # NR||AR encrypted: 8 bytes, reader→card, after NT elif (not is_tx) and auth_state == 'nt_seen' and szBits == 64 and len(data) == 8: nr_ar_enc = bytes(data) auth_state = 'nr_ar_seen' decoded_ctx = f"NR||AR (enc) NR={data[:4].hex().upper()} AR={data[4:].hex().upper()}" col_ctx = CG # AT encrypted: 4 bytes, card→reader, after NR||AR elif is_tx and auth_state == 'nr_ar_seen' and szBits == 32 and len(data) == 4: at_enc = bytes(data) auth_state = 'done' decoded_ctx = f"AT (enc) = {data.hex().upper()}" col_ctx = CG decoded, col = _decode_14a_frame_col(data, szBits) if decoded_ctx is not None: decoded, col = decoded_ctx, col_ctx dir_str = f'{CG}<<<{C0}' if is_tx else f'{CY}>>>{C0}' print(f" {CY}{n + 1:>3}{C0} {dir_str} {szBits:>4} {hex_str:<42} {col}{decoded}{C0}") # Crypto1 verification block — if we have NT + NR||AR, decrypt AR/AT # and confirm they match prng_successor(NT, 32) / prng_successor(NT, 64). print() if nt_int is not None and nr_ar_enc is not None and len(uid_bytes) >= 4: uid32 = int.from_bytes(uid_bytes[-4:], 'big') # last 4 bytes for cascade≥2 print(f" {CC}Crypto1 analysis:{C0}") print(f" UID (low 4 bytes) : {uid_bytes[-4:].hex().upper()}") print(f" NT (plaintext) : {nt_int:08X}") print(f" NR (fixed in fw) : 12345678 (encrypted on wire: {nr_ar_enc[:4].hex().upper()})") ar_expected = Crypto1.prng_next(nt_int, 64) at_expected = Crypto1.prng_next(nt_int, 96) if at_enc is not None: # Re-run Crypto1 forward to recover ks2 (AR keystream) and ks3 (AT keystream). state = Crypto1() state.key = key_hex state.lfsr48_u32(uid32 ^ nt_int, False) # ks0 state.lfsr48_u32(int.from_bytes(nr_ar_enc[:4], 'big'), True) # ks1 ks_ar = state.lfsr48_u32(0, False) # ks2 (AR keystream) ks_at = state.lfsr48_u32(0, False) # ks3 (AT keystream) ar_int = int.from_bytes(nr_ar_enc[4:], 'big') ar_decoded = ar_int ^ ks_ar ar_ok = (ar_decoded == ar_expected) ar_colour = CG if ar_ok else CR ar_mark = '✓' if ar_ok else '✗' print(f" AR expected : {ar_expected:08X} = prng_successor(NT, 64)") print(f" AR (encrypted) : {nr_ar_enc[4:].hex().upper()}") print(f" AR decrypted : {ar_colour}{ar_decoded:08X}{C0} {ar_colour}{ar_mark} " f"{'MATCH' if ar_ok else 'MISMATCH — wrong key or replay'}{C0}") at_int = int.from_bytes(at_enc, 'big') at_decoded = at_int ^ ks_at ok = (at_decoded == at_expected) colour = CG if ok else CR mark = '✓' if ok else '✗' print(f" AT expected : {at_expected:08X} = prng_successor(NT, 96)") print(f" AT (encrypted) : {at_enc.hex().upper()}") print(f" AT decrypted : {colour}{at_decoded:08X}{C0} {colour}{mark} " f"{'MATCH — auth verified' if ok else 'MISMATCH — wrong key or replay'}{C0}") # Cross-check against mfkey32 prediction too. nr_enc_int = int.from_bytes(nr_ar_enc[:4], 'big') ar_enc_int = int.from_bytes(nr_ar_enc[4:], 'big') key_match = Crypto1.mfkey32_is_reader_has_key(uid32, nt_int, nr_enc_int, ar_enc_int, key_hex) if key_match: print(f" mfkey32 forward : {CG}key {key_hex.upper()} verified against NT/NR/AR{C0}") else: print(f" {CR}AT not received — auth was rejected by the card{C0}") elif nt_int is not None: print(f" {CY}Auth aborted before NR||AR — NT={nt_int:08X}, no further analysis{C0}") def _decode_sw(sw1: int, sw2: int) -> str: """Decode an ISO 7816-4 status word pair.""" exact = { 0x9000: 'OK', 0x6100: 'Response bytes available', 0x6283: 'File deactivated', 0x6300: 'Auth failed', 0x6400: 'No changes', 0x6581: 'Memory failure', 0x6700: 'Wrong length', 0x6881: 'Logical channel not supported', 0x6882: 'Secure messaging not supported', 0x6900: 'Command not allowed', 0x6981: 'Command incompatible with file structure', 0x6982: 'Security status not satisfied', 0x6983: 'Auth method blocked', 0x6984: 'Referenced data invalidated', 0x6985: 'Conditions of use not satisfied', 0x6986: 'Command not allowed — no EF selected', 0x6A00: 'Wrong parameters P1-P2', 0x6A80: 'Incorrect data in command', 0x6A81: 'Function not supported', 0x6A82: 'File not found', 0x6A83: 'Record not found', 0x6A84: 'Not enough memory', 0x6A85: 'Lc inconsistent with TLV', 0x6A86: 'Incorrect parameters P1-P2', 0x6A87: 'Lc inconsistent with P1-P2', 0x6A88: 'Referenced data not found', 0x6B00: 'Wrong parameters P1-P2', 0x6D00: 'Instruction not supported', 0x6E00: 'Class not supported', 0x6F00: 'Unknown error', } key = (sw1 << 8) | sw2 if key in exact: return exact[key] if sw1 == 0x61: return f'Response bytes available: {sw2}' if sw1 == 0x62: return f'Warning — no info change: {sw2:02X}' if sw1 == 0x63: return f'Warning — state changed: {sw2:02X}' if sw1 == 0x6C: return f'Wrong Le — use {sw2}' if sw1 == 0x90: return 'OK' if sw1 == 0x91: return 'Proprietary OK' return '' def _decode_14a_frame_col(data: bytes, szBits: int): """Return (description, colour) for a 14A frame.""" if not data: return '', C0 b0 = data[0] # --------------------------------------------------------------------- # ISO14443-A "reply" frames that often show as "unknown" in hf 14a sniff # because the sniffer doesn't know if a frame is reader->card or card->reader. # We infer by payload length/bits and known structures. # --------------------------------------------------------------------- # ATQA: Answer To Request (Type A) - 2 bytes / 16 bits (tag -> reader). # Transmitted LSB first. Common MIFARE Classic ATQA is 0x0004 => '04 00'. # Be conservative: avoid mislabeling common commands like 0x93 0x20 (ANTICOLL). if szBits == 16 and len(data) == 2: not_atqa_prefixes = { 0x93, 0x95, 0x97, # ANTICOLL / SELECT cascade levels 0x50, # HLTA 0x60, 0x61, # MIFARE Classic AUTH 0x30, # READ 0xA0, 0xA2, # WRITE variants 0xE0, # RATS } if data[0] not in not_atqa_prefixes: atqa = data[0] | (data[1] << 8) # LSB first in air return f"ATQA (Answer To Request, Type A) = 0x{atqa:04X}", CG # SAK: Select Acknowledge — 1 byte on air, but the trace may include the # 2-byte CRC-A appended, giving 3 bytes / 24 bits total. if (szBits == 8 and len(data) == 1) or (szBits == 24 and len(data) == 3): sak = data[0] # Reuse existing NXP SAK classification table if present # (type_id_SAK_dict is defined near top of file). try: sak_type = type_id_SAK_dict.get(sak, "") except Exception: sak_type = "" if sak_type: return f"SAK (Select Acknowledge) = 0x{sak:02X} [{sak_type}]", CG return f"SAK (Select Acknowledge) = 0x{sak:02X}", CG # Anticollision CL1 response: 4 UID bytes + BCC = 5 bytes / 40 bits. # BCC is XOR of UID bytes. if szBits == 40 and len(data) == 5: uid0, uid1, uid2, uid3, bcc = data calc = uid0 ^ uid1 ^ uid2 ^ uid3 if calc == bcc: uid = bytes([uid0, uid1, uid2, uid3]).hex() return f"ANTICOLL CL1 response: UID={uid} BCC=0x{bcc:02X} (OK)", CG # If BCC doesn't match, still label it as anticoll-like, but warn. uid = bytes(data[:4]).hex() return f"ANTICOLL-like: UID={uid} BCC=0x{bcc:02X} (expected 0x{calc:02X})", CY # Short frames (7-bit) if szBits == 7: if b0 == 0x26: return 'REQA', CG if b0 == 0x52: return 'WUPA', CG return f'short(0x{b0:02x})', CC # Anti-collision / Select if b0 == 0x93: if len(data) > 1 and data[1] == 0x70: uid = ' '.join(f'{b:02x}' for b in data[2:6]) if len(data) >= 6 else '' return f'SELECT CL1 UID={uid}', CB nvb = f'NVB={data[1]:02x}' if len(data) > 1 else '' return f'ANTICOLL CL1 {nvb}', CB if b0 == 0x95: if len(data) > 1 and data[1] == 0x70: uid = ' '.join(f'{b:02x}' for b in data[2:6]) if len(data) >= 6 else '' return f'SELECT CL2 UID={uid}', CB nvb = f'NVB={data[1]:02x}' if len(data) > 1 else '' return f'ANTICOLL CL2 {nvb}', CB if b0 == 0x97: if len(data) > 1 and data[1] == 0x70: uid = ' '.join(f'{b:02x}' for b in data[2:6]) if len(data) >= 6 else '' return f'SELECT CL3 UID={uid}', CB nvb = f'NVB={data[1]:02x}' if len(data) > 1 else '' return f'ANTICOLL CL3 {nvb}', CB # HALT (0x50 0x00 + CRC — b1 may vary after parity strip) if b0 == 0x50: return 'HALT', CC # S-DESELECT (ISO14443-4 block) if b0 == 0xc2: return 'S-DESELECT', CC # PPS if b0 == 0xd0: return f'PPS PPS1={data[1]:02x}' if len(data) > 1 else 'PPS', CC # RATS if b0 == 0xe0: fsdi = (data[1] >> 4) if len(data) > 1 else 0 cid = (data[1] & 0xf) if len(data) > 1 else 0 return f'RATS FSDI={fsdi} CID={cid}', CC # MIFARE Classic commands if b0 == 0x60: return f'AUTH KeyA block={data[1]}' if len(data) > 1 else 'AUTH KeyA', CR if b0 == 0x61: return f'AUTH KeyB block={data[1]}' if len(data) > 1 else 'AUTH KeyB', CR # Encrypted nonce / auth response (follows AUTH, first byte varies) if szBits == 72: return '(encrypted nonce — auth challenge/response)', CC if b0 == 0x30: return f'READ block={data[1]}' if len(data) > 1 else 'READ', CC if b0 == 0xa0: return f'WRITE block={data[1]}' if len(data) > 1 else 'WRITE', CY if b0 == 0x40: return 'MAGIC WUPC1', CY if b0 == 0x43: return 'MAGIC WUPC2', CY if b0 == 0x41: return 'MAGIC WIPE', CR # ISO 7816-4 APDUs if len(data) >= 2 and b0 in (0x00, 0x80, 0x90, 0xa0): cla, ins = data[0], data[1] p1 = data[2] if len(data) > 2 else 0 p2 = data[3] if len(data) > 3 else 0 # SELECT FILE / AID if cla == 0x00 and ins == 0xa4: if len(data) > 5: aid = ' '.join(f'{b:02x}' for b in data[5:5+data[4]]) # Identify known AIDs aid_raw = bytes(data[5:5+data[4]]) name = _known_aid(aid_raw) label = f'SELECT AID {aid.upper()}' if name: label += f' ({name})' return label, CY return 'SELECT', CY # READ BINARY if cla == 0x00 and ins == 0xb0: return f'READ BINARY off={p1 << 8 | p2} len={data[4] if len(data) > 4 else 0}', CC # READ RECORD if cla == 0x00 and ins == 0xb2: sfi = p2 >> 3 return f'READ RECORD SFI={sfi} rec={p1}', CC # GET DATA if cla == 0x80 and ins == 0xca: tag = (p1 << 8) | p2 name = _known_bertag(tag) return f'GET DATA {p1:02x}{p2:02x}' + (f' ({name})' if name else ''), CC # GET PROCESSING OPTIONS if cla == 0x80 and ins == 0xa8: return 'GPO (Get Processing Options)', CY # GENERATE AC if cla == 0x80 and ins == 0xae: actype = {0x00: 'AAC', 0x40: 'TC', 0x80: 'ARQC'}.get(p1 & 0xc0, f'AC/{p1:02x}') return f'GENERATE AC requesting {actype}', CR # VERIFY if cla == 0x00 and ins == 0x20: return 'VERIFY PIN', CY # INTERNAL AUTHENTICATE if cla == 0x00 and ins == 0x88: return 'INTERNAL AUTH', CR # EXTERNAL AUTHENTICATE if cla == 0x00 and ins == 0x82: return 'EXTERNAL AUTH', CR # MANAGE CHANNEL if cla == 0x00 and ins == 0x70: return 'MANAGE CHANNEL', CC return f'APDU CLA={cla:02x} INS={ins:02x} P1={p1:02x} P2={p2:02x}', CY # ISO 7816-4 status word — scan last 2 bytes (and last 4 if CRC present) sw_label = '' for sw_offset in (-2, -4): if len(data) >= abs(sw_offset): s1, s2 = data[sw_offset], data[sw_offset + 1] lbl = _decode_sw(s1, s2) if lbl: sw_label = f'SW {s1:02X} {s2:02X} {lbl}' break if sw_label: return sw_label, CY # Unknown — show first byte return f'unknown (0x{b0:02x})', CC def _known_aid(aid: bytes) -> str: table = { bytes.fromhex('a0000000031010'): 'Visa Credit/Debit', bytes.fromhex('a0000000032010'): 'Visa Electron', bytes.fromhex('a0000000033010'): 'Visa Classic', bytes.fromhex('a0000000038010'): 'Visa Plus', bytes.fromhex('a0000000041010'): 'Mastercard', bytes.fromhex('a0000000043060'): 'Maestro', bytes.fromhex('a000000025010801'): 'AmEx', bytes.fromhex('a0000000181002'): 'Mastercard Debit', bytes.fromhex('d2760000850101'): 'NDEF (NFC Forum)', bytes.fromhex('d27600002545'): 'NDEF Type 4', bytes.fromhex('315041592e5359532e4444463031'): 'PPSE (2PAY.SYS.DDF01)', } return table.get(aid, '') def _known_bertag(tag: int) -> str: table = { 0x9f36: 'ATC', 0x9f13: 'Last Online ATC', 0x9f17: 'PIN Try Counter', 0x9f4f: 'Log Format', 0x9f4e: 'Merchant Name', } return table.get(tag, '') def _extract_sniff_nonces(frames): """ Extract MIFARE Classic auth nonces from a complete frame list (reader + card). Walks frames looking for the 3-pass auth pattern: 1. reader→card AUTH (0x60/0x61 + block) -- not is_tx 2. card→reader nt (4 bytes, tag nonce) -- is_tx 3. reader→card {nr}{ar} (8 bytes) -- not is_tx The current UID is tracked from SELECT (NVB=0x70) reader→card frames. Returns a list of dicts: { uid, block, key_type, nt, nr, ar } Paired nonces for the same (uid, block, key_type) appear consecutively. """ nonces = [] uid_hex = None for i, (szBits, data, is_tx) in enumerate(frames): if not data: continue # Track UID from completed SELECT (NVB=0x70), reader→card if (not is_tx and data[0] in (0x93, 0x95, 0x97) and len(data) >= 6 and data[1] == 0x70): # bytes [2:6] = UID0..UID3; skip cascade byte 0x88 for multi-level UIDs if not (data[0] == 0x93 and data[2] == 0x88): uid_hex = ''.join(f'{b:02X}' for b in data[2:6]) # AUTH command: reader→card, 0x60 (KeyA) or 0x61 (KeyB) if not is_tx and data[0] in (0x60, 0x61) and len(data) >= 2: key_type = 'A' if data[0] == 0x60 else 'B' block = data[1] # frame i+1: card→reader, exactly 4 bytes = nt (tag nonce) if i + 1 >= len(frames): continue _, d1, tx1 = frames[i + 1] if not tx1 or len(d1) != 4: continue nt_hex = ''.join(f'{b:02X}' for b in d1) # frame i+2: reader→card, exactly 8 bytes = {nr} || {ar} if i + 2 >= len(frames): continue _, d2, tx2 = frames[i + 2] if tx2 or len(d2) != 8: continue nr_hex = ''.join(f'{b:02X}' for b in d2[:4]) ar_hex = ''.join(f'{b:02X}' for b in d2[4:]) nonces.append({ 'uid': uid_hex or '00000000', 'block': block, 'key_type': key_type, 'nt': nt_hex, 'nr': nr_hex, 'ar': ar_hex, }) return nonces def _print_14a_sniff_summary(frames): """Print a decoded summary of the sniff session.""" uid_cl1 = None uid_cl2 = None uid_cl3 = None aids = [] auth_blocks = [] # (key_type, block) auth_seen = False arqc_seen = False tc_seen = False halted = False rats_seen = False atc_tag = None amount = None for szBits, data, is_tx in frames: if not data or is_tx: # protocol decode uses reader→card frames only continue b0 = data[0] # Extract UID from anticoll frames (NVB != 70 = anticoll, NVB = 70 = select) # Anticoll frame with NVB=41 means we're requesting UID bytes # The *response* from the tag contains the UID — but we only see reader frames # So extract from SELECT (NVB=70) which contains the full UID if b0 in (0x93, 0x95, 0x97) and len(data) >= 5 and data[1] == 0x70: uid_bytes = bytes(data[2:6]) if b0 == 0x93: if data[2] == 0x88: uid_cl1 = None # cascade tag, UID continues in CL2 else: uid_cl1 = uid_bytes elif b0 == 0x95: uid_cl2 = uid_bytes elif b0 == 0x97: uid_cl3 = uid_bytes # Extract partial UID from anticoll frames — the tag sends back UID bytes # We capture the reader's anticoll command which may contain partial UID # NVB high nibble = number of full bytes sent, low nibble = bits # NVB=41 means reader sent 4 bits, so tag should respond with rest # NVB=e1 (225) is unusual — may be tag response captured by NFCT # RATS if b0 == 0xe0: rats_seen = True # SELECT AID if b0 == 0x00 and len(data) > 5 and data[1] == 0xa4: aid = bytes(data[5:5+data[4]]) name = _known_aid(aid) entry = aid.hex().upper() if name: entry += f' ({name})' if entry not in aids: aids.append(entry) # MIFARE Classic auth if b0 in (0x60, 0x61) and len(data) > 1: auth_seen = True key_type = 'KeyA' if b0 == 0x60 else 'KeyB' block = data[1] if (key_type, block) not in auth_blocks: auth_blocks.append((key_type, block)) # GENERATE AC — check AC type if b0 == 0x80 and len(data) > 2 and data[1] == 0xae: if (data[2] & 0xc0) == 0x80: arqc_seen = True if (data[2] & 0xc0) == 0x40: tc_seen = True # GET DATA — ATC if b0 == 0x80 and len(data) > 2 and data[1] == 0xca: tag = (data[2] << 8) | data[3] atc_tag = _known_bertag(tag) or f'{data[2]:02x}{data[3]:02x}' # GPO — extract amount if PDOL present if b0 == 0x80 and len(data) > 4 and data[1] == 0xa8: # Amount is usually first 6 bytes of PDOL data at offset 4+ if len(data) >= 11: amt_bytes = data[5:11] amt = int.from_bytes(amt_bytes, 'big') if amt > 0: amount = amt # HALT / DESELECT if b0 == 0x50 or b0 == 0xc2: halted = True # Build UID from cascade levels uid_bytes = None if uid_cl1 and uid_cl2: uid_bytes = uid_cl1 + uid_cl2 if uid_cl3: uid_bytes = uid_bytes + uid_cl3 elif uid_cl1: uid_bytes = uid_cl1 # Do NOT attempt to extract UID from anticoll frames (81-bit NVB=e1): # those frames are the CU's own emulated tag responding, so the UID # would always be the active slot's UID — not useful information. # Only report UID when we see a completed SELECT (NVB=70). print(f" {'─'*55}") if uid_bytes: uid_str = ' '.join(f'{b:02X}' for b in uid_bytes) cascade = f' ({len(uid_bytes)}-byte UID)' if uid_bytes else '' print(f" {CC}UID :{C0} {CG}{uid_str}{cascade}{C0}") if rats_seen: print(f" {CC}Protocol :{C0} ISO14443-4 (RATS seen)") for aid in aids: print(f" {CC}AID :{C0} {CY}{aid}{C0}") if amount is not None: major = amount // 100 minor = amount % 100 print(f" {CC}Amount :{C0} {CG}{major}.{minor:02d}{C0} (raw={amount})") if auth_blocks: for key_type, block in auth_blocks: print(f" {CC}Auth :{C0} {CR}MIFARE Classic {key_type} block={block}{C0}") elif auth_seen: print(f" {CC}Auth :{C0} {CR}MIFARE Classic auth detected{C0}") if arqc_seen: print(f" {CC}Auth type:{C0} {CR}ARQC — online authorisation requested{C0}") if tc_seen: print(f" {CC}Auth type:{C0} {CG}TC — approved offline{C0}") if atc_tag: print(f" {CC}ATC :{C0} tag {atc_tag} (transaction counter)") if halted: print(f" {CC}End :{C0} HALT / DESELECT") if not uid_bytes and not aids and not auth_seen and not rats_seen: print(f" {CC}Note :{C0} anti-collision incomplete — no SELECT seen (reader could not complete exchange)") # ── Nonce cracking ───────────────────────────────────────────────────── nonces = _extract_sniff_nonces(frames) if nonces: from collections import defaultdict groups = defaultdict(list) for n in nonces: groups[(n['uid'], n['block'], n['key_type'])].append(n) print() print(f" {'-'*55}") total = sum(len(v) for v in groups.values()) print(f" {CC}Nonces :{C0} {total} auth exchange(s) captured") for (uid, block, kt), ns in groups.items(): print(f" Block {block} Key {kt} uid={uid}") for idx, n in enumerate(ns): print(f" [{idx}] nt={n['nt']} nr={n['nr']} ar={n['ar']}") if len(ns) >= 2: # Case A: two paired incomplete auths. # ns[1].nt is the {at} the CU sent after exchange 0's {nr}/{ar}; # the reader treated it as the fresh nt for the next auth round. # mfkey64 is deterministic; mfkey32v2 is a probabilistic fallback. n0, n1 = ns[0], ns[1] cmd64 = (f"mfkey64 {uid} {n0['nt']} {n0['nr']} {n0['ar']} {n1['nt']}") cmd32 = (f"mfkey32v2 {uid} {n0['nt']} {n0['nr']} {n0['ar']}" f" {n1['nt']} {n1['nr']} {n1['ar']}") # Always print both command strings so the user can run them # manually even when the binaries are unavailable/blocked. print(f" {CC}mfkey64 :{C0} {cmd64}") print(f" {CC}mfkey32v2:{C0} {cmd32}") key = _run_mfkey64(uid, n0['nt'], n0['nr'], n0['ar'], n1['nt']) if key not in (_TOOL_MISSING, _TOOL_BLOCKED, _TOOL_NO_KEY): print(f" {CG}Key: [{key.upper()}]{C0}") elif key == _TOOL_MISSING: print(f" {CY}mfkey64 binary not found in bin/ — " f"copy the command above and run it manually{C0}") elif key == _TOOL_BLOCKED: print(f" {CY}mfkey64 could not be executed " f"(antivirus / permissions) — " f"run the command above manually{C0}") else: # _TOOL_NO_KEY — mfkey64 ran but found nothing (rare); # try mfkey32v2 as probabilistic fallback. result32 = _run_mfkey32v2_sniff(n0, n1) if result32 not in (_TOOL_MISSING, _TOOL_BLOCKED, _TOOL_NO_KEY): print(f" {CG}Key: [{result32.upper()}]{C0} (via mfkey32v2)") elif result32 == _TOOL_MISSING: print(f" {CY}mfkey64 found no key and mfkey32v2 is not in bin/ — " f"run the commands above manually{C0}") elif result32 == _TOOL_BLOCKED: print(f" {CY}mfkey64 found no key and mfkey32v2 could not be " f"executed (antivirus / permissions) — " f"run the commands above manually{C0}") else: print(f" {CR}Key not found by either tool — " f"capture more nonce exchanges and retry{C0}") elif len(ns) == 1: # Single capture — can't crack without a paired exchange n = ns[0] print(f" {CY}Only one exchange captured — " f"need a second auth to crack{C0}") print(f" {CC}When paired, run:{C0} " f"mfkey64 {uid} {n['nt']} {n['nr']} {n['ar']} ") def _get_capture(): """Return last capture buffer or print error.""" import chameleon_cli_unit as _m if not _m._last_capture: return None return _m._last_capture @data.command('hexsamples') class DataHexsamples(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'Dump last LF sniff capture as hex bytes (PM3 style)' parser.add_argument('-n', '--num', type=int, default=512, metavar='N', help='Number of bytes to display (default: 512)') return parser def on_exec(self, args: argparse.Namespace): buf = _get_capture() if buf is None: print(f"{CR}No capture in buffer — run lf sniff first{C0}") return n = min(args.num, len(buf)) print(f" Buffer: {CG}{len(buf)}{C0} bytes total, showing {n}") print() for row in range(0, n, 16): chunk = buf[row:row+16] hex_part = ' '.join(f'{b:02x}' for b in chunk) bar = '' for b in chunk: if b < 0x10: bar += '_' elif b < 0x40: bar += '.' elif b < 0x80: bar += '-' elif b < 0xa0: bar += '+' elif b < 0xc0: bar += 'o' elif b < 0xe0: bar += 'O' else: bar += '#' print(f" {row // 16:02d} | {hex_part:<47s} | {bar}") print() print(" _ gap . ringing - low + mid o carrier O high # clipped") @data.command('plot') class DataPlot(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'Graphical waveform plot of last LF sniff capture (PyQt5 or matplotlib)' parser.add_argument('--start', type=int, default=0, metavar='N', help='Start sample (default: 0)') parser.add_argument('--len', type=int, default=4000, metavar='N', help='Number of samples to plot (default: all)') parser.add_argument('--ascii', action='store_true', help='Force ASCII plot even if GUI is available') return parser def on_exec(self, args: argparse.Namespace): buf = _get_capture() if buf is None: print(f"{CR}No capture in buffer — run lf sniff first{C0}") return start = max(0, args.start) end = min(len(buf), start + args.len) view = list(buf[start:end]) n = len(view) # X axis: time in µs (1 sample = 8µs) xs = [((start + i) * 8) for i in range(n)] mean = sum(view) // n threshold = mean // 2 if not args.ascii: # Try PyQt5 first, then matplotlib try: from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget from PyQt5.QtCore import Qt import pyqtgraph as pg _plot_pyqtgraph(xs, view, mean, threshold, start, end) return except ImportError: pass try: import matplotlib matplotlib.use('Qt5Agg') import matplotlib.pyplot as plt _plot_matplotlib(xs, view, mean, threshold, start, end) return except ImportError: pass try: import matplotlib.pyplot as plt _plot_matplotlib(xs, view, mean, threshold, start, end) return except ImportError: print(" No GUI library found (install PyQt5+pyqtgraph or matplotlib)") print(" Falling back to ASCII plot...") # ASCII fallback w = 64 bsize = max(1, n // w) buckets = [] for i in range(0, n, bsize): chunk = view[i:i+bsize] buckets.append(sum(chunk) // len(chunk)) buckets = buckets[:w] mn, mx = min(view), max(view) print(f" Samples {start}–{end} range 0x{mn:02x}–0x{mx:02x} mean 0x{mean:02x}") print() levels = [0xe0, 0xc0, 0xa0, 0x80, 0x60, 0x40, 0x20, 0x00] labels = ['0xff', '0xc0', '0xa0', '0x80', '0x60', '0x40', '0x20', '0x00'] for thresh, lbl in zip(levels, labels): row = ''.join('#' if v >= thresh else ' ' for v in buckets) print(f" {lbl} |{row}|") print(f" +{'-'*len(buckets)}+") def _plot_matplotlib(xs, ys, mean, threshold, start, end): import matplotlib.pyplot as plt import matplotlib.patches as mpatches fig, ax = plt.subplots(figsize=(14, 5)) fig.patch.set_facecolor('#1a1a2e') ax.set_facecolor('#0d1117') # Main waveform ax.plot(xs, ys, color='#00e5ff', linewidth=0.8, label='LF field') # Mean and gap threshold lines ax.axhline(mean, color='#ffb020', linewidth=0.8, linestyle='--', label=f'mean 0x{mean:02x}') ax.axhline(threshold, color='#ff3d57', linewidth=0.8, linestyle=':', label=f'gap threshold 0x{threshold:02x}') # Shade gap regions in_gap = False gap_start = 0 for i, v in enumerate(ys): if not in_gap and v < threshold: in_gap = True gap_start = xs[i] elif in_gap and v >= threshold: ax.axvspan(gap_start, xs[i], alpha=0.25, color='#ff3d57', linewidth=0) in_gap = False if in_gap: ax.axvspan(gap_start, xs[-1], alpha=0.25, color='#ff3d57', linewidth=0) ax.set_xlabel('Time (µs)', color='#8899b4') ax.set_ylabel('ADC value', color='#8899b4') ax.set_title(f'LF Sniff — samples {start}–{end} ({(end-start)*8}µs)', color='#dde8f5', fontsize=11) ax.set_ylim(0, 270) ax.set_xlim(xs[0], xs[-1]) ax.tick_params(colors='#8899b4') for spine in ax.spines.values(): spine.set_edgecolor('#21262d') ax.legend(facecolor='#161b22', edgecolor='#30363d', labelcolor='#c9d1d9', fontsize=8, loc='upper right') ax.grid(True, color='#21262d', linewidth=0.5) gap_patch = mpatches.Patch(color='#ff3d57', alpha=0.4, label='field gap') handles, labels = ax.get_legend_handles_labels() ax.legend(handles + [gap_patch], labels + ['field gap'], facecolor='#161b22', edgecolor='#30363d', labelcolor='#c9d1d9', fontsize=8, loc='upper right') plt.tight_layout() plt.show() def _plot_pyqtgraph(xs, ys, mean, threshold, start, end): import sys from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QLabel from PyQt5.QtCore import Qt from PyQt5.QtGui import QFont import pyqtgraph as pg pg.setConfigOption('background', '#0d1117') pg.setConfigOption('foreground', '#8899b4') app = QApplication.instance() or QApplication(sys.argv) win = pg.GraphicsLayoutWidget(title='ChameleonUltra — LF Sniff') win.resize(1200, 400) win.setWindowTitle(f'LF Sniff — samples {start}–{end} ({(end-start)*8}µs)') plot = win.addPlot() plot.setLabel('bottom', 'Time (µs)') plot.setLabel('left', 'ADC value') plot.showGrid(x=True, y=True, alpha=0.2) plot.setYRange(0, 270) # Waveform plot.plot(xs, ys, pen=pg.mkPen('#00e5ff', width=1)) # Mean line plot.addLine(y=mean, pen=pg.mkPen('#ffb020', width=1, style=pg.QtCore.Qt.DashLine)) # Gap threshold line plot.addLine(y=threshold, pen=pg.mkPen('#ff3d57', width=1, style=pg.QtCore.Qt.DotLine)) # Shade gaps for i in range(len(ys)-1): if ys[i] < threshold: r = pg.LinearRegionItem([xs[i], xs[i+1]], brush=pg.mkBrush(255, 61, 87, 40), pen=pg.mkPen(None), movable=False) plot.addItem(r) # Legend / info panel legend_text = ( '' '━ LF field (ADC)  ' '- - Mean  ' '··· Gap threshold (mean÷2)  ' '   ' ' Field gap (below threshold)  ' 'Ringing = exponential rise on field restore' '' ) legend = pg.LabelItem(legend_text, justify='left') win.addItem(legend, row=1, col=0) win.show() app.exec_() @data.command('manrawdecode') class DataManrawdecode(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'Manchester decode the last LF sniff capture' parser.add_argument('--clock', type=int, default=64, metavar='N', help='Clock divisor in Tc (default: 64 = RF/64)') parser.add_argument('--invert', action='store_true', help='Invert logic (high=0, low=1)') return parser def on_exec(self, args: argparse.Namespace): buf = _get_capture() if buf is None: print(f"{CR}No capture in buffer — run lf sniff first{C0}") return # Binarise: above mean = 1 (carrier), below = 0 (gap) mean = sum(buf) // len(buf) threshold = mean // 2 bits_raw = [1 if b > threshold else 0 for b in buf] if args.invert: bits_raw = [1 - b for b in bits_raw] # Find transitions and measure run lengths runs = [] cur = bits_raw[0] count = 1 for b in bits_raw[1:]: if b == cur: count += 1 else: runs.append((cur, count)) cur = b count = 1 runs.append((cur, count)) # Clock period in samples (1 sample = 8µs) half_clk = args.clock // 2 # samples per half-bit # Decode Manchester: half-bit transitions # Low->High = 0, High->Low = 1 (standard Manchester) decoded_bits = [] tol = max(2, half_clk // 3) i = 0 while i < len(runs): val, cnt = runs[i] # Short run = half period, long run = full period half = abs(cnt - half_clk) <= tol full = abs(cnt - args.clock) <= tol if half: # need next run to complete bit if i + 1 < len(runs): nval, ncnt = runs[i+1] nhalf = abs(ncnt - half_clk) <= tol if nhalf: # two halves: transition val->nval if val == 0 and nval == 1: decoded_bits.append(0) elif val == 1 and nval == 0: decoded_bits.append(1) i += 2 continue elif full: # biphase / stay same level for full period = repeated bit decoded_bits.append(val) i += 1 if not decoded_bits: print(f"{CR}No bits decoded — check clock rate or signal quality{C0}") print(f" Mean threshold: 0x{threshold:02x} Clock: RF/{args.clock}") return bits_str = ''.join(str(b) for b in decoded_bits) hex_str = hex(int(bits_str, 2))[2:] if decoded_bits else '' print(f" Clock : RF/{args.clock} ({args.clock} Tc = {args.clock*8}µs/bit)") print(f" Threshold: 0x{threshold:02x} Inverted: {args.invert}") print(f" Bits : {CG}{len(decoded_bits)}{C0}") print() # Print in rows of 64 for i in range(0, len(bits_str), 64): print(f" {bits_str[i:i+64]}") if hex_str: print() print(f" Hex: {CG}{hex_str[:64]}{C0}{'...' if len(hex_str) > 64 else ''}") @data.command('modulation') class DataModulation(BaseCLIUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'Detect clock rate and modulation type in last LF capture' return parser def on_exec(self, args: argparse.Namespace): buf = _get_capture() if buf is None: print(f"{CR}No capture in buffer — run lf sniff first{C0}") return n = len(buf) mean = sum(buf) // n mn = min(buf) mx = max(buf) threshold = mean // 2 print(f" Samples : {CG}{n}{C0} ({n*8}µs)") print(f" Range : 0x{mn:02x} – 0x{mx:02x} mean: 0x{mean:02x}") print() # Check if there is any modulation at all dynamic_range = mx - mn if dynamic_range < 0x20: print(f" Modulation: {CR}none — flat carrier (no signal){C0}") return # Binarise bits = [1 if b > threshold else 0 for b in buf] # Measure run lengths (periods between transitions) runs = [] cur = bits[0] count = 1 for b in bits[1:]: if b == cur: count += 1 else: runs.append(count) cur = b count = 1 runs.append(count) if len(runs) < 4: print(f" Modulation: {CR}insufficient transitions{C0}") return runs_sorted = sorted(runs) # Remove outliers (top/bottom 10%) trim = max(1, len(runs) // 10) # Estimate clock: most common run length = half-period from collections import Counter run_counts = Counter(runs) most_common_run = run_counts.most_common(1)[0][0] # Map to nearest standard RF divider half_samples = most_common_run full_period_us = half_samples * 2 * 8 # us rf_dividers = [8, 16, 32, 40, 50, 64, 100, 128] tc_us = 8 # 1 Tc = 8µs at 125kHz best_div = min(rf_dividers, key=lambda d: abs(d*tc_us - full_period_us)) print(f" Half-period : ~{most_common_run} samples = {most_common_run*8}µs") print(f" Full period : ~{full_period_us}µs") print(f" Nearest RF : {CG}RF/{best_div}{C0} ({best_div*tc_us}µs/bit)") print() # Modulation type heuristic # Manchester: runs cluster around 1 value (half period) and 2x that (full period) # ASK/NRZ: long runs of same value # FSK: two distinct run lengths alternating unique_runs = set(runs) long_runs = [r for r in runs if r > most_common_run * 3] # Manchester has runs clustering at N and 2N (half and full period) # Check if second most common run is ~2x the most common tol = max(2, most_common_run // 3) top2 = run_counts.most_common(2) is_manchester = (len(top2) >= 2 and abs(top2[1][0] - most_common_run * 2) <= tol) if len(long_runs) > len(runs) * 0.3: mod = "ASK / NRZ (long steady periods)" col = CG elif is_manchester: mod = f"Manchester (RF/{best_div})" col = CG elif len(unique_runs) <= 4: mod = f"Biphase (RF/{best_div})" col = CG else: mod = "FSK or mixed (multiple run lengths)" col = CG print(f" Modulation : {col}{mod}{C0}") # Gap detection gap_threshold = mean // 2 gaps = [i for i, b in enumerate(buf[200:]) if b < gap_threshold] if gaps: print(f" RTF gaps : {CG}{len(gaps)}{C0} samples below 0x{gap_threshold:02x}" f" ^`^t gap commands present") else: print(f" RTF gaps : {CR}none ^`^t no gap commands detected{C0}") # ============================================================================ # EMV contactless payment card commands (emv subgroup) # ============================================================================ def _emv_decode_apdu(data: bytes) -> str: """Return a brief human-readable description of a command APDU.""" if len(data) < 4: return '' cla, ins, p1, p2 = data[0], data[1], data[2], data[3] lc = data[4] if len(data) > 4 else 0 body = data[5:5 + lc] if len(data) > 5 else b'' if cla == 0x00 and ins == 0xA4 and p1 == 0x04 and body: known = { bytes.fromhex('325041592e5359532e4444463031'): 'PPSE (2PAY.SYS.DDF01)', bytes.fromhex('a0000000031010'): 'Visa Credit/Debit', bytes.fromhex('a0000000041010'): 'Mastercard Debit', bytes.fromhex('a000000025010402'): 'Amex', } return 'SELECT AID ' + known.get(body.lower(), body.hex().upper()) if cla == 0x80 and ins == 0xA8: return 'GET PROCESSING OPTIONS (GPO)' if cla == 0x00 and ins == 0xB2: return f'READ RECORD SFI={(p2 >> 3) & 0x1F} rec={p1}' return f'CLA={cla:02x} INS={ins:02x} P1={p1:02x} P2={p2:02x}' @emv.command('scan') class EMVScan(DeviceRequiredUnit): """ Full EMV contactless card scan — equivalent to PM3 'emv scan -at'. Scans an ISO14443-4 card, performs the full EMV transaction sequence (SELECT PPSE, SELECT AID, GPO, READ RECORDs) and saves results to a JSON file compatible with PM3's emv scan output format. Place the card on the CU antenna before running. Usage: emv scan print results to terminal emv scan -f /tmp/card.json save to JSON file """ def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'EMV contactless card scan (reader mode) — like PM3 emv scan -at' parser.add_argument('-f', '--file', default='', metavar='', help='Save results to JSON file (PM3-compatible format)') parser.add_argument('-s', '--slot', type=int, default=None, metavar='<1-8>', help='Also load scanned card into this slot for emulation') return parser def on_exec(self, args: argparse.Namespace): import time import json as jsonlib cmd = self.cmd # Ensure reader mode try: if not cmd.is_device_reader_mode(): cmd.set_device_reader_mode(True) time.sleep(0.5) except Exception: time.sleep(0.3) print(f' {CY}Scanning... (place card on antenna) [fw-canary:v5]{C0}') # Single firmware call — full EMV sequence without USB round-trips resp = cmd.hf14a_4_emv_scan() if resp.status != Status.HF_TAG_OK or not resp.data: print(f' {CR}No card found or scan failed (status={resp.status}){C0}') return # Parse packed response d = bytes(resp.data) off = 0 uid_len = d[off] off += 1 uid = d[off:off+uid_len] off += uid_len atqa = d[off:off+2] off += 2 sak = d[off] off += 1 ats_len = d[off] off += 1 ats = d[off:off+ats_len] off += ats_len uid_str = ' '.join(f'{b:02X}' for b in uid) atqa_str = ' '.join(f'{b:02X}' for b in atqa) ats_str = ' '.join(f'{b:02X}' for b in ats) print(f' {CG}UID : {uid_str}{C0}') print(f' {CG}ATQA: {atqa_str} SAK: {sak:02X}{C0}') print(f' {CG}ATS : {ats_str}{C0}') num_apdus = d[off] off += 1 pairs = [] for _ in range(num_apdus): cl = d[off] off += 1 c = d[off:off+cl] off += cl rl = d[off] | (d[off+1] << 8) off += 2 r = d[off:off+rl] off += rl pairs.append((c, r)) if not pairs: print(f' {CR}No APDU responses captured{C0}') return result = {} result['File'] = {'Created': 'chameleon emv scan'} result['Card'] = {'Contactless': { 'Communication': 'iso14443-4a', 'UID': uid_str, 'ATQA': atqa_str, 'SAK': f'{sak:02X}', 'ATS': ats_str, }} def tlv_to_dict(data): if not data: return {} i = 0 tl = 2 if (data[i] & 0x1F) == 0x1F else 1 tag_hex = data[:tl].hex().upper() i += tl if i >= len(data): return {} if data[i] & 0x80: nb = data[i] & 0x7F i += 1 vlen = int.from_bytes(data[i:i+nb], 'big') i += nb else: vlen = data[i] i += 1 val = data[i:i+vlen] return {'tag': tag_hex, 'length': f'{vlen:02X}', 'value': ' '.join(f'{b:02X}' for b in val)} def find_tag(data, tag): results = [] i = 0 while i < len(data) - 1: tl = 2 if (data[i] & 0x1F) == 0x1F else 1 if i + tl > len(data): break cur = data[i:i+tl] i += tl if i >= len(data): break if data[i] & 0x80: nb = data[i] & 0x7F i += 1 vlen = int.from_bytes(data[i:i+nb], 'big') i += nb else: vlen = data[i] i += 1 val = data[i:i+vlen] i += vlen if int.from_bytes(cur, 'big') == tag: results.append(val) elif cur[0] & 0x20: results.extend(find_tag(val, tag)) return results # PPSE if pairs: ppse_cmd, ppse_resp = pairs[0] ppse_body = ppse_resp[:-2] if len(ppse_resp) >= 2 else ppse_resp print(f'\n {CG}PPSE OK ({len(ppse_resp)}b){C0}') result['PPSE'] = { 'AID': '32 50 41 59 2E 53 59 53 2E 44 44 46 30 31', 'FCITemplate': tlv_to_dict(ppse_body), } if len(pairs) >= 2: sel_cmd, sel_resp = pairs[1] sel_body = sel_resp[:-2] if len(sel_resp) >= 2 else sel_resp aid_bytes = sel_cmd[5:-1] if len(sel_cmd) > 6 else b'' aid_str = ' '.join(f'{b:02X}' for b in aid_bytes) print(f' {CG}SELECT AID OK ({len(sel_resp)}b){C0}') result['Application'] = {'AID': aid_str, 'FCITemplate': tlv_to_dict(sel_body)} if len(pairs) >= 3: gpo_cmd, gpo_resp = pairs[2] gpo_body = gpo_resp[:-2] if len(gpo_resp) >= 2 else gpo_resp print(f' {CG}GPO OK ({len(gpo_resp)}b){C0}') result['Application']['GPO'] = tlv_to_dict(gpo_body) records = [] for cb, rb in pairs[3:]: sfi_n = (cb[3] >> 3) & 0x1F if len(cb) >= 4 else 0 rec_n = cb[2] if len(cb) >= 3 else 0 r_body = rb[:-2] if len(rb) >= 2 else rb print(f' {CG}READ RECORD SFI={sfi_n} rec={rec_n} OK ({len(rb)}b){C0}') records.append({'SFI': f'{sfi_n:02X}', 'RecordNum': f'{rec_n:02X}', 'Offline': '01', 'Data': tlv_to_dict(r_body)}) result['Application']['Records'] = records # ---- Decode and display key card fields from EMV records -------- def _pan_luhn(pan: str) -> bool: digits = [int(c) for c in pan if c.isdigit()] digits.reverse() total = sum(d if i % 2 == 0 else (d * 2 - 9 if d * 2 > 9 else d * 2) for i, d in enumerate(digits)) return total % 10 == 0 def _find_tag_all(data: bytes, *tags: int): """Recursively find all values for any of the given tags.""" results = {} for t in tags: results[t] = [] i = 0 while i < len(data) - 1: tl = 2 if (data[i] & 0x1F) == 0x1F else 1 if i + tl > len(data): break cur_tag = int.from_bytes(data[i:i+tl], 'big') i += tl if i >= len(data): break if data[i] & 0x80: nb = data[i] & 0x7F i += 1 vlen = int.from_bytes(data[i:i+nb], 'big') i += nb else: vlen = data[i] i += 1 val = data[i:i+vlen] i += vlen if cur_tag in results: results[cur_tag].append(val) # recurse into constructed TLV if data[i - vlen - (1 if vlen < 128 else 2)] & 0x20 if False else (data[i - vlen - 1] & 0x20 if vlen < 128 else False): sub = _find_tag_all(val, *tags) for t in tags: results[t].extend(sub[t]) return results # Simpler recursive TLV walker def tlv_find(data: bytes, *want_tags: int) -> dict: found = {t: [] for t in want_tags} i = 0 while i < len(data): if i + 1 >= len(data): break b0 = data[i] tl = 2 if (b0 & 0x1F) == 0x1F else 1 if i + tl > len(data): break tag = int.from_bytes(data[i:i+tl], 'big') i += tl if i >= len(data): break constructed = bool(b0 & 0x20) if data[i] & 0x80: nb = data[i] & 0x7F i += 1 if i + nb > len(data): break vlen = int.from_bytes(data[i:i+nb], 'big') i += nb else: vlen = data[i] i += 1 # For truncated TLV: read whatever bytes are available and # continue parsing — don't break, so we can find tags inside # truncated constructed TLV (e.g. 6F/A5 larger than received data) truncated = (i + vlen > len(data)) val = data[i:i+vlen] if not truncated else data[i:] i = (i + vlen) if not truncated else len(data) if tag in found and not truncated: found[tag].append(val) if constructed: sub = tlv_find(val, *want_tags) for t in want_tags: found[t].extend(sub[t]) return found # Collect all response bodies for tag search. # tlv_to_dict stores the VALUE (content) of the outermost tag — # so rec['Data']['value'] is already the unwrapped inner bytes. all_record_data = b'' for rec in result.get('Application', {}).get('Records', []): raw_hex = rec.get('Data', {}).get('value', '') try: all_record_data += bytes.fromhex(raw_hex.replace(' ', '')) except Exception: pass # Also include GPO and SELECT AID FCI values for label/name tags extra_data = b'' for key in ('GPO', 'FCITemplate'): v = result.get('Application', {}).get(key, {}) if isinstance(v, dict): try: extra_data += bytes.fromhex(v.get('value', '').replace(' ', '')) except Exception: pass all_search_data = all_record_data + extra_data # EMV tag definitions: # 0x5A = PAN # 0x5F24 = Expiry Date (YYMMDD) # 0x5F20 = Cardholder Name # 0x5F28 = Issuer Country Code # 0x8C / 0x8D = CDOL — skip # 0x9F12 = Application Preferred Name # 0x50 = Application Label tags = tlv_find(all_record_data, 0x5A, 0x57, 0x5F24, 0x5F20, 0x5F28) app_tags = tlv_find(all_search_data, 0x9F12, 0x50) tags[0x9F12] = app_tags[0x9F12] tags[0x50] = app_tags[0x50] print(f'') print(f' {CG}── Card Details ──────────────────────{C0}') # App label — show first unique label only seen_labels = set() for v in tags.get(0x50, []) + app_tags.get(0x50, []): try: lbl = v.decode('ascii', errors='replace').strip() if lbl and lbl not in seen_labels: seen_labels.add(lbl) print(f' {CG}App Label :{C0} {CY}{lbl}{C0}') except Exception: pass # PAN — prefer Track2 D-separator (authoritative, no padding ambiguity) pan_hex = None for v in tags.get(0x57, []): t2 = v.hex().upper() sep = t2.find('D') if sep > 0: pan_hex = t2[:sep] break if not pan_hex: for v in tags.get(0x5A, []): raw = v.hex().upper() pan_hex = raw.rstrip('F') if raw.endswith('F') else raw break if pan_hex: pan_fmt = ' '.join(pan_hex[i:i+4] for i in range(0, len(pan_hex), 4)) luhn_ok = _pan_luhn(pan_hex) luhn_str = f'{CG}✓{C0}' if luhn_ok else f'{CR}✗{C0}' print(f' {CG}PAN :{C0} {CY}{pan_fmt}{C0} Luhn: {luhn_str}') result.setdefault('Decoded', {})['PAN'] = pan_hex else: print(f' {CR}PAN : not found{C0}') # Expiry — 5F24 is 3 bytes BCD: YYMMDD expiry_found = False for v in tags.get(0x5F24, []): if len(v) == 3: exp = v.hex().upper() exp_fmt = f'20{exp[0:2]}/{exp[2:4]}' print(f' {CG}Expiry :{C0} {CY}{exp_fmt}{C0}') result.setdefault('Decoded', {})['Expiry'] = exp_fmt expiry_found = True # Fallback: extract expiry from Track2 after D separator (YYMM) if not expiry_found and pan_hex: for v in tags.get(0x57, []): t2 = v.hex().upper() sep = t2.find('D') if sep > 0 and len(t2) >= sep + 5: yymm = t2[sep+1:sep+5] if yymm.isdigit(): exp_fmt = f'20{yymm[0:2]}/{yymm[2:4]}' print(f' {CG}Expiry :{C0} {CY}{exp_fmt}{C0} (from Track2)') result.setdefault('Decoded', {})['Expiry'] = exp_fmt expiry_found = True break if not expiry_found: print(f' {CR}Expiry : not found{C0}') # Cardholder Name (tag 5F20: printable ASCII only) for v in tags[0x5F20]: try: if v and all(0x20 <= b <= 0x7E for b in v): name = v.decode('ascii').strip() if name: print(f' {CG}Cardholder :{C0} {CY}{name}{C0}') result.setdefault('Decoded', {})['CardholderName'] = name except Exception: pass # Issuer Country Code (ISO 3166-1 numeric, BCD) for v in tags[0x5F28]: country = v.hex().upper().lstrip('0') or '0' print(f' {CG}Issuer Country:{C0} {CY}{country}{C0}') result.setdefault('Decoded', {})['IssuerCountry'] = country # Application Preferred Name (9F12) — only if different from label for v in tags[0x9F12]: try: name = v.decode('ascii', errors='replace').strip() if name and name not in seen_labels: print(f' {CG}App Name :{C0} {CY}{name}{C0}') except Exception: pass print(f' {CG}──────────────────────────────────────{C0}') json_str = jsonlib.dumps(result, indent=2) if args.file: try: with open(args.file, 'w') as fp: fp.write(json_str) print(f'\n {CG}Saved to {args.file}{C0}') except Exception as e: print(f' {CR}Save failed: {e}{C0}') else: print(f'\n{json_str}') if args.slot is not None and pairs: target_slot = SlotNumber(args.slot) print(f'\n {CY}Loading into slot {target_slot}...{C0}') try: cmd.set_slot_tag_type(target_slot, TagSpecificType.HF14A_4) cmd.set_slot_data_default(target_slot, TagSpecificType.HF14A_4) cmd.set_slot_enable(target_slot, TagSenseType.HF, True) cmd.hf14a_4_set_anti_coll(uid, atqa, sak, ats) # atqa already in wire order cmd.hf14a_4_clear_static_responses() for c, r in pairs: cmd.hf14a_4_add_static_response(c, r) # use full cmd as match key cmd.slot_data_config_save() print(f' {CG}Slot {target_slot} ready. Run: hw slot change -s {args.slot} && hw mode -e{C0}') except Exception as e: print(f' {CR}Slot load failed: {e}{C0}') @emv.command('debug') class EMVDebug(DeviceRequiredUnit): """Show T=CL emulation debug counters (I-blocks rx/tx, last PCB, last match).""" def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'Show T=CL emulation debug counters' return parser def on_exec(self, args: argparse.Namespace): resp = self.cmd.device.send_cmd_sync(6010, b'') if resp.status != Status.SUCCESS or not resp.data or len(resp.data) < 4: print(f' {CR}Debug command failed{C0}') return d = resp.data print(f' {CY}T=CL debug counters:{C0}') print(f' I-blocks received : {d[0]}') print(f' I-blocks sent : {d[1]}') print( f' Last rx PCB : {d[2]:02x} (blk_num={(d[2] & 0x01)}, chain={(d[2] >> 5) & 1}, cid={(d[2] >> 4) & 1})') print(f' Last static match : {"yes" if d[3] else "no"}') @emv.command('load') class EMVLoad(DeviceRequiredUnit): """ Load EMV card data into an HF14A_4 slot for emulation. Supports two modes: 1. Load from a JSON file (PM3 emv scan -at output) 2. Add a single custom APDU command/response pair Usage: emv load -f /tmp/card.json -s 3 load full card from PM3 JSON emv load --clear clear static responses emv load --cmd 00A4... --resp 6F.. add single APDU pair emv load --defaults load Mastercard test defaults """ def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'Load EMV APDU responses into HF14A_4 slot for autonomous emulation' parser.add_argument('-f', '--file', default='', metavar='', help='Load from PM3 emv scan JSON file') parser.add_argument('-s', '--slot', type=int, default=None, metavar='<1-8>', help='Target slot when using --file (default: active)') parser.add_argument('--clear', action='store_true', help='Clear all static responses from active slot') parser.add_argument('--cmd', default='', metavar='', help='Command APDU prefix to match (hex)') parser.add_argument('--resp', default='', metavar='', help='Response APDU to return (hex)') parser.add_argument('--defaults', action='store_true', help='Load built-in Mastercard test responses') return parser def on_exec(self, args: argparse.Namespace): cmd = self.cmd if args.clear: cmd.hf14a_4_clear_static_responses() print(f' {CG}Static responses cleared.{C0}') return if args.cmd and args.resp: try: c = bytes.fromhex(args.cmd.replace(' ', '')) r = bytes.fromhex(args.resp.replace(' ', '')) cmd.hf14a_4_add_static_response(c, r) print(f' {CG}Added: {c.hex().upper()} → {r.hex().upper()}{C0}') except ValueError as e: print(f' {CR}Invalid hex: {e}{C0}') return if args.defaults: self._load_defaults(cmd) return if args.file: if args.slot is not None: target_slot = SlotNumber(args.slot) else: target_slot = SlotNumber.from_fw(cmd.get_active_slot()) self._load_from_json(args.file, target_slot, cmd) return print(f' {CY}Specify --file, --cmd/--resp, --clear, or --defaults{C0}') def _load_defaults(self, cmd): """Load built-in Mastercard test APDU responses.""" cmd.hf14a_4_clear_static_responses() pairs = [ # SELECT PPSE (bytes.fromhex('00a404000e325041592e5359532e4444463031'), bytes.fromhex('6f23840e325041592e5359532e4444463031' 'a511bf0c0e610c4f07a000000004101087010190 00'.replace(' ', '')), 'SELECT PPSE'), # SELECT Mastercard Debit AID (bytes.fromhex('00a4040007a0000000041010'), bytes.fromhex('6f1d8407a0000000041010a512500a' '4d6173746572436172648701019f38009000'), 'SELECT Mastercard AID'), # GPO — decline gracefully (bytes.fromhex('80a80000'), bytes.fromhex('6985'), 'GPO (conditions not satisfied)'), ] for c, r, name in pairs: resp = cmd.hf14a_4_add_static_response(c, r) if resp.status == Status.SUCCESS: print(f' {CG}Loaded: {name}{C0}') else: print(f' {CR}Failed: {name}{C0}') print(f'\n {CY}Default responses loaded. Run: hw mode -e{C0}') def _tlv_encode_len(self, n: int) -> bytes: """Encode integer n as BER-TLV length (short or long form).""" if n < 0x80: return bytes([n]) elif n <= 0xFF: return bytes([0x81, n]) else: return bytes([0x82, (n >> 8) & 0xFF, n & 0xFF]) def _load_from_json(self, filepath, target_slot, cmd): """Load card data from a PM3 emv scan JSON file.""" import json as jsonlib import os if not os.path.exists(filepath): print(f' {CR}File not found: {filepath}{C0}') return try: with open(filepath) as f: data = jsonlib.load(f) except Exception as e: print(f' {CR}JSON parse error: {e}{C0}') return # Parse card info try: card = data['Card']['Contactless'] uid = bytes.fromhex(card['UID'].replace(' ', '')) atqa = bytes.fromhex(card['ATQA'].replace(' ', '')) sak = int(card['SAK'], 16) ats_raw = bytes.fromhex(card['ATS'].replace(' ', '')) ats = ats_raw[:ats_raw[0]] if ats_raw else b'' except Exception as e: print(f' {CR}Card info parse error: {e}{C0}') return uid_str = ' '.join(f'{b:02X}' for b in uid) print(f' {CG}Card from JSON:{C0}') print(f' UID : {CG}{uid_str}{C0}') print(f' ATQA : {CG}{atqa.hex().upper()}{C0} SAK: {CG}{sak:02X}{C0}') print(f' ATS : {CG}{ats.hex().upper()}{C0}') static_pairs = [] def tlv_resp(tag_hex, len_hex, val_hex): """Reconstruct TLV response with proper BER length encoding + SW 9000.""" tag_b = bytes.fromhex(tag_hex) val_b = bytes.fromhex(val_hex) n = int(len_hex, 16) len_b = self._tlv_encode_len(n) return tag_b + len_b + val_b + bytes([0x90, 0x00]) try: v = data['PPSE']['FCITemplate']['value'].replace(' ', '') l = data['PPSE']['FCITemplate']['length'] static_pairs.append(( bytes.fromhex('00a404000e325041592e5359532e4444463031'), tlv_resp('6F', l, v), 'SELECT PPSE')) except Exception as e: print(f' {CR}PPSE: {e}{C0}') try: v = data['Application']['FCITemplate']['value'].replace(' ', '') l = data['Application']['FCITemplate']['length'] aid = data['Application']['AID'].replace(' ', '') static_pairs.append(( bytes.fromhex('00a4040007' + aid), tlv_resp('6F', l, v), 'SELECT AID')) except Exception as e: print(f' {CR}Application FCI: {e}{C0}') try: v = data['Application']['GPO']['value'].replace(' ', '') l = data['Application']['GPO']['length'] tag = data['Application']['GPO'].get('tag', '77') static_pairs.append(( bytes.fromhex('80a80000'), tlv_resp(tag, l, v), 'GPO')) except Exception as e: print(f' {CR}GPO: {e}{C0}') try: for rec in data['Application'].get('Records', []): sfi_n = int(rec['SFI'], 16) rec_n = int(rec['RecordNum'], 16) v = rec['Data']['value'].replace(' ', '') l = rec['Data']['length'] tag = rec['Data'].get('tag', '70') p2 = (sfi_n << 3) | 4 static_pairs.append(( bytes([0x00, 0xB2, rec_n, p2, 0x00]), tlv_resp(tag, l, v), f'READ RECORD SFI={sfi_n} rec={rec_n}')) except Exception as e: print(f' {CR}Records: {e}{C0}') # Configure slot print(f'\n {CY}Configuring slot {target_slot}...{C0}') cmd.set_slot_tag_type(target_slot, TagSpecificType.HF14A_4) cmd.set_slot_data_default(target_slot, TagSpecificType.HF14A_4) cmd.set_slot_enable(target_slot, TagSenseType.HF, True) # PM3 JSON stores ATQA in display order (byte1,byte0) — swap to wire order atqa_wire = bytes([atqa[1], atqa[0]]) if len(atqa) == 2 else atqa cmd.hf14a_4_set_anti_coll(uid, atqa_wire, sak, ats) cmd.hf14a_4_clear_static_responses() for c, r, name in static_pairs: try: cmd.hf14a_4_add_static_response(c, r) print(f' {CG}+ {name} ({len(r)}b){C0}') except Exception as e: print(f' {CR} Failed {name}: {e}{C0}') cmd.slot_data_config_save() print(f'\n {CG}Done! Slot {target_slot} ready with {len(static_pairs)} response(s).{C0}') print(f' {C0}Next: hw slot change -s {target_slot} && hw mode -e{C0}') @emv.command('apdu') class EMVApdu(DeviceRequiredUnit): """ ISO14443-4 T=CL interactive APDU relay. CU emulates an ISO14443-4 card and relays APDUs to/from the terminal. For each APDU from the reader, you type the hex response bytes. Requires HF14A_4 slot configured with SAK=20 and ATS. Run hw mode -e first. Usage: emv apdu emv apdu --timeout 30000 """ def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = 'ISO14443-4 T=CL interactive APDU relay (manual response mode)' parser.add_argument('--timeout', type=int, default=15000, metavar='', help='Total relay timeout in ms (default: 15000)') return parser def on_exec(self, args: argparse.Namespace): import time cmd = self.cmd timeout_ms = max(1000, min(60000, args.timeout)) print(f' {CY}ISO14443-4 T=CL APDU relay started{C0}') print(f' Waiting for a reader to connect (SAK=20 slot required)...') print(f' Type {CY}quit{C0} to exit, or enter hex response bytes when prompted.') exchange_count = 0 while True: resp = None deadline = time.monotonic() + (timeout_ms / 1000.0) while time.monotonic() < deadline: try: r = cmd.hf14a_4_apdu_recv() except Exception as e: print(f' {CR}Error polling for APDU: {e}{C0}') resp = None break if r.status == Status.SUCCESS: resp = r break elif r.status != Status.HF_TAG_NO: print(f' {CR}Firmware error: {r.status}{C0}') resp = None break time.sleep(0.02) if resp is None: print(f' {C0}No APDU received within timeout.{C0}') break apdu = bytes(resp.data) desc = _emv_decode_apdu(apdu) exchange_count += 1 apdu_hex = ' '.join(f'{b:02x}' for b in apdu) print(f'\n [{exchange_count}] {CY}APDU →→ {apdu_hex}{C0}') if desc: print(f' {C0}{desc}{C0}') try: user_input = input(f' Response (hex) [{CG}90 00{C0}]: ').strip() except (EOFError, KeyboardInterrupt): break if user_input.lower() == 'quit': break if not user_input: user_input = '9000' try: response_bytes = bytes.fromhex(user_input.replace(' ', '')) except ValueError: print(f' {CR}Invalid hex — sending 6F00 (error){C0}') response_bytes = bytes.fromhex('6F00') try: cmd.hf14a_4_apdu_send(response_bytes) resp_hex = ' '.join(f'{b:02x}' for b in response_bytes) print(f' {CG}←← Response {resp_hex}{C0}') except Exception as e: print(f' {CR}Error sending response: {e}{C0}') break print(f'\n {C0}Relay ended. {exchange_count} APDU exchange(s) completed.{C0}') # --------------------------------------------------------------------------- # hf des — MIFARE DESFire commands # --------------------------------------------------------------------------- def _des_raw(cmd, keep_field=True, activate=False, wait_resp=True, append_crc=False, data=b'', timeout_ms=200): """Helper used inside HfDes* commands — wraps cmd.hf14a_raw options.""" options = { 'activate_rf_field': 1 if activate else 0, 'wait_response': 1 if wait_resp else 0, 'append_crc': 1 if append_crc else 0, 'auto_select': 0, 'keep_rf_field': 1 if keep_field else 0, 'check_response_crc': 0, } return cmd.hf14a_raw(options=options, resp_timeout_ms=timeout_ms, data=list(data)) def _des_select(cmd): """ Field-on + ISO14443-A select via hf14a_scan. Returns (uid_bytes, sak, ats). hf14a_scan returns a list of tag dicts (via @expect_response which unwraps .parsed). """ tags = cmd.hf14a_scan() if not tags: raise RuntimeError("No 14443A tag in field") tag = tags[0] uid_bytes = tag['uid'] sak = tag['sak'][0] # sak is a 1-byte bytes object ats = tag['ats'] return uid_bytes, sak, ats def _des_wrap(cmd_byte: int, data: bytes = b'') -> bytes: """ Wrap a native DESFire command in an ISO 7816-4 envelope: CLA=90 INS=cmd P1=00 P2=00 [Lc data] Le=00 This is the 'native ISO' framing DESFire EV1+ accepts over T=CL. """ if data: return bytes([0x90, cmd_byte, 0x00, 0x00, len(data)]) + data + bytes([0x00]) else: return bytes([0x90, cmd_byte, 0x00, 0x00, 0x00]) def _des_transceive(cmd, des_cmd: int, data: bytes = b'', timeout_ms=500) -> bytes: """ Send one native DESFire command via ISO-wrapped APDU over T=CL. Uses hf14a_4_reader_apdu which handles RATS, CRC32, and PCB framing in firmware. Returns payload bytes with the DESFire status byte appended: [...payload..., status] where status 0x00=OK, 0xAF=more data, other=error. Raises RuntimeError on comms failure. """ apdu = _des_wrap(des_cmd, data) resp = cmd.hf14a_4_reader_apdu(apdu) if resp.status not in (Status.SUCCESS, Status.HF_TAG_OK): raise RuntimeError(f"No response to command 0x{des_cmd:02X}") rdata = resp.data if not rdata or len(rdata) < 2: raise RuntimeError(f"Empty response to command 0x{des_cmd:02X}") # ISO response: [...payload...] SW1 SW2 # DESFire status is in SW2 (SW1=0x91 for native wrapped) sw1, sw2 = rdata[-2], rdata[-1] if sw1 != 0x91: raise RuntimeError(f"Unexpected SW1=0x{sw1:02X} SW2=0x{sw2:02X}") payload = rdata[:-2] # Return payload + DESFire status byte so callers can check for 0xAF (more frames) return payload + bytes([sw2]) def _desfire_auth_des(cmd, key: bytes, key_no: int = 0) -> bool: """ DESFire native D40 authentication (DES/2TDEA, command 0x0A). Returns True on success, False on wrong key. Raises RuntimeError on comms failure. """ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend import os if len(key) == 8: key2tdea = key + key elif len(key) == 16: key2tdea = key else: raise ValueError(f"DES key must be 8 or 16 bytes, got {len(key)}") # Step 1: send Authenticate command (0x0A + key_no) resp = _des_transceive(cmd, 0x0A, bytes([key_no])) status = resp[-1] if status != 0xAF: return False enc_rnd_b = resp[:-1] if len(enc_rnd_b) != 8: raise RuntimeError(f"Expected 8-byte encRndB, got {len(enc_rnd_b)}") # Step 2: decrypt RndB with IV=0 iv = bytes(8) cipher = Cipher(algorithms.TripleDES(key2tdea), modes.CBC(iv), backend=default_backend()) dec = cipher.decryptor() rnd_b = dec.update(enc_rnd_b) + dec.finalize() # Step 3: rotate RndB left by 1 byte rnd_b_rot = rnd_b[1:] + rnd_b[:1] # Step 4: generate RndA and encrypt RndA || RndB' in CBC (IV = encRndB) rnd_a = os.urandom(8) cipher2 = Cipher(algorithms.TripleDES(key2tdea), modes.CBC(enc_rnd_b), backend=default_backend()) enc2 = cipher2.encryptor() enc_both = enc2.update(rnd_a + rnd_b_rot) + enc2.finalize() # Step 5: send AF + enc(RndA || RndB') as additional frame resp2 = _des_transceive(cmd, 0xAF, enc_both) status2 = resp2[-1] if status2 != 0x00: return False # Step 6: verify enc(RndA') enc_rnd_a_card = resp2[:-1] if len(enc_rnd_a_card) != 8: raise RuntimeError(f"Expected 8-byte encRndA', got {len(enc_rnd_a_card)}") iv3 = enc_both[-8:] cipher3 = Cipher(algorithms.TripleDES(key2tdea), modes.CBC(iv3), backend=default_backend()) dec3 = cipher3.decryptor() rnd_a_card = dec3.update(enc_rnd_a_card) + dec3.finalize() rnd_a_rot = rnd_a[1:] + rnd_a[:1] return rnd_a_card == rnd_a_rot def _desfire_auth_aes(cmd, key: bytes, key_no: int = 0) -> bool: """ DESFire EV1 AES authentication (command 0xAA). Returns True on success, False on wrong key. """ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend import os if len(key) != 16: raise ValueError(f"AES key must be 16 bytes, got {len(key)}") # Step 1: send AuthenticateAES (0xAA + key_no) resp = _des_transceive(cmd, 0xAA, bytes([key_no])) status = resp[-1] if status != 0xAF: return False enc_rnd_b = resp[:-1] if len(enc_rnd_b) != 16: raise RuntimeError(f"Expected 16-byte encRndB, got {len(enc_rnd_b)}") # Step 2: decrypt RndB with IV=0 iv = bytes(16) cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) dec = cipher.decryptor() rnd_b = dec.update(enc_rnd_b) + dec.finalize() # Step 3: rotate RndB left 1 rnd_b_rot = rnd_b[1:] + rnd_b[:1] # Step 4: generate RndA, encrypt RndA || RndB' with IV = encRndB rnd_a = os.urandom(16) cipher2 = Cipher(algorithms.AES(key), modes.CBC(enc_rnd_b), backend=default_backend()) enc2 = cipher2.encryptor() enc_both = enc2.update(rnd_a + rnd_b_rot) + enc2.finalize() # Step 5: send AF + enc(RndA || RndB') resp2 = _des_transceive(cmd, 0xAF, enc_both) status2 = resp2[-1] if status2 != 0x00: return False # Step 6: verify enc(RndA') enc_rnd_a_card = resp2[:-1] if len(enc_rnd_a_card) != 16: raise RuntimeError(f"Expected 16-byte encRndA', got {len(enc_rnd_a_card)}") iv3 = enc_both[-16:] cipher3 = Cipher(algorithms.AES(key), modes.CBC(iv3), backend=default_backend()) dec3 = cipher3.decryptor() rnd_a_card = dec3.update(enc_rnd_a_card) + dec3.finalize() rnd_a_rot = rnd_a[1:] + rnd_a[:1] return rnd_a_card == rnd_a_rot def _desfire_auth_3k3des(cmd, key: bytes, key_no: int = 0) -> bool: """ DESFire EV1 3K3DES (3-key Triple-DES) authentication (command 0x1A). Key must be 24 bytes. Returns True on success, False on wrong key. """ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend import os if len(key) != 24: raise ValueError(f"3K3DES key must be 24 bytes, got {len(key)}") # Step 1: send Authenticate3K3DES (0x1A + key_no) resp = _des_transceive(cmd, 0x1A, bytes([key_no])) status = resp[-1] if status != 0xAF: return False enc_rnd_b = resp[:-1] if len(enc_rnd_b) != 8: raise RuntimeError(f"Expected 8-byte encRndB, got {len(enc_rnd_b)}") # Step 2: decrypt RndB with IV=0 using 3K3DES (EDE3) iv = bytes(8) cipher = Cipher(algorithms.TripleDES(key), modes.CBC(iv), backend=default_backend()) dec = cipher.decryptor() rnd_b = dec.update(enc_rnd_b) + dec.finalize() # Step 3: rotate RndB left by 1 byte rnd_b_rot = rnd_b[1:] + rnd_b[:1] # Step 4: generate RndA, encrypt RndA || RndB' with IV = encRndB rnd_a = os.urandom(8) cipher2 = Cipher(algorithms.TripleDES(key), modes.CBC(enc_rnd_b), backend=default_backend()) enc2 = cipher2.encryptor() enc_both = enc2.update(rnd_a + rnd_b_rot) + enc2.finalize() # Step 5: send AF + enc(RndA || RndB') resp2 = _des_transceive(cmd, 0xAF, enc_both) status2 = resp2[-1] if status2 != 0x00: return False # Step 6: verify enc(RndA') enc_rnd_a_card = resp2[:-1] if len(enc_rnd_a_card) != 8: raise RuntimeError(f"Expected 8-byte encRndA', got {len(enc_rnd_a_card)}") iv3 = enc_both[-8:] cipher3 = Cipher(algorithms.TripleDES(key), modes.CBC(iv3), backend=default_backend()) dec3 = cipher3.decryptor() rnd_a_card = dec3.update(enc_rnd_a_card) + dec3.finalize() rnd_a_rot = rnd_a[1:] + rnd_a[:1] return rnd_a_card == rnd_a_rot def _desfire_get_app_ids(cmd) -> list: """Send GetApplicationIDs (0x6A) and return list of 3-byte AIDs.""" resp = _des_transceive(cmd, 0x6A) status = resp[-1] payload = resp[:-1] aids = [] # Fixed: iterate full payload in 3-byte steps (previous code used len-2, dropping the last AID) for i in range(0, len(payload), 3): if i + 3 <= len(payload): aids.append(payload[i:i+3]) while status == 0xAF: resp = _des_transceive(cmd, 0xAF) status = resp[-1] payload = resp[:-1] for i in range(0, len(payload), 3): if i + 3 <= len(payload): aids.append(payload[i:i+3]) return aids def _desfire_select_app(cmd, aid: bytes): """Send SelectApplication (0x5A) for a 3-byte AID.""" resp = _des_transceive(cmd, 0x5A, aid) return resp[-1] == 0x00 def _desfire_get_version(cmd) -> dict: """Send GetVersion (0x60) and return a dict of card info. HW frame: vendor(0) type(1) subtype(2) major(3) minor(4) storage(5) protocol(6) SW frame: same layout. UID frame: uid(0:7) batch(7:12) prod_week(12) prod_year(13) Per-field length guards make the parser robust against short frames. If the SW frame returns no payload (some reader/BLE stacks don't relay it) sw_major, sw_storage and sw_proto are derived from the HW frame values. """ resp = _des_transceive(cmd, 0x60) info: dict = {} hw = resp[:-1] # HW frame — individual guards so a short frame still populates what it has if len(hw) >= 1: info['hw_vendor'] = hw[0] if len(hw) >= 2: info['hw_type'] = hw[1] if len(hw) >= 3: info['hw_subtype'] = hw[2] if len(hw) >= 4: info['hw_major'] = hw[3] if len(hw) >= 5: info['hw_minor'] = hw[4] if len(hw) >= 6: info['hw_storage'] = hw[5] if len(hw) >= 7: info['hw_proto'] = hw[6] if resp[-1] == 0xAF: resp2 = _des_transceive(cmd, 0xAF) sw = resp2[:-1] # SW frame — same per-field guards if len(sw) >= 1: info['sw_vendor'] = sw[0] if len(sw) >= 2: info['sw_type'] = sw[1] if len(sw) >= 3: info['sw_subtype'] = sw[2] if len(sw) >= 4: info['sw_major'] = sw[3] if len(sw) >= 5: info['sw_minor'] = sw[4] if len(sw) >= 6: info['sw_storage'] = sw[5] if len(sw) >= 7: info['sw_proto'] = sw[6] # Fallback: derive SW fields from HW when SW frame payload is empty if 'sw_major' not in info and 'hw_major' in info: info['sw_major'] = _DESFIRE_HW_MAJOR_TO_SW_MAJOR.get( info['hw_major'], info['hw_major']) info['sw_minor'] = 0 if 'sw_storage' not in info: info['sw_storage'] = info.get('hw_storage') if 'sw_proto' not in info: info['sw_proto'] = info.get('hw_proto') if resp2[-1] == 0xAF: resp3 = _des_transceive(cmd, 0xAF) p3 = resp3[:-1] if len(p3) >= 7: info['uid'] = p3[:7].hex().upper() if len(p3) >= 12: info['batch'] = p3[7:12].hex().upper() if len(p3) >= 13: info['prod_week'] = p3[12] if len(p3) >= 14: info['prod_year'] = p3[13] return info _DESFIRE_HW_TYPE = {0x01: "DESFire", 0x81: "DESFire (SW)"} # Keyed on hw_major (payload[3]), matching PM3 logic. # hw_subtype (payload[2]) is always 0x01 for all EV variants and cannot # distinguish EV1 from EV2/EV3. _DESFIRE_HW_MAJOR = { 0x00: "MF3ICD40 (D40)", 0x01: "EV1", 0x12: "EV2", 0x30: "Light", 0x33: "EV3", } _DESFIRE_STORAGE = {0x16: "2 KB", 0x18: "4 KB", 0x1A: "8 KB"} _DESFIRE_PROTOCOL = { 0x03: "ISO 14443-3", 0x04: "ISO 14443-4", 0x05: "ISO 14443-3, 14443-4", } # hw_major -> SW major (EV generation number), used as fallback when the SW # frame returns no data (some reader/BLE stacks don't relay it). _DESFIRE_HW_MAJOR_TO_SW_MAJOR = {0x01: 1, 0x12: 2, 0x30: 3, 0x33: 3} @hf_des.command("info") class HfDesInfo(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Get MIFARE DESFire card information (version, UID, AIDs)." parser.epilog = "examples:\n hf des info\n" return parser def on_exec(self, args: argparse.Namespace): try: uid_bytes, sak, ats = _des_select(self.cmd) except RuntimeError as e: print(f" {CR}[!] {e}{C0}") return print(f" UID : {uid_bytes.hex().upper()}") print(f" SAK : 0x{sak:02X}") try: ver = _desfire_get_version(self.cmd) def _fmtver(major, minor): """Format as PM3-style bare hex digits: 0x33, 0x00 -> '33.0'""" return f"{major:x}.{minor:x}" if minor else f"{major:x}.0" hw_maj = ver.get('hw_major') hw_min = ver.get('hw_minor', 0) sw_maj = ver.get('sw_major') sw_min = ver.get('sw_minor', 0) hw_gen = _DESFIRE_HW_MAJOR.get(hw_maj, f"hw_major 0x{hw_maj:02X}") \ if hw_maj is not None else "?" hw_stor = _DESFIRE_STORAGE.get(ver.get('hw_storage'), f"0x{ver['hw_storage']:02X}" if 'hw_storage' in ver else "?") sw_stor = _DESFIRE_STORAGE.get(ver.get('sw_storage'), f"0x{ver['sw_storage']:02X}" if 'sw_storage' in ver else "?") proto = _DESFIRE_PROTOCOL.get(ver.get('sw_proto'), f"0x{ver['sw_proto']:02X}" if 'sw_proto' in ver else "?") hw_ver_str = _fmtver(hw_maj, hw_min) if hw_maj is not None else "?" sw_ver_str = _fmtver(sw_maj, sw_min) if sw_maj is not None else "?" print(f" HW version : {hw_ver_str} ({hw_gen}) storage: {hw_stor}") print(f" SW version : {sw_ver_str} storage: {sw_stor}") print(f" Protocol : {proto}") if 'uid' in ver: print(f" Card UID : {ver['uid']}") if 'batch' in ver: print(f" Batch no : {ver['batch']}") if 'prod_week' in ver: print(f" Production : week {ver['prod_week']:02d} / 20{ver['prod_year']:02d}") except Exception as e: print(f" {CY}[!] GetVersion failed: {e}{C0}") try: aids = _desfire_get_app_ids(self.cmd) if aids: print(f"\n Applications : {len(aids)} found") for aid in aids: print(f" AID: {aid.hex().upper()} ({int.from_bytes(aid, 'little'):06X})") else: print(f"\n Applications : none") except Exception as e: print(f" {CY}[!] GetApplicationIDs failed: {e}{C0}") @hf_des.command("chk") class HfDesChk(ReaderRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = ( "Check DESFire keys against a card (dictionary / pattern / single key). " "Tries DES, 2TDEA, and AES for each key. " "Iterates all AIDs on card unless --aid is specified." ) parser.add_argument("--aid", type=str, default=None, metavar="", help="Target AID (3 hex bytes, e.g. 123456). Default: PICC master (000000) + all apps.") parser.add_argument("-n", "--keyno", type=int, default=0, metavar="<0-13>", help="Key number to authenticate with (default: 0)") parser.add_argument("-k", "--key", type=str, default=None, metavar="", help="Single key to try (8, 16 or 24 hex bytes)") parser.add_argument("-f", "--file", type=str, default=None, metavar="", help="Dictionary file (one hex key per line)") parser.add_argument("--pattern1b", action="store_true", help="Try all 1-byte patterns (0000..00, 0101..01, ..., FFFF..FF) for DES and AES") parser.add_argument("--pattern2b", action="store_true", help="Try all 2-byte patterns for AES (0000..00 to FFFF..FF, step 0x0101)") parser.add_argument("-t", "--timeout", type=int, default=5000, metavar="", help="Card presence timeout ms (default 5000)") parser.epilog = ( "examples:\n" " hf des chk -> try built-in defaults on all AIDs\n" " hf des chk --aid 123456 -k 00112233445566778899AABBCCDDEEFF\n" " hf des chk -f mykeys.txt\n" " hf des chk --pattern1b\n" ) return parser # ---- built-in default keys (matches PM3 mfdes_default_keys.dic) ---- # DES / 2TDEA keys (8 bytes each) DES_DEFAULTS = [ bytes(8), # NXP Default DES bytes([0xFF]*8), bytes.fromhex('7544d1652bc9bd43'), bytes.fromhex('0011223344556677'), bytes.fromhex('1122334455667788'), bytes.fromhex('a0a1a2a3a4a5a6a7'), bytes.fromhex('d3f7d3f7d3f7d3f7'), ] # AES-128 keys (16 bytes each) AES_DEFAULTS = [ bytes(16), # NXP Default AES bytes([0x79,0x70,0x25,0x53]*4), # TI TRF7970A bytes.fromhex('00112233445566778899AABBCCDDEEFF'), # TI TRF7970A sloa213 bytes.fromhex('4E617468616E2E4C6920546564647920'), bytes.fromhex('43464F494D48504E4C4359454E528841'), # NHIF bytes.fromhex('6AC292FAA1315B4D858AB3A3D7D5933A'), bytes.fromhex('404142434445464748494a4b4c4d4e4f'), bytes.fromhex('3112B738D8862CCD34302EB299AAB456'), # Gallagher AES bytes.fromhex('47454D5850524553534F53414D504C45'), # Gemalto bytes.fromhex('2b7e151628aed2a6abf7158809cf4f3c'), bytes.fromhex('fbeed618357133667c85e08f7236a8de'), bytes.fromhex('f7ddac306ae266ccf90bc11ee46d513b'), bytes.fromhex('54686973206973206D79206B65792020'), bytes.fromhex('a0a1a2a3a4a5a6a7a0a1a2a3a4a5a6a7'), bytes.fromhex('b0b1b2b3b4b5b6b7b0b1b2b3b4b5b6b7'), bytes.fromhex('a0a1a2a3b0b1b2b3c0c1c2c3d0d1d2d3'), bytes.fromhex('d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7'), bytes.fromhex('C238E449F725B1510EAA699550CABA16'), # J2A040 bytes([0x11]*16), bytes([0x22]*16), bytes([0x33]*16), bytes([0x44]*16), bytes([0x55]*16), bytes([0x66]*16), bytes([0x77]*16), bytes([0x88]*16), bytes([0x99]*16), bytes([0xAA]*16), bytes([0xBB]*16), bytes([0xCC]*16), bytes([0xDD]*16), bytes([0xEE]*16), bytes([0xFF]*16), bytes(range(16)), # 000102...0f bytes(range(1, 17)), # 010203...10 bytes([0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07, 0x08,0x09,0x10,0x11,0x12,0x13,0x14,0x15]), bytes([0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08, 0x09,0x10,0x11,0x12,0x13,0x14,0x15,0x16]), bytes([0x16,0x15,0x14,0x13,0x12,0x11,0x10,0x09, 0x08,0x07,0x06,0x05,0x04,0x03,0x02,0x01]), bytes([0x15,0x14,0x13,0x12,0x11,0x10,0x09,0x08, 0x07,0x06,0x05,0x04,0x03,0x02,0x01,0x00]), bytes([0x0f,0x0e,0x0d,0x0c,0x0b,0x0a,0x09,0x08, 0x07,0x06,0x05,0x04,0x03,0x02,0x01,0x00]), bytes([0x10,0x0f,0x0e,0x0d,0x0c,0x0b,0x0a,0x09, 0x08,0x07,0x06,0x05,0x04,0x03,0x02,0x01]), bytes([0x30,0x31,0x32,0x33,0x34,0x35,0x36,0x37, 0x38,0x39,0x3a,0x3b,0x3c,0x3d,0x3e,0x3f]), bytes.fromhex('9CABF398358405AE2F0E2B3D31C99A8A'), bytes.fromhex('605F5E5D5C5B5A59605F5E5D5C5B5A59'), # access control bytes.fromhex('22094904FF22677E5D28C6E3ED4F694C'), bytes([0x40+i for i in range(16)]), ] # 3K3DES keys (24 bytes each) TDEA3_DEFAULTS = [ bytes(24), # NXP Default 3K3DES bytes.fromhex('00112233445566778899AABBCCDDEEFF0102030405060708'), bytes([0xFF]*24), bytes.fromhex('d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7'), ] def _build_key_lists(self, args): des_keys = list(self.DES_DEFAULTS) aes_keys = list(self.AES_DEFAULTS) tdea3_keys = list(self.TDEA3_DEFAULTS) if args.key: raw = bytes.fromhex(args.key.replace(" ", "")) if len(raw) == 8: return [raw], [], [] elif len(raw) == 16: return [], [raw], [] elif len(raw) == 24: return [], [], [raw] else: raise ValueError("Key must be 8, 16 or 24 bytes") if args.file: des_keys, aes_keys, tdea3_keys = [], [], [] with open(args.file) as fh: for line in fh: # Strip inline comments (text after #) then whitespace line = line.split('#')[0].strip() if not line: continue try: raw = bytes.fromhex(line.split()[0]) except ValueError: continue if len(raw) == 8: des_keys.append(raw) elif len(raw) == 16: aes_keys.append(raw) elif len(raw) == 24: tdea3_keys.append(raw) return des_keys, aes_keys, tdea3_keys if args.pattern1b: des_keys = [bytes([i]*8) for i in range(256)] aes_keys = [bytes([i]*16) for i in range(256)] tdea3_keys = [bytes([i]*24) for i in range(256)] return des_keys, aes_keys, tdea3_keys if args.pattern2b: des_keys, aes_keys, tdea3_keys = [], [], [] for i in range(0x10000): hi, lo = (i >> 8) & 0xFF, i & 0xFF des_keys.append(bytes([hi, lo]*4)) aes_keys.append(bytes([hi, lo]*8)) tdea3_keys.append(bytes([hi, lo]*12)) return des_keys, aes_keys, tdea3_keys return des_keys, aes_keys, tdea3_keys def _try_key(self, aid_label, key_no, key, algo): """ Try one key against a DESFire AID+keyno. A failed authentication leaves the DESFire session in an aborted state. We must reset it before the next attempt by issuing SelectApplication(000000) (which selects the PICC master app and clears any partial auth state). If that APDU itself fails (e.g. card went away), fall back to a full ISO14443-A re-select so the RF field / T=CL layer is re-established. """ # --- Reset DESFire session state --- try: _desfire_select_app(self.cmd, bytes([0x00, 0x00, 0x00])) except Exception: # SelectApp(000000) failed — card may have dropped; do a full re-select try: _des_select(self.cmd) except Exception: return False # Select the target application (skip for PICC master 000000) if aid_label != "000000": aid_bytes = bytes.fromhex(aid_label) try: if not _desfire_select_app(self.cmd, aid_bytes): return False except Exception: return False try: if algo == 'DES': return _desfire_auth_des(self.cmd, key, key_no) elif algo == 'AES': return _desfire_auth_aes(self.cmd, key, key_no) else: # 3K3DES return _desfire_auth_3k3des(self.cmd, key, key_no) except Exception: return False def on_exec(self, args: argparse.Namespace): try: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend except ImportError: print(f" {CR}[!] 'cryptography' library required: pip install cryptography{C0}") return try: des_keys, aes_keys, tdea3_keys = self._build_key_lists(args) except Exception as e: print(f" {CR}[!] {e}{C0}") return key_no = args.keyno # Select card and get AID list print(f" Selecting card...") try: uid_bytes, sak, _ = _des_select(self.cmd) except RuntimeError as e: print(f" {CR}[!] {e}{C0}") return print(f" UID: {uid_bytes.hex().upper()} SAK: 0x{sak:02X}") # Determine target AIDs if args.aid: aid_list = [args.aid.upper().zfill(6)] else: try: raw_aids = _desfire_get_app_ids(self.cmd) aid_list = ["000000"] + [a.hex().upper() for a in raw_aids] except Exception as e: print(f" {CY}[!] Could not enumerate AIDs: {e} — trying PICC only{C0}") aid_list = ["000000"] total_des = len(des_keys) total_aes = len(aes_keys) total_tdea3 = len(tdea3_keys) total_keys = (total_des + total_aes + total_tdea3) * len(aid_list) print(f" AIDs to check : {len(aid_list)} ({', '.join(aid_list)})") print(f" DES keys : {total_des}") print(f" AES-128 keys : {total_aes}") print(f" 3K3DES keys : {total_tdea3}") print(f" Total attempts : {total_keys}") print() found = [] checked = 0 start = time.time() for aid in aid_list: print(f" Checking AID {CY}{aid}{C0} key#{key_no} ...") aid_found = False for key in des_keys: checked += 1 ok = self._try_key(aid, key_no, key, 'DES') if ok: msg = f" {CG}[+] AID {aid} DES/2TDEA key#{key_no} key: {key.hex().upper()}{C0}" print(msg) found.append(('DES', aid, key_no, key.hex().upper())) aid_found = True break else: print(f" {CR}✗{C0} DES {key.hex().upper()}") if not aid_found: for key in aes_keys: checked += 1 ok = self._try_key(aid, key_no, key, 'AES') if ok: msg = f" {CG}[+] AID {aid} AES-128 key#{key_no} key: {key.hex().upper()}{C0}" print(msg) found.append(('AES', aid, key_no, key.hex().upper())) aid_found = True break else: print(f" {CR}✗{C0} AES {key.hex().upper()}") if not aid_found: for key in tdea3_keys: checked += 1 ok = self._try_key(aid, key_no, key, '3K3DES') if ok: msg = f" {CG}[+] AID {aid} 3K3DES key#{key_no} key: {key.hex().upper()}{C0}" print(msg) found.append(('3K3DES', aid, key_no, key.hex().upper())) aid_found = True break else: print(f" {CR}✗{C0} 3K3DES {key.hex().upper()}") if not aid_found: print(f" {CY}[-] AID {aid} no key found{C0}\n") elapsed = time.time() - start print() print(f"Checked {checked} combinations in {elapsed:.1f}s") if found: print(f"\n {CG}Found {len(found)} key(s):{C0}") for algo, aid, kno, key_hex in found: print(f"\n {CG}{algo:8s} AID {aid} key#{kno} {key_hex}{C0}") else: print(f"\n {CR}No keys found{C0}") @hf_seos.command("eview") class HFSeosEView(SlotIndexArgsAndGoUnit, DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "View data from emulator memory" self.add_slot_args(parser) return parser def on_exec(self, args: argparse.Namespace): selected_slot = self.cmd.get_active_slot() slot_info = self.cmd.get_slot_info() tag_type = TagSpecificType(slot_info[selected_slot]["hf"]) if tag_type != TagSpecificType.SEOS: raise Exception( "Card in current slot is not SEOS" ) data = self.cmd.seos_read_emu_data() print("[=] Data:", data["data"].hex().upper()) print("[=] OID:", data["oid"].hex().upper()) print("[=] Tag:", data["tag"].hex().upper()) print("[=] Diversifier:", data["diversifier"].hex().upper()) @hf_seos.command("eload") class HFSeosELoad(SlotIndexArgsAndGoUnit, HF14AAntiCollArgsUnit, DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Load data into emulator memory" self.add_slot_args(parser) self.add_hf14a_anticoll_args(parser) parser.add_argument("-d", "--data", type=str, default=None, metavar="", help="Data to present to reader (2-255 bytes). Must be valid BER-TLV.") parser.add_argument("-o", "--oid", type=str, default=None, metavar="", help=f"Target OID (1-32 bytes).") parser.add_argument("-t", "--tag", type=str, default=None, metavar="", help=f"Tag of presented data (1-2 bytes).") parser.add_argument("--diversifier", type=str, default=None, metavar="", help=f"Simulated card diversifier (1-16 bytes).") return parser def on_exec(self, args: argparse.Namespace): selected_slot = self.cmd.get_active_slot() slot_info = self.cmd.get_slot_info() tag_type = TagSpecificType(slot_info[selected_slot]["hf"]) if tag_type != TagSpecificType.SEOS: raise Exception( "Card in current slot is not SEOS" ) # Handle ISO14443-A anticollision changes anti_coll_data = self.cmd.hf14a_get_anti_coll_data() if anti_coll_data is None or len(anti_coll_data) == 0: print( f"{color_string((CR, f'Slot does not contain any HF 14A config'))}" ) return uid = anti_coll_data["uid"] atqa = anti_coll_data["atqa"] sak = anti_coll_data["sak"] ats = anti_coll_data["ats"] change_requested, change_done, uid, atqa, sak, ats = self.update_hf14a_anticoll( args, uid, atqa, sak, ats ) if ( args.data is None and args.oid is None and args.tag is None and args.diversifier is None and change_requested is False ): print(color_string((CR, "Error: No changes were requested."))) seos_data = self.cmd.seos_read_emu_data() # Parse args data = bytes.fromhex(args.data) if args.data else seos_data["data"] oid = bytes.fromhex(args.oid) if args.oid else seos_data["oid"] tag = bytes.fromhex(args.tag) if args.tag else seos_data["tag"] diversifier = bytes.fromhex(args.diversifier) if args.diversifier else seos_data["diversifier"] # These are not currently configurable hash_alg = seos_data["hash_alg"] encr_alg = seos_data["encr_alg"] if len(data) < 2 or len(data) > 255: print(color_string((CR, "Error: invalid data length. Accepts 2-255 bytes."))) return if len(oid) < 1 or len(oid) > 32: print(color_string((CR, "Error: invalid OID length. Accepts 1-32 bytes."))) return if len(tag) < 1 or len(tag) > 2: print(color_string((CR, "Error: invalid tag length. Accepts 1-2 bytes."))) return if len(diversifier) < 1 or len(diversifier) > 16: print(color_string((CR, "Error: invalid diversifier length. Accepts 1-16 bytes."))) return self.cmd.seos_write_emu_data( data=data, oid=oid, tag=tag, diversifier=diversifier, hash_alg=hash_alg, encr_alg=encr_alg ) @hf_seos.command("keys") class HFSeosKeys(SlotIndexArgsAndGoUnit, DeviceRequiredUnit): def args_parser(self) -> ArgumentParserNoExit: parser = ArgumentParserNoExit() parser.description = "Load data into emulator memory" self.add_slot_args(parser) parser.add_argument("-a", "--auth", type=str, metavar="", required=True, help="Auth key (16 bytes)") parser.add_argument("-e", "--privenc", type=str, metavar="", required=True, help="PrivEnc key (16 bytes)") parser.add_argument("-m", "--privmac", type=str, metavar="", required=True, help="PrivMac key (16 bytes)") return parser def on_exec(self, args: argparse.Namespace): selected_slot = self.cmd.get_active_slot() slot_info = self.cmd.get_slot_info() tag_type = TagSpecificType(slot_info[selected_slot]["hf"]) if tag_type != TagSpecificType.SEOS: raise Exception( "Card in current slot is not SEOS" ) # Parse args auth = bytes.fromhex(args.auth) privenc = bytes.fromhex(args.privenc) privmac = bytes.fromhex(args.privmac) if len(auth) != 16: print(color_string((CR, "Error: invalid auth key length. Accepts 16 bytes."))) return if len(privenc) != 16: print(color_string((CR, "Error: invalid PrivEnc key length. Accepts 16 bytes."))) return if len(privmac) != 16: print(color_string((CR, "Error: invalid PrivMac key length. Accepts 16 bytes."))) return self.cmd.seos_write_emu_keys( auth=auth, privenc=privenc, privmac=privmac )