pysim/pySim/apdu/__init__.py

437 lines
16 KiB
Python

# coding=utf-8
"""APDU (and TPDU) parser for UICC/USIM/ISIM cards.
The File (and its classes) represent the structure / hierarchy
of the APDUs as seen in SIM/UICC/SIM/ISIM cards. The primary use case
is to perform a meaningful decode of protocol traces taken between card and UE.
The ancient wirshark dissector developed for GSMTAP generated by SIMtrace
is far too simplistic, while this decoder can utilize all of the information
we already know in pySim about the filesystem structure, file encoding, etc.
"""
# (C) 2022 by Harald Welte <laforge@osmocom.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import abc
from termcolor import colored
from typing import List, Dict, Optional
from construct import *
from construct import Optional as COptional
from pySim.construct import *
from pySim.utils import *
from pySim.filesystem import RuntimeLchan, RuntimeState, lchan_nr_from_cla
from pySim.filesystem import CardADF, CardFile, TransparentEF, LinFixedEF
"""There are multiple levels of decode:
1) pure TPDU / APDU level (no filesystem state required to decode)
1a) the raw C-TPDU + R-TPDU
1b) the raw C-APDU + R-APDU
1c) the C-APDU + R-APDU split in its portions (p1/p2/lc/le/cmd/rsp)
1d) the abstract C-APDU + R-APDU (mostly p1/p2 parsing; SELECT response)
2) the decoded DATA of command/response APDU
* READ/UPDATE: requires state/context: which file is selected? how to decode it?
"""
class ApduCommandMeta(abc.ABCMeta):
"""A meta-class that we can use to set some class variables when declaring
a derived class of ApduCommand."""
def __new__(metacls, name, bases, namespace, **kwargs):
x = super().__new__(metacls, name, bases, namespace)
x._name = namespace.get('name', kwargs.get('n', None))
x._ins = namespace.get('ins', kwargs.get('ins', None))
x._cla = namespace.get('cla', kwargs.get('cla', None))
return x
class Tpdu:
def __init__(self, cmd: bytes, rsp: Optional[bytes] = None):
self.cmd = cmd
self.rsp = rsp
def __str__(self):
return '%s(%02X %02X %02X %02X %02X %s %s %s)' % (type(self).__name__, self.cla, self.ins, self.p1,
self.p2, self.p3, b2h(self.cmd_data), b2h(self.rsp_data), b2h(self.sw))
@property
def cla(self) -> int:
"""Return CLA of the C-APDU Header."""
return self.cmd[0]
@property
def ins(self) -> int:
"""Return INS of the C-APDU Header."""
return self.cmd[1]
@property
def p1(self) -> int:
"""Return P1 of the C-APDU Header."""
return self.cmd[2]
@property
def p2(self) -> int:
"""Return P2 of the C-APDU Header."""
return self.cmd[3]
@property
def p3(self) -> int:
"""Return P3 of the C-APDU Header."""
return self.cmd[4]
@property
def cmd_data(self) -> int:
"""Return the DATA portion of the C-APDU"""
return self.cmd[5:]
@property
def sw(self) -> Optional[bytes]:
"""Return Status Word (SW) of the R-APDU"""
return self.rsp[-2:] if self.rsp else None
@property
def rsp_data(self) -> Optional[bytes]:
"""Return the DATA portion of the R-APDU"""
return self.rsp[:-2] if self.rsp else None
class Apdu(Tpdu):
@property
def lc(self) -> int:
"""Return Lc; Length of C-APDU body."""
return len(self.cmd_data)
@property
def lr(self) -> int:
"""Return Lr; Length of R-APDU body."""
return len(self.rsp_data)
@property
def successful(self) -> bool:
"""Was the execution of this APDU successful?"""
method = getattr(self, '_is_success', None)
if callable(method):
return method()
# default case: only 9000 is success
return self.sw == b'\x90\x00'
class ApduCommand(Apdu, metaclass=ApduCommandMeta):
"""Base class from which you would derive individual commands/instructions like SELECT.
A derived class represents a decoder for a specific instruction.
An instance of such a derived class is one concrete APDU."""
# fall-back constructs if the derived class provides no override
_construct_p1 = Byte
_construct_p2 = Byte
_construct = HexAdapter(GreedyBytes)
_construct_rsp = HexAdapter(GreedyBytes)
def __init__(self, cmd: bytes, rsp: Optional[bytes] = None):
"""Instantiate a new ApduCommand from give cmd + resp."""
# store raw data
super().__init__(cmd, rsp)
# default to 'empty' ID column. To be set to useful values (like record number)
# by derived class {cmd_rsp}_to_dict() or process() methods
self.col_id = '-'
# fields only set by process_* methods
self.file = None
self.lchan = None
self.processed = None
# the methods below could raise exceptions and those handlers might assume cmd_{dict,resp}
self.cmd_dict = None
self.rsp_dict = None
# interpret the data
self.cmd_dict = self.cmd_to_dict()
self.rsp_dict = self.rsp_to_dict() if self.rsp else {}
@classmethod
def from_apdu(cls, apdu:Apdu, **kwargs) -> 'ApduCommand':
"""Instantiate an ApduCommand from an existing APDU."""
return cls(cmd=apdu.cmd, rsp=apdu.rsp, **kwargs)
@classmethod
def from_bytes(cls, buffer:bytes) -> 'ApduCommand':
"""Instantiate an ApduCommand from a linear byte buffer containing hdr,cmd,rsp,sw.
This is for example used when parsing GSMTAP traces that traditionally contain the
full command and response portion in one packet: "CLA INS P1 P2 P3 DATA SW" and we
now need to figure out whether the DATA part is part of the CMD or the RSP"""
apdu_case = cls.get_apdu_case(buffer)
if apdu_case in [1, 2]:
# data is part of response
return cls(buffer[:5], buffer[5:])
elif apdu_case in [3, 4]:
# data is part of command
lc = buffer[4]
return cls(buffer[:5+lc], buffer[5+lc:])
else:
raise ValueError('%s: Invalid APDU Case %u' % (cls.__name__, apdu_case))
@property
def path(self) -> List[str]:
"""Return (if known) the path as list of files to the file on which this command operates."""
if self.file:
return self.file.fully_qualified_path()
else:
return []
@property
def path_str(self) -> str:
"""Return (if known) the path as string to the file on which this command operates."""
if self.file:
return self.file.fully_qualified_path_str()
else:
return ''
@property
def col_sw(self) -> str:
"""Return the ansi-colorized status word. Green==OK, Red==Error"""
if self.successful:
return colored(b2h(self.sw), 'green')
else:
return colored(b2h(self.sw), 'red')
@property
def lchan_nr(self) -> int:
"""Logical channel number over which this ApduCommand was transmitted."""
if self.lchan:
return self.lchan.lchan_nr
else:
return lchan_nr_from_cla(self.cla)
def __str__(self) -> str:
return '%02u %s(%s): %s' % (self.lchan_nr, type(self).__name__, self.path_str, self.to_dict())
def __repr__(self) -> str:
return '%s(INS=%02x,CLA=%s)' % (self.__class__, self.ins, self.cla)
def _process_fallback(self, rs: RuntimeState):
"""Fall-back function to be called if there is no derived-class-specific
process_global or process_on_lchan method. Uses information from APDU decode."""
self.processed = {}
if not 'p1' in self.cmd_dict:
self.processed = self.to_dict()
else:
self.processed['p1'] = self.cmd_dict['p1']
self.processed['p2'] = self.cmd_dict['p2']
if 'body' in self.cmd_dict and self.cmd_dict['body']:
self.processed['cmd'] = self.cmd_dict['body']
if 'body' in self.rsp_dict and self.rsp_dict['body']:
self.processed['rsp'] = self.rsp_dict['body']
return self.processed
def process(self, rs: RuntimeState):
# if there is a global method, use that; else use process_on_lchan
method = getattr(self, 'process_global', None)
if callable(method):
self.processed = method(rs)
return self.processed
method = getattr(self, 'process_on_lchan', None)
if callable(method):
self.lchan = rs.get_lchan_by_cla(self.cla)
self.processed = method(self.lchan)
return self.processed
# if none of the two methods exist:
return self._process_fallback(rs)
@classmethod
def get_apdu_case(cls, hdr:bytes) -> int:
if hasattr(cls, '_apdu_case'):
return cls._apdu_case
method = getattr(cls, '_get_apdu_case', None)
if callable(method):
return method(hdr)
raise ValueError('%s: Class definition missing _apdu_case attribute or _get_apdu_case method' % cls.__name__)
@classmethod
def match_cla(cls, cla) -> bool:
"""Does the given CLA match the CLA list of the command?."""
if not isinstance(cla, str):
cla = '%02X' % cla
cla = cla.lower()
for cla_match in cls._cla:
cla_masked = ""
for i in range(0, 2):
if cla_match[i] == 'X':
cla_masked += 'X'
else:
cla_masked += cla[i]
if cla_masked == cla_match:
return True
return False
def cmd_to_dict(self) -> Dict:
"""Convert the Command part of the APDU to a dict."""
method = getattr(self, '_decode_cmd', None)
if callable(method):
return method()
else:
r = {}
method = getattr(self, '_decode_p1p2', None)
if callable(method):
r = self._decode_p1p2()
else:
r['p1'] = parse_construct(self._construct_p1, self.p1.to_bytes(1, 'big'))
r['p2'] = parse_construct(self._construct_p2, self.p2.to_bytes(1, 'big'))
r['p3'] = self.p3
if self.cmd_data:
r['body'] = parse_construct(self._construct, self.cmd_data)
return r
def rsp_to_dict(self) -> Dict:
"""Convert the Response part of the APDU to a dict."""
method = getattr(self, '_decode_rsp', None)
if callable(method):
return method()
else:
r = {}
if self.rsp_data:
r['body'] = parse_construct(self._construct_rsp, self.rsp_data)
r['sw'] = b2h(self.sw)
return r
def to_dict(self) -> Dict:
"""Convert the entire APDU to a dict."""
return {'cmd': self.cmd_dict, 'rsp': self.rsp_dict}
def to_json(self) -> str:
"""Convert the entire APDU to JSON."""
d = self.to_dict()
return json.dumps(d)
def _determine_file(self, lchan) -> CardFile:
"""Helper function for read/update commands that might use SFI instead of selected file.
Expects that the self.cmd_dict has already been populated with the 'file' member."""
if self.cmd_dict['file'] == 'currently_selected_ef':
self.file = lchan.selected_file
elif self.cmd_dict['file'] == 'sfi':
cwd = lchan.get_cwd()
self.file = cwd.lookup_file_by_sfid(self.cmd_dict['sfi'])
class ApduCommandSet:
"""A set of card instructions, typically specified within one spec."""
def __init__(self, name: str, cmds: List[ApduCommand] =[]):
self.name = name
self.cmds = {c._ins: c for c in cmds}
def __str__(self) -> str:
return self.name
def __getitem__(self, idx) -> ApduCommand:
return self.cmds[idx]
def __add__(self, other) -> 'ApduCommandSet':
if isinstance(other, ApduCommand):
if other.ins in self.cmds:
raise ValueError('%s: INS 0x%02x already defined: %s' %
(self, other.ins, self.cmds[other.ins]))
self.cmds[other.ins] = other
elif isinstance(other, ApduCommandSet):
for c in other.cmds.keys():
self.cmds[c] = other.cmds[c]
else:
raise ValueError(
'%s: Unsupported type to add operator: %s' % (self, other))
return self
def lookup(self, ins, cla=None) -> Optional[ApduCommand]:
"""look-up the command within the CommandSet."""
ins = int(ins)
if not ins in self.cmds:
return None
cmd = self.cmds[ins]
if cla and not cmd.match_cla(cla):
return None
return cmd
def parse_cmd_apdu(self, apdu: Apdu) -> ApduCommand:
"""Parse a Command-APDU. Returns an instance of an ApduCommand derived class."""
# first look-up which of our member classes match CLA + INS
a_cls = self.lookup(apdu.ins, apdu.cla)
if not a_cls:
raise ValueError('Unknown CLA=%02X INS=%02X' % (apdu.cla, apdu.ins))
# then create an instance of that class and return it
return a_cls.from_apdu(apdu)
def parse_cmd_bytes(self, buf:bytes) -> ApduCommand:
"""Parse from a buffer (simtrace style). Returns an instance of an ApduCommand derived class."""
# first look-up which of our member classes match CLA + INS
cla = buf[0]
ins = buf[1]
a_cls = self.lookup(ins, cla)
if not a_cls:
raise ValueError('Unknown CLA=%02X INS=%02X' % (cla, ins))
# then create an instance of that class and return it
return a_cls.from_bytes(buf)
class ApduHandler(abc.ABC):
@abc.abstractmethod
def input(self, cmd: bytes, rsp: bytes):
pass
class TpduFilter(ApduHandler):
"""The TpduFilter removes the T=0 specific GET_RESPONSE from the TPDU stream and
calls the ApduHandler only with the actual APDU command and response parts."""
def __init__(self, apdu_handler: ApduHandler):
self.apdu_handler = apdu_handler
self.state = 'INIT'
self.last_cmd = None
def input_tpdu(self, tpdu:Tpdu):
# handle SW=61xx / 6Cxx
if tpdu.sw[0] == 0x61 or tpdu.sw[0] == 0x6C:
self.state = 'WAIT_GET_RESPONSE'
# handle successive 61/6c responses by stupid phone/modem OS
if tpdu.ins != 0xC0:
self.last_cmd = tpdu.cmd
return None
else:
if self.last_cmd:
icmd = self.last_cmd
self.last_cmd = None
else:
icmd = tpdu.cmd
apdu = Apdu(icmd, tpdu.rsp)
if self.apdu_handler:
return self.apdu_handler.input(apdu)
else:
return Apdu(icmd, tpdu.rsp)
def input(self, cmd: bytes, rsp: bytes):
if isinstance(cmd, str):
cmd = bytes.fromhex(cmd)
if isinstance(rsp, str):
rsp = bytes.fromhex(rsp)
tpdu = Tpdu(cmd, rsp)
return self.input_tpdu(tpdu)
class ApduDecoder(ApduHandler):
def __init__(self, cmd_set: ApduCommandSet):
self.cmd_set = cmd_set
def input(self, apdu: Apdu):
return self.cmd_set.parse_cmd_apdu(apdu)
class CardReset:
pass