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
+237 -13

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 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
+101 -13
View File
@@ -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 byte == 0xFF: if action == 'retry':
self.log("line appears idle (all 0xFF), resetting ATR hunt") if byte == 0xFF:
self._atr_hunt_count = 0 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' 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.
+87
View File
@@ -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