Your Joomla front-end starts returning 500 errors on every page. Admin still loads. Joomla’s own error log is empty. If that’s where you are right now, and you run the JCE editor, read this before you do anything else — you may be looking at the 2026 JCE profile-import remote code execution chain, and the fix is methodical rather than complicated.

TL:DR – This is a write-up of a real incident response on a production Joomla 6.1.1 site in June 2026. Names, paths and database prefixes are anonymised; the technical details are exactly as encountered. If your symptoms match, the playbook below will get you out cleanly.

The symptom that tells you it’s this attack

The combination is distinctive:

  • Every front-end page returns HTTP 500.
  • Joomla admin loads normally.
  • Joomla’s error log files are empty — no entries around the time the 500s started.
  • The PHP-FPM or Apache error log (your host’s log, not Joomla’s) shows a fatal of the form: Cannot redeclare function posturl() (previously declared in /home/youruser/public_html/libraries/tmp:68).

The empty Joomla log is the giveaway: the fatal happens before Joomla’s logger initialises, so only the web server’s own log captures it. The path libraries/tmp — with no file extension — is the second clue. Joomla ships no such file. It’s a webshell.

Diagnosing the redeclare

The redeclare fatal means the same PHP file is being included twice on one request. The shell loaded by the second include tries to re-define a function the first include already created, and PHP gives up. Find the include point:

grep -RIln "include 'libraries/tmp'" \
    ~/public_html/libraries/ \
    ~/public_html/administrator/ \
    ~/public_html/plugins/ 2>/dev/null

In the case I dealt with, the result was a single, very telling file:

/home/youruser/public_html/libraries/src/Document/HtmlDocument.php:734:include 'libraries/tmp';
/home/youruser/public_html/libraries/src/Document/HtmlDocument.php:735:include 'libraries/tmp';

Joomla’s core HtmlDocument.php — the class that renders every HTML response on the front-end — has been patched to include the webshell twice on every page render. Two consecutive includes is the redeclare. That’s why admin is unaffected: admin uses different rendering paths that boot before HtmlDocument is engaged, so the shell loads once and silently executes, while the front-end double-loads and fatals.

Finding the rest of the payload

Once you know one core file has been modified and one shell exists, you have to assume there’s more. Don’t patch HtmlDocument.php and stop — you’ll likely be hit again within hours.

Instead, pull a list of every .php file that’s been touched since the start of the compromise window. Pick a date earlier than your earliest suspicious file modification — in this case, the attacker’s first XML upload was eight days before symptoms appeared. Be generous with the window.

mkdir -p ~/forensics/$(date +%F)

find ~/public_html -newermt '2026-06-08 00:00:00' -type f -name '*.php' \
    -not -path '*/cache/*' \
    -not -path '*/administrator/cache/*' \
    -not -path '*/logs/*' \
    -not -path '*/administrator/logs/*' \
    -not -path '*/tmp/*' \
    -printf '%T+ %p\n' | sort > ~/forensics/$(date +%F)/php-touched.txt

wc -l ~/forensics/$(date +%F)/php-touched.txt
head -50 ~/forensics/$(date +%F)/php-touched.txt

The output looks scary — you’ll likely see hundreds or thousands of lines. Most of those are legitimate: Joomla updates, extension updates, your own work. The malware itself is a tiny minority. The trick is knowing which patterns are never legitimate.

The patterns that are always hostile

Run these targeted finds. Anything they return is malware, not a false positive.

# PHP files inside /css/ or /js/ asset folders — never legitimate
find ~/public_html -path '*/css/*.php' -o -path '*/js/*.php' 2>/dev/null

# PHP files anywhere under /images/ or in unexpected /media/ paths
find ~/public_html/images ~/public_html/media -name '*.php' -ls

# Random-hex directories under /language/ or /api/language/
find ~/public_html/language ~/public_html/api/language \
    -mindepth 1 -maxdepth 1 -type d \
    -name '????????????????????????????????' 2>/dev/null

# Known-bad code patterns in any PHP file
grep -RIln --include='*.php' \
    'eval(\$_\|eval(base64_decode\|gzinflate(base64_decode\|str_rot13(base64_decode' \
    ~/public_html/ 2>/dev/null

In this incident the sweeps surfaced nine hostile files in total:

PathWhy it’s hostile
tmp/nx<random>.php “Nxploited” commodity webshell payload
tmp/nx<random>.txt Canary file with the string Nxploited — confirms write access to the attacker
libraries/tmp No extension; libraries/ only ships .php and class folders
media/com_contenthistory/js/link.php /js/ folders should only contain JavaScript
media/com_templates/css/restorer.php /css/ folders should only contain stylesheets
language/<32-hex>/sitemaps.php Random-hex directories under language/ are never legitimate
api/language/<32-hex>/zip.php Same pattern
modules/mod_languages/src/zip/<32-hex>.php Random-hex filename in a core module path
libraries/src/Document/HtmlDocument.php Core file modified — the double include we already found

