深入分析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: 8000Disable_ssl: true
其中,/run终端非常重要。它通过salt-ssh子系统发出命令,而salt-ssh子系统会使用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.4Wraps :py:meth:`salt.client.LocalClient.cmd_batch`:return: Returns the result from the execution module for each batch ofreturns"""local = salt.client.get_local_client(mopts=self.opts)return local.cmd_batch(*args, **kwargs)def ssh(self, *args, **kwargs):"""Run salt-ssh commands synchronouslyWraps :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()方法。
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"] = timeoutarg = salt.utils.args.condition_input(arg, kwarg)opts["argv"] = [fun] + argopts["selected_target_option"] = tgt_typeopts["tgt"] = tgtreturn 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-11143final = {}for ret in ssh.run_iter(jid=kwargs.get("jid", None)): #<------------- ZDI-CAN-11173final.update(ret)return finaldef 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 = Noneself.opts = optsif self.opts["regen_thin"]:self.opts["ssh_wipe"] = Trueif 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()方法生成公钥和私钥密钥对,并将其存储在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命令的参数。在这里,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 parameterelse:user = self.opts.get("ssh_user") # <------------- vulnerable parameterif hostname == "*":hostname = ""if salt.utils.network.is_reachable_host(hostname):hostname = salt.utils.network.ip_to_host(hostname)self.opts["tgt"] = hostnameself.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的值由ssh_user参数设置。port、remote_port_forwards和ssh_options的值分别由HTTP参数ssh_port、ssh_remote_port_forwards和ssh_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) # <--------------- callsreturn retdef _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 therecommand = [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 = 0send_password = Trueret_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的修复原理是添加对eauth和token参数的验证,以此来修复漏洞。这样一来,就仅允许有效用户通过rest-cherrypy netapi模块访问salt-ssh功能。这是我们的ZDI项目中收到的第一个SaltStack漏洞,后续的工作也非常重要。我们期待以后能接收到更多的提交报告。
大家可以关注我的Twitter @ nktropy,跟进团队研究进展,获取最新的漏洞利用技术和安全补丁。
译文声明
译文仅供参考,具体内容表达以及含义原文为准。
