feat(fingerprint): ToS/DSCP/ECN extraction in active + passive TCP fingerprint
Active prober now reads ip.tos from the SYN-ACK and emits tos/dscp/ecn alongside the existing TTL/window/options fields. dscp is folded into the fingerprint hash so different DSCP markings produce distinct signatures. Passive sniffer logs the same three fields on tcp_syn_fingerprint events; profiler rollup carries them into the attacker tcp_fingerprint snapshot; AttackerDetail's TCP STACK panel now surfaces DSCP and ECN cells.
This commit is contained in:
@@ -133,6 +133,9 @@ def _parse_synack(resp: Any) -> dict[str, Any]:
|
|||||||
ttl = ip_layer.ttl
|
ttl = ip_layer.ttl
|
||||||
df_bit = 1 if (ip_layer.flags & 0x2) else 0 # DF = bit 1
|
df_bit = 1 if (ip_layer.flags & 0x2) else 0 # DF = bit 1
|
||||||
ip_id = ip_layer.id
|
ip_id = ip_layer.id
|
||||||
|
tos = int(getattr(ip_layer, "tos", 0))
|
||||||
|
dscp = (tos >> 2) & 0x3F
|
||||||
|
ecn = tos & 0x3
|
||||||
|
|
||||||
# TCP fields
|
# TCP fields
|
||||||
window_size = tcp_layer.window
|
window_size = tcp_layer.window
|
||||||
@@ -159,6 +162,9 @@ def _parse_synack(resp: Any) -> dict[str, Any]:
|
|||||||
"window_size": window_size,
|
"window_size": window_size,
|
||||||
"df_bit": df_bit,
|
"df_bit": df_bit,
|
||||||
"ip_id": ip_id,
|
"ip_id": ip_id,
|
||||||
|
"tos": tos,
|
||||||
|
"dscp": dscp,
|
||||||
|
"ecn": ecn,
|
||||||
"mss": mss,
|
"mss": mss,
|
||||||
"window_scale": window_scale,
|
"window_scale": window_scale,
|
||||||
"sack_ok": sack_ok,
|
"sack_ok": sack_ok,
|
||||||
@@ -191,7 +197,8 @@ def _compute_fingerprint(fields: dict[str, Any]) -> tuple[str, str]:
|
|||||||
raw = (
|
raw = (
|
||||||
f"{fields['ttl']}:{fields['window_size']}:{fields['df_bit']}:"
|
f"{fields['ttl']}:{fields['window_size']}:{fields['df_bit']}:"
|
||||||
f"{fields['mss']}:{fields['window_scale']}:{fields['sack_ok']}:"
|
f"{fields['mss']}:{fields['window_scale']}:{fields['sack_ok']}:"
|
||||||
f"{fields['timestamp']}:{fields['options_order']}"
|
f"{fields['timestamp']}:{fields['options_order']}:"
|
||||||
|
f"{fields.get('dscp', 0)}:{fields.get('ecn', 0)}"
|
||||||
)
|
)
|
||||||
h = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
h = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
||||||
return raw, h
|
return raw, h
|
||||||
|
|||||||
@@ -412,6 +412,9 @@ def _tcpfp_phase(
|
|||||||
sack_ok=str(result["sack_ok"]),
|
sack_ok=str(result["sack_ok"]),
|
||||||
timestamp=str(result["timestamp"]),
|
timestamp=str(result["timestamp"]),
|
||||||
options_order=result["options_order"],
|
options_order=result["options_order"],
|
||||||
|
tos=str(result["tos"]),
|
||||||
|
dscp=str(result["dscp"]),
|
||||||
|
ecn=str(result["ecn"]),
|
||||||
msg=f"TCPFP {ip}:{port} = {result['tcpfp_hash']}",
|
msg=f"TCPFP {ip}:{port} = {result['tcpfp_hash']}",
|
||||||
)
|
)
|
||||||
logger.info("prober: TCPFP %s:%d = %s", ip, port, result["tcpfp_hash"])
|
logger.info("prober: TCPFP %s:%d = %s", ip, port, result["tcpfp_hash"])
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]:
|
|||||||
"options_sig": e.fields.get("options_sig", ""),
|
"options_sig": e.fields.get("options_sig", ""),
|
||||||
"has_sack": e.fields.get("has_sack") == "true",
|
"has_sack": e.fields.get("has_sack") == "true",
|
||||||
"has_timestamps": e.fields.get("has_timestamps") == "true",
|
"has_timestamps": e.fields.get("has_timestamps") == "true",
|
||||||
|
"tos": _int_or_none(e.fields.get("tos")),
|
||||||
|
"dscp": _int_or_none(e.fields.get("dscp")),
|
||||||
|
"ecn": _int_or_none(e.fields.get("ecn")),
|
||||||
}
|
}
|
||||||
tcp_fp_context = "syn"
|
tcp_fp_context = "syn"
|
||||||
|
|
||||||
@@ -236,6 +239,9 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]:
|
|||||||
"options_sig": e.fields.get("options_order", ""),
|
"options_sig": e.fields.get("options_order", ""),
|
||||||
"has_sack": e.fields.get("sack_ok") == "1",
|
"has_sack": e.fields.get("sack_ok") == "1",
|
||||||
"has_timestamps": e.fields.get("timestamp") == "1",
|
"has_timestamps": e.fields.get("timestamp") == "1",
|
||||||
|
"tos": _int_or_none(e.fields.get("tos")),
|
||||||
|
"dscp": _int_or_none(e.fields.get("dscp")),
|
||||||
|
"ecn": _int_or_none(e.fields.get("ecn")),
|
||||||
}
|
}
|
||||||
tcp_fp_context = "synack" # prober sent SYN, captured attacker's SYN-ACK
|
tcp_fp_context = "synack" # prober sent SYN, captured attacker's SYN-ACK
|
||||||
|
|
||||||
|
|||||||
@@ -1056,6 +1056,9 @@ class SnifferEngine:
|
|||||||
options_sig=tcp_fp["options_sig"],
|
options_sig=tcp_fp["options_sig"],
|
||||||
has_sack=str(tcp_fp["sack_ok"]).lower(),
|
has_sack=str(tcp_fp["sack_ok"]).lower(),
|
||||||
has_timestamps=str(tcp_fp["has_timestamps"]).lower(),
|
has_timestamps=str(tcp_fp["has_timestamps"]).lower(),
|
||||||
|
tos=str(int(getattr(ip, "tos", 0))),
|
||||||
|
dscp=str((int(getattr(ip, "tos", 0)) >> 2) & 0x3F),
|
||||||
|
ecn=str(int(getattr(ip, "tos", 0)) & 0x3),
|
||||||
os_guess=os_label,
|
os_guess=os_label,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ interface AttackerBehavior {
|
|||||||
options_sig?: string;
|
options_sig?: string;
|
||||||
has_sack?: boolean;
|
has_sack?: boolean;
|
||||||
has_timestamps?: boolean;
|
has_timestamps?: boolean;
|
||||||
|
tos?: number | null;
|
||||||
|
dscp?: number | null;
|
||||||
|
ecn?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
retransmit_count: number;
|
retransmit_count: number;
|
||||||
behavior_class: string | null;
|
behavior_class: string | null;
|
||||||
@@ -688,7 +691,7 @@ const BeaconBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
|||||||
|
|
||||||
const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
||||||
const fp = b.tcp_fingerprint;
|
const fp = b.tcp_fingerprint;
|
||||||
if (!fp || (!fp.window && !fp.mss && !fp.options_sig)) return null;
|
if (!fp || (!fp.window && !fp.mss && !fp.options_sig && fp.dscp == null && fp.ecn == null)) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
border: '1px solid var(--border-color)', padding: '12px 16px',
|
border: '1px solid var(--border-color)', padding: '12px 16px',
|
||||||
@@ -723,6 +726,18 @@ const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
|||||||
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.mss}</span>
|
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.mss}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{fp.dscp !== null && fp.dscp !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="dim" style={{ fontSize: '0.7rem' }}>DSCP </span>
|
||||||
|
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.dscp}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fp.ecn !== null && fp.ecn !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="dim" style={{ fontSize: '0.7rem' }}>ECN </span>
|
||||||
|
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.ecn}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span className="dim" style={{ fontSize: '0.7rem' }}>RETRANSMITS </span>
|
<span className="dim" style={{ fontSize: '0.7rem' }}>RETRANSMITS </span>
|
||||||
<span
|
<span
|
||||||
@@ -1597,29 +1612,47 @@ const AttackerDetail: React.FC = () => {
|
|||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<Section title="SERVICES TARGETED" open={openSections.services} onToggle={() => toggle('services')}>
|
<Section title="SERVICES TARGETED" open={openSections.services} onToggle={() => toggle('services')}>
|
||||||
<div style={{ padding: '16px', display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
<div style={{ padding: '16px' }}>
|
||||||
{attacker.services.length > 0 ? attacker.services.map((svc) => {
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
const isActive = serviceFilter === svc;
|
{attacker.services.length > 0 ? attacker.services.map((svc) => {
|
||||||
return (
|
const isActive = serviceFilter === svc;
|
||||||
<span
|
const interacted = attacker.service_activity?.interacted.includes(svc) ?? false;
|
||||||
key={svc}
|
const baseStyle: React.CSSProperties = interacted
|
||||||
className="service-badge"
|
? { borderColor: 'var(--accent-color)', color: 'var(--accent-color)', background: 'rgba(238, 130, 238, 0.08)' }
|
||||||
style={{
|
: { opacity: 0.55 };
|
||||||
fontSize: '0.85rem', padding: '4px 12px', cursor: 'pointer',
|
const activeStyle: React.CSSProperties = isActive
|
||||||
...(isActive ? {
|
? interacted
|
||||||
backgroundColor: 'var(--text-color)',
|
? { backgroundColor: 'var(--accent-color)', color: 'var(--bg-color)', borderColor: 'var(--accent-color)', opacity: 1 }
|
||||||
color: 'var(--bg-color)',
|
: { backgroundColor: 'var(--text-color)', color: 'var(--bg-color)', borderColor: 'var(--text-color)', opacity: 1 }
|
||||||
borderColor: 'var(--text-color)',
|
: {};
|
||||||
} : {}),
|
return (
|
||||||
}}
|
<span
|
||||||
onClick={() => setServiceFilter(isActive ? null : svc)}
|
key={svc}
|
||||||
title={isActive ? 'Clear filter' : `Filter by ${svc.toUpperCase()}`}
|
className="service-badge"
|
||||||
>
|
style={{
|
||||||
{svc.toUpperCase()}
|
fontSize: '0.85rem', padding: '4px 12px', cursor: 'pointer',
|
||||||
</span>
|
...baseStyle,
|
||||||
);
|
...activeStyle,
|
||||||
}) : (
|
}}
|
||||||
<span className="dim">No services recorded</span>
|
onClick={() => setServiceFilter(isActive ? null : svc)}
|
||||||
|
title={
|
||||||
|
isActive
|
||||||
|
? 'Clear filter'
|
||||||
|
: `Filter by ${svc.toUpperCase()} — ${interacted ? 'interacted with' : 'scanned only'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{interacted ? '· ' : ''}{svc.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<span className="dim">No services recorded</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{attacker.services.length > 0 && (
|
||||||
|
<div style={{ marginTop: '12px', fontSize: '0.7rem', display: 'flex', gap: '16px' }}>
|
||||||
|
<span style={{ color: 'var(--accent-color)' }}>· INTERACTED</span>
|
||||||
|
<span className="dim">SCAN-ONLY</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ def _make_synack(
|
|||||||
ttl: int = 64,
|
ttl: int = 64,
|
||||||
flags: int = 0x02, # IP flags (DF = 0x02)
|
flags: int = 0x02, # IP flags (DF = 0x02)
|
||||||
ip_id: int = 0,
|
ip_id: int = 0,
|
||||||
|
tos: int = 0,
|
||||||
window: int = 65535,
|
window: int = 65535,
|
||||||
tcp_flags: int = 0x12, # SYN-ACK
|
tcp_flags: int = 0x12, # SYN-ACK
|
||||||
options: list | None = None,
|
options: list | None = None,
|
||||||
@@ -56,6 +57,7 @@ def _make_synack(
|
|||||||
ttl=ttl,
|
ttl=ttl,
|
||||||
flags=flags,
|
flags=flags,
|
||||||
id=ip_id,
|
id=ip_id,
|
||||||
|
tos=tos,
|
||||||
)
|
)
|
||||||
|
|
||||||
class FakePacket:
|
class FakePacket:
|
||||||
@@ -181,6 +183,28 @@ class TestParseSynack:
|
|||||||
result = _parse_synack(resp)
|
result = _parse_synack(resp)
|
||||||
assert result["ip_id"] == 12345
|
assert result["ip_id"] == 12345
|
||||||
|
|
||||||
|
def test_tos_default_zero(self):
|
||||||
|
resp = _make_synack()
|
||||||
|
result = _parse_synack(resp)
|
||||||
|
assert result["tos"] == 0
|
||||||
|
assert result["dscp"] == 0
|
||||||
|
assert result["ecn"] == 0
|
||||||
|
|
||||||
|
def test_tos_dscp_af11_ecn_ect0(self):
|
||||||
|
# AF11 = DSCP 10 (0b001010); ECT(0) = 0b10 → ToS byte 0b00101010 = 0x2A
|
||||||
|
resp = _make_synack(tos=0x2A)
|
||||||
|
result = _parse_synack(resp)
|
||||||
|
assert result["tos"] == 0x2A
|
||||||
|
assert result["dscp"] == 10
|
||||||
|
assert result["ecn"] == 2
|
||||||
|
|
||||||
|
def test_tos_ce_marked(self):
|
||||||
|
# ECN CE bit set, no DSCP marking → ToS = 0x03
|
||||||
|
resp = _make_synack(tos=0x03)
|
||||||
|
result = _parse_synack(resp)
|
||||||
|
assert result["dscp"] == 0
|
||||||
|
assert result["ecn"] == 3
|
||||||
|
|
||||||
def test_empty_options(self):
|
def test_empty_options(self):
|
||||||
resp = _make_synack(options=[])
|
resp = _make_synack(options=[])
|
||||||
result = _parse_synack(resp)
|
result = _parse_synack(resp)
|
||||||
@@ -254,9 +278,22 @@ class TestComputeFingerprint:
|
|||||||
"ttl": 64, "window_size": 65535, "df_bit": 1,
|
"ttl": 64, "window_size": 65535, "df_bit": 1,
|
||||||
"mss": 1460, "window_scale": 7, "sack_ok": 1,
|
"mss": 1460, "window_scale": 7, "sack_ok": 1,
|
||||||
"timestamp": 1, "options_order": "M,N,W",
|
"timestamp": 1, "options_order": "M,N,W",
|
||||||
|
"dscp": 0, "ecn": 0,
|
||||||
}
|
}
|
||||||
raw, _ = _compute_fingerprint(fields)
|
raw, _ = _compute_fingerprint(fields)
|
||||||
assert raw == "64:65535:1:1460:7:1:1:M,N,W"
|
assert raw == "64:65535:1:1460:7:1:1:M,N,W:0:0"
|
||||||
|
|
||||||
|
def test_dscp_changes_hash(self):
|
||||||
|
base = {
|
||||||
|
"ttl": 64, "window_size": 65535, "df_bit": 1,
|
||||||
|
"mss": 1460, "window_scale": 7, "sack_ok": 1,
|
||||||
|
"timestamp": 1, "options_order": "M,N,W",
|
||||||
|
"dscp": 0, "ecn": 0,
|
||||||
|
}
|
||||||
|
marked = dict(base, dscp=46) # EF
|
||||||
|
_, h_base = _compute_fingerprint(base)
|
||||||
|
_, h_marked = _compute_fingerprint(marked)
|
||||||
|
assert h_base != h_marked
|
||||||
|
|
||||||
def test_sha256_correctness(self):
|
def test_sha256_correctness(self):
|
||||||
fields = {
|
fields = {
|
||||||
|
|||||||
@@ -147,6 +147,22 @@ class TestSynFingerprintEmission:
|
|||||||
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
|
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
|
||||||
assert len(fp_lines) == 2
|
assert len(fp_lines) == 2
|
||||||
|
|
||||||
|
def test_tos_dscp_ecn_emitted(self):
|
||||||
|
engine, captured = _make_engine()
|
||||||
|
# ToS 0x2A → DSCP 10 (AF11), ECN 2 (ECT(0))
|
||||||
|
pkt = IP(src=_ATTACKER_IP, dst=_DECKY_IP, ttl=64, tos=0x2A) / TCP(
|
||||||
|
sport=45100, dport=22, flags="S", window=29200,
|
||||||
|
options=[("MSS", 1460), ("SAckOK", b""), ("Timestamp", (0, 0)),
|
||||||
|
("NOP", None), ("WScale", 7)],
|
||||||
|
)
|
||||||
|
engine.on_packet(pkt)
|
||||||
|
fp_lines = [ln for ln in captured if _msgid(ln) == "tcp_syn_fingerprint"]
|
||||||
|
assert len(fp_lines) == 1
|
||||||
|
f = _fields_from_line(fp_lines[0])
|
||||||
|
assert f["tos"] == "42"
|
||||||
|
assert f["dscp"] == "10"
|
||||||
|
assert f["ecn"] == "2"
|
||||||
|
|
||||||
def test_decky_source_does_not_emit(self):
|
def test_decky_source_does_not_emit(self):
|
||||||
"""Packets originating from a decky (outbound reply) should NOT
|
"""Packets originating from a decky (outbound reply) should NOT
|
||||||
be classified as an attacker fingerprint."""
|
be classified as an attacker fingerprint."""
|
||||||
|
|||||||
Reference in New Issue
Block a user