The entry point: jce_poc_*.xml

Looking inside the site’s tmp/ directory you’ll likely find files of this form:

jce_poc_2116790_140711667635776.xml
jce_poc_2116790_140711852275264.xml
jce_poc_2134851_140731096225344.xml
...

“poc” = proof of concept. These are the residue of automated scanners trying the JCE profile-import RCE chain. Read one:

<?xml version="1.0" encoding="utf-8"?>
<profiles>
  <profiles>
    <profile>
      <name>Pwned</name>
      <description>RCE via JCE</description>
      <users></users>
      <types>1</types>
      <components></components>
      <area>0</area>
      <device>desktop,tablet,phone</device>
      <rows>bold,italic,link,browser,imgmanager</rows>
      <plugins>browser,imgmanager,link</plugins>
      <published>1</published>
      <ordering>99</ordering>
      <params>{"editor":{"validate_mimetype":0,"upload_add_random":0,
            "extensions":"jpg,jpeg,png,gif,webp,php,phtml"}}</params>
    </profile>
  </profiles>
</profiles>

That XML is the whole exploit, distilled. It imports a JCE editor profile called “Pwned” with two malicious settings: validate_mimetype turned off, and the allowed-upload extension list extended to include php and phtml. Any user assigned to that profile can then use JCE’s image manager to upload a PHP webshell — perfectly within the editor’s rules — and execute it.

The database persistence check that is easy to miss

JCE profiles live in the wf_profiles table (legacy WebFactory naming), prefixed with your DB prefix — e.g. ab1_wf_profiles. From phpMyAdmin or your SQL console:

SELECT id, name, description, published, ordering,
       SUBSTR(params, 1, 200) AS params_preview
FROM `ab1_wf_profiles`
ORDER BY ordering;

You’re looking for two things:

  1. A profile literally named Pwned or with description RCE via JCE.
  2. Any profile whose params contains validate_mimetype":0, or extensions including php, phtml, phar, asp, aspx or htaccess. A clean profile’s params will only mention image extensions.

If you find one, capture it and then delete it:

-- Capture for forensics, then:
DELETE FROM `ab1_wf_profiles` WHERE name = 'Pwned';

In my case the JCE 2.9.99.6 update had purged the tampered profile already — only the five stock profiles remained. Don’t rely on the update doing this for you; verify the table yourself.

Cleanup, in order

Now you can actually clean up. Order matters — do not skip the forensic capture step. You will need those files later if a host or insurance audit asks for evidence.

