Skip to content

Repository files navigation

Phantom

CI License: MIT Python 3.11+

Attack surface mapping from the terminal. Point Phantom at a host you are allowed to look at and it maps what is reachable: open ports and what is behind them, DNS and subdomains, TLS and HTTP posture, registration data, and the files people leave in a web root. Point it at a domain and it does the same for every host it can find underneath.

No server, no dashboard, no account. Scans go into a SQLite file under your home directory, so you can list them, diff them and export them later.

Only scan what you are authorised to scan. An active scan prompts for confirmation and --passive never sends a packet to the target, but neither of those is a legal defence. Unauthorised scanning is a crime in most jurisdictions.

phantom scan

Install

git clone https://github.com/Xyness/Phantom.git
cd Phantom
pipx install .          # or: pip install -e ".[dev]"

That gives you a working phantom with a pure Python port scanner. If you have Rust, build the fast one too:

cd scanner && cargo build --release

Phantom picks the binary up from scanner/target/release/, from $PATH, or from $PHANTOM_SCANNER. phantom modules tells you which engine you ended up with. The difference matters once you go past a few thousand ports: the Rust scanner streams them through a bounded worker pool, the Python fallback caps itself at 400 sockets so it does not run you out of file descriptors.

Use

phantom scan example.com                       # one host, standard profile
phantom sweep example.com                      # the host, and everything under it
phantom watch example.com --every 6h           # rescan on a schedule, report the changes

phantom scan example.com --passive             # nothing touches the target
phantom scan 10.0.0.5 -p 1-65535 --profile deep
phantom scan example.com -m dns,tls,http       # pick your modules
phantom scan example.com -o report.html        # and write a report

phantom list                                   # what you have scanned
phantom show last --details                    # full findings with fixes
phantom show a3f9 --explain                    # how the score was reached
phantom diff a3f9 b7e1                         # what changed between two runs
phantom export last -f md -o report.md
phantom rm a3f9

Ids are eight hex characters and any unambiguous prefix works. last means the most recent run. Everything that takes an id takes a scan or a sweep.

Profiles

profile ports modules use it for
quick top 100 ports, dns, tls, http a first look, about a minute
standard 1-1024 plus the top 100 everything the default
deep 1-65535 everything, full wordlist, retries when you have time
passive none dns, osint, takeover before you have authorisation

Every profile is a set of defaults and every field stays overridable, so --profile quick -p 1-65535 does exactly what it says.

What it looks for

module mode what it does
ports active TCP connect scan, banner grabbing, service naming
dns passive records, DNSSEC, CAA, wildcards, AXFR, SPF/DMARC/DKIM, subdomains
tls active certificate chain, expiry, key strength, protocol versions, ciphers
http active security headers, cookies, stack fingerprint, CDN and WAF, CORS, methods
exposure active sensitive paths, exposed repositories, secrets, directory listings
scripts active API endpoints and credentials in the JavaScript the page loads

Findings then go through a verification pass (below) that confirms the real ones and flags the false positives. | osint | passive | RDAP, WHOIS, certificate transparency, Wayback Machine | | takeover | passive | dangling CNAMEs pointing at services nobody owns |

A few of these are worth explaining.

Ports. Connect scanning, not SYN scanning: it completes the handshake and shows up in the target's logs, which is the right trade for authorised recon. The scanner listens before it speaks, because SSH, SMTP, FTP and most of the rest announce themselves, and writing first throws the greeting away. If a port stays quiet it gets an HTTP request, since most of what sits on an unrecognised port these days is a web service. Services are named from what they said when they said anything, and from the port number otherwise, and the report tells you which of the two it was.

Exposure. Every path goes through a soft 404 filter first. Plenty of hosts answer 200 to everything, so Phantom asks for two paths that cannot exist, remembers what that looks like, and discards anything matching. On top of that, most paths carry a content check: /.git/HEAD has to start with ref: or a commit hash, /.env has to look like assignments, a .zip that comes back as HTML is the site's error page. When a .git config is readable it gets parsed, and a remote URL with credentials in it is reported as one. Environment files are reported by variable name and never by value.

Takeover. For every subdomain with a CNAME, Phantom checks whether the target still resolves and whether the service behind it answers with its own "this is unclaimed" page. It knows about 40 providers. A dead CNAME to an unknown host is reported as dangling rather than as a takeover, because that is all the evidence supports.

