Ethical Hacking (methodology, lab-only) · lesson 2 of 6
Beginner
3Intermediate
3Also part of Ethical Hacking (methodology, lab-only)
12- + Port Scanning Your Own Lab with nmap: Open, Closed, Filtered from Networking
- + Host Firewalls with nftables: DROP Times Out, REJECT Refuses from Networking
- + Permission Bits, Ownership, and Why passwd Needs setuid from Linux
- + Broken Access Control and IDOR: Fix It in Code, Pin It with a Test from Web Security (OWASP Top 10, fix-side)
- + CSRF: Synchronizer Tokens and SameSite Cookies, Fixed and Tested from Web Security (OWASP Top 10, fix-side)
- + SSRF: Allowlists, Resolved-IP Checks and No Redirects from Web Security (OWASP Top 10, fix-side)
- + Security Misconfiguration: Error Pages, Debug Mode and Security Headers from Web Security (OWASP Top 10, fix-side)
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Authentication Failures: Username Enumeration and Login Rate Limiting from Web Security (OWASP Top 10, fix-side)
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + Password Hashing Work Factors, Measured from Cryptography
- + DAST with an OWASP ZAP Baseline Scan Against Your Own App from DevSecOps (AppSec in the pipeline)
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
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
- Fix F-02’s vector by adding the missing
/A:N, and run the fixedscore_findings.pyagain. What does a cookie without theSecureflag 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.
- The admin API from
score.pyturns 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.
- 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.
- The client removes the setuid bit, changing
chmod 4755tochmod 0755in 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.