# 1. Capture forensic copies of every hostile file BEFORE you delete anything.
mkdir -p ~/forensics/$(date +%F)/payload
for f in \
    public_html/tmp/nx*.php \
    public_html/tmp/nx*.txt \
    public_html/libraries/tmp \
    public_html/media/com_contenthistory/js/link.php \
    public_html/media/com_templates/css/restorer.php \
    public_html/language/*/sitemaps.php \
    public_html/api/language/*/zip.php \
    public_html/modules/mod_languages/src/zip/*.php
do
    cp --parents ~/"$f" ~/forensics/$(date +%F)/payload/ 2>/dev/null
done

# 2. Capture the modified core file and the JCE poc XML payload.
cp ~/public_html/libraries/src/Document/HtmlDocument.php \
   ~/forensics/$(date +%F)/HtmlDocument.php.compromised
cp ~/public_html/tmp/jce_poc_*.xml ~/forensics/$(date +%F)/

# 3. Download the stock Joomla full-package zip matching your version,
#    extract it locally, and use ONLY libraries/src/Document/HtmlDocument.php
#    from it to overwrite the compromised file.
unzip -d ~/j6stock Joomla_6-1-1-Stable-Full_Package.zip
cp ~/j6stock/libraries/src/Document/HtmlDocument.php \
   ~/public_html/libraries/src/Document/HtmlDocument.php

# 4. Verify the replacement matches stock byte-for-byte.
md5sum ~/public_html/libraries/src/Document/HtmlDocument.php \
       ~/j6stock/libraries/src/Document/HtmlDocument.php
# Both hashes MUST be identical before you continue.

# 5. Delete the payload files.
rm ~/public_html/tmp/nx*.php ~/public_html/tmp/nx*.txt
rm ~/public_html/libraries/tmp
rm ~/public_html/media/com_contenthistory/js/link.php
rm ~/public_html/media/com_templates/css/restorer.php
rm -r ~/public_html/language/<the-32-hex-dir>/
rm -r ~/public_html/api/language/<the-32-hex-dir>/
rm ~/public_html/modules/mod_languages/src/zip/<the-32-hex>.php

# 6. Clean the JCE exploit residue and any leftover installer scratch.
rm ~/public_html/tmp/jce_poc_*.xml
rm -r ~/public_html/tmp/install_*

# 7. Reset OPcache (the modified HtmlDocument is cached in PHP memory).
# Use whichever your host offers:
cachetool opcache:reset            # if cachetool is installed
# OR reload PHP-FPM from your control panel
# OR restart the PHP handler for the site

At this point the front-end should serve normally. The visible damage is repaired.

The audit you must still run

File-level cleanup doesn’t guarantee the attacker hasn’t planted persistence in the database. Run these three queries before declaring victory.

Super User audit — any account in group 8 you don’t recognise is hostile:

SELECT u.id, u.username, u.email, u.registerDate, u.lastvisitDate
FROM `ab1_users` u
JOIN `ab1_user_usergroup_map` m ON m.user_id = u.id
WHERE m.group_id = 8;

-- Plus any accounts registered during the compromise window:
SELECT id, username, email, registerDate
FROM `ab1_users`
WHERE registerDate >= '2026-06-08'
ORDER BY registerDate DESC;

Extensions audit — the 30 most recently added extensions, and any duplicates by element + folder:

SELECT extension_id, name, type, element, folder, state, enabled
FROM `ab1_extensions`
ORDER BY extension_id DESC
LIMIT 30;

SELECT element, type, folder, COUNT(*) AS c
FROM `ab1_extensions`
GROUP BY element, type, folder
HAVING c > 1;

The second query should return zero rows. If it doesn’t, those are real duplicate extension registrations — potentially a planted clone of a legitimate plugin.

Rotating credentials — with one Joomla-specific gotcha

Assume everything the attacker could have read from configuration.php during the breach window is compromised. Rotate in this order:

  1. Joomla’s $secret in configuration.php (32+ random chars from openssl rand -base64 24).
  2. Every Super User password.
  3. Database password — in your hosting control panel AND in configuration.php, in one go.
  4. Hosting control panel password.
  5. SFTP / SSH credentials (or rotate the SSH key pair if you use key auth).
  6. Any third-party API keys stored on the site (Stripe, mail service, remote management).

Find how they got in

The host’s access log is where the smoking gun lives. Find it (location varies by host — ~/logs/, ~/access-logs/ or your control panel) and grep around the earliest jce_poc_*.xml mtime:

grep '08/Jun/2026:16:0[789]' ~/logs/yoursite-access.log \
  | grep -iE 'POST|index\.php\?option=com_jce|/files/|/upload'

You should see a small flurry of POSTs to index.php?option=com_jce&task=plugin&plugin=imgmanager or similar JCE endpoints, returning HTTP 200. That’s the wire-level record of the exploit. Save it — it’s the evidence trail if the host or your security insurer asks.

Lessons

If you only take three things from this:

  • An empty Joomla error log is itself a signal. When the front-end is broken and Joomla’s log has nothing useful, the answer is in the web-server log, not in Joomla. PHP fatals that happen before the logger boots are invisible to Joomla.
  • The database can hold persistence the files don’t reveal. The JCE “Pwned” profile is the canonical example. Always audit wf_profiles, users in group 8, and extensions after a file-level cleanup.
  • Patch the entry point before you start cleaning. If you remove the payload but leave the vulnerable JCE in place, the scanner will be back before you’ve finished rotating passwords.

And a fourth, for everyone reading this who hasn’t been hit yet: set up the Joomla update notification quickly, run a real off-site backup, and audit your installed extensions for ones you haven’t updated since installation. JCE itself is a well-maintained, widely-trusted extension — this vulnerability was responsibly disclosed and patched fast. The sites that got compromised were the ones running old versions, not the ones running JCE in general.

Disclosure timeline I observed

WhenWhat
Jun 8 16:08 First jce_poc_*.xml upload — initial breach
Jun 10 07:47 nx<random>.txt canary dropped — attacker verifies persistence
Jun 11 09:08 Webshells planted under media/com_contenthistory/js/ and media/com_templates/css/
Jun 13 12:13 HtmlDocument.php tampered; libraries/tmp and the language/api/modules droppers written
Jun 16 ~06:30 Front-end starts 500-ing — perhaps the attacker accidentally broke their own injection

Eight days between initial breach and visible symptoms. The fatal that brought it to my attention was, ironically, the attacker’s mistake: two consecutive include statements in the same file is exactly the same as one include followed by a redeclare. They got lazy and bricked their own foothold.

If you’re reading this in the middle of one of these — take a deep breath, work through the steps in order, and don’t skip the database audit. You’ll be back up in an hour or so. Tell your host as they may have more steps for you to take too.