protecting against unsafe use of screen/tmux

15 Dec 2017 09:35 | linux | macOS | security | bash

It occurred to me recently that a lot of people probably use screen or tmux in
ways that leave an easy path to privilege escalation open. For example if you
start a screen session as your local user and then escalate to root inside the
screen session. As soon as you do that, anyone with access to the non-root
account can simply resume the screen session and immediately be root.

It is therefore sensible to never do this and always escalate *before* starting
a screen or tmux session. I'm pretty sure I've done this loads of times without
really thinking about it. I decided to look into simple ways to mitigate this.

On linux it's pretty easy, we can add this code to /root/.bashrc:

-------------------------------------------------------------------------------
pid=$$

screen=`which screen`
tmux=`which tmux`

while :
do
  parent=`ps -o ppid= $pid 2>/dev/null | xargs`

  if [ "$parent" == "1" ] ; then
    break
  fi

  exe=`readlink -f /proc/$parent/exe`
  owner=`ps h -o ruser -p $parent`

  if [ "$owner" != "root" ] ; then
    if [ "$exe" == "$screen" -o "$exe" == "$tmux" ] ; then
      echo "unsafe escalation - escalate to root *before* running screen/tmux!"
      kill -9 `ps -o ppid= $$ 2>/dev/null | xargs`
      exit 0
    fi
  fi

  pid=$parent
done
-------------------------------------------------------------------------------

Now if we spawn a root-owned shell from inside a screen this code will execute
when it starts up. It walks up the parent process hierarchy and if it finds
screen or tmux running as a non-root user it will terminate its parent and thus
kill the escalation. This is what we see when this happens:

$ screen
$ sudo bash
[sudo] password for user:
unsafe escalation - escalate to root *before* running screen/tmux!
Killed
a ~ $

Of course we can still escalate to root *outside* the screen session and we
can still use sudo for other things inside screen that won't leave a root-owned
shell running.

Doing this on macOS is a bit more complicated for a couple of reasons - firstly
there's the issue I blogged about here:

https://m4.rkw.sh/blog/macos-sudo-wtf.html

In that the default sudoers file that ships with macOS has the HOME path set to
inherit when escalating with sudo, leading to your local admin's dotfiles being
executed as root when you escalate. I would strongly recommend disabling this.
If you like the convenience of keeping your HOME environment variable when
escalating you can simply add this to /var/root/.bashrc:

export HOME=/Users/user

(or whatever your home path is). This gives you basically the same convenience
without the security compromise of having your dotfiles executed as root every
time.

But I digress. For the purposes of this post I'll assume that you have made this
change and that when you sudo your /var/root/.bashrc is the one that gets
executed rather than the non-root user's one.

The second problem with doing this on macOS is that there's not (at least as far
as I know) an easy way to look up the real binary path for a process without
using a system call. We have no handy proc filesystem like we have on linux and
I'm not really a big fan of fuse.

So first we need to write a little tool that will take a process id and give us
the real path to its binary:

-------------------------------------------------------------------------------
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <libproc.h>

int main (int argc, char* argv[])
{
    pid_t pid; int ret;
    char pathbuf[PROC_PIDPATHINFO_MAXSIZE];
    int i;

    if (argc < 2) {
      printf("usage: %s <pid>\n", argv[0]);
      return 0;
    }

    ret = proc_pidpath (atoi(argv[1]), pathbuf, sizeof(pathbuf));

    if ( ret > 0 ) {
      printf("%s\n", pathbuf);
    } else {
      fprintf(stderr, "%s\n", strerror(errno));
    }

    return 0;
}
-------------------------------------------------------------------------------

Stick this in /usr/local/bin/ like so:

$ sudo gcc -o /usr/local/bin/psr psr.c

Now we can grab the real binary path for any process:

$ psr $$
/bin/bash
$

Cool. By the way the proc_pidpath() system call is quite handy when examining
processes on your system. The process name shown in the ps output can be easily
manipulated by overwriting argv[0] but I have no found a way to mask the real
binary path returned by proc_pidpath(). It seems to be a low-level kernel
function.

So now we just need a bit of bash similar to the linux version in our
/var/root/.bashrc file:

-------------------------------------------------------------------------------
screen=`which screen`
tmux=`which tmux`

function expand_path()
{
  p=$1

  while :
  do
    realpath=`python -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' $p`
    if [ "$realpath" == "$p" ] ; then
      break
    fi
    p=$realpath
  done
}

if [ "$screen" != "" ] ; then
  expand_path $screen
  screen=$p
fi

if [ "$tmux" != "" ] ; then
  expand_path $tmux
  tmux=$p
fi

pid=$$

while :
do
  parent=`ps -o ppid= $pid 2>/dev/null | xargs`

  if [ "$parent" == "1" ] ; then
    break
  fi

  exe=`/usr/local/bin/psr $parent`
  owner=`ps h -o ruser= $parent`

  if [ "$owner" != "root" ] ; then
    if [ "$exe" == "$screen" -o "$exe" == "$tmux" ] ; then
      echo "unsafe escalation - don't do this in a non-root screen/tmux session!"
      kill -9 `ps -o ppid= $$ 2>/dev/null | xargs`
      exit 0
    fi
  fi

  pid=$parent
done
-------------------------------------------------------------------------------

It's a little more complicated because macOS package managers often install
binaries using symlinks. I use screen from macports because it seems to work
better than the standard one but the path returned by which is a symlink which
obviously isn't useful if we're comparing against the output of the
proc_pidpath() call. Also macOS doesn't seem to support readlink -f so we need
to use a tiny bit of python to expand the symlinks.