Security · python · 43 copies
Subnet Forensic Scanner
Deep forensic subnet scanner for network security assessments. Performs ICMP host discovery, TCP scan across 1000 common ports, OS fingerprinting via TTL analysis, service banner grabbing (SSH/HTTP/FTP/SMTP), SMB signing check, RDP/Telnet detection, SNMP default community probing, TLS certificate inspection, and per-host risk scoring (HIGH/MEDIUM/LOW). Exports findings to JSON and CSV. For authorized testing only.
- Linux
- macOS
- Windows
pip install "requests>=2.28" "cryptography>=41.0"#!/usr/bin/env python3
"""
Subnet Forensic Scanner
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/subnet-scanner
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "requests>=2.28" "cryptography>=41.0"
"""
import argparse, csv, json, socket, ssl, struct, subprocess, sys, time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from ipaddress import ip_network
# Windows consoles default to a legacy code page (cp1252), which cannot encode
# the arrows, box-drawing and check-mark characters used in the output below.
# Printing one raises UnicodeEncodeError and kills the script mid-scan, so make
# stdout UTF-8 and degrade unencodable characters instead of crashing.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass # already wrapped, or not a real stream (piped/redirected)
TOP_100 = [21,22,23,25,53,80,110,111,135,139,143,161,443,445,993,995,
1723,3306,3389,5900,8080,8443,8888,10000]
TOP_1000 = TOP_100 + [20,26,37,69,79,88,106,109,113,119,123,137,138,179,199,
389,427,444,465,513,514,515,543,544,548,554,587,631,646,873,990,
1025,1026,1027,1028,1029,1110,1433,1720,1900,2000,2001,2049,2121,
2717,3000,3128,3268,3269,3986,4899,5000,5009,5051,5060,5101,5190,
5357,5432,5631,5666,5800,6000,6001,6646,7070,8000,8008,8009,8081,
9100,9999,32768,49152,49153,49154,49155,49156,49157]
SERVICE_MAP = {
21:"FTP",22:"SSH",23:"Telnet",25:"SMTP",53:"DNS",80:"HTTP",110:"POP3",
135:"MSRPC",139:"NetBIOS",143:"IMAP",161:"SNMP",389:"LDAP",443:"HTTPS",
445:"SMB",465:"SMTPS",587:"SMTP-TLS",993:"IMAPS",995:"POP3S",1433:"MSSQL",
1723:"PPTP",3306:"MySQL",3389:"RDP",5432:"PostgreSQL",5900:"VNC",
8080:"HTTP-ALT",8443:"HTTPS-ALT"
}
DEFAULT_COMMUNITIES = ["public","private","community","admin","cisco","secret"]
def ping_host(host: str, timeout: float = 1.0):
try:
import platform
flag = "-n" if platform.system().lower() == "windows" else "-c"
wflag = "-w" if platform.system().lower() == "windows" else "-W"
result = subprocess.run(
["ping", flag, "1", wflag, str(int(timeout*1000) if platform.system().lower()=="windows" else int(timeout)), host],
capture_output=True, text=True, timeout=timeout+2)
ttl = 0
for part in result.stdout.split():
if part.lower().startswith("ttl="):
try: ttl = int(part.split("=")[1])
except (ValueError, IndexError):
pass # TTL not present in this ping output
return result.returncode == 0, ttl
except Exception:
return False, 0
def fingerprint_os(ttl: int) -> str:
if ttl == 0: return "Unknown"
if ttl <= 64: return "Linux / macOS"
if ttl <= 128: return "Windows"
return "Cisco IOS / Network device"
def discover_hosts(subnet: str, timeout: float = 1.0, args_verbose: bool = False):
hosts_list = list(ip_network(subnet, strict=False).hosts())
live = []
with ThreadPoolExecutor(max_workers=100) as ex:
futures = {ex.submit(ping_host, str(h), timeout): str(h) for h in hosts_list}
for future in as_completed(futures):
host = futures[future]
try:
alive, ttl = future.result()
if alive:
live.append({"host": host, "ttl": ttl, "os_guess": fingerprint_os(ttl)})
except (OSError, ValueError) as exc:
# One unreachable host must not abort the sweep, but a
# bare except here would also swallow Ctrl-C.
if args_verbose:
print(f" ! {host}: {type(exc).__name__}: {exc}")
live.sort(key=lambda x: [int(p) for p in x["host"].split(".")])
return live
def _try_connect(host, port, timeout):
try:
with socket.create_connection((host, port), timeout=timeout): return True
except (OSError, socket.timeout):
return False
def tcp_scan(host, ports, timeout=0.5):
open_ports = []
with ThreadPoolExecutor(max_workers=200) as ex:
futures = {ex.submit(_try_connect, host, p, timeout): p for p in ports}
for f in as_completed(futures):
if f.result(): open_ports.append(futures[f])
return sorted(open_ports)
def grab_banner(host, port, timeout=2.0):
try:
with socket.create_connection((host, port), timeout=timeout) as s:
s.settimeout(timeout)
try:
return s.recv(1024).decode("utf-8", errors="replace").strip()[:200]
except (OSError, socket.timeout):
if port in (80, 8080, 8000):
s.sendall(b"GET / HTTP/1.0\r\nHost: " + host.encode() + b"\r\n\r\n")
resp = s.recv(2048).decode("utf-8", errors="replace")
for line in resp.splitlines():
if line.lower().startswith("server:"):
return line.strip()
return ""
except (OSError, socket.timeout, UnicodeDecodeError):
return ""
def _build_snmp_get(community):
oid = bytes([0x30,0x0d,0x06,0x09,0x2b,0x06,0x01,0x02,0x01,0x01,0x01,0x00,0x05,0x00])
cb = community.encode()
ctlv = bytes([0x04, len(cb)]) + cb
req_id = bytes([0x02,0x01,0x01])
err_s = bytes([0x02,0x01,0x00])
err_i = bytes([0x02,0x01,0x00])
vbl = bytes([0x30, len(oid)]) + oid
pdu_c = req_id + err_s + err_i + vbl
pdu = bytes([0xa0, len(pdu_c)]) + pdu_c
version = bytes([0x02,0x01,0x01])
msg = version + ctlv + pdu
return bytes([0x30, len(msg)]) + msg
def check_snmp(host, timeout=2.0):
for comm in DEFAULT_COMMUNITIES:
try:
pkt = _build_snmp_get(comm)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(timeout)
s.sendto(pkt, (host, 161))
data, _ = s.recvfrom(4096)
s.close()
if data:
return {"open": True, "community": comm, "version": "v2c"}
except (OSError, socket.timeout, struct.error):
pass # this community string did not answer
return {"open": False, "community": None, "version": None}
def check_smb(host, timeout=3.0):
result = {"open": False, "signing_required": None}
try:
with socket.create_connection((host, 445), timeout=timeout) as s:
result["open"] = True
pkt = (b"\x00\x00\x00\x2f\xffSMB\x72" + b"\x00"*19 +
b"\x31\x00\x02NT LM 0.12\x00\x02SMB 2.002\x00")
s.sendall(pkt)
resp = s.recv(4096)
if len(resp) > 39:
result["signing_required"] = bool(resp[39] & 0x08)
except (OSError, socket.timeout, struct.error, IndexError):
pass # SMB not reachable or returned a short packet
return result
def grab_tls_cert(host, port=443, timeout=5.0):
result = {"cn": None, "expiry": None, "days_left": None, "tls_version": None}
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, port), timeout=timeout) as raw:
with ctx.wrap_socket(raw, server_hostname=host) as tls:
cert = tls.getpeercert()
result["tls_version"] = tls.version()
if cert:
subj = dict(x[0] for x in cert.get("subject", []))
result["cn"] = subj.get("commonName", "")
not_after = cert.get("notAfter", "")
if not_after:
exp = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
result["expiry"] = exp.strftime("%Y-%m-%d")
result["days_left"] = (exp - datetime.now(timezone.utc)).days
except (OSError, socket.timeout, ssl.SSLError, ValueError):
pass # no TLS on this port
return result
def score_host(findings):
ports = set(findings.get("open_ports", []))
snmp = findings.get("snmp", {})
smb = findings.get("smb", {})
critical = 0
medium = 0
# Weights are chosen so a host's risk score matches the severity the
# per-finding output prints for it. They disagreed before: SMB signing
# disabled printed "HIGH" but scored MEDIUM, and the summary count that
# drives the exit code reported 0 HIGH.
if 23 in ports: critical += 2 # telnet, plaintext creds
if snmp.get("open") and snmp.get("community"): critical += 2 # default community
if smb.get("open") and smb.get("signing_required") is False:
critical += 2 # NTLM relay (PetitPotam)
if 3389 in ports: critical += 1 # RDP exposed
if 5900 in ports: critical += 1 # VNC exposed
if ports & {80, 8080, 110, 143, 25}: medium += 1 # plaintext services
if critical >= 2: return "HIGH"
if critical == 1 or medium >= 2: return "MEDIUM"
return "LOW"
def scan_host_full(host_info, ports, do_snmp, do_banners, timeout):
host = host_info["host"]
open_ports = tcp_scan(host, ports, timeout)
banners = {}
if do_banners:
for p in open_ports:
b = grab_banner(host, p)
if b: banners[str(p)] = b
snmp_r = check_snmp(host) if do_snmp else {}
smb_r = check_smb(host) if 445 in open_ports else {}
tls_r = {}
for p in [443, 8443]:
if p in open_ports:
tls_r[str(p)] = grab_tls_cert(host, p)
findings = {
"host": host, "ttl": host_info["ttl"], "os_guess": host_info["os_guess"],
"open_ports": open_ports, "banners": banners,
"snmp": snmp_r, "smb": smb_r, "tls": tls_r
}
findings["risk_score"] = score_host(findings)
return findings
def print_host_report(f):
print(f"\n[+] {f['host']} ({f['os_guess']})")
for port in f["open_ports"]:
svc = SERVICE_MAP.get(port, "unknown")
banner = f["banners"].get(str(port), "")[:50]
tag = ""
if port == 23: tag = " ✗ CRITICAL: Telnet cleartext!"
elif port == 3389: tag = " ✗ HIGH: RDP exposed — brute-force risk"
elif port == 5900: tag = " ✗ HIGH: VNC exposed"
tls = f["tls"].get(str(port))
tls_tag = ""
if tls and tls.get("cn"):
tls_tag = f" | TLS {tls.get('tls_version','')} | Cert: {tls['cn']} exp {tls.get('expiry','')} ({tls.get('days_left',0)}d)"
print(f" {port}/tcp OPEN {svc:<10} {banner}{tag}{tls_tag}")
smb = f["smb"]
if smb.get("open"):
req = smb.get("signing_required")
if req is True:
print(" SMB signing: ENABLED")
elif req is False:
print(" SMB signing: DISABLED ✗ HIGH: NTLM relay risk (PetitPotam)")
else:
# None means the probe got no parseable response - report that
# honestly rather than claiming a vulnerability we never confirmed.
print(" SMB signing: UNDETERMINED (no parseable SMB response; "
"verify manually with nmap --script smb2-security-mode)")
snmp = f["snmp"]
if snmp.get("open"):
print(f" 161/udp OPEN SNMP {snmp.get('version','')} community='{snmp.get('community','')}' ✗ HIGH: default community!")
risk_map = {"HIGH": "✗ HIGH", "MEDIUM": "⚠ MEDIUM", "LOW": "✓ LOW"}
print(f" Risk Score: {risk_map.get(f['risk_score'], f['risk_score'])}")
def main():
parser = argparse.ArgumentParser(description="Subnet Forensic Scanner v2")
parser.add_argument("subnet")
parser.add_argument("--ports", type=int, default=1000, choices=[100, 1000])
parser.add_argument("--snmp", action="store_true")
parser.add_argument("--banners", action="store_true")
parser.add_argument("--timeout", type=float, default=0.5)
parser.add_argument("--output", type=str, default=None)
parser.add_argument("--verbose", action="store_true",
help="Report per-host errors during discovery instead of "
"skipping them silently. Useful when a sweep finds "
"fewer hosts than expected.")
args = parser.parse_args()
ports = TOP_1000 if args.ports == 1000 else TOP_100
date_str = datetime.now().strftime("%Y-%m-%d")
subnet_safe = args.subnet.replace("/", "_")
prefix = args.output or f"scan_{subnet_safe}_{date_str}"
print(f"[*] Subnet Scanner v2 — Forensic Mode | {args.subnet}")
print("[*] Phase 1: Host Discovery (ICMP)...")
live = discover_hosts(args.subnet, args.timeout,
args_verbose=getattr(args, 'verbose', False))
for h in live:
print(f"[+] {h['host']:<16} UP TTL={h['ttl']:<4} → {h['os_guess']}")
if not live:
print("[!] No hosts found.")
sys.exit(0)
print(f"\n[*] {len(live)} host(s) up. Starting port scan + fingerprinting...")
all_findings = []
with ThreadPoolExecutor(max_workers=10) as ex:
futures = {ex.submit(scan_host_full, h, ports, args.snmp, args.banners, args.timeout): h["host"] for h in live}
for future in as_completed(futures):
try:
r = future.result()
all_findings.append(r)
print_host_report(r)
except Exception as e:
print(f"[!] Error: {e}")
high = sum(1 for f in all_findings if f["risk_score"] == "HIGH")
medium = sum(1 for f in all_findings if f["risk_score"] == "MEDIUM")
low = sum(1 for f in all_findings if f["risk_score"] == "LOW")
print(f"\n[*] Summary: {len(all_findings)} hosts | {high} HIGH risk | {medium} MEDIUM | {low} LOW")
with open(f"{prefix}.json", "w") as fh:
json.dump(all_findings, fh, indent=2)
with open(f"{prefix}.csv", "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["host","os_guess","open_ports","snmp_community","smb_signing","risk_score"])
for h in all_findings:
w.writerow([h["host"], h["os_guess"], " ".join(str(p) for p in h["open_ports"]),
h["snmp"].get("community",""), h["smb"].get("signing_required",""), h["risk_score"]])
print(f"[*] Saved: {prefix}.json + {prefix}.csv")
return 2 if high else (1 if medium else 0)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
SUBNETstrrequiredSubnet in CIDR notation, e.g. 192.168.1.0/24.
--portsintTop ports to scan: 100 or 1000. Default: 1000.
--snmpflagProbe SNMP UDP 161 with default community strings.
--bannersflagGrab service banners from open ports.
--timeoutfloatTCP connect timeout in seconds. Default: 0.5.
--outputstrOutput filename prefix.
--verboseflagReport per-host errors during discovery instead of skipping them silently. Useful when a sweep finds fewer hosts than expected.
What This Solves
You have a subnet and no reliable inventory of what is on it. The spreadsheet is two years old, half the hosts predate everyone currently employed, and nobody can tell you which of them still has Telnet open. This walks the subnet and builds the picture from what actually answers: live hosts ICMP discovery open ports TCP connect across the top 100 or 1000 probable OS TTL analysis what is listening banner grabbing on SSH, HTTP, FTP, SMTP SMB signing on or off RDP and Telnet present or not SNMP default community strings, if you ask TLS certificates inspected on the ports that offer one The output is the list of things to fix, in the order they matter: an SMB server without signing, a Telnet port, an SNMP agent answering to `public`. Use it before an audit, after inheriting a network, or to find out what is really in a VLAN that was supposed to be decommissioned.
Is It Safe to Run - Read This First
**Only scan networks you are authorised to scan.** This is an active scanner. It sends ICMP to every address in the range and opens TCP connections to up to 1000 ports on every host that answers. That is not passive observation - it is traffic that arrives at someone's machine, and on a network you do not own it may be a criminal offence regardless of your intent. Before you run it against anything: * confirm in writing that the range is yours or that you have permission to test it * confirm the range is what you think it is - a typo in a CIDR is how people scan their ISP * tell whoever runs the IDS, unless you want to find out how good it is the hard way It changes nothing on the hosts it scans. It writes nothing to them, it does not attempt authentication, and it does not exploit anything. But it is loud: It will trigger your IDS/IPS. A sequential TCP scan across 1000 ports on a /24 is the textbook signature. Expect alerts, and expect to have to explain them. It can disturb fragile devices. Printers, building-management controllers, medical and industrial equipment, old embedded stacks. A port scan is enough to hang some of them. **Do not point this at an OT or ICS segment** without knowing exactly what is on it. --snmp sends default community strings. `public`, `private` and the rest. That is a credential guess, harmless in intent, and it will be logged as an attack on anything watching. On your own machine it writes the report files under --output.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "requests>=2.28" "cryptography>=41.0" 3. Authorisation for the range. See the section above - this is the step people skip. 4. Network position. The scan is only as good as what your host can reach. A firewall between you and the subnet produces a clean report about a network you did not actually see.
Your First Run
Start with a small range you definitely own - your own lab, or a /29 you can account for host by host: python subnet_scanner.py 192.168.56.0/29 No credentials, no inventory file, nothing to configure. It discovers, scans the top 1000 ports, and prints what it found. Check the result against what you know is there. If a host you can ping by hand does not appear, your scanning position or ICMP filtering is the problem - find that out on a range you understand, not on the /16. Then widen, and add the noisier checks deliberately: python subnet_scanner.py 10.20.30.0/24 --banners python subnet_scanner.py 10.20.30.0/24 --banners --snmp --output audit `--ports 100` is much faster and enough for a first pass on a large range.
What the Output Means
The report is per host: the addresses that answered, the ports open on each, the probable OS from TTL, and the findings. Reading it: The probable OS is a guess. TTL analysis distinguishes "something Windows-like" from "something Unix-like" by the initial TTL. A router in the path that rewrites TTL, or a tuned stack, makes it wrong. Treat it as a hint for prioritising, never as inventory. An open port is a question, not a finding. Port 445 open is normal. Port 445 open **without SMB signing** is a finding, because it is what makes relay attacks work. The script reports the second kind. Telnet and RDP are called out specifically. Telnet because the credentials cross the wire in clear text and there is no version of that which is acceptable. RDP because it is the most-attacked service on the internet and it is routinely exposed by accident. SNMP answering a default community. With --snmp. An agent that answers `public` will hand over the full configuration of most network devices to anyone who asks. This is usually the highest-value finding in a run. A banner is a lead, not proof. `OpenSSH_7.4` tells you what to look up; it does not tell you whether the backported patch is present. Distributions patch without changing the version string, so **do not report a CVE from a banner alone.** A host that does not answer ICMP is not absent. Plenty of hosts drop ping and answer TCP. This scanner discovers with ICMP, so a silent host is missing from the report entirely. That is the main blind spot to know about.
Exit Codes for Cron and CI
0 nothing above low severity 1 medium-severity findings 2 high-severity findings 130 you pressed Ctrl-C Non-zero means findings, not failure. A pipeline can gate on 2 and merely report 1. This is not a script to leave on a schedule without thinking about it - a nightly scan is a nightly IDS event and a nightly load on anything fragile in the range.
Every Flag
SUBNET REQUIRED, positional. CIDR notation: 192.168.1.0/24. Check it twice. --ports N Top ports to scan: 100 or 1000. Default 1000. Use 100 for a first pass on a large range. --snmp Probe UDP 161 with default community strings. This is a credential guess - see the safety section. --banners Grab service banners from open ports. Slower, and much more useful. --timeout F TCP connect timeout in seconds. Default 0.5. Raise it across a WAN - a short timeout on a slow path reports closed ports that are open. --output PREFIX Filename prefix for the report files. --verbose Report per-host errors during discovery instead of skipping them silently. Worth using when the results look thinner than expected.
When Something Fails
ModuleNotFoundError The pip install step was skipped. See Before You Start. No hosts found on a subnet you know is populated Three usual causes, in order of likelihood: ICMP is filtered between you and the range, so discovery finds nothing; you are not routed to that subnet at all; or the hosts themselves drop ping. Run with --verbose, and confirm by pinging one host by hand. Far fewer open ports than expected Raise --timeout. The default of 0.5 seconds is tuned for a LAN; across a WAN or a VPN it reports open ports as closed, which is the most misleading possible failure because the report looks clean. The scan is extremely slow 1000 ports on a /24 is 254,000 connections. Use --ports 100 for the first pass, then scan the interesting hosts in full. The OS guess is wrong TTL analysis is a heuristic and a router in the path can rewrite TTL. Use it to prioritise, not to inventory. Your SIEM lit up Expected. That is what an active scan looks like. Tell them before, not after. A device stopped responding during the scan Some embedded stacks cannot survive a port scan. Stop, note the address, and exclude it. This is why OT and ICS segments are not a place to point this without preparation. You found a CVE from a banner Verify it before reporting it. Distributions backport security fixes without changing the version string, so a banner-derived CVE is a lead for `host-vulnerability-scanner`, which reads the actual installed packages over SSH.