// 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 // VendorFixes lists distribution packages that backport the fix // without bumping the upstream version string. When the observed // banner matches one of these (same vendor and upstream version, // with a package revision at or above FixedFrom), the CVE is // suppressed even though AffectedRanges would otherwise match. VendorFixes []vendorFix } type opensshRange struct { MinInclusive string // "" means open-ended below MaxExclusive string // "" means open-ended above } // vendorFix records the earliest distribution package revision that // carries a backported fix for a given upstream OpenSSH version. The // Vendor is the leading token of the banner's vendor comment (e.g. // "Debian" in "Debian-2+deb12u3"); Upstream is the upstream version // the distro ships (e.g. "9.2p1"); FixedFrom is the package revision // that first shipped the fix (e.g. "2+deb12u3"). Revisions are compared // with the dpkg version-ordering algorithm. type vendorFix struct { Vendor string Upstream string FixedFrom string } 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"}, }, // Distributions backported the fix without changing the upstream // version. Sources: DSA-5724-1 (Debian) and USN-6859-1 (Ubuntu). // Debian bullseye (8.4p1) and Ubuntu focal (8.2p1) ship versions // below 8.5p1 and are therefore not affected at all. VendorFixes: []vendorFix{ {Vendor: "Debian", Upstream: "9.2p1", FixedFrom: "2+deb12u3"}, // bookworm {Vendor: "Ubuntu", Upstream: "9.6p1", FixedFrom: "3ubuntu13.3"}, // noble 24.04 {Vendor: "Ubuntu", Upstream: "9.3p1", FixedFrom: "1ubuntu3.6"}, // mantic 23.10 {Vendor: "Ubuntu", Upstream: "8.9p1", FixedFrom: "3ubuntu0.10"}, // jammy 22.04 }, }, { 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 upstream version match is // deliberately loose, because distribution maintainers tend to backport // fixes without changing the version string (e.g. "OpenSSH_9.2p1 // Debian-2+deb12u3" reports the same 9.2p1 as a vulnerable build). To // avoid false positives, a matched CVE is suppressed when the vendor // comment identifies a distribution package known to carry the // backported fix (see vendorFix). Any residual false positives can // still be overridden at the UI layer, same as other checkers. func analyseBannerVulns(addr, banner, software, vendor 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) { continue } if vendorPatched(software, vendor, v.VendorFixes) { continue } 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 } // vendorPatched reports whether the banner's vendor comment identifies a // distribution package that has backported the fix for this CVE. The // vendor comment ("Debian-2+deb12u3") is split into a vendor name and a // package revision; a fix applies when the vendor name and the upstream // version both match and the observed revision is at or above the // recorded FixedFrom revision (dpkg ordering). func vendorPatched(software, vendor string, fixes []vendorFix) bool { if vendor == "" || len(fixes) == 0 { return false } name, revision := splitVendorComment(vendor) if name == "" || revision == "" { return false } upstream := upstreamVersionString(software) for _, f := range fixes { if !strings.EqualFold(f.Vendor, name) || f.Upstream != upstream { continue } if dpkgVerCmp(revision, f.FixedFrom) >= 0 { return true } } return false } // splitVendorComment splits a banner vendor comment such as // "Debian-2+deb12u3" or "Ubuntu-3ubuntu0.10" into its vendor name and // package revision on the first '-'. func splitVendorComment(vendor string) (name, revision string) { name, revision, _ = strings.Cut(vendor, "-") return name, revision } // upstreamVersionString returns the bare upstream version token from a // software identifier, e.g. "OpenSSH_9.2p1" -> "9.2p1". It returns "" // when the identifier is not a recognised OpenSSH banner. func upstreamVersionString(software string) string { m := opensshBannerRe.FindString(software) return strings.TrimPrefix(m, "OpenSSH_") } // 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, vendor string) []Issue { out := analyseBannerSoftware(addr, banner, software) out = append(out, analyseBannerVulns(addr, banner, software, vendor)...) 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 } // dpkgVerCmp compares two Debian/Ubuntu package version segments using // the dpkg version-ordering algorithm (verrevcmp). It returns a negative // value when a sorts before b, zero when equal, and a positive value // when a sorts after b. We feed it the package-revision portion of a // banner vendor comment (e.g. "2+deb12u3" vs "2+deb12u2"), which is // sufficient because backport comparisons are always within the same // upstream version. func dpkgVerCmp(a, b string) int { i, j := 0, 0 for i < len(a) || j < len(b) { // Compare the non-digit prefixes character by character using // dpkg's special ordering (notably '~' sorts before everything, // including the end of string). for (i < len(a) && !isASCIIDigit(a[i])) || (j < len(b) && !isASCIIDigit(b[j])) { ac, bc := 0, 0 if i < len(a) { ac = dpkgOrder(a[i]) } if j < len(b) { bc = dpkgOrder(b[j]) } if ac != bc { return ac - bc } i++ j++ } // Skip leading zeros so digit runs compare by numeric value. for i < len(a) && a[i] == '0' { i++ } for j < len(b) && b[j] == '0' { j++ } // Compare the digit runs: a longer run of significant digits is // the larger number; on equal length the first differing digit // decides. firstDiff := 0 for i < len(a) && isASCIIDigit(a[i]) && j < len(b) && isASCIIDigit(b[j]) { if firstDiff == 0 { firstDiff = int(a[i]) - int(b[j]) } i++ j++ } if i < len(a) && isASCIIDigit(a[i]) { return 1 } if j < len(b) && isASCIIDigit(b[j]) { return -1 } if firstDiff != 0 { return firstDiff } } return 0 } // dpkgOrder maps a non-digit byte to its dpkg sort weight: '~' sorts // before everything (including end-of-string, weight 0), letters keep // their ASCII value, and any other character sorts after letters. func dpkgOrder(c byte) int { switch { case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'): return int(c) case c == '~': return -1 default: return int(c) + 256 } } func isASCIIDigit(c byte) bool { return c >= '0' && c <= '9' }