Distributions backport security fixes without bumping the upstream OpenSSH version, so a banner like "OpenSSH_9.2p1 Debian-2+deb12u3" was wrongly flagged for regreSSHion despite carrying the fix. Thread the banner vendor comment into analyseBannerVulns and add a per-CVE VendorFixes table recording the earliest patched package revision per distro/upstream version. Revisions are compared with a faithful port of dpkg's verrevcmp ordering. Populated for CVE-2024-6387 from DSA-5724-1 (Debian) and USN-6859-1 (Ubuntu).
399 lines
14 KiB
Go
399 lines
14 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
|
|
// 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' }
|