Continuing my decision model journey with Jev. We know that an Nmap scan tells you what ports are open. It does not tell you which of these hosts matter. You may find a vCenter console, a Jenkins server, and the company blog can all look the same from the outside: TCP 443 or 8443, a TLS certificate, a web page. Typical attack path tools need a distinction before they can do anything useful. After seeing some examples of Jev like decision models being used in cyber security, I thought of building an attack path generation tool based on Nmap scans. In this case, Jev helps in deciding what each host is, and whether each open service should be open from where it was seen. Everything else is kept deterministic. Then it measures whether Jev’s judgment beats the rules.

The attack path generation tool being discussed will be released at a later date. In an internal penetration test, a simple /24 scan is normally launched to see what hosts can reach each other. What it misses are firewalls and host ACLs, and tools end up calling a router hop as an attack step. So, I thought of taking a different approach in my test environment using different vantage points. Results are stored in self explanatory files.
| Vantage | Explanation |
|---|---|
internet.xml | scanned from outside |
dmz.xml | scanned from a host in the DMZ |
users.xml | scanned from an employee workstation |
servers.xml | scanned from a server in the internal server network |
mgmt.xml | scanned from the admins’ management network |
Internal penetration test establishes reachability by reading similar scans and making a verdict that “Port 445 on the domain controller is open in dmz.xml” means anything in the DMZ can reach it, firewalls included. The tool also manages to map each vantage to its subnet, so that it knows which hosts stand where. The graph follows from that:
host A -> host B if B has a port open in the scan taken from A’s zone
internet -> host if the host has a port open in internet.xml
Like the Vulners Nmap script, I take a similar approach and converts each one to CPE 2.3, asks NVD for CVEs that affect it, then checks the CVEs against CISA’s KEV list and FIRST’s EPSS API. The tool also caches the results, since NVD is often slow and rate-limited. The method needs one Nmap scan per place an attacker could stand, plus a note of which subnet each scan was taken from:
nmap -sV -sC -oX internet.xml <your public ranges> # from outside
nmap -sV -sC -oX dmz.xml <internal ranges> # from a DMZ host, and so on
-sV is what gives you product names and CPEs. Without it there’s nothing for the CVE lookups or the model to work with. Always only scan networks you’re authorized to test!
Each edge then gets an effort score from the easiest service it crosses based on rules similar to:
| Effort | When |
|---|---|
| 1 | a CVE on CISA’s KEV list |
| 2 | a CVE with EPSS ≥ 0.1 |
| 3 | any other known CVE |
| 5 | a login or admin service (SSH, RDP, SMB, database ports): credential attacks |
| 8 | anything else |
Attack paths are least-effort routes from the internet to each crown jewel. The tool does not multiply probabilities along the path. EPSS is the probability that a CVE gets exploited somewhere in the next 30 days. It isn’t the chance this hop falls, and multiplying it with other numbers gives you precision you don’t have. Effort is a ranking, and it simply stays so.
I then use NetworkX to lazily compute these attack paths. shortest_simple_paths is used as a generator. Listing every path and then keeping three blows up on any well-connected network, so the tool takes the first few and stops.
Using the Decision Model: Jev
I use Jev to make the following decisions:
- Host classification: A decision is made over 15 roles: domain controller, database, CI/CD, virtualization console, out-of-band management (iDRAC, iLO), backup server, secrets manager, file server, mail, VPN gateway, network device, monitoring, web server, workstation, printer or IoT. It’s asked twice in the same call, the second time with the options reversed. Seven of these roles are crown jewels.
- Host reachability: It decides if a particular host must be reachable from here? Using one
Noulper open port, per vantage and answers questions like “Should the service on port 8443 of this host be reachable from the public internet?”
What Jev sees, per host:
{"hostname": "build-ext.corp.example",
"services": [{"port": 22, "service": "ssh", "product": "OpenSSH"},
{"port": 8443, "service": "https", "product": "Jetty",
"http_title": "Dashboard [Jenkins]", "tls_name": "build-ext.corp.example"}]}
No IP addresses or CPEs and no version numbers are sent to Jev. Version identification tells you what services are not patched, and the CVE lookup already uses them locally. Every banner is clipped to 200 characters. We know that banners are written by whoever runs the host and hence, they are an untrusted input.
If Jev’s confidence is under 0.7, or its answer changes when the options are reversed, the host is “uncertain”, and uncertain hosts are treated as crown jewels. If the API fails, the host is uncertain and every exposure is flagged by the tool. For the attack paths, a host is a crown jewel if the keyword rules say so or Jev does. The model can add crown jewels the rules missed, but it can’t remove one the rules found. The eval scores Jev alone and this layered version separately.
This is my test network:
- Org A (53 hosts): The network I wrote the rules for.
- Org B (46 hosts): Comprising of different products such as Argo CD, Proxmox, Rubrik, CyberArk, XClarity, Ivanti, MikroTik and others. A few Microsoft Windows laptops and site-coded servers also reside here.
The two networks have deliberate weaknesses such as a domain controller’s SMB reachable from the DMZ or an iDRAC reachable from the office network. The roles and the “should this be reachable” labels are simulated based on my judgment in this test environment. These are my results:
- Org B crown jewels: Jev finds at least 14 of 17, with no more than 5 false alarms (non-crown hosts called crown jewels, including “uncertain”).
- Org A: Jev finds at least 14 of 16. It doesn’t need to beat rules written for that network, only to stay close.
- Exposure: on both orgs together, Jev catches at least as many mis-configurations as the port rules (17 of 29) with half their false flags or fewer (58 of 311).
This is the flow diagram for the tool:

