Skip to content

Maybe implement a context relevant parser for HF sniff frames? #884

Description

@NaiveTomcat

Is your feature request related to a problem? Please describe.
Now, the HF sniff frame annotation's only way to determine the frame's nature is by looking at this frame and only this frame. When encountering frames such as resp to READ cmd, it will most likely be gibberish since that frame contains only data. Also, some other annotations will benefit from implementing a context-relevant parser.

Describe the solution you'd like
Implement a context-relevant parser.

Describe alternatives you've considered
A clear and concise description of any alternative solutions or features you've considered.

Additional context
There's my approach written in Python to implement a context-relevant parser. It now only focuses on Mifare Ultralight commands since this is what I am currently working with.

Click to expand
class HfSniffDirection(Enum):
    reader_to_card = 0
    card_to_reader = 1

@dataclass(frozen=True)
class HfSniffFrame:
    raw_bit_length: int
    bit_length: int
    data: bytes
    direction: HfSniffDirection

    @property
    def is_reader_to_card(self) -> bool:
        return self.direction == HfSniffDirection.reader_to_card

    @property
    def is_card_to_reader(self) -> bool:
        return self.direction == HfSniffDirection.card_to_reader

    @property
    def hex_string(self) -> str:
        return _hex(self.data)

@dataclass(frozen=True)
class HfSniffAnnotatedFrame:
    frame: HfSniffFrame
    label: str

@dataclass
class Hf14aDecodeContext:
    probable_ultralight: bool = False
    probable_ultralight_ev1: bool = False
    pending_ul_cmd: Optional[str] = None
    pending_page: Optional[int] = None
    pending_end_page: Optional[int] = None
    pending_counter: Optional[int] = None
    pending_anticoll_level: Optional[str] = None

def annotate_hf14a_sniff_frames(frames: List[HfSniffFrame]) -> List[HfSniffAnnotatedFrame]:
    annotated = []
    expect_nt = False
    expect_nrar = False
    last_auth_key_type = None
    last_auth_block = None
    context = Hf14aDecodeContext()

    for frame in frames:
        data = frame.data
        label = ""
        if (frame.is_reader_to_card and frame.bit_length == 32 and len(data) == 4
                and data and data[0] in (0x60, 0x61)):
            last_auth_key_type = 'A' if data[0] == 0x60 else 'B'
            last_auth_block = data[1]
            expect_nt = True
            expect_nrar = False
            label = f"MIFARE Classic AUTH Key{last_auth_key_type} block=0x{last_auth_block:02X} ({last_auth_block})"
        elif (frame.is_card_to_reader and expect_nt and frame.bit_length == 32 and len(data) == 4):
            label = f"AUTH: NT (card nonce) = {_hex(data, spaced=False)}"
            expect_nt = False
            expect_nrar = True
        elif (frame.is_reader_to_card and expect_nrar and frame.bit_length == 64 and len(data) == 8):
            label = (f"AUTH continuation: NR||AR (enc)  NR={_hex(data[:4], spaced=False)}  "
                     f"AR={_hex(data[4:8], spaced=False)}")
            expect_nrar = False
        else:
            expect_nt = False
            expect_nrar = False
            label = _decode_hf14a_frame_with_context(frame, context)
        annotated.append(HfSniffAnnotatedFrame(frame=frame, label=label))
    return annotated

def _decode_hf14a_frame_with_context(frame: HfSniffFrame, context: Hf14aDecodeContext) -> str:
    data = frame.data
    if not data:
        return _decode_hf14a_frame(frame)

    if frame.is_reader_to_card and len(data) >= 2 and data[0] in (0x93, 0x95, 0x97):
        level = {0x93: 'CL1', 0x95: 'CL2', 0x97: 'CL3'}[data[0]]
        context.pending_anticoll_level = level

    if frame.is_card_to_reader and frame.bit_length == 40 and len(data) == 5:
        level = context.pending_anticoll_level or 'CL1'
        calc = data[0] ^ data[1] ^ data[2] ^ data[3]
        uid = _hex(data[:4], spaced=False)
        if calc == data[4]:
            return f"ANTICOLL {level} response: UID={uid}  BCC=0x{data[4]:02X} (OK)"
        return f"ANTICOLL-like {level}: UID={uid}  BCC=0x{data[4]:02X}"

    if frame.is_reader_to_card:
        ul_label = _decode_ultralight_reader_command(frame, context)
        if ul_label:
            return ul_label
    else:
        ul_label = _decode_ultralight_card_response(frame, context)
        if ul_label:
            return ul_label

    return _decode_hf14a_frame(frame)

