Exploitability Triage
A CVSS score is a property of a vulnerability in the abstract. Risk is a property of your deployment. The scanner that prints "CRITICAL 9.8" knows the first and nothing about the second: whether the vulnerable function is ever called, whether an attacker can steer input into it, whether the exploit's preconditions hold behind your config, your WAF, your auth. A queue of 4,000 criticals triaged by the number the tool printed is not triage — it is the tool's ignorance, re-sorted.
The finding is not "we have CVE-X." The finding is "the vulnerable path is reachable, reached by attacker-controlled input, and its preconditions hold here" — or it is "we have CVE-X and it is not exposed, here is why." Both are results. The score alone is neither.
Two symmetric errors this kills:
- Severity inflation. Patching the unreachable 9.8 (a vulnerable function that no code path ever calls, in a dependency loaded but never exercised) while the 5.3 on the internet-facing authentication path goes to the back of the queue because the number was smaller. The attacker sorts by exposure; a defender who sorts by CVSS is optimizing a different problem.
- Reachability blindness. Declaring the app vulnerable because the installed version matches the advisory, without ever checking that the code shipping in that version even contains — let alone calls — the vulnerable routine. A version match is a candidate, not a confirmation.
Composes with the library:
- beyond-the-sink — the scanner's keyword/version map is a lead, not the territory; confirm reachability in the real call graph
- dependency-provenance — "is the vulnerable dependency actually loaded, at that version, with that code" is answered here, not assumed
- red-team-auditing — "exploitable — confirmed" is a claim earned by demonstrating reach, never asserted from a score
- attack-surface-triage — exposure = reachability × attacker access; that ranking lives there and feeds this
- honest-degradation — unproven reachability is a WARN/candidate, never a silent PASS or a confident CRITICAL
Step 0 — Separate the vulnerability from the exposure
Two different objects, and conflating them is the whole disease:
- The vulnerability — a property of the code: this function mishandles this input. Fixed by the vendor, described by the CVE, scored by CVSS. Real regardless of you.
- The exposure — a property of this deployment: whether that code is present, reachable, reached by an attacker, and unblocked by your controls. This is what you are actually triaging, and the CVE says almost nothing about it.
Write both. "CVE-2024-X: deserialization in libfoo ≤2.3 (the vulnerability). We run
2.1; the vulnerable readObject path is called only by the admin import feature,
which is authenticated and internal-only (the exposure)." Now you can decide.
Step 1 — Reachability: is the vulnerable code in the call graph at all
The first filter, and the one the scanner cannot do. Does a path exist from any entry point to the vulnerable function?
- If the vulnerable routine is never called — dead code, an unused feature, a code path behind a flag that is off — the vulnerability is present and not reachable, and its practical exposure is near zero regardless of CVSS. Say so explicitly.
- Do not confuse "the library is a dependency" with "the vulnerable function runs."
Most libraries ship far more code than any one app exercises (link
dependency-provenance: loaded ≠ exercised). - This is the same discipline as
beyond-the-sink: the version-match grep is where you start, not where you conclude.
Unproven reachability is not "safe." It is a candidate awaiting evidence — mark it WARN, not PASS.
Step 2 — Reached: does attacker-controlled input get there
Reachable in principle is not reached in practice. Trace whether an attacker can actually steer tainted input into the vulnerable path:
- Is the input to the vulnerable function attacker-controlled, or is it a fixed internal value / a config file only an admin edits?
- What sits between the entry point and the sink — validation, encoding, a type boundary — that the taint must survive?
- From where can the attacker reach the entry point: unauthenticated internet, authenticated user, internal network, local only? Exposure scales with access.
"Reachable by an internal admin action" and "reachable by an unauthenticated HTTP request" are the same CVSS and different emergencies.
Step 3 — Exploitable: do the preconditions hold behind your controls
An exploit is a conjunction of conditions. Enumerate the ones the advisory or PoC requires, and check each against your reality:
- Non-default configuration the exploit needs (a specific flag, a JNDI lookup enabled, a permissive parser setting) — is it set in yours?
- Mitigations already in the path: a WAF rule, network segmentation, an allow-list, a runtime protection, memory mitigations. Do not over-credit these (WAF rules are bypassable — treat as a WARN reducing severity, not a fix), but do not ignore them.
- Platform/version specifics: the exploit needs a heap layout, an OS, a library version you may not run.
Each precondition that fails to hold here lowers exposure — with the reasoning written down, so a reviewer can challenge it. "Not exploitable because the WAF blocks it" is a weaker claim than "not exploitable because the vulnerable parser mode is disabled by our config"; grade accordingly.
Step 4 — Rank by exposure × impact, and state what you did not check
Sort the queue by the product of demonstrated exposure (Steps 1–3) and impact if
exploited (what the attacker gains — link attack-surface-triage and
assume-breach-modeling for blast radius), not by CVSS:
- Confirmed exposed — reachable, reached, preconditions hold. Patch/mitigate by impact. These are usually a small fraction of the scanner's "criticals."
- Present, not exposed — reachable-but-not-reached, or a precondition fails here. Record why, patch on normal cadence, and re-open if the deployment changes (that "internal-only" admin feature becomes internet-facing next quarter).
- Candidate — reachability unproven — version matches, reach not confirmed. WARN, not CRITICAL and not dismissed; the honest state is "we have not established exposure."
Never let a triage output read as a uniform green or a uniform red. Each verdict carries what was checked and what was assumed, because the next reviewer — and the auditor after the breach — will ask "exploitable against what evidence?" A confident "not exploitable" with no stated basis is exactly the claim that ends careers when the one unchecked precondition turns out to hold.
The one-line test
If your prioritization would not change when the CVSS score were deleted from every row, you are triaging exposure. If deleting the score leaves you with nothing to sort by, you were re-sorting the scanner's ignorance — go establish reachability, because that, not the number, is the finding.