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 (
+
+
+
+
+
+
+ Free SSHole check
+
+ Am I affected by SSHole?
+
+
+ SSHole (CVE-2026-123158) is an authentication bypass in OpenSSH itself: on musl
+ systems with PAM disabled, including Alpine's default server configuration, an
+ oversized password logs the attacker into accounts that have no password set. It
+ was being exploited within hours of disclosure. The fix is OpenSSH 10.6p2. Paste
+ your version below.
+
One paste, no sign-up, nothing leaves your browser
+
+
+
+
+
+
+
+
+ What SSHole is
+
A failed hash read as a successful login
+
+
+ Accounts with no usable password carry a * in their password field.
+ When OpenSSH checks a password without PAM, a 257 to 1024 byte password makes the
+ hashing step fail, and the failure is read as the same *. The stored
+ asterisk and the produced asterisk match, and the login succeeds. No credentials
+ stolen, no brute force, one request.
+
+
+ The condition is narrow and precise: Linux, musl, OpenSSH with passwords checked
+ without PAM, and at least one account whose password field is *.
+ Dropbear is not affected, glibc distributions are not affected, and systems that
+ check passwords through PAM are not affected. That covers the vulnerable class:
+ Alpine's default OpenSSH server.
+
+
+
+
+
+
+
+ What to do now
+
Upgrade, restart, then look for what already got in
+
+
+
+
+
+
Fix the version
+
+ Install OpenSSH 10.6p2, or your distribution's patched package — on Alpine
+ that is 10.6_p2-r0 or newer — and then restart sshd. Until the
+ restart, the old code is still serving logins.
+
+
+
+
+
+
+
Hunt what got in
+
+ Alpine's own incident advice: look for Accepted password for{' '}
+ lines involving accounts that should never accept a password. Review doas use,
+ recently created users, files and authorized keys. If you cannot rule out a
+ login, treat the machine as compromised.
+
+
+
+
+
+
+
+
+ How StackdogApp checks it
+
The version, and the logs a successful attack leaves
+
+ The same two-layer shape as PHP2Shell: one detector for exposure, one for the
+ evidence. Both run on every sniff pass.
+
+
+
+
+
+ The version check reads the apk package database, because musl is the affected half
+ of the configuration and the package database says musl: the default{' '}
+ openssh-server on Alpine is built without PAM. The PAM flavor and glibc
+ distributions are not reported, because the CVE does not reach them. The log half
+ reads the same auth and syslog sources the brute-force bans come from — list yours in{' '}
+ STACKDOG_LOG_SOURCES; on Alpine, sshd logs to{' '}
+ /var/log/messages.
+
+
+
+
+
+
+ Honest limits
+
What the checks do not cover
+
+
+ The log detector knows key-only accounts only because we name them: root and
+ alpine by default, your own via{' '}
+ STACKDOG_SSHOLE_KEYONLY_USERS. A password login to an unnamed
+ key-only account passes it by.
+
+
+ One successful SSHole login is already a foothold. The ban stops repeat sessions
+ and scanners; it does not undo the first one. If the check or the logs say a
+ password login reached a key-only account, the work is incident response, not
+ tuning.
+
+
+ The version check covers musl systems read from package databases. An OpenSSH
+ built from source carries no package to read. And none of this patches anything:
+ upgrading is the fix, same as always.
+
+ );
+}
diff --git a/website/components/Footer.tsx b/website/components/Footer.tsx
index 4c815a5..2d8f506 100644
--- a/website/components/Footer.tsx
+++ b/website/components/Footer.tsx
@@ -86,6 +86,11 @@ export default function Footer(_: FooterProps) {
PHP2Shell Version Check
+
+
+ SSHole Version Check
+
+
Fail2ban Alternative
diff --git a/website/components/SsholeChecker.tsx b/website/components/SsholeChecker.tsx
new file mode 100644
index 0000000..615ff6a
--- /dev/null
+++ b/website/components/SsholeChecker.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useMemo, useState } from 'react';
+
+type Platform = 'musl' | 'pam' | 'glibc' | 'unsure';
+
+interface Verdict {
+ state: 'affected' | 'patched' | 'not-affected' | 'unknown';
+ version: string;
+ headline: string;
+ detail: string;
+}
+
+const FIXED: readonly [number, number, number] = [10, 6, 2];
+
+function parseVersion(input: string): [number, number, number] | null {
+ const match = input.match(/(\d+)\.(\d+)(?:[._]?p(\d+))?/);
+ if (!match) return null;
+ return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
+}
+
+function check(input: string, platform: Platform): Verdict | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+ const parsed = parseVersion(trimmed);
+
+ if (platform === 'glibc' || platform === 'pam') {
+ return {
+ state: 'not-affected',
+ version: '',
+ headline: 'SSHole does not reach this configuration',
+ detail:
+ platform === 'pam'
+ ? 'SSHole needs passwords checked without PAM. Your sshd checks passwords through PAM, so the oversized-password path is closed.'
+ : 'SSHole is specific to musl: glibc-based distributions handle the failed hash differently. This box is not in the affected class.'
+ };
+ }
+
+ if (!parsed) {
+ return {
+ state: 'unknown',
+ version: '',
+ headline: "Couldn't read a version",
+ detail: 'Paste the output of ssh -V — it looks like "OpenSSH_10.6p2, OpenSSL ...".'
+ };
+ }
+
+ const [major, minor, p] = parsed;
+ const version = `${major}.${minor}p${p}`;
+ const fixed = major > FIXED[0] || (major === FIXED[0] && (minor > FIXED[1] || (minor === FIXED[1] && p >= FIXED[2])));
+
+ if (fixed) {
+ return {
+ state: 'patched',
+ version,
+ headline: `${version} carries the fix`,
+ detail:
+ 'SSHole (CVE-2026-123158) was fixed in OpenSSH 10.6p2. Even patched, note that SSHole only ever applied to musl systems with PAM disabled.'
+ };
+ }
+
+ const unsureNote =
+ platform === 'unsure'
+ ? ' You said you are unsure about the platform: if this is Alpine or another musl system with the default (no-PAM) OpenSSH, you are affected. Check /etc/os-release and UsePAM in sshd_config.'
+ : '';
+
+ return {
+ state: 'affected',
+ version,
+ headline: `${version} predates the SSHole fix`,
+ detail: `Upgrade to OpenSSH 10.6p2 — 10.6_p2 on Alpine — and restart sshd. Until then, an oversized password can log an attacker into accounts whose password field is * (root, alpine).${unsureNote}`
+ };
+}
+
+export default function SsholeChecker() {
+ const [input, setInput] = useState('');
+ const [platform, setPlatform] = useState('musl');
+ const verdict = useMemo(() => check(input, platform), [input, platform]);
+
+ const options: { value: Platform; label: string }[] = [
+ { value: 'musl', label: 'Alpine / musl, default OpenSSH (no PAM)' },
+ { value: 'pam', label: 'OpenSSH checks passwords through PAM' },
+ { value: 'glibc', label: 'glibc distro (Debian, Ubuntu, RHEL...)' },
+ { value: 'unsure', label: 'Not sure' }
+ ];
+
+ return (
+
+
+
+ );
+}
From a2d07df670b11394a08491f7ebdcdc7ab81497c1 Mon Sep 17 00:00:00 2001
From: Vasili Pascal
Date: Mon, 9 Nov 2026 14:13:42 +0200
Subject: [PATCH 2/3] feat: ban SSHole logins on the first line and name the
interim defense
One password login to a key-only account is already a foothold: waiting
for the five-offense threshold hands the intruder time to switch the
monitoring off. OffenseInput gains an immediate flag, and
ssh.password-login sets it, so the first line bans the address. The gate
still runs first: allowlisted and protected addresses are never touched.
The exposure alerts now say what defends the box until the upgrade, so
the Slack message is actionable on arrival: vuln.sshole names the
first-line ban on password logins to key-only accounts, vuln.php2shell
names the out-of-memory bans, the webshell scan and session.upload_
progress.enabled = 0.
Tests: an end-to-end attack simulation on captured fixtures (Alpine
3.20's openssh 9.7_p1-r6 apk database and a real sshd login line from a
lab container) walks exposure finding, log findings and the ban to an
active block; a new test proves one line bans and the allowlist still
holds. 469+8+108 pass, clippy -D warnings and fmt clean, website 15/15.
---
CHANGELOG.md | 4 +-
README.md | 2 +-
src/detectors/php2shell.rs | 2 +-
src/detectors/sshole.rs | 12 ++-
src/ip_ban/engine.rs | 15 ++-
src/sniff/mod.rs | 156 +++++++++++++++++++++++++-----
website/app/sshole-check/page.tsx | 8 +-
7 files changed, 169 insertions(+), 30 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af1f9c2..dbe2c44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,7 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
+ 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
diff --git a/README.md b/README.md
index 7a3563b..aa21397 100644
--- a/README.md
+++ b/README.md
@@ -666,7 +666,7 @@ 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 |
+| `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
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
index 1459e5e..ef349db 100644
--- a/src/detectors/sshole.rs
+++ b/src/detectors/sshole.rs
@@ -186,7 +186,7 @@ pub fn findings(installs: &[(String, String, String)]) -> Vec {
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"
+ "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,
@@ -352,6 +352,8 @@ mod tests {
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());
@@ -393,12 +395,16 @@ mod tests {
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(), 2);
+ assert_eq!(found.len(), 3);
assert_eq!(found[0].detector_id, PASSWORD_LOGIN_DETECTOR_ID);
- assert!(found[0].sample_line.contains("89.116.89.18"));
+ assert!(found[1].sample_line.contains("89.116.89.18"));
}
#[test]
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/mod.rs b/src/sniff/mod.rs
index 1725896..cfd9887 100644
--- a/src/sniff/mod.rs
+++ b/src/sniff/mod.rs
@@ -523,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?;
}
@@ -557,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?;
}
@@ -1713,14 +1719,77 @@ mod tests {
}
#[actix_rt::test]
- async fn test_sshole_password_logins_count_one_offense_per_line() {
+ 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,
- // High enough that the test records offenses without blocking.
- max_retries: 100,
+ max_retries: 5,
find_time_secs: 300,
ban_time_secs: 60,
unban_check_interval_secs: 60,
@@ -1730,18 +1799,55 @@ mod tests {
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 = logins;
+ orchestrator
+ .apply_ip_ban(&entries, &test_log_source(), &summary, &engine)
+ .await
+ .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
@@ -1749,19 +1855,25 @@ mod tests {
.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));
+ assert_eq!(summary.anomalies.len(), 2);
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);
+ 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]
diff --git a/website/app/sshole-check/page.tsx b/website/app/sshole-check/page.tsx
index 6b8708b..8dc8401 100644
--- a/website/app/sshole-check/page.tsx
+++ b/website/app/sshole-check/page.tsx
@@ -44,7 +44,7 @@ const checks = [
[
'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.'
+ '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;
@@ -214,6 +214,12 @@ export default function SsholeCheckPage() {
STACKDOG_SSHOLE_KEYONLY_USERS. A password login to an unnamed
key-only account passes it by.
+
+ The ban on these lines is immediate on purpose. That also means a password login
+ you did not expect gets the same treatment: if you or your tooling log into root
+ with a password, put those addresses in STACKDOG_IP_BAN_ALLOWLIST{' '}
+ first.
+
One successful SSHole login is already a foothold. The ban stops repeat sessions
and scanners; it does not undo the first one. If the check or the logs say a
From 35f3b05f47a9958f4f64ef276b919a7292a84339 Mon Sep 17 00:00:00 2001
From: Vasili Pascal
Date: Mon, 9 Nov 2026 14:53:02 +0200
Subject: [PATCH 3/3] fix(tests): tolerate the root-only firewall write on
Linux CI
The two SSHole ban tests unwrapped apply_ip_ban, which panics on a Linux
runner without root: the nftables backend fails with 'Operation not
permitted' before the block is recorded. Mirror the existing pattern in
test_apply_ip_ban_blocks_and_emits_alert_after_repeated_anomalies: on
Linux without root, accept that error and skip the block assertions; the
detection assertions still run everywhere. macOS keeps asserting the
ban end to end.
---
src/sniff/mod.rs | 40 ++++++++++++++++++++++++++++++++++------
1 file changed, 34 insertions(+), 6 deletions(-)
diff --git a/src/sniff/mod.rs b/src/sniff/mod.rs
index cfd9887..186b90a 100644
--- a/src/sniff/mod.rs
+++ b/src/sniff/mod.rs
@@ -1801,10 +1801,24 @@ mod tests {
);
let mut summary = make_summary("unused", AnomalySeverity::Critical);
summary.anomalies = logins;
- orchestrator
+ let apply = orchestrator
.apply_ip_ban(&entries, &test_log_source(), &summary, &engine)
- .await
- .unwrap();
+ .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")
@@ -1857,10 +1871,24 @@ mod tests {
.collect();
assert_eq!(summary.anomalies.len(), 2);
- orchestrator
+ let apply = orchestrator
.apply_ip_ban(&[], &test_log_source(), &summary, &engine)
- .await
- .unwrap();
+ .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")