深入分析SaltStack Salt命令注入漏洞

 

一、概述

11月3日,SaltStack发布了Salt的安全补丁,修复了三个高危漏洞。其中有两个修复程序,是为了解决外部研究人员通过ZDI项目提交的五个漏洞。这些漏洞可导致在运行Salt应用程序的系统上实现未经身份验证的命令注入。ZDI-CAN-11143是由一位匿名研究人员提交给ZDI的,而其余的漏洞则是我们发现的ZDI-CAN-11143的变体。在这篇文章中,我们将详细研究这些漏洞的根本原因。

 

二、漏洞详情

漏洞影响应用程序的rest-cherrypy netapi模块。rest-cherrypy模块为Salt提供REST API。该模块来源于Python的CherryPy模块,在默认情况下未启用。如果要启用rest-cherrypy模块,主配置文件/etc/salt/master中必须加入以下行:

rest_cherrypy: 
  Port: 8000 
  Disable_ssl: true

其中,/run终端非常重要。它通过salt-ssh子系统发出命令,而salt-ssh子系统会使用SSH来执行Salt例程。
发送到/run API的POST请求将调用salt.netapi.rest_cherrypy.app.Run类的POST()方法,这个类最终会调用salt.netapi.NetapiClientrun()方法:

class NetapiClient(object): 
    # [... Truncated ...] 

    salt.exceptions.SaltInvocationError( 
               # "Invalid client specified: '{0}'".format(low.get("client")) 
                "Invalid client specified: '{0}'".format(CLIENTS) 
            ) 

        if not ("token" in low or "eauth" in low): 
            raise salt.exceptions.EauthAuthenticationError( 
                "No authentication credentials given" 
            ) 

        if low.get("raw_shell") and not self.opts.get("netapi_allow_raw_shell"): 
            raise salt.exceptions.EauthAuthenticationError( 
                "Raw shell option not allowed." 
            ) 

        l_fun = getattr(self, low["client"]) 
        f_call = salt.utils.args.format_call(l_fun, low) 
        return l_fun(*f_call.get("args", ()), **f_call.get("kwargs", {})) 

 def local_batch(self, *args, **kwargs): 
        """ 
        Run :ref:`execution modules <all-salt.modules>` against batches of minions 

        .. versionadded:: 0.8.4 

        Wraps :py:meth:`salt.client.LocalClient.cmd_batch` 

        :return: Returns the result from the execution module for each batch of 
            returns 
        """ 
        local = salt.client.get_local_client(mopts=self.opts) 
        return local.cmd_batch(*args, **kwargs) 

    def ssh(self, *args, **kwargs): 
        """ 
        Run salt-ssh commands synchronously 

        Wraps :py:meth:`salt.client.ssh.client.SSHClient.cmd_sync`. 

        :return: Returns the result from the salt-ssh command 
        """ 
        ssh_client = salt.client.ssh.client.SSHClient( 
            mopts=self.opts, disable_custom_roster=True 
        ) 
        return ssh_client.cmd_sync(kwargs)

如上所示,run()方法负责验证client参数的值。client参数的有效值包括locallocal_asynclocal_batchlocal_subsetrunnerrunner_asyncsshwheelwheel_async。在验证了client参数后,它将检查请求中是否存在token或eauth参数。值得关注的是,这个方法无法验证token或eauth参数的值。因此,无论token或eauth参数是任何值,都可以通过检查。在通过检查后,该方法会根据client参数的值,去调用相应的方法。

如果client参数值为ssh时,将触发漏洞。在这种情况下,run()方法将调用ssh()方法。ssh()方法通过调用salt.client.ssh.client.SSHClient类的cmd_sync()方法同步执行ssh-salt命令,最终会调用_prep_ssh()方法。

