一、概述

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: 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参数的值。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"] = 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对象。

三、触发漏洞

触发漏洞的请求如下:

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()方法生成公钥和私钥密钥对,并将其存储在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()方法设置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)  #         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()首先调用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,跟进团队研究进展,获取最新的漏洞利用技术和安全补丁。

译文声明

译文仅供参考,具体内容表达以及含义原文为准。

戳“阅读原文”查看更多内容

命令注入_深入分析SaltStack Salt命令注入漏洞相关推荐

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

     聚焦源代码安全,网罗国内外最新资讯! 编译:奇安信代码卫士团队 11月3日,SaltStack 发布 Salt 安全补丁,修复了三个严重漏洞,其中两个是为了回应起初通过 ZDI 报告的5个 bug. ...

  2. 05_SQL注入_功能语句报错注入盲注

    05_SQL注入_功能语句&报错回显&盲注 1. SQL 语句和网站功能 1.1 Web开发中常见语句 [本章代码来源于pikachu和sqli-lab中的靶场] 开发中,根据不同的需 ...

  3. mysql 常规命令操作_常见的MySQL命令大全

    一.连接MySQL格式: mysql -h主机地址 -u用户名 -p用户密码1.例1:连接到本机上的MYSQL.首先在打开DOS窗口,然后进入目录 mysqlbin,再键入命令mysql -uroot ...

  4. java远程线程注入_系统权限远程线程注入到Explorer.exe

    提升为系统权限,注入到explorer中 一丶简介 我们上一面说了系统服务拥有系统权限.并且拥有system权限.还尝试启动了一个进程. 那么我们是不是可以做点坏事了. 我们有一个系统权限进程.而调用 ...

  5. 命令 结构_只需一个命令!从你的U盘里读出更多内容

    U盘是我们最常使用的一种USB设备,本文使用DOSUSB做驱动,试图以读取扇区的方式读取你的U盘. 温馨提示 本文涉及的协议可能会比较多. 了解你的U盘 首先我们用程序usbview.exe去看一下你 ...

  6. factorybean 代理类不能按照类型注入_彻底搞懂依赖注入(一)Bean实例创建过程

    点击上方"Java知音",选择"置顶公众号" 技术文章第一时间送达! 上一章介绍了Bean的加载过程(IOC初始化过程),加载完成后,紧接着就要用到它的依赖注入 ...

  7. sql 整改措施 注入_记一次Sql注入 解决方案

    老大反馈代码里面存在sql注入,这个漏洞会导致系统遭受攻击,定位到对应的代码,如下图所示 image like 进行了一个字符串拼接,正常的情况下,前端传一个 cxk 过来,那么执行的sql就是 se ...

  8. java经常用到的命令词_从关键词到命令,一种全新的上网方式

    先提一个谁都没认真想过的问题,你是如何上网的? 上网嘛,不就是进入网站,鼠标点点嘛. 高级点的网虫会说自己能输入关键词搜索. 不过肯定没人这么说,上网我除了关键词还敲击命令. 有的时候,用命令来解决问 ...

  9. python logging命令注入_整理后的手动注入脚本命令

    1.判断是否有注入;and 1=1 ;and 1=2 2.初步判断是否是mssql ;and user>0 3.注入参数是字符'and [查询条件] and ''=' 4.搜索时没过滤参数的'a ...

最新文章

  1. 计算机名称改变之后,HOUDINI Server 连接不上的解决办法
  2. c语言220程序,《C语言程序实例大全》原代码220例
  3. python引用类 魔法方法_Python 学习笔记 -- 类的魔法方法
  4. 中文条件jsp mysql_jsp MySQL中的一些中文问题的解决
  5. Ollydbg中断方法浅探
  6. java对import语句_Java的import语句 - 不积跬步,无以至千里 - 51Testing软件测试网 51Testing软件测试网-软件测试人的精神家园...
  7. php 中 stream_select 中的小窟窿.
  8. python中字典的find_python中的字典
  9. MongoDB 3.0+访问数据库的方法
  10. 也用C#做个视频监控客户端来玩玩
  11. loadrunner- winsock 函数 一览表
  12. dispimg函数怎么用_excel中的lookup函数究竟该怎么用?如何才能准确理解它的用法?...
  13. 计算机图形学(四)—— 实验四:种子填充算法
  14. 网易轻舟服务网格数据面性能优化实践
  15. 日语开发java自我介绍,优秀日语自我介绍范文
  16. 编程语言入门YC创始人Paul Graham:如何开始创业
  17. win10服务器系统进不去怎么办,win10开机进不去系统怎么办。
  18. FineReport自动数据点提示轮播接口
  19. 计算机系统安全启动,关闭电脑的安全启动项( Secure Boot )
  20. 并发编程系列之并发编程的认识

热门文章

  1. 师范物理考研换计算机需要报班么,东北师范大学凝聚态物理考研心路历程
  2. sql多字段求和降序排序_快速入门:Excel中如何按照多个字段排序
  3. 20 30 tph complete crushing plant price
  4. shell编程防火墙快速配置脚本
  5. 为什么使用代理IP后导致网速变慢?
  6. delphi winexec打开exe文件
  7. 基于springboot的微信公众号管理系统(支持多公众号接入)
  8. android屏幕投影到pc
  9. pygame,上下左右移动
  10. android自动测试2:使用android studio实现设备循环自动重启