From c341571300444783919eb5f1b9b06459521009d4 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: Wed, 16 Sep 2026 08:22:59 +0300 Subject: [PATCH] scp81: correct GET STATUS pagination and registry P1s (v2.1.16) - continuation repeats the SAME GET STATUS command with P2.b1 set (the pagination state lives in the card); changing the 4F criterion is a match filter, not a position - P2=03 with the last AID is rejected with 6A80 and P2=02 with it returns that single match (the earlier duplicate) - handle the standard "more data available" warning SW 63 10 (Table 11-38) in addition to the live card's proprietary CA FE - explore script: P1=40 is applications+SDs, P1=20 the ELF registry, P1=10 ELF+modules (Table 11-33) - the ELF-only registry was never queried, which hid the installed package; labels and the results decoder show C4 (ELF AID) and CC (SD AID) too - UICC_SPECS.md: GET STATUS P1/P2 tables made explicit with the pagination rule, plus BER length coding notes for the scripting templates and the TS 102 223 channel data TLV (the two >127-byte traps) 201 python + 346 frontend; service worker v153 --- docs/scp81-findings.md | 18 +++++++-- frontend/index.html | 14 ++++--- frontend/sw.js | 2 +- frontend/tests/scp81.test.js | 5 ++- pyproject.toml | 2 +- pysim_otaman_server/server.py | 75 ++++++++++------------------------- tests/test_scp81.py | 73 ++++++++++++++++++++-------------- 7 files changed, 94 insertions(+), 95 deletions(-) diff --git a/docs/scp81-findings.md b/docs/scp81-findings.md index 03336ff..e97c813 100644 --- a/docs/scp81-findings.md +++ b/docs/scp81-findings.md @@ -255,9 +255,21 @@ installed/registered entries were invisible (the installed package `AA1902BC225801` was missing from the ELF registry). The correct value is `P2=03` = "**Get next occurrence(s)**". -**Fix:** `_scp81_continuation` emits `80F2 03 4F 00` -and the earlier duplicate entry per page is gone (the criterion entry is no -longer re-returned). +**Fix:** the continuation repeats the *same* GET STATUS command with P2.b1 +set (`80F2 03 00`) - the pagination state lives in the card. +A changed `4F` criterion is a match filter, not a position: `P2=03` combined +with the last AID as criterion is rejected with SW 6A80, and `P2=02` with it +returns that single match (the duplicate seen earlier). The card's +truncation warning is its proprietary `CA FE`; GP defines `63 10` (Table +11-38) and both trigger the continuation. + +**Also fixed (same week):** the explore script's P1 values - per Table 11-33 +`P1=40` is *applications and supplementary security domains*, `P1=20` the +*ELF registry* and `P1=10` *ELF+modules*; the script never queried the +ELF-only registry, which is why the installed package `AA1902BC225801` was +invisible. Labels/decoder updated; the remote APDU script builder's P1 map +(0x02 load / 0x0C install / 0x08 make-selectable / 0x40 reg-update / 0x10 +extradition) was already correct. ## Next tests / work diff --git a/frontend/index.html b/frontend/index.html index 00f10c0..e26e798 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
-

OTAMan SIM OTA with a Human Face v2.1.15

+

OTAMan SIM OTA with a Human Face v2.1.16

