diff --git a/decnet/prober/tcpfp.py b/decnet/prober/tcpfp.py
index 62284634..3495230d 100644
--- a/decnet/prober/tcpfp.py
+++ b/decnet/prober/tcpfp.py
@@ -139,6 +139,10 @@ def _parse_synack(resp: Any) -> dict[str, Any]:
# TCP fields
window_size = tcp_layer.window
+ # Server ISN: single sample from one probe — not classified here, but
+ # exported so a downstream consumer correlating multiple probes against
+ # the same target can apply seq_class.classify_sequence().
+ server_isn = int(getattr(tcp_layer, "seq", 0))
# Parse TCP options
mss = 0
@@ -165,6 +169,7 @@ def _parse_synack(resp: Any) -> dict[str, Any]:
"tos": tos,
"dscp": dscp,
"ecn": ecn,
+ "server_isn": server_isn,
"mss": mss,
"window_scale": window_scale,
"sack_ok": sack_ok,
diff --git a/decnet/prober/worker.py b/decnet/prober/worker.py
index 5f53c2ac..6c37608f 100644
--- a/decnet/prober/worker.py
+++ b/decnet/prober/worker.py
@@ -415,6 +415,7 @@ def _tcpfp_phase(
tos=str(result["tos"]),
dscp=str(result["dscp"]),
ecn=str(result["ecn"]),
+ server_isn=str(result["server_isn"]),
msg=f"TCPFP {ip}:{port} = {result['tcpfp_hash']}",
)
logger.info("prober: TCPFP %s:%d = %s", ip, port, result["tcpfp_hash"])
diff --git a/decnet/profiler/fingerprint.py b/decnet/profiler/fingerprint.py
index 6d46181e..c2721125 100644
--- a/decnet/profiler/fingerprint.py
+++ b/decnet/profiler/fingerprint.py
@@ -144,6 +144,7 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]:
hops: list[int] = []
tcp_fp: dict[str, Any] | None = None
ipid_latest: str | None = None
+ isn_latest: str | None = None
# Tracks which event set tcp_fp last — picks the provider "context"
# (syn vs synack) when we feed the p0f-v2 matcher below.
tcp_fp_context: str = "syn"
@@ -193,6 +194,10 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]:
if ipid_class and ipid_class != "unknown":
ipid_latest = ipid_class
tcp_fp["ipid_class"] = ipid_latest
+ isn_class = e.fields.get("isn_class")
+ if isn_class and isn_class != "unknown":
+ isn_latest = isn_class
+ tcp_fp["isn_class"] = isn_latest
tcp_fp_context = "syn"
elif e.event_type == _SNIFFER_FLOW_EVENT:
diff --git a/decnet/sniffer/fingerprint.py b/decnet/sniffer/fingerprint.py
index c94e1ac2..cd9bd31e 100644
--- a/decnet/sniffer/fingerprint.py
+++ b/decnet/sniffer/fingerprint.py
@@ -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,
)
diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx
index 478ac807..b37e1626 100644
--- a/decnet_web/src/components/AttackerDetail.tsx
+++ b/decnet_web/src/components/AttackerDetail.tsx
@@ -23,6 +23,7 @@ interface AttackerBehavior {
dscp?: number | null;
ecn?: number | null;
ipid_class?: string | null;
+ isn_class?: string | null;
} | null;
retransmit_count: number;
behavior_class: string | null;
@@ -771,6 +772,12 @@ const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
{fp.ipid_class && fp.ipid_class !== 'unknown' && (