Skip to main content

netscli_core/
os_hint.rs

1//! A best guess at a host's operating system, from clues a scan already has,
2//! each named with where it came from.
3//!
4//! This is not nmap's `-O`. That sends deliberately malformed packets and
5//! fingerprints how the TCP/IP stack answers, which needs raw sockets (so
6//! administrator rights, and Npcap on Windows) and a fingerprint database
7//! under nmap's own licence. The clues here need neither:
8//!
9//! - **SMB**: a Windows host's NTLM challenge states its exact version and
10//!   build before any login (`os_hint/smb.rs`);
11//! - **SSH banner**: OpenSSH usually names the distribution
12//!   (`OpenSSH_9.6p1 Ubuntu-3ubuntu13`) or says `for_Windows`;
13//! - **HTTP `Server` header**: `Apache/2.4.58 (Ubuntu)`, `Microsoft-IIS/10.0`;
14//! - **open ports**: 135 with 445 is Windows' RPC and file sharing;
15//! - **MAC vendor**: an Apple or Raspberry Pi network card;
16//! - **ping TTL**: hosts start the TTL at 64 (Linux, macOS, most Unix), 128
17//!   (Windows) or 255 (network gear), and a LAN hop barely lowers it.
18//!
19//! The strongest clue sets the family; every clue is kept as evidence, so a
20//! reader can see both what was concluded and why, including clues that
21//! disagree. It's a hint: a host can run anything behind any of these.
22
23pub(crate) mod smb;
24
25use serde::Serialize;
26
27use crate::scan::PortResult;
28use smb::SmbInfo;
29
30/// A best guess at a host's operating system, and the clues behind it.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct OsHint {
33    /// `Windows`, `Linux`, `macOS or iOS`, `FreeBSD`, `Unix-like` or
34    /// `Network device`.
35    pub family: String,
36    /// More precise, when a clue said: `Windows 11 or Server 2025 (build
37    /// 26100)`, `Ubuntu`.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub detail: Option<String>,
40    /// Every clue that pointed somewhere, strongest first, each naming its
41    /// source: `SMB: Windows 10.0 build 26100`, `TTL 128`.
42    pub evidence: Vec<String>,
43}
44
45impl OsHint {
46    /// One line for display: the detail, with the family in front when the
47    /// detail doesn't already say it (`Linux, Debian`, but not `Windows,
48    /// Windows 11 ...`).
49    pub fn summary(&self) -> String {
50        match &self.detail {
51            Some(detail) if detail.contains(&self.family) => detail.clone(),
52            Some(detail) => format!("{}, {detail}", self.family),
53            None => self.family.clone(),
54        }
55    }
56}
57
58/// What a scan knows about a host that bears on its OS.
59pub(crate) struct Clues<'a> {
60    pub(crate) ttl: Option<u8>,
61    pub(crate) ports: &'a [PortResult],
62    pub(crate) vendor: Option<&'a str>,
63    pub(crate) smb: Option<&'a SmbInfo>,
64}
65
66struct Clue {
67    strength: u8,
68    family: &'static str,
69    detail: Option<String>,
70    evidence: String,
71}
72
73/// The hint the clues add up to, or `None` when none of them point anywhere.
74pub(crate) fn hint(clues: &Clues) -> Option<OsHint> {
75    let mut found: Vec<Clue> = Vec::new();
76    found.extend(clues.smb.and_then(from_smb));
77    for port in clues.ports.iter().filter(|port| port.open) {
78        if let Some(banner) = port.banner.as_deref().filter(|b| b.starts_with("SSH-")) {
79            found.extend(from_ssh_banner(banner));
80        }
81        let server = port.http.as_ref().and_then(|http| {
82            http.headers
83                .iter()
84                .find(|h| h.name.eq_ignore_ascii_case("server"))
85        });
86        if let Some(server) = server {
87            found.extend(from_server_header(&server.value));
88        }
89    }
90    found.extend(from_ports(clues.ports));
91    found.extend(clues.vendor.and_then(from_vendor));
92    found.extend(clues.ttl.and_then(from_ttl));
93
94    // Stable, so equally strong clues keep the order they were gathered in.
95    found.sort_by_key(|clue| std::cmp::Reverse(clue.strength));
96    let best = found.first()?;
97    let detail = best.detail.clone().or_else(|| {
98        found
99            .iter()
100            .filter(|clue| clue.family == best.family)
101            .find_map(|clue| clue.detail.clone())
102    });
103    let mut evidence: Vec<String> = Vec::new();
104    for clue in &found {
105        if !evidence.contains(&clue.evidence) {
106            evidence.push(clue.evidence.clone());
107        }
108    }
109    Some(OsHint {
110        family: best.family.to_string(),
111        detail,
112        evidence,
113    })
114}
115
116fn clue(strength: u8, family: &'static str, detail: Option<&str>, evidence: String) -> Clue {
117    Clue {
118        strength,
119        family,
120        detail: detail.map(str::to_string),
121        evidence,
122    }
123}
124
125/// Only a real Windows build is taken as Windows. A server reporting build 0
126/// isn't Windows' own SMB stack, so it says nothing either way.
127fn from_smb(smb: &SmbInfo) -> Option<Clue> {
128    if smb.build == 0 {
129        return None;
130    }
131    let mut evidence = format!(
132        "SMB: Windows {}.{} build {}",
133        smb.major, smb.minor, smb.build
134    );
135    if let Some(name) = &smb.computer {
136        evidence.push_str(&format!(", name {name}"));
137    }
138    let detail = format!(
139        "{} (build {})",
140        windows_name(smb.major, smb.minor, smb.build),
141        smb.build
142    );
143    Some(clue(100, "Windows", Some(&detail), evidence))
144}
145
146/// The marketing names a Windows version number can mean. Client and server
147/// share version numbers, and from Windows 10 on even the build overlaps, so
148/// each is named as the pair it could be.
149fn windows_name(major: u8, minor: u8, build: u16) -> String {
150    match (major, minor) {
151        (10, 0) if build >= 22000 => "Windows 11 or Server 2025".to_string(),
152        (10, 0) => "Windows 10 or Server 2016-2022".to_string(),
153        (6, 3) => "Windows 8.1 or Server 2012 R2".to_string(),
154        (6, 2) => "Windows 8 or Server 2012".to_string(),
155        (6, 1) => "Windows 7 or Server 2008 R2".to_string(),
156        (6, 0) => "Windows Vista or Server 2008".to_string(),
157        _ => format!("Windows {major}.{minor}"),
158    }
159}
160
161/// `SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13` names Ubuntu;
162/// `SSH-2.0-OpenSSH_for_Windows_9.5` names Windows.
163fn from_ssh_banner(banner: &str) -> Option<Clue> {
164    const DISTROS: &[(&str, &str, &str)] = &[
165        ("Ubuntu", "Linux", "Ubuntu"),
166        ("Debian", "Linux", "Debian"),
167        ("Raspbian", "Linux", "Raspberry Pi OS"),
168        ("FreeBSD", "FreeBSD", "FreeBSD"),
169    ];
170    if banner.contains("for_Windows") {
171        return Some(clue(
172            70,
173            "Windows",
174            None,
175            "SSH banner: OpenSSH for Windows".to_string(),
176        ));
177    }
178    DISTROS.iter().find_map(|&(token, family, name)| {
179        banner
180            .contains(token)
181            .then(|| clue(80, family, Some(name), format!("SSH banner: {name}")))
182    })
183}
184
185/// `Apache/2.4.58 (Ubuntu)` names Ubuntu; IIS and HTTP.sys only run on
186/// Windows.
187fn from_server_header(value: &str) -> Option<Clue> {
188    const MARKERS: &[(&str, &str, Option<&str>)] = &[
189        ("(Ubuntu)", "Linux", Some("Ubuntu")),
190        ("(Debian)", "Linux", Some("Debian")),
191        ("(Raspbian)", "Linux", Some("Raspberry Pi OS")),
192        ("(CentOS)", "Linux", Some("CentOS")),
193        ("(Red Hat)", "Linux", Some("Red Hat")),
194        ("(Fedora)", "Linux", Some("Fedora")),
195        ("(Win64)", "Windows", None),
196        ("(Win32)", "Windows", None),
197        ("Microsoft-IIS", "Windows", None),
198        ("Microsoft-HTTPAPI", "Windows", None),
199    ];
200    MARKERS.iter().find_map(|&(marker, family, detail)| {
201        value.contains(marker).then(|| {
202            let seen = marker.trim_matches(|c| c == '(' || c == ')');
203            clue(60, family, detail, format!("HTTP server: {seen}"))
204        })
205    })
206}
207
208/// Windows' RPC endpoint mapper (135) alongside SMB (445).
209fn from_ports(ports: &[PortResult]) -> Option<Clue> {
210    let open = |n: u16| ports.iter().any(|p| p.port == n && p.open);
211    (open(135) && open(445))
212        .then(|| clue(50, "Windows", None, "ports 135 and 445 open".to_string()))
213}
214
215fn from_vendor(vendor: &str) -> Option<Clue> {
216    if vendor.contains("Apple") {
217        return Some(clue(
218            40,
219            "macOS or iOS",
220            None,
221            format!("MAC vendor: {vendor}"),
222        ));
223    }
224    if vendor.contains("Raspberry Pi") {
225        return Some(clue(
226            40,
227            "Linux",
228            Some("Raspberry Pi OS"),
229            format!("MAC vendor: {vendor}"),
230        ));
231    }
232    None
233}
234
235fn from_ttl(ttl: u8) -> Option<Clue> {
236    let family = match ttl {
237        0 => return None,
238        1..=64 => "Unix-like",
239        65..=128 => "Windows",
240        _ => "Network device",
241    };
242    Some(clue(20, family, None, format!("TTL {ttl}")))
243}
244
245#[cfg(test)]
246mod tests;