class SSHClient(object): 
    # [... Truncated] 

    def _prep_ssh( 
        self, tgt, fun, arg=(), timeout=None, tgt_type="glob", kwarg=None, **kwargs 
    ): 
        """ 
        Prepare the arguments 
        """ 
        opts = copy.deepcopy(self.opts) 
        opts.update(kwargs) 
        if timeout: 
            opts["timeout"] = timeout 
        arg = salt.utils.args.condition_input(arg, kwarg) 
        opts["argv"] = [fun] + arg 
        opts["selected_target_option"] = tgt_type 
        opts["tgt"] = tgt 
        return salt.client.ssh.SSH(opts) 

    def cmd( 
        self, tgt, fun, arg=(), timeout=None, tgt_type="glob", kwarg=None, **kwargs 
    ): 
        ssh = self._prep_ssh(tgt, fun, arg, timeout, tgt_type, kwarg, **kwargs) #<-------------- calls ZDI-CAN-11143 
        final = {} 
        for ret in ssh.run_iter(jid=kwargs.get("jid", None)): #<------------- ZDI-CAN-11173 
            final.update(ret) 
        return final 

    def cmd_sync(self, low): 
        kwargs = copy.deepcopy(low) 

        for ignore in ["tgt", "fun", "arg", "timeout", "tgt_type", "kwarg"]: 
            if ignore in kwargs: 
                del kwargs[ignore] 

        return self.cmd( 
            low["tgt"], 
            low["fun"], 
            low.get("arg", []), 
            low.get("timeout"), 
            low.get("tgt_type"), 
            low.get("kwarg"), 
            **kwargs 
        )            #<------------------- calls

_prep_ssh()函数设置参数,并初始化SSH对象。

 

三、触发漏洞

触发漏洞的请求如下:

curl -i $salt_ip_addr:8000/run -H "Content-type: application/json" -d '{"client":"ssh","tgt":"A","fun":"B","eauth":"C","ssh_priv":"|id>/tmp/test #"}'

在这里,client参数的值为ssh,存在漏洞的参数是ssh_priv。在内部,ssh_priv参数在SSH对象初始化期间会用到,如下所示:

SSH(object): 
    """ 
    Create an SSH execution system 
    """ 

    ROSTER_UPDATE_FLAG = "#__needs_update" 

    def __init__(self, opts): 
        self.__parsed_rosters = {SSH.ROSTER_UPDATE_FLAG: True} 
        pull_sock = os.path.join(opts["sock_dir"], "master_event_pull.ipc") 
        if os.path.exists(pull_sock) and zmq: 
            self.event = salt.utils.event.get_event( 
                "master", opts["sock_dir"], opts["transport"], opts=opts, listen=False 
            ) 
        else: 
            self.event = None 
        self.opts = opts 
        if self.opts["regen_thin"]: 
            self.opts["ssh_wipe"] = True 
        if not salt.utils.path.which("ssh"): 
            raise salt.exceptions.SaltSystemExit( 
                code=-1, 
                msg="No ssh binary found in path -- ssh must be installed for salt-ssh to run. Exiting.", 
            ) 
        self.opts["_ssh_version"] = ssh_version() 
        self.tgt_type = ( 
            self.opts["selected_target_option"] 
            if self.opts["selected_target_option"] 
            else "glob" 
        ) 
        self._expand_target() 
        self.roster = salt.roster.Roster(self.opts, self.opts.get("roster", "flat")) 
        self.targets = self.roster.targets(self.opts["tgt"], self.tgt_type) 
        if not self.targets: 
            self._update_targets() 
        # If we're in a wfunc, we need to get the ssh key location from the 
        # top level opts, stored in __master_opts__ 
        if "__master_opts__" in self.opts: 
            if self.opts["__master_opts__"].get("ssh_use_home_key") and os.path.isfile( 
                os.path.expanduser("~/.ssh/id_rsa") 
            ): 
                priv = os.path.expanduser("~/.ssh/id_rsa") 
            else: 
                priv = self.opts["__master_opts__"].get( 
                    "ssh_priv", 
                    os.path.join( 
                        self.opts["__master_opts__"]["pki_dir"], "ssh", "salt-ssh.rsa" 
                    ), 
                ) 
        else: 
            priv = self.opts.get( 
                "ssh_priv", os.path.join(self.opts["pki_dir"], "ssh", "salt-ssh.rsa") 
            ) 
        if priv != "agent-forwarding": 
            if not os.path.isfile(priv): 
                try: 
                    salt.client.ssh.shell.gen_key(priv) 
                except OSError: 
                    raise salt.exceptions.SaltClientError( 
                        "salt-ssh could not be run because it could not generate keys.\n\n" 
                        "You can probably resolve this by executing this script with " 
                        "increased permissions via sudo or by running as root.\n" 
                        "You could also use the '-c' option to supply a configuration " 
                        "directory that you have permissions to read and write to." 
                    )