HTTP. Along with the header grading, it computes the favicon hash the way Shodan does, so you can pivot with http.favicon.hash:-1234567890 and find the rest of the estate running the same application.

Scripts. Front end builds ship the map of the back end. This reads the same origin scripts the landing page loads, pulls out the API paths they reference, and flags the ones nobody meant to publish. Credential patterns run in strict mode here, since the broad ones fire on every minified bundle.

Verification

Recon produces leads. A verification pass turns the ones it can into confirmed findings, and flags the ones that do not hold up, so you fix what is real instead of wading through maybes. It runs after the modules, on by default for active scans, and --no-verify turns it off.

It confirms or refutes, it never exploits. The contract every check obeys:

  • Benign requests only. No exploit payload, no injection, no traversal past the file the module already found.
  • No sensitive data leaves the target. A check reads structure, magic bytes, a line count, a content type. An exposed .env is confirmed by counting assignments; the values are never read.
  • Nothing changes. No writes, no deletes, no login attempts.
  • A prefix, not a download. A backup is confirmed from its first 512 bytes, streamed and stopped, even if the server ignores the Range request.

What it does today:

  • Exposed .git goes from "HEAD returned 200" to "HEAD is a valid ref, config parses, index present" (metadata only, nothing reconstructed), or gets refuted when /.git/HEAD is an HTML page behind a catch-all 200.
  • .env files, backups, SQL dumps, Spring Actuator are confirmed by type, or refuted.
  • Version-based CVE matches on distro builds are refuted. nginx/1.18.0 (Ubuntu) gets flagged: the vendor backports security fixes without moving the upstream version, so the range match is unreliable. That is the biggest source of false positives in version-based matching, and dropping it is most of what makes the CVE output trustworthy.

A confirmed finding keeps its full weight in the score; a refuted one drops to possible, so the number reflects what held up. The terminal marks them verified and unconfirmed, and --details prints the evidence. It is a tool for defenders: it tells you what an attacker would find genuinely real on your own surface. It stops at looking.

Sweeps

A root domain is a starting point, not a target. phantom sweep example.com scans the root, takes the names it discovered, and scans those too.

phantom sweep example.com --max-hosts 40
phantom sweep example.com -m ports,http,tls -p 80,443,8080,8443

Two things stop it from being a loop around the scanner. Names that resolve to the same address get one port scan between them, because forty subdomains behind one load balancer is one machine, not forty. And hosts sharing an address are scanned one after another, so a shared origin never takes several scans at once. The zone wide modules, dns, osint and takeover, only run against the root: asking forty subdomains about the same SPF record is noise.

The host budget defaults to 25 and names are sorted before it applies, so admin, staging, vpn and their neighbours go first. phantom show <sweep-id> prints the estate again later, and phantom diff <a> <b> between two sweeps is where this earns its keep:

  hosts appeared   git.example.com, grafana.example.com
  new names        vpn-old.example.com

  git.example.com   +21.0 risk
  +  CRITICAL  exposure  Git repository exposed
  +  HIGH      cve       CVE-2021-22205: Unauthenticated RCE via image parsing
    ports opened  8022

Watching

phantom watch example.com --every 6h
phantom watch example.com --sweep --every 24h --webhook https://hooks.example.com/phantom

Scan, diff against the previous run of the same target, print only when something changed, sleep, repeat. The first run prints in full because there is nothing to compare against; after that a quiet run is one line. --webhook POSTs the diff as JSON when there is one, --runs N stops after N rounds, and Ctrl-C stops it any time.

It is a foreground loop, not a daemon. Put it in tmux, systemd or a container. A tool that forks itself is a tool you have to debug at three in the morning.

Passive mode

--passive means no connection is opened to the target's own infrastructure. What runs: certificate transparency, RDAP, WHOIS over port 43, the Wayback Machine, and record lookups through a recursive resolver. What does not: the port scan, TLS handshakes, HTTP requests, subdomain brute forcing and AXFR attempts.

The one nuance worth stating plainly: resolving a name through a public resolver eventually reaches the target's authoritative nameservers, and the takeover check fetches pages from third party providers such as GitHub Pages. Neither touches the host you named. If your threat model is stricter than that, passive mode is not strict enough for you.

CVE matching

Banners and Server headers are parsed into (product, version), and the version is compared against declared ranges. Two rules keep it honest:

  • A version bounded entry only fires when a version was actually parsed. Server: nginx with no version matches nothing, because nothing is known.
  • Entries with no range are appliance products whose banners never carry a version. Those match on identity and are labelled possible, and their weight in the score is discounted.

