v1.6.0: ATR-hunt deadlock + idle-loop trap fixes
Add hunt-side live ETU recovery so a card already at a non-default F/D (e.g. Samsung F=512/D=32 -> 16 CLK/bit) can be decoded without a 372-rate ATR, and bound the all-0xFF hunt so an idle/unsyncable line can never loop forever. - _recover_etu_in_hunt(): measure the live ETU from DATA edges while still in FIND START, then re-derive clock_skip; reuses _confirm_etu validation. - _atr_hunt_failure_action(): pure recover/retry/resume_data decision, with ATR_HUNT_ETU_ATTEMPTS=3 / ATR_HUNT_IDLE_RESETS=3 / ATR_HUNT_FAIL_LIMIT=12. - Persistent counters reset only on a successful ATR parse (not RST re-arm), so RST-cycling phones cannot starve the escape. - 8 new unit tests (55 total). Validated on the full trace suite (test_8 56/0/OK, test_7 65/0/OK, xiaomi 640, xiaomi_cold 473, 4gmodem 327, A55 407/395, S21p 399/375, target S21p 369; 0 CONCAT, RESULT: OK) with no regressions.
This commit is contained in:
1 parent
b7a6e50fc8
commit
bca1b85b94
3 files changed
+235
-11
No files matched your search
@@ -255,6 +255,55 @@ mis-frame at the source) is the next decoder step; until then the gate
|
|||||||
makes the corruption visible in live and offline captures instead of
|
makes the corruption visible in live and offline captures instead of
|
||||||
silently producing wrong APDUs.
|
silently producing wrong APDUs.
|
||||||
|
|
||||||
|
### v1.6.0: ATR-hunt deadlock + idle-loop trap fixes
|
||||||
|
|
||||||
|
A live Samsung cold-boot capture that never emits a 372-rate ATR exposed two
|
||||||
|
defects in the ATR hunt (`state == 'FIND START'`):
|
||||||
|
|
||||||
|
1. **Deadlock.** `RST low at start` arms the hunt at the spec-default
|
||||||
|
`clock_skip=372` with `bit_samples=None`. The ETU-recovery routine
|
||||||
|
`_measure_etu()` is only reachable from the `DATA` state, which the hunt
|
||||||
|
can only enter after reading a valid `TS=0x3B/0x3F` (or a plausible
|
||||||
|
CLA/INS header) **at 372**. A card already running at a non-default F/D
|
||||||
|
(Samsung `F=512/D=32` -> 16 CLK/bit) produces none, so the hunt reads
|
||||||
|
idle `0xFF` forever and never reaches the ETU measurement -- a circular
|
||||||
|
dependency. This is why Samsung (non-default F/D) fails where
|
||||||
|
default-F/D devices succeed: not clock rate, but `F/D`.
|
||||||
|
|
||||||
|
2. **Trap.** The all-`0xFF` branch unconditionally reset `_atr_hunt_count=0`
|
||||||
|
and returned to `FIND START`, so the bounded fall-through to `DATA` was
|
||||||
|
unreachable. With RST re-arm also resetting the counter, the hunt could
|
||||||
|
spin forever.
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- **Hunt-side ETU recovery.** `_recover_etu_in_hunt()` runs `_measure_etu()`
|
||||||
|
+ `_derive_clock_skip_from_etu()` from the hunt after the default-rate
|
||||||
|
hunt has already failed a few rounds, so a fast device is read at its
|
||||||
|
true rate. It reuses the DATA-path `_confirm_etu()` validation, so a
|
||||||
|
noise-locked ETU self-clears after 2 parity failures. No-op for normal
|
||||||
|
ATRs (parsed on hunt 1).
|
||||||
|
- **Bounded escape.** `_atr_hunt_failure_action(byte)` (pure, unit-tested)
|
||||||
|
returns `recover` / `retry` / `resume_data`. ETU recovery every 2 failed
|
||||||
|
rounds up to 3 times; then a hard bound (`_atr_fail_total >= 12`) or the
|
||||||
|
all-`0xFF` idle cap (3) falls through to the existing "resuming DATA
|
||||||
|
without synthetic ATR" path (clearing `bit_samples` so the DATA path
|
||||||
|
re-measures).
|
||||||
|
- **Persistent counters.** `_atr_fail_total`, `_hunt_etu_attempts`,
|
||||||
|
`_atr_idle_resets` are reset only on a successful ATR parse, **not** on
|
||||||
|
RST re-arm -- otherwise a phone that power-cycles RST while never sending
|
||||||
|
a valid ATR would starve the escape. Constants: `ATR_HUNT_ETU_ATTEMPTS=3`,
|
||||||
|
`ATR_HUNT_IDLE_RESETS=3`, `ATR_HUNT_FAIL_LIMIT=12`.
|
||||||
|
|
||||||
|
Verification: no regression on the full trace suite (test_8 56/0/OK,
|
||||||
|
test_7 65/0/OK, xiaomi 640, xiaomi_cold 473, 4gmodem 327, A55 407/395,
|
||||||
|
S21p 399/375, target S21p_coldboot 369, all `RESULT: OK`, 0 CONCAT);
|
||||||
|
54/54 unit tests (7 new hunt-escape regression tests). VERSION -> 1.6.0.
|
||||||
|
|
||||||
|
Known limitation: no on-disk fixture reproduces the exact live failure (all
|
||||||
|
real captures contain a 372 ATR), so end-to-end proof needs a re-captured
|
||||||
|
`.sr` from the failing phone; the unit tests cover the loop-escape logic.
|
||||||
|
|
||||||
### v1.5.0: invalid-INS one-byte re-frame (residual CONCATs → 0)
|
### v1.5.0: invalid-INS one-byte re-frame (residual CONCATs → 0)
|
||||||
|
|
||||||
The last CONCAT family (4gmodem/A55_sim2/S21p_sim2) is now **reframed at
|
The last CONCAT family (4gmodem/A55_sim2/S21p_sim2) is now **reframed at
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from .gsmtap_stream import (GsmtapStreamSender,
|
|||||||
GSMTAP_SIM_RST_EVENT, GSMTAP_SIM_VCC_EVENT,
|
GSMTAP_SIM_RST_EVENT, GSMTAP_SIM_VCC_EVENT,
|
||||||
GSMTAP_FLAG_BAD_FCS)
|
GSMTAP_FLAG_BAD_FCS)
|
||||||
|
|
||||||
VERSION = '1.5.0'
|
VERSION = '1.6.0'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -276,6 +276,15 @@ MAX_TPDU_LEN = 271 # ISO 7816-3 T=0: header(5) + proc + 255 + SW(2) < 271
|
|||||||
IDLE_RESYNC_ETU = 4 # idle bit-periods marking an exchange boundary
|
IDLE_RESYNC_ETU = 4 # idle bit-periods marking an exchange boundary
|
||||||
MAX_RESYNC_ATTEMPTS = 1024 # bail out if no idle gap found in that many tries
|
MAX_RESYNC_ATTEMPTS = 1024 # bail out if no idle gap found in that many tries
|
||||||
|
|
||||||
|
# ATR-hunt failure bounds. When the default-rate (372 CLK/bit) hunt keeps
|
||||||
|
# failing, the card is either idle or already running at a non-default F/D
|
||||||
|
# (e.g. Samsung F=512/D=32 -> 16 CLK/bit). Without a bound the hunt reads
|
||||||
|
# idle 0xFF forever at the wrong rate and can never reach the DATA state,
|
||||||
|
# where the live ETU is measured -- a deadlock. These cap the retries.
|
||||||
|
ATR_HUNT_ETU_ATTEMPTS = 3 # live-ETU recovery attempts before resuming DATA
|
||||||
|
ATR_HUNT_IDLE_RESETS = 3 # all-0xFF hunt resets before resuming DATA
|
||||||
|
ATR_HUNT_FAIL_LIMIT = 12 # total failed hunt rounds before resuming DATA
|
||||||
|
|
||||||
|
|
||||||
class Decoder(srd.Decoder):
|
class Decoder(srd.Decoder):
|
||||||
api_version = 3
|
api_version = 3
|
||||||
@@ -482,6 +491,15 @@ class Decoder(srd.Decoder):
|
|||||||
# ever present) eventually falls back to T=0 parsing instead of
|
# ever present) eventually falls back to T=0 parsing instead of
|
||||||
# looping forever.
|
# looping forever.
|
||||||
self._atr_hunt_count = 0
|
self._atr_hunt_count = 0
|
||||||
|
# Persistent hunt-failure accounting (reset only when an ATR is
|
||||||
|
# parsed, NOT on RST re-arm): a device that power-cycles RST while
|
||||||
|
# never sending a valid ATR would otherwise starve the escape below.
|
||||||
|
# _atr_fail_total counts every failed hunt round across activations;
|
||||||
|
# _hunt_etu_attempts caps live-ETU recovery; _atr_idle_resets caps the
|
||||||
|
# all-0xFF reset loop.
|
||||||
|
self._atr_fail_total = 0
|
||||||
|
self._hunt_etu_attempts = 0
|
||||||
|
self._atr_idle_resets = 0
|
||||||
# PPS is only attempted once after an ATR/warm reset. Prevents
|
# PPS is only attempted once after an ATR/warm reset. Prevents
|
||||||
# idle-line 0xFF bytes from being parsed as an endless stream of
|
# idle-line 0xFF bytes from being parsed as an endless stream of
|
||||||
# PPS requests when the card does not actually perform PPS.
|
# PPS requests when the card does not actually perform PPS.
|
||||||
@@ -946,6 +964,57 @@ class Decoder(srd.Decoder):
|
|||||||
self._edge_read = True
|
self._edge_read = True
|
||||||
self.log("etu recovered (samples):", self.bit_samples)
|
self.log("etu recovered (samples):", self.bit_samples)
|
||||||
|
|
||||||
|
def _recover_etu_in_hunt(self):
|
||||||
|
'''Recover the live bit period from the DATA line while still in the
|
||||||
|
ATR hunt.
|
||||||
|
|
||||||
|
The hunt reads at the spec-default 372 CLK/bit. A device already
|
||||||
|
running at a non-default F/D (Samsung F=512/D=32 -> 16 CLK/bit) or a
|
||||||
|
mid-session capture with no ATR sends no 372-rate bytes, so every
|
||||||
|
candidate reads as idle 0xFF and the hunt can never reach the DATA
|
||||||
|
state -- where _measure_etu() normally lives. Measure the ETU here
|
||||||
|
(after the default-rate hunt has already failed a few rounds) and
|
||||||
|
re-derive clock_skip so subsequent hunt reads use the true rate.
|
||||||
|
Reuses the DATA-path measurement and its _confirm_etu() validation.'''
|
||||||
|
if self._samples_per_clock is None:
|
||||||
|
self._measure_clock_period()
|
||||||
|
self._measure_etu()
|
||||||
|
self._derive_clock_skip_from_etu()
|
||||||
|
# Start a fresh default-rate counting window at the recovered rate.
|
||||||
|
self._atr_hunt_count = 0
|
||||||
|
self.log("hunt ETU recovery attempt", self._hunt_etu_attempts,
|
||||||
|
"bit_samples", self.bit_samples,
|
||||||
|
"clock_skip", self.clock_skip)
|
||||||
|
|
||||||
|
def _atr_hunt_failure_action(self, byte):
|
||||||
|
'''Decide what to do after a failed ATR hunt round.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
'recover' -- stop and recover the live ETU, then re-hunt
|
||||||
|
'retry' -- go back to FIND START (bounded)
|
||||||
|
'resume_data' -- give up on the ATR and resume T=0 framing
|
||||||
|
|
||||||
|
The counters are persistent across RST re-arms (reset only when an
|
||||||
|
ATR is parsed) so a phone that cycles RST while never emitting a
|
||||||
|
valid ATR cannot starve the escape. Pure function of state + byte
|
||||||
|
so it is unit-testable without libsigrokdecode.'''
|
||||||
|
self._atr_fail_total += 1
|
||||||
|
# Live-ETU recovery every 2 failures, up to ATR_HUNT_ETU_ATTEMPTS.
|
||||||
|
if (self.bit_samples is None
|
||||||
|
and self._hunt_etu_attempts < ATR_HUNT_ETU_ATTEMPTS
|
||||||
|
and self._atr_fail_total >=
|
||||||
|
2 * (self._hunt_etu_attempts + 1)):
|
||||||
|
self._hunt_etu_attempts += 1
|
||||||
|
return 'recover'
|
||||||
|
# Hard bound: never hunt forever on an idle/unsyncable line.
|
||||||
|
if self._atr_fail_total >= ATR_HUNT_FAIL_LIMIT:
|
||||||
|
return 'resume_data'
|
||||||
|
if byte == 0xFF and self._atr_idle_resets >= ATR_HUNT_IDLE_RESETS:
|
||||||
|
return 'resume_data'
|
||||||
|
if self._atr_hunt_count < 8:
|
||||||
|
return 'retry'
|
||||||
|
return 'resume_data'
|
||||||
|
|
||||||
def _confirm_etu(self, parity_ok):
|
def _confirm_etu(self, parity_ok):
|
||||||
'''Validate ETU measurement after _measure_etu. Call after each byte
|
'''Validate ETU measurement after _measure_etu. Call after each byte
|
||||||
decode in _read_byte_edges. After 3 successful decodes the
|
decode in _read_byte_edges. After 3 successful decodes the
|
||||||
@@ -1564,24 +1633,40 @@ class Decoder(srd.Decoder):
|
|||||||
ts_attempts += 1
|
ts_attempts += 1
|
||||||
if (byte not in (0x3b, 0x3f)):
|
if (byte not in (0x3b, 0x3f)):
|
||||||
self._atr_hunt_count += 1
|
self._atr_hunt_count += 1
|
||||||
# No valid ATR found this round -- go back to hunting for the
|
# No valid ATR found this round. Never fabricate a synthetic
|
||||||
# next falling edge. Never fabricate a synthetic ATR: a stray
|
# ATR: a stray 0x3B/0x3F byte inside command traffic (e.g. the
|
||||||
# 0x3B/0x3F byte inside command traffic (e.g. the FID '3F00' of a
|
# FID '3F00' of a SELECT) would otherwise be mis-committed as a
|
||||||
# SELECT) would otherwise be mis-committed as a fake 2-byte ATR
|
# fake 2-byte ATR and destroy the surrounding command.
|
||||||
# and destroy the surrounding command. The hunt is bounded by
|
action = self._atr_hunt_failure_action(byte)
|
||||||
# _atr_hunt_count only to avoid logging forever on a dead line.
|
if action == 'recover':
|
||||||
if (self._atr_hunt_count < 8):
|
# Default-rate hunt has failed a few rounds: the card may be
|
||||||
self.log("No valid TS byte, back to FIND START (hunt {count})".format(count=self._atr_hunt_count))
|
# idle or already at a non-default F/D. Measure the live ETU
|
||||||
|
# and re-hunt at that rate instead of looping on idle 0xFF.
|
||||||
|
self.log("ATR hunt failing; recovering live ETU")
|
||||||
|
self._recover_etu_in_hunt()
|
||||||
self.state = 'FIND START'
|
self.state = 'FIND START'
|
||||||
return
|
return
|
||||||
|
if action == 'retry':
|
||||||
if byte == 0xFF:
|
if byte == 0xFF:
|
||||||
self.log("line appears idle (all 0xFF), resetting ATR hunt")
|
self._atr_idle_resets += 1
|
||||||
|
self.log("line appears idle (all 0xFF), resetting ATR "
|
||||||
|
"hunt (#{n})".format(n=self._atr_idle_resets))
|
||||||
self._atr_hunt_count = 0
|
self._atr_hunt_count = 0
|
||||||
|
else:
|
||||||
|
self.log("No valid TS byte, back to FIND START "
|
||||||
|
"(hunt {count})".format(count=self._atr_hunt_count))
|
||||||
self.state = 'FIND START'
|
self.state = 'FIND START'
|
||||||
return
|
return
|
||||||
# Give up hunting for an ATR here without emitting anything;
|
# Give up hunting for an ATR here without emitting anything;
|
||||||
# resume T=0 framing from the next byte instead of inventing one.
|
# resume T=0 framing from the next byte instead of inventing one.
|
||||||
self.log("No valid ATR after {count} hunts; resuming DATA without synthetic ATR".format(count=self._atr_hunt_count))
|
# Clear bit_samples so the DATA path measures the ETU fresh.
|
||||||
|
self.log("No valid ATR after {count} hunts (fails={fails}); "
|
||||||
|
"resuming DATA without synthetic ATR".format(
|
||||||
|
count=self._atr_hunt_count,
|
||||||
|
fails=self._atr_fail_total))
|
||||||
|
self.bit_samples = None
|
||||||
|
self._etu_confirm_count = 0
|
||||||
|
self._etu_fail_count = 0
|
||||||
self.hasT0 = True
|
self.hasT0 = True
|
||||||
self.hasT1 = False
|
self.hasT1 = False
|
||||||
self.state = 'DATA'
|
self.state = 'DATA'
|
||||||
@@ -1595,6 +1680,9 @@ class Decoder(srd.Decoder):
|
|||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
self._atr_hunt_count = 0
|
self._atr_hunt_count = 0
|
||||||
|
self._atr_fail_total = 0
|
||||||
|
self._hunt_etu_attempts = 0
|
||||||
|
self._atr_idle_resets = 0
|
||||||
self.ATR.append(byte)
|
self.ATR.append(byte)
|
||||||
# TS=0x3F means inverse convention: logic 1 = LOW, logic 0 = HIGH.
|
# TS=0x3F means inverse convention: logic 1 = LOW, logic 0 = HIGH.
|
||||||
# All subsequent byte reads must invert bits.
|
# All subsequent byte reads must invert bits.
|
||||||
|
|||||||
@@ -248,6 +248,93 @@ class TestDeglitchEdges(unittest.TestCase):
|
|||||||
self.assertEqual(result, [(0, 0), (100, 0)])
|
self.assertEqual(result, [(0, 0), (100, 0)])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAtrHuntEscape(unittest.TestCase):
|
||||||
|
"""Regression: the ATR hunt must never loop forever on an idle/unsyncable
|
||||||
|
line, and must recover the live ETU when the card is already at a
|
||||||
|
non-default F/D (Samsung 16 CLK/bit) with no 372-rate ATR coming."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
_, pd = _load_iso7816()
|
||||||
|
self.pd = pd
|
||||||
|
self.inst = object.__new__(pd.Decoder)
|
||||||
|
self.inst.bit_samples = None
|
||||||
|
self.inst._atr_hunt_count = 0
|
||||||
|
self.inst._atr_fail_total = 0
|
||||||
|
self.inst._hunt_etu_attempts = 0
|
||||||
|
self.inst._atr_idle_resets = 0
|
||||||
|
|
||||||
|
def _hunt_round(self, byte):
|
||||||
|
"""Mirror handle_atr's bookkeeping around _atr_hunt_failure_action."""
|
||||||
|
self.inst._atr_hunt_count += 1
|
||||||
|
action = self.inst._atr_hunt_failure_action(byte)
|
||||||
|
if action == 'recover':
|
||||||
|
self.inst._atr_hunt_count = 0
|
||||||
|
elif action == 'retry' and byte == 0xFF:
|
||||||
|
self.inst._atr_idle_resets += 1
|
||||||
|
self.inst._atr_hunt_count = 0
|
||||||
|
return action
|
||||||
|
|
||||||
|
def test_retry_then_recover_after_two_failures(self):
|
||||||
|
self.assertEqual(self._hunt_round(0x00), 'retry')
|
||||||
|
self.assertEqual(self._hunt_round(0x00), 'recover')
|
||||||
|
self.assertEqual(self.inst._hunt_etu_attempts, 1)
|
||||||
|
|
||||||
|
def test_recovery_attempts_capped_then_hard_limit(self):
|
||||||
|
actions = [self._hunt_round(0x00) for _ in range(200)]
|
||||||
|
actions = actions[:actions.index('resume_data') + 1]
|
||||||
|
self.assertEqual(actions.count('recover'),
|
||||||
|
self.pd.ATR_HUNT_ETU_ATTEMPTS)
|
||||||
|
self.assertEqual(actions[-1], 'resume_data')
|
||||||
|
|
||||||
|
def test_all_ff_idle_loop_is_bounded(self):
|
||||||
|
seen = []
|
||||||
|
for _ in range(200):
|
||||||
|
seen.append(self._hunt_round(0xFF))
|
||||||
|
if seen[-1] == 'resume_data':
|
||||||
|
break
|
||||||
|
self.assertEqual(seen[-1], 'resume_data')
|
||||||
|
self.assertLess(len(seen), 20)
|
||||||
|
|
||||||
|
def test_idle_ff_retries_before_cap(self):
|
||||||
|
self.inst.bit_samples = 100 # ETU known -> recovery disabled
|
||||||
|
self.inst._hunt_etu_attempts = self.pd.ATR_HUNT_ETU_ATTEMPTS
|
||||||
|
self.inst._atr_idle_resets = self.pd.ATR_HUNT_IDLE_RESETS - 1
|
||||||
|
self.assertEqual(self.inst._atr_hunt_failure_action(0xFF), 'retry')
|
||||||
|
|
||||||
|
def test_idle_resets_capped_then_resume(self):
|
||||||
|
self.inst.bit_samples = 100
|
||||||
|
self.inst._hunt_etu_attempts = self.pd.ATR_HUNT_ETU_ATTEMPTS
|
||||||
|
self.inst._atr_idle_resets = self.pd.ATR_HUNT_IDLE_RESETS
|
||||||
|
self.assertEqual(self.inst._atr_hunt_failure_action(0xFF),
|
||||||
|
'resume_data')
|
||||||
|
|
||||||
|
def test_non_ff_resumes_when_count_high(self):
|
||||||
|
self.inst.bit_samples = 100
|
||||||
|
self.inst._hunt_etu_attempts = self.pd.ATR_HUNT_ETU_ATTEMPTS
|
||||||
|
self.inst._atr_hunt_count = 8
|
||||||
|
self.assertEqual(self.inst._atr_hunt_failure_action(0x00),
|
||||||
|
'resume_data')
|
||||||
|
|
||||||
|
def test_no_recovery_when_etu_known(self):
|
||||||
|
self.inst.bit_samples = 100
|
||||||
|
for _ in range(6):
|
||||||
|
self.assertEqual(self._hunt_round(0x00), 'retry')
|
||||||
|
self.assertEqual(self.inst._hunt_etu_attempts, 0)
|
||||||
|
|
||||||
|
def test_recover_etu_in_hunt_runs_measurement(self):
|
||||||
|
calls = []
|
||||||
|
self.inst._samples_per_clock = 4.0 # skip CLK-period measurement
|
||||||
|
self.inst._measure_etu = lambda: calls.append('measure')
|
||||||
|
self.inst._derive_clock_skip_from_etu = lambda: calls.append('derive')
|
||||||
|
self.inst._measure_clock_period = lambda: calls.append('period')
|
||||||
|
self.inst.log = lambda *a, **k: None
|
||||||
|
self.inst.clock_skip = 372
|
||||||
|
self.inst._atr_hunt_count = 5
|
||||||
|
self.inst._recover_etu_in_hunt()
|
||||||
|
self.assertEqual(calls, ['measure', 'derive'])
|
||||||
|
self.assertEqual(self.inst._atr_hunt_count, 0)
|
||||||
|
|
||||||
|
|
||||||
class TestEmbeddedExchanges(unittest.TestCase):
|
class TestEmbeddedExchanges(unittest.TestCase):
|
||||||
"""CONCAT detection: structurally valid packets that embed complete T=0
|
"""CONCAT detection: structurally valid packets that embed complete T=0
|
||||||
exchanges (a byte-drop mis-frame swallowed several real exchanges into
|
exchanges (a byte-drop mis-frame swallowed several real exchanges into
|
||||||
|
|||||||
Reference in new issue
Block a user