// This file is part of the happyDomain (R) project. // Copyright (c) 2020-2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. // For commercial licensing, contact us at . // // For AGPL licensing: // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package checker import ( "fmt" "regexp" "strconv" "strings" ) // OpenSSH CVE database. The entries here are a curated subset of the // ssh-audit vulnerability list focused on issues that are both // remotely observable from a banner and serious enough to warrant // surfacing in a periodic check. // // Versions are expressed using a semver-ish triple plus an optional // "p" patch suffix, which mirrors OpenSSH's own numbering // (e.g. 9.3p1 < 9.3p2 < 9.4p1). The matcher is conservative: when a // banner can't be parsed into a version, we skip the match rather // than over-flag. type opensshVuln struct { Code string CVE string Severity string Title string Description string Fix string AffectedRanges []opensshRange } type opensshRange struct { MinInclusive string // "" means open-ended below MaxExclusive string // "" means open-ended above } var opensshVulns = []opensshVuln{ { Code: "cve_2024_6387_regreSSHion", CVE: "CVE-2024-6387", Severity: SeverityCrit, Title: "regreSSHion (CVE-2024-6387)", Description: "Signal-handler race in OpenSSH's sshd allows unauthenticated remote code execution as root on glibc-based systems.", Fix: "Upgrade OpenSSH to 9.8p1 or later. If upgrading is not possible, set LoginGraceTime 0 in sshd_config as a mitigation (denial-of-service trade-off).", AffectedRanges: []opensshRange{ // Regression reintroduced in 8.5p1; fixed in 9.8p1. {MinInclusive: "8.5p1", MaxExclusive: "9.8p1"}, // The race also existed in < 4.4p1 (CVE-2006-5051 variant). {MaxExclusive: "4.4p1"}, }, }, { Code: "cve_2023_38408_agent", CVE: "CVE-2023-38408", Severity: SeverityCrit, Title: "ssh-agent PKCS#11 provider RCE", Description: "OpenSSH's forwarded ssh-agent in 5.5 through 9.3p1 can load and execute arbitrary shared libraries, enabling RCE if an attacker controls the forwarded agent.", Fix: "Upgrade OpenSSH to 9.3p2 or later.", AffectedRanges: []opensshRange{ {MinInclusive: "5.5", MaxExclusive: "9.3p2"}, }, }, { Code: "cve_2023_48795_terrapin", CVE: "CVE-2023-48795", Severity: SeverityWarn, Title: "Terrapin prefix truncation (CVE-2023-48795)", Description: "A MITM can silently drop the first messages after KEX completes, potentially downgrading security features. Affects any SSH server supporting ChaCha20-Poly1305 or CBC-EtM without strict-KEX.", Fix: "Upgrade OpenSSH to 9.6p1 or later (advertises kex-strict-s-v00@openssh.com).", AffectedRanges: []opensshRange{ {MaxExclusive: "9.6p1"}, }, }, { Code: "cve_2021_41617_agent_forward", CVE: "CVE-2021-41617", Severity: SeverityWarn, Title: "sshd AuthorizedKeysCommand / AuthorizedPrincipalsCommand privilege drop flaw", Description: "sshd from 6.2 to 8.8 fails to correctly drop supplementary groups when executing the AuthorizedKeysCommand/AuthorizedPrincipalsCommand helpers.", Fix: "Upgrade OpenSSH to 8.8p1 or later.", AffectedRanges: []opensshRange{ {MinInclusive: "6.2", MaxExclusive: "8.8p1"}, }, }, { Code: "cve_2020_15778_scp", CVE: "CVE-2020-15778", Severity: SeverityWarn, Title: "scp command-injection via shell quoting", Description: "scp in OpenSSH through 8.3p1 does not sanitise filenames when copying files, enabling command injection on the destination via crafted names.", Fix: "Upgrade OpenSSH to 8.4p1 or later; prefer sftp/rsync over scp.", AffectedRanges: []opensshRange{ {MaxExclusive: "8.4p1"}, }, }, { Code: "cve_2018_15473_user_enum", CVE: "CVE-2018-15473", Severity: SeverityWarn, Title: "Username enumeration via timing", Description: "OpenSSH through 7.7p1 allows remote username enumeration by timing the response to malformed authentication packets.", Fix: "Upgrade OpenSSH to 7.8p1 or later.", AffectedRanges: []opensshRange{ {MaxExclusive: "7.8p1"}, }, }, } // analyseBannerSoftware flags a non-OpenSSH banner for operator // awareness. No CVE match is attempted on unrecognised software. func analyseBannerSoftware(addr, banner, software string) []Issue { if banner == "" { return nil } if parseOpenSSHVersion(software) != nil { return nil } if looksLikeOpenSSH(software) { return nil } return []Issue{{ Code: "non_openssh", Severity: SeverityInfo, Message: fmt.Sprintf("Server reports %q, not a recognised OpenSSH build. Verify the deployed software is maintained.", software), Endpoint: addr, }} } // analyseBannerVulns runs the banner through the OpenSSH CVE database // and returns the matched issues. The banner parser is deliberately // loose: a server running a vendor-patched OpenSSH (e.g. // "OpenSSH_9.2p1 Debian-2+deb12u2") will still match the upstream // version numbers, because distribution maintainers tend to backport // fixes without changing the version string. Operators get to // override these false positives at the UI layer, same as other // checkers. func analyseBannerVulns(addr, banner, software string) []Issue { if banner == "" { return nil } ver := parseOpenSSHVersion(software) if ver == nil { return nil } var issues []Issue for _, v := range opensshVulns { if rangesMatch(ver, v.AffectedRanges) { issues = append(issues, Issue{ Code: v.Code, Severity: v.Severity, Message: fmt.Sprintf("%s: %s", v.Title, v.Description), Fix: v.Fix, Endpoint: addr, }) } } return issues } // analyseBanner combines software-awareness and vulnerability matches. // Retained as a convenience for the HTML report, which surfaces both // concerns in a single "What to fix" list. func analyseBanner(addr, banner, software string) []Issue { out := analyseBannerSoftware(addr, banner, software) out = append(out, analyseBannerVulns(addr, banner, software)...) return out } func looksLikeOpenSSH(s string) bool { return strings.HasPrefix(s, "OpenSSH_") } // opensshVersion captures a (major, minor, portable) tuple. Portable // is 0 when the banner lists only a vanilla upstream version (which // is rare). OpenSSH_9.3p1 → {9, 3, 1}. type opensshVersion struct{ Major, Minor, Portable int } var opensshBannerRe = regexp.MustCompile(`^OpenSSH_(\d+)\.(\d+)(?:p(\d+))?`) func parseOpenSSHVersion(software string) *opensshVersion { m := opensshBannerRe.FindStringSubmatch(software) if m == nil { return nil } v := &opensshVersion{} v.Major, _ = strconv.Atoi(m[1]) v.Minor, _ = strconv.Atoi(m[2]) if m[3] != "" { v.Portable, _ = strconv.Atoi(m[3]) } return v } func less(a, b opensshVersion) bool { if a.Major != b.Major { return a.Major < b.Major } if a.Minor != b.Minor { return a.Minor < b.Minor } return a.Portable < b.Portable } func rangesMatch(v *opensshVersion, ranges []opensshRange) bool { for _, r := range ranges { min, okMin := parseVersionString(r.MinInclusive) max, okMax := parseVersionString(r.MaxExclusive) if okMin && less(*v, min) { continue } if okMax && !less(*v, max) { continue } return true } return false } func parseVersionString(s string) (opensshVersion, bool) { if s == "" { return opensshVersion{}, false } // Reuse the banner regex by pretending we have a "OpenSSH_" prefix. v := parseOpenSSHVersion("OpenSSH_" + s) if v == nil { return opensshVersion{}, false } return *v, true }