ssh_priv参数的值用于SSH私有文件。如果ssh_priv值对应的文件不存在,则调用/salt/client/ssh/shell.pygen_key()方法来创建文件,并将ssh_priv作为path参数传递给该方法。基本上,gen_key()方法生成公钥和私钥密钥对,并将其存储在path参数定义的文件中。

def gen_key(path): 
    """ 
    Generate a key for use with salt-ssh 
    """ 
    cmd = 'ssh-keygen -P "" -f {0} -t rsa -q'.format(path) 
    if not os.path.isdir(os.path.dirname(path)): 
        os.makedirs(os.path.dirname(path)) 
    subprocess.call(cmd, shell=True)

从上面的方法中我们可以看到,这里并没有清除路径,且在后续的Shell命令中使用了该路径来创建RSA密钥对。如果ssh_priv包含命令注入字符,则可以在通过subprocess.call()方法执行命令时执行用户控制的命令。这样就导致攻击者可以在运行Salt应用程序的系统上运行任意命令。

在进一步研究SSH对象初始化方法之后,可以观察到多个变量设置为用户控制的HTTP参数的值。随后,这些变量在shell命令中用作执行SSH命令的参数。在这里,userportremote_port_forwardsssh_options变量非常容易受到攻击,如下所示:

class SSH(object): 
    """ 
    Create an SSH execution system 
    """ 

    ROSTER_UPDATE_FLAG = "#__needs_update" 

    def __init__(self, opts): 
    # [...] 


 self.targets = self.roster.targets(self.opts["tgt"], self.tgt_type) 
        if not self.targets: 
            self._update_targets() 
    # [...] 
     self.defaults = { 
            "user": self.opts.get( 
                "ssh_user", salt.config.DEFAULT_MASTER_OPTS["ssh_user"] 
            ),  
            "port": self.opts.get( 
                "ssh_port", salt.config.DEFAULT_MASTER_OPTS["ssh_port"] 
            ),  # <------------- vulnerable parameter 
            "passwd": self.opts.get( 
                "ssh_passwd", salt.config.DEFAULT_MASTER_OPTS["ssh_passwd"] 
            ), 
            "priv": priv, 
            "priv_passwd": self.opts.get( 
                "ssh_priv_passwd", salt.config.DEFAULT_MASTER_OPTS["ssh_priv_passwd"] 
            ), 
            "timeout": self.opts.get( 
                "ssh_timeout", salt.config.DEFAULT_MASTER_OPTS["ssh_timeout"] 
            ) 
            + self.opts.get("timeout", salt.config.DEFAULT_MASTER_OPTS["timeout"]), 
            "sudo": self.opts.get( 
                "ssh_sudo", salt.config.DEFAULT_MASTER_OPTS["ssh_sudo"] 
            ), 
            "sudo_user": self.opts.get( 
                "ssh_sudo_user", salt.config.DEFAULT_MASTER_OPTS["ssh_sudo_user"] 
            ), 
            "identities_only": self.opts.get( 
                "ssh_identities_only", 
                salt.config.DEFAULT_MASTER_OPTS["ssh_identities_only"], 
            ), 
            "remote_port_forwards": self.opts.get("ssh_remote_port_forwards"), # <------------- vulnerable parameter 
            "ssh_options": self.opts.get("ssh_options"), # <------------- vulnerable parameter 
        } 

    def _update_targets(self): 
        """ 
        Update targets in case hostname was directly passed without the roster. 
        :return: 
        """ 
        hostname = self.opts.get("tgt", "")  
        if "@" in hostname: 
            user, hostname = hostname.split("@", 1) # <------------- vulnerable parameter 
        else: 
            user = self.opts.get("ssh_user") # <------------- vulnerable parameter  
        if hostname == "*": 
            hostname = "" 

        if salt.utils.network.is_reachable_host(hostname): 
            hostname = salt.utils.network.ip_to_host(hostname) 
            self.opts["tgt"] = hostname 
            self.targets[hostname] = { 
                "passwd": self.opts.get("ssh_passwd", ""), 
                "host": hostname, 
                "user": user, 
            } 
            if self.opts.get("ssh_update_roster"): 
                self._update_roster()

