详述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: 8000Disable_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.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() 方法被调用。_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"] = 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
触发该漏洞的易受攻击请求如下:
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() 方法生成公共和私有 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 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 变量。否则,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) # <--------------- 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() 首先调用 _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 计划披露,而且非常耐人寻味。
https://www.thezdi.com/blog/2020/11/24/detailing-saltstack-salt-command-injection-vulnerabilities
题图:Pixabay License
本文由奇安信代码卫士编译,不代表奇安信观点。转载请注明“转自奇安信代码卫士 https://codesafe.qianxin.com”。
奇安信代码卫士 (codesafe)
国内首个专注于软件开发安全的
产品线。
觉得不错,就点个 “在看” 或 "赞” 吧~
