Skip to main content
Ethical Hacking (methodology, lab-only) · lesson 2 of 6
Open the Ethical Hacking (methodology, lab-only) page →

Vulnerability Assessment: Scoring Findings with CVSS v3.1

Score findings with CVSS v3.1 in Python: vector to base score to severity, one metric at a time, the client's context, and a malformed vector.

By SkillByExample · Published

ethical-hacking vulnerability-assessment cvss python docker

A vulnerability assessment finds weaknesses, proves each one with evidence, and scores it so the client knows what to fix first. This lesson does the scoring with the Common Vulnerability Scoring System (CVSS) v3.1, the version most advisories and scanners still publish. You’ll turn a vector string into a score, see which metrics move it, adjust it for the client’s deployment, and catch the bug that makes a broken vector look like a harmless finding. The last step is an assessor’s check run against your own throwaway container.

An assessment stops at evidence: a confirmed vulnerable version, or a misconfiguration you can show. It never exploits the weakness. That needs explicit permission in the rules of engagement, and to practise it legally, use TryHackMe or Hack The Box Academy. To find known CVEs from package versions, see SCA and SBOMs.

The examples use Python 3.12 and the cvss package, pinned:

$ python -m venv .venv && source .venv/bin/activate
$ pip install cvss==3.6

From vector string to severity

A CVSS vector is a list of metric values. AV:N says the attack vector is the network, and C:H/I:H/A:H says confidentiality, integrity and availability impacts are all high. The library turns the vector into a score.

# score.py: one finding, from vector string to severity
from cvss import CVSS3

# An admin API reachable over the network with no login and full read/write.
vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
c = CVSS3(vector)
print("vector:  ", c.clean_vector())
print("base:    ", c.base_score)
print("severity:", c.severities()[0])
$ python score.py
vector:   CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
base:     9.8
severity: Critical

severities() returns three ratings: base, temporal and environmental. Index 0 is the base rating. This finding is 9.8, not 10.0, even though every impact is high. The highest scores need S:C (scope changed), which means the bug lets an attacker affect components beyond the vulnerable one. Put the vector in the report next to the score, never the score alone. The vector is the reasoning, and a reader can check it.

One metric at a time

The score is a formula, so you can watch it react. Change one metric at a time from the 9.8 vector and print the result.

# vary.py: change one metric at a time and watch the base score
from cvss import CVSS3

BASE = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
changes = [("AV:N", "AV:A"), ("AV:N", "AV:L"), ("AV:N", "AV:P"),
           ("PR:N", "PR:L"), ("PR:N", "PR:H"), ("S:U", "S:C"),
           ("C:H/I:H/A:H", "C:H/I:N/A:N"), ("C:H/I:H/A:H", "C:N/I:N/A:N")]

c = CVSS3(BASE)
print(f"{'(as published)':<26} {c.base_score:>4}  {c.severities()[0]}")
for old, new in changes:
    c = CVSS3(BASE.replace(old, new))
    print(f"{old + ' -> ' + new:<26} {c.base_score:>4}  {c.severities()[0]}")
$ python vary.py
(as published)              9.8  Critical
AV:N -> AV:A                8.8  High
AV:N -> AV:L                8.4  High
AV:N -> AV:P                6.8  Medium
PR:N -> PR:L                8.8  High
PR:N -> PR:H                7.2  High
S:U -> S:C                 10.0  Critical
C:H/I:H/A:H -> C:H/I:N/A:N  7.5  High
C:H/I:H/A:H -> C:N/I:N/A:N  0.0  None

The attack vector steps down from network (N) to adjacent network (A), local (L) and physical (P), and each step costs points. Needing a high-privilege account (PR:H) drops the score to 7.2, still High. That’s why “you need to be an admin first” doesn’t make a finding minor. Read-only impact (C:H alone) is 7.5. With no impact at all, the score is 0.0 whatever the other metrics say.

The severity bands are fixed by the specification: 0.1-3.9 Low, 4.0-6.9 Medium, 7.0-8.9 High and 9.0-10.0 Critical. A single metric can move a finding across a band, as AV:P does here. So when two testers disagree about a severity, compare their vectors. The disagreement is always in one of the metrics.

The client’s context

An advisory publishes a base score for everyone. Your client’s deployment may make the same bug more or less serious, and CVSS v3.1 has temporal and environmental metrics for that.

# context.py: one advisory, scored for this client's deployment
from cvss import CVSS3

ADVISORY = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
contexts = {
    "as published": "",
    "internal network only": "/MAV:A",
    "test system, no real data": "/CR:L/IR:L/AR:L",
    "fix released, PoC exists": "/E:P/RL:O/RC:C",
}
for label, extra in contexts.items():
    c = CVSS3(ADVISORY + extra)
    base, temporal, env = c.scores()
    print(f"{label:<26} base {base}  temporal {temporal}  environmental {env} ({c.severities()[2]})")
