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:
Трошин Антон Валерьевич committed 2026-09-11 17:45:54 +03:00
1 parent b7a6e50fc8
commit bca1b85b94
3 files changed
+235 -11

No files matched your search

+49
View File
@@ -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
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)
The last CONCAT family (4gmodem/A55_sim2/S21p_sim2) is now **reframed at
+99 -11
View File
@@ -28,7 +28,7 @@ from .gsmtap_stream import (GsmtapStreamSender,
GSMTAP_SIM_RST_EVENT, GSMTAP_SIM_VCC_EVENT,
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
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):
api_version = 3
@@ -482,6 +491,15 @@ class Decoder(srd.Decoder):
# ever present) eventually falls back to T=0 parsing instead of
# looping forever.
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
# idle-line 0xFF bytes from being parsed as an endless stream of
# PPS requests when the card does not actually perform PPS.
@@ -946,6 +964,57 @@ class Decoder(srd.Decoder):
self._edge_read = True
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):
'''Validate ETU measurement after _measure_etu. Call after each byte
decode in _read_byte_edges. After 3 successful decodes the
@@ -1564,24 +1633,40 @@ class Decoder(srd.Decoder):
ts_attempts += 1
if (byte not in (0x3b, 0x3f)):
self._atr_hunt_count += 1
# No valid ATR found this round -- go back to hunting for the
# next falling edge. Never fabricate a synthetic ATR: a stray
# 0x3B/0x3F byte inside command traffic (e.g. the FID '3F00' of a
# SELECT) would otherwise be mis-committed as a fake 2-byte ATR
# and destroy the surrounding command. The hunt is bounded by
# _atr_hunt_count only to avoid logging forever on a dead line.
if (self._atr_hunt_count < 8):
self.log("No valid TS byte, back to FIND START (hunt {count})".format(count=self._atr_hunt_count))
# No valid ATR found this round. Never fabricate a synthetic
# ATR: a stray 0x3B/0x3F byte inside command traffic (e.g. the
# FID '3F00' of a SELECT) would otherwise be mis-committed as a
# fake 2-byte ATR and destroy the surrounding command.
action = self._atr_hunt_failure_action(byte)
if action == 'recover':
# Default-rate hunt has failed a few rounds: the card may be
# 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'
return
if action == 'retry':
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
else:
self.log("No valid TS byte, back to FIND START "
"(hunt {count})".format(count=self._atr_hunt_count))
self.state = 'FIND START'
return
# Give up hunting for an ATR here without emitting anything;
# 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.hasT1 = False
self.state = 'DATA'
@@ -1595,6 +1680,9 @@ class Decoder(srd.Decoder):
return
else:
self._atr_hunt_count = 0
self._atr_fail_total = 0
self._hunt_etu_attempts = 0
self._atr_idle_resets = 0
self.ATR.append(byte)
# TS=0x3F means inverse convention: logic 1 = LOW, logic 0 = HIGH.
# All subsequent byte reads must invert bits.
+87
View File
@@ -248,6 +248,93 @@ class TestDeglitchEdges(unittest.TestCase):
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):
"""CONCAT detection: structurally valid packets that embed complete T=0
exchanges (a byte-drop mis-frame swallowed several real exchanges into