feat(sniffer): capture SSH client banner from TCP stream
Parse RFC 4253 §4.2 identification strings from the first attacker→decky data segment on TCP/22; emit ssh_client_banner syslog events and bus fan-out. Profiler's sniffer_rollup dedupes observed banners into a new AttackerBehavior.ssh_client_banners JSON column. Closes gap #3 from SIGNAL_CAPTURE_AUDIT.md.
This commit is contained in:
@@ -90,11 +90,13 @@ def build_behavior_record(events: list[LogEvent]) -> dict[str, Any]:
|
|||||||
_span.set_attribute("tools", ",".join(all_tools))
|
_span.set_attribute("tools", ",".join(all_tools))
|
||||||
|
|
||||||
kex_list = rollup.get("kex_order_raw") or []
|
kex_list = rollup.get("kex_order_raw") or []
|
||||||
|
ssh_banners = rollup.get("ssh_client_banners") or []
|
||||||
return {
|
return {
|
||||||
"os_guess": rollup["os_guess"],
|
"os_guess": rollup["os_guess"],
|
||||||
"hop_distance": rollup["hop_distance"],
|
"hop_distance": rollup["hop_distance"],
|
||||||
"tcp_fingerprint": json.dumps(rollup["tcp_fingerprint"]),
|
"tcp_fingerprint": json.dumps(rollup["tcp_fingerprint"]),
|
||||||
"kex_order_raw": json.dumps(kex_list) if kex_list else None,
|
"kex_order_raw": json.dumps(kex_list) if kex_list else None,
|
||||||
|
"ssh_client_banners": json.dumps(ssh_banners) if ssh_banners else None,
|
||||||
"retransmit_count": rollup["retransmit_count"],
|
"retransmit_count": rollup["retransmit_count"],
|
||||||
"behavior_class": behavior,
|
"behavior_class": behavior,
|
||||||
"beacon_interval_s": beacon_interval_s,
|
"beacon_interval_s": beacon_interval_s,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ _SNIFFER_FLOW_EVENT: str = "tcp_flow_timing"
|
|||||||
_PROBER_TCPFP_EVENT: str = "tcpfp_fingerprint"
|
_PROBER_TCPFP_EVENT: str = "tcpfp_fingerprint"
|
||||||
# Prober-emitted HASSHServer fingerprint; carries the raw kex_algorithms string.
|
# Prober-emitted HASSHServer fingerprint; carries the raw kex_algorithms string.
|
||||||
_PROBER_HASSH_EVENT: str = "hassh_fingerprint"
|
_PROBER_HASSH_EVENT: str = "hassh_fingerprint"
|
||||||
|
# Sniffer-emitted SSH client identification string (RFC 4253 §4.2).
|
||||||
|
_SNIFFER_SSH_BANNER_EVENT: str = "ssh_client_banner"
|
||||||
|
|
||||||
# Canonical initial TTL for each coarse OS bucket. Used to derive hop
|
# Canonical initial TTL for each coarse OS bucket. Used to derive hop
|
||||||
# distance when only the observed TTL is available (prober path).
|
# distance when only the observed TTL is available (prober path).
|
||||||
@@ -75,6 +77,8 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]:
|
|||||||
retransmits = 0
|
retransmits = 0
|
||||||
kex_order_raw: list[str] = []
|
kex_order_raw: list[str] = []
|
||||||
_kex_seen: set[str] = set()
|
_kex_seen: set[str] = set()
|
||||||
|
ssh_client_banners: list[str] = []
|
||||||
|
_ssh_banner_seen: set[str] = set()
|
||||||
|
|
||||||
for e in events:
|
for e in events:
|
||||||
if e.event_type == _SNIFFER_SYN_EVENT:
|
if e.event_type == _SNIFFER_SYN_EVENT:
|
||||||
@@ -122,6 +126,15 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]:
|
|||||||
kex_order_raw.append(kex)
|
kex_order_raw.append(kex)
|
||||||
_kex_seen.add(kex)
|
_kex_seen.add(kex)
|
||||||
|
|
||||||
|
elif e.event_type == _SNIFFER_SSH_BANNER_EVENT:
|
||||||
|
# Sniffer-observed SSH identification string from attacker.
|
||||||
|
# Dedup: the same attacker will reuse the same client banner
|
||||||
|
# across flows/reconnects; record distinct values in order seen.
|
||||||
|
banner = e.fields.get("ssh_version")
|
||||||
|
if banner and banner not in _ssh_banner_seen:
|
||||||
|
ssh_client_banners.append(banner)
|
||||||
|
_ssh_banner_seen.add(banner)
|
||||||
|
|
||||||
elif e.event_type == _PROBER_TCPFP_EVENT:
|
elif e.event_type == _PROBER_TCPFP_EVENT:
|
||||||
# Active-probe result: prober sent SYN to attacker, got SYN-ACK back.
|
# Active-probe result: prober sent SYN to attacker, got SYN-ACK back.
|
||||||
# Field names differ from the passive sniffer (different emitter).
|
# Field names differ from the passive sniffer (different emitter).
|
||||||
@@ -173,4 +186,5 @@ def sniffer_rollup(events: list[LogEvent]) -> dict[str, Any]:
|
|||||||
"tcp_fingerprint": tcp_fp or {},
|
"tcp_fingerprint": tcp_fp or {},
|
||||||
"retransmit_count": retransmits,
|
"retransmit_count": retransmits,
|
||||||
"kex_order_raw": kex_order_raw,
|
"kex_order_raw": kex_order_raw,
|
||||||
|
"ssh_client_banners": ssh_client_banners,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,35 @@ _BUS_TRAFFIC_EVENTS: frozenset[str] = frozenset({
|
|||||||
"tls_session",
|
"tls_session",
|
||||||
"tcp_flow_timing",
|
"tcp_flow_timing",
|
||||||
"tcp_syn_fingerprint",
|
"tcp_syn_fingerprint",
|
||||||
|
"ssh_client_banner",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ssh_banner(data: bytes) -> str | None:
|
||||||
|
"""
|
||||||
|
Return the attacker's SSH identification string (RFC 4253 §4.2) if
|
||||||
|
*data* begins with one, else None.
|
||||||
|
|
||||||
|
A valid banner starts with ``SSH-`` and terminates at the first CR or LF
|
||||||
|
within the 255-byte RFC-mandated window. The returned string is decoded
|
||||||
|
as ASCII and stripped of the trailing CR/LF bytes.
|
||||||
|
"""
|
||||||
|
if not data.startswith(b"SSH-"):
|
||||||
|
return None
|
||||||
|
end = -1
|
||||||
|
# RFC 4253: identification string (incl. CR LF) must not exceed 255 bytes.
|
||||||
|
for i, b in enumerate(data[:255]):
|
||||||
|
if b in (0x0D, 0x0A): # CR or LF
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
if end < 5: # "SSH-X" minimum
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return data[:end].decode("ascii", errors="strict")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ─── TCP option extraction for passive fingerprinting ───────────────────────
|
# ─── TCP option extraction for passive fingerprinting ───────────────────────
|
||||||
|
|
||||||
def _extract_tcp_fingerprint(tcp_options: list) -> dict[str, Any]:
|
def _extract_tcp_fingerprint(tcp_options: list) -> dict[str, Any]:
|
||||||
@@ -1053,6 +1079,29 @@ class SnifferEngine:
|
|||||||
if not payload:
|
if not payload:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# SSH client banner (RFC 4253 §4.2): attacker→decky TCP/22, first
|
||||||
|
# application-data segment of the flow. Emit once per flow.
|
||||||
|
if (
|
||||||
|
dst_port == 22
|
||||||
|
and dst_ip in self._ip_to_decky
|
||||||
|
and direction_forward
|
||||||
|
):
|
||||||
|
flow = self._flows.get(flow_key)
|
||||||
|
if flow is not None and not flow.get("ssh_banner_seen"):
|
||||||
|
banner = _parse_ssh_banner(payload)
|
||||||
|
if banner is not None:
|
||||||
|
flow["ssh_banner_seen"] = True
|
||||||
|
target_node = self._ip_to_decky[dst_ip]
|
||||||
|
self._log(
|
||||||
|
target_node,
|
||||||
|
"ssh_client_banner",
|
||||||
|
src_ip=src_ip,
|
||||||
|
src_port=str(src_port),
|
||||||
|
dst_ip=dst_ip,
|
||||||
|
dst_port=str(dst_port),
|
||||||
|
ssh_version=banner,
|
||||||
|
)
|
||||||
|
|
||||||
if payload[0] != _TLS_RECORD_HANDSHAKE:
|
if payload[0] != _TLS_RECORD_HANDSHAKE:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ class AttackerBehavior(SQLModel, table=True):
|
|||||||
default=None,
|
default=None,
|
||||||
sa_column=Column("kex_order_raw", Text, nullable=True),
|
sa_column=Column("kex_order_raw", Text, nullable=True),
|
||||||
) # JSON list[str] — kex_algorithms comma-separated strings
|
) # JSON list[str] — kex_algorithms comma-separated strings
|
||||||
|
# Sniffer-observed SSH client identification strings (RFC 4253 §4.2),
|
||||||
|
# deduped in observation order. Captures the attacker's SSH client
|
||||||
|
# software (e.g. "SSH-2.0-OpenSSH_9.2p1", "SSH-2.0-libssh2_1.10.0").
|
||||||
|
ssh_client_banners: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column("ssh_client_banners", Text, nullable=True),
|
||||||
|
) # JSON list[str]
|
||||||
retransmit_count: int = Field(default=0)
|
retransmit_count: int = Field(default=0)
|
||||||
# Behavioral (derived by the profiler from log-event timing)
|
# Behavioral (derived by the profiler from log-event timing)
|
||||||
behavior_class: Optional[str] = None # beaconing | interactive | scanning | brute_force | slow_scan | mixed | unknown
|
behavior_class: Optional[str] = None # beaconing | interactive | scanning | brute_force | slow_scan | mixed | unknown
|
||||||
|
|||||||
@@ -683,6 +683,16 @@ class SQLModelRepository(BaseRepository):
|
|||||||
d["kex_order_raw"] = []
|
d["kex_order_raw"] = []
|
||||||
elif raw_kex is None:
|
elif raw_kex is None:
|
||||||
d["kex_order_raw"] = []
|
d["kex_order_raw"] = []
|
||||||
|
# Same list-or-None pattern for ssh_client_banners.
|
||||||
|
raw_banners = d.get("ssh_client_banners")
|
||||||
|
if isinstance(raw_banners, str):
|
||||||
|
try:
|
||||||
|
parsed_banners = json.loads(raw_banners)
|
||||||
|
d["ssh_client_banners"] = parsed_banners if isinstance(parsed_banners, list) else [parsed_banners]
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
d["ssh_client_banners"] = []
|
||||||
|
elif raw_banners is None:
|
||||||
|
d["ssh_client_banners"] = []
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -486,6 +486,30 @@ class TestSnifferRollup:
|
|||||||
r = sniffer_rollup(events)
|
r = sniffer_rollup(events)
|
||||||
assert r["kex_order_raw"] == []
|
assert r["kex_order_raw"] == []
|
||||||
|
|
||||||
|
def test_ssh_client_banners_collected(self):
|
||||||
|
# Sniffer ssh_client_banner events accumulate the attacker's observed
|
||||||
|
# SSH identification strings, deduplicated in observation order.
|
||||||
|
ban_a = "SSH-2.0-OpenSSH_9.2p1 Debian-2"
|
||||||
|
ban_b = "SSH-2.0-libssh2_1.10.0"
|
||||||
|
events = [
|
||||||
|
_mk(0, event_type="ssh_client_banner",
|
||||||
|
fields={"ssh_version": ban_a}),
|
||||||
|
_mk(1, event_type="ssh_client_banner",
|
||||||
|
fields={"ssh_version": ban_a}), # dup
|
||||||
|
_mk(2, event_type="ssh_client_banner",
|
||||||
|
fields={"ssh_version": ban_b}),
|
||||||
|
]
|
||||||
|
r = sniffer_rollup(events)
|
||||||
|
assert r["ssh_client_banners"] == [ban_a, ban_b]
|
||||||
|
|
||||||
|
def test_ssh_client_banners_empty_when_none(self):
|
||||||
|
events = [
|
||||||
|
_mk(0, event_type="tcp_syn_fingerprint",
|
||||||
|
fields={"os_guess": "linux"}),
|
||||||
|
]
|
||||||
|
r = sniffer_rollup(events)
|
||||||
|
assert r["ssh_client_banners"] == []
|
||||||
|
|
||||||
|
|
||||||
# ─── build_behavior_record (composite) ──────────────────────────────────────
|
# ─── build_behavior_record (composite) ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -565,6 +589,20 @@ class TestBuildBehaviorRecord:
|
|||||||
r = build_behavior_record(_regular_beacon(count=5, interval_s=60.0))
|
r = build_behavior_record(_regular_beacon(count=5, interval_s=60.0))
|
||||||
assert r["kex_order_raw"] is None
|
assert r["kex_order_raw"] is None
|
||||||
|
|
||||||
|
def test_ssh_client_banners_persisted_as_json(self):
|
||||||
|
banner = "SSH-2.0-OpenSSH_9.2p1"
|
||||||
|
events = [
|
||||||
|
_mk(0, event_type="ssh_client_banner",
|
||||||
|
fields={"ssh_version": banner}),
|
||||||
|
]
|
||||||
|
r = build_behavior_record(events)
|
||||||
|
assert isinstance(r["ssh_client_banners"], str)
|
||||||
|
assert json.loads(r["ssh_client_banners"]) == [banner]
|
||||||
|
|
||||||
|
def test_ssh_client_banners_null_when_none(self):
|
||||||
|
r = build_behavior_record(_regular_beacon(count=5, interval_s=60.0))
|
||||||
|
assert r["ssh_client_banners"] is None
|
||||||
|
|
||||||
def test_nmap_promoted_from_tcp_fingerprint(self):
|
def test_nmap_promoted_from_tcp_fingerprint(self):
|
||||||
# p0f identifies nmap from TCP handshake → must appear in tool_guesses
|
# p0f identifies nmap from TCP handshake → must appear in tool_guesses
|
||||||
# even when no HTTP request events are present.
|
# even when no HTTP request events are present.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from decnet.sniffer.fingerprint import (
|
|||||||
_ja3s,
|
_ja3s,
|
||||||
_parse_client_hello,
|
_parse_client_hello,
|
||||||
_parse_server_hello,
|
_parse_server_hello,
|
||||||
|
_parse_ssh_banner,
|
||||||
_session_resumption_info,
|
_session_resumption_info,
|
||||||
_tls_version_str,
|
_tls_version_str,
|
||||||
)
|
)
|
||||||
@@ -125,6 +126,33 @@ class TestTlsParsers:
|
|||||||
assert _parse_server_hello(b"garbage") is None
|
assert _parse_server_hello(b"garbage") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── SSH banner parser tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSshBannerParser:
|
||||||
|
def test_openssh_banner_crlf(self):
|
||||||
|
data = b"SSH-2.0-OpenSSH_9.2p1 Debian-2\r\nkex-init..."
|
||||||
|
assert _parse_ssh_banner(data) == "SSH-2.0-OpenSSH_9.2p1 Debian-2"
|
||||||
|
|
||||||
|
def test_banner_lf_only(self):
|
||||||
|
data = b"SSH-2.0-libssh2_1.10.0\n"
|
||||||
|
assert _parse_ssh_banner(data) == "SSH-2.0-libssh2_1.10.0"
|
||||||
|
|
||||||
|
def test_non_ssh_payload(self):
|
||||||
|
assert _parse_ssh_banner(b"GET / HTTP/1.1\r\n") is None
|
||||||
|
assert _parse_ssh_banner(b"") is None
|
||||||
|
assert _parse_ssh_banner(b"\x16\x03\x01\x00") is None
|
||||||
|
|
||||||
|
def test_missing_terminator(self):
|
||||||
|
# No CR/LF within the 255-byte RFC window → not a complete banner yet.
|
||||||
|
assert _parse_ssh_banner(b"SSH-2.0-OpenSSH_9.2p1" + b" " * 300) is None
|
||||||
|
|
||||||
|
def test_banner_too_short(self):
|
||||||
|
assert _parse_ssh_banner(b"SSH-\r\n") is None
|
||||||
|
|
||||||
|
def test_non_ascii_rejected(self):
|
||||||
|
assert _parse_ssh_banner(b"SSH-2.0-\xff\xfe\r\n") is None
|
||||||
|
|
||||||
|
|
||||||
# ─── Fingerprint computation tests ──────────────────────────────────────────
|
# ─── Fingerprint computation tests ──────────────────────────────────────────
|
||||||
|
|
||||||
class TestFingerprints:
|
class TestFingerprints:
|
||||||
|
|||||||
Reference in New Issue
Block a user