Skip to content

Commit afc26b0

Browse files
bandrelclaude
andcommitted
feat: run vnc-title on 5900/5901 and report the disclosed desktop name
vnc-info and realvnc-auth-bypass already ran on the VNC ports; vnc-title completes the set by retrieving the desktop name once either of those paths gets it logged in (security type None, or the RealVNC bypass — vnc-brute is not run, so a name means no credentials were supplied). That yields the identity of the exposed desktop, which the CRITICAL/HIGH findings do not carry, as a LOW "VNC Desktop Name Disclosed" finding. Two supporting changes: - --source-port is now omitted on 5900/5901 during a script pass, via a new _MULTI_SCRIPT_PORTS set that generalises the SMB-only special case. Three portrule scripts each open their own RFB connection; with a fixed source port they collide on one (src, dst) 4-tuple and all but one fail silently, which for vnc-title looks like "no desktop name" rather than an error. - findings.txt renders per-host detail for this title. The grouped renderer collapsed every non-"Service Exposed Externally" finding to one shared description, which would have dropped the desktop name — the entire point of the finding — from the text report. _PER_HOST_DETAIL_TITLES replaces the hardcoded title comparison. The multi-line NSE table is whitespace-collapsed so it stays on a single line. Failure output is filtered: every vnc-title failure path returns stdnse.format_output(false, ...), rendering as "ERROR: ...". 907 tests pass, 100% coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3b28357 commit afc26b0

3 files changed

Lines changed: 130 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,9 +319,9 @@ Inter-scan wait: 29s (target ~256 hosts)
319319

320320
When `script_scan` is enabled, nmap runs targeted NSE scripts against relevant ports. Scripts are chosen based on scan type (External vs Internal):
321321

322-
**External scans** run: `ftp-anon`, `ssh-auth-methods`, `ssh2-enum-algos`, `*-ntlm-info`, `ssl-cert`, `ms-sql-ntlm-info`, `rdp-ntlm-info`, `docker-version`, `snmp-brute`, `snmp-sysdescr`, `ajp-headers`, `x11-access`, `dameware-detect` (custom, 6129), `cucm-detect` (custom, 6970), `ipmi-version`, `ipmi-cipher-zero`, `ipmi-hashdump` (custom, U:623), `ike-version` (U:500), `openvpn-detect` (custom, U:1194/1194), `vnc-info`, `realvnc-auth-bypass` (5900, 5901), `ollama-detect` (custom, 11434), `openai-api-detect` (custom, 1234/1337/3000/8000), `gradio-detect` (custom, 7860), `koboldcpp-detect` (custom, 5001)
322+
**External scans** run: `ftp-anon`, `ssh-auth-methods`, `ssh2-enum-algos`, `*-ntlm-info`, `ssl-cert`, `ms-sql-ntlm-info`, `rdp-ntlm-info`, `docker-version`, `snmp-brute`, `snmp-sysdescr`, `ajp-headers`, `x11-access`, `dameware-detect` (custom, 6129), `cucm-detect` (custom, 6970), `ipmi-version`, `ipmi-cipher-zero`, `ipmi-hashdump` (custom, U:623), `ike-version` (U:500), `openvpn-detect` (custom, U:1194/1194), `vnc-info`, `realvnc-auth-bypass`, `vnc-title` (5900, 5901), `ollama-detect` (custom, 11434), `openai-api-detect` (custom, 1234/1337/3000/8000), `gradio-detect` (custom, 7860), `koboldcpp-detect` (custom, 5001)
323323

