checker-ssh/checker/vulns.go

249 lines
8.3 KiB
Go

// 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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
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
}