Hello world! In this security research blog, I am gonna explain a LPE for a new and shiny Linux distro called Pop!_OS
, which sort of has an Ubuntu flavor with its own special DBus services and settings. It is driven by a company which bundles Open Source hardware with Open Source firmware and Operating Systems to create free and open intelligence platforms, which is a cool thing and worth all the support.
After install, lets check out some of these special DBus services it provides:
$ cat /usr/share/dbus-1/system-services/org.pop_os.repolib.service
[D-BUS Service]
Name=org.pop_os.repolib
Exec=/usr/bin/python3 /usr/lib/repolib/service.py
User=root
This is something that can be triggered via DBus activation to run as root. Python is always a thankful exploit development target when you want to look for services running with higher privileges.
But first, have a look at the DBus config for this service:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<type>system</type>
<!-- Only root can own the service -->
<policy user="root">
<allow own="org.pop_os.repolib"/>
<allow send_destination="org.pop_os.repolib"/>
<allow receive_sender="org.pop_os.repolib"/>
</policy>
<policy group="adm">
<allow send_destination="org.pop_os.repolib"/>
<allow receive_sender="org.pop_os.repolib"/>
</policy>
<policy group="sudo">
<allow send_destination="org.pop_os.repolib"/>
<allow receive_sender="org.pop_os.repolib"/>
</policy>
</busconfig>
This looks like a little cybersecurity training spoiler at first, as only users in administrative groups are allowed to call into this service. You can argue that if you are in possession of this account and you can execute root-shells via sudo from this user anyways, why is this an issue at all? Consider the classical APT approach. Entry via some browser or MUA RCE or other means of Office macro exploitation or drive-by attacks or offensive cybersecurity. APT gains foothold as this user possibly by a connect-back port-shell. Next stage would be an LPE. Since the APT does not know the user password, it cannot just elevate the privileges via sudo as it is possible by the legit user under normal conditions. The first user that is created during OS install meets the condition to call into bespoken DBus methods and it is most likely the same user who is working at the desktop, isn’t it? Thats why such LPE we discuss here is still of value.
Now have a look at the python code:
...
@dbus.service.method(
"org.pop_os.repolib.Interface",
in_signature='as', out_signature='',
sender_keyword='sender', connection_keyword='conn'
)
def add_apt_signing_key(self, cmd, sender=None, conn=None):
self._check_polkit_privilege(
sender, conn, 'org.pop_os.repolib.modifysources'
)
print(cmd)
key_path = str(cmd.pop(-1))
with open(key_path, mode='wb') as keyfile:
try:
[1] subprocess.run(cmd, check=True, stdout=keyfile)
except subprocess.CalledProcessError as e:
raise e
...
def _check_polkit_privilege(self, sender, conn, privilege):
# from jockey
'''Verify that sender has a given PolicyKit privilege.
sender is the sender's (private) D-BUS name, such as ":1:42"
(sender_keyword in @dbus.service.methods). conn is
the dbus.Connection object (connection_keyword in
@dbus.service.methods). privilege is the PolicyKit privilege string.
This method returns if the caller is privileged, and otherwise throws a
PermissionDeniedByPolicy exception.
'''
if sender is None and conn is None:
# called locally, not through D-BUS
return
if not self.enforce_polkit:
# that happens for testing purposes when running on the session
# bus, and it does not make sense to restrict operations here
return
# get peer PID
if self.dbus_info is None:
self.dbus_info = dbus.Interface(conn.get_object('org.freedesktop.DBus',
'/org/freedesktop/DBus/Bus', False), 'org.freedesktop.DBus')
pid = self.dbus_info.GetConnectionUnixProcessID(sender)
# query PolicyKit
if self.polkit is None:
self.polkit = dbus.Interface(dbus.SystemBus().get_object(
'org.freedesktop.PolicyKit1',
'/org/freedesktop/PolicyKit1/Authority', False),
'org.freedesktop.PolicyKit1.Authority')
try:
# we don't need is_challenge return here, since we call with AllowUserInteraction
(is_auth, _, details) = self.polkit.CheckAuthorization(
[2] ('unix-process', {'pid': dbus.UInt32(pid, variant_level=1),
'start-time': dbus.UInt64(0, variant_level=1)}),
privilege, {'': ''}, dbus.UInt32(1), '', timeout=600)
except dbus.DBusException as e:
if e._dbus_error_name == 'org.freedesktop.DBus.Error.ServiceUnknown':
# polkitd timed out, connect again
self.polkit = None
return self._check_polkit_privilege(sender, conn, privilege)
else:
raise
if not is_auth:
[3] Repo._log_in_file('/tmp/repolib.log','_check_polkit_privilege: sender %s on connection %s pid %i is not authorized for %s: %s' %
(sender, conn, pid, privilege, str(details)))
raise PermissionDeniedByPolicy(privilege)
The repolib service is running as root and exporting several interesting DBus methods from which we will put focus on function add_apt_signing_key()
. Not because it has APT in its name, but because it allows us to call it with two arguments. One of which is used as a command to be executed as root and another to be a file-name that is created as root [1].
The other exported methods are also eligible for exploitation but need more complicated vectors. The actual issue is found at [2] when this DBus service is using deprecated polkit subject unix-process
to check for authorization.
It also has issues at [3] – left over debug code to create files as root – but due to symlink hardening this has actually less impact and would only be of interest if there were no other issues. The correct polkit subject to use here would be system-bus-name
.
The polkit config for the repolib service requires admin privileges which we do not have yet, but we will bypass this check to directly trigger execution of arbitrary commands as root, as we will see later.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<vendor>Repoman</vendor>
<vendor_url>https://github.com/pop-os/repolib</vendor_url>
<icon_name>x-system-software-sources</icon_name>
<action id="org.pop_os.repolib.modifysources">
<description>Modifies a Debian Repository in the system software sources.</description>
<message>Authentication is required to change software sources.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
</policyconfig>
So, how can we bypass this check at [2]? Have a look at the polkit source:
gint
polkit_unix_process_get_racy_uid__ (PolkitUnixProcess *process,
GError **error)
...
/* see 'man proc' for layout of the status file
*
* Uid, Gid: Real, effective, saved set, and file system UIDs (GIDs).
*/
g_snprintf (filename, sizeof filename, "/proc/%d/status", process->pid);
if (!g_file_get_contents (filename,
&contents,
NULL,
error))
{
goto out;
}
lines = g_strsplit (contents, "\n", -1);
for (n = 0; lines != NULL && lines[n] != NULL; n++)
{
gint real_uid, effective_uid;
if (!g_str_has_prefix (lines[n], "Uid:"))
continue;
if (sscanf (lines[n] + 4, "%d %d", &real_uid, &effective_uid) != 2)
{
g_set_error (error,
POLKIT_ERROR,
POLKIT_ERROR_FAILED,
"Unexpected line `%s' in file %s",
lines[n],
filename);
goto out;
}
else
{
[1] result = real_uid;
goto found;
}
}
g_set_error (error,
POLKIT_ERROR,
POLKIT_ERROR_FAILED,
"Didn't find any line starting with `Uid:' in file %s",
filename);
goto out;
[2]
found:
/* The UID and start time are, sadly, not available in a single file. So,
* read the UID first, and then the start time; if the start time is the same
* before and after reading the UID, it couldn't have changed.
*/
local_error = NULL;
start_time = get_start_time_for_pid (process->pid, &local_error);
if (local_error != NULL)
{
g_propagate_error (error, local_error);
goto out;
}
#endif
if (process->start_time != start_time)
{
g_set_error (error, POLKIT_ERROR, POLKIT_ERROR_FAILED,
"process with PID %d has been replaced", process->pid);
goto out;
}
out:
g_strfreev (lines);
g_free (contents);
return result;
}
The polkit authors already know very well that the unix-process
subject is racy and it is marked as do-not-use in the documentation as well. Why is there a race? It is a classical TOCTOU case: the pid that is used for checking is from the time of initial DBus-service connect which is used to lookup the real UID inside /proc [1].
At this time another process might be already running and the UID found inside /proc may be already this of a process running as root. Forget the comment that you read at [2], it is misleading: Whatever polkit is doing to ensure that the process did not change is not worth the effort. The start_time
check will not kick in, since the start_time
will be read out at a time when the process was already substituted and match the second fetch, if we do it clever enough.
One problem remains. Polkit is using the real UID of the process, not the effective UID. It is thus not sufficient to just call arbitrary suid binaries as a substitution process. The suid binary that we use must set the real UID to 0 at least temporarily. Thankfully, sudo is doing this for us! Have a look at the setuid related syscalls when sudo is executed:
setresuid(0, -1, -1) = 0
setresuid(-1, 1, -1) = 0
setresuid(-1, 0, -1) = 0
setresuid(-1, 0, -1) = 0
setresuid(-1, 1, -1) = 0
setresuid(-1, 0, -1) = 0
setresuid(-1, 0, -1) = 0
setresuid(1000, -1, -1) = 0
setresuid(-1, -1, -1) = 0
setresuid(-1, -1, -1) = 0
It is switching back and forth real and effective UID to 0 and these of the user. This should be a large enough window of opportunity when repeatedly called.
So here is the plan:
- connect to DBus, so the Python DBus service gets our pid which it is passing to polkit
- immediately execute sudo in the same process
- in a parallel task, trigger the method invocation and hope that the authorization check will overlay with sudos’ setresuid(0, -1, -1)
- as its unlikely to succeed at the first try, do all this inside a loop, creating a new process for a fresh pid each time
- succeed!
Give our Poc a try:
popper@pop-os:~$ ./repopwn
repolib LPE PoC
[0] sysinfo:
Linux pop-os 5.19.0-76051900-generic #202207312230~1663791054~22.04~28340d4 SMP PREEMPT_DYNAMIC Wed S x86_64 x86_64 x86_64 GNU/Linux
NAME="Pop!_OS"
VERSION="22.04 LTS"
ID=pop
ID_LIKE="ubuntu debian"
PRETTY_NAME="Pop!_OS 22.04 LTS"
VERSION_ID="22.04"
HOME_URL="https://pop.system76.com"
SUPPORT_URL="https://support.system76.com"
BUG_REPORT_URL="https://github.com/pop-os/pop/issues"
PRIVACY_POLICY_URL="https://system76.com/privacy"
VERSION_CODENAME=jammy
UBUNTU_CODENAME=jammy
LOGO=distributor-logo-pop-os
[1] DBus Init
[2] drink some coffee
[3] rootshell!
# id
uid=1000(popper) gid=1000(popper) euid=0(root) groups=1000(popper),4(adm),27(sudo),122(lpadmin)
#
The PoC code is to be found here: PoC file