Last week I cleaned a six-site WordPress compromise on one of my OpenLiteSpeed boxes. The most interesting payload was the “DOLLY” mu-plugin family — a credential-harvesting backdoor that hides itself with a few clever tricks and survives most casual cleanups because it doesn’t trigger the obvious eval/base64 patterns scanners look for.
Here’s what it looks like, how to find it, and how to clean it from both the filesystem and the database.
The shape of the attack
DOLLY drops a file like this:
wp-content/mu-plugins/<md5-hash>.phpmu-plugins is the WordPress “Must-Use” plugins directory. Anything in it is auto-loaded on every request without showing up in the regular Plugins admin tab. Filename is the MD5 hash of something the attacker hardcodes — different per site so file-hash detection fails.
The first lines of the file are deceptive:
<?php
$z = "PD9waHAgCgogICRiYWRfYWRtaW5zW109J2FkbWluQHp6bmEucnUnOwogICRiYW...";
eval(base64_decode($z));Decode that base64 and you find:
- A list of “
$bad_admins” — usernames the attacker has planted on victim sites (e.g.admin@zzna.ru,wpsupp-user,wadmiine,wp-configuser,wpadminee…). - A POST handler that captures any login attempt for one of those usernames and exfiltrates the credentials base64-encoded to a remote host (commonly
https://0ems.com/i.php?stars1=...). - A
DOLLY_WAYconstant — the family signature. - Functions
get_dolly_option()/set_dolly_option()that read/write a serialized config blob fromwp_optionsunder the same MD5 key as the filename. - A
pre_user_queryfilter that hides a planted admin user from the WordPress users list — username is also the same MD5. - JavaScript injected into
admin_print_footer_scriptsthat hides the.mustusetab in the plugin admin so you don’t notice the file in Plugins → Must-Use. - A daily WP-Cron event (using the same MD5 as the hook name) that fetches new exfil endpoints from a list of “nodes” — so the C2 rotates if any one host gets blocked.
That’s a lot of moving parts in 12,536 bytes of obfuscated PHP. The point: it’s a credential collector, not a defacement. By the time you notice anything visible on the site, the attacker already has working admin creds harvested over weeks.
How to find it
Three signatures together are nearly diagnostic:
# 1. MD5-named files in any mu-plugins directory (highest signal)
find /var/www -type f -regex ".*/mu-plugins/[0-9a-f]\{32\}\.php$" 2>/dev/null
# 2. The "DOLLY_WAY" constant string (works even if filename randomized)
grep -rl "DOLLY_WAY" --include="*.php" /var/www 2>/dev/null
# 3. The exfil URL pattern (if not yet rotated)
grep -rl "0ems\.com" --include="*.php" /var/www 2>/dev/nullFor the database side, query wp_users for admin accounts with user_registered = '1979-01-01 00:00:00' (epoch-zero spoof) and empty email — that combination is essentially never legitimate:
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON u.ID = m.user_id
WHERE m.meta_key = 'wp_capabilities'
AND m.meta_value LIKE '%administrator%'
AND (u.user_registered = '1979-01-01 00:00:00' OR u.user_email = '');Any results from that query are almost certainly planted backdoor accounts. Common usernames I’ve seen alongside the DOLLY MD5: deleted-<random>, wp_update-<timestamp>, wpcron<hex>, root2.
Cleaning it
Don’t just rm the mu-plugin file. The DB persistence will recreate it on the next admin visit if it still has the planted admin user to authenticate as. Order matters:
- Quarantine the mu-plugin file (move, don’t delete — you may want it for forensics):
mv wp-content/mu-plugins/<md5>.php /root/quarantine/ - Delete the planted admin user (and meta):
DELETE m FROM wp_usermeta m JOIN wp_users u ON m.user_id=u.ID WHERE u.user_login='<md5>'; DELETE FROM wp_users WHERE user_login='<md5>'; - Drop the persistence option:
DELETE FROM wp_options WHERE option_name='<md5>'; - Reset cron (WP rebuilds legitimate hooks on the next request — this nukes the DOLLY-scheduled event):
DELETE FROM wp_options WHERE option_name='cron'; - Repeat the user query to clean any other backdoor admins (deleted-X, wp_update-X, root2…). Same DELETE pattern.
- Force-rotate every WP admin password and the wp-config salts. The malware was logging real login attempts that matched the bad-admin list, but the attacker-planted account itself never had its credentials exfiltrated to you — you don’t know if there are other backdoors you haven’t found yet, so assume any account that logged in during the infection window is suspect.
Closing the entry vector
Cleanup without finding the entry point is a temporary win. In every DOLLY case I’ve looked at, the way in was one of:
- An out-of-date WordPress core (especially anything 4.x or pre-6.0) with a known unauth RCE.
- A vulnerable plugin in the
plugins/dir —wp-file-managerbelow 6.9, GADWP, abandoned plugins like Header-and-Footer-Scripts. - Brute force via
xmlrpc.phporwp-login.phpwith no rate limiting. - Lateral movement from a sibling site sharing the same Linux user and DB credentials.
Wordfence’s own wfissues table is gold here — even if you don’t have the Premium feed enabled, it logs WP-version warnings and plugin-vulnerability flags going back months. Query SELECT FROM_UNIXTIME(time), shortMsg FROM wp_wfissues ORDER BY time DESC and you’ll usually see the original “WordPress version is out of date” message dated very close to the first compromise.
Once cleaned, the right next steps are: (1) update everything, (2) move every site off the shared MySQL root user to per-site grants, (3) block xmlrpc.php and hide wp-login.php at the LSWS level, and (4) deny PHP execution under wp-content/uploads/. Each of those gets its own post.
