From f76990b72686c071f6d8cfc084af0fce96ac5adf Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 9 Nov 2026 13:56:58 +0200 Subject: [PATCH 1/3] feat: detect SSHole exposure and ban password logins to key-only accounts SSHole (CVE-2026-123158) is an OpenSSH authentication bypass on musl with PAM disabled, Alpine's default server included: a 257-1024 byte password makes the hash attempt fail, the failure reads as the same '*' the account stores, and the login succeeds. Fixed in OpenSSH 10.6p2. - vuln.sshole flags openssh-server builds older than 10.6p2 in the apk package database, on the host and in running containers: apk is the musl half of the vulnerable configuration. The PAM flavor and glibc distributions are not affected and are not reported. Alert only: a version names no attacker. - 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: Alpine's own incident guidance is to hunt exactly these lines. One offense per line, repeats numbered so the registry counts them. - STACKDOG_SSHOLE_ENABLED=false turns both off; the version check reads package databases through the same host-root mount as the PHP checks. Docs: README subsection, CHANGELOG, .env.sample, and an /am-i-affected style page at /sshole-check with an in-browser version check (ssh -V plus the platform conditions), wired into sitemap and footer. Tests: 468+8+108 pass, clippy -D warnings and fmt clean, website build 15/15. --- .env.sample | 7 + CHANGELOG.md | 12 + README.md | 19 ++ src/detectors/mod.rs | 17 ++ src/detectors/sshole.rs | 427 +++++++++++++++++++++++++++ src/sniff/config.rs | 11 + src/sniff/mod.rs | 85 +++++- website/app/sitemap.ts | 6 + website/app/sshole-check/page.tsx | 282 ++++++++++++++++++ website/components/Footer.tsx | 5 + website/components/SsholeChecker.tsx | 159 ++++++++++ 11 files changed, 1027 insertions(+), 3 deletions(-) create mode 100644 src/detectors/sshole.rs create mode 100644 website/app/sshole-check/page.tsx create mode 100644 website/components/SsholeChecker.tsx 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..af1f9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ 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. + `STACKDOG_SSHOLE_ENABLED=false` turns both off. + ## [0.2.9] - 2026-10-31 ### Added diff --git a/README.md b/README.md index ee5804e..7a3563b 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 offense per line; five from one address within the ban window ban it. That login line is what a successful SSHole attack leaves behind | + +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/sshole.rs b/src/detectors/sshole.rs new file mode 100644 index 0000000..1459e5e --- /dev/null +++ b/src/detectors/sshole.rs @@ -0,0 +1,427 @@ +//! 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" + ), + 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")); + + 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(&[ + "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(), 2); + assert_eq!(found[0].detector_id, PASSWORD_LOGIN_DETECTOR_ID); + assert!(found[0].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/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..1725896 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?; @@ -638,6 +659,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 +667,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 +1627,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 +1712,58 @@ mod tests { assert_eq!(offenses[0].offense_count, 5); } + #[actix_rt::test] + async fn test_sshole_password_logins_count_one_offense_per_line() { + let orchestrator = SniffOrchestrator::new(memory_sniff_config()).unwrap(); + let engine = IpBanEngine::new( + orchestrator.pool.clone(), + IpBanConfig { + enabled: true, + // High enough that the test records offenses without blocking. + max_retries: 100, + 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, + }, + ); + // What a successful SSHole run leaves behind: password logins to an + // account that should never accept one, one line per session. + let entries: Vec = (0..5) + .map(|i| reader::LogEntry { + source_id: "sshd".into(), + timestamp: Utc::now(), + line: format!( + "Nov 6 10:00:0{i} host sshd[12{i}]: Accepted password for root from 95.163.183.216 port 5123{i} ssh2" + ), + 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(), 5); + assert!(summary.anomalies.iter().all(should_auto_ban)); + + orchestrator + .apply_ip_ban(&[], &test_log_source(), &summary, &engine) + .await + .unwrap(); + + let since = Utc::now() - chrono::Duration::minutes(5); + let offenses = + find_recent_offenses(&orchestrator.pool, "95.163.183.216", "sniff", since).unwrap(); + assert_eq!(offenses.len(), 1); + assert_eq!(offenses[0].offense_count, 5); + } + #[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..6b8708b --- /dev/null +++ b/website/app/sshole-check/page.tsx @@ -0,0 +1,282 @@ +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 offense per line. Five from one address within the ban window and the address is banned. Allowlists still apply.' + ] +] as const; + +export default function SsholeCheckPage() { + return ( +
+