Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/binver.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "binver.hpp"

#include <algorithm>
#include <cctype>
#include <regex>
#include <string_view>
#include <utility>
Expand Down Expand Up @@ -93,6 +94,20 @@ bool is_elf(std::span<const uint8_t> d) {
return d.size() >= 4 && d[0] == 0x7f && d[1] == 'E' && d[2] == 'L' && d[3] == 'F';
}

// A vendor-SDK fork label for a version tail that continued past the parsed base
// semver (e.g. "0.8.x_rtw_r24647.20171025", "2.10_ATBM_0.2"). The Realtek "_rtw_"
// hostapd/wpa_supplicant fork is ubiquitous in IoT Wi-Fi SoCs; AltoBeam ("_ATBM")
// is another. Anything else that continued with a non-numeric tag is a generic
// vendor fork. Empty tail (a clean upstream version) returns "".
std::string vendor_fork_label(const std::string& tail) {
std::string lt = tail;
for (char& c : lt) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (lt.find("rtw") != std::string::npos || lt.find("realtek") != std::string::npos)
return "Realtek SDK";
if (lt.find("atbm") != std::string::npos) return "AltoBeam SDK";
return "vendor fork";
}

} // namespace

std::vector<Component> scan_kernel_version(std::span<const uint8_t> data,
Expand Down Expand Up @@ -223,6 +238,28 @@ std::vector<Component> scan_binver(std::span<const uint8_t> data, const std::str
c.origin_path = origin_path;
c.confidence = 70; // weaker than a package-DB entry
c.evidence = "binary version banner: " + m[0].str();

// Vendor-fork detection: read the characters that continued past the base
// version. A clean continuation is ".<digits>" (already folded into the
// version); anything with a letter/'_'/'-'/'+' is a vendor tag. Keep the
// full on-disk string in the evidence and label the fork.
size_t tail_pos = off + static_cast<size_t>(m.position(0)) + static_cast<size_t>(m.length(0));
auto is_vertail = [](uint8_t ch) {
return std::isalnum(ch) || ch == '.' || ch == '_' || ch == '-' || ch == '+' || ch == '~';
};
size_t t = tail_pos;
while (t < end && is_vertail(data[t])) ++t;
if (t > tail_pos) {
std::string tail(reinterpret_cast<const char*>(data.data()) + tail_pos, t - tail_pos);
bool non_numeric = false;
for (char ch : tail)
if (ch != '.' && !(ch >= '0' && ch <= '9')) { non_numeric = true; break; }
if (non_numeric) {
c.fork = vendor_fork_label(tail);
c.evidence = "binary version banner: " + m[0].str() + tail; // full string
}
}

out.push_back(std::move(c));
return true;
});
Expand Down
7 changes: 7 additions & 0 deletions src/component.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ struct Component {

uint8_t confidence = 0; // 0-100
std::string evidence; // short reason, e.g. "dpkg status: install ok installed"

// A vendor SDK fork label (e.g. "Realtek SDK") when the version string carried
// a fork suffix that was stripped to a base semver (e.g. "0.8.x_rtw_r24647" ->
// "0.8"). Empty for a clean upstream version. The CVE join uses this to
// down-rank unbounded-range matches, since a fork's feature/backport state is
// unknown (a 0.8.x_rtw hostapd predates the SAE code some CVEs live in).
std::string fork;
};

} // namespace ft
14 changes: 14 additions & 0 deletions src/cve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ double cvss_base_score(const std::string& vector) {
// vector.
bool cve_is_high_signal(const CveMatch& m) {
if (m.kev) return true; // exploited in the wild
// A vendor-fork component matched only by an unbounded-below range: we cannot
// confirm the fork's base carries the vulnerable code (e.g. a 0.8.x_rtw hostapd
// predates SAE), so drop it from the default view. Still listed under
// --component-cves-all with the "verify" basis. KEV above overrides this.
if (m.fork && m.unbounded_below) return false;
double score = cvss_base_score(m.severity);
if (score >= kHighSignalCvss) return true; // Critical, any shape
if (score < kHighFloorCvss) return false; // hard floor: High/Critical only
Expand Down Expand Up @@ -388,6 +393,15 @@ std::vector<CveMatch> nvd_join(const NvdDb& db, const std::vector<Component>& co
m.component_purl = c.purl;
m.severity = v.cvss;
m.basis = "nvd-cpe-range (" + p.vendor + ":" + p.product + ")";
// A range with no lower bound (no exact version, no start) matches every
// version below the ceiling -- including ones that predate the vulnerable
// code. On a vendor fork (stripped to a base semver) that is a likely
// false match, so flag both and annotate the basis for the down-rank.
m.unbounded_below =
v.version.empty() && v.start_incl.empty() && v.start_excl.empty();
m.fork = !c.fork.empty();
if (m.fork && m.unbounded_below)
m.basis += " [" + c.fork + "; range lower-bound unknown -- verify]";
out.push_back(std::move(m));
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/cve.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ struct CveMatch {
std::string summary;
bool kev = false; // on the CISA Known-Exploited catalog (annotation only)
double epss = -1.0; // EPSS exploit-probability (0..1), -1 if unknown
bool fork = false; // component is a vendor SDK fork (Component::fork set)
bool unbounded_below = false; // matched an affected range with no lower bound
};

// Canonicalize a vuln id to its CVE form when one is embedded ("DEBIAN-CVE-2021-
Expand Down Expand Up @@ -102,6 +104,9 @@ double cvss_base_score(const std::string& vector);
// The default human CVE view is gated to foothold-worthy findings for a manual
// pentester; the JSON view stays complete. A component CVE is "high-signal" if:
// - it is on the CISA KEV catalog (exploited in the wild) -- unconditional; OR
// - (it is NOT a vendor-fork component matched only by an unbounded-below range:
// such a match cannot be confirmed to apply -- a fork's base may predate the
// vulnerable code -- so it is demoted to the --component-cves-all list); AND
// - its CVSS base score is >= 9.0 (Critical) -- any shape; OR
// - it clears a hard High/Critical floor (base score >= 7.0) AND has EPSS
// traction (>= 0.10) AND is low-complexity (AC:L) AND EITHER
Expand Down
1 change: 1 addition & 0 deletions src/json.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ void emit_component(std::string& o, const Component& c) {
if (!c.arch.empty()) kv_str(o, "arch", c.arch, first);
if (!c.license.empty()) kv_str(o, "license", c.license, first);
kv_str(o, "source", c.source, first);
if (!c.fork.empty()) kv_str(o, "fork", c.fork, first);
kv_str(o, "origin_path", c.origin_path, first);
kv_num(o, "confidence", c.confidence, first);
kv_str(o, "evidence", c.evidence, first);
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/unit_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,22 @@ static void test_binver() {
CHECK(ver_of("wpa_supplicant", "wpa_supplicant v2.10_ATBM_0.2_") == "2.10");
CHECK(ver_of("wpa_supplicant", "wpa_supplicant v0.8.x_rtw_r24") == "0.8");
CHECK(ver_of("wpa_supplicant", "wpa_supplicant v2.10-devel") == "2.10");

// Vendor-fork detection: the stripped suffix sets Component::fork (labeled for
// the known IoT SDKs) and the evidence keeps the full on-disk string; a clean
// upstream version has no fork.
auto comp_of = [](const std::string& name, const std::string& body) -> ft::Component {
std::string e = std::string("\x7f\x45\x4c\x46", 4) + " " + body + " end";
for (const auto& c : binver(e))
if (c.name == name) return c;
return {};
};
auto rtw = comp_of("hostapd", "hostapd v0.8.x_rtw_r24647.20171025");
CHECK(rtw.version == "0.8" && rtw.fork == "Realtek SDK");
CHECK(rtw.evidence.find("0.8.x_rtw_r24647.20171025") != std::string::npos); // full string
CHECK(comp_of("wpa_supplicant", "wpa_supplicant v2.10_ATBM_0.2_").fork == "AltoBeam SDK");
CHECK(comp_of("hostapd", "hostapd v2.10-devel").fork == "vendor fork");
CHECK(comp_of("hostapd", "hostapd v2.10").fork.empty()); // clean upstream -> no fork
}

// ---------------------------------------------------------------- u-boot banner
Expand Down Expand Up @@ -1255,6 +1271,25 @@ static void test_cvss_gate() {
CHECK(ft::cve_is_high_signal(mk("AV:N/AC:L/Au:N/C:P/I:P/A:P", false, 0.20)));
// v2 Medium (local, 1.2) -> hidden by the floor even with high EPSS.
CHECK(!ft::cve_is_high_signal(mk("AV:L/AC:H/Au:N/C:P/I:N/A:N", false, 0.90)));

// --- vendor-fork + unbounded-below range: demoted (can't confirm the fork's
// base carries the vulnerable code). A Critical shape that would normally
// show is dropped when fork && unbounded_below; either flag alone keeps it.
auto crit = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"; // 9.8
ft::CveMatch f = mk(crit, false, 0.0);
f.fork = true;
f.unbounded_below = true;
CHECK(!ft::cve_is_high_signal(f)); // fork + unbounded -> demoted
f.unbounded_below = false;
CHECK(ft::cve_is_high_signal(f)); // fork but bounded range -> kept
ft::CveMatch u = mk(crit, false, 0.0);
u.unbounded_below = true; // unbounded but not a fork -> kept
CHECK(ft::cve_is_high_signal(u));
// KEV overrides the fork demotion (exploited in the wild).
ft::CveMatch k = mk(crit, true, 0.0);
k.fork = true;
k.unbounded_below = true;
CHECK(ft::cve_is_high_signal(k));
}

// ---------------------------------------------------------------- nvd/cpe join
Expand Down
Loading