Attacking UNIX Systems via CUPS, Part I
Hello friends, this is the first of two, possibly three (if and when I have time to finish the Windows research) writeups. We will start with targeting GNU/Linux systems with an RCE. As someone who’s directly involved in the CUPS project said:
From a generic security point of view, a whole Linux system as it is nowadays is just an endless and hopeless mess of security holes waiting to be exploited.
Well they’re not wrong!
While this is not the first time I try to more or less responsibly report a vulnerability, it is definitely the weirdest and most frustrating time as some of you might have noticed from my socials, and it is also the last time. More on this later, but first.
Summary
- CVE-2024-47176 | cups-browsed <= 2.0.1 binds on UDP INADDR_ANY:631 trusting any packet from any source to trigger a
Get-Printer-Attributes
IPP request to an attacker controlled URL. - CVE-2024-47076 | libcupsfilters <= 2.1b1
cfGetPrinterAttributes5
does not validate or sanitize the IPP attributes returned from an IPP server, providing attacker controlled data to the rest of the CUPS system. - CVE-2024-47175 | libppd <= 2.1b1
ppdCreatePPDFromIPP2
does not validate or sanitize the IPP attributes when writing them to a temporary PPD file, allowing the injection of attacker controlled data in the resulting PPD. - CVE-2024-47177 | cups-filters <= 2.0.1
foomatic-rip
allows arbitrary command execution via theFoomaticRIPCommandLine
PPD parameter.
(can you already see where this is going? :D)
Plus a couple of other bugs that will be mentioned and that are arguably security issues but have been pretty much ignored during the conversation with the developers and the CERT. They are still there, along with several other bugs that are more or less exploitable.
Impact
A remote unauthenticated attacker can silently replace existing printers’ (or install new ones) IPP urls with a malicious one, resulting in arbitrary command execution (on the computer) when a print job is started (from that computer).
Entry Points
- WAN / public internet: a remote attacker sends an UDP packet to port 631. No authentication whatsoever.
- LAN: a local attacker can spoof zeroconf / mDNS / DNS-SD advertisements (we will talk more about this in the next writeup ) and achieve the same code path leading to RCE.
Quoting one of the first comments from the guy who literally wrote the book about CUPS, while trying to explain to me why this is not that bad:
I am just pointing out that the public Internet attack is limited to servers that are directly connected to the Internet
Affected Systems
CUPS and specifically cups-browsed are packaged for most UNIX systems:
- most GNU/Linux distributions
- some BSDs.
- Google Chromium / ChromeOS … maybe?
- Oracle Solaris
- Possibly more?
This thing is packaged for anything, in some cases it’s enabled by default, in others it’s not, go figure 🤷. Full disclosure, I’ve been scanning the entire public internet IPv4 ranges several times a day for weeks, sending the UDP packet and logging whatever connected back. And I’ve got back connections from hundreds of thousands of devices, with peaks of 200-300K concurrent clients. This file contains a list of the unique Linux systems affected. Note that everything that is not Linux has been filtered out. That is why I was getting increasingly alarmed during the last few weeks.
Remediation
- Disable and remove the
cups-browsed
service if you don’t need it (and probably you don’t). - Update the CUPS package on your systems.
- In case your system can’t be updated and for some reason you rely on this service, block all traffic to UDP port 631 and possibly all DNS-SD traffic (good luck if you use zeroconf).
Entirely personal recommendation, take it or leave it: I’ve seen and attacked enough of this codebase to remove any CUPS service, binary and library from any of my systems and never again use a UNIX system to print. I’m also removing every zeroconf / avahi / bonjour listener. You might consider doing the same.
Intro
One lazy day a few weeks ago, I was configuring Ubuntu on a new laptop (GPD Pocket 3, amazing little hacking machine btw) and for reasons that are irrelevant to this post I wanted to check which services were listening on UDP ports - so I type netstat -anu
in a terminal and after checking the output, I notice something interesting:
1 | Proto Recv-Q Send-Q Local Address Foreign Address State |
The 0.0.0.0
part is especially unusual, it means that whatever process is listening on port 631, it is listening on and responding to any network interface: LAN, WAN, VPN, whatever you have. I also vaguely recalled that CUPS, the Common Unix Printing System, uses TCP port 631, but this is UDP. I investigated with a lsof -i :631
, that confirmed CUPS on 631 tcp plus this other process, cups-browsed
(likely related to CUPS), using the udp port instead:
1 | cupsd 1868642 root 6u IPv6 32034095 0t0 TCP ip6-localhost:ipp (LISTEN) |
And ps aux | grep "cups-brow"
ultimately confirmed that this process runs as root:
1 | root 1868652 0.0 0.0 172692 11196 ? Ssl 13:20 0:00 /usr/sbin/cups-browsed |
What is cups-browsed?
After some googling I found out that cups-browsed
is indeed part of the CUPS system and it is responsible for discovering new printers and automatically adding them to the system. Very interesting, I had no idea Linux just added anything found on a network before the user can even accept or be notified. The more you know!
At this point I was extremely intrigued and curious, so I start digging into the source code of this service. While it’s pretty messy on one hand, it is also self contained and relatively easy to understand. So I quickly search for bind API usage and confirm that this thing is indeed listening on INADDR_ANY:631 UDP:
1 | ... |
Cool, this code is using global variables like there’s no tomorrow, so searching for the browsesocket
revealed that the process_browse_data
function is reading a packet from it, performing some checks and then some parsing:
1 | got = recvfrom (browsesocket, packet, sizeof (packet) - 1, 0, |
Essentially, this service expects an UDP packet with the format HEX_NUMBER HEX_NUMBER TEXT_DATA
and, if the allowed
function returns true for the specific source IP, more things happen later.
Well it turns out that while you could configure who can and who can’t connect by editing the /etc/cups/cups-browsed.conf
configuration file … the default configuration file, on pretty much any system, is entirely commented out and simply allows anyone.
Great 🤦
Later in the code, some pointer operations are performed to parse the packet. If all checks pass, two text fields parsed from the packet are passed to the found_cups_printer
function. We’ll return to this function in a moment, but for now let’s focus on the parsing.
Stack Buffer Overflows and Race Conditions
Keep in mind that while the CUPS
package itself is covered in oss-fuzz (barely to be honest …), cups-browsed is not; there seems to be no fuzzing coverage for this component. And I don’t know about you, but to me this parsing routine looks fishy and definitely something worth fuzzing:
1 | end = packet + sizeof(packet); |
So I quickly put together a fuzzing target around process_browse_data
, start my good old friend AFL, and wait. You won’t believe what happens next!!!
There are 5 different fuzzing inputs that trigger this:
1 | process_browse_data() in THREAD 136077340691200 |
I believe it being due to the pointer being dereferenced before the exit condition is verified, in both loops. I also found out later on that there’s a race condition and possibly DoS in the lock acquired here.
Both these issues have been reported and thoroughly documented, to the devs and the CERT, but nobody seemed to give a damn. I can tell you that there’re other, more easily exploitable code paths going on, not just in the discovery mechanism - also reported and ignored. To this day they have not been acknowledged or patched. Happy hunting.
However, I’m a bit lazy and most importantly I’m a noob when it comes to binary exploitation. Hell, I can barely tell whether a buffer overflow or a race condition are exploitable or not. Hardening mechanisms are getting more and more complex to bypass and to be honest I had no intention of spending months on this stuff - I hate printers. So for the moment I decided to move on to what seemed to be a lower hanging fruit.
Back to found_cups_printer
By looking at found_cups_printer we can see that one of the two text fields parsed from the packet is a URL:
1 | // |
After some further validation and parsing, this URL and other data are then passed as arguments to the examine_discovered_printer_record function, which ultimately executes create_remote_printer_entry. The create_remote_printer_entry
function will then call cfGetPrinterAttributes from libcupsfilters
:
1 | // For a remote CUPS printer our local queue will be raw or get a |
To understand what this means, we’ll need to briefly mention what the IPP protocol is, but for now the key points are:
- A packet containing any URL, in the form of
0 3 http://<ATTACKER-IP>:<PORT>/printers/whatever
, gets to UDP port 631 - This triggers a sequence of events that result in cups-browsed connecting to that URL, a drive-by kind of thing.
So I tell to myself: there’s no freaking way that if I send this packet to a public IP running CUPS (thank you shodan.io), that computer will connect back to the server I specified. No way.
I hack some python code together, fire up a VPS and try anyway.
HOLY SH!!!!! Not only it connected back immediately, but it also reported the exact kernel version and architecture in the User-Agent header! We’ll see later how this protocol also reports the requesting username (on the target) for some requests. Also this aspect, that to me matches pretty well with CWE-200, has been reported and just scoffed off as part of the mechanism. Alright … let’s not waste time on arguing whether or not this is a problem, let’s get to the juicy stuff. We know that this thing talks HTTP and POSTs some semi binary payload, what the hell is that?
Internet Printing Protocol
The Internet Printing Protocol, in short IPP, is a specialized communication protocol for communication between client devices (computers, mobile phones, tablets, etc.) and printers (or print servers). It allows clients to submit one or more print jobs to the network-attached printer or print server, and perform tasks such as querying the status of a printer, obtaining the status of print jobs, or cancelling individual print jobs.
Essentially, the system now believes that we are a printer and it is sending us, encapsulated in HTTP, a Get-Printer-Attributes request in order to fetch printer attributes such as the model, vendor and several others. It makes sense, the system discovered a new printer and somehow it has to know what it is. Well …
I went back to writing some code and, by using the ippserver python package I was now able to respond properly, with attributes I controlled, to the service request. My fake printer was immediately added to the local printers with no notification whatsoever to the user.
AMAZING! 🎉🥳🎉
What can we do with this? At this point I enabled debug logs in the service so I could observe what was going on when my fake printer was being discovered and added, and noticed these lines:
1 | ... |
Wait what?! It looks like the service fetches these attributes and then creates some sort of temporary file, a “PPD”, on which these attributes are possibly saved.
If we search for the PPD generation successful
string that appears in the logs, we find ourselves in the create_queue function, where we can see how the attributes are passed to the ppdCreatePPDFromIPP2
API in libppd
:
1 | // If we do not want CUPS-generated PPDs or we cannot obtain a |
We finally get to libppd, where the ppdCreatePPDFromIPP2 API is used to save some of those attacker controlled text attributes to a file with a very specific, line oriented syntax, without any sanitization whatsoever:
1 | if ((attr = ippFindAttribute(supported, "printer-make-and-model", |
Notice how many attributes are fprintf’ed, unescaped, into the file. The printer-make-and-model
is just one of them. So, what the hell is a PPD file now?
NOTE: These two API are also used in other parts of the overall CUPS system, not just the discovery. IYKWIM.
PostScript Printer Description
PostScript Printer Description (PPD) files are created by vendors to describe the entire set of features and capabilities available for their PostScript printers.
A PPD also contains the PostScript code (commands) used to invoke features for the print job. As such, PPDs function as drivers for all PostScript printers, by providing a unified interface for the printer’s capabilities and features.
So a PPD file is a text file provided by a vendor that describes in a domain specific language the printer capabilities to CUPS and instructs it on how to use it properly. It looks something like this:
1 | *% ================================= |
And there are tons of different instructions that are supported and can be used to do all sorts of things. I spent a few hours just reading the PPD specs (thank you MIT), and studying the CUPS specific extensions in order to find something I could rely to perform an attack. And then I found about the cupsFilter2
directive:
A filter is any executable contained in the /usr/lib/cups/filter
path (CUPS does check this, you can’t specify any binary), which will get executed when a print job is sent to the printer, in order to perform some document conversion if the printer doesn’t support that specific format. So, given that we have a constraint on which binary we can execute, we need to find a way to leverage one of the existing filters to run arbitrary commands. And also bypass these checks here, which only takes a space before the colon.
The problematic child: foomatic-rip
Another search revealed pretty quickly what could be defined as the necessary evil of the CUPS family, the foomatic-rip filter. This executable has a long history of being leveraged for exploitation, starting from the first known (to me at least) CVE-2011-2964 and CVE-2011-2697 back in 2011. The filter accepted the FoomaticRIPCommandLine
directive in the PPD that would allow ANY command to be executed through it. Nice!
According to the records, this is the commit that fixed those CVEs. However, you might have noticed that this package is different and it’s called foomatic-filters
. When foomatic-filters was integrated in the CUPS system, this fix was not ported to CUPS, as it is possible to verify by the --ppd argument
, initially removed as part of the fix, and still present in the code today. And in fact, we can find mentions of the FoomaticRIPCommandLine
directive being leveraged for arbitrary command execution in the more recent CVE-2024-35235.
So apparently foomatic-rip
was a known issue (confirmed by the CUPS devs), but somehow it has not been fixed for … decades? Why is something that allows arbitrary commands in a generally untrusted context not considered a security issue worth fixing? I’ll tell you why! Because it’s very hard to fix. According to the CUPS developers:
… it is very difficult to limit what can be provided in the FoomaticRIPCommandLine line in the PPD file. REDACTED and the rest of the OpenPrinting team have been talking about ways to limit what can be done through Foomatic without breaking existing drivers - we can certainly recommend that people not use Foomatic, but there are likely hundreds of older printer models (before 2010) that are only supported through Foomatic.
And many of those hundreds of models, really use this directive in creative ways such as:
1 | *FoomaticRIPCommandLine: "(printf '\033%%-12345X@PJL\n@PJL JOB\n@PJL SET COPIES=&copies;\n'%G|perl -p -e "s/\x26copies\x3b/1/"); |
I had no idea that this can happen every time you print something, and to be frank it’s quite scary. They have to allow FoomaticRIPCommandLine
to accept pretty much anything (including perl as you can see), or many printers will just stop working on UNIX.
Remote Command Execution chain
So, in theory, we should now be able to:
- Force the target machine to connect back to our malicious IPP server.
- Return an IPP attribute string that will inject controlled PPD directives to the temporary file.
- Wait for a print job to be sent to our fake printer for the PPD directives, and therefore the command, to be executed.
Shall we? This is the configuration payload for the IPP server (this is a YAML file that you will be able to use with the next bettercap release and its new zeroconf
and ipp
modules):
1 | # ... other configuration removed for brevity ... |
You can see how we’re returning a printer-privacy-policy-uri
attribute string (it can be any of the many attributes saved to the PPD) that will:
- Set
printer-privacy-policy-uri
to"https://www.google.com/"
, close the PPD string with the double quote, and add a new line. - Inject the
*FoomaticRIPCommandLine: "echo 1 > /tmp/PWNED"
line with our command in the PPD. - Inject the
*cupsFilter2 : "application/pdf application/vnd.cups-postscript 0 foomatic-rip
line (notice the spaces before and after the colon and no closing double quotes) directive to instruct CUPS to execute/usr/lib/cups/filter/foomatic-rip
(with ourFoomaticRIPCommandLine
) when a print job is sent.
In this video you can see me on my attacker machine (on the left) using the first version of this exploit to attack my new laptop, a fully patched Ubuntu 24.04.1 LTS
running cups-browsed 2.0.1
, and (finally!!!) achieving command execution:
Personal Considerations
You will maybe be thinking now “wow, that’s a lot of stuff to read, code, RFCs, PDFs of forgotten standards, this research must have been so tiring”, but in reality this was a weekend worth of rabbit holes, this was the fun part. The actual work, the heavy, boring stuff started when on September 5, after confirming my findings, I decided to open a security advisory on the OpenPrinting cups-browsed repository and do what to me was the right thing to do: responsible disclosure.
I won’t go into the details of the initial conversation, or the ones that followed. You are free to read them (if they will ever open any of the threads and you are willing to read 50+ pages of conversations) or not, and make your own opinion.
While the research only took a couple of days, this part took 22. And this part was not fun. I will only say that to my personal experience, the responsible disclosure process is broken. That a lot is expected and taken for granted from the security researchers by triagers that behave like you have to “prove to be worth listening to” while in reality they barely care to process and understand what you are saying, only to realize you were right all along three weeks later (if at all).
Two days for the research, 249 lines of text for the fully working exploit.
Twenty-two days of arguments, condescension, several gaslighting attempts (the things i’ve read these days … you have no idea), more or less subtle personal attacks, dozens of emails and messages, more than 100 pages of text in total. Hours and hours and hours and hours and fucking hours. Not to mention somehow being judged by a big chunk of the infosec community with a tendency of talking and judging situations they simply don’t know.
Let that sink in for a moment … WTAF.
And we’re not talking about time spent on fixes while I was impatient and throwing a tantrum on twitter. The actual fixes (or a part of them) started being pushed much later. The vast majority of the time has been spent arguing whether or not these were issues worth considering. While I was trying to report that there’s something bad that should be addressed asap, the devs were being dismissive (and pushing other code, also vulnerable, for other functionalities instead of fixing) because I dared to criticize the design of their software. While at the same time I was trying to reach out privately to de-escalate and assure whoever was getting offended that my intent was not adversarial:
To the people that more or less directly questioned my integrity, accused me of spectacularization and of spreading FUD on my socials: I don’t do this for a living. I don’t need CVEs to get a job or to prove how good my kung-fu is. Or any attention other than what my projects and research already provide. I don’t play InfoSec Influencer™ like many. To put it like Javier beautifully put it, my mission was to interrupt the triagers focus until they re-prioritized. When I saw that what I thought was pretty serious was being dismissed as an annoyance, I used the only platform I had plus a pinch of drama as a tool to have them fucking re-prioritize. And it worked, wonderfully, more fixes happened after two tweets than with all the arguing and talking, so 🤷.
Don’t hate me, hate the system that forced me to do that in order to be taken seriously.
About the 9.9 CVSS
Somebody also accused of making things up, especially due to the 9.9 CVSS severity that I claimed in this tweet. Granted, as I very transparently said in the thread, I’m really not familiar with CVSS scores, how they are assigned and so on. But here’s a screenshot from the VINCE report of the initial CVSS scores, including the 9.9, being estimated by a RedHat engineer (and also reviewed by another one):
As I said, I’m not an expert, and I think that the initial 9.9 was mostly due to the fact that the RCE is trivial to exploit and the package presence so widespread. Impact wise I wouldn’t classify it as a 9.9, but then again, what the hell do I know?
By the way, CERT’s VINCE either has a backdoor, or an inside leak, or has zero vetting on who they add to a disclosure, because there’s been a leak of the exact markdown report that I only shared there, including the exploit.
What a fucking circus.
One More Thing
When initially I wrote exploit.py
, it only sent the UDP packet and created the rogue IPP server. Then with time I started adding features to it, especially zeroconf advertising, and it became a tool. So at some point I decided to rewrite it in Go and integrate this new code in bettercap, giving it the ability to transparently impersonate any service advertised via zeroconf / Bonjour / Avahi on a LAN and doing interesting things with the TXT records and specific service attributes, like IPP. And I discovered other interesting stuff :)
In part II of this series (date TBD since there’s another disclosure in process), we’ll see how to use these new bettercap modules (not yet released) to attack Apple macOS.
For now, I hope you enjoyed part I, hack the planet!