From bca1b85b941e0b0d5643048a7bb41b04145e6417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D1=80=D0=BE=D1=88=D0=B8=D0=BD=20=D0=90=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=BD=20=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D1=8C=D0=B5=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Fri, 11 Sep 2026 17:45:54 +0300 Subject: [PATCH] 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. --- AGENTS.md | 49 +++++++++++++++++++ pd.py | 114 ++++++++++++++++++++++++++++++++++++++----- tests/test_gsmtap.py | 87 +++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7943378..24eb97e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/pd.py b/pd.py index 312c7d7..273b9b1 100644 --- a/pd.py +++ b/pd.py @@ -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 byte == 0xFF: - self.log("line appears idle (all 0xFF), resetting ATR hunt") - self._atr_hunt_count = 0 + if action == 'retry': + if byte == 0xFF: + 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. diff --git a/tests/test_gsmtap.py b/tests/test_gsmtap.py index 50d24c6..d581d5a 100644 --- a/tests/test_gsmtap.py +++ b/tests/test_gsmtap.py @@ -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