feat(sniffer): ISN sequence classifier (reuses seq_class helper)

Mirrors the IP-ID classifier for TCP ISN values: per-source-IP rolling
deque (maxlen=8) populated from each inbound SYN's tcp.seq, classified
on every emission. A 'random' verdict is the modern norm; 'incremental',
'zero', or 'constant' indicates legacy stacks or hand-rolled raw-socket
tooling — a strong fingerprint signal.

Active prober now also captures server_isn (single sample, not classified
in-flight; downstream consumers correlating multi-probe results can apply
seq_class.classify_sequence themselves).

Profiler rollup carries the latest non-'unknown' label into
attacker.tcp_fingerprint. Dedup key already covers isn_class from
the previous commit, so transitions emit cleanly.

UI surfaces ISN class as a colour-coded tag with a ⚠ glyph for
non-random verdicts, since they're the genuinely interesting case.
This commit is contained in:
2026-04-26 20:30:24 -04:00
parent 0e40cc8ae1
commit c595d039bd
7 changed files with 67 additions and 0 deletions

View File

@@ -752,6 +752,7 @@ class SnifferEngine:
# we can label them random/incremental/zero/constant.
self._SEQ_SAMPLE_SIZE = 8
self._ipid_samples: dict[str, deque[int]] = {}
self._isn_samples: dict[str, deque[int]] = {}
# Per-flow timing aggregator. Key: (src_ip, src_port, dst_ip, dst_port).
# Flow direction is client→decky; reverse packets are associated back
@@ -1052,6 +1053,12 @@ class SnifferEngine:
)
ipid_buf.append(int(ip.id))
ipid_class = classify_sequence(list(ipid_buf))
isn_buf = self._isn_samples.setdefault(
src_ip, deque(maxlen=self._SEQ_SAMPLE_SIZE)
)
isn_buf.append(int(tcp.seq))
isn_class = classify_sequence(list(isn_buf))
os_label = guess_os(
ttl=ip.ttl,
window=int(tcp.window),
@@ -1082,6 +1089,8 @@ class SnifferEngine:
ecn=str(int(getattr(ip, "tos", 0)) & 0x3),
ipid_class=ipid_class,
ipid_samples=str(len(ipid_buf)),
isn_class=isn_class,
isn_samples=str(len(isn_buf)),
os_guess=os_label,
)