From 840e233baf7bdc9fccbf3c21b3624a37a348331e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 24 Aug 2026 19:51:40 +0300 Subject: [PATCH] Flag ciphered SCP80 fields: on-wire CNTR/PCNTR/RC are ciphertext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-reported: the decoded CNTR (0x1830A44E41 ≈ 104 billion) cannot be the real counter — the OTA platform shows ~200 for that card. Root cause: per TS 102 225 Table 2 note 1, when SPI indicates ciphering the whole block from CNTR through the secured data (CNTR, PCNTR, RC/CC/DS, secured data) is ENCRYPTED. Only CPI, CPL, CHI/CHL, SPI, KIc, KID and TAR travel in the clear. Our decoder parsed the ciphertext bytes as if they were the real CNTR/PCNTR/RC and presented them as such — misleading (the true values are only recoverable after 3DES decryption with the card's keys). Fix: - _decode_secured_packet: emit 'ciphered': True when SPI1.b3 is set (wire hex values kept for reference); docstring documents the ciphered block. - _decode_response_packet: accepts ciphered (command SPI2.b5, PoR ciphering) and flags the response likewise; database context now carries por_ciphered from the ENVELOPE so the GET RESPONSE PoR inherits it. Standalone SMS-SUBMIT PoRs carry no SPI — state unknowable, defaults to False (documented). - Frontend: CNTR/PCNTR/RC-CC-DS labels get "(encrypted)" when the respective packet is ciphered (command, response, and ENVELOPE response renderings). Tests: +4 TestSecuredCiphering (ciphered flagged, unciphered plain CNTR e.g. 200, response flag, PoR-via-context). 348 total. Version 1.18.0, SW cache simtrace-analyser-v26. --- frontend/index.html | 39 ++++++++++++---------- frontend/sw.js | 2 +- pyproject.toml | 2 +- simtrace2_pysniff/__init__.py | 2 +- simtrace2_pysniff/server/database.py | 2 ++ simtrace2_pysniff/server/decode.py | 19 +++++++++-- tests/test_decode.py | 48 ++++++++++++++++++++++++++++ tests/test_pcap_import.py | 2 +- 8 files changed, 93 insertions(+), 23 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 60c9398..f01e256 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -633,13 +633,14 @@ function responseRows(resp) { } } if (resp.rpl !== undefined) { + const renc = resp.ciphered ? ' (encrypted)' : ''; rows.push(['Response packet', 'Response Packet Length ' + resp.rpl + ' · Response Header Length ' + resp.rhl]); rows.push(['TAR', '0x' + resp.tar]); - rows.push(['CNTR', '0x' + resp.cntr]); - rows.push(['PCNTR', resp.pcntr]); + rows.push(['CNTR' + renc, '0x' + resp.cntr]); + rows.push(['PCNTR' + renc, resp.pcntr]); const st = resp.status || {}; rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]); - if (resp.rc_cc_ds) rows.push(['RC/CC/DS', resp.rc_cc_ds]); + if (resp.rc_cc_ds) rows.push(['RC/CC/DS' + renc, resp.rc_cc_ds]); if (resp.data) rows.push(['Response data', resp.data]); } @@ -763,23 +764,27 @@ function cmdRows(cmd) { rows.push(['KIc', 'key ' + sec.kic.key + ' — ' + sec.kic.algo]); rows.push(['KID', 'key ' + sec.kid.key + ' — ' + sec.kid.algo]); rows.push(['TAR', '0x' + sec.tar]); - rows.push(['CNTR', '0x' + sec.cntr]); - rows.push(['PCNTR', sec.pcntr]); - if (sec.rc_cc_ds) rows.push(['RC/CC/DS', sec.rc_cc_ds]); + // When the packet is ciphered, CNTR/PCNTR/RC-CC-DS on the wire + // are ciphertext (TS 102 225 Table 2 note 1) — not real values. + const enc = sec.ciphered ? ' (encrypted)' : ''; + rows.push(['CNTR' + enc, '0x' + sec.cntr]); + rows.push(['PCNTR' + enc, sec.pcntr]); + if (sec.rc_cc_ds) rows.push(['RC/CC/DS' + enc, sec.rc_cc_ds]); rows.push(['Secured data', sec.data]); } const rp = t.response_packet; - if (rp && !rp.raw) { - rows.push(['Response packet', 'Response Packet Length ' + rp.rpl + ' · Response Header Length ' + rp.rhl]); - rows.push(['TAR', '0x' + rp.tar]); - rows.push(['CNTR', '0x' + rp.cntr]); - rows.push(['PCNTR', rp.pcntr]); - const st = rp.status || {}; - rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]); - if (rp.rc_cc_ds) rows.push(['RC/CC/DS', rp.rc_cc_ds]); - rows.push(['Response data', rp.data]); - } + if (rp && !rp.raw) { + const renc = rp.ciphered ? ' (encrypted)' : ''; + rows.push(['Response packet', 'Response Packet Length ' + rp.rpl + ' · Response Header Length ' + rp.rhl]); + rows.push(['TAR', '0x' + rp.tar]); + rows.push(['CNTR' + renc, '0x' + rp.cntr]); + rows.push(['PCNTR' + renc, rp.pcntr]); + const st = rp.status || {}; + rows.push(['Status', '0x' + st.code + (st.name ? ' — ' + st.name : '')]); + if (rp.rc_cc_ds) rows.push(['RC/CC/DS' + renc, rp.rc_cc_ds]); + rows.push(['Response data', rp.data]); + } } if (!rows.length) return ''; @@ -1901,7 +1906,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '1.17.1'; +const PWA_VERSION = '1.18.0'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 9540a82..557cd4e 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v25'; +const CACHE = 'simtrace-analyser-v26'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 7eef658..eca3e03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace2-pysniff" -version = "1.17.1" +version = "1.18.0" description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "GPL-2.0-or-later"} diff --git a/simtrace2_pysniff/__init__.py b/simtrace2_pysniff/__init__.py index 5a2c95a..7a2759a 100644 --- a/simtrace2_pysniff/__init__.py +++ b/simtrace2_pysniff/__init__.py @@ -6,7 +6,7 @@ from .gsmtap import GsmtapSender, GsmtapReceiver from .pcap import PcapWriter from .dump import FileDumper, format_message -__version__ = '1.17.1' +__version__ = '1.18.0' __all__ = [ 'SniffSession', 'DeviceDisconnected', diff --git a/simtrace2_pysniff/server/database.py b/simtrace2_pysniff/server/database.py index 25a9ca5..beefac5 100644 --- a/simtrace2_pysniff/server/database.py +++ b/simtrace2_pysniff/server/database.py @@ -310,11 +310,13 @@ class Database: file_ok = True elif ins in (0xB0, 0xD6): file_ok = (d.get('p1p2') or {}).get('value', 0) == 0 + sec = ((d.get('cmd') or {}).get('tpdu') or {}).get('secured') or {} return { 'ins': ins, 'ins_name': d.get('ins_name'), 'sw1': (d.get('sw') or {}).get('sw1'), 'file_ok': file_ok, + 'por_ciphered': bool((sec.get('spi') or {}).get('por_ciphered')), } def _selection_after(self, d, sel): diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py index 4a89460..6365c07 100644 --- a/simtrace2_pysniff/server/decode.py +++ b/simtrace2_pysniff/server/decode.py @@ -2293,6 +2293,11 @@ def _decode_secured_packet(body): *body* is the TP-UD remainder after the UDH (CPI IEI 0x70). Returns a dict of the decoded header fields; ``{'raw': hex}`` when unparseable. + + When SPI indicates ciphering, the on-wire CNTR, PCNTR and RC/CC/DS are + CIPHERTEXT (TS 102 225 Table 2, note 1 — the whole block from CNTR + through the secured data is encrypted); ``'ciphered'`` is set so + callers do not present those bytes as the real values. """ if not body or len(body) < 16: return {'raw': body.hex().upper()} if body else {} @@ -2305,6 +2310,7 @@ def _decode_secured_packet(body): cntr = body[10:15] pcntr = body[15] spi1, spi2 = spi[0], spi[1] + ciphered = bool(spi1 & 0x04) rc_cc_ds_kind = spi1 & 0x03 rc_cc_ds_len = max(0, chl - 13) # 13 = SPI+KIc+KID+TAR+CNTR+PCNTR rc_cc_ds = body[16:16 + rc_cc_ds_len] @@ -2313,6 +2319,7 @@ def _decode_secured_packet(body): result = { 'cpl': cpl, 'chl': chl, + 'ciphered': ciphered, 'spi': { 'hex': spi.hex().upper(), 'counter': _OTA_COUNTER.get((spi1 >> 4) & 0x03, 'reserved'), @@ -2349,11 +2356,17 @@ def _decode_secured_packet(body): return result -def _decode_response_packet(body): +def _decode_response_packet(body, ciphered=False): """Decode a Response Packet (TS 102 225 §5.2 / TS 31.115 §4.4). *body* is the TP-UD remainder after the UDH (RPI IEI 0x71). Returns a dict of the decoded header fields; ``{'raw': hex}`` when unparseable. + + *ciphered* reflects the command's SPI2.b5 (PoR ciphering): when set, + the on-wire CNTR, PCNTR and RC/CC/DS are ciphertext (Table 4, note 1) + and ``'ciphered'`` is set in the result. A standalone Response Packet + (e.g. inside an SMS-SUBMIT) carries no SPI, so the state is unknowable + there and defaults to False. """ if not body or len(body) < 13: return {'raw': body.hex().upper()} if body else {} @@ -2370,6 +2383,7 @@ def _decode_response_packet(body): result = { 'rpl': rpl, 'rhl': rhl, + 'ciphered': bool(ciphered), 'tar': tar.hex().upper(), 'cntr': cntr.hex().upper(), 'pcntr': pcntr, @@ -3929,7 +3943,8 @@ def decode_message(raw_data, prev=None): # RPI mapping precedes the packet: UDHL='02', IEIa='71', # IEIDLa='00' (TS 31.115 §4.4). if data[:3] == b'\x02\x71\x00': - result['response'] = _decode_response_packet(data[3:]) + result['response'] = _decode_response_packet( + data[3:], ciphered=bool(prev.get('por_ciphered'))) else: response = _decode_response_for(prev_ins, remaining[:cmd_body_len]) if response: diff --git a/tests/test_decode.py b/tests/test_decode.py index cd00048..b16cf72 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -2080,6 +2080,54 @@ class TestSwUiccSpecific(unittest.TestCase): self.assertIsNone(self._sw('6205')['name']) +class TestSecuredCiphering(unittest.TestCase): + """Ciphered SCP80 packets: on-wire CNTR/PCNTR/RC are ciphertext + (TS 102 225 Table 2 note 1) — flagged, not shown as real values.""" + + def test_ciphered_packet_flagged(self): + # stk_test1-style SMS-PP DOWNLOAD secured packet (ciphering on): + # CNTR on the wire is ciphertext; the card's real counter (~200) + # is only recoverable after 3DES decryption. + from simtrace2_pysniff.server.decode import _decode_secured_packet + sec = bytes.fromhex('003815160115 15b000011830a44e4191ad45d0ec' + '2a291315986468a539d248fcdce7952c31cd635baec' + '7e93a5d567b59f6283316c0dad8603bf3'.replace(' ', '')) + r = _decode_secured_packet(sec) + self.assertTrue(r['ciphered']) + self.assertTrue(r['spi']['ciphering']) + self.assertEqual(r['cntr'], '1830A44E41') # wire bytes kept, flagged + + def test_unciphered_packet_plain_cntr(self): + # SPI1 = 0x10: counter present, NO ciphering, RC/CC/DS none → + # CNTR is genuine plaintext (e.g. 200 = 0x00000000C8). + from simtrace2_pysniff.server.decode import _decode_secured_packet + sec = bytes.fromhex('00100d10011515b0000100000000c800aabb') + r = _decode_secured_packet(sec) + self.assertFalse(r['ciphered']) + self.assertEqual(r['cntr'], '00000000C8') + self.assertNotIn('rc_cc_ds', r) + + def test_response_packet_ciphered_flag(self): + from simtrace2_pysniff.server.decode import _decode_response_packet + body = bytes.fromhex('001f0a00000000000000500000ab12800101230d08a0000001510000000f9a9000') + r = _decode_response_packet(body, ciphered=True) + self.assertTrue(r['ciphered']) + r2 = _decode_response_packet(body) + self.assertFalse(r2['ciphered']) + + def test_por_ciphered_via_context(self): + # GET RESPONSE after a ciphered ENVELOPE: the response packet's + # ciphered flag flows from the command's SPI2.b5 via prev context. + prev = {'ins': 0xC2, 'ins_name': 'ENVELOPE', 'sw1': '61', + 'por_ciphered': True} + raw = bytes.fromhex('80c0000019' + + '0271' + + '001f0a00000000000000500000ab12800101230d08a0000001510000000f9a9000'[:0x19 * 2] + + '9000') + r = decode_message(raw, prev=prev) + self.assertTrue(r['response']['ciphered']) + + class TestSpiBits(unittest.TestCase): """TS 102 225 §5.1.1: SPI1 b3 = ciphering, SPI2 b5 = PoR ciphering.""" diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 2a32b32..b9fe5ef 100644 --- a/tests/test_pcap_import.py +++ b/tests/test_pcap_import.py @@ -197,7 +197,7 @@ class TestImportEndpoint(unittest.TestCase): class TestVersion(unittest.TestCase): def test_package_version(self): from simtrace2_pysniff import __version__ - self.assertEqual(__version__, '1.17.1') + self.assertEqual(__version__, '1.18.0') def test_status_includes_version(self): import os