Jev Run Results
Live run, jev-1.13.0, NVD/KEV/EPSS lookups on:
| Org A | Org B | |
|---|---|---|
| Roles right, port rules | 25 / 53 | 14 / 46 |
| Roles right, keyword rules | 51 / 53 | 16 / 46 |
| Roles right, Jev | 37 / 53 | 41 / 46 |
| Jev “uncertain” (treated as crown jewels) | 13 | 1 |
| Crown jewels found, keyword rules | 16 / 16 | 5 / 17 |
| Crown jewels found, Jev alone | 16 / 16 | 15 / 17 |
| False alarms, Jev alone | 13 | 1 |
| Crown jewels found, rules + Jev | 16 / 16 | 15 / 17 |
| False alarms, rules + Jev | 13 | 1 |
| Misconfigs caught, port rules / Jev | 13 / 18, 9 / 18 | 4 / 11, 5 / 11 |
| False flags, port rules / Jev | 75 / 185, 29 / 185 | 42 / 126, 13 / 126 |
| Calls / input tokens / cost | 130 / 106,566 / $0.0045 | 110 / 87,390 / $0.0037 |
| Latency p50 / p95 | 102 ms / 197 ms | 105 ms / 195 ms |
The odd result in my tests is that Jev did better on the network it had never seen than on the one my rules were built for. Org A has 20 workstations named ws-001 and so on with nearly identical services, and those are where the uncertainty piled up. Org B’s laptops had Windows default names and Jev was sure about them. I don’t know why from this data. It’s one run per network, and the reversed-options check flags a host if the two answers disagree. I will recreate the setup and run these tests again.
What the paths look like
This one came straight from the org A run:
{"target": "10.30.0.10", "effort": 6,
"path": ["vantage:internet", "10.10.1.11", "10.30.0.10"],
"steps": [{"to": "10.10.1.11", "effort": 1, "why": "known exploited (CISA KEV): CVE-2021-41773"},
{"to": "10.30.0.10", "effort": 5, "why": "login or admin service on 3389 (credential attack)"}]}
The internet to the portal’s Apache 2.4.49, whose path traversal CVE-2021-41773 is in KEV, then from the DMZ to the domain controller’s RDP through one of the planted firewall holes.
Org A produced 87 paths to 29 targets. Two things in that list are wrong, and the tool should own them:
- 39 of the 87 paths end at workstations. They’re the “uncertain” hosts. On a real network that’s noise a reviewer will learn to skip, which is worse than no list.
- 56 of the 87 paths start with CVE-2023-44487 on the nginx web server, scored effort 1 because it’s in KEV. That’s HTTP/2 Rapid Reset, a denial-of-service bug. It knocks a server over. It doesn’t get you onto it. CVE-2022-1552 on the PostgreSQL server (effort 2 from EPSS) needs an account that can already create objects in the database. KEV and EPSS say a CVE gets exploited. They don’t say it gives you a foothold from the network, and my effort table treats them as if they did.
Where I want to improve next is – effort is a ranking of where to look first. A KEV CVE on a reachable service still needs the right version, config and conditions. As my results show, a KEV denial-of-service bug currently scores as an easy way in. Filtering on what a CVE gives an attacker (remote code execution or authentication bypass versus denial of service) is the fix I’d make first ammend.
Leave a Reply
You must be logged in to post a comment.