@@ -7170,7 +7170,7 @@ function scp81DecodeGetStatus(hex) { if (ln === 0x81 && i + 3 <= bytes.length) { ln = bytes[i + 2]; off = i + 3; } if (off + ln > bytes.length) break; const content = bytes.slice(off, off + ln); - const entry = { aid: null, lifecycle: null, privileges: null, modules: [] }; + const entry = { aid: null, lifecycle: null, privileges: null, modules: [], elf: null, sd: null }; const toHex = v => v.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase(); let k = 0; while (k + 2 <= content.length) { @@ -7186,6 +7186,8 @@ function scp81DecodeGetStatus(hex) { else if (tag === 0x9F70) entry.lifecycle = val.length ? val[val.length - 1].toString(16).padStart(2, '0').toUpperCase() : null; else if (tag === 0xC5) entry.privileges = toHex(val); else if (tag === 0x84) entry.modules.push(toHex(val)); + else if (tag === 0xC4) entry.elf = toHex(val); + else if (tag === 0xCC) entry.sd = toHex(val); k += hdr + tlen; } if (entry.aid) out.push(entry); @@ -7199,8 +7201,9 @@ function scp81CmdLabel(apdu) { if (a.startsWith('80CAFF21')) return 'GET DATA FF21 (extended card resources)'; if (a.startsWith('80CA0085')) return 'GET DATA 0085 (HTTP administration parameters)'; if (a.startsWith('80F280')) return 'GET STATUS P1=80 (Issuer Security Domain)'; - if (a.startsWith('80F240')) return 'GET STATUS P1=40 (executable load files)'; - if (a.startsWith('80F210')) return 'GET STATUS P1=10 (applications)'; + if (a.startsWith('80F240')) return 'GET STATUS P1=40 (applications and security domains)'; + if (a.startsWith('80F220')) return 'GET STATUS P1=20 (executable load files)'; + if (a.startsWith('80F210')) return 'GET STATUS P1=10 (executable load files and modules)'; if (a.startsWith('80E6')) return 'INSTALL'; if (a.startsWith('80E8')) return 'LOAD'; if (a.startsWith('80CA')) return 'GET DATA ' + a.slice(4, 8); @@ -7350,7 +7353,8 @@ function scp81ResultLines(group) { unique.forEach(e => { const priv = (typeof decodePrivileges === 'function' && e.privileges) ? decodePrivileges(e.privileges) : (e.privileges || ''); lines.push(e.aid + (e.lifecycle ? ' life=' + e.lifecycle : '') + (priv ? ' [' + priv + ']' : '') + - (e.modules.length ? ' module=' + e.modules.join(',') : '')); + (e.elf ? ' elf=' + e.elf : '') + (e.modules.length ? ' module=' + e.modules.join(',') : '') + + (e.sd ? ' sd=' + e.sd : '')); }); const bad = group.results.filter(r => r.sw && r.sw !== '9000' && r.sw !== 'CAFE'); bad.forEach(r => lines.push('SW ' + r.sw)); diff --git a/frontend/sw.js b/frontend/sw.js index 3d4cea2..fea8e44 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v152'; +const CACHE = 'otaman-v153'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/scp81.test.js b/frontend/tests/scp81.test.js index f46b1f3..5bfcead 100644 --- a/frontend/tests/scp81.test.js +++ b/frontend/tests/scp81.test.js @@ -124,7 +124,8 @@ test('scp81DecodeAdminParams decodes the stored 0085 answer', () => { test('scp81CmdLabel names the explore commands', () => { assert.strictEqual(scp81CmdLabel('80CAFF2100'), 'GET DATA FF21 (extended card resources)'); - assert.strictEqual(scp81CmdLabel('80F24002024F0000'), 'GET STATUS P1=40 (executable load files)'); - assert.strictEqual(scp81CmdLabel('80F21002024F0000'), 'GET STATUS P1=10 (applications)'); + assert.strictEqual(scp81CmdLabel('80F24002024F0000'), 'GET STATUS P1=40 (applications and security domains)'); + assert.strictEqual(scp81CmdLabel('80F22002024F0000'), 'GET STATUS P1=20 (executable load files)'); + assert.strictEqual(scp81CmdLabel('80F21002024F0000'), 'GET STATUS P1=10 (executable load files and modules)'); assert.strictEqual(scp81CmdLabel('80E8800000'), 'LOAD'); }); diff --git a/pyproject.toml b/pyproject.toml index c5daf5e..58ef5aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.1.15" +version = "2.1.16" description = "HTTP REST server wrapping pysim for the OTAMan PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 19963f4..2452b35 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter from osmocom.tlv import BER_TLV_IE -VERSION = '2.1.15' +VERSION = '2.1.16' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -1380,12 +1380,14 @@ _SCP81_SCRIPTS = { # The command sequence of the reference administration server # (samples/HTTP_OTA/httpota_adminserver_php_v2, get_next_apdu), extended # with the registries: GET DATA FF21 (extended card resources / free - # memory), GET STATUS P1=80 (Issuer Security Domain), GET DATA 0085, - # GET STATUS P1=40 (executable load files / ELF), GET STATUS P1=10 - # (applications/applets); P2=02 with data '4F00' selects the TLV format, - # Le=00 so no GET RESPONSE is needed. + # memory), GET DATA 0085, then GET STATUS with P2=02 (TLV structure, + # 'first or all') and data '4F00' (match all): P1=80 (Issuer Security + # Domain), P1=40 (applications and supplementary security domains), + # P1=20 (executable load files), P1=10 (ELF and their modules); + # Le=00 so no GET RESPONSE is needed. Long listings answer SW CAFE and + # are auto-continued with the same command carrying P2.b1=1 ('next'). 'explore': ['80CAFF2100', '80F28002024F0000', '80CA008500', - '80F24002024F0000', '80F21002024F0000'], + '80F24002024F0000', '80F22002024F0000', '80F21002024F0000'], 'none': [], } _SCP81_SCRIPT = list(_SCP81_SCRIPTS['explore']) @@ -1508,51 +1510,19 @@ def _scp81_decode_memory(rapdu): return out or None -def _scp81_last_aid(rapdu): - """Last complete AID (the '4F' TLV of a GET STATUS entry) in a page. - - The page is a stream of 'E3' entries; a 127-byte page may end mid-entry, - so only complete entries count. Unknown leading bytes (seen in live - pages) are skipped.""" - last = None - i = 0 - while i + 2 <= len(rapdu): - if rapdu[i] != 0xE3: - i += 1 - continue - ln = rapdu[i + 1] - off = i + 2 - if ln == 0x81 and i + 3 <= len(rapdu): - ln = rapdu[i + 2] - off = i + 3 - if off + ln > len(rapdu): - break - content = rapdu[off:off + ln] - if len(content) >= 2 and content[0] == 0x4F: - alen = content[1] - if 2 + alen <= len(content): - last = content[2:2 + alen] - i = off + ln - return last - - -def _scp81_continuation(apdu, rapdu): +def _scp81_continuation(apdu): """Continuation APDU for a truncated GET STATUS page, or None. - GET STATUS P2=02 with the last returned AID as search criterion asks the - card for the next occurrence (GP GET STATUS, next-occurrence mode).""" + GET STATUS P2.b1 distinguishes first/all (0) from the *next* batch (1) + of the matches for the SAME search criteria; the pagination state lives + in the card, so the continuation is the same command with P2.b1 set. + Using a changed search criterion (the last returned AID) was rejected + with SW 6A80 - the criterion is a match filter, not a position.""" u = apdu.upper() - if not u.startswith('80F2'): + if not u.startswith('80F2') or len(u) < 8: return None - aid = _scp81_last_aid(rapdu) - if not aid: - return None - lc = 2 + len(aid) - # P2=03 = "get next occurrence(s)" (Table 11-34); P2=02 ("first or all") - # made the card return the first listing again, so every continuation - # page repeated its search criterion and the scan stopped early - the - # newly installed package never appeared in the registry. - return '80F2%s03%02X4F%02X%s00' % (u[4:6], lc, len(aid), aid.hex().upper()) + p2 = int(u[6:8], 16) | 0x01 + return '%s%02X%s' % (u[:6], p2, u[8:]) def _scp81_script_responder(method, target, headers, body): @@ -1579,12 +1549,11 @@ def _scp81_script_responder(method, target, headers, body): decoded = _scp81_decode_memory(rapdus[-1][0]) if decoded: _BIP.log('script-memory', **decoded) - if rapdus[-1][1].upper() == 'CAFE' and _SCP81_PAGES < SCP81_MAX_PAGES: - cont = _scp81_continuation(apdu, rapdus[-1][0]) - if cont and cont in _SCP81_SCRIPT_INSERTED: - # The card returned the same page again: stop paging. - _BIP.log('script-page-stalled', index=index, apdu=cont) - elif cont: + # '63 10' = "more data available" (GP Table 11-38); the live + # card uses a proprietary 'CA FE' for the same condition. + if rapdus[-1][1].upper() in ('CAFE', '6310') and _SCP81_PAGES < SCP81_MAX_PAGES: + cont = _scp81_continuation(apdu) + if cont: _SCP81_PAGES += 1 _SCP81_SCRIPT.insert(_SCP81_SCRIPT_SENT, cont) _SCP81_SCRIPT_INSERTED.append(cont) diff --git a/tests/test_scp81.py b/tests/test_scp81.py index ee3fa90..c79b9c3 100644 --- a/tests/test_scp81.py +++ b/tests/test_scp81.py @@ -667,20 +667,13 @@ class TargetedAppTest(unittest.TestCase): server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) server._SCP81_SCRIPT_SENT = 0 - def test_last_aid_parses_complete_entries_only(self): - page = bytes.fromhex( - 'FC' # live prefix - 'E3114F08A0000000030000009F70010FC50100' # entry 1 - 'E3104F07A00000015153509F700107C50104' # entry 2 - 'E3204F08D27600') # truncated - self.assertEqual(server._scp81_last_aid(page), - bytes.fromhex('A0000001515350')) - - def test_continuation_builds_next_occurrence_apdu(self): - page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') - self.assertEqual(server._scp81_continuation('80F24002024F0000', page), - '80F240030A4F08A00000000300000000') - self.assertIsNone(server._scp81_continuation('80CAFF2100', page)) + def test_continuation_sets_p2_next_bit(self): + # P2.b1: 0 = first/all, 1 = next batch of the SAME search criteria + self.assertEqual(server._scp81_continuation('80F24002024F0000'), + '80F24003024F0000') + self.assertEqual(server._scp81_continuation('80F21002024F0000'), + '80F21003024F0000') + self.assertIsNone(server._scp81_continuation('80CAFF2100')) def test_cafe_page_auto_continuation(self): server._SCP81_SCRIPT = ['80F24002024F0000'] @@ -689,18 +682,15 @@ class TargetedAppTest(unittest.TestCase): server._SCP81_SCRIPT_INSERTED = [] server._SCP81_PAGES = 0 try: - page = (bytes.fromhex('E3114F08A0000000030000009F70010FC50100') - + bytes.fromhex('E3104F07A00000015153509F700107C50104') - + bytes.fromhex('E3204F08D27600')) + page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE' body = b'\xAF\x80' + tlv + b'\x00\x00' status, headers, out = server._scp81_script_responder( 'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body) # The continuation was appended and sent as the next command. - self.assertEqual(server._SCP81_SCRIPT[1], - '80F24003094F07A000000151535000') + self.assertEqual(server._SCP81_SCRIPT[1], '80F24003024F0000') self.assertEqual(status, 200) - self.assertIn(bytes.fromhex('80F24003094F07A000000151535000'), out) + self.assertIn(bytes.fromhex('80F24003024F0000'), out) finally: server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) server._SCP81_SCRIPT_SENT = 0 @@ -708,21 +698,44 @@ class TargetedAppTest(unittest.TestCase): server._SCP81_SCRIPT_INSERTED = [] server._SCP81_PAGES = 0 - def test_repeated_page_stalls(self): + def test_standard_more_data_sw_also_pages(self): + # GP Table 11-38: SW '63 10' = more data available, continue with + # GET STATUS [next occurrence] - same handling as the card's 'CA FE'. + server._SCP81_SCRIPT = ['80F2 4002 024F 0000'.replace(' ', '')] + server._SCP81_SCRIPT_SENT = 1 + server._SCP81_SCRIPT_RESULTS = [] + server._SCP81_SCRIPT_INSERTED = [] + server._SCP81_PAGES = 0 + try: + page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') + tlv = bytes([0x23, len(page) + 2]) + page + b'\x63\x10' + body = b'\xAF\x80' + tlv + b'\x00\x00' + server._scp81_script_responder( + 'POST', '/api/scp81?req=1', {'x-admin-script-status': 'ok'}, body) + self.assertEqual(server._SCP81_SCRIPT[1], '80F24003024F0000') + finally: + server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) + server._SCP81_SCRIPT_SENT = 0 + server._SCP81_SCRIPT_RESULTS = [] + server._SCP81_SCRIPT_INSERTED = [] + server._SCP81_PAGES = 0 + + def test_repeated_pages_keep_paging(self): + # The continuation is stateful (P2=03): the same APDU legitimately + # repeats until the card answers 9000; the page counter caps it. server._SCP81_SCRIPT = ['80F24002024F0000'] server._SCP81_SCRIPT_SENT = 1 server._SCP81_SCRIPT_RESULTS = [] - server._SCP81_SCRIPT_INSERTED = ['80F240030A4F08A00000000300000000'] - server._SCP81_PAGES = 1 + server._SCP81_SCRIPT_INSERTED = [] + server._SCP81_PAGES = 0 try: page = bytes.fromhex('E3114F08A0000000030000009F70010FC50100') tlv = bytes([0x23, len(page) + 2]) + page + b'\xCA\xFE' body = b'\xAF\x80' + tlv + b'\x00\x00' - server._scp81_script_responder( - 'POST', '/api/scp81?req=2', {'x-admin-script-status': 'ok'}, body) - # Same page again: no new continuation inserted. - self.assertEqual(_count := len(server._SCP81_SCRIPT_INSERTED), 1) - self.assertEqual(server._SCP81_PAGES, 1) + for _ in range(3): + server._scp81_script_responder( + 'POST', '/api/scp81?req=2', {'x-admin-script-status': 'ok'}, body) + self.assertEqual(server._SCP81_PAGES, 3) finally: server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore']) server._SCP81_SCRIPT_SENT = 0 @@ -731,8 +744,8 @@ class TargetedAppTest(unittest.TestCase): server._SCP81_PAGES = 0 def test_new_session_drops_inserted_pages(self): - server._SCP81_SCRIPT = ['80F24002024F0000', '80F24002094F07A000000151535000'] - server._SCP81_SCRIPT_INSERTED = ['80F24002094F07A000000151535000'] + server._SCP81_SCRIPT = ['80F24002024F0000', '80F24003024F0000'] + server._SCP81_SCRIPT_INSERTED = ['80F24003024F0000'] server._SCP81_SCRIPT_SENT = 2 try: server._scp81_script_responder('POST', '/api/scp81', {}, b'')