324-
**Internal scans** run: `ftp-anon`, `rpcinfo`, `nfs-showmount`, `nfs-ls`, `smb-security-mode`, `smb2-security-mode`, `smb-vuln-ms17-010`, `smb-vuln-ms08-067`, `smb-double-pulsar-backdoor`, `smb-vuln-cve-2017-7494`, `rmi-dumpregistry`, `ms-sql-info`, `docker-version`, `snmp-brute`, `snmp-sysdescr`, `ajp-headers`, `x11-access`, `jdwp-info` (5005), `http-title` (8001), `banner` (61616), `dameware-detect` (custom, 6129), `cucm-detect` (custom, 6970), `nodejs-inspector` (custom, 9229), `kubelet-anon-check` (custom, 10250), `delve-debugger` (custom, 2345), `ipmi-version`, `ipmi-cipher-zero`, `ipmi-hashdump` (custom, U:623), `ike-version` (U:500), `openvpn-detect` (custom, U:1194/1194), `vnc-info`, `realvnc-auth-bypass` (5900, 5901), `ollama-detect` (custom, 11434), `openai-api-detect` (custom, 1234/1337/3000/8000), `gradio-detect` (custom, 7860), `koboldcpp-detect` (custom, 5001)
324+
**Internal scans** run: `ftp-anon`, `rpcinfo`, `nfs-showmount`, `nfs-ls`, `smb-security-mode`, `smb2-security-mode`, `smb-vuln-ms17-010`, `smb-vuln-ms08-067`, `smb-double-pulsar-backdoor`, `smb-vuln-cve-2017-7494`, `rmi-dumpregistry`, `ms-sql-info`, `docker-version`, `snmp-brute`, `snmp-sysdescr`, `ajp-headers`, `x11-access`, `jdwp-info` (5005), `http-title` (8001), `banner` (61616), `dameware-detect` (custom, 6129), `cucm-detect` (custom, 6970), `nodejs-inspector` (custom, 9229), `kubelet-anon-check` (custom, 10250), `delve-debugger` (custom, 2345), `ipmi-version`, `ipmi-cipher-zero`, `ipmi-hashdump` (custom, U:623), `ike-version` (U:500), `openvpn-detect` (custom, U:1194/1194), `vnc-info`, `realvnc-auth-bypass`, `vnc-title` (5900, 5901), `ollama-detect` (custom, 11434), `openai-api-detect` (custom, 1234/1337/3000/8000), `gradio-detect` (custom, 7860), `koboldcpp-detect` (custom, 5001)
325325

326326
Port 9100 (JetDirect raw printing protocol) is included in the Specialized category. Hosts with port 9100 open are identified as printers; SNMP default community string and anonymous FTP findings are suppressed for these hosts to reduce noise.
327327

@@ -382,6 +382,7 @@ After scanning, `generate_findings()` parses all nmap XML results and produces s
382382
| LOW | OpenVPN Service Detected (U:1194/1194; identification only — VPN exposure is expected, no "exposed externally" finding is raised) |
383383
| LOW | SQL Server instance discovered |
384384
| LOW | WSUS Service Detected (8530/8531; identification only — review CVE-2025-59287 patch status) |
385+
| LOW | VNC Desktop Name Disclosed (5900/5901, retrieved by `vnc-title` without credentials) |
385386

386387
On Internal scans, if `ms-sql-info` discovers SQL Server named instances on non-standard ports, nmap is automatically re-run against those ports.
387388

