fix(os-fingerprint): Win timestamps ON + windows_server profile + OS_MANGLE schema
Win10/11 run TCP timestamps ON (nmap SEQ.TS=A); the windows profile had them OFF, fingerprinting as an ancient stack. Add a windows_server slug (ECN negotiated, CC=Y) and point the server/DC archetypes at it. Introduce the OS_MANGLE map (per-slug egress SYN-ACK shape: window, option order, IP-ID policy) consumed by the new cloak package.
This commit is contained in:
@@ -47,7 +47,7 @@ ARCHETYPES: dict[str, Archetype] = {
|
|||||||
description="Windows domain member: SMB, RDP, and LDAP directory",
|
description="Windows domain member: SMB, RDP, and LDAP directory",
|
||||||
services=["smb", "rdp", "ldap"],
|
services=["smb", "rdp", "ldap"],
|
||||||
preferred_distros=["debian", "ubuntu22"],
|
preferred_distros=["debian", "ubuntu22"],
|
||||||
nmap_os="windows",
|
nmap_os="windows_server",
|
||||||
),
|
),
|
||||||
"domain-controller": Archetype(
|
"domain-controller": Archetype(
|
||||||
slug="domain-controller",
|
slug="domain-controller",
|
||||||
@@ -55,7 +55,7 @@ ARCHETYPES: dict[str, Archetype] = {
|
|||||||
description="Active Directory DC: LDAP, SMB, RDP, LLMNR",
|
description="Active Directory DC: LDAP, SMB, RDP, LLMNR",
|
||||||
services=["ldap", "smb", "rdp", "llmnr"],
|
services=["ldap", "smb", "rdp", "llmnr"],
|
||||||
preferred_distros=["debian", "ubuntu22"],
|
preferred_distros=["debian", "ubuntu22"],
|
||||||
nmap_os="windows",
|
nmap_os="windows_server",
|
||||||
),
|
),
|
||||||
"linux-server": Archetype(
|
"linux-server": Archetype(
|
||||||
slug="linux-server",
|
slug="linux-server",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ Windows (64240) from the kernel's default tcp_rmem settings.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
OS_SYSCTLS: dict[str, dict[str, str]] = {
|
OS_SYSCTLS: dict[str, dict[str, str]] = {
|
||||||
"linux": {
|
"linux": {
|
||||||
"net.ipv4.ip_default_ttl": "64",
|
"net.ipv4.ip_default_ttl": "64",
|
||||||
@@ -49,9 +51,12 @@ OS_SYSCTLS: dict[str, dict[str, str]] = {
|
|||||||
"net.ipv4.icmp_ratemask": "6168",
|
"net.ipv4.icmp_ratemask": "6168",
|
||||||
},
|
},
|
||||||
"windows": {
|
"windows": {
|
||||||
|
# Windows 10/11 workstation. NOTE: modern Windows runs TCP timestamps
|
||||||
|
# ON (nmap SEQ.TS=A) — an earlier value of 0 here fingerprinted as an
|
||||||
|
# ancient Windows/Linux stack. ECN off → nmap ECN.CC=N (workstation).
|
||||||
"net.ipv4.ip_default_ttl": "128",
|
"net.ipv4.ip_default_ttl": "128",
|
||||||
"net.ipv4.tcp_syn_retries": "2",
|
"net.ipv4.tcp_syn_retries": "2",
|
||||||
"net.ipv4.tcp_timestamps": "0",
|
"net.ipv4.tcp_timestamps": "1",
|
||||||
"net.ipv4.tcp_window_scaling": "1",
|
"net.ipv4.tcp_window_scaling": "1",
|
||||||
"net.ipv4.tcp_sack": "1",
|
"net.ipv4.tcp_sack": "1",
|
||||||
"net.ipv4.tcp_ecn": "0",
|
"net.ipv4.tcp_ecn": "0",
|
||||||
@@ -60,6 +65,22 @@ OS_SYSCTLS: dict[str, dict[str, str]] = {
|
|||||||
"net.ipv4.icmp_ratelimit": "0",
|
"net.ipv4.icmp_ratelimit": "0",
|
||||||
"net.ipv4.icmp_ratemask": "0",
|
"net.ipv4.icmp_ratemask": "0",
|
||||||
},
|
},
|
||||||
|
"windows_server": {
|
||||||
|
# Windows Server 2016/2019. Same NT stack as the workstation; the only
|
||||||
|
# stack-visible deltas nmap reads are ECN negotiated (CC=Y → tcp_ecn=1)
|
||||||
|
# and randomized IP-ID (SEQ.TI=RD, applied by the cloak mangler, not a
|
||||||
|
# sysctl). Everything else == "windows".
|
||||||
|
"net.ipv4.ip_default_ttl": "128",
|
||||||
|
"net.ipv4.tcp_syn_retries": "2",
|
||||||
|
"net.ipv4.tcp_timestamps": "1",
|
||||||
|
"net.ipv4.tcp_window_scaling": "1",
|
||||||
|
"net.ipv4.tcp_sack": "1",
|
||||||
|
"net.ipv4.tcp_ecn": "1",
|
||||||
|
"net.ipv4.ip_no_pmtu_disc": "0",
|
||||||
|
"net.ipv4.tcp_fin_timeout": "30",
|
||||||
|
"net.ipv4.icmp_ratelimit": "0",
|
||||||
|
"net.ipv4.icmp_ratemask": "0",
|
||||||
|
},
|
||||||
"bsd": {
|
"bsd": {
|
||||||
"net.ipv4.ip_default_ttl": "64",
|
"net.ipv4.ip_default_ttl": "64",
|
||||||
"net.ipv4.tcp_syn_retries": "6",
|
"net.ipv4.tcp_syn_retries": "6",
|
||||||
@@ -112,3 +133,47 @@ def all_os_families() -> list[str]:
|
|||||||
"""Return all registered nmap OS family slugs."""
|
"""Return all registered nmap OS family slugs."""
|
||||||
return list(OS_SYSCTLS.keys())
|
return list(OS_SYSCTLS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Egress mangle profiles (cloak) ──────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# sysctls above reach only GLOBAL fields (TTL, timestamps on/off, ECN). The
|
||||||
|
# SYN-ACK *shape* nmap also scores — exact window, TCP option order, IP-ID
|
||||||
|
# generation — cannot be set per-container by sysctl. The cloak mangler
|
||||||
|
# (decnet/cloak) rewrites those on egress, driven by these profiles, keyed by
|
||||||
|
# the SAME nmap_os slug. A slug ABSENT here needs no mangling (its real Linux
|
||||||
|
# stack already approximates the target, e.g. "linux"/"bsd").
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MangleProfile:
|
||||||
|
"""How the cloak rewrites a decky's egress to match an nmap_os family."""
|
||||||
|
|
||||||
|
window: int # TCP advertised window on SYN-ACK
|
||||||
|
mss: int # MSS option value
|
||||||
|
wscale: int # window-scale shift
|
||||||
|
# Ordered TCP option layout to emit on SYN-ACK. "TS" is kept only if the
|
||||||
|
# kernel emitted a Timestamp (sysctl tcp_timestamps=1) so its live,
|
||||||
|
# incrementing value survives the rewrite (nmap SEQ.TS rate test).
|
||||||
|
option_order: tuple[str, ...]
|
||||||
|
ipid: str # "incr" (TI=I) | "random" (TI=RD) | "keep"
|
||||||
|
respond_t2t3: bool # synthesize Windows T2/T3 replies
|
||||||
|
|
||||||
|
|
||||||
|
_WIN_OPTS = ("MSS", "NOP", "WScale", "SAckOK", "TS")
|
||||||
|
|
||||||
|
OS_MANGLE: dict[str, MangleProfile] = {
|
||||||
|
"windows": MangleProfile(
|
||||||
|
window=0x2000, mss=1460, wscale=8,
|
||||||
|
option_order=_WIN_OPTS, ipid="incr", respond_t2t3=True,
|
||||||
|
),
|
||||||
|
"windows_server": MangleProfile(
|
||||||
|
window=0x2000, mss=1460, wscale=8,
|
||||||
|
option_order=_WIN_OPTS, ipid="random", respond_t2t3=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_os_mangle(nmap_os: str) -> MangleProfile | None:
|
||||||
|
"""Return the cloak mangle profile for *nmap_os*, or None if it needs none."""
|
||||||
|
return OS_MANGLE.get(nmap_os)
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,20 @@ def test_linux_tcp_timestamps_is_1():
|
|||||||
assert get_os_sysctls("linux")["net.ipv4.tcp_timestamps"] == "1"
|
assert get_os_sysctls("linux")["net.ipv4.tcp_timestamps"] == "1"
|
||||||
|
|
||||||
|
|
||||||
def test_windows_tcp_timestamps_is_0():
|
def test_windows_tcp_timestamps_is_1():
|
||||||
assert get_os_sysctls("windows")["net.ipv4.tcp_timestamps"] == "0"
|
# Modern Windows 10/11 runs TCP timestamps ON (nmap SEQ.TS=A). A prior
|
||||||
|
# value of 0 here fingerprinted as an ancient stack — see os_fingerprint.py.
|
||||||
|
assert get_os_sysctls("windows")["net.ipv4.tcp_timestamps"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_server_tcp_timestamps_is_1():
|
||||||
|
assert get_os_sysctls("windows_server")["net.ipv4.tcp_timestamps"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_server_tcp_ecn_is_1():
|
||||||
|
# Server negotiates ECN (nmap ECN.CC=Y); workstation does not (CC=N).
|
||||||
|
assert get_os_sysctls("windows_server")["net.ipv4.tcp_ecn"] == "1"
|
||||||
|
assert get_os_sysctls("windows")["net.ipv4.tcp_ecn"] == "0"
|
||||||
|
|
||||||
|
|
||||||
def test_embedded_tcp_timestamps_is_0():
|
def test_embedded_tcp_timestamps_is_0():
|
||||||
@@ -237,7 +249,7 @@ def test_all_os_families_non_empty():
|
|||||||
assert "embedded" in families
|
assert "embedded" in families
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("family", ["linux", "windows", "bsd", "embedded", "cisco"])
|
@pytest.mark.parametrize("family", ["linux", "windows", "windows_server", "bsd", "embedded", "cisco"])
|
||||||
def test_all_os_profiles_have_required_sysctls(family: str):
|
def test_all_os_profiles_have_required_sysctls(family: str):
|
||||||
"""Every OS profile must define the full canonical sysctl set."""
|
"""Every OS profile must define the full canonical sysctl set."""
|
||||||
from decnet.os_fingerprint import _REQUIRED_SYSCTLS
|
from decnet.os_fingerprint import _REQUIRED_SYSCTLS
|
||||||
@@ -246,7 +258,7 @@ def test_all_os_profiles_have_required_sysctls(family: str):
|
|||||||
assert not missing, f"OS profile '{family}' is missing sysctls: {missing}"
|
assert not missing, f"OS profile '{family}' is missing sysctls: {missing}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("family", ["linux", "windows", "bsd", "embedded", "cisco"])
|
@pytest.mark.parametrize("family", ["linux", "windows", "windows_server", "bsd", "embedded", "cisco"])
|
||||||
def test_all_os_sysctl_values_are_strings(family: str):
|
def test_all_os_sysctl_values_are_strings(family: str):
|
||||||
"""Docker Compose requires sysctl values to be strings, never ints."""
|
"""Docker Compose requires sysctl values to be strings, never ints."""
|
||||||
for _key, _val in get_os_sysctls(family).items():
|
for _key, _val in get_os_sysctls(family).items():
|
||||||
@@ -267,9 +279,13 @@ def test_archetype_nmap_os_is_known(slug, arch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("slug", ["windows-workstation", "windows-server", "domain-controller"])
|
def test_windows_workstation_archetype_nmap_os():
|
||||||
def test_windows_archetypes_have_windows_nmap_os(slug):
|
assert ARCHETYPES["windows-workstation"].nmap_os == "windows"
|
||||||
assert ARCHETYPES[slug].nmap_os == "windows"
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("slug", ["windows-server", "domain-controller"])
|
||||||
|
def test_windows_server_archetypes_use_server_nmap_os(slug):
|
||||||
|
assert ARCHETYPES[slug].nmap_os == "windows_server"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("slug", ["printer", "iot-device", "industrial-control"])
|
@pytest.mark.parametrize("slug", ["printer", "iot-device", "industrial-control"])
|
||||||
@@ -403,11 +419,11 @@ def test_compose_linux_sysctls_include_timestamps():
|
|||||||
assert sysctls.get("net.ipv4.tcp_timestamps") == "1"
|
assert sysctls.get("net.ipv4.tcp_timestamps") == "1"
|
||||||
|
|
||||||
|
|
||||||
def test_compose_windows_sysctls_no_timestamps():
|
def test_compose_windows_sysctls_timestamps_on():
|
||||||
"""Windows compose output must have tcp_timestamps disabled (= 0)."""
|
"""Windows compose output must have tcp_timestamps ENABLED (= 1) — Win10/11."""
|
||||||
compose = generate_compose(_make_config("windows"))
|
compose = generate_compose(_make_config("windows"))
|
||||||
sysctls = compose["services"]["decky-01"]["sysctls"]
|
sysctls = compose["services"]["decky-01"]["sysctls"]
|
||||||
assert sysctls.get("net.ipv4.tcp_timestamps") == "0"
|
assert sysctls.get("net.ipv4.tcp_timestamps") == "1"
|
||||||
|
|
||||||
|
|
||||||
def test_compose_linux_sysctls_full_set():
|
def test_compose_linux_sysctls_full_set():
|
||||||
|
|||||||
Reference in New Issue
Block a user