The bundled database is phantom/cve/data/cve.json: around 120 hand curated entries across 66 products. The version comparator handles what real services print (9.6p1, 1.1.1w, 8.0.36-0ubuntu0.22.04.1) rather than assuming semver.

A hand curated set rots, so it can be topped up from the NVD, which publishes exactly the shape the matcher wants: a product identified by CPE, and version bounds as data rather than as a regex.

phantom cve status                       # what is loaded, and where it came from
phantom cve sync                         # pull every mapped product
phantom cve sync --products nginx,php --min-cvss 7.0
phantom cve check "SSH-2.0-OpenSSH_9.6p1"   # what would this banner match

Synced entries go to ~/.local/share/phantom/cve.json and take priority over the bundled summary for the same id. The tool stays offline otherwise: nothing else in Phantom reaches the network for CVE data. Without an NVD API key the sync makes one request every 6 seconds, which is slow but polite; --api-key raises the limit tenfold.

None of this replaces a vulnerability scanner. It tells you that the version on the wire falls inside a published range, which is a lead, not a finding.

Scoring

Findings are weighted by severity (critical 10, high 7, medium 4, low 1, info 0), discounted by confidence (confirmed 1.0, likely 0.7, possible 0.4), summed, and squashed onto 0-100 with 100 * (1 - exp(-w/20)). The curve is steep early and flattens out, so a handful of serious findings lands in the eighties and the number saturates instead of running away.

The 20 is a shape constant chosen so the output reads sensibly, not a calibrated one. That is why --explain exists:

  finding                             severity   weight   share
  Environment file exposed            critical     10.0     13%   ▪▪
  Git repository exposed              critical     10.0     13%   ▪▪
  CVE-2024-4577: CGI argument ...     critical      7.0      9%   ▪

  total weight 74.6  =  27.0 critical + 22.8 medium + 16.8 high + 8.0 low
  score  100 x (1 - e^(-74.6/20))  =  97.6

It is not CVSS and does not pretend to be. Most recon findings have no CVE to inherit a base score from.

Reports

-o report.html writes a self contained page with no external requests, which prints to PDF from a browser. -f md gives you something to paste into a ticket, -f csv gives you rows you can concatenate across scans (target and scan id are on every row), and -f json gives you everything, module data included.

phantom scan example.com --json | jq '.findings[] | select(.severity == "critical")'

In a pipeline

phantom scan staging.example.com --yes --quiet --fail-on high

Exit codes: 0 clean, 1 error, 2 findings at or above --fail-on, 130 interrupted. --yes skips the authorisation prompt, which is required in a non interactive session. Interrupting a scan with Ctrl-C keeps whatever it had collected.

Layout

phantom/
  cli.py            command surface
  engine.py         resolve, run the modules in dependency waves, score
  sweep.py          discovery, address grouping, one scan per host
  watch.py          the scheduled rescan loop
  modules/          one file per technique
  verify.py         confirm or refute findings with benign probes
  cve/              product identification, version comparison, the NVD sync
  store.py          SQLite history
  render.py         everything the terminal prints
  export.py         json, markdown, csv, html
  portscan.py       Rust bridge, and the pure Python fallback
scanner/            the Rust TCP scanner

Modules are independent and run concurrently. The only declared dependency is takeover on dns, since it needs the subdomains. There is one soft link on top of that: if nothing answers on 80 or 443, the HTTP modules wait for the port scan and use whatever web port it found, which is how a site on 8099 still gets checked.

Adding a module means dropping a file in phantom/modules/, exposing async def run(ctx) -> ModuleResult, and adding a line to the registry in phantom/modules/__init__.py. tls.py is the smallest complete example.

Tests

./scripts/run-tests.sh          # everything
pytest -q                       # python
cd scanner && cargo test        # rust

338 Python tests and 8 Rust tests, all offline: HTTP is mocked with respx, DNS with a fake resolver, and the scanner tests bind a local socket. The version comparator and the CVE matcher get the most attention, since boundary errors there are silent.

Roadmap

ROADMAP.md. Short version: UDP, adaptive timing, cloud storage checks, and calibrating that scoring constant against something real.

License

MIT, see LICENSE.

About

Terminal attack surface mapper. Scan a host or sweep an estate, verify the findings, watch for changes. Ports, DNS, TLS, HTTP, OSINT, subdomain takeover and version-aware CVE matching, all offline, no dashboard.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages