TLDR: my self-orchestrating team of vulnerability hunting agents discovered two issues in CUPS, CVE-2026-34980 and CVE-2026-34990, chainable into unauthenticated remote attacker -> unprivileged RCE -> root file (over)write. See below for the prerequisites, details, and mitigation options.
Intro #
CUPS is the standard way to do printing on Linux and other Unix(-like) systems. It’s been on my mind as a research target ever since doing incident response to Simone Margaritelli’s 2024 unauth’d RCE finding, where he chained several CUPS vulnerabilities into an unauth’d RCE as lp, the default CUPS service user.
CUPS is complex – there are:
- a network-exposable HTTP/IPP print server that accepts untrusted jobs/printer metadata,
- legacy PPD (PostScript Printer Description) files that describe printer capabilities,
- “filters” (helpers to convert jobs into printer-ready data), and
- “backends” that send the print data to “printer-like” destinations (including files, which will matter below).
Most of the filters run as lp, but the scheduler normally runs as root – and so do some of the backends. This makes for a rich attack surface.
Findings #
- CVE-2026-34980: Shared PostScript queue lets anonymous Print-Job requests reach
lpcode execution over the network - CVE-2026-34990: Local print admin token disclosure using temporary printers
At a high level, in the first vulnerability, the attacker:
- Submits a malicious print job to a shared PostScript queue,
- Gets CUPS to treat attacker-controlled text as a trusted queue config by abusing a parsing bug, and
- Gets code execution as the CUPS service user,
lp(vim in the PoC)
And in the second vulnerability, the attacker:
- Uses any* unprivileged local user to set up a localhost listener,
- Creates a local printer object in CUPS, pointing it at the listener above,
- Gets CUPS to authenticate to it and captures the auth token,
- Creates another queue pointing at
file:///...for the target rootful write, - Uses the token to race against CUPS validation logic’s cleanup of the dangerous queue, and
- Writes what they want into the target
file:///...(/etc/sudoers.d/...in the PoC)
* any unprivileged local user that can bind on some TCP port and reach the local CUPS listener.
Are you affected? + Mitigation #
The unauth’d RCE as lp (CVE-2026-34980) requires the CUPS server to be reachable over the network and expose a shared PostScript queue (these are legacy, but still used). This would be a deliberate config choice – realistic for, say, networked printing servers in your corporate environment, but not for your desktop (unless you for some reason set it up to be a remote printing server).
The LPE to root file (over)write (CVE-2026-34990), on the other hand, works on the stock CUPS config.
For both issues, the harm can be limited by a security module that confines CUPS (e.g., SELinux, AppArmor, etc.). So, if you run CUPS under a sane security policy (default on some distributions), the impact of both vulnerabilities is much less severe – e.g., no rootful file writes outside the paths CUPS is constrained to touch.
As of 4/5/2026, there are public commits with fixes to both issues but no fixed release (latest being 2.4.16). So, your best mitigations are:
- Do not expose CUPS over the network with a shared PostScript queue – or at all
- If you must use a shared queue, require auth for job submissions to that queue
- Make sure your CUPS runs under a reasonable AppArmor/SELinux/etc. policy, so that the impact is minimized even if you are targeted
Technical details #
The advisories (CVE-2026-34980, CVE-2026-34990) and the PoCs in them go into full detail. Below is a summary of the particularly interesting pieces.
CVE-2026-34980: turning a print option into scheduler control data #
Under the default policy, CUPS will accept anonymous Print-Job requests, and it only blocks remote printing when the queue is not shared:
<Limit Create-Job Print-Job Print-URI Validate-Job>
Order deny,allow
</Limit>if (!printer->shared && ...) {
send_ipp_status(..., _("The printer or class is not shared."));
return (NULL);
}This gives us the ability to target all the rich escaping/parsing logic on a shared queue without any auth layer by default.
When CUPS serializes job attributes for filters, it escapes newlines by prefixing them with a backslash. Later, when it parses that option string back, it strips the backslash back out. So, an embedded newline survives the round trip.
if (strchr(" \t\n\\\'\"", *valptr))
*optptr++ = '\\';if (*ptr == '\\' && ptr[1])
_cups_strcpy(ptr, ptr + 1);This matters when using a PostScript queue. pstops logs an invalid page-border value if it encounters one, and the helper it uses prefixes only the first line. So if the attacker smuggles a newline into the value, the second line can begin with PPD: (think HTTP PPD request smuggling):
_cupsLangPrintFilter(stderr, "ERROR", _("Unsupported page-border value %s, using " "page-border=none."), val);snprintf(temp, sizeof(temp), "%s: %s\n", prefix, _cupsLangString(cg->lang_default, message));
vsnprintf(buffer, sizeof(buffer), temp, ap);And since CUPS treats PPD: as a trusted control record, it reparses the remainder into queue options, which get added to the queue’s PPD:
else if (!strncmp(sb->buffer, "PPD:", 4))
...
message = sb->buffer + 4;cupsdLogJob(job, CUPSD_LOG_DEBUG, "PPD: %s", message);
job->num_keywords = cupsParseOptions(message, job->num_keywords, &job->keywords);if (job->num_keywords)
{
if (cupsdUpdatePrinterPPD(job->printer, job->num_keywords, job->keywords))
cupsdSetPrinterAttrs(job->printer);
...At this point, the attacker is able to modify queue configuration. The practical payload is to inject a malicious cupsFilter2 entry into the PPD, then send a second raw job so CUPS will launch an attacker-chosen existing binary as a filter (the PoC in the report uses vim).
CVE-2026-34990: from lp to root via localhost admin auth and file:///
#
This is the LPE to file (over)write as root. Any low-privilege account (lp used just to demonstrate the chain) that can talk to CUPS on localhost can issue a CUPS-Create-Local-Printer command; it is not an admin-authenticated operation in the default policy when coming from localhost. So, the attacker can stand up a fake printer on localhost:<some TCP port> and trigger CUPS to set it up.
During the printer setup/validation, CUPS will authenticate to the target printer using its Local auth scheme. An attacker listening on localhost can then reply with 401 Unauthorized and WWW-Authenticate: Local trc="y", mirroring the “try root certificate” auth option that CUPS itself would normally use for privileged localhost auth. CUPS then uses certs/0 (“cert” here is really a “token”) and presents the admin token (Authorization: Local ...) to the attacker. Finally, cupsd accepts the replayed Authorization: Local ... token on loopback:
strlcpy(auth_key, ", Local trc=\"y\"", auth_size);snprintf(filename, sizeof(filename), "%s/certs/0", cg->cups_statedir);else if (!strncmp(authorization, "Local", 5) &&
httpAddrLocalhost(httpGetAddress(con->http))) {
...
cupsdLogClient(con, CUPSD_LOG_DEBUG, "Authorized as %s using Local.", username);That token is enough to issue /admin/ requests on localhost. Normally, CUPS does have a guardrail here: file: device URIs are supposed to be blocked unless FileDevice is explicitly enabled. However, the temporary printer path stores the URI first and later lets printer-is-shared=true clear the temporary flag without revalidating the URI:
printer->shared = ippGetBoolean(attr, 0);
if (printer->shared && printer->temporary)
printer->temporary = 0;So, the attacker now needs to just create a second, temporary local queue (pointed at file:///<target>) and immediately use the stolen token to set printer-is-shared=true so that the queue is persisted. This is racy as background setup/validation can remove the printer. However, once the race is won (single-digit attempts have sufficed in my testing), printing to it is just a root file (over)write on whatever file:///... path the attacker chose:
else if (!strncmp(job->printer->device_uri, "file:///", 8))
job->print_pipes[1] = open(job->printer->device_uri + 7,
O_WRONLY | O_CREAT | O_TRUNC, 0600);At this point, the target can be /etc/sudoers.d/pwn. Note: open(..., 0600) sets 0600 on new files, but existing files’ bits – e.g., exec – would be preserved.
PostScript #
I will eventually cover the self-orchestrating setup – with its strengths and limitations – in a dedicated post but it’s worth calling out immediately how this chain showcases LLMs’ prowess in vulnerability research.
You may not vibe-discover the whole chain with a single “find me a remote RCE to root, make no mistakes” prompt. But tasking them with a) a search for a remote code exec as anything and b) anything -> a useful root primitive allows the agents to greatly narrow the search space and not burn as many tokens.
At that point, their relentlessness is unleashed in very tight directions, finding all the low-hanging fruit much quicker – something they are now very good at doing.