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.io/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.