_update_targets()方法设置user变量,该变量取决于tgtssh_user的值。如果HTTP参数tgt的值使用了“username@localhost”的格式,则会将“username”分配给用户变量。否则,user的值由ssh_user参数设置。portremote_port_forwardsssh_options的值分别由HTTP参数ssh_portssh_remote_port_forwardsssh_options定义。

在初始化SSH对象后,_prep_ssh()方法通过handle_ssh()产生一个子进程,最终会执行salt.client.ssh.shell.Shell类的exec_cmd()方法。

def exec_cmd(self, cmd): 
        """ 
        Execute a remote command 
        """ 
        cmd = self._cmd_str(cmd) 

        logmsg = "Executing command: {0}".format(cmd) 
        if self.passwd: 
            logmsg = logmsg.replace(self.passwd, ("*" * 6)) 
        if 'decode("base64")' in logmsg or "base64.b64decode(" in logmsg: 
            log.debug("Executed SHIM command. Command logged to TRACE") 
            log.trace(logmsg) 
        else: 
            log.debug(logmsg) 

        ret = self._run_cmd(cmd)  # <--------------- calls 
        return ret 

    def _cmd_str(self, cmd, ssh="ssh"): 
        """ 
        Return the cmd string to execute 
        """ 

        # TODO: if tty, then our SSH_SHIM cannot be supplied from STDIN Will 
        # need to deliver the SHIM to the remote host and execute it there 

        command = [ssh] 
        if ssh != "scp": 
            command.append(self.host) 
        if self.tty and ssh == "ssh": 
            command.append("-t -t") 
        if self.passwd or self.priv: 
            command.append(self.priv and self._key_opts() or self._passwd_opts()) 
        if ssh != "scp" and self.remote_port_forwards: 
            command.append( 
                " ".join( 
                    [ 
                        "-R {0}".format(item) 
                        for item in self.remote_port_forwards.split(",") 
                    ] 
                ) 
            ) 
        if self.ssh_options: 
            command.append(self._ssh_opts()) 

        command.append(cmd) 

        return " ".join(command) 

    def _run_cmd(self, cmd, key_accept=False, passwd_retries=3): 
        # [...] 

        term = salt.utils.vt.Terminal( 
            cmd, 
            shell=True, 
            log_stdout=True, 
            log_stdout_level="trace", 
            log_stderr=True, 
            log_stderr_level="trace", 
            stream_stdout=False, 
            stream_stderr=False, 
        ) 
        sent_passwd = 0 
        send_password = True 
        ret_stdout = "" 
        ret_stderr = "" 
        old_stdout = "" 

        try: 
            while term.has_unread_data: 
                stdout, stderr = term.recv()

如上述代码所示,exec_cmd()首先调用the_cmd_str()方法来创建一个命令字符串,而不进行任何验证。然后,它通过显式调用系统Shell程序,调用_run_cmd()来执行命令。这会将命令注入字符视为Shell的元字符,而并非是命令的参数。一旦执行这个精心设计的命令字符串,就可能会导致任意命令注入。

 

四、总结

SaltStack发布了修复程序,以解决命令注入和身份验证绕过漏洞。同时,他们为这两个漏洞分配了编号CVE-2020-16846和CVE-2020-25592。CVE-2020-16846的修复原理是在执行命令时禁用系统Shell,以此来解决漏洞问题。禁用系统Shell意味着Shell元字符会被视为第一个命令的参数的一部分。

CVE-2020-25592的修复原理是添加对eauthtoken参数的验证,以此来修复漏洞。这样一来,就仅允许有效用户通过rest-cherrypy netapi模块访问salt-ssh功能。这是我们的ZDI项目中收到的第一个SaltStack漏洞,后续的工作也非常重要。我们期待以后能接收到更多的提交报告。

大家可以关注我的Twitter @ nktropy,跟进团队研究进展,获取最新的漏洞利用技术和安全补丁。

(完)