def _decode_ultralight_reader_command(frame: HfSniffFrame, context: Hf14aDecodeContext) -> Optional[str]:
    data = frame.data
    if not data:
        return None

    b0 = data[0]
    if b0 == 0x60 and len(data) in (1, 3):
        context.probable_ultralight = True
        context.probable_ultralight_ev1 = True
        context.pending_ul_cmd = "get_version"
        return "UL EV1 GET_VERSION"

    if len(data) >= 2 and b0 == 0x30:
        page = data[1]
        context.probable_ultralight = True
        context.pending_ul_cmd = "read"
        context.pending_page = page
        return f"UL READ  page={page}"

    if len(data) >= 3 and b0 == 0x3A:
        start_page, end_page = data[1], data[2]
        context.probable_ultralight = True
        context.pending_ul_cmd = "fast_read"
        context.pending_page = start_page
        context.pending_end_page = end_page
        return f"UL FAST_READ  start={start_page} end={end_page}"

    if len(data) >= 6 and b0 == 0xA2:
        page = data[1]
        payload = _hex(data[2:6]).upper()
        context.probable_ultralight = True
        context.pending_ul_cmd = "write"
        context.pending_page = page
        return f"UL WRITE  page={page} data={payload}"

    if len(data) >= 2 and b0 == 0xA0:
        page = data[1]
        context.probable_ultralight = True
        context.pending_ul_cmd = "compat_write"
        context.pending_page = page
        return f"UL COMPAT_WRITE  page={page}"

    if len(data) == 16 and context.pending_ul_cmd == "compat_write":
        page = context.pending_page if context.pending_page is not None else -1
        context.pending_ul_cmd = "compat_write_data"
        return f"UL COMPAT_WRITE DATA  page={page} data={_hex(data).upper()}"

    if len(data) >= 5 and b0 == 0x1B:
        context.probable_ultralight = True
        context.probable_ultralight_ev1 = True
        context.pending_ul_cmd = "pwd_auth"
        return f"UL EV1 PWD_AUTH  pwd={_hex(data[1:5]).upper()}"

    if len(data) >= 2 and b0 == 0x39:
        context.probable_ultralight = True
        context.probable_ultralight_ev1 = True
        context.pending_ul_cmd = "read_cnt"
        context.pending_counter = data[1]
        return f"UL EV1 READ_CNT  counter={data[1]}"

    if len(data) >= 6 and b0 == 0xA5:
        context.probable_ultralight = True
        context.probable_ultralight_ev1 = True
        context.pending_ul_cmd = "incr_cnt"
        context.pending_counter = data[1]
        value = _bytes_to_int(data[2:5])
        return f"UL EV1 INCR_CNT  counter={data[1]} inc={value}"

    if len(data) >= 2 and b0 == 0x3E:
        context.probable_ultralight = True
        context.probable_ultralight_ev1 = True
        context.pending_ul_cmd = "check_tearing"
        context.pending_counter = data[1]
        return f"UL EV1 CHECK_TEARING_EVENT  counter={data[1]}"

    if b0 == 0x3C:
        context.probable_ultralight = True
        context.probable_ultralight_ev1 = True
        context.pending_ul_cmd = "read_sig"
        return "UL EV1 READ_SIG"

    return None

def _decode_ultralight_card_response(frame: HfSniffFrame, context: Hf14aDecodeContext) -> Optional[str]:
    data = frame.data
    if not data:
        return None

    if frame.bit_length == 4 and len(data) >= 1:
        code = data[0] & 0x0F
        if code == 0xA:
            pending = context.pending_ul_cmd or "unknown"
            context.pending_ul_cmd = None
            return f"UL ACK  (for {pending})"
        return f"UL NAK/4bit response  code=0x{code:X}"

    pending = context.pending_ul_cmd
    if pending == "read":
        page = context.pending_page if context.pending_page is not None else -1
        context.pending_ul_cmd = None
        data_hex = _hex(data).upper()
        if len(data) in (16, 18):
            last_page = page + 3 if page >= 0 else -1
            if last_page >= 0:
                return f"UL READ RESP  pages={page}-{last_page} len={len(data)} data={data_hex}"
            return f"UL READ RESP  len={len(data)} data={data_hex}"
        return f"UL READ RESP?  len={len(data)} data={data_hex}"

    if pending == "fast_read":
        start_page = context.pending_page if context.pending_page is not None else -1
        end_page = context.pending_end_page if context.pending_end_page is not None else -1
        context.pending_ul_cmd = None
        data_hex = _hex(data).upper()
        if start_page >= 0 and end_page >= start_page:
            expected = (end_page - start_page + 1) * 4
            return (f"UL FAST_READ RESP  pages={start_page}-{end_page} "
                    f"len={len(data)} expected={expected} data={data_hex}")
        return f"UL FAST_READ RESP  len={len(data)} data={data_hex}"

    if pending == "get_version":
        context.pending_ul_cmd = None
        if len(data) >= 8:
            context.probable_ultralight_ev1 = True
            return (f"UL EV1 VERSION  vendor=0x{data[0]:02X} type=0x{data[1]:02X} "
                    f"sub=0x{data[2]:02X} ver={data[3]}.{data[4]} "
                    f"size=0x{data[6]:02X} proto=0x{data[7]:02X}")
        return f"UL EV1 VERSION RESP? len={len(data)}"

    if pending == "pwd_auth":
        context.pending_ul_cmd = None
        if len(data) >= 2:
            return f"UL EV1 PWD_AUTH RESP  PACK={_hex(data[:2]).upper()}"
        return f"UL EV1 PWD_AUTH RESP? len={len(data)}"

    if pending == "read_cnt":
        counter = context.pending_counter if context.pending_counter is not None else -1
        context.pending_ul_cmd = None
        if len(data) >= 3:
            value = data[0] | (data[1] << 8) | (data[2] << 16)
            return f"UL EV1 READ_CNT RESP  counter={counter} value={value}"
        return f"UL EV1 READ_CNT RESP? len={len(data)}"

    if pending == "check_tearing":
        counter = context.pending_counter if context.pending_counter is not None else -1
        context.pending_ul_cmd = None
        if len(data) >= 1:
            return f"UL EV1 CHECK_TEARING_EVENT RESP  counter={counter} value=0x{data[0]:02X}"
        return f"UL EV1 CHECK_TEARING_EVENT RESP? len={len(data)}"

    if pending == "read_sig":
        context.pending_ul_cmd = None
        if len(data) >= 32:
            return f"UL EV1 SIGNATURE RESP  len={len(data)}"
        return f"UL EV1 SIGNATURE RESP? len={len(data)}"

    if pending in ("write", "incr_cnt", "compat_write_data"):
        context.pending_ul_cmd = None
        # Some traces contain full-byte ACK/NAK instead of 4-bit representation.
        if len(data) == 1 and data[0] == 0x0A:
            return "UL ACK"

    return None

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions