Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 11 min read

๐Ÿ“ CVSS 4.0 for Bug Reports: Score the Impact You Actually Proved

Most severity arguments over a bug report are not really about the bug. They are about the gap between what the reporter showed and what the reporter scored. The proof says "I read another user's invoice". The score says

๐Ÿ“ CVSS 4.0 for Bug Reports: Score the Impact You Actually Proved

Most severity arguments over a bug report are not really about the bug. They are about the gap between what the reporter showed and what the reporter scored. The proof says "I read another user's invoice". The score says Critical, because "this could lead to full account takeover". The triager rescores it, the reporter feels robbed, and both sides lose a week.

CVSS 4.0 has a rule for this, and it is the most useful sentence in the spec for anyone who writes reports. In short: score the impact you are confident an attacker can actually achieve, and score the end state, not the scariest thing you can imagine. That turns severity from a debate into a checklist. Every metric value should point at a piece of evidence in your report.

This post walks through the CVSS 4.0 base metrics from a web and API tester's point of view, shows how the same stand-in bug scores anywhere from 9.3 to 5.3 depending on what you proved, and ends with a report block you can copy. Everything here is about findings on systems you own or are authorized to test.

What changed in CVSS 4.0, in one minute

FIRST published CVSS v4.0 on 1 November 2023. If you learned on v3.1, these are the changes that affect a web report:

  • Scope is gone. Instead of the S:U / S:C switch, impact is split into two sets: the vulnerable system (VC, VI, VA) and subsequent systems (SC, SI, SA), the things outside it that get hurt.
  • New metric: Attack Requirements (AT). Conditions that come from how the target is deployed or runs, such as having to win a race, now live here instead of inside Attack Complexity.
  • User Interaction has three values: None, Passive and Active, instead of None and Required.
  • Temporal became Threat, and only Exploit Maturity (E) is left in it.
  • Scores get labels. A number computed only from base metrics is CVSS-B. Add threat data and it's CVSS-BT, environmental data CVSS-BE, both CVSS-BTE.

The User Guide is also blunt about something reporters tend to forget: a base score measures severity, not risk. And the spec makes threat and environment the consumer's job, which is why vendors and the NVD usually publish CVSS-B only. As a reporter, you are scoring the base metrics. Do that well and leave the rest to the program.

The base metrics, from a web tester's chair

Exploitability: how the attack happens

  • Attack Vector (AV): for anything you reach over HTTP from the internet, N. A is for attacks limited to a local network segment or similar adjacent domain.
  • Attack Complexity (AC): only about security mechanisms you must evade or defeat, such as exploit mitigations or a target-specific secret you have to obtain first. The spec assumes the attacker knows the target's general setup. If your exploit works every time, it is L, however clever it took to find.
  • Attack Requirements (AT): deployment or runtime conditions outside the attacker's control. Racing a window, or needing to be on the network path between victim and server, is P. Most IDOR, injection and XSS bugs are N.
  • Privileges Required (PR): what you needed before the attack. If the request needs a normal user session, that's usually L; the spec says how the attacker got the account (a free trial, for example) is outside this metric. It also notes that a self-service account generally isn't a privilege requirement when the attacker can grant themselves privileges as part of the attack, so if you think that applies, say why in the report. The spec's guidance puts reflected XSS and CSRF at PR:N, because the attacker needs no account; the victim's session does the work.
  • User Interaction (UI): N if no one but you is involved. P (Passive) when the victim only has to use the site normally: viewing a page with a stored payload, or most CSRF. A (Active) when they must do something deliberate, such as clicking your crafted link for reflected XSS or pasting a string for self-XSS.

Impact: what you actually got

The spec gives three rules that settle most arguments:

  1. Constrain impact to a reasonable final outcome you are confident is achievable. Not the chain you would like to build next.
  2. Only count what you gained. If you already had read access and the bug gives you write, score integrity and leave confidentiality at None.
  3. Score the end game. If you start with a little data and end with all of it, score the final state.

For confidentiality, H means you get everything in the system, or a smaller piece that does direct, serious damage (an admin password, a private key). L means you get something restricted, but you can't control what, or the amount is limited. Integrity and availability follow the same High / Low / None pattern.

Vulnerable or subsequent system? For web bugs, FIRST's own examples model XSS with the web application as the vulnerable system and the victim's browser as the subsequent system. Their reflected XSS example scores VC:N/VI:N/VA:N/SC:L/SI:L/SA:N. They also show a variation where impacts on the application itself are scored, and they say such impacts should be clearly documented. That is the theme of this whole post: if you claim it, show it.

One bug, four scores

Here is a stand-in finding. On api.example.com, GET /api/v2/invoices/{id} returns any invoice to any logged-in user. The IDs are sequential. Invoices include the customer's name, billing address and line items.

You prove it the safe way: two test accounts you control, alice and bob. Alice creates an invoice; bob requests it by ID and gets it back. A control request for an ID that doesn't exist returns 404, so you know the 200 isn't a catch-all. You touch no real customer's data.

Now score it four ways. These scores come from FIRST's official v4.0 calculator, and I cross-checked each one in NVD's v4.0 calculator.

The same IDOR scored four ways with CVSS 4.0: 9.3 when scored on fear, 8.6 with read and write proven, 7.1 with read proven, 5.3 when the leak is limited

Scored on fear: 9.3 Critical.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
No account needed, full read, write and outage. None of which you showed: you needed a login, you only read, and nothing went down. This is the score that gets rescored, and costs you credibility on the next report too.

Read and write proven: 8.6 High.
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N
Only if you also showed that PUT /api/v2/invoices/{id} from bob changes alice's invoice, and alice sees the change. That is a separate proof with its own request and response in the report.

