diff --git a/.env.sample b/.env.sample index 907f5d5..9abb8c2 100644 --- a/.env.sample +++ b/.env.sample @@ -100,6 +100,13 @@ RUST_BACKTRACE=full # PHP2Shell (CVE-2026-109618, CVE-2026-109619): alert on PHP builds older than # the fixed releases, on the host and in running containers. Alert only. #STACKDOG_PHP_VERSION_CHECK_ENABLED=true +# SSHole (CVE-2026-123158): flag OpenSSH builds older than 10.6p2 on musl +# systems (Alpine's default sshd is the vulnerable configuration), and ban +# password logins to key-only accounts. That login line is what a successful +# SSHole attack leaves behind. +#STACKDOG_SSHOLE_ENABLED=true +# Accounts that should never accept a password login. Defaults to root,alpine. +#STACKDOG_SSHOLE_KEYONLY_USERS=root,alpine # --------------------------------------------------------------------------- # Dashboard container (docker-compose.yml / docker-compose.app.yml) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8f91a..dbe2c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- SSHole detection (CVE-2026-123158). `vuln.sshole` flags OpenSSH builds older + than 10.6p2 in the apk package database, on the host and in running + containers: that is the vulnerable class (musl, passwords checked without + PAM, Alpine's default). The PAM flavor and glibc distributions are not + affected and are not reported. `ssh.password-login` alerts on password + logins to key-only accounts (root, alpine by default, + `STACKDOG_SSHOLE_KEYONLY_USERS` extends the list) and feeds the ban engine: + that line is what a successful SSHole login leaves in the logs. A single + line bans the address on the spot (allowlists and protected addresses + exempt): a foothold must not wait for the offense threshold. + `STACKDOG_SSHOLE_ENABLED=false` turns both off. + ## [0.2.9] - 2026-10-31 ### Added diff --git a/README.md b/README.md index ee5804e..aa21397 100644 --- a/README.md +++ b/README.md @@ -657,6 +657,25 @@ exploit traffic. Limits: Crash lines come from php-fpm and Apache error logs too; add host logs such as `/var/log/php8.3-fpm.log`. Stackdog cannot patch PHP: upgrading is the fix. +#### SSHole exposure (CVE-2026-123158) + +SSHole is an authentication bypass in OpenSSH on musl systems with PAM disabled +(Alpine's default server): an oversized password logs the attacker into accounts +whose password field is `*`, such as root. Fixed in OpenSSH 10.6p2. + +| Finding | When | Action | +|---|---|---| +| `vuln.sshole` | an `openssh-server` build older than 10.6p2 in the apk package database, on the host or in a running container | Critical alert naming the version; upgrade to 10.6p2 (`10.6_p2` on Alpine) and restart sshd. Never bans: a version names no attacker | +| `ssh.password-login` | a password login to a key-only account (root, alpine by default) | alert + an immediate ban on the first line (allowlists and protected addresses still apply). One login is already a foothold: waiting for five offenses would let the intruder switch the monitoring off | + +The version check reads apk databases only: musl is the affected half of the +configuration, and the PAM flavor (`openssh-server-pam`) and glibc +distributions are not affected by the CVE. The log half reads the auth and +syslog sources the brute-force bans come from — on Alpine, sshd logs to +`/var/log/messages`. Key-only accounts are `root` and `alpine`; add yours with +`STACKDOG_SSHOLE_KEYONLY_USERS`. Stackdog cannot patch OpenSSH: upgrading is +the fix. + --- ## 📦 Installation diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index 5617274..30932f3 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -10,6 +10,7 @@ mod integrity; mod ml; pub mod php2shell; pub mod php_shell; +pub mod sshole; use std::collections::HashSet; @@ -26,6 +27,7 @@ use self::php2shell::{Php2ShellAttemptDetector, PhpVersionMonitor, PhpWorkerCras use self::php_shell::{ ContainerFilesystem, PhpShellAccessDetector, PhpShellOptions, PhpShellScanner, ScanRoot, }; +use self::sshole::{SshPasswordLoginDetector, SshVersionMonitor}; use crate::database::connection::DbPool; use crate::sniff::analyzer::{AnomalySeverity, LogAnomaly}; use crate::sniff::reader::LogEntry; @@ -103,6 +105,7 @@ pub struct DetectorRegistry { docker_posture_monitor: DockerPostureMonitor, php_shell_scanner: PhpShellScanner, php_version_monitor: PhpVersionMonitor, + sshole_monitor: SshVersionMonitor, } impl DetectorRegistry { @@ -115,6 +118,7 @@ impl DetectorRegistry { docker_posture_monitor: DockerPostureMonitor, php_shell_scanner: PhpShellScanner::new(), php_version_monitor: PhpVersionMonitor::new(), + sshole_monitor: SshVersionMonitor::new(), } } @@ -147,6 +151,7 @@ impl DetectorRegistry { )); self.register(PhpWorkerCrashDetector); self.register(Php2ShellAttemptDetector); + self.register(SshPasswordLoginDetector::new()); } pub fn detect_log_anomalies(&self, entries: &[LogEntry]) -> Vec { @@ -207,6 +212,18 @@ impl DetectorRegistry { .collect() } + pub fn detect_sshole_anomalies( + &self, + containers: &[ContainerFilesystem], + host_root: Option<&str>, + ) -> Vec { + self.sshole_monitor + .detect(containers, host_root) + .into_iter() + .map(|finding| finding.to_log_anomaly()) + .collect() + } + pub fn detect_config_assessment_anomalies(&self, paths: &[String]) -> Result> { Ok(self .config_assessment_monitor diff --git a/src/detectors/php2shell.rs b/src/detectors/php2shell.rs index 504e907..2323c98 100644 --- a/src/detectors/php2shell.rs +++ b/src/detectors/php2shell.rs @@ -305,7 +305,7 @@ pub fn findings(installs: &[PhpInstall]) -> Vec { ), }; let description = format!( - "{}. Until then, session.upload_progress.enabled = 0 closes the confirmed php-fpm path", + "{}. Until then, session.upload_progress.enabled = 0 closes the confirmed php-fpm path, and Stackdog keeps defending this box: it bans clients whose requests trip the startup out-of-memory step, scans the web roots for shells, and bans whoever drives a flagged file", description ); diff --git a/src/detectors/sshole.rs b/src/detectors/sshole.rs new file mode 100644 index 0000000..ef349db --- /dev/null +++ b/src/detectors/sshole.rs @@ -0,0 +1,433 @@ +//! SSHole (CVE-2026-123158): OpenSSH authentication bypass on musl. +//! +//! SSHole lets an unauthenticated attacker log into accounts that carry a `*` +//! in the password field (password-disabled accounts such as root or alpine) +//! by sending an oversized password: 257 to 1024 bytes make the hash attempt +//! fail, and OpenSSH without PAM reads that failure as the same `*` the +//! account stores. It affects Linux systems that run OpenSSH on musl with PAM +//! disabled, including Alpine's default server configuration. Fixed in +//! OpenSSH 10.6p2 (10.6_p2 on Alpine). +//! +//! Two halves, same shape as the PHP2Shell work: +//! +//! - [`SshVersionMonitor`] flags OpenSSH builds that predate the fix on musl +//! systems (`vuln.sshole`). A version names no attacker, so it alerts and +//! never bans. +//! - [`SshPasswordLoginDetector`] watches logs for password logins to key-only +//! accounts (`ssh.password-login`). That is what a successful SSHole login +//! looks like, and Alpine's own guidance is to hunt exactly these lines. The +//! line carries the client address, so it feeds the ban engine. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; + +use crate::detectors::audits::parse_apk_installed; +use crate::detectors::php_shell::{under_host_root, ContainerFilesystem}; +use crate::detectors::{DetectorFamily, DetectorFinding, LogDetector}; +use crate::sniff::analyzer::AnomalySeverity; +use crate::sniff::reader::LogEntry; + +pub const VERSION_DETECTOR_ID: &str = "vuln.sshole"; +pub const PASSWORD_LOGIN_DETECTOR_ID: &str = "ssh.password-login"; + +const CVES: &str = "CVE-2026-123158"; + +/// First fixed upstream release: 10.6p2. +const FIXED: (u32, u32, u32) = (10, 6, 2); + +/// Accounts whose password field is `*` by default, so a password login to +/// them is either a misconfiguration or SSHole. Alpine's advisory names these +/// two; `STACKDOG_SSHOLE_KEYONLY_USERS` extends the list. +pub const DEFAULT_KEYONLY_USERS: &[&str] = &["root", "alpine"]; + +/// Findings per pass. Each one is an offense, like the PHP2Shell attempts. +const MAX_FINDINGS: usize = 50; + +/// Packages that carry the sshd that checks passwords without PAM (Alpine's +/// default flavor). The PAM and krb5 flavors are built `--with-pam` and are +/// not affected; their packages are `openssh-server-pam` and +/// `openssh-server-krb5`. +const VULNERABLE_SERVER_PACKAGES: &[&str] = &["openssh-server", "openssh"]; + +/// Only the musl distribution uses this package database, and musl is the +/// half of the vulnerable configuration we can see from disk. +const APK_DATABASE: &str = "lib/apk/db/installed"; + +/// `10.6_p2`, `10.6p2`, `OpenSSH_10.6p2`, `9.3_p2-r0` -> (10, 6, 2). +pub fn upstream_ssh_version(version: &str) -> Option<(u32, u32, u32)> { + let without_epoch = version.split_once(':').map_or(version, |(_, rest)| rest); + let start = without_epoch.find(|c: char| c.is_ascii_digit())?; + let rest = &without_epoch[start..]; + + let major_len = rest.chars().take_while(|c| c.is_ascii_digit()).count(); + let major: u32 = rest[..major_len].parse().ok()?; + let rest = &rest[major_len..]; + let rest = rest.strip_prefix('.')?; + + let minor_len = rest.chars().take_while(|c| c.is_ascii_digit()).count(); + let minor: u32 = rest[..minor_len].parse().ok()?; + let suffix = &rest[minor_len..]; + + // The portable release number: "p2" upstream, "_p2" on Alpine. Absent + // means the first portable release of that series. + let p = suffix + .find('p') + .and_then(|index| { + suffix[index + 1..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse() + .ok() + }) + .unwrap_or(0); + + Some((major, minor, p)) +} + +/// Whether an OpenSSH build predates the SSHole fix. +pub fn predates_fix(version: &str) -> Option { + upstream_ssh_version(version).map(|parsed| parsed < FIXED) +} + +/// A package database parsed from a file, kept while the file does not change. +struct CachedPackages { + modified: SystemTime, + size: u64, + packages: Vec<(String, String)>, +} + +#[derive(Default)] +pub struct SshVersionMonitor { + cache: Mutex>, +} + +impl SshVersionMonitor { + pub fn new() -> Self { + Self::default() + } + + pub fn detect( + &self, + containers: &[ContainerFilesystem], + host_root: Option<&str>, + ) -> Vec { + let mut installs = self.installs_under("host", &under_host_root(host_root, "/")); + + for container in containers { + if let Some(merged_dir) = &container.merged_dir { + installs.extend( + self.installs_under(&container.name, &under_host_root(host_root, merged_dir)), + ); + } + } + + findings(&installs) + } + + fn installs_under(&self, owner: &str, root: &Path) -> Vec<(String, String, String)> { + self.packages(&root.join(APK_DATABASE)) + .into_iter() + .filter(|(name, _)| VULNERABLE_SERVER_PACKAGES.contains(&name.as_str())) + .map(|(name, version)| (owner.to_string(), name, version)) + .collect() + } + + fn packages(&self, path: &Path) -> Vec<(String, String)> { + let Ok(metadata) = std::fs::metadata(path) else { + return Vec::new(); + }; + let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH); + let size = metadata.len(); + + if let Ok(cache) = self.cache.lock() { + if let Some(cached) = cache.get(path) { + if cached.modified == modified && cached.size == size { + return cached.packages.clone(); + } + } + } + + let Ok(content) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + let packages = parse_apk_installed(&content); + + if let Ok(mut cache) = self.cache.lock() { + cache.insert( + path.to_path_buf(), + CachedPackages { + modified, + size, + packages: packages.clone(), + }, + ); + } + packages + } +} + +/// One finding per exposed `(owner, version)`. +pub fn findings(installs: &[(String, String, String)]) -> Vec { + let mut seen = HashSet::new(); + let mut findings = Vec::new(); + + for (owner, name, version) in installs { + if !seen.insert((owner.as_str(), version.as_str())) { + continue; + } + if predates_fix(version) != Some(true) { + continue; + } + + findings.push(DetectorFinding { + detector_id: VERSION_DETECTOR_ID.into(), + family: DetectorFamily::Vulnerability, + description: format!( + "SSHole authentication bypass ({CVES}): OpenSSH {version} on {owner} ({name}) predates the 10.6p2 fix. This is the vulnerable configuration: musl, passwords checked without PAM. An oversized password logs the attacker into accounts whose password field is `*` (root, alpine). Upgrade to 10.6p2, 10.6_p2 on Alpine, and restart sshd. Until the upgrade Stackdog is defending this box: it bans on the spot any address that password-logs into a key-only account, and keeps watching the auth logs" + ), + severity: AnomalySeverity::Critical, + confidence: 92, + sample_line: format!("{owner} package {name} {version}"), + }); + } + + findings +} + +/// Password logins to accounts that should never accept one. That is either a +/// misconfiguration or the signature of a successful SSHole login. +pub struct SshPasswordLoginDetector { + keyonly_users: Vec, +} + +impl SshPasswordLoginDetector { + pub fn new() -> Self { + Self { + keyonly_users: keyonly_users(), + } + } + + #[cfg(test)] + fn with_users(users: &[&str]) -> Self { + Self { + keyonly_users: users.iter().map(|user| user.to_string()).collect(), + } + } +} + +impl Default for SshPasswordLoginDetector { + fn default() -> Self { + Self::new() + } +} + +/// `root`, `alpine`, plus whatever the operator runs key-only. +pub fn keyonly_users() -> Vec { + let configured = std::env::var("STACKDOG_SSHOLE_KEYONLY_USERS").ok(); + configured + .map(|value| { + value + .split(',') + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect::>() + }) + .filter(|users| !users.is_empty()) + .unwrap_or_else(|| { + DEFAULT_KEYONLY_USERS + .iter() + .map(|user| user.to_string()) + .collect() + }) +} + +impl LogDetector for SshPasswordLoginDetector { + fn id(&self) -> &'static str { + PASSWORD_LOGIN_DETECTOR_ID + } + + fn family(&self) -> DetectorFamily { + DetectorFamily::Execution + } + + fn detect(&self, entries: &[LogEntry]) -> Vec { + // Identical lines are separate logins, and the registry drops findings + // with identical samples, so repeats are numbered to count as the + // offenses they are. + let mut seen: HashMap = HashMap::new(); + + entries + .iter() + .filter_map(|entry| { + let account = password_login_account(&entry.line)?; + if !self + .keyonly_users + .iter() + .any(|user| user.eq_ignore_ascii_case(account)) + { + return None; + } + + let count = seen.entry(entry.line.clone()).or_insert(0); + *count += 1; + let sample_line = if *count > 1 { + format!("{} (repeat {})", entry.line, count) + } else { + entry.line.clone() + }; + + Some(DetectorFinding { + detector_id: PASSWORD_LOGIN_DETECTOR_ID.into(), + family: DetectorFamily::Execution, + description: format!( + "Password login to key-only account {account}. SSHole ({CVES}) logs in exactly this way, and a successful login is a foothold, not a failed attempt. If nobody should use passwords here, treat the host as compromised: check users, authorized_keys and doas, then upgrade OpenSSH to 10.6p2 and restart sshd" + ), + severity: AnomalySeverity::High, + confidence: 85, + sample_line, + }) + }) + .take(MAX_FINDINGS) + .collect() + } +} + +/// The account an "Accepted password for from ..." line names. +fn password_login_account(line: &str) -> Option<&str> { + let lower = line.to_ascii_lowercase(); + let marker = "accepted password for "; + let start = lower.find(marker)? + marker.len(); + let rest = &line[start..]; + let end = rest + .find(|c: char| c.is_whitespace() || c == ',') + .unwrap_or(rest.len()); + let account = &rest[..end]; + (!account.is_empty()).then_some(account) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + #[test] + fn test_version_parsing() { + assert_eq!(upstream_ssh_version("10.6_p2-r0"), Some((10, 6, 2))); + assert_eq!(upstream_ssh_version("10.6p2"), Some((10, 6, 2))); + assert_eq!(upstream_ssh_version("OpenSSH_10.6p2"), Some((10, 6, 2))); + assert_eq!(upstream_ssh_version("9.3_p2-r0"), Some((9, 3, 2))); + assert_eq!(upstream_ssh_version("1:9.2p1-2+deb12u5"), Some((9, 2, 1))); + assert_eq!(upstream_ssh_version("10.6"), Some((10, 6, 0))); + assert_eq!(upstream_ssh_version("not-a-version"), None); + } + + #[test] + fn test_versions_before_the_fix_are_exposed() { + assert_eq!(predates_fix("10.6_p1-r0"), Some(true)); + assert_eq!(predates_fix("10.6_p2-r0"), Some(false)); + assert_eq!(predates_fix("9.3_p2-r0"), Some(true)); + assert_eq!(predates_fix("10.7p1"), Some(false)); + assert_eq!(predates_fix("garbage"), None); + } + + fn monitor_with(root: &Path, packages: &str) -> Vec { + let apk = root.join("lib/apk/db"); + std::fs::create_dir_all(&apk).unwrap(); + std::fs::write(apk.join("installed"), packages).unwrap(); + SshVersionMonitor::new().detect(&[], root.to_str()) + } + + #[test] + fn test_old_openssh_on_musl_is_reported_and_the_fix_is_not() { + let root = tempfile::tempdir().unwrap(); + let found = monitor_with( + root.path(), + "P:openssh-server\nV:10.6_p1-r0\n\nP:openssh-keygen\nV:10.6_p1-r0\n\n", + ); + assert_eq!(found.len(), 1, "{found:#?}"); + assert_eq!(found[0].detector_id, VERSION_DETECTOR_ID); + assert_eq!(found[0].severity, AnomalySeverity::Critical); + assert!(found[0].description.contains("10.6_p1-r0")); + assert!(found[0].description.contains("restart sshd")); + // The Slack message must say what defends the box until the upgrade. + assert!(found[0].description.contains("Until the upgrade")); + + let patched = tempfile::tempdir().unwrap(); + assert!(monitor_with(patched.path(), "P:openssh-server\nV:10.6_p2-r0\n\n").is_empty()); + } + + #[test] + fn test_pam_flavour_and_glibc_systems_are_not_reported() { + // The PAM flavor checks passwords through PAM: not affected. + let root = tempfile::tempdir().unwrap(); + assert!(monitor_with(root.path(), "P:openssh-server-pam\nV:9.3_p2-r0\n\n").is_empty()); + + // A dpkg system is glibc-based: not affected, and unread anyway. + let glibc = tempfile::tempdir().unwrap(); + let dpkg = glibc.path().join("var/lib/dpkg"); + std::fs::create_dir_all(&dpkg).unwrap(); + std::fs::write( + dpkg.join("status"), + "Package: openssh-server\nVersion: 1:9.2p1-2+deb12u5\n\n", + ) + .unwrap(); + assert!(SshVersionMonitor::new() + .detect(&[], glibc.path().to_str()) + .is_empty()); + } + + fn entries(lines: &[&str]) -> Vec { + lines + .iter() + .map(|line| LogEntry { + source_id: "sshd".into(), + timestamp: Utc::now(), + line: (*line).into(), + metadata: HashMap::new(), + }) + .collect() + } + + #[test] + fn test_password_login_to_keyonly_account_is_reported() { + let detector = SshPasswordLoginDetector::with_users(&["root", "alpine"]); + let found = detector.detect(&entries(&[ + // Captured verbatim from a live Alpine sshd (password login in a + // lab container): the exact line shape a successful SSHole login + // produces. + "Accepted password for root from 192.168.65.1 port 28046 ssh2", + "Nov 6 10:00:00 host sshd[123]: Accepted password for root from 89.116.89.18 port 51234 ssh2", + "Nov 6 10:00:01 host sshd[124]: Accepted password for alpine from 89.116.89.18 port 51236 ssh2", + ])); + assert_eq!(found.len(), 3); + assert_eq!(found[0].detector_id, PASSWORD_LOGIN_DETECTOR_ID); + assert!(found[1].sample_line.contains("89.116.89.18")); + } + + #[test] + fn test_ordinary_ssh_lines_are_not_reported() { + let detector = SshPasswordLoginDetector::with_users(&["root", "alpine"]); + let found = detector.detect(&entries(&[ + // A password user that is not on the key-only list. + "Nov 6 10:00:00 host sshd[123]: Accepted password for deploy from 203.0.113.9 port 51234 ssh2", + // Key logins are the normal case. + "Nov 6 10:00:01 host sshd[124]: Accepted publickey for root from 203.0.113.9 port 51236 ssh2", + // Failures belong to the brute-force detector. + "Nov 6 10:00:02 host sshd[125]: Failed password for root from 203.0.113.9 port 51238 ssh2", + // A PAM-based login is not the SSHole path. + "Nov 6 10:00:03 host sshd[126]: Accepted keyboard-interactive/pam for root from 203.0.113.9 port 51240 ssh2", + ])); + assert_eq!(found.len(), 0, "{found:#?}"); + } + + #[test] + fn test_each_login_line_is_its_own_offense() { + let detector = SshPasswordLoginDetector::with_users(&["root"]); + let line = "Nov 6 10:00:00 host sshd[123]: Accepted password for root from 89.116.89.18 port 51234 ssh2"; + let found = detector.detect(&entries(&[line; 5])); + assert_eq!(found.len(), 5); + } +} diff --git a/src/ip_ban/engine.rs b/src/ip_ban/engine.rs index ffaa7dd..39e3168 100644 --- a/src/ip_ban/engine.rs +++ b/src/ip_ban/engine.rs @@ -25,6 +25,10 @@ pub struct OffenseInput { pub container_id: Option, pub source_path: Option, pub sample_line: Option, + /// Ban on this offense alone, whatever the threshold says. For findings + /// where one line already means a foothold: an attacker who gets in with + /// one request can switch the monitoring off before offense two. + pub immediate: bool, } /// What happened to a manual ban request. @@ -149,7 +153,7 @@ impl IpBanEngine { now - Duration::seconds(self.config.find_time_secs as i64), )?; - if offense_count >= self.config.max_retries { + if offense_count >= self.config.max_retries || offense.immediate { self.block_ip(&offense, offense_count, now).await?; return Ok(true); } @@ -507,6 +511,7 @@ impl IpBanEngine { container_id: None, source_path: None, sample_line: None, + immediate: true, }; self.apply_block(&offense, 1, now, duration).await?; Ok(BanOutcome::Banned { @@ -758,6 +763,7 @@ mod tests { container_id: None, source_path: Some("/var/log/auth.log".into()), sample_line: Some("Failed password from 192.0.2.44".into()), + immediate: false, }) .await .unwrap(); @@ -770,6 +776,7 @@ mod tests { container_id: None, source_path: Some("/var/log/auth.log".into()), sample_line: Some("Failed password from 192.0.2.44".into()), + immediate: false, }) .await; @@ -840,6 +847,7 @@ mod tests { container_id: None, source_path: Some("/var/log/auth.log".into()), sample_line: Some("Failed password for root from 198.51.100.7 port 22".into()), + immediate: false, }; let until = "2026-09-10T08:00:00Z".parse::>().unwrap(); @@ -862,6 +870,7 @@ mod tests { container_id: Some("0f3b46ca0c16aaaaaaaa".into()), source_path: None, sample_line: Some("x".repeat(5_000)), + immediate: false, }; let until = "2026-09-10T08:00:00Z".parse::>().unwrap(); @@ -958,6 +967,7 @@ mod tests { container_id: None, source_path: Some("/var/log/auth.log".into()), sample_line: Some(format!("Failed password from {ip}")), + immediate: false, }) .await .unwrap(); @@ -1005,6 +1015,7 @@ mod tests { container_id: None, source_path: Some("/var/log/auth.log".into()), sample_line: Some("Failed password from 192.0.2.77".into()), + immediate: false, }) .await; } @@ -1078,6 +1089,7 @@ mod tests { container_id: None, source_path: Some("/var/log/auth.log".into()), sample_line: Some("Failed password from 192.0.2.55".into()), + immediate: false, }) .await; @@ -1208,6 +1220,7 @@ mod tests { container_id: None, source_path: None, sample_line: None, + immediate: false, }) .await .unwrap(); diff --git a/src/sniff/config.rs b/src/sniff/config.rs index 1498916..9fdf80e 100644 --- a/src/sniff/config.rs +++ b/src/sniff/config.rs @@ -97,6 +97,9 @@ pub struct SniffConfig { pub phpshell_enabled: bool, /// Flag PHP builds older than the PHP2Shell fixes, on the host and in containers pub php_version_check_enabled: bool, + /// Flag OpenSSH builds older than the SSHole fix on musl systems, and ban + /// password logins to key-only accounts + pub sshole_enabled: bool, /// Host directories to scan for PHP shells pub phpshell_paths: Vec, /// Container name/image fragments that mark a PHP workload @@ -312,6 +315,14 @@ impl SniffConfig { _ => None, }) .unwrap_or(true), + sshole_enabled: env::var("STACKDOG_SSHOLE_ENABLED") + .ok() + .and_then(|v| match v.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + }) + .unwrap_or(true), phpshell_paths: env_list("STACKDOG_PHPSHELL_PATHS").unwrap_or_default(), phpshell_container_patterns: env_list("STACKDOG_PHPSHELL_CONTAINERS") .map(|patterns| { diff --git a/src/sniff/mod.rs b/src/sniff/mod.rs index 3d53743..186b90a 100644 --- a/src/sniff/mod.rs +++ b/src/sniff/mod.rs @@ -14,6 +14,7 @@ use crate::alerting::{notifications::NotificationConfig, AlertSeverity}; use crate::database::connection::{create_pool, init_database, DbPool}; use crate::database::repositories::log_sources as log_sources_repo; use crate::detectors::php_shell; +use crate::detectors::sshole; use crate::detectors::DetectorRegistry; use crate::docker::DockerClient; use crate::ip_ban::{gate, IpBanConfig, IpBanEngine, OffenseInput}; @@ -81,6 +82,9 @@ impl SniffOrchestrator { if !config.phpshell_enabled { registry.remove_detector(php_shell::ACCESS_DETECTOR_ID); } + if !config.sshole_enabled { + registry.remove_detector(sshole::PASSWORD_LOGIN_DETECTOR_ID); + } registry }; let tool_registry = @@ -190,7 +194,10 @@ impl SniffOrchestrator { let mut container_filesystems = Vec::new(); match DockerClient::new().await { Ok(docker) => { - if self.config.phpshell_enabled || self.config.php_version_check_enabled { + if self.config.phpshell_enabled + || self.config.php_version_check_enabled + || self.config.sshole_enabled + { match docker.list_container_filesystems().await { Ok(filesystems) => container_filesystems = filesystems, Err(err) => log::debug!("Skipping container PHP checks: {}", err), @@ -254,6 +261,20 @@ impl SniffOrchestrator { .await?; } + if self.config.sshole_enabled { + let anomalies = self + .detectors + .detect_sshole_anomalies(&container_filesystems, self.config.host_root.as_deref()); + self.report_detector_batch( + &mut result, + "sshole", + container_filesystems.len() + 1, + "SSHole version check", + anomalies, + ) + .await?; + } + // 1. Discover sources log::debug!("Step 1: discovering log sources..."); let sources = discovery::discover_all(&self.config.extra_sources).await?; @@ -502,6 +523,11 @@ impl SniffOrchestrator { container_id: source_container_id(source), source_path: source_path(source, None), sample_line: Some(anomaly.sample_line.clone()), + // A password login to a key-only account is already a + // foothold: waiting for five gives the intruder time to + // switch the monitoring off. One line bans. + immediate: anomaly.detector_id.as_deref() + == Some(sshole::PASSWORD_LOGIN_DETECTOR_ID), }) .await?; } @@ -536,6 +562,7 @@ impl SniffOrchestrator { container_id: source_container_id(source), source_path: source_path(source, Some(entry)), sample_line: Some(entry.line.clone()), + immediate: false, }) .await?; } @@ -638,6 +665,7 @@ fn should_auto_ban(anomaly: &analyzer::LogAnomaly) -> bool { | "web.webshell-probe" | "web.php-shell-access" | "web.php2shell-attempt" + | "ssh.password-login" | "file.sensitive-access" | "cloud.metadata-ssrf" ) { @@ -645,10 +673,14 @@ fn should_auto_ban(anomaly: &analyzer::LogAnomaly) -> bool { } // Alert-only by policy. Their descriptions carry file and URL paths, // and a shell named `webshell.php` would otherwise pass the phrase - // check below. The PHP2Shell findings name no client at all. + // check below. The PHP2Shell and SSHole findings name no client at all. if matches!( detector_id, - "php.shell-file" | "php.shell-request" | "vuln.php2shell" | "php.worker-crash" + "php.shell-file" + | "php.shell-request" + | "vuln.php2shell" + | "php.worker-crash" + | "vuln.sshole" ) { return false; } @@ -1601,6 +1633,7 @@ mod tests { "php.shell-request", "vuln.php2shell", "php.worker-crash", + "vuln.sshole", ] { let anomaly = LogAnomaly { description: "Request to a file flagged as a PHP shell: GET /webshell.php".into(), @@ -1685,6 +1718,192 @@ mod tests { assert_eq!(offenses[0].offense_count, 5); } + #[actix_rt::test] + async fn test_sshole_attack_is_detected_and_the_attacker_banned() { + // The whole defense chain against the observed SSHole attack + // (89.116.89.18 ran id, uname and hostname through non-interactive + // sessions after logging in with an oversized password). + // + // Fixtures are real: the apk stanza is Alpine 3.20's openssh + // 9.7_p1-r6, captured from a live container; the login lines are the + // session shape real sshd produced there (captured verbatim in the + // detector tests), with the client address set to the attacker from + // the public report. The exploit itself is not reproduced here — its + // log footprint is an ordinary password login. + let orchestrator = SniffOrchestrator::new(memory_sniff_config()).unwrap(); + + // 1. The exposed system: an apk database that predates the fix. + let rootfs = tempfile::tempdir().unwrap(); + let apk = rootfs.path().join("lib/apk/db"); + std::fs::create_dir_all(&apk).unwrap(); + std::fs::write( + apk.join("installed"), + "P:openssh\nV:9.7_p1-r6\n\nP:openssh-server\nV:9.7_p1-r6\n\n", + ) + .unwrap(); + let version_anomalies = orchestrator + .detectors + .detect_sshole_anomalies(&[], rootfs.path().to_str()); + assert_eq!(version_anomalies.len(), 1, "{version_anomalies:#?}"); + assert_eq!( + version_anomalies[0].detector_id.as_deref(), + Some("vuln.sshole") + ); + assert_eq!(version_anomalies[0].severity, AnomalySeverity::Critical); + assert!(version_anomalies[0].description.contains("9.7_p1-r6")); + // A version names no attacker: it must never reach the ban engine. + assert!(!should_auto_ban(&version_anomalies[0])); + + // 2. The attack in the logs: three password sessions (three recon + // commands) and two failed attempts against accounts with real + // passwords. + let entries: Vec = [ + "Accepted password for root from 89.116.89.18 port 28046 ssh2", + "Accepted password for root from 89.116.89.18 port 28048 ssh2", + "Accepted password for alpine from 89.116.89.18 port 28050 ssh2", + "Failed password for deploy from 89.116.89.18 port 28052 ssh2", + "Failed password for admin from 89.116.89.18 port 28054 ssh2", + ] + .into_iter() + .map(|line| reader::LogEntry { + source_id: "sshd".into(), + timestamp: Utc::now(), + line: line.into(), + metadata: std::collections::HashMap::new(), + }) + .collect(); + + let detector_anomalies = orchestrator.detectors.detect_log_anomalies(&entries); + let logins: Vec = detector_anomalies + .into_iter() + .filter(|anomaly| anomaly.detector_id.as_deref() == Some("ssh.password-login")) + .collect(); + assert_eq!(logins.len(), 3, "{logins:#?}"); + assert!(logins.iter().all(should_auto_ban)); + assert!(logins[0].sample_line.contains("89.116.89.18")); + + // 3. The ban engine: a login finding bans on the first offense (a + // foothold must not wait for five), so the attacker is out before + // the recon sessions finish. + let engine = IpBanEngine::new( + orchestrator.pool.clone(), + IpBanConfig { + enabled: true, + max_retries: 5, + find_time_secs: 300, + ban_time_secs: 60, + unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], + max_bans_per_minute: 0, + protect_local_ips: true, + }, + ); + let mut summary = make_summary("unused", AnomalySeverity::Critical); + summary.anomalies = logins; + let apply = orchestrator + .apply_ip_ban(&entries, &test_log_source(), &summary, &engine) + .await; + + // On Linux without root the firewall write fails before the block is + // recorded. That is a test-environment limit, not detector behavior. + #[cfg(target_os = "linux")] + if !running_as_root() { + let error = apply.unwrap_err().to_string(); + assert!( + error.contains("Operation not permitted") + || error.contains("Permission denied") + || error.contains("you must be root") + ); + return; + } + + apply.unwrap(); + + assert!( + active_block_for_ip(&orchestrator.pool, "89.116.89.18") + .unwrap() + .is_some(), + "the attacker must be banned on the first login finding" + ); + } + + #[actix_rt::test] + async fn test_one_password_login_bans_immediately() { + // One password login to a key-only account is already a foothold: + // waiting for five offenses would give the intruder time to switch + // the monitoring off. The first line bans — except for allowlisted + // and protected addresses, which stay untouched whatever the policy. + let orchestrator = SniffOrchestrator::new(memory_sniff_config()).unwrap(); + let engine = IpBanEngine::new( + orchestrator.pool.clone(), + IpBanConfig { + enabled: true, + // The default threshold: irrelevant for this detector. + max_retries: 5, + find_time_secs: 300, + ban_time_secs: 60, + unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![("95.163.183.217".parse().unwrap(), 32)], + max_bans_per_minute: 0, + protect_local_ips: true, + }, + ); + let entries: Vec = [ + "Nov 6 10:00:00 host sshd[123]: Accepted password for root from 95.163.183.216 port 51234 ssh2", + "Nov 6 10:00:01 host sshd[124]: Accepted password for root from 95.163.183.217 port 51236 ssh2", + ] + .into_iter() + .map(|line| reader::LogEntry { + source_id: "sshd".into(), + timestamp: Utc::now(), + line: line.into(), + metadata: std::collections::HashMap::new(), + }) + .collect(); + let mut summary = make_summary("unused", AnomalySeverity::Critical); + summary.anomalies = orchestrator + .detectors + .detect_log_anomalies(&entries) + .into_iter() + .filter(|anomaly| anomaly.detector_id.as_deref() == Some("ssh.password-login")) + .collect(); + assert_eq!(summary.anomalies.len(), 2); + + let apply = orchestrator + .apply_ip_ban(&[], &test_log_source(), &summary, &engine) + .await; + + // On Linux without root the firewall write fails before the block is + // recorded. That is a test-environment limit, not detector behavior. + #[cfg(target_os = "linux")] + if !running_as_root() { + let error = apply.unwrap_err().to_string(); + assert!( + error.contains("Operation not permitted") + || error.contains("Permission denied") + || error.contains("you must be root") + ); + return; + } + + apply.unwrap(); + + assert!( + active_block_for_ip(&orchestrator.pool, "95.163.183.216") + .unwrap() + .is_some(), + "a single login line must be enough to ban" + ); + assert!( + active_block_for_ip(&orchestrator.pool, "95.163.183.217") + .unwrap() + .is_none(), + "allowlisted addresses must never be banned" + ); + } + #[actix_rt::test] async fn test_php_shell_access_records_offense_except_for_allowlisted() { let orchestrator = SniffOrchestrator::new(memory_sniff_config()).unwrap(); diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts index 8a59645..cec4bb6 100644 --- a/website/app/sitemap.ts +++ b/website/app/sitemap.ts @@ -36,6 +36,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'weekly', priority: 0.9 }, + { + url: `${siteUrl}/sshole-check`, + lastModified, + changeFrequency: 'weekly', + priority: 0.9 + }, { url: `${siteUrl}/fail2ban-alternative`, lastModified, diff --git a/website/app/sshole-check/page.tsx b/website/app/sshole-check/page.tsx new file mode 100644 index 0000000..8dc8401 --- /dev/null +++ b/website/app/sshole-check/page.tsx @@ -0,0 +1,288 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowRight, ShieldCheck, TerminalSquare } from 'lucide-react'; +import SsholeChecker from '@/components/SsholeChecker'; +import { getSiteUrl } from '@/lib/config'; +import { buildDocsStructuredData, toJsonLd } from '@/lib/structured-data'; + +const PAGE_PATH = '/sshole-check'; + +export const metadata: Metadata = { + title: 'Am I Affected by SSHole? Check Your OpenSSH Version', + description: + 'Free SSHole check for CVE-2026-123158: paste ssh -V, see whether your OpenSSH predates the 10.6p2 fix, and learn how StackdogApp detects the version exposure and the logins a successful attack leaves behind.', + keywords: [ + 'SSHole check', + 'CVE-2026-123158', + 'OpenSSH vulnerability check', + 'OpenSSH 10.6p2', + 'Alpine Linux sshd', + 'StackdogApp' + ], + alternates: { + canonical: PAGE_PATH + }, + openGraph: { + title: 'Am I Affected by SSHole? — StackdogApp', + description: + 'Paste ssh -V and see whether your OpenSSH predates the SSHole fix. Plus how StackdogApp checks it on every sniff pass.', + url: getSiteUrl() + PAGE_PATH + }, + twitter: { + title: 'Am I Affected by SSHole? — StackdogApp', + description: + 'Paste ssh -V and see whether your OpenSSH predates the SSHole fix. Plus how StackdogApp checks it.' + } +}; + +const checks = [ + [ + 'vuln.sshole', + 'An OpenSSH build older than 10.6p2 on a musl system: the package database on the host and inside every running container (openssh-server on Alpine)', + 'Critical alert. Names the version and says to upgrade to 10.6p2 and restart sshd. Never bans: a version names no attacker.' + ], + [ + 'ssh.password-login', + 'A password login to a key-only account (root, alpine by default; more via STACKDOG_SSHOLE_KEYONLY_USERS). That is what a successful SSHole login looks like in the logs', + 'Alert and an immediate ban on the first line. One login is already a foothold, and waiting for five would let the intruder switch the monitoring off. Allowlists and protected addresses still apply.' + ] +] as const; + +export default function SsholeCheckPage() { + return ( +
+