cmd ssh不是内部命令_SaltStack Salt中的命令注入漏洞细节分析

8605f0bd752f6205fac25a21d6488e03.png 点击上方 蓝字 关注我们 11月3日,SaltStack发布了Salt的安全补丁,以修复三个关键漏洞。其中两个补丁程序是为了修复由ZDI程序上报的五个漏洞。这些漏洞可用于在运行包含漏洞的Salt应用程序的系统上,实现未经身份验证的命令注入。本篇文章中,安全研究人员将分析这些漏洞产生的根本原因。 漏洞细节 该漏洞影响Salt应用程序的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子系统允许使用安全外壳协议(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 ` 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参数的值。客户端参数的有效值为“local”、“local_async”、“local_batch”、“local_subset”、“runner”、“runner_async”、“ssh”、“wheel”和“wheel_async”。验证了客户端参数之后,它将检查请求中是否存在token(令牌)或eauth参数。有趣的是,该方法无法验证token(令牌)或eauth参数的值。因此,token(令牌)或eauth参数的任意值都可以通过此检查。通过此检查后,该方法将根据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) #        final = {}         for ret in ssh.run_iter(jid=kwargs.get("jid", None)): #            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         )            #
_prep_ssh()函数设置参数并初始化SSH对象 。 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 #"}'
该情况下,客户端参数的值为“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作为路径参数传递给该方法。基本上,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)
上 面显示的方法表明路径未被清除,并且被用于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"]             ),  #             "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"), #             "ssh_options": self.opts.get("ssh_options"), #         }      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) #         else:             user = self.opts.get("ssh_user") #         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()方法设置用户变量,该变量取决于tgt或ssh_user值。如果tgt HTTP参数的值采用“username@localhost”格式,则将“username”分配给用户变量。否则,user的值由ssh_user参数设置。port、remote_port_forwards和ssh_options值分别由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)  #         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()
如上所示,ex ec_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功能。 c097cadb00e1b8b4b87760d3627764c0.png

END

9768ce5d3a0f358ea8a00557cb73acdc.png

好文!必须在看
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值