Read proven: 7.1 High.
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
This is the honest score for the bug as described. PR:L because bob needed an account. VC:H because any invoice is reachable by ID and you choose which one, so the whole data set is exposed. You can defend that in one sentence.

Limited leak: 5.3 Medium.
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
If the endpoint only returned, say, an invoice total and status for a handful of records, and you couldn't pick which, that is VC:L.

The vulnerability is the same in all four rows. What moves the number is evidence. And the honest 7.1 is still High: you don't need to inflate a real bug to be taken seriously.

For comparison, a stored XSS where a low-privileged account plants a payload that runs when another user views the page, with impact shown in the victim's browser, scores CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N, which is 5.1 Medium. FIRST's reflected XSS example also lands at 5.1 in v4.0, down from 6.1 in v3.1.

Map every metric to a line of proof

Before you submit, walk the vector and check that each value is backed by something in the report:

Metric Value you chose What the report must show
AV N The request goes to a public hostname, not an internal one
AC / AT L / N It works every time; no race, no secret you had to obtain
PR N, L or H Which account (if any) sent the request, and its role
UI N, P or A What the victim has to do, or that there is no victim step
VC H, L or N Exactly what data came back, and whether you chose it
VI H, L or N A change you made, and the victim seeing it
VA usually N Only score this if you showed an outage, safely and with permission
SC / SI / SA Which other system was affected, named explicitly

If a row's right-hand column is empty, lower the value or go and get the proof. Detection isn't proof, and a metric you can't back with evidence is detection.

A few mistakes I'd check for every time:

  • PR:N on an endpoint that needs a session. If bob had to log in, the report should say so, and the vector should match.
  • Carrying the v3.1 Scope habit over. There's no S:C to flip anymore. If you claim subsequent-system impact, name the system.
  • Scoring a chain you didn't run. "Combined with a CSRF this becomes..." belongs in the impact discussion, not in the vector, until you've demonstrated the chain.
  • A bare number. FIRST asks that anyone publishing CVSS data give both the score and the vector. A number on its own can't be checked; a vector can.
  • Using E to adjust the score. Exploit Maturity is threat intelligence that the consumer fills in. Its default (Not Defined) is treated as the worst case, Attacked, so a reporter's CVSS-B already assumes the worst.

A report block you can copy

Put this near the top of the report, right under the summary:

Severity: 7.1 High (CVSS-B)
Vector:   CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

AV:N  Public API at api.example.com
AC:L  Works on every attempt; IDs are sequential
AT:N  No race or special deployment condition
PR:L  Requires a normal user session (test account "bob")
UI:N  No victim action needed
VC:H  Any invoice is readable by ID; attacker chooses the record
      (see Request 2 / Response 2: bob reads alice's invoice)
VI:N  Write not tested / PUT returns 403 (see Request 4)
VA:N  No availability impact tested
SC/SI/SA:N  No impact outside the API

One line per metric feels like overkill until the first time a triager agrees with your score without a back-and-forth.

And if you keep vectors in notes or a tracker, a tiny check stops typos before they reach a report. This validates the structure of a v4.0 base vector against the spec's mandatory metrics, allowed values and fixed order. It doesn't compute a score; use the official calculator for that.

import sys

# Base metrics in the order the CVSS v4.0 spec requires, with their allowed values.
BASE = {
    "AV": "NALP", "AC": "LH", "AT": "NP", "PR": "NLH", "UI": "NPA",
    "VC": "HLN", "VI": "HLN", "VA": "HLN", "SC": "HLN", "SI": "HLN", "SA": "HLN",
}

def check_base_vector(vector: str) -> dict:
    prefix, _, rest = vector.partition("/")
    if prefix != "CVSS:4.0":
        raise ValueError("vector must start with CVSS:4.0/")
    pairs = [p.split(":", 1) for p in rest.split("/")]
    names = [name for name, _ in pairs]
    if names != list(BASE):
        raise ValueError(f"expected exactly the base metrics in order {list(BASE)}, got {names}")
    metrics = dict(pairs)
    for name, value in metrics.items():
        if value not in BASE[name]:
            raise ValueError(f"{name}:{value} is not a valid value")
    return metrics

if __name__ == "__main__":
    metrics = check_base_vector(sys.argv[1])
    print("OK, base-only vector (label the score CVSS-B)")
    if metrics["SC"] + metrics["SI"] + metrics["SA"] != "NNN":
        print("Subsequent-system impact claimed: say in the report which system that is")
$ python3 cvss_check.py "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N"
OK, base-only vector (label the score CVSS-B)
Subsequent-system impact claimed: say in the report which system that is

$ python3 cvss_check.py "CVSS:4.0/AV:F/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N"
Traceback (most recent call last):
  ...
ValueError: AV:F is not a valid value

The second vector is one of the spec's own invalid examples. The check only handles base-only vectors on purpose. That's what a reporter should be sending.

Try it yourself

Only test systems you own or are explicitly authorized to test. These labs are built for it:

Paste your vectors into the FIRST CVSS v4.0 calculator and compare with a friend's. Where your scores disagree, the disagreement is almost always about evidence, not about the bug.

Conclusion

CVSS 4.0 is not a severity opinion with a number attached. It's a structured claim, and each metric is a sentence you should be able to prove. Score the end state you reached, label it CVSS-B, ship the vector with the score, and tie every value to a request and response in your report. Your scores will come out a little lower. They'll also stop getting rescored.

Further reading

I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.

๐Ÿ” If this was useful, follow me for more hands-on web & API security write-ups.

๐Ÿ”— Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.