Abusing Ubuntu 24.04 features for root privilege escalation
Written by:
Rory McNamara
September 9, 2024
Introduction
With the recent release of Ubuntu 24.04, we at Snyk Security Labs thought
it would be interesting to examine the latest version of this Linux
distribution to see if we could find any interesting privilege escalation
vulnerabilities.
I'll let the results speak for themselves:
1 rory@ubuntu2404release:~$ cat /etc/lsb-release
2 DISTRIB_ID=Ubuntu
3 DISTRIB_RELEASE=24.04
4 DISTRIB_CODENAME=noble
5 DISTRIB_DESCRIPTION="Ubuntu 24.04 LTS"
6 rory@ubuntu2404release:~$ id
7 uid=1000(rory) gid=1000(rory) groups=1000(rory),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),114(lpadmin)
8 rory@ubuntu2404release:~$ sh exploit.sh
9 Re-executing via Desktop
10 Disabling notifications
11 Staging cups symlinks
12 Taking over cupsd.conf
13 Rewriting cupsd.conf
14 Taking over cups-files.conf
15 Setting filter group to netdev
16 Staging for wpa_supplicant
17 Triggering wpa_supplicant as group netdev via cups
18 Re-enabling notifications
19 Cleaning up and providing shell
20 # id
21 uid=0(root) gid=0(root) groups=0(root)
During our research, we successfully identified a privilege escalation
from the default user on a fresh Ubuntu Desktop installation to root. To
achieve this, we chained one small bug in a privileged component together
with a number of features, which all work as expected, to achieve
arbitrary command execution as root.
This blog post will outline the journey of our research, discuss how we
identified these vulnerabilities, and, we hope, show that you can keep it
simple when it comes to exploitation and achieve the same results without
needing a very complex (although extremely cool) kernel memory corruption
vulnerability, for example.
Hunting for root privilege escalation on Linux
User privilege boundaries
Whenever I'm looking at something new, especially for privilege escalation
vulnerabilities, my first thought is always, "Where are the privilege
boundaries?". This is not a novel place to start, but I find it is always
very useful to define what specific thing it is I'm trying to break.
There's no use going into a product to look for vulnerabilities with no
plan for what they will even look like.
When looking at a Linux system such as Ubuntu, there are a handful of very
common privilege boundaries. Some, such as setuid binaries, bad file
permissions, and Unix sockets, have been around for a long time. I
generally skip these things on first blush -- they've often already been
closely examined. I do, of course, come back to them later, but for the
density of vulnerabilities, my first port of call is always DBus.
DBus is, for our purposes at least, a client/server-based remote procedure
call (RPC) framework. What this means in practice is that a server, for
example, a privileged component that manages users on the system, can
register itself on the DBus message bus and offer up a number of methods,
which can then be called by clients on the same bus. There are a number of
built-in security controls for this bus, including explicit rules for
which users or groups can or cannot call methods and even which users can
claim to be a specific service. We will expand deeper into the permission
model later on.
I am heavily simplifying what DBus is and can do here, but for the sake of
brevity, let's define DBus as "a service which allows certain users to
call methods inside another, possibly privileged, process," which gives us
enough context to establish our privilege boundary of interest.
Investigating DBus
Many before me, and I'm sure many after me, have found vulnerabilities
using the DBus interface. At a high level, tooling such as d-feet (now
superseded by D-Spy) can allow you to enumerate all services that have
registered methods on the DBus bus and let you test each of the registered
methods (modulo permission checks) by showing you the correct arguments
that should be provided.
I used this method to go through all of the services present on a new
installation of Ubuntu 24.04 and review what may present an opportunity.
After a few days and a fair few false starts, I eventually found the
interface org.opensuse.cupspkhelper, which, by looking at the available
methods, is used to manage the cups daemon (a service that manages
printers and printing). Cups is a privileged process and I know from
previous experience (repeatedly exploiting cups on Google's ChromeOS) that
if the privilege boundaries surrounding cups are not careful, cups can be
used as a very powerful exploitation gadget.
D-feet: Viewing the available methods in the org.opensuse.CupsPkHelper
DBus interface
D-feet: Viewing the available methods in the org.opensuse.CupsPkHelper
DBus interface
With this in mind, a DBus interface that allows you to manage this daemon
could result in some very interesting and unexpected behavior.
This feels privileged
As you may know or have inferred, printer management tends to be an
"admin" task and should generally be privileged. Why, then, are we able to
call these methods without entering our password?
Enter Polkit. Gone are the days of needing to use sudo for everything,
with its associated lack of granularity. With Polkit, every privileged
action you may want to take can be given its own unique name, and
authorization can be defined per-name and take into account how the action
is being requested.
For example, if you want to change the system time, there's a unique name
for that: org.freedesktop.timedate1.set-time:
Following is the
/usr/share/polkit-1/actions/org.freedesktop.timedate1.policy file
contents:
1
2 Set system time
3 Authentication is required to set the system time.
4
5 auth_admin_keep
6 auth_admin_keep
7 auth_admin_keep
8
9 org.freedesktop.timedate1.set-timezone org.freedesktop.timedate1.set-ntp
10
This XML for Polkit defines the actions that could be taken, what message
should pop up when a password is requested ("Authentication is required to
set the system time."), and what the rules are around who can authenticate
(auth_admin_keep). The auth_admin_keep means an
administrator (i.e, someone in the "sudo" group) can use their password to
change the time, and the _keep means that the authentication will be
remembered for a short time, so you can change the time twice quickly
without needing to re-enter your password.
But how does this apply to us? We aren't prompted to enter our password
when we call the methods on our target DBus interface. Looking at the
policy definitions for org.opensuse.cupspkhelper.mechanism, we see that
all the actions require auth_admin, which should need the admin password
every time.
The solution to our puzzle is the second half of what makes Polkit
powerful: rules. Polkit allows for the definition of rules (written in
JavaScript), which can be used to change the behavior of what is defined
in the policy file. Looking at the rule sets in the file
/usr/share/polkit-1/rules.d, we can find the following snippet:
The contents of the file
/usr/share/polkit-1/rules.d/com.ubuntu.desktop.rules:
1 // Printer administration
2 polkit.addRule(function(action, subject) {
3 if (action.id.indexOf("org.opensuse.cupspkhelper.mechanism.") == 0 &&
4 subject.active == true && subject.local == true &&
5 (subject.isInGroup("sudo") || subject.isInGroup("lpadmin"))) {
6 return polkit.Result.YES;
7 }
8 });
This rule is made up of 3 main parts.
o The first part,
action.id.indexOf("org.opensuse.cupspkhelper.mechanism.") == 0, checks
to see if this is one of the actions used by our DBus interface by
checking the prefix. The specific rule looks similar to
org.opensuse.cupspkhelper.mechanism.printer-local-edit.
o The second section is subject.active == true && subject.local == true.
This is Polkit speak for "Is the user logged in to the computer
directly and sat looking at the screen?". This applies to most direct
uses of Ubuntu Desktop, but won't apply for, SSH sessions on the same
machine, for example.
o The final section (subject.isInGroup("sudo") ||
subject.isInGroup("lpadmin")) checks to see if the requesting user is
in either the "sudo" or "lpadmin" user groups.
If all 3 sets of conditions for this rule are found to be true, i.e a user
who is sat in front of their computer, in the sudo or lpadmin groups, and
trying to make changes to printers, this rule instructs the authorization
check to succeed (polkit.Result.YES) without needing the user to enter
their password. This is quite a nice usability feature and will decrease
the amount of passwords an administrator will need to type in all the
time.
The problems begin when an attacker is able to compromise the user's
session, potentially through social engineering to execute a payload, or
other exploitation (I may not have Chrome full RCE vulnerabilities, but
they've happened before). In this context they will be able to call
interfaces secured in this way without needing to first compromise the
user's password, which may have been necessary if these rules did not
exist.
So we've found an interface which crosses a potentially interesting
privilege boundary, and we've also found that we may be able to call these
privileged functions without necessarily needing to compromise the user's
password. This is quite a good starting point for a privilege escalation
chain, as privileged functions which are nominally protected, but can be
accessed in certain circumstances bypassing said protection may allow us
to exploit some interesting behavior.
Finding the bug
If you've read some of my previous work you may know that I love the
strace tool. If you're not familiar with this tool I encourage you to go
back and read the first part of my Leaky Vessels deep dive post, which
contains a primer.
Whenever exploring an interface across a privilege boundary, it's a very
good habit to attach strace to your target and save the logs. This is a
great way to see what's actually being done by the application when you
call each method, and may give you quick wins from low hanging fruit.
Using this methodology, I observed that the cups daemon was restarting
when calling ServerSetSettings. This was identified during the evaluation
of the ServerGetSettings and ServerSetSettings methods. These functions
are defined as follows (this output is taken from the gdbus introspect
tool):
1 @org.freedesktop.DBus.GLib.Async("")
2 ServerGetSettings(out s error,
3 out a{ss} settings);
4 @org.freedesktop.DBus.GLib.Async("")
5 ServerSetSettings(in a{ss} settings,
6 out s error);
For those not familiar with DBus datatypes, a{ss} is an "array (a) of
structs ({}) mapping a string (s) to a string (s)". This is functionally
equivalent to a dictionary in Python where the keys and values are both
strings. By calling ServerGetSettings to get a valid "settings" value and
passing that same value to ServerSetSettings, cups performed a restart.
From this it can be inferred that the settings being changed are those in
cups itself, and the service is restarted to make the changes take effect.
This matches with what we can observe in the actual output of
ServerGetSettings:
1 {'BrowseLocalProtocols': 'dnssd',
2 'DefaultAuthType': 'Basic',
3 'ErrorPolicy': 'retry-job',
4 'IdleExitTimeout': '60',
5 'JobPrivateAccess': 'default',
6 'JobPrivateValues': 'default',
7 'MaxLogSize': '0',
8 'PageLogFormat': '',
9 'SubscriptionPrivateAccess': 'default',
10 'SubscriptionPrivateValues': 'default',
11 'WebInterface': 'Yes',
12 '_debug_logging': '0',
13 '_remote_admin': '0',
14 '_remote_any': '0',
15 '_share_printers': '0',
16 '_user_cancel_any': '0'}
Most of these values (those not starting with an underscore) match what
can be seen in the cups configuration file /etc/cups/cupsd.conf. By
changing some of these values and observing the contents of the cups
configuration file we can confirm that we are successfully changing these
items, and they are taking effect. Great! This is a privileged action and
a privileged configuration file, by default a normal user can't even read
the file so it must be juicy, right?
A quick spin around the man page doesn't turn up anything interesting
unfortunately. Nothing so easy as "please run this command as root when
you start". However, we still have some options. The next step is to look
for what our strace has turned up during this process. My first instincts
are always to look for potentially unsafe handling of configuration values
that we've provided, or other sections of the code that we can influence.
With this in mind we can do some quick greps for key syscalls like chmod,
chown, and execve to investigate further behavior.
Generally we're looking for things that could potentially be influenced by
the configuration file we have partial control over. After searching we
can come across this strace snippet:
1 unlink("/var/run/cups/cups.sock") = -1 ENOENT (No such file or directory)
2 umask(000) = 022
3 bind(8, {sa_family=AF_UNIX, sun_path="/var/run/cups/cups.sock"}, 26) = 0
4 umask(022) = 000
5 chmod("/var/run/cups/cups.sock", 0140777) = 0
We know from when we validated our previous assumptions against the cupsd
configuration file that /var/run/cups/cups.sock is defined as a "Listen"
argument. So, we can potentially control the value used in these syscalls
here. My initial reaction is that there is a race condition here. The
chmod call does not happen against a file descriptor, so there is a small
window between the bind call and the chmod call where the socket could be
swapped out for a symbolic link, allowing for arbitrary chmod. A chmod of
0140777 (essentially the same as chmod 777) will set the target of the
call to be readable and writable by all users on the system. Since the
cupsd process runs as root, this is potentially a very useful exploitation
gadget, as we could (for example) set /etc/passwd to be world-writable and
change our user id to 0, effectively making us root!
If we look up this specific functionality in cups we can see the following
snippet from cups/cups/http-addr.c:
1 // Remove any existing domain socket file...
2 unlink(addr->un.sun_path);
3
4 // Save the current umask and set it to 0 so that all users can access
5 // the domain socket...
6 mask = umask(0);
7
8 // Bind the domain socket...
9 status = bind(fd, (struct sockaddr *)addr, (socklen_t)httpAddrLength(addr));
10
11 // Restore the umask and fix permissions...
12 umask(mask);
13 chmod(addr->un.sun_path, 0140777);
Notice that the return values to neither unlink nor bind are checked until
after the chmod call is executed (omitted, status is checked later). The
bind system call doesn't like symlinks, so if there is one present at the
provided path, the bind will fail. My assumption, based on previous
experience and the strace output, was that the bind call return value
would be checked, and if there was a symlink present, cupsd would quit
with an error. But this was not the case in the code, so we could have our
malicious symlink in place well before the call to bind and still get to
the chmod call. This changes our initial assumption of a race condition
into a vulnerability caused by not checking return values.
Eagle-eyed readers may also question the call to unlink and ask why that
wouldn't still make this a race condition. Whilst this may look like a
major hurdle (and a return of the race condition), we will see shortly why
this is not the case.
So, where are we now? We have found a method on the DBus interface which
allows us to set arguments in the cupsd.conf configuration file, cups is
then restarted and our configuration should take effect. We've also found
some potentially unsafe handling of the Listen argument. We know that this
is not a race condition.
So we can call our identified method, set Listen to a symlink to something
interesting, and have write access to whatever we like now? To test this,
we'll create a new directory in /tmp, and add a symlink to /etc/passwd as
our sample target. We can then call the DBus method and set Listen to this
path (/tmp/stage/passwd). Unfortunately, this does not work:
1 unlink("/tmp/stage/passwd") = -1 EACCES (Permission denied)
2 umask(000) = 022
3 bind(6, {sa_family=AF_UNIX, sun_path="/tmp/stage/passwd"}, 20) = -1 EADDRINUSE (Address already in use)
4 umask(022) = 000
5 chmod("/tmp/stage/passwd", 0140777) = -1 EACCES (Permission denied)
This is unexpected. cupsd is running as root, and it even has
CAP_DAC_OVERRIDE. Why can't we remove a file (the unlink system call)
owned by a normal user, let alone run chmod for our own file? (/etc/passwd
is owned by root)
Linux AppArmor
The answer lies in AppArmor.
What is AppArmor
AppArmor is a Linux kernel security module that can restrict contained
programs to only a specific set of resources. Similar to SELinux, AppArmor
can be used to define profiles which an application will be executed with,
and this profile specifies an allow and deny list of resources which the
application can interact with.
A profile looks something like the following excerpt, taken from the
tcpdump profile on my Ubuntu test machine:
1 profile tcpdump /usr/bin/tcpdump {
2 [...]
3 # for -z
4 /{usr/,}bin/gzip ixr,
5 /{usr/,}bin/bzip2 ixr,
6
7 # for -F and -w
8 audit deny @{HOME}/.* mrwkl,
9 audit deny @{HOME}/.*/ rw,
10 audit deny @{HOME}/.*/** mrwkl,
11 audit deny @{HOME}/bin/ rw,
12 audit deny @{HOME}/bin/** mrwkl,
13 owner @{HOME}/ r,
14 owner @{HOME}/** rw,
15
16 # for -r, -F and -w
17 /**.[pP][cC][aA][pP] rw,
18 /**.[pP][cC][aA][pP][nN][gG] rw,
19 /**.[cC][aA][pP] rw,
20 [...]
21 }
This profile will restrict what files any tcpdump process can access. For
example, we can see that it is only allowed to execute (the ixr flag) a
certain list of binaries (gzip, bzip2). We can also see that it is not
allowed to access hidden files and directories in any user's home
directory but is explicitly allowed to write to directories owned by the
user under which tcpdump is executing. Finally, we can see that the
process is only allowed to write files outside of the home directory if
the file extension is .pcap or similar. We can see this in action in the
following snippet. Observe that, even though /dev/shm is world writable,
tcpdump cannot write to this directory unless the file extension ends with
a pcap extension.
1 $ ls -ld /dev/shm
2 drwxrwxrwt 2 root root 40 May 10 13:59 /dev/shm
3 $ sudo tcpdump -i lo -w /dev/shm/test
4 tcpdump: /dev/shm/test: Permission denied
5 $ sudo tcpdump -i lo -w /dev/shm/test.pcap
6 tcpdump: listening on lo, link-type EN10MB (Ethernet), snapshot length 262144 bytes
Linux AppArmor, savior, and saboteur
Now that we've had a quick introduction to AppArmor, how does it apply to
our cups vulnerability? We can see in /etc/apparmor.d/usr.sbin.cupsd that
cupsd has its own profile.
There are two parts that we can attribute to the behavior of the strace
output we observed earlier. Firstly, we can see that the policy imports
abstractions/user-tmp. The abstractions folder contains a number of
partial policies which are generic and can be reused. In this file, we can
see the following snippet:
1 owner /tmp/** rwkl,
2 /tmp/ rw,
What this policy means is that cupsd has read and write access to the
directory path at /tmp but only has read and write (and lock and link)
access to subdirectories of /tmp if it is the owner of the subdirectory.
Since our testing directory /tmp/stage is owned by my user and not root,
cupsd is not allowed to make changes to this directory -- so, the system
call to unlink fails. This is a very convenient case of a security control
actually helping with exploitation, as without this restriction, the code
we've identified as vulnerable would actually be a race condition as
discussed above, but as it stands, because the unlink fails, there is no
timing element.
The second part of the policy impacting our proof of concept is the lack
of write access to /etc. This is why our chmod /etc/passwd attempt (via
the symlink) is failing. This makes a lot of sense, because there's no
need for cups to be able to write to /etc, so why give it the opportunity?
We now have established why our proof of concept has failed. But all is
not lost. We can use the AppArmor policy as an instruction manual for
things that we know we could successfully chmod and gain write access to.
By reading through the policy, we can see that most of the places we have
write access to are specific to the cupsd functionality, such as the
following snippet, giving us full access to everything under /etc/cups.
1 /etc/cups/ rw,
2 /etc/cups/** rw,
Pivot, pivooot
We've gone from what looked like a very easy-to-exploit vulnerability to
something that has quite a limited impact. Looking at the AppArmor policy,
we can only really make changes to cups configuration, cache, or log
files. We will somehow need to find a method to turn arbitrary control
over all of the cups configuration files into something more interesting,
our aim being full root command execution.
To decide what we can achieve with what we have so far, the first port of
call is the documentation for the files we think we can take over. This
will include cupsd.conf, cups-files.conf, and a handful of other files in
/etc/cups. Reading through the documentation for files we come across two
interesting snippets from the cups-files.conf man page:
1 Group group-name-or-number
2 Specifies the group name or ID that will be used when executing external programs. The default group is operating system specific but is usually "lp" or "nobody".
3
4 User username
5 Specifies the user name or ID that is used when running external programs. The default is "lp".
This sounds very interesting, as it allows for the specification of a user
used for external programs. To actually try this out, however, we do need
to exploit our vulnerability above to have a successful chmod against
/etc/cups/cups-files.conf. There is a bit of hidden complexity in
exploitation that hasn't come up yet. When using the DBus
ServerSetSettings method to change the Listen parameter, cups will no
longer run properly. It will successfully execute the chmod call as we saw
above, but because the bind call is unsuccessful, when the bind return
value is checked later in the code cups will identify that the bind failed
and exit. In this state, we're not able to make any more changes, and,
critically, we're not able to exploit the vulnerability again.
To mitigate this, we can target /etc/cups/cupsd.conf first. Our attack
will progress, this file will become world writable, and we can fix our
inserted malicious Listen lines to be like the original lines -- in
essence turning our somewhat limited control of this file via DBus into
full control. This will allow cupsd to successfully start again*, but
cupsd.conf will remain world writable. Once in this state, we can add more
Listen lines to the end of cupsd.conf (as cupsd will accept multiple of
these lines) and trigger a restart of the cupsd service (by using
ServerSetSettings on an unrelated and safe configuration item). We can now
safely re-exploit the vulnerability whenever we like without losing access
to cupsd, or breaking the org.opensuse.CupsPkHelper interface (which
requires cups to be functional and listening at an expected location).
Using this we can then make /etc/cups/cups-files.conf world writable, and
now we can add our own `User' and `Group' configuration lines we
identified in the configuration documentation.
*Starting stopped services is generally a privileged action. However,
cupsd is a special case because it's configured to be "socket activated"
by default, so by attempting to communicate with cupsd where systemd
thinks the socket is (our interference notwithstanding), systemd will
happily start cupsd for us, without the need for any extra privileges.
Be who you wanna be
We have identified that we can set the User and Group for "external
programs". Naively, we would like to set the User and Group to root,
giving us root access immediately (when we can control "external
programs"). However, if we try to set the User/Group lines to this in the
configuration file and trigger a restart of cups, we'll get the following
error.
From the source code of cups/scheduler/conf.c:
1 if (!p->pw_uid)
2 {
3 cupsdLogMessage(CUPSD_LOG_ERROR,
4 "Will not use User %s (UID=0) as specified on line "
5 "%d of %s for security reasons. You must use a "
6 "non-privileged account instead.",
7 value, linenum, CupsFilesFile);
8 if (FatalErrors & CUPSD_FATAL_CONFIG)
9 return (0);
10 }
This error tells us that root (or any user with a UID of 0) is explicitly
disallowed. Since this step does not immediately result in root command
execution, we must find another user to target, which can act as a
stepping stone to our target of full root.
Before we evaluate our attack surface further, we will take a brief moment
to discuss "external programs".
Cups external programs
Cups is a complex beast -- more complex than can be covered in an article
of even this length. The following explainer is heavily condensed (and
likely overly simplified), mainly to cover the areas we care about. I did
not find this technique for this specific chain of vulnerabilities.
However, like many things in this post, it was identified by reading
documentation.
Cups is a printing system for Unix (literally. The name CUPS stands for
Common Unix Printing System). You can register printers with cups,
defining their capabilities using a PPD (Postscript Printer Description)
configuration file. This file describes common things like the paper size,
duplex options, resolution, etc. This file can also be used to define the
supported filetypes of the printer. There is no use sending a raw PDF file
to a printer that cannot understand the format. Therefore this file
instructs cups what to do when a specific filetype is printed.
Cups distributes a set of "filters", which can be used to convert between
these different standard formats. For example, the pdftops filter can
convert a pdf file to a ps (postscript) file, so you can effectively print
PDF files on printers that do not understand them by transparently
converting them inside cups.
One of the filters that a PPD file can specify is "foomatic-rip", which
claims to be a "universal filter". As part of the configuration for this
filter, there is a very juicy option: FoomaticRIPCommandLine. Even the
naming of this option may pique your interest. This option can be used to
define a command line that will be executed when the filter is triggered.
I will skip over the tedious validation using strace (-fe execve is a
useful flag if you wish to try this at home) and conclude the following.
First we create a PPD file for a fake printer, which contains a
FoomaticRIPCommandLine item, itself containing a command line controlled
by us. We then register a new printer using this PPD file. Helpfully, our
previously identified org.opensuse.CupsPkHelper DBus interface also
includes a method for adding a printer with a manually specified PPD file.
With our printer now created, we can attempt to print to it with a sample
PDF file. Observing with strace we would see our command line configured
in the PPD file executing when the print job is submitted.
The end result of this is command injection, and because of our previous
exploitation controlling the User and Group parameters for exactly these
external programs, the programs will run as any User/Group we desire (with
the previously discussed exception of root).
Our final step is to identify a way of getting from any user/group to
root.
More vulnerability gadgets features
There are probably a lot of ways of achieving this. For example, /dev/sda2
(my root disk) has a group of disk, and permissions 660, so I could use
the configuration to set my Group to disk and modify this file. However,
when I am in this kind of situation, I always try to err on the side of
simplicity. A lot of things can go wrong with modifying a raw disk (even
with good tools like debugfs), and it can make exploitation fiddly and
unreliable (I've done it before). Therefore, I took the time to attempt to
identify other options which may be more reliable or at least simpler.
When reporting vulnerabilities to vendors, simplicity is always
beneficial.
Much like the start of this post, I began by investigating what was
available on the DBus bus. This time, however, I first looked at the
permission settings in the DBus daemon (defined in
/usr/share/dbus-1/system.d) to find services accessible by specific users
or groups and not to my original user.
These configuration files look something like the following
/usr/share/dbus-1/system.d/wpa_supplicant.conf:
1
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
In this example, for wpa_supplicant, normal users (i.e. context="default")
cannot send_destination (call) methods on the interface at all. User root
(user="root") can own and call the methods, this is pretty standard but
doesn't help us because we can't become root. However, the potentially
interesting part here is group netdev being allowed to call methods on
this interface as well. It turns out the ability for the netdev group to
call the interface is actually a Debian-family-specific addition.
Here we can iterate over finding an interesting interface callable by a
user or group we can achieve, enumerate the accessible functions, and read
the documentation. I will skip that this time for brevity (and because I
did not discover this feature during this research) and we can go straight
on to what the gadget is.
The wpa_supplicant service runs as root and, as we saw in the above
example, has an interface that could be callable with our previous
exploitation by becoming group netdev. On this interface there is a method
CreateInterface. This method is generally used to configure a network
interface for wireless network security (e.g. WPA/802.1X). This method
takes an argument ConfigFile, which is a path to a wpa_supplicant.conf
configuration file. Looking at the documentation example for this file
shows a couple of options that can be used to load arbitrary shared
objects which can provide various functionality to the supplicant (e.g.
crypto routines).
We can, therefore, write a shared object with some payload inside it,
create a configuration file which includes a reference to this shared
object to be loaded, exploit our previous vulnerability chain to attain
command execution with a group ID of netdev, call the wpa_supplicant's
CreateInterface DBus method with our configuration file, and achieve
arbitrary code execution when wpa_supplicant loads the shared object.
Because, as previously mentioned, wpa_supplicant runs as root, we will now
have arbitrary root code execution.
In the actual proof of concept, we use the shared object to set the Python
binary to be setuid root. This makes it very easy to finish our script
with an interactive root shell, and perform the cleanup activities
necessary to undo what we have changed, such as the cups file permissions.
Proof of concept wrap-up
The following section combines everything we've learned into a walkthrough
of what the proof of concept actually looks like, to achieve full root
command execution.
1. Create a staging area in /tmp/stage for the symbolic links which we
will target with cups.
2. Call the ServerSetSettings DBus method to change the cups Listen
parameter to /tmp/stage/cupsd.conf, which is a symlink to
/etc/cups/cupsd.conf.
3. The file /etc/cups/cupsd.conf will now be world-writable. Fix up the
changes made by ServerSetSettings and add an additional Listen
configuration item pointing to /tmp/stage/cups-files.conf, which is a
symlink to /etc/cups/cups-files.conf.
4. Use ServerSetSettings to change the IdleExitTimeout parameter. We
don't care about this parameter, but we are using it to trigger the
cups restart, which is a privileged action.
5. The file /etc/cups/cups-files.conf will now be world writable. Modify
this file to set the Group parameter to netdev. We don't actually need
to set the User parameter for our chain.
6. Repeat step 4 to trigger a restart of cups, causing the Group
parameter in cups-files.conf to take effect.
7. Stage for wpa_supplicant by creating a configuration file that defines
the opensc_engine_path pointing to our shared object and creating a
Python script to call the wpa_supplicant's CreateInterface method.
Because of the required data types of this method, it is easier to use
Python than dbus-send.
8. Install a new printer into cups using the PrinterAddWithPpdFile's DBus
method. The PPD file passed here will define the
FoomaticRIPCommandLine parameter to execute the Python script created
in step 7.
9. Print a sample PDF file using the newly created printer. This will
trigger the Python script, which will call the CreateInterface method,
which will cause wpa_supplicant to load our shared object defined in
our configuration file. This shared object will set Python to be
setuid root as soon as it is loaded (using the gcc constructor
attribute).
10. Using the now-changed setuid Python, clean up the changes we have
made. Remove any new lines from the various cups configuration files,
reset the permissions of these files to their originals, and restart
cups so everything takes effect.
11. Finally, provide an interactive root shell to the user to finish the
proof of concept.
Or, as a diagram:
blog-ubuntu-chart
Conclusion
In this post, we have seen that it only takes the leveraging of one small
vulnerability, combined with a number of features, to achieve a chain of
exploitation resulting in a full privilege escalation. Even where security
controls are in place preventing the direct exploitation of a small
vulnerability it may still be possible to finesse limited exploitation
potential into a much greater impact.
There are a few key takeaways that I find very useful to bear in mind when
performing this kind of research. The first is trying to be as holistic as
possible. When looking at a system as complex as an entire Linux
distribution, it pays to consider the system as a whole rather than
focusing on one component. If I had limited my scope to cups alone, even
with the multiple items I identified, I may not have been successful in
escalating all the way to root. Still valuable research, to be sure, but
not as good as it turned out to be. By keeping my eyes open and not
showing favoritism to a single area, it's possible to pivot to a
completely separate area of the system (from printing to networking) to
get the results I'd hoped.
The second key takeaway is that it is not necessary to find everything in
order. Especially when it comes to features, it's very valuable to have
notes or prior knowledge of "if I'm in situation X, I could use feature Y
to achieve result Z". Some of the features used in this chain have been
around for years, ready to be dropped into your PoC as the next step. Even
when looking at something new to you, don't make assumptions about what is
achievable or realistic. You never know when you'll end up in a weird jail
as a low-privilege user, but if you see something in that context, keep it
in mind when you're gluing things together. Do not be afraid to
temporarily decrease your privileges if it might help you get higher
privileges on the way out. We (arguably) did this here by dropping
privileges from rory:sudo to lp:netdev, because we knew lp:netdev could
end up higher than rory:sudo could on its own.
We want to end with a shout-out to both the Ubuntu Security team and the
cups team, who responded quickly to the reports. A special shout out to
the cups team who were able to produce a patch on the same day as the
report which both fixes the direct vulnerability but also added additional
security measures, which would have made it doubly difficult to exploit in
the first place.
Timeline
08-May-2024: Reported the vulnerability chain to Ubuntu Security
23-May-2024: wpa_supplicant issue assigned CVE-2024-5290 by Ubuntu
security
24-May-2024: Reported the cups specific vulnerability to OpenPrinting/cups
at Ubuntu's request. GHSA-vvwp-mv6j-hw6f
24-May-2024: cups proposed a suitable patch for the reported vulnerability
24-May-2024: cups issue assigned CVE-2024-35235 by Github
30-May-2024: wpa_supplicant bug opened by Ubuntu Security
03-June-2024: cups announced upcoming advisory release to
11-June-2024: cups released patch and advisory
26-July-2024: wpa_supplicant bug patch created
06-Aug-2024: wpa_supplicant patch released
Mitigations
Cups mitigated the identified vulnerability by removing the call to chmod
entirely and relying on umask to ensure the permissions are correct.
Additionally, when cupsd is configured to be started on-demand (i.e. via
systemd socket activation), cupsd will not process the Listen directives
itself.
The wpa_supplicant project mitigated this issue by implementing a
mandatory path prefix to ensure that all loaded shared objects are under
the /usr/lib directory. This ensures that attacker-controlled code cannot
be loaded even with sufficient access to the DBus interface.
References
Visible links
. https://snyk.io/blog/abusing-ubuntu-root-privilege-escalation/
. https://unit42.paloaltonetworks.com/usbcreator-d-bus-privilege-escalation-in-ubuntu-desktop/
. https://apps.gnome.org/en-GB/Dspy/
. https://issues.chromium.org/issues/40092465
. https://issues.chromium.org/issues/40052058
. https://snyk.io/blog/leaky-vessels-container-vuln-deep-dive/
. https://www.cups.org/doc/man-cupsd.conf.html
. https://www.cups.org/doc/man-cups-files.conf.html
. https://issuetracker.google.com/issues/172219872
. https://git.launchpad.net/ubuntu/+source/wpa/tree/debian/patches/02_dbus_group_policy.patch
. https://w1.fi/cgit/hostap/tree/wpa_supplicant/wpa_supplicant.conf#n177
. https://github.com/OpenPrinting/cups/security/advisories/GHSA-vvwp-mv6j-hw6f
. https://security.snyk.io/vuln/SNYK-UNMANAGED-OPENPRINTINGCUPS-7254925
. https://bugs.launchpad.net/ubuntu/+source/wpa/+bug/2067613