blob: 3961fe9d9da4d20e96c8e2642a38911214a2e240 [file] [log] [blame]
Harald Welte21caf322022-07-16 14:06:46 +02001# coding=utf-8
2"""APDU (and TPDU) parser for UICC/USIM/ISIM cards.
3
4The File (and its classes) represent the structure / hierarchy
5of the APDUs as seen in SIM/UICC/SIM/ISIM cards. The primary use case
6is to perform a meaningful decode of protocol traces taken between card and UE.
7
8The ancient wirshark dissector developed for GSMTAP generated by SIMtrace
9is far too simplistic, while this decoder can utilize all of the information
10we already know in pySim about the filesystem structure, file encoding, etc.
11"""
12
13# (C) 2022 by Harald Welte <laforge@osmocom.org>
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 2 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
27
28
29import abc
30from termcolor import colored
31import typing
32from typing import List, Dict, Optional
33
34from construct import *
35from construct import Optional as COptional
36from pySim.construct import *
37from pySim.utils import *
38from pySim.filesystem import RuntimeLchan, RuntimeState, lchan_nr_from_cla
39from pySim.filesystem import CardADF, CardFile, TransparentEF, LinFixedEF
40
41"""There are multiple levels of decode:
42
43 1) pure TPDU / APDU level (no filesystem state required to decode)
44 1a) the raw C-TPDU + R-TPDU
45 1b) the raw C-APDU + R-APDU
46 1c) the C-APDU + R-APDU split in its portions (p1/p2/lc/le/cmd/rsp)
47 1d) the abstract C-APDU + R-APDU (mostly p1/p2 parsing; SELECT response)
48 2) the decoded DATA of command/response APDU
49 * READ/UPDATE: requires state/context: which file is selected? how to decode it?
50"""
51
52class ApduCommandMeta(abc.ABCMeta):
53 """A meta-class that we can use to set some class variables when declaring
54 a derived class of ApduCommand."""
55 def __new__(metacls, name, bases, namespace, **kwargs):
56 x = super().__new__(metacls, name, bases, namespace)
57 x._name = namespace.get('name', kwargs.get('n', None))
58 x._ins = namespace.get('ins', kwargs.get('ins', None))
59 x._cla = namespace.get('cla', kwargs.get('cla', None))
60 return x
61
62BytesOrHex = typing.Union[bytes, Hexstr]
63
64class Tpdu:
65 def __init__(self, cmd: BytesOrHex, rsp: Optional[BytesOrHex] = None):
66 if isinstance(cmd, str):
67 self.cmd = h2b(cmd)
68 else:
69 self.cmd = cmd
70 if isinstance(rsp, str):
71 self.rsp = h2b(rsp)
72 else:
73 self.rsp = rsp
74
75 def __str__(self):
76 return '%s(%02X %02X %02X %02X %02X %s %s %s)' % (type(self).__name__, self.cla, self.ins, self.p1,
77 self.p2, self.p3, b2h(self.cmd_data), b2h(self.rsp_data), b2h(self.sw))
78
79 @property
80 def cla(self) -> int:
81 """Return CLA of the C-APDU Header."""
82 return self.cmd[0]
83
84 @property
85 def ins(self) -> int:
86 """Return INS of the C-APDU Header."""
87 return self.cmd[1]
88
89 @property
90 def p1(self) -> int:
91 """Return P1 of the C-APDU Header."""
92 return self.cmd[2]
93
94 @property
95 def p2(self) -> int:
96 """Return P2 of the C-APDU Header."""
97 return self.cmd[3]
98
99 @property
100 def p3(self) -> int:
101 """Return P3 of the C-APDU Header."""
102 return self.cmd[4]
103
104 @property
105 def cmd_data(self) -> int:
106 """Return the DATA portion of the C-APDU"""
107 return self.cmd[5:]
108
109 @property
110 def sw(self) -> Optional[bytes]:
111 """Return Status Word (SW) of the R-APDU"""
112 return self.rsp[-2:] if self.rsp else None
113
114 @property
115 def rsp_data(self) -> Optional[bytes]:
116 """Return the DATA portion of the R-APDU"""
117 return self.rsp[:-2] if self.rsp else None
118
119
120class Apdu(Tpdu):
121 @property
122 def lc(self) -> int:
123 """Return Lc; Length of C-APDU body."""
124 return len(self.cmd_data)
125
126 @property
127 def lr(self) -> int:
128 """Return Lr; Length of R-APDU body."""
129 return len(self.rsp_data)
130
131 @property
132 def successful(self) -> bool:
133 """Was the execution of this APDU successful?"""
134 method = getattr(self, '_is_success', None)
135 if callable(method):
136 return method()
137 # default case: only 9000 is success
138 return self.sw == b'\x90\x00'
139
140
141class ApduCommand(Apdu, metaclass=ApduCommandMeta):
142 """Base class from which you would derive individual commands/instructions like SELECT.
143 A derived class represents a decoder for a specific instruction.
144 An instance of such a derived class is one concrete APDU."""
145 # fall-back constructs if the derived class provides no override
146 _construct_p1 = Byte
147 _construct_p2 = Byte
148 _construct = HexAdapter(GreedyBytes)
149 _construct_rsp = HexAdapter(GreedyBytes)
150
151 def __init__(self, cmd: BytesOrHex, rsp: Optional[BytesOrHex] = None):
152 """Instantiate a new ApduCommand from give cmd + resp."""
153 # store raw data
154 super().__init__(cmd, rsp)
155 # default to 'empty' ID column. To be set to useful values (like record number)
156 # by derived class {cmd_rsp}_to_dict() or process() methods
157 self.col_id = '-'
158 # fields only set by process_* methods
159 self.file = None
160 self.lchan = None
161 self.processed = None
162 # the methods below could raise exceptions and those handlers might assume cmd_{dict,resp}
163 self.cmd_dict = None
164 self.rsp_dict = None
165 # interpret the data
166 self.cmd_dict = self.cmd_to_dict()
167 self.rsp_dict = self.rsp_to_dict() if self.rsp else {}
168
169
170 @classmethod
171 def from_apdu(cls, apdu:Apdu, **kwargs) -> 'ApduCommand':
172 """Instantiate an ApduCommand from an existing APDU."""
173 return cls(cmd=apdu.cmd, rsp=apdu.rsp, **kwargs)
174
175 @classmethod
176 def from_bytes(cls, buffer:bytes) -> 'ApduCommand':
177 """Instantiate an ApduCommand from a linear byte buffer containing hdr,cmd,rsp,sw.
178 This is for example used when parsing GSMTAP traces that traditionally contain the
179 full command and response portion in one packet: "CLA INS P1 P2 P3 DATA SW" and we
180 now need to figure out whether the DATA part is part of the CMD or the RSP"""
181 apdu_case = cls.get_apdu_case(buffer)
182 if apdu_case in [1, 2]:
183 # data is part of response
184 return cls(buffer[:5], buffer[5:])
185 elif apdu_case in [3, 4]:
186 # data is part of command
187 lc = buffer[4]
188 return cls(buffer[:5+lc], buffer[5+lc:])
189 else:
190 raise ValueError('%s: Invalid APDU Case %u' % (cls.__name__, apdu_case))
191
192 @property
193 def path(self) -> List[str]:
194 """Return (if known) the path as list of files to the file on which this command operates."""
195 if self.file:
196 return self.file.fully_qualified_path()
197 else:
198 return []
199
200 @property
201 def path_str(self) -> str:
202 """Return (if known) the path as string to the file on which this command operates."""
203 if self.file:
204 return self.file.fully_qualified_path_str()
205 else:
206 return ''
207
208 @property
209 def col_sw(self) -> str:
210 """Return the ansi-colorized status word. Green==OK, Red==Error"""
211 if self.successful:
212 return colored(b2h(self.sw), 'green')
213 else:
214 return colored(b2h(self.sw), 'red')
215
216 @property
217 def lchan_nr(self) -> int:
218 """Logical channel number over which this ApduCommand was transmitted."""
219 if self.lchan:
220 return self.lchan.lchan_nr
221 else:
222 return lchan_nr_from_cla(self.cla)
223
224 def __str__(self) -> str:
225 return '%02u %s(%s): %s' % (self.lchan_nr, type(self).__name__, self.path_str, self.to_dict())
226
227 def __repr__(self) -> str:
228 return '%s(INS=%02x,CLA=%s)' % (self.__class__, self.ins, self.cla)
229
230 def _process_fallback(self, rs: RuntimeState):
231 """Fall-back function to be called if there is no derived-class-specific
232 process_global or process_on_lchan method. Uses information from APDU decode."""
233 self.processed = {}
234 if not 'p1' in self.cmd_dict:
235 self.processed = self.to_dict()
236 else:
237 self.processed['p1'] = self.cmd_dict['p1']
238 self.processed['p2'] = self.cmd_dict['p2']
239 if 'body' in self.cmd_dict and self.cmd_dict['body']:
240 self.processed['cmd'] = self.cmd_dict['body']
241 if 'body' in self.rsp_dict and self.rsp_dict['body']:
242 self.processed['rsp'] = self.rsp_dict['body']
243 return self.processed
244
245 def process(self, rs: RuntimeState):
246 # if there is a global method, use that; else use process_on_lchan
247 method = getattr(self, 'process_global', None)
248 if callable(method):
249 self.processed = method(rs)
250 return self.processed
251 method = getattr(self, 'process_on_lchan', None)
252 if callable(method):
253 self.lchan = rs.get_lchan_by_cla(self.cla)
254 self.processed = method(self.lchan)
255 return self.processed
256 # if none of the two methods exist:
257 return self._process_fallback(rs)
258
259 @classmethod
260 def get_apdu_case(cls, hdr:bytes) -> int:
261 if hasattr(cls, '_apdu_case'):
262 return cls._apdu_case
263 method = getattr(cls, '_get_apdu_case', None)
264 if callable(method):
265 return method(hdr)
266 raise ValueError('%s: Class definition missing _apdu_case attribute or _get_apdu_case method' % cls.__name__)
267
268 @classmethod
269 def match_cla(cls, cla) -> bool:
270 """Does the given CLA match the CLA list of the command?."""
271 if not isinstance(cla, str):
272 cla = '%02X' % cla
273 cla = cla.lower()
274 # see https://github.com/PyCQA/pylint/issues/7219
275 # pylint: disable=no-member
276 for cla_match in cls._cla:
277 cla_masked = ""
278 for i in range(0, 2):
279 if cla_match[i] == 'X':
280 cla_masked += 'X'
281 else:
282 cla_masked += cla[i]
283 if cla_masked == cla_match:
284 return True
285 return False
286
287 def cmd_to_dict(self) -> Dict:
288 """Convert the Command part of the APDU to a dict."""
289 method = getattr(self, '_decode_cmd', None)
290 if callable(method):
291 return method()
292 else:
293 r = {}
294 method = getattr(self, '_decode_p1p2', None)
295 if callable(method):
296 r = self._decode_p1p2()
297 else:
298 r['p1'] = parse_construct(self._construct_p1, self.p1.to_bytes(1, 'big'))
299 r['p2'] = parse_construct(self._construct_p2, self.p2.to_bytes(1, 'big'))
300 r['p3'] = self.p3
301 if self.cmd_data:
302 r['body'] = parse_construct(self._construct, self.cmd_data)
303 return r
304
305 def rsp_to_dict(self) -> Dict:
306 """Convert the Response part of the APDU to a dict."""
307 method = getattr(self, '_decode_rsp', None)
308 if callable(method):
309 return method()
310 else:
311 r = {}
312 if self.rsp_data:
313 r['body'] = parse_construct(self._construct_rsp, self.rsp_data)
314 r['sw'] = b2h(self.sw)
315 return r
316
317 def to_dict(self) -> Dict:
318 """Convert the entire APDU to a dict."""
319 return {'cmd': self.cmd_dict, 'rsp': self.rsp_dict}
320
321 def to_json(self) -> str:
322 """Convert the entire APDU to JSON."""
323 d = self.to_dict()
324 return json.dumps(d)
325
326 def _determine_file(self, lchan) -> CardFile:
327 """Helper function for read/update commands that might use SFI instead of selected file.
328 Expects that the self.cmd_dict has already been populated with the 'file' member."""
329 if self.cmd_dict['file'] == 'currently_selected_ef':
330 self.file = lchan.selected_file
331 elif self.cmd_dict['file'] == 'sfi':
332 cwd = lchan.get_cwd()
333 self.file = cwd.lookup_file_by_sfid(self.cmd_dict['sfi'])
334
335
336class ApduCommandSet:
337 """A set of card instructions, typically specified within one spec."""
338
339 def __init__(self, name: str, cmds: List[ApduCommand] =[]):
340 self.name = name
341 self.cmds = {c._ins: c for c in cmds}
342
343 def __str__(self) -> str:
344 return self.name
345
346 def __getitem__(self, idx) -> ApduCommand:
347 return self.cmds[idx]
348
349 def __add__(self, other) -> 'ApduCommandSet':
350 if isinstance(other, ApduCommand):
351 if other.ins in self.cmds:
352 raise ValueError('%s: INS 0x%02x already defined: %s' %
353 (self, other.ins, self.cmds[other.ins]))
354 self.cmds[other.ins] = other
355 elif isinstance(other, ApduCommandSet):
356 for c in other.cmds.keys():
357 self.cmds[c] = other.cmds[c]
358 else:
359 raise ValueError(
360 '%s: Unsupported type to add operator: %s' % (self, other))
361 return self
362
363 def lookup(self, ins, cla=None) -> Optional[ApduCommand]:
364 """look-up the command within the CommandSet."""
365 ins = int(ins)
366 if not ins in self.cmds:
367 return None
368 cmd = self.cmds[ins]
369 if cla and not cmd.match_cla(cla):
370 return None
371 return cmd
372
373 def parse_cmd_apdu(self, apdu: Apdu) -> ApduCommand:
374 """Parse a Command-APDU. Returns an instance of an ApduCommand derived class."""
375 # first look-up which of our member classes match CLA + INS
376 a_cls = self.lookup(apdu.ins, apdu.cla)
377 if not a_cls:
378 raise ValueError('Unknown CLA=%02X INS=%02X' % (apdu.cla, apdu.ins))
379 # then create an instance of that class and return it
380 return a_cls.from_apdu(apdu)
381
382 def parse_cmd_bytes(self, buf:bytes) -> ApduCommand:
383 """Parse from a buffer (simtrace style). Returns an instance of an ApduCommand derived class."""
384 # first look-up which of our member classes match CLA + INS
385 cla = buf[0]
386 ins = buf[1]
387 a_cls = self.lookup(ins, cla)
388 if not a_cls:
389 raise ValueError('Unknown CLA=%02X INS=%02X' % (cla, ins))
390 # then create an instance of that class and return it
391 return a_cls.from_bytes(buf)
392
393
394
395class ApduHandler(abc.ABC):
396 @abc.abstractmethod
397 def input(self, cmd: bytes, rsp: bytes):
398 pass
399
400
401class TpduFilter(ApduHandler):
402 """The TpduFilter removes the T=0 specific GET_RESPONSE from the TPDU stream and
403 calls the ApduHandler only with the actual APDU command and response parts."""
404 def __init__(self, apdu_handler: ApduHandler):
405 self.apdu_handler = apdu_handler
406 self.state = 'INIT'
407 self.last_cmd = None
408
409 def input_tpdu(self, tpdu:Tpdu):
410 # handle SW=61xx / 6Cxx
411 if tpdu.sw[0] == 0x61 or tpdu.sw[0] == 0x6C:
412 self.state = 'WAIT_GET_RESPONSE'
413 # handle successive 61/6c responses by stupid phone/modem OS
414 if tpdu.ins != 0xC0:
415 self.last_cmd = tpdu.cmd
416 return None
417 else:
418 if self.last_cmd:
419 icmd = self.last_cmd
420 self.last_cmd = None
421 else:
422 icmd = tpdu.cmd
423 apdu = Apdu(icmd, tpdu.rsp)
424 if self.apdu_handler:
425 return self.apdu_handler.input(apdu)
426 else:
427 return Apdu(icmd, tpdu.rsp)
428
429 def input(self, cmd: bytes, rsp: bytes):
430 if isinstance(cmd, str):
431 cmd = bytes.fromhex(cmd)
432 if isinstance(rsp, str):
433 rsp = bytes.fromhex(rsp)
434 tpdu = Tpdu(cmd, rsp)
435 return self.input_tpdu(tpdu)
436
437class ApduDecoder(ApduHandler):
438 def __init__(self, cmd_set: ApduCommandSet):
439 self.cmd_set = cmd_set
440
441 def input(self, apdu: Apdu):
442 return self.cmd_set.parse_cmd_apdu(apdu)
443
444
445class CardReset:
446 pass