$ python context.py
as published               base 9.8  temporal 9.8  environmental 9.8 (Critical)
internal network only      base 9.8  temporal 9.8  environmental 8.8 (High)
test system, no real data  base 9.8  temporal 9.8  environmental 8.0 (High)
fix released, PoC exists   base 9.8  temporal 8.8  environmental 8.8 (High)

The base score never changes, because the bug is the same bug. MAV:A (modified attack vector: adjacent) records that this client’s instance is only reachable from the internal network. CR:L/IR:L/AR:L records that the system holds nothing the client values much. The last row uses temporal metrics: a proof of concept exists (E:P), an official fix exists (RL:O), and the report is confirmed (RC:C). The environmental score includes the temporal metrics, so it drops too.

Report the base vector exactly as published, and give any environmental adjustment its own vector and a reason. “We lowered it” without the modified metrics is an opinion. With them, the client can check your reasoning.

A malformed vector that hides a finding

Vectors are copied by hand from advisories and scanner output, and they get truncated. The tempting way to score a list of findings catches every error and moves on.

[
  {"id": "F-01", "title": "Admin API answers without authentication",
   "cvss": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"},
  {"id": "F-02", "title": "Session cookie set without the Secure flag",
   "cvss": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L"},
  {"id": "F-03", "title": "Error pages show stack traces",
   "cvss": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N"},
  {"id": "F-04", "title": "Unexpected setuid binary in the app image",
   "cvss": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"}
]
# score_findings.py: score every finding in findings.json
import json

from cvss import CVSS3

for f in json.load(open("findings.json")):
    try:
        c = CVSS3(f["cvss"])
        score, severity = c.base_score, c.severities()[0]
    except Exception:
        score, severity = 0.0, "None"
    print(f"{f['id']}  {score:>4}  {severity:<8}  {f['title']}")
$ python score_findings.py
F-01   9.8  Critical  Admin API answers without authentication
F-02   0.0  None      Session cookie set without the Secure flag
F-03   5.3  Medium    Error pages show stack traces
F-04   7.8  High      Unexpected setuid binary in the app image

F-02 scores 0.0, None, so it looks like a finding with no impact. It’s really a vector that lost its last metric, /A:N, when it was copied. A report sorted by score puts it at the bottom, and the client never fixes it. except Exception turned a data error into a believable result, which is worse than crashing.

Catch only the library’s own error, collect every bad vector, and refuse to produce scores until they’re fixed:

# score_findings.py: score every finding, and refuse to guess at a bad vector
import json
import sys

from cvss import CVSS3, CVSSError

invalid = []
for f in json.load(open("findings.json")):
    try:
        c = CVSS3(f["cvss"])
    except CVSSError as err:
        invalid.append(f"{f['id']}: {err}")
        continue
    print(f"{f['id']}  {c.base_score:>4}  {c.severities()[0]:<8}  {f['title']}")

if invalid:
    sys.exit("invalid CVSS vectors, fix before reporting:\n  " + "\n  ".join(invalid))
$ python score_findings.py; echo "exit=$?"
F-01   9.8  Critical  Admin API answers without authentication
F-03   5.3  Medium    Error pages show stack traces
F-04   7.8  High      Unexpected setuid binary in the app image
invalid CVSS vectors, fix before reporting:
  F-02: Missing mandatory metrics "A"
exit=1

The error names the finding and the missing metric, and the exit code is 1, so a report build stops here. CVSSError is the base class of the library’s exceptions, including the missing-metric error raised here and the malformed-vector error raised for an unknown value such as A:Q. A genuine bug in your own code, such as a typo in a key name, still crashes loudly instead of turning into a 0.0.

What an assessor checks: setuid binaries in your own image

Scores need evidence behind them. F-04 came from a check every Linux assessment includes: listing setuid binaries. A setuid program runs with its owner’s privileges, usually root’s, so any one the vendor didn’t ship needs a reason to exist. Build a stand-in for the client’s image with one extra setuid binary:

# Dockerfile: a stand-in for the client's app image
FROM debian:12-slim
RUN cp /bin/true /usr/local/bin/backup-helper \
 && chmod 4755 /usr/local/bin/backup-helper

Build it with docker build -t assess-me .. Then list setuid files in the clean base image and in the built image, and compare the two lists:

$ docker run --rm debian:12-slim find / -xdev -perm -4000 -type f | sort > baseline.txt
$ docker run --rm assess-me find / -xdev -perm -4000 -type f | sort > image.txt
$ diff baseline.txt image.txt
8a9
> /usr/local/bin/backup-helper
$ docker run --rm assess-me stat -c '%A %U %n' /usr/local/bin/backup-helper
-rwsr-xr-x root /usr/local/bin/backup-helper

The base image ships eight setuid binaries, among them su, passwd and mount, and the diff leaves only the one the image added. The s in -rwsr-xr-x is the setuid bit, and the owner is root. -perm -4000 matches files with the setuid bit set. -xdev keeps find on the container’s own filesystem, out of /proc.

That’s the finding and its evidence: an unexpected root-owned setuid binary, proven by a diff against the vendor baseline. The vector AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H (7.8, High) is the usual one for local privilege escalation, and the recommended fix is to remove the bit. Whether the binary can actually be abused is a question for an authorized penetration test, not for the assessment. The defensive side of setuid is covered in Linux users and permissions.

Practice

  1. Fix F-02’s vector by adding the missing /A:N, and run the fixed score_findings.py again. What does a cookie without the Secure flag score?
Solution
  {"id": "F-02", "title": "Session cookie set without the Secure flag",
   "cvss": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N"},
$ python score_findings.py; echo "exit=$?"
F-01   9.8  Critical  Admin API answers without authentication
F-02   4.2  Medium    Session cookie set without the Secure flag
F-03   5.3  Medium    Error pages show stack traces
F-04   7.8  High      Unexpected setuid binary in the app image
exit=0

4.2, Medium. The attack needs a victim on a network the attacker can watch (AC:H) and a victim who visits the site (UI:R), and it only leaks or changes a little (C:L/I:L). That’s very different from the 0.0 the broken script reported.

  1. The admin API from score.py turns out to need a logged-in victim to click a crafted link. Change one metric in the vector and score it.
Solution

User interaction goes from none to required, UI:N to UI:R:

vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"
$ python score.py
vector:   CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
base:     8.8
severity: High

One metric moves it from Critical to High. Write the reason for UI:R in the finding’s evidence, because it’s the first thing a reviewer will question.

  1. A teammate adds a finding with a CVSS 4.0 vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N. Add it as F-05 (with F-02 fixed) and run the fixed script. What happens?
Solution
$ python score_findings.py; echo "exit=$?"
F-01   9.8  Critical  Admin API answers without authentication
F-02   4.2  Medium    Session cookie set without the Secure flag
F-03   5.3  Medium    Error pages show stack traces
F-04   7.8  High      Unexpected setuid binary in the app image
invalid CVSS vectors, fix before reporting:
  F-05: Malformed CVSS3 vector "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N" is missing mandatory prefix or uses unsupported CVSS version
exit=1

CVSS3 refuses a 4.0 vector, and the fixed script reports it instead of scoring it 0.0. The package has a separate CVSS4 class (this vector scores 8.7 there), but v4.0 uses different metrics and a different formula. Agree on one version per report, then rescore F-05 in v3.1 so it can be compared with the others.

  1. The client removes the setuid bit, changing chmod 4755 to chmod 0755 in the Dockerfile. Rebuild and rerun the comparison. How do you know the fix worked?
Solution

Rebuild first with docker build -t assess-me ., or the old image still shows the 8a9 line.

$ docker run --rm assess-me find / -xdev -perm -4000 -type f | sort > image.txt
$ diff baseline.txt image.txt; echo "diff exit=$?"
diff exit=0

diff prints nothing and exits 0 when the files match, so the image’s setuid list is the vendor’s list again. Run the same check you used to find the issue. That makes the retest evidence as strong as the original finding.

Frequently Asked Questions

What is a CVSS score?
The Common Vulnerability Scoring System (CVSS) turns a description of a vulnerability into a number from 0.0 to 10.0. The description is a vector string such as CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, where each metric records one fact: how the bug is reached, what it needs, and what it affects. The same vector always gives the same score, which makes findings comparable across testers.
What are the CVSS v3.1 severity ratings?
CVSS v3.1 maps scores to five ratings: 0.0 is None, 0.1 to 3.9 is Low, 4.0 to 6.9 is Medium, 7.0 to 8.9 is High, and 9.0 to 10.0 is Critical. The rating is a label for the number, not a separate judgement. Prioritize by the score and the client's context, not by the label alone.
What is the difference between base, temporal and environmental CVSS scores?
The base score describes the vulnerability itself and is what advisories publish. The temporal score adjusts it for things that change over time, such as whether a fix or a public proof of concept exists. The environmental score adjusts it for one organization's deployment, for example a system that is only reachable from the internal network.
Should I use CVSS v3.1 or CVSS v4.0?
Use whichever version your client and your vulnerability feeds use, and keep one version per report so scores are comparable. CVSS v3.1 is still the version most advisories and scanners publish, and v4.0 is being adopted alongside it. The two use different metrics, so a v4.0 vector can't be scored as v3.1 or the other way round.
Does a vulnerability assessment include exploiting the vulnerability?
No. A vulnerability assessment identifies and scores weaknesses, and it stops at evidence such as a confirmed vulnerable version or a misconfiguration you can show. Exploitation belongs to a penetration test with explicit permission in the rules of engagement. To practise exploitation legally, use TryHackMe or Hack The Box.