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.
180 lines
7.1 KiB
Python
180 lines
7.1 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||
"""
|
||
OS TCP/IP fingerprint profiles for DECNET deckies.
|
||
|
||
Maps an nmap OS family slug to a dict of Linux kernel sysctls that, when applied
|
||
to a container's network namespace, make its TCP/IP stack behaviour resemble the
|
||
claimed OS as closely as possible within the Linux kernel's constraints.
|
||
|
||
All sysctls listed here are network-namespace-scoped and safe to set per-container
|
||
without --privileged (beyond the NET_ADMIN capability already granted).
|
||
|
||
Primary discriminator leveraged by nmap: net.ipv4.ip_default_ttl (TTL)
|
||
Linux → 64
|
||
Windows → 128
|
||
BSD (FreeBSD/macOS)→ 64 (different TCP options, but same TTL as Linux)
|
||
Embedded / network → 255
|
||
|
||
Secondary discriminators (nmap OPS / WIN / ECN / T2–T6 probe groups):
|
||
net.ipv4.tcp_syn_retries – SYN retransmits before giving up
|
||
net.ipv4.tcp_timestamps – TCP timestamp option (OPS probes); Windows = off
|
||
net.ipv4.tcp_window_scaling – Window scale option; embedded/Cisco typically off
|
||
net.ipv4.tcp_sack – Selective ACK option; absent on most embedded stacks
|
||
net.ipv4.tcp_ecn – ECN negotiation; Linux offers (2), Windows off (0)
|
||
net.ipv4.ip_no_pmtu_disc – DF bit in ICMP replies (IE probes); embedded on
|
||
net.ipv4.tcp_fin_timeout – FIN_WAIT_2 seconds (T2–T6 timing); Windows shorter
|
||
|
||
ICMP tuning (nmap IE / U1 probe groups):
|
||
net.ipv4.icmp_ratelimit – Min ms between ICMP error replies; Windows = 0 (none)
|
||
net.ipv4.icmp_ratemask – Bitmask of ICMP types subject to rate limiting
|
||
|
||
Note: net.core.rmem_default is a global (non-namespaced) sysctl and cannot be
|
||
set per-container without --privileged; TCP window size is already correct for
|
||
Windows (64240) from the kernel's default tcp_rmem settings.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
OS_SYSCTLS: dict[str, dict[str, str]] = {
|
||
"linux": {
|
||
"net.ipv4.ip_default_ttl": "64",
|
||
"net.ipv4.tcp_syn_retries": "6",
|
||
"net.ipv4.tcp_timestamps": "1",
|
||
"net.ipv4.tcp_window_scaling": "1",
|
||
"net.ipv4.tcp_sack": "1",
|
||
"net.ipv4.tcp_ecn": "2",
|
||
"net.ipv4.ip_no_pmtu_disc": "0",
|
||
"net.ipv4.tcp_fin_timeout": "60",
|
||
"net.ipv4.icmp_ratelimit": "1000",
|
||
"net.ipv4.icmp_ratemask": "6168",
|
||
},
|
||
"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.tcp_syn_retries": "2",
|
||
"net.ipv4.tcp_timestamps": "1",
|
||
"net.ipv4.tcp_window_scaling": "1",
|
||
"net.ipv4.tcp_sack": "1",
|
||
"net.ipv4.tcp_ecn": "0",
|
||
"net.ipv4.ip_no_pmtu_disc": "0",
|
||
"net.ipv4.tcp_fin_timeout": "30",
|
||
"net.ipv4.icmp_ratelimit": "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": {
|
||
"net.ipv4.ip_default_ttl": "64",
|
||
"net.ipv4.tcp_syn_retries": "6",
|
||
"net.ipv4.tcp_timestamps": "1",
|
||
"net.ipv4.tcp_window_scaling": "1",
|
||
"net.ipv4.tcp_sack": "1",
|
||
"net.ipv4.tcp_ecn": "0",
|
||
"net.ipv4.ip_no_pmtu_disc": "0",
|
||
"net.ipv4.tcp_fin_timeout": "60",
|
||
"net.ipv4.icmp_ratelimit": "250",
|
||
"net.ipv4.icmp_ratemask": "6168",
|
||
},
|
||
"embedded": {
|
||
"net.ipv4.ip_default_ttl": "255",
|
||
"net.ipv4.tcp_syn_retries": "3",
|
||
"net.ipv4.tcp_timestamps": "0",
|
||
"net.ipv4.tcp_window_scaling": "0",
|
||
"net.ipv4.tcp_sack": "0",
|
||
"net.ipv4.tcp_ecn": "0",
|
||
"net.ipv4.ip_no_pmtu_disc": "1",
|
||
"net.ipv4.tcp_fin_timeout": "15",
|
||
"net.ipv4.icmp_ratelimit": "0",
|
||
"net.ipv4.icmp_ratemask": "0",
|
||
},
|
||
"cisco": {
|
||
"net.ipv4.ip_default_ttl": "255",
|
||
"net.ipv4.tcp_syn_retries": "2",
|
||
"net.ipv4.tcp_timestamps": "0",
|
||
"net.ipv4.tcp_window_scaling": "0",
|
||
"net.ipv4.tcp_sack": "0",
|
||
"net.ipv4.tcp_ecn": "0",
|
||
"net.ipv4.ip_no_pmtu_disc": "1",
|
||
"net.ipv4.tcp_fin_timeout": "15",
|
||
"net.ipv4.icmp_ratelimit": "0",
|
||
"net.ipv4.icmp_ratemask": "0",
|
||
},
|
||
}
|
||
|
||
_DEFAULT_OS = "linux"
|
||
|
||
_REQUIRED_SYSCTLS: frozenset[str] = frozenset(OS_SYSCTLS["linux"].keys())
|
||
|
||
|
||
def get_os_sysctls(nmap_os: str) -> dict[str, str]:
|
||
"""Return the sysctl dict for *nmap_os*. Falls back to Linux on unknown slugs."""
|
||
return dict(OS_SYSCTLS.get(nmap_os, OS_SYSCTLS[_DEFAULT_OS]))
|
||
|
||
|
||
def all_os_families() -> list[str]:
|
||
"""Return all registered nmap OS family slugs."""
|
||
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)
|
||
|