fix: BER-safe proactive TLV walks + hardened FETCH loop (v3.6.57)
The live 500 in the Explore ('OTA send error: index out of range') was a
parser bug, reproduced with the live FETCH shape: _parse_proactive_header
walked the command TLVs reading the length as a single byte, so the card's
SEND SHORT MESSAGE with its 150-byte SMS TPDU (8B 81 96) was mis-walked
into the TPDU and eventually indexed past the end (val[2] on a short
slice) - the FETCH was left unanswered and the request answered 500.
- _ber_tlv_list/_proactive_body helpers: BER lengths (_ber_len_at) plus a
bounds check that stops at a truncated TLV instead of walking into the
value; _parse_proactive_header, _parse_display_text, _parse_select_item,
_parse_setup_menu_items, _tlv_map and _decode_poll_negotiation all use
them now (the same latent bug for any long-form TLV);
- _run_proactive_chain guards every step (header parse, proactive log,
on_fetch, BIP/timer handlers): an exception is logged with the offending
FETCH bytes and the TERMINAL RESPONSE is still sent - a decoder bug can
never leave a FETCH unanswered again;
- the expanded-registry report distinguishes a request error from a remote
SW: 'ELFs: request error (index out of range) - compact listing used'.
Regression tests: the live long-form FETCH shape parses (header + TLV map +
TPDU), truncated TLVs stop the walk, and a raising parser still gets a TR.
sw cache simple-v330; tests: 553 Python / 711 Node.
This commit is contained in:
+19
-6
@@ -1749,7 +1749,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// by the server version check in pysimConnect().
|
||||||
const SIMPLE_VERSION = '3.6.56';
|
const SIMPLE_VERSION = '3.6.57';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -9170,6 +9170,7 @@ function ramExpandedGroups(report) {
|
|||||||
groups.push(g);
|
groups.push(g);
|
||||||
}
|
}
|
||||||
g.compact = g.compact || !!r.compact;
|
g.compact = g.compact || !!r.compact;
|
||||||
|
g.transport = g.transport || !!r.transport;
|
||||||
if (r.form) {
|
if (r.form) {
|
||||||
const score = ((r.entries || 0) > 0 ? 100 : 0) +
|
const score = ((r.entries || 0) > 0 ? 100 : 0) +
|
||||||
(RAM_EXPANDED_FORMS.length - RAM_EXPANDED_FORMS.indexOf(r.form));
|
(RAM_EXPANDED_FORMS.length - RAM_EXPANDED_FORMS.indexOf(r.form));
|
||||||
@@ -9204,7 +9205,10 @@ function ramExpandedReport(report) {
|
|||||||
if (g.form) return;
|
if (g.form) return;
|
||||||
const why = g.sw ? ' (' + g.sw + ')' : '';
|
const why = g.sw ? ' (' + g.sw + ')' : '';
|
||||||
if (!g.compact) {
|
if (!g.compact) {
|
||||||
parts.push(g.label + ': ' + (g.sw || t('no data')));
|
parts.push(g.label + ': ' + (g.transport && g.sw
|
||||||
|
? t('request error') + ' (' + g.sw + ')' : (g.sw || t('no data'))));
|
||||||
|
} else if (g.transport) {
|
||||||
|
parts.push(g.label + ': ' + t('request error') + why + ' \u2014 ' + t('compact listing used'));
|
||||||
} else if (g.p1 === '80') {
|
} else if (g.p1 === '80') {
|
||||||
// cards commonly report nothing for the ISD in the expanded form
|
// cards commonly report nothing for the ISD in the expanded form
|
||||||
// (GPPro treats its 6A88/6A86 as 'no data to report')
|
// (GPPro treats its 6A88/6A86 as 'no data to report')
|
||||||
@@ -9409,10 +9413,12 @@ async function ramExplore(sp) {
|
|||||||
: RAM_EXPANDED_FORMS.slice();
|
: RAM_EXPANDED_FORMS.slice();
|
||||||
if (p1 === '80') forms = [expandedForm || 'tags'];
|
if (p1 === '80') forms = [expandedForm || 'tags'];
|
||||||
let lastSw = '';
|
let lastSw = '';
|
||||||
|
let lastTransport = false;
|
||||||
for (const form of forms) {
|
for (const form of forms) {
|
||||||
let data = '';
|
let data = '';
|
||||||
let sw = '';
|
let sw = '';
|
||||||
let accepted = true;
|
let accepted = true;
|
||||||
|
let transportError = false;
|
||||||
const sendOne = async (apdu, what) => {
|
const sendOne = async (apdu, what) => {
|
||||||
ramShowProgress(label + ' P1=' + p1 + ' ' + what + '...');
|
ramShowProgress(label + ' P1=' + p1 + ' ' + what + '...');
|
||||||
const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
|
const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 }));
|
||||||
@@ -9421,6 +9427,8 @@ async function ramExplore(sp) {
|
|||||||
else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
|
else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
|
||||||
if (!res.success || !res.por || !spPorAccepted(res.por)) {
|
if (!res.success || !res.por || !spPorAccepted(res.por)) {
|
||||||
accepted = false;
|
accepted = false;
|
||||||
|
// a request/transport failure is not a remote status word
|
||||||
|
transportError = !res.por;
|
||||||
sw = res.por ? res.por.response_status : (res.error || t('no data'));
|
sw = res.por ? res.por.response_status : (res.error || t('no data'));
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -9451,16 +9459,19 @@ async function ramExplore(sp) {
|
|||||||
const parsed = data ? parser(data) : [];
|
const parsed = data ? parser(data) : [];
|
||||||
if (parsed.length) {
|
if (parsed.length) {
|
||||||
expandedForm = form;
|
expandedForm = form;
|
||||||
return { form: form, parsed: parsed, sw: sw, entries: parsed.length };
|
return { form: form, parsed: parsed, sw: sw, entries: parsed.length,
|
||||||
|
transport: transportError };
|
||||||
}
|
}
|
||||||
lastSw = sw;
|
lastSw = sw;
|
||||||
|
lastTransport = transportError;
|
||||||
if (!accepted) break;
|
if (!accepted) break;
|
||||||
if (ramRemoteSwOk(sw)) {
|
if (ramRemoteSwOk(sw)) {
|
||||||
// accepted but empty: nothing to gain from another form
|
// accepted but empty: nothing to gain from another form
|
||||||
return { form: form, parsed: [], sw: sw, entries: 0 };
|
return { form: form, parsed: [], sw: sw, entries: 0,
|
||||||
|
transport: transportError };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { form: null, parsed: [], sw: lastSw, entries: 0 };
|
return { form: null, parsed: [], sw: lastSw, entries: 0, transport: lastTransport };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function paginate(p1, collector, parser, label, quiet) {
|
async function paginate(p1, collector, parser, label, quiet) {
|
||||||
@@ -9533,7 +9544,8 @@ async function ramExplore(sp) {
|
|||||||
if (collector.length === before || wantDetails) {
|
if (collector.length === before || wantDetails) {
|
||||||
const out = await expandedPass(p1, label, parser, spi2);
|
const out = await expandedPass(p1, label, parser, spi2);
|
||||||
expandedReport.push({ p1: p1, label: label, compact: collector.length > before,
|
expandedReport.push({ p1: p1, label: label, compact: collector.length > before,
|
||||||
form: out.form, sw: out.sw, entries: out.entries });
|
form: out.form, sw: out.sw, entries: out.entries,
|
||||||
|
transport: out.transport });
|
||||||
if (collector.length === before) {
|
if (collector.length === before) {
|
||||||
// Nothing from the compact pass: the expanded result is the listing
|
// Nothing from the compact pass: the expanded result is the listing
|
||||||
collector.push(...out.parsed);
|
collector.push(...out.parsed);
|
||||||
@@ -18766,6 +18778,7 @@ const LANG_RU = {
|
|||||||
'Package versions come from the expanded registry query \u2014 this card did not return them.': 'Версии пакетов приходят из расширенного запроса реестра \u2014 эта карта их не вернула.',
|
'Package versions come from the expanded registry query \u2014 this card did not return them.': 'Версии пакетов приходят из расширенного запроса реестра \u2014 эта карта их не вернула.',
|
||||||
'Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.': 'Версии пакетов есть только в формах расширенного запроса со списком тегов \u2014 эта карта приняла только форму без списка тегов.',
|
'Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.': 'Версии пакетов есть только в формах расширенного запроса со списком тегов \u2014 эта карта приняла только форму без списка тегов.',
|
||||||
'from the app instance': 'от экземпляра апплета',
|
'from the app instance': 'от экземпляра апплета',
|
||||||
|
'request error': 'ошибка запроса',
|
||||||
'STK menu entry:': 'Пункт меню STK:',
|
'STK menu entry:': 'Пункт меню STK:',
|
||||||
'position': 'позиция',
|
'position': 'позиция',
|
||||||
'Other registry data:': 'Прочие данные реестра:',
|
'Other registry data:': 'Прочие данные реестра:',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v329';
|
const CACHE = 'simple-v330';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -666,6 +666,14 @@ test('ramExpandedReport summarizes the query forms and the fallbacks', () => {
|
|||||||
{ p1: '10', label: 'ELFs', compact: true, form: 'lean', sw: '9000', entries: 3 },
|
{ p1: '10', label: 'ELFs', compact: true, form: 'lean', sw: '9000', entries: 3 },
|
||||||
{ p1: '20', label: 'ELFs', compact: true, form: 'notags', sw: '9000', entries: 5 },
|
{ p1: '20', label: 'ELFs', compact: true, form: 'notags', sw: '9000', entries: 5 },
|
||||||
]), 'Expanded registry: lean tag list (ELFs)');
|
]), 'Expanded registry: lean tag list (ELFs)');
|
||||||
|
// a request/transport failure is not a remote status word
|
||||||
|
assert.strictEqual(ramExpandedReport([
|
||||||
|
{ p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '9000', entries: 9 },
|
||||||
|
{ p1: '10', label: 'ELFs', compact: true, form: null, sw: 'index out of range', transport: true },
|
||||||
|
]), 'Expanded registry: tag list + EA (Apps) \u00b7 ELFs: request error (index out of range) \u2014 compact listing used');
|
||||||
|
assert.strictEqual(ramExpandedReport([
|
||||||
|
{ p1: '10', label: 'ELFs', compact: false, form: null, sw: 'index out of range', transport: true },
|
||||||
|
]), 'Expanded registry refused by the card \u2014 compact listing only (package versions unavailable) ELFs: request error (index out of range)');
|
||||||
assert.strictEqual(ramExpandedReport([]), '');
|
assert.strictEqual(ramExpandedReport([]), '');
|
||||||
delete globalThis.t;
|
delete globalThis.t;
|
||||||
});
|
});
|
||||||
@@ -707,6 +715,8 @@ test('the Explore detects the expanded query form and reports it', () => {
|
|||||||
assert.ok(src.includes('ramChainGetResponse(apdu)'), '61xx must be answered with a chained GET RESPONSE');
|
assert.ok(src.includes('ramChainGetResponse(apdu)'), '61xx must be answered with a chained GET RESPONSE');
|
||||||
assert.ok(!src.includes('00C00000'), 'no standalone GET RESPONSE (it answers 6700)');
|
assert.ok(!src.includes('00C00000'), 'no standalone GET RESPONSE (it answers 6700)');
|
||||||
assert.ok(src.includes('ramExpandedQueryApdu(p1, form, page > 1)'), '6310 must page');
|
assert.ok(src.includes('ramExpandedQueryApdu(p1, form, page > 1)'), '6310 must page');
|
||||||
|
// a transport failure must not be reported as a remote status word
|
||||||
|
assert.ok(src.includes('transportError'), 'request errors are marked as such');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ramParseAppStatus parses the expanded E3 listing (vendor traces)', () => {
|
test('ramParseAppStatus parses the expanded E3 listing (vendor traces)', () => {
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.6.56"
|
version = "3.6.57"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.6.56'
|
VERSION = '3.6.57'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -2070,11 +2070,7 @@ def _decode_poll_negotiation(data_hex):
|
|||||||
return None
|
return None
|
||||||
result = None
|
result = None
|
||||||
unit = interval = None
|
unit = interval = None
|
||||||
off = 0
|
for tag, val in _ber_tlv_list(data):
|
||||||
while off + 1 < len(data):
|
|
||||||
tag, tlen = data[off], data[off + 1]
|
|
||||||
val = data[off + 2: off + 2 + tlen]
|
|
||||||
off += 2 + tlen
|
|
||||||
if tag in (0x11, 0x91) and len(val) >= 1:
|
if tag in (0x11, 0x91) and len(val) >= 1:
|
||||||
result = val[0]
|
result = val[0]
|
||||||
elif tag in (0x04, 0x84) and len(val) >= 2:
|
elif tag in (0x04, 0x84) and len(val) >= 2:
|
||||||
@@ -2203,11 +2199,8 @@ def _tlv_map(data):
|
|||||||
"""Top-level COMPREHENSION-TLV map {tag: value} of a payload without a
|
"""Top-level COMPREHENSION-TLV map {tag: value} of a payload without a
|
||||||
D0 wrapper (e.g. the command-specific TLVs of a TERMINAL RESPONSE)."""
|
D0 wrapper (e.g. the command-specific TLVs of a TERMINAL RESPONSE)."""
|
||||||
out = {}
|
out = {}
|
||||||
off = 0
|
for tag, val in _ber_tlv_list(data or b''):
|
||||||
while off + 1 < len(data):
|
out.setdefault(tag, val)
|
||||||
tag, tlen = data[off], data[off + 1]
|
|
||||||
out.setdefault(tag, data[off + 2: off + 2 + tlen])
|
|
||||||
off += 2 + tlen
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -4221,6 +4214,38 @@ def _skip_ber_len(raw, off):
|
|||||||
return off + 3
|
return off + 3
|
||||||
|
|
||||||
|
|
||||||
|
def _ber_tlv_list(data):
|
||||||
|
"""[(tag, value)] of a BER-TLV sequence, stopping at a truncated or
|
||||||
|
malformed TLV instead of walking into the value.
|
||||||
|
|
||||||
|
The length MUST be read as BER: the big listings carry long-form lengths
|
||||||
|
(`8B 81 96` = the 150-byte SMS TPDU) and a single-byte read walks into the
|
||||||
|
value, eventually indexing past the end (live 2026-09-30: `IndexError:
|
||||||
|
index out of range` in the FETCH path - a SEND SHORT MESSAGE whose TLV
|
||||||
|
list was mis-walked)."""
|
||||||
|
out = []
|
||||||
|
off = 0
|
||||||
|
while off + 1 < len(data):
|
||||||
|
tag = data[off]
|
||||||
|
tlen, voff = _ber_len_at(data, off + 1)
|
||||||
|
if voff + tlen > len(data):
|
||||||
|
break
|
||||||
|
out.append((tag, data[voff:voff + tlen]))
|
||||||
|
if voff + tlen <= off: # no forward progress: stop
|
||||||
|
break
|
||||||
|
off = voff + tlen
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _proactive_body(raw):
|
||||||
|
"""The TLV region of a D0 proactive command (after its BER length)."""
|
||||||
|
if not raw or raw[0] != 0xD0:
|
||||||
|
return b''
|
||||||
|
ln, off = _ber_len_at(raw, 1)
|
||||||
|
end = off + ln if 0 < ln <= len(raw) - off else len(raw)
|
||||||
|
return raw[off:end]
|
||||||
|
|
||||||
|
|
||||||
# ---- TIMER MANAGEMENT (TS 102 223 6.6.21, 6.8.13/14, 7.4) -----------------
|
# ---- TIMER MANAGEMENT (TS 102 223 6.6.21, 6.8.13/14, 7.4) -----------------
|
||||||
# The terminal keeps up to 8 timers per card session. On expiry it must send
|
# The terminal keeps up to 8 timers per card session. On expiry it must send
|
||||||
# ENVELOPE (TIMER EXPIRATION, tag D7) so the card can act (a common OTA retry
|
# ENVELOPE (TIMER EXPIRATION, tag D7) so the card can act (a common OTA retry
|
||||||
@@ -4362,18 +4387,14 @@ def _parse_proactive_header(raw):
|
|||||||
cmd_num, cmd_type = 1, 0
|
cmd_num, cmd_type = 1, 0
|
||||||
cmd_qual = None
|
cmd_qual = None
|
||||||
dev_src, dev_dst = 0x83, 0x81
|
dev_src, dev_dst = 0x83, 0x81
|
||||||
if raw[0] == 0xD0:
|
for tag, val in _ber_tlv_list(_proactive_body(raw)):
|
||||||
off = _skip_ber_len(raw, 1)
|
# Cards use both the plain (01/02) and comprehension-required
|
||||||
while off < len(raw) - 1:
|
# (81/82) tag variants - TS 101 220 7.1.1 leaves the CR flag to the
|
||||||
tag, tlen = raw[off], raw[off + 1]
|
# application, and the reference cards switch between them.
|
||||||
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
|
if tag in (0x01, 0x81) and len(val) >= 3:
|
||||||
# Cards use both the plain (01/02) and comprehension-required
|
cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2]
|
||||||
# (81/82) tag variants - TS 101 220 7.1.1 leaves the CR flag to
|
elif tag in (0x02, 0x82) and len(val) >= 2:
|
||||||
# the application, and the reference cards switch between them.
|
dev_src, dev_dst = val[0], val[1]
|
||||||
if tag in (0x01, 0x81) and tlen >= 3:
|
|
||||||
cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2]
|
|
||||||
elif tag in (0x02, 0x82) and tlen >= 2:
|
|
||||||
dev_src, dev_dst = val[0], val[1]
|
|
||||||
return cmd_num, cmd_type, dev_src, dev_dst, cmd_qual
|
return cmd_num, cmd_type, dev_src, dev_dst, cmd_qual
|
||||||
|
|
||||||
|
|
||||||
@@ -4479,13 +4500,9 @@ def _parse_sms_concat(tpdu_bytes):
|
|||||||
|
|
||||||
|
|
||||||
def _parse_display_text(raw):
|
def _parse_display_text(raw):
|
||||||
if raw[0] == 0xD0:
|
for tag, val in _ber_tlv_list(_proactive_body(raw)):
|
||||||
off = _skip_ber_len(raw, 1)
|
if tag in (0x8D, 0x0D) and val:
|
||||||
while off < len(raw) - 1:
|
return _decode_dcs_text(val)
|
||||||
tag, tlen = raw[off], raw[off + 1]
|
|
||||||
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
|
|
||||||
if tag in (0x8D, 0x0D) and tlen >= 1:
|
|
||||||
return _decode_dcs_text(val)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -4598,35 +4615,26 @@ def _parse_get_input(raw):
|
|||||||
def _parse_select_item(raw):
|
def _parse_select_item(raw):
|
||||||
items = []
|
items = []
|
||||||
nai = None
|
nai = None
|
||||||
if raw[0] == 0xD0:
|
for tag, val in _ber_tlv_list(_proactive_body(raw)):
|
||||||
off = _skip_ber_len(raw, 1)
|
if tag in (0x85, 0x05) and val:
|
||||||
while off < len(raw) - 1:
|
try:
|
||||||
tag, tlen = raw[off], raw[off + 1]
|
_title = _decode_stk_text(val)
|
||||||
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
|
except Exception:
|
||||||
if tag in (0x85, 0x05) and tlen >= 1:
|
pass
|
||||||
try:
|
elif tag in (0x8F, 0x0F) and len(val) >= 2:
|
||||||
_title = _decode_stk_text(val)
|
items.append({'id': val[0], 'text': _decode_stk_text(val[1:])})
|
||||||
except Exception:
|
elif tag in (0x18, 0x98) and val:
|
||||||
pass
|
nai = val
|
||||||
elif tag in (0x8F, 0x0F) and tlen >= 2:
|
|
||||||
items.append({'id': val[0], 'text': _decode_stk_text(val[1:])})
|
|
||||||
elif tag in (0x18, 0x98) and tlen >= 1:
|
|
||||||
nai = val
|
|
||||||
return _attach_nai(items, nai)
|
return _attach_nai(items, nai)
|
||||||
|
|
||||||
|
|
||||||
def _parse_setup_menu_items(raw):
|
def _parse_setup_menu_items(raw):
|
||||||
items = []
|
items = []
|
||||||
nai = None
|
nai = None
|
||||||
if not raw or raw[0] != 0xD0:
|
for tag, val in _ber_tlv_list(_proactive_body(raw)):
|
||||||
return items
|
if tag in (0x8F, 0x0F) and len(val) >= 2:
|
||||||
off = _skip_ber_len(raw, 1)
|
|
||||||
while off < len(raw) - 1:
|
|
||||||
tag, tlen = raw[off], raw[off + 1]
|
|
||||||
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
|
|
||||||
if tag in (0x8F, 0x0F) and tlen >= 2:
|
|
||||||
items.append({'id': val[0], 'text': _decode_stk_text(val[1:])})
|
items.append({'id': val[0], 'text': _decode_stk_text(val[1:])})
|
||||||
elif tag in (0x18, 0x98) and tlen >= 1:
|
elif tag in (0x18, 0x98) and val:
|
||||||
nai = val
|
nai = val
|
||||||
return _attach_nai(items, nai)
|
return _attach_nai(items, nai)
|
||||||
|
|
||||||
@@ -4655,23 +4663,42 @@ def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True):
|
|||||||
fdata, sw = rv[0], rv[1]
|
fdata, sw = rv[0], rv[1]
|
||||||
raw = bytes.fromhex(fdata) if fdata else None
|
raw = bytes.fromhex(fdata) if fdata else None
|
||||||
action = None
|
action = None
|
||||||
|
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None
|
||||||
|
entry = None
|
||||||
if raw:
|
if raw:
|
||||||
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw)
|
# Every step is guarded: a decoder bug must never break the
|
||||||
entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num)
|
# FETCH/TERMINAL RESPONSE conversation (live 2026-09-30: an
|
||||||
else:
|
# IndexError in the header parser turned into a 500 with the
|
||||||
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None
|
# card's command left unanswered). The exception and the
|
||||||
entry = None
|
# offending bytes go to the log; the TR is still sent.
|
||||||
if raw and cmd_type in (0x03, 0x04):
|
try:
|
||||||
_handle_card_poll_command(cmd_type, raw)
|
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw)
|
||||||
if on_fetch:
|
except Exception as e:
|
||||||
action = on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst)
|
sys.stderr.write('FETCH parse error: %s (raw=%s)\n' % (e, raw.hex()))
|
||||||
|
try:
|
||||||
|
entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write('FETCH log error: %s (raw=%s)\n' % (e, raw.hex()))
|
||||||
|
try:
|
||||||
|
if raw and cmd_type in (0x03, 0x04):
|
||||||
|
_handle_card_poll_command(cmd_type, raw)
|
||||||
|
if on_fetch:
|
||||||
|
action = on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write('FETCH handler error: %s (raw=%s)\n' % (
|
||||||
|
e, raw.hex() if raw else '(none)'))
|
||||||
paused = action == 'pause'
|
paused = action == 'pause'
|
||||||
if not paused:
|
if not paused:
|
||||||
tr_tlv = None
|
tr_tlv = None
|
||||||
if raw and cmd_type in (0x40, 0x41, 0x42, 0x43, 0x44):
|
try:
|
||||||
tr_tlv = _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
|
if raw and cmd_type in (0x40, 0x41, 0x42, 0x43, 0x44):
|
||||||
if cmd_type == 0x27:
|
tr_tlv = _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
|
||||||
tr_tlv = _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
|
if cmd_type == 0x27:
|
||||||
|
tr_tlv = _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write('FETCH handler error: %s (raw=%s)\n' % (
|
||||||
|
e, raw.hex() if raw else '(none)'))
|
||||||
|
tr_tlv = None
|
||||||
if tr_tlv is None:
|
if tr_tlv is None:
|
||||||
tr_tlv = _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual)
|
tr_tlv = _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual)
|
||||||
tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr_tlv), tr_tlv.hex()))
|
tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr_tlv), tr_tlv.hex()))
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ from pysim_simple_server.server import (
|
|||||||
_parse_setup_menu_items,
|
_parse_setup_menu_items,
|
||||||
_calc_ud_offset,
|
_calc_ud_offset,
|
||||||
_find_sms_tpdu,
|
_find_sms_tpdu,
|
||||||
|
_parse_proactive_header,
|
||||||
|
_parse_display_text,
|
||||||
|
_tlv_map,
|
||||||
_parse_response_scripting,
|
_parse_response_scripting,
|
||||||
_parse_sms_concat,
|
_parse_sms_concat,
|
||||||
_por_remote_sw,
|
_por_remote_sw,
|
||||||
@@ -1588,6 +1591,81 @@ class RamSendGpApduLoggingTest(unittest.TestCase):
|
|||||||
self.assertIn('RAM SECURED-PACKET (LOAD (1/2)): AABB', out)
|
self.assertIn('RAM SECURED-PACKET (LOAD (1/2)): AABB', out)
|
||||||
|
|
||||||
|
|
||||||
|
class ProactiveLongTlvTests(unittest.TestCase):
|
||||||
|
"""BER long-form lengths in proactive command TLVs (v3.6.57).
|
||||||
|
|
||||||
|
The live card's SEND SHORT MESSAGE carries its 150-byte SMS TPDU as
|
||||||
|
`8B 81 96`: reading the length as one byte walked into the TPDU and
|
||||||
|
eventually indexed past the end (`IndexError: index out of range`, live
|
||||||
|
2026-09-30, 500 in the FETCH path with the command left unanswered)."""
|
||||||
|
|
||||||
|
# the live FETCH shape: command details + device identities + address +
|
||||||
|
# the long-form SMS TPDU TLV
|
||||||
|
TPDU = bytes.fromhex(
|
||||||
|
'410005812143f500f68c0700030302017100008d0a00000000000000bb0000029000e3284f10'
|
||||||
|
'a1130001180001ffffffff89a10039009f7001018410a1130001180001ffffffff89a1003908'
|
||||||
|
'e33a4f10a1130001180002fff7100e89040002009f7001018410a1130001180002fff7100e'
|
||||||
|
'89040002088410a1130001180002fff7100e89494d4508e3174f07f0414c464160019f7001')
|
||||||
|
RAW = (bytes.fromhex('d081ab8103011300820281838607919733824009f08b8196') +
|
||||||
|
TPDU + bytes(range(len(TPDU), 150)))
|
||||||
|
|
||||||
|
def test_header_parses_with_a_long_form_tlv(self):
|
||||||
|
self.assertEqual(_parse_proactive_header(self.RAW), (1, 0x13, 0x81, 0x83, 0))
|
||||||
|
# the long TLV is found as one TLV, not mis-walked into its value
|
||||||
|
self.assertEqual(sorted(_tlv_map(self.RAW[3:]).keys()), [0x81, 0x82, 0x86, 0x8B])
|
||||||
|
self.assertEqual(len(_find_sms_tpdu(self.RAW)), 150 * 2)
|
||||||
|
|
||||||
|
def test_a_truncated_tlv_does_not_raise(self):
|
||||||
|
# a declared length running past the data ends the walk
|
||||||
|
raw = bytes.fromhex('d004810381')
|
||||||
|
self.assertEqual(_parse_proactive_header(raw), (1, 0, 0x83, 0x81, None))
|
||||||
|
self.assertEqual(_tlv_map(bytes.fromhex('810381')), {})
|
||||||
|
# display text with a long-form text TLV (144 bytes -> 8D 81 90)
|
||||||
|
text = bytes([0x8D, 0x81, 0x90]) + b'\x00' * 0x90
|
||||||
|
head = bytes([0x81, 0x03, 0x01, 0x21, 0x00])
|
||||||
|
raw2 = bytes([0xD0, 0x81, len(head) + len(text)]) + head + text
|
||||||
|
self.assertIsNotNone(_parse_display_text(raw2))
|
||||||
|
|
||||||
|
|
||||||
|
class ProactiveChainHardeningTests(unittest.TestCase):
|
||||||
|
"""A decoder bug must never leave a FETCH unanswered (v3.6.57): every
|
||||||
|
step of the FETCH/TR loop is guarded and the TR is still sent."""
|
||||||
|
|
||||||
|
def test_a_raising_parser_still_gets_a_terminal_response(self):
|
||||||
|
from pysim_simple_server import server as srv
|
||||||
|
|
||||||
|
class FakeTp:
|
||||||
|
def __init__(self):
|
||||||
|
self.sent = []
|
||||||
|
self.proactive_handler = None
|
||||||
|
|
||||||
|
def send_apdu(self, apdu):
|
||||||
|
self.sent.append(apdu)
|
||||||
|
if apdu.startswith('80120000'): # FETCH
|
||||||
|
return ('d0048103011300', '9000')
|
||||||
|
return ('', '9000')
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
cat_cla = '80'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tp = FakeTp()
|
||||||
|
|
||||||
|
scc = FakeScc()
|
||||||
|
orig = srv._parse_proactive_header
|
||||||
|
|
||||||
|
def boom(raw):
|
||||||
|
raise IndexError('index out of range')
|
||||||
|
|
||||||
|
try:
|
||||||
|
srv._parse_proactive_header = boom
|
||||||
|
srv._run_proactive_chain(scc, '91A8', None, status_poll=False)
|
||||||
|
finally:
|
||||||
|
srv._parse_proactive_header = orig
|
||||||
|
self.assertTrue(any(a.startswith('80140000') for a in scc._tp.sent),
|
||||||
|
'the FETCH must be answered: %r' % (scc._tp.sent,))
|
||||||
|
|
||||||
|
|
||||||
class RamNvFootprintTests(unittest.TestCase):
|
class RamNvFootprintTests(unittest.TestCase):
|
||||||
"""GET DATA FF21 reads around an install (v3.6.51): the free-NV values and
|
"""GET DATA FF21 reads around an install (v3.6.51): the free-NV values and
|
||||||
their delta. The reads are best-effort - a card without FF21 (or a
|
their delta. The reads are best-effort - a card without FF21 (or a
|
||||||
|
|||||||
Reference in New Issue
Block a user