@@ -412,7 +413,7 @@ On External scans, each externally-exposed sensitive service is additionally vul
412413
| U:500 | IKE/IPsec VPN | Aggressive Mode + PSK auto-detected (HIGH); ike-version identifies vendor/mode |
413414
| U:1194, 1194 | OpenVPN | Custom NSE (`openvpn-detect`) completes the P_CONTROL_HARD_RESET handshake to confirm the protocol over UDP or TCP; identification only (LOW) — no version is disclosed pre-auth, and no "exposed externally" finding is raised since VPN reachability is expected. Servers using `tls-auth`/`tls-crypt` silently drop the probe and read as no match |
414415
| U:623 | IPMI / BMC | Cipher Zero auth bypass auto-detected (CRITICAL); RAKP hash disclosure for offline crack (HIGH, CVE-2013-4786) |
415-
| 5900, 5901 | VNC | No-auth auto-detected (CRITICAL); realvnc-auth-bypass checked (HIGH) |
416+
| 5900, 5901 | VNC | No-auth auto-detected (CRITICAL); realvnc-auth-bypass checked (HIGH); desktop name retrieved via vnc-title (LOW) |
416417
| 11434 | Ollama LLM Runtime | Custom NSE (`ollama-detect`) probes `/api/tags` and `/api/version`; unauthenticated access exposes model inventory and full inference API |
417418
| 1234, 1337 | LM Studio / OpenAI-compat API | Custom NSE (`openai-api-detect`) probes `/v1/models`; unauthenticated access exposes models and inference |
418419
| 3000, 8000 | OpenAI-compatible LLM API | Custom NSE (`openai-api-detect`) probes `/v1/models` with three-string fingerprint to avoid false positives on generic web apps |
@@ -441,4 +442,4 @@ On External scans, each externally-exposed sensitive service is additionally vul
441442
- **ActiveMQ (61616)** — [CVE-2023-46604 PoC](https://github.com/X1r0z/ActiveMQ-RCE) · [Rapid7 module](https://www.rapid7.com/db/modules/exploit/multi/misc/apache_activemq_rce_cve_2023_46604)
442443
- **IKE/IPsec (U:500)** — [ike-version NSE](https://nmap.org/nsedoc/scripts/ike-version.html) · ike-scan --aggressive for hash capture · hashcat mode 5300 (IKEv1) / 5400 (IKEv2)
443444
- **IPMI (U:623)** — [US-CERT TA13-207A](https://www.cisa.gov/news-events/alerts/2013/07/26/risks-using-intelligent-platform-management-interface-ipmi) · [hashcat mode 7300](https://hashcat.net/wiki/doku.php?id=hashcat)
444-
- **VNC (5900/5901)** — [CVE-2006-2369](https://nvd.nist.gov/vuln/detail/CVE-2006-2369) · [vnc-info NSE](https://nmap.org/nsedoc/scripts/vnc-info.html)
445+
- **VNC (5900/5901)** — [CVE-2006-2369](https://nvd.nist.gov/vuln/detail/CVE-2006-2369) · [vnc-info NSE](https://nmap.org/nsedoc/scripts/vnc-info.html) · [vnc-title NSE](https://nmap.org/nsedoc/scripts/vnc-title.html)

‎spoonmap.py‎

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1943,7 +1943,7 @@ def _build_nmap_cmd(dest_port, input_file, output_file, source_port,
19431943
dest_port[2:] if 'U:' in dest_port else dest_port,
19441944
'--open', '--randomize-hosts',
19451945
]
1946-
if source_port and dest_port not in _SMB_PORTS:
1946+
if source_port and dest_port not in _MULTI_SCRIPT_PORTS:
19471947
cmd += ['--source-port', source_port]
19481948
cmd += ['-iL', input_file, '-oX', output_file]
19491949
scripts = _get_scripts_for_port(dest_port, target_scan)
@@ -1974,8 +1974,9 @@ def _build_nmap_cmd(dest_port, input_file, output_file, source_port,
19741974
'-Pn', '-p', dest_port,
19751975
'--open', '--randomize-hosts',
19761976
]
1977-
# Skip --source-port for SMB when scripts run to avoid 4-tuple collision
1978-
if source_port and not (script_scan and dest_port in _SMB_PORTS):
1977+
# Skip --source-port on multi-script ports when scripts run, to avoid
1978+
# 4-tuple collision between concurrent NSE connections
1979+
if source_port and not (script_scan and dest_port in _MULTI_SCRIPT_PORTS):
19791980
cmd += ['--source-port', source_port]
19801981

19811982
cmd += ['-iL', input_file, '-oX', output_file]
@@ -2458,8 +2459,8 @@ def lineCount(file):
24582459
'U:500': 'ike-version',
24592460
'U:1194': f'{_DIR}/nse/openvpn-detect.nse',
24602461
'1194': f'{_DIR}/nse/openvpn-detect.nse',
2461-
'5900': 'vnc-info,realvnc-auth-bypass',
2462-
'5901': 'vnc-info,realvnc-auth-bypass',
2462+
'5900': 'vnc-info,realvnc-auth-bypass,vnc-title',
2463+
'5901': 'vnc-info,realvnc-auth-bypass,vnc-title',
24632464
'631': f'{_DIR}/nse/cups-browsed-rce.nse',
24642465
'U:631': f'{_DIR}/nse/cups-browsed-rce.nse',
24652466
'8530': f'{_DIR}/nse/wsus-detect.nse',
@@ -2512,8 +2513,8 @@ def lineCount(file):
25122513
'U:500': 'ike-version',
25132514
'U:1194': f'{_DIR}/nse/openvpn-detect.nse',
25142515
'1194': f'{_DIR}/nse/openvpn-detect.nse',
2515-
'5900': 'vnc-info,realvnc-auth-bypass',
2516-
'5901': 'vnc-info,realvnc-auth-bypass',
2516+
'5900': 'vnc-info,realvnc-auth-bypass,vnc-title',
2517+
'5901': 'vnc-info,realvnc-auth-bypass,vnc-title',
25172518
'631': f'{_DIR}/nse/cups-browsed-rce.nse',
25182519
'U:631': f'{_DIR}/nse/cups-browsed-rce.nse',
25192520
'8530': f'{_DIR}/nse/wsus-detect.nse',
@@ -2528,10 +2529,17 @@ def lineCount(file):
25282529
'8000': f'{_DIR}/nse/openai-api-detect.nse',
25292530
}
25302531

2531-
# Ports that use multiple concurrent NSE scripts; omit --source-port to prevent
2532-
# TCP 4-tuple collision when nsock opens parallel connections to the same target.
25332532
_SMB_PORTS = frozenset({'139', '445'})
25342533

2534+
# Ports whose NSE scripts run concurrently against the same service; omit
2535+
# --source-port for these to prevent TCP 4-tuple collision when nsock opens
2536+
# parallel connections to the same target — with a fixed source port every
2537+
# script connects from the same (src, dst) tuple and all but one fail silently.
2538+
# VNC has three portrule scripts (vnc-info, realvnc-auth-bypass, vnc-title),
2539+
# each opening its own RFB connection; for vnc-title a lost connection reads as
2540+
# "no desktop name" rather than as an error.
2541+
_MULTI_SCRIPT_PORTS = _SMB_PORTS | frozenset({'5900', '5901'})
2542+
25352543
# Windows SMB ports are always co-resident; merge their live hosts after scanning
25362544
# so a missed SYN-ACK on one port does not suppress nmap SMB script checks.
25372545
_SMB_COUPLED_PORTS = ('139', '445')
@@ -3505,6 +3513,23 @@ def port_str_from_fname(fname):
35053513
'The VNC server accepts connections with no password (security type "None"). '
35063514
'Any client can view and control the desktop without credentials.')
35073515

3516+
# ── vnc-title (desktop name via no-auth or bypassed login) ────
3517+
# vnc-title only produces a name once it is actually logged in —
3518+
# via security type None or the realvnc-auth-bypass flaw, since
3519+
# vnc-brute is not run — so any name is corroboration of the
3520+
# CRITICAL/HIGH above plus the identity of the exposed desktop.
3521+
if 'vnc-title' in scripts:
3522+
# Success is a multi-line table (name/geometry/color_depth);
3523+
# every failure path returns stdnse.format_output(false, ...),
3524+
# which renders as "ERROR: ...". Collapse the whitespace so
3525+
# the detail stays on one line in findings.txt.
3526+
out = ' '.join(scripts['vnc-title'].split())
3527+
if out and not out.upper().startswith('ERROR'):
3528+
add('LOW', ip, port_str,
3529+
'VNC Desktop Name Disclosed',
3530+
f'{out[:200]} — read without supplying credentials, '
3531+
'so this desktop session is reachable unauthenticated.')
3532+
35083533
# ── realvnc-auth-bypass (CVE-2006-2369) ──────────────────────
35093534
if 'realvnc-auth-bypass' in scripts:
35103535
out = scripts['realvnc-auth-bypass']
@@ -4240,6 +4265,14 @@ def _write_artifact(path, content):
42404265
return False
42414266

42424267

4268+
# Findings whose detail string differs per host, so findings.txt must print it
4269+
# alongside each host instead of collapsing the group to one description.
4270+
_PER_HOST_DETAIL_TITLES = frozenset({
4271+
'Service Exposed Externally',
4272+
'VNC Desktop Name Disclosed',
4273+
})
4274+
4275+
42434276
def _write_findings_txt(output_path, target_scan, findings):
42444277
today = datetime.date.today()
42454278
lines = [
@@ -4277,9 +4310,10 @@ def _write_findings_txt(output_path, target_scan, findings):
42774310
repro = _FINDING_REPRO.get(title, {})
42784311
lines.append(f' [{title}] port {port_str}')
42794312
lines.append(f' Affected hosts ({len(hosts)}):')
4280-
if title == 'Service Exposed Externally':
4281-
# Detail (exposure label + embedded per-host vuln check) varies per
4282-
# host, so render it inline rather than collapsing to one line.
4313+
if title in _PER_HOST_DETAIL_TITLES:
4314+
# Detail varies per host (exposure label + embedded vuln check;
4315+
# the VNC desktop name), so render it inline rather than
4316+
# collapsing the group to one shared description.
42834317
for h, d in sorted(grp['host_details']):
42844318
lines.append(f'{h} — {d}')
42854319
else:

‎tests/test_spoonmap.py‎

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7472,7 +7472,7 @@ def test_no_wsus_finding_without_script_output(self, nmap_dir):
74727472

74737473

74747474
class TestVNCFindings:
7475-
"""VNC findings from vnc-info and realvnc-auth-bypass scripts."""
7475+
"""VNC findings from vnc-info, realvnc-auth-bypass and vnc-title scripts."""
74767476

74777477
def test_vnc_no_auth_critical(self, nmap_dir):
74787478
"""vnc-info output with security type None -> CRITICAL finding."""
@@ -7532,6 +7532,84 @@ def test_vnc_on_port_5901(self, nmap_dir):
75327532
assert 'CRITICAL' in content
75337533
assert '10.0.2.5' in content
75347534

7535+
def test_vnc_title_output_low_finding(self, nmap_dir):
7536+
"""vnc-title returning a desktop name -> LOW finding carrying the name."""
7537+
xml = _nmap_xml(
7538+
'10.0.2.6', 'tcp', '5900',
7539+
scripts={'vnc-title': '\n name: root\'s X desktop (kiosk01:0)'
7540+
'\n geometry: 1024 x 768\n color_depth: 24'})
7541+
(nmap_dir / 'nse_results' / 'port5900.xml').write_text(xml)
7542+
generate_findings(str(nmap_dir), 'External')
7543+
content = (nmap_dir / 'findings.txt').read_text()
7544+
assert 'VNC Desktop Name Disclosed' in content
7545+
assert 'LOW' in content
7546+
assert '10.0.2.6' in content
7547+
assert 'kiosk01:0' in content
7548+
# Multi-line NSE table must be collapsed to one line in findings.txt
7549+
assert 'color_depth: 24' in content
7550+
for line in content.splitlines():
7551+
if 'kiosk01:0' in line:
7552+
assert 'geometry: 1024 x 768' in line
7553+
7554+
def test_vnc_title_on_port_5901(self, nmap_dir):
7555+
"""vnc-title finding also fires on 5901 and on Internal scans."""
7556+
xml = _nmap_xml(
7557+
'10.0.2.7', 'tcp', '5901',
7558+
scripts={'vnc-title': '\n name: build-agent\n geometry: 800 x 600'})
7559+
(nmap_dir / 'nse_results' / 'port5901.xml').write_text(xml)
7560+
generate_findings(str(nmap_dir), 'Internal')
7561+
content = (nmap_dir / 'findings.txt').read_text()
7562+
assert 'VNC Desktop Name Disclosed' in content
7563+
assert '10.0.2.7' in content
7564+
7565+
def test_vnc_title_blank_output_no_finding(self, nmap_dir):
7566+
"""Whitespace-only vnc-title output -> no finding."""
7567+
xml = _nmap_xml('10.0.2.8', 'tcp', '5900', scripts={'vnc-title': ' \n '})
7568+
(nmap_dir / 'nse_results' / 'port5900.xml').write_text(xml)
7569+
generate_findings(str(nmap_dir), 'External')
7570+
findings_file = nmap_dir / 'findings.txt'
7571+
if findings_file.exists():
7572+
assert 'VNC Desktop Name Disclosed' not in findings_file.read_text()
7573+
7574+
def test_vnc_title_error_output_no_finding(self, nmap_dir):
7575+
"""vnc-title ERROR output (login failed) -> no finding."""
7576+
xml = _nmap_xml(
7577+
'10.0.2.9', 'tcp', '5900',
7578+
scripts={'vnc-title': "ERROR: Couldn't log in: Authentication failed"})
7579+
(nmap_dir / 'nse_results' / 'port5900.xml').write_text(xml)
7580+
generate_findings(str(nmap_dir), 'External')
7581+
findings_file = nmap_dir / 'findings.txt'
7582+
if findings_file.exists():
7583+
assert 'VNC Desktop Name Disclosed' not in findings_file.read_text()
7584+
7585+
7586+
class TestVNCTitleScriptWiring:
7587+
"""vnc-title is mapped to 5900/5901 and does not collide on --source-port."""
7588+
7589+
@pytest.mark.parametrize('port', ['5900', '5901'])
7590+
@pytest.mark.parametrize('scan', ['External', 'Internal'])
7591+
def test_vnc_title_mapped_both_scans(self, port, scan):
7592+
scripts = _get_scripts_for_port(port, scan).split(',')
7593+
assert 'vnc-title' in scripts
7594+
assert 'vnc-info' in scripts
7595+
assert 'realvnc-auth-bypass' in scripts
7596+
7597+
@pytest.mark.parametrize('port', ['5900', '5901'])
7598+
def test_vnc_script_pass_omits_source_port(self, port):
7599+
"""Three concurrent RFB scripts on one port must not share a 4-tuple."""
7600+
cmd = _build_nmap_cmd(port, '/in.txt', '/out.xml', '53',
7601+
script_scan=True, target_scan='External',
7602+
script_only=True)
7603+
assert '--source-port' not in cmd
7604+
assert 'vnc-title' in cmd[cmd.index('--script') + 1]
7605+
7606+
@pytest.mark.parametrize('port', ['5900', '5901'])
7607+
def test_vnc_banner_pass_keeps_source_port(self, port):
7608+
"""Banner pass runs no scripts, so the source port is still usable."""
7609+
cmd = _build_nmap_cmd(port, '/in.txt', '/out.xml', '53',
7610+
script_scan=False, target_scan='External')
7611+
assert '--source-port' in cmd
7612+
75357613

75367614
class TestIKEFindings:
75377615
"""IKE findings from ike-version script on U:500."""

0 commit comments

Comments
 (0)