大数跨境
0
0

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

详述SaltStack Salt 命令注入漏洞(CVE-2020-16846/25592) 代码卫士
2020-12-01
1
导读:速修复

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

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





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.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 参数的有效值为 “local”、”local_async”、”local_batch”、”local_subset”、”runner”、”runner_async”、”ssh”、”wheel” 和 “wheel_async”。验证 client 参数后,会查看该请求中是否存在 tokeneauth 参数。有意思的是,该方法并未验证 tokeneauth 参数的值。为此,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.pygen_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 命令中的参数。这里,变量 userportremote_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_portssh_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)

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

产品线。

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


【声明】内容源于网络
0
0
代码卫士
奇安信代码卫士是国内第一家专注于软件开发安全的产品线,产品涵盖代码安全缺陷检测、软件编码合规检测、开源组件溯源检测三大方向,分别解决软件开发过程中的安全缺陷和漏洞问题、编码合规性问题、开源组件安全管控问题。本订阅号提供国内外热点安全资讯。
内容 3434
粉丝 0
代码卫士 奇安信代码卫士是国内第一家专注于软件开发安全的产品线,产品涵盖代码安全缺陷检测、软件编码合规检测、开源组件溯源检测三大方向,分别解决软件开发过程中的安全缺陷和漏洞问题、编码合规性问题、开源组件安全管控问题。本订阅号提供国内外热点安全资讯。
总阅读766